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

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

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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct 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 lines
48
49
50
34 unmodified lines
99
51 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 lines
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
34 unmodified lines
124
125
126
127
51 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 lines
237 unmodified lines
23 unmodified lines
237 unmodified lines
23 unmodified lines
24
25
26
27
28
29
237 unmodified lines
273
274
275
276
277
278
279
280
281
23 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 lines
71 unmodified lines
140 unmodified lines
14 unmodified lines
71 unmodified lines
140 unmodified lines
14 unmodified lines
15
71 unmodified lines
93
140 unmodified lines
240
14 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

Issues, Limitations, and Mitigations

Follow-up Work

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.

New Changes as of 2026-05-25 19:43 EDT

Summary of changes

Capped the VLC loopback range cache so an oversized player request like bytes=0-711080521 is answered as a small partial response instead of being fetched and stored as one huge in-memory Data value.

Why this change was made

The device logs showed VLC requesting almost the entire file from the local cache on startup. The cache honored that range literally, which could allocate hundreds of megabytes for the upstream response and then keep another copy in the sparse cache. That explains the memory kill during playback.

Code diffs

Rendered with @pierre/diffs/ssr. These are focused snippets for the memory cap and its tests.

Dreamio/ProgressiveHTTPRangeCache.swift and Tests/StreamResolverTests.swift

Dreamio/ProgressiveHTTPRangeCache.swift
-3+21
114 unmodified lines
115
116
117
118
119
144 unmodified lines
266
267
174 unmodified lines
446
447
448
449
450
451
452
453
454
114 unmodified lines
func evict(keeping window: HTTPByteRange) {
lock.withLock {
segments.removeAll { !$0.range.overlapsOrTouches(window) }
}
}
144 unmodified lines
return store.data(for: bounded) ?? data
}
174 unmodified lines
let requestedRange = parseRangeHeader(in: requestText, contentLength: session.contentLength)
?? HTTPByteRange(start: 0, end: min(session.contentLength - 1, 1_048_575))
do {
let data = try await session.data(for: requestedRange)
let headers = [
"Accept-Ranges": "bytes",
"Content-Length": "\(data.count)",
"Content-Range": "bytes \(requestedRange.start)-\(requestedRange.end)/\(session.contentLength)",
"Content-Type": "application/octet-stream",
114 unmodified lines
115
116
117
118
119
120
121
122
123
124
125
126
127
128
144 unmodified lines
278
279
280
281
282
283
284
285
286
287
174 unmodified lines
466
467
468
469
470
471
472
473
474
475
114 unmodified lines
func evict(keeping window: HTTPByteRange) {
lock.withLock {
segments = segments.compactMap { segment in
guard segment.range.overlapsOrTouches(window) else {
return nil
}
let start = max(segment.range.start, window.start)
let end = min(segment.range.end, window.end)
let lower = Int(start - segment.range.start)
let upper = Int(end - segment.range.start + 1)
return Segment(range: HTTPByteRange(start: start, end: end), data: segment.data.subdata(in: lower..<upper))
}
}
}
144 unmodified lines
return store.data(for: bounded) ?? data
}
func responseRange(for requestedRange: HTTPByteRange) -> HTTPByteRange {
let bounded = clamp(requestedRange)
return HTTPByteRange(
start: bounded.start,
end: min(bounded.end, bounded.start + responseChunkSize - 1)
)
}
174 unmodified lines
let requestedRange = parseRangeHeader(in: requestText, contentLength: session.contentLength)
?? HTTPByteRange(start: 0, end: min(session.contentLength - 1, 1_048_575))
let responseRange = session.responseRange(for: requestedRange)
do {
let data = try await session.data(for: responseRange)
let headers = [
"Accept-Ranges": "bytes",
"Content-Length": "\(data.count)",
"Content-Range": "bytes \(responseRange.start)-\(responseRange.end)/\(session.contentLength)",
"Content-Type": "application/octet-stream",
Tests/StreamResolverTests.swift
+12
320 unmodified lines
319
320
320 unmodified lines
assertEqual(store.cachedRanges, [HTTPByteRange(start: 10, end: 12)])
}
320 unmodified lines
321
322
323
324
325
326
327
328
329
330
331
332
333
334
320 unmodified lines
assertEqual(store.cachedRanges, [HTTPByteRange(start: 10, end: 12)])
}
private static func testSparseRangeStoreTrimsOverlappingWindow() {
let store = SparseHTTPByteRangeStore()
store.insert(data: Data([0, 1, 2, 3, 4, 5]), at: 0)
store.evict(keeping: HTTPByteRange(start: 2, end: 4))
assertEqual(store.cachedRanges, [HTTPByteRange(start: 2, end: 4)])
}
private static func testRangeCacheSessionCapsResponseRange() {
let session = ProgressiveHTTPRangeCacheSession(fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]), contentLength: 711_080_522, durationProvider: { 0 })
assertEqual(session.responseRange(for: HTTPByteRange(start: 0, end: 711_080_521)), HTTPByteRange(start: 0, end: 1_048_575))
}

Related issues or PRs

Related to Beads issue dreamio-9gw and branch lavender/vlc-local-range-cache.