# Dreamio Turn: VLC Seeking With a Local Range Cache

# 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.swift` with range parsing, sparse cached byte storage, remote range fetching, prefetch window logic, and a small loopback HTTP server.
- Updated `VLCNativePlaybackBackend` to 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 `HEAD` first, then a tiny `Range: bytes=0-0` request if needed.
- The local server responds to VLC range requests with `206 Partial Content` and 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-caching` value and `http-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

Dreamio/ProgressiveHTTPRangeCache.swift
+25

    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

Dreamio/VLCNativePlaybackBackend.swift
-4+26

    51 unmodified lines48495034 unmodified lines9951 unmodified lines        let media = VLCMedia(url: request.playbackURL)        mediaPlayer.media = media        mediaPlayer.play()34 unmodified lines        mediaPlayer.position = max(0, min(1, position))51 unmodified lines5253545556575859606162636465666768697071727334 unmodified lines12412512612751 unmodified lines        playbackStartupTask = Task { [weak self] in            let 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 = session                    session.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 lines        let 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

Tests/StreamResolverTests.swift
+15

    23 unmodified lines237 unmodified lines23 unmodified lines237 unmodified lines23 unmodified lines242526272829237 unmodified lines27327427527627727827928028123 unmodified lines        testContentRangeParsing()        testSparseRangeStoreMergesOverlaps()        testSparseRangeStoreHitPartialHitAndMiss()        testSparseRangeStoreEvictsOutsideWindow()        await testRangeProbeFallsBackWhenServerIgnoresRange()        await testRangeFetcherPreservesHeaders()237 unmodified lines    private static func testRangeFetcherPreservesHeaders() async {        MockURLProtocol.handler = { request in            assertEqual(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

Dreamio.xcodeproj/project.pbxproj
+3

    14 unmodified lines71 unmodified lines140 unmodified lines14 unmodified lines71 unmodified lines140 unmodified lines14 unmodified lines1571 unmodified lines93140 unmodified lines24014 unmodified lines        6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */; };71 unmodified lines                6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */,140 unmodified lines                6F2A2B522C00100100DREAMIO /* 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/StreamResolverTests` passed.
- `pod install` restored missing CocoaPods support files for the worktree.
- `xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS Simulator' build` passed.

## 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`.