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.

New Changes as of 2026-05-25 23:59 EDT

Summary of changes

Adjusted range-cache prefetching for seek-heavy playback so the cache prioritizes chunks around the post-seek byte offset and avoids cancelling useful in-flight prefetch work when the requested offset remains inside the active window.

Why this change was made

Runtime logs showed VLC entering buffering after a seek while Dreamio repeatedly reported cache misses and cancelled prefetches. The prefetcher was warming from the back edge of a broad window, which could spend bandwidth behind the seek target before fetching the bytes VLC needed next.

Code diffs

Rendered with @pierre/diffs/ssr. The diffs below cover the seek-prefetch changes and focused regression coverage.

Dreamio/ProgressiveHTTPRangeCache.swift

Dreamio/ProgressiveHTTPRangeCache.swift
-4+41
15 unmodified lines
16
17
18
19
20
21
232 unmodified lines
254
255
256
257
258
259
28 unmodified lines
288
289
290
291
292
293
294
295
296
297
1 unmodified line
299
300
301
302
303
304
305
306
307
2 unmodified lines
310
311
312
313
314
315
316
317
318
319
320
321
322
323
9 unmodified lines
333
334
335
336
337
338
15 unmodified lines
func merged(with other: HTTPByteRange) -> HTTPByteRange {
HTTPByteRange(start: min(start, other.start), end: max(end, other.end))
}
}
struct HTTPContentRange: Equatable {
232 unmodified lines
private let prefetchChunkSize: Int64 = 1_048_576
private let responseChunkSize: Int64 = 1_048_576
private var prefetchTask: Task<Void, Never>?
init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {
self.fetcher = fetcher
28 unmodified lines
}
func prefetch(aroundByteOffset offset: Int64) {
prefetchTask?.cancel()
let window = targetWindow(aroundByteOffset: offset)
store.evict(keeping: window)
guard !store.hasData(for: window) else {
return
}
1 unmodified line
guard let self else {
return
}
var cursor = window.start
while cursor <= window.end, !Task.isCancelled {
let chunk = HTTPByteRange(start: cursor, end: min(window.end, cursor + prefetchChunkSize - 1))
if !store.hasData(for: chunk) {
do {
let data = try await fetcher.fetch(range: chunk)
2 unmodified lines
print("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)")
#endif
} catch {
#if DEBUG
print("[DreamioRangeCache] prefetch failed range=\(chunk.start)-\(chunk.end) error=\(error)")
#endif
return
}
}
cursor = chunk.end + 1
}
}
}
9 unmodified lines
return clamp(HTTPByteRange(start: offset - behind, end: offset + ahead))
}
private func estimatedBytesPerSecond() -> Int64 {
let duration = durationProvider()
guard duration > 1 else {
15 unmodified lines
16
17
18
19
20
21
22
23
24
25
232 unmodified lines
258
259
260
261
262
263
264
28 unmodified lines
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
1 unmodified line
310
311
312
313
314
315
316
317
318
319
2 unmodified lines
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
9 unmodified lines
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
15 unmodified lines
func merged(with other: HTTPByteRange) -> HTTPByteRange {
HTTPByteRange(start: min(start, other.start), end: max(end, other.end))
}
func contains(_ offset: Int64) -> Bool {
start <= offset && offset <= end
}
}
struct HTTPContentRange: Equatable {
232 unmodified lines
private let prefetchChunkSize: Int64 = 1_048_576
private let responseChunkSize: Int64 = 1_048_576
private var prefetchTask: Task<Void, Never>?
private var activePrefetchWindow: HTTPByteRange?
init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {
self.fetcher = fetcher
28 unmodified lines
}
func prefetch(aroundByteOffset offset: Int64) {
if activePrefetchWindow?.contains(offset) == true, prefetchTask?.isCancelled == false {
return
}
prefetchTask?.cancel()
let window = targetWindow(aroundByteOffset: offset)
activePrefetchWindow = window
store.evict(keeping: window)
guard !store.hasData(for: window) else {
activePrefetchWindow = nil
return
}
1 unmodified line
guard let self else {
return
}
for chunk in self.prefetchChunks(in: window, preferredOffset: offset) {
guard !Task.isCancelled else {
return
}
if !store.hasData(for: chunk) {
do {
let data = try await fetcher.fetch(range: chunk)
2 unmodified lines
print("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)")
#endif
} catch {
if Task.isCancelled {
return
}
#if DEBUG
print("[DreamioRangeCache] prefetch failed range=\(chunk.start)-\(chunk.end) error=\(error)")
#endif
return
}
}
}
self.activePrefetchWindow = nil
}
}
9 unmodified lines
return clamp(HTTPByteRange(start: offset - behind, end: offset + ahead))
}
func prefetchChunks(in window: HTTPByteRange, preferredOffset offset: Int64) -> [HTTPByteRange] {
let boundedOffset = max(window.start, min(window.end, offset))
let preferredStart = window.start + ((boundedOffset - window.start) / prefetchChunkSize) * prefetchChunkSize
var chunks: [HTTPByteRange] = []
var cursor = preferredStart
while cursor <= window.end {
let chunk = HTTPByteRange(start: cursor, end: min(window.end, cursor + prefetchChunkSize - 1))
chunks.append(chunk)
cursor = chunk.end + 1
}
cursor = window.start
while cursor < preferredStart {
let chunk = HTTPByteRange(start: cursor, end: min(preferredStart - 1, cursor + prefetchChunkSize - 1))
chunks.append(chunk)
cursor = chunk.end + 1
}
return chunks
}
private func estimatedBytesPerSecond() -> Int64 {
let duration = durationProvider()
guard duration > 1 else {

Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+23
35 unmodified lines
36
37
38
39
40
41
302 unmodified lines
344
345
346
347
348
349
35 unmodified lines
testSparseRangeStoreEvictsOutsideWindow()
testSparseRangeStoreTrimsOverlappingWindow()
testRangeCacheSessionCapsResponseRange()
await testRangeProbeFallsBackWhenServerIgnoresRange()
await testRangeFetcherPreservesHeaders()
print("StreamResolverTests passed")
302 unmodified lines
assertEqual(responseRange, HTTPByteRange(start: 0, end: 1_048_575))
}
private static func testRangeProbeFallsBackWhenServerIgnoresRange() async {
MockURLProtocol.handler = { request in
if request.httpMethod == "HEAD" {
35 unmodified lines
36
37
38
39
40
41
42
302 unmodified lines
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
35 unmodified lines
testSparseRangeStoreEvictsOutsideWindow()
testSparseRangeStoreTrimsOverlappingWindow()
testRangeCacheSessionCapsResponseRange()
testRangeCachePrefetchPrioritizesSeekOffset()
await testRangeProbeFallsBackWhenServerIgnoresRange()
await testRangeFetcherPreservesHeaders()
print("StreamResolverTests passed")
302 unmodified lines
assertEqual(responseRange, HTTPByteRange(start: 0, end: 1_048_575))
}
private static func testRangeCachePrefetchPrioritizesSeekOffset() {
let session = ProgressiveHTTPRangeCacheSession(
fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),
contentLength: 20_000_000,
durationProvider: { 0 }
)
let chunks = session.prefetchChunks(
in: HTTPByteRange(start: 0, end: 4_194_303),
preferredOffset: 2_200_000
)
assertEqual(chunks.prefix(2).map { $0 }, [
HTTPByteRange(start: 2_097_152, end: 3_145_727),
HTTPByteRange(start: 3_145_728, end: 4_194_303)
])
assertEqual(chunks.suffix(2).map { $0 }, [
HTTPByteRange(start: 0, end: 1_048_575),
HTTPByteRange(start: 1_048_576, end: 2_097_151)
])
}
private static func testRangeProbeFallsBackWhenServerIgnoresRange() async {
MockURLProtocol.handler = { request in
if request.httpMethod == "HEAD" {

Related issues or PRs

Related to Beads issue dreamio-42s and branch lavender/vlc-local-range-cache.