VLC Seeking With a Local Range Cache
Dreamio now probes file-like HTTP streams for byte-range support and, when safe, feeds MobileVLCKit through a loopback URL backed by a sparse progressive cache.
Summary
Implemented a Dreamio-owned progressive HTTP range cache for native VLC playback. Cacheable HTTP/HTTPS streams are served to VLC from 127.0.0.1, while HLS, live, non-HTTP, unknown-length, and non-range sources stay on direct MobileVLCKit playback.
Changes Made
- Added
ProgressiveHTTPRangeCache.swiftwith range parsing, sparse cached byte storage, remote range fetching, prefetch window logic, and a small loopback HTTP server. - Updated
VLCNativePlaybackBackendto probe before playback, choose local-cache vs direct mode, apply separate VLC caching options, and reprioritize prefetching on seek/jump. - Preserved upstream request headers in remote range fetches, including user agent, referrer, cookies, and custom auth headers.
- Added diagnostics for cache mode, probe fallback reasons, seek byte estimates, cache hits/misses, fetched ranges, and throttled repeated buffering logs.
- Added range/cache unit coverage and URLProtocol-backed fetcher tests.
Context
MobileVLCKit exposes coarse input caching knobs, but not a precise “keep nearby bytes around the playhead” buffer. This change puts Dreamio in charge of the byte window for regular file-like streams and leaves segment/playlist media on VLC’s normal path.
Important Implementation Details
- The cache uses sparse byte ranges and merges overlapping or adjacent segments rather than downloading whole files.
- Probe logic uses
HEADfirst, then a tinyRange: bytes=0-0request if needed. - The local server responds to VLC range requests with
206 Partial Contentand fills misses from the upstream URL. - Prefetch targets roughly 30 seconds behind and 60 seconds ahead when duration is known; otherwise it uses fixed byte heuristics.
- Direct fallback uses a larger
network-cachingvalue andhttp-reconnect; loopback playback uses lower caching because Dreamio is buffering locally.
Relevant Diff Snippets
Rendered with @pierre/diffs/ssr. These are selected snippets, not the full patch, to keep the turn note readable.
Dreamio/ProgressiveHTTPRangeCache.swift
12345678910111213141516171819202122232425struct HTTPContentRange: Equatable { let range: HTTPByteRange let totalLength: Int64?
static func parse(_ value: String) -> HTTPContentRange? { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.lowercased().hasPrefix("bytes ") else { return nil } ... }}
final class ProgressiveHTTPRangeCacheSession { func data(for requestedRange: HTTPByteRange) async throws -> Data { if let data = store.data(for: bounded) { print("[DreamioRangeCache] cache=hit range=\(bounded.start)-\(bounded.end)") return data } let data = try await fetcher.fetch(range: bounded) store.insert(data: data, at: bounded.start) prefetch(aroundByteOffset: bounded.end + 1) return store.data(for: bounded) ?? data }}Dreamio/VLCNativePlaybackBackend.swift
51 unmodified lines48495034 unmodified lines9951 unmodified lineslet media = VLCMedia(url: request.playbackURL)mediaPlayer.media = mediamediaPlayer.play()34 unmodified linesmediaPlayer.position = max(0, min(1, position))51 unmodified lines5253545556575859606162636465666768697071727334 unmodified lines12412512612751 unmodified linesplaybackStartupTask = Task { [weak self] inlet fetcher = HTTPRangeRemoteFetcher(url: request.playbackURL, headers: request.headers)let probe = await fetcher.probe()if probe.isCacheable, let contentLength = probe.contentLength, contentLength > 0 {let session = ProgressiveHTTPRangeCacheSession(fetcher: fetcher,contentLength: contentLength,durationProvider: { [weak self] in self?.duration ?? 0 })let localURL = try ProgressiveHTTPRangeCacheServer.shared.localURL(for: session)await MainActor.run {self?.rangeCacheSession = sessionsession.prefetch(aroundByteOffset: 0)self?.startVLCMedia(url: localURL, request: request, playbackMode: "local-cache", cachingMilliseconds: 500, includeRemoteHTTPOptions: false)}return}await MainActor.run {self?.startVLCMedia(url: request.playbackURL, request: request, playbackMode: "direct", cachingMilliseconds: 2500, includeRemoteHTTPOptions: true)}}34 unmodified lineslet clamped = max(0, min(1, position))rangeCacheSession?.prefetch(aroundByteOffset: rangeCacheSession?.byteOffset(for: clamped) ?? 0)print("[DreamioVLC] seek targetPosition=\(clamped)")mediaPlayer.position = clamped
Tests/StreamResolverTests.swift
23 unmodified lines237 unmodified lines23 unmodified lines237 unmodified lines23 unmodified lines242526272829237 unmodified lines27327427527627727827928028123 unmodified linestestContentRangeParsing()testSparseRangeStoreMergesOverlaps()testSparseRangeStoreHitPartialHitAndMiss()testSparseRangeStoreEvictsOutsideWindow()await testRangeProbeFallsBackWhenServerIgnoresRange()await testRangeFetcherPreservesHeaders()237 unmodified linesprivate static func testRangeFetcherPreservesHeaders() async {MockURLProtocol.handler = { request inassertEqual(request.value(forHTTPHeaderField: "User-Agent"), "DreamioTest/1")assertEqual(request.value(forHTTPHeaderField: "Referer"), "https://web.stremio.com/")assertEqual(request.value(forHTTPHeaderField: "Cookie"), "session=abc")assertEqual(request.value(forHTTPHeaderField: "Range"), "bytes=5-7")return (Data([5, 6, 7]), HTTPURLResponse(url: request.url!, statusCode: 206, httpVersion: nil, headerFields: nil)!)}}
Dreamio.xcodeproj/project.pbxproj
14 unmodified lines71 unmodified lines140 unmodified lines14 unmodified lines71 unmodified lines140 unmodified lines14 unmodified lines1571 unmodified lines93140 unmodified lines24014 unmodified lines6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */; };71 unmodified lines6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */,140 unmodified lines6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */,
Expected Impact for End-Users
Nearby seeks on cacheable MP4/MKV/AVI/WebM-style HTTP streams should recover faster because VLC reads from a local range-aware endpoint backed by targeted upstream fills. Unsupported sources should continue playing through the direct MobileVLCKit path.
Validation
xcrun swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/ProgressiveHTTPRangeCache.swift Tests/StreamResolverTests.swift -o /tmp/StreamResolverTests && /tmp/StreamResolverTestspassed.pod installrestored missing CocoaPods support files for the worktree.xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS Simulator' buildpassed.
Issues, Limitations, and Mitigations
- Manual device validation against the original problematic stream is still needed because local playback quality depends on real server range behavior.
- The first version intentionally does not cache HLS/live streams; those remain on direct VLC playback because their model is playlist/segment based.
- The loopback server is intentionally small and per-process. If future playback needs multiple concurrent videos, session lifecycle cleanup should be tightened further.
Follow-up Work
- Run the manual 15-second seek validation on device with the stream that produced the buffering logs.
- Add an end-to-end local server integration test that opens the loopback URL and verifies repeated range reuse through the full server path.
- Consider exposing cache counters in a debug overlay if native playback diagnostics continue to be a focus.
New Changes as of 2026-05-25 18:32 EDT
Summary of changes
Fixed the loopback cache server startup so Dreamio waits for Network.framework to report the real assigned port before giving VLC the local playback URL.
Why this change was made
Device logs showed VLC opening http://127.0.0.1:0/stream/.... Port 0 is only the ephemeral-port request placeholder, not a usable listening port, so VLC immediately failed playback even though the upstream range cache had started fetching data.
Code diffs
ProgressiveHTTPRangeCacheServer.localURL(for:) is now async.
let assignedPort = try await startIfNeeded()
URL(string: "http://127.0.0.1:\(assignedPort)/stream/\(session.id)")
listener.stateUpdateHandler waits for .ready
port = UInt16(listener.port.rawValue)
startup continuations resume only after the real port is available.Related issues or PRs
Related to Beads issue dreamio-11s and branch lavender/vlc-local-range-cache.