Add Native Playback Seek Buffer

Native VLC playback now applies a centralized 30-second media caching value before opening streams, giving short forward and backward seeks more room to avoid feeling like a fresh stream start.

Date: 2026-05-25 Issue: dreamio-3yb Area: native playback

Summary

Added VLC-side caching options to the native playback backend only. The player UI, skip controls, scrubber, subtitle flow, audio-track flow, and NativePlaybackBackend protocol were left unchanged.

Changes Made

Context

The requested buffer is a VLC media caching setting, not a custom proxy or a new app-visible setting. Keeping the change inside VLCNativePlaybackBackend.play(request:) preserves the existing player contract and keeps the first tuning point small and easy to adjust after real-device testing.

Important Implementation Details

The caching options are added after request headers and VLC HTTP options are configured, and before the media is assigned to the player. This ensures the options belong to the same VLCMedia instance used for the native stream.

The helper is compiled only when MobileVLCKit is available, matching the rest of the backend's conditional compilation pattern.

Relevant Diff Snippets

The repository asks for diffs.com rendering through @pierre/diffs/ssr. The package was not installed in this worktree, so the ESM availability check failed with ERR_MODULE_NOT_FOUND. A plain unified diff is included as the fallback.

diff --git a/Dreamio.xcodeproj/project.pbxproj b/Dreamio.xcodeproj/project.pbxproj
index af6a9dc..d7fbe19 100644
--- a/Dreamio.xcodeproj/project.pbxproj
+++ b/Dreamio.xcodeproj/project.pbxproj
@@ -7,6 +7,7 @@
  objects = {
 
 /* Begin PBXBuildFile section */
+        00160C2FABC913A6DDCAB0C4 /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; };
@@ -15,7 +16,6 @@
-        BA013CEC876B829A86AE8DCB /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; };
@@ -39,6 +39,7 @@
          files = (
+                00160C2FABC913A6DDCAB0C4 /* Pods_Dreamio.framework in Frameworks */,
          );

diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift
index c3c2318..0fa779a 100644
--- a/Dreamio/VLCNativePlaybackBackend.swift
+++ b/Dreamio/VLCNativePlaybackBackend.swift
@@ -5,6 +5,8 @@ import MobileVLCKit
 #endif
 
 final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
+    private static let seekBufferMilliseconds = 30_000
+
@@ -67,10 +69,11 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
          if !headerValue.isEmpty {
              media.addOption(":http-header=\(headerValue)")
          }
+        configureSeekBuffer(for: media)
 
          mediaPlayer.media = media
 #if DEBUG
-        print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))")
+        print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString)) seekBufferMilliseconds=\(Self.seekBufferMilliseconds)")
 #endif
@@ -84,6 +87,18 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
 #endif
      }
 
+#if canImport(MobileVLCKit)
+    private func configureSeekBuffer(for media: VLCMedia) {
+        let cachingOptions = [
+            ":network-caching=\(Self.seekBufferMilliseconds)",
+            ":http-caching=\(Self.seekBufferMilliseconds)",
+            ":file-caching=\(Self.seekBufferMilliseconds)"
+        ]
+
+        cachingOptions.forEach { media.addOption($0) }
+    }
+#endif

Expected Impact for End-Users

Short native playback seeks should have more buffered media available, especially around repeated 15-second skip forward and backward actions. Users should not see any new controls or settings.

Validation

Issues, Limitations, and Mitigations

Follow-up Work

New Changes as of 2026-05-25 15:20 EDT

Summary of changes

After device testing showed VLC still reported state=buffering on 15-second skip actions, the native backend now also applies :live-caching with the same centralized 30-second value and prints DEBUG seek/jump telemetry.

Why this change was made

Some VLC inputs can use the live/access cache path even when the app treats them as direct native streams. Adding :live-caching keeps the cache policy consistent across VLC's relevant access paths. The new logs help distinguish normal post-seek buffering from a longer restart-like stall.

Code diffs

diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift
@@
         let cachingOptions = [
             ":network-caching=\(Self.seekBufferMilliseconds)",
             ":http-caching=\(Self.seekBufferMilliseconds)",
-            ":file-caching=\(Self.seekBufferMilliseconds)"
+            ":file-caching=\(Self.seekBufferMilliseconds)",
+            ":live-caching=\(Self.seekBufferMilliseconds)"
         ]
@@
-        mediaPlayer.position = max(0, min(1, position))
+        let nextPosition = max(0, min(1, position))
+#if DEBUG
+        print("[DreamioVLC] seek position from=\(mediaPlayer.position) to=\(nextPosition) currentTime=\(currentTime) duration=\(duration)")
+#endif
+        mediaPlayer.position = nextPosition
@@
         let nextTime = max(0, min(duration, currentTime + seconds))
+#if DEBUG
+        print("[DreamioVLC] jump seconds=\(seconds) from=\(currentTime) to=\(nextTime) duration=\(duration) seekBufferMilliseconds=\(Self.seekBufferMilliseconds)")
+#endif
         mediaPlayer.time = VLCTime(int: Int32(nextTime * 1000))

Related issues or PRs

Related Beads issue: dreamio-3yb. No new Beads issue was opened because this is a direct tuning update to the same native seek-buffer task.

New Changes as of 2026-05-25 15:25 EDT

Summary of changes

After real-device logs showed native 15-second jumps staying in repeated VLC buffering states, the relative skip buttons now use MobileVLCKit's native jumpForward and jumpBackward APIs instead of assigning an absolute VLCTime. The media options also include :input-fast-seek.

Why this change was made

The 15-second buttons are relative jumps, so using VLC's relative jump API gives libVLC the clearest signal for that action. The scrubber still uses position-based seeking, preserving precise absolute seek behavior for drag interactions.

Code diffs

diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift
@@
             ":http-caching=\(Self.seekBufferMilliseconds)",
             ":file-caching=\(Self.seekBufferMilliseconds)",
-            ":live-caching=\(Self.seekBufferMilliseconds)"
+            ":live-caching=\(Self.seekBufferMilliseconds)",
+            ":input-fast-seek"
         ]
@@
-        mediaPlayer.time = VLCTime(int: Int32(nextTime * 1000))
+        if seconds > 0 {
+            mediaPlayer.jumpForward(Int32(seconds.rounded()))
+        } else if seconds < 0 {
+            mediaPlayer.jumpBackward(Int32(abs(seconds).rounded()))
+        }

Related issues or PRs

Related Beads issue: dreamio-3yb. This remains part of the same native seek-buffer tuning task.

New Changes as of 2026-05-25 15:50 EDT

Summary of changes

After testing still showed repeated VLC buffering with the native relative jump APIs, the 15-second skip path now uses the same normalized position seek mechanism as the scrubber, then calls play() to nudge VLC out of the paused or buffering state seen in device logs.

Why this change was made

The scrubber path is the remaining seek path that has not shown the same hanging behavior in the logs. Reusing it for fixed-size jumps keeps all native seeks on one mechanism while preserving the UI and protocol.

Code diffs

diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift
@@
-        if seconds > 0 {
-            mediaPlayer.jumpForward(Int32(seconds.rounded()))
-        } else if seconds < 0 {
-            mediaPlayer.jumpBackward(Int32(abs(seconds).rounded()))
-        }
+        guard duration > 0 else {
+            return
+        }
+        mediaPlayer.position = Float(nextTime / duration)
+        mediaPlayer.play()

Related issues or PRs

Related Beads issue: dreamio-3yb. This update is still part of the same native playback seek-buffer branch.

New Changes as of 2026-05-25 15:57 EDT

Summary of changes

After further testing still showed VLC repeatedly entering buffering after 15-second jumps, the DEBUG logging now captures a compact playback snapshot on every VLC state change and at 0.25, 1, 3, and 6 seconds after each fixed jump.

Why this change was made

The caching and seek-mechanism changes have not resolved the stream-specific stall. The new logs are intended to show whether VLC's time and position continue advancing, whether playback remains marked as playing, and whether seekability changes while the user sees a stalled native player. That should make the next fix more grounded, likely either a recoverable re-open at the target timestamp or stream/range-support handling, instead of another blind VLC option change.

Code diffs

diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift
@@
         mediaPlayer.position = Float(nextTime / duration)
         mediaPlayer.play()
+#if DEBUG
+        schedulePostSeekDiagnostics(label: "jump", expectedTime: nextTime)
+#endif
@@
+    private func logPlaybackSnapshot(reason: String) {
+        print("[DreamioVLC] snapshot reason=\(reason) state=\(stateName(mediaPlayer.state)) isPlaying=\(mediaPlayer.isPlaying) isSeekable=\(mediaPlayer.isSeekable) time=\(currentTime) duration=\(duration) position=\(mediaPlayer.position) selectedAudio=\(mediaPlayer.currentAudioTrackIndex) selectedSubtitle=\(mediaPlayer.currentVideoSubTitleIndex)")
+    }
+
+    private func schedulePostSeekDiagnostics(label: String, expectedTime: TimeInterval) {
+        [0.25, 1.0, 3.0, 6.0].forEach { delay in
+            DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
+                self?.logPlaybackSnapshot(reason: "\(label)-after-\(String(format: "%.2f", delay))s expected=\(String(format: "%.3f", expectedTime))")
+            }
+        }
+    }
@@
         print("[DreamioVLC] state=\(stateName(mediaPlayer.state))")
+        logPlaybackSnapshot(reason: "state-\(stateName(mediaPlayer.state))")

Related issues or PRs

Related Beads issue: dreamio-3yb. This is a diagnostic update to the same native seek-buffer investigation. The diff is shown as a plain fallback because @pierre/diffs/ssr is not installed in this workspace.