VLC Seek Range Cache Priming
Dreamio now primes local VLC range-cache seeks from stable 1 MB boundaries behind the estimated target, retains useful nearby bytes under a bounded budget, and logs foreground misses with more precision.
Summary
Reduced post-seek buffering risk in native VLC playback by making seek and jump prefetch conservative behind the target instead of only racing ahead from the estimated byte offset.
Changes Made
- Added seek-specific prefetch through
prefetchForSeek, with a 4 MB backward prime window and user-initiated task priority for the first chunks. - Aligned prefetch chunks to global 1 MB boundaries so repeated VLC requests reuse stable cached ranges.
- Replaced prefetch-window eviction with a 64 MB bounded cache budget that preserves active playback, recent seek, header, and tail/index ranges.
- Changed foreground miss handling to fetch only missing subranges and log
uncachedversuspartial-miss. - Routed VLC delegate state handling onto the main actor before reading player state or firing UI callbacks.
- Added tests for the observed VLC request pattern, global chunk alignment, and budget retention.
Context
A 15-second skip estimated byte 213615760, while VLC’s first real read started at 212942432. The old prefetch overlapped the request but missed its front edge, then eviction could discard ranges that were still useful during the next VLC probes.
Important Implementation Details
- The observed request and estimated seek offset both fall inside global chunk
212860928-213909503, so seek priming now warms that chunk before later ahead chunks. - Normal prefetch still prefers the playhead area, while seek prefetch starts at the backward-biased window start.
- Cache eviction removes unprotected segments farthest from protected areas until the byte budget is met; protected ranges are not trimmed simply because a new prefetch window appears.
- Foreground reads cancel or reprioritize normal prefetch when VLC asks outside the expected area.
Relevant Diff Snippets
Rendered with @pierre/diffs/ssr.
Dreamio/ProgressiveHTTPRangeCache.swift
72 unmodified lines73747576777838 unmodified lines11711811912012112212 unmodified lines135136137138139140116 unmodified lines2572582592602612622632642652662672682692702 unmodified lines2732742752762772781 unmodified line2802812822832842852862872882892902912922935 unmodified lines2993003013023033043053063073083093103113123133143153163173183193203213223233243253263273281 unmodified line33033133233333433537 unmodified lines3733743753763773783793803813823833843853863873 unmodified lines3913923933943953963973 unmodified lines40140240340440540672 unmodified lines}}func insert(data: Data, at start: Int64) {guard !data.isEmpty else {return38 unmodified linesdata(for: range) != nil}func evict(keeping window: HTTPByteRange) {lock.withLock {segments = segments.compactMap { segment in12 unmodified lines}}private func mergeSegments() {guard !segments.isEmpty else {return116 unmodified lineslet durationProvider: () -> TimeIntervalprivate let prefetchChunkSize: Int64 = 1_048_576private let responseChunkSize: Int64 = 1_048_576private var prefetchTask: Task<Void, Never>?private var activePrefetchWindow: HTTPByteRange?private var activePrefetchPreferredOffset: Int64?init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) {self.fetcher = fetcherself.contentLength = contentLengthself.durationProvider = durationProvider}deinit {2 unmodified linesfunc data(for requestedRange: HTTPByteRange) async throws -> Data {let bounded = clamp(requestedRange)if let data = store.data(for: bounded) {#if DEBUGprint("[DreamioRangeCache] cache=hit range=\(bounded.start)-\(bounded.end)")1 unmodified linereturn data}#if DEBUGprint("[DreamioRangeCache] cache=miss range=\(bounded.start)-\(bounded.end)")#endifcancelPrefetchIfNeeded(forForegroundRange: bounded)let data = try await fetcher.fetch(range: bounded)store.insert(data: data, at: bounded.start)prefetch(aroundByteOffset: bounded.end + 1, forceRestart: true)return store.data(for: bounded) ?? data}func responseRange(for requestedRange: HTTPByteRange) -> HTTPByteRange {5 unmodified lines}func prefetch(aroundByteOffset offset: Int64) {prefetch(aroundByteOffset: offset, forceRestart: false)}func prefetch(aroundByteOffset offset: Int64, forceRestart: Bool) {if !forceRestart, activePrefetchWindow?.contains(offset) == true, prefetchTask?.isCancelled == false {return}prefetchTask?.cancel()let window = targetWindow(aroundByteOffset: offset)activePrefetchWindow = windowactivePrefetchPreferredOffset = offsetstore.evict(keeping: window)guard !store.hasData(for: window) else {activePrefetchWindow = nilactivePrefetchPreferredOffset = nilreturn}prefetchTask = Task { [weak self] inguard let self else {return}for chunk in self.prefetchChunks(in: window, preferredOffset: offset) {guard !Task.isCancelled else {return}1 unmodified linedo {let data = try await fetcher.fetch(range: chunk)store.insert(data: data, at: chunk.start)#if DEBUGprint("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)")#endif37 unmodified lines}private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {let bytesPerSecond = estimatedBytesPerSecond()let behind = max(prefetchChunkSize, bytesPerSecond * 30)let ahead = max(prefetchChunkSize * 2, bytesPerSecond * 60)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) * prefetchChunkSizevar chunks: [HTTPByteRange] = []var cursor = preferredStart3 unmodified linescursor = chunk.end + 1}cursor = window.startwhile cursor < preferredStart {let chunk = HTTPByteRange(start: cursor, end: min(preferredStart - 1, cursor + prefetchChunkSize - 1))chunks.append(chunk)3 unmodified linesreturn chunks}private func estimatedBytesPerSecond() -> Int64 {let duration = durationProvider()guard duration > 1 else {72 unmodified lines73747576777879808182838438 unmodified lines12312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415512 unmodified lines168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228116 unmodified lines3453463473483493503513523533543553563573583593603613623633643653663673682 unmodified lines3713723733743753763771 unmodified line3793803813823833843853863873883893903913923933943953965 unmodified lines4024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544551 unmodified line45745845946046146246337 unmodified lines5015025035045055065075085095105115125135145155165175185195205215225235245253 unmodified lines5295305315325335345353 unmodified lines53954054154254354454554654754854955055155255355455555655755855956056156256356456572 unmodified lines}}var cachedByteCount: Int64 {lock.withLock {segments.reduce(0) { $0 + Int64($1.data.count) }}}func insert(data: Data, at start: Int64) {guard !data.isEmpty else {return38 unmodified linesdata(for: range) != nil}func missingRanges(in range: HTTPByteRange) -> [HTTPByteRange] {lock.withLock {var cursor = range.startvar missing: [HTTPByteRange] = []for segment in segments where segment.range.end >= cursor {guard segment.range.start <= range.end else {break}if segment.range.start > cursor {missing.append(HTTPByteRange(start: cursor, end: min(range.end, segment.range.start - 1)))}if segment.range.end >= cursor {cursor = max(cursor, segment.range.end + 1)}if cursor > range.end {break}}if cursor <= range.end {missing.append(HTTPByteRange(start: cursor, end: range.end))}return missing}}func evict(keeping window: HTTPByteRange) {lock.withLock {segments = segments.compactMap { segment in12 unmodified lines}}func evict(toByteBudget byteBudget: Int64, preserving protectedRanges: [HTTPByteRange]) -> [HTTPByteRange] {lock.withLock {guard byteBudget > 0 else {let evicted = segments.map(\.range)segments.removeAll()return evicted}var totalBytes = segments.reduce(0) { $0 + Int64($1.data.count) }guard totalBytes > byteBudget else {return []}var evicted: [HTTPByteRange] = []while totalBytes > byteBudget,let index = evictionCandidateIndex(protectedRanges: protectedRanges) {let removed = segments.remove(at: index)totalBytes -= Int64(removed.data.count)evicted.append(removed.range)}return evicted}}private func evictionCandidateIndex(protectedRanges: [HTTPByteRange]) -> Int? {var bestIndex: Int?var bestDistance: Int64 = .minfor (index, segment) in segments.enumerated() {if protectedRanges.contains(where: { $0.overlapsOrTouches(segment.range) }) {continue}let distance = protectedRanges.map { rangeDistance(from: segment.range, to: $0) }.min() ?? segment.range.startif distance > bestDistance {bestDistance = distancebestIndex = index}}return bestIndex}private func rangeDistance(from range: HTTPByteRange, to protectedRange: HTTPByteRange) -> Int64 {if range.overlapsOrTouches(protectedRange) {return 0}if range.end < protectedRange.start {return protectedRange.start - range.end}return range.start - protectedRange.end}private func mergeSegments() {guard !segments.isEmpty else {return116 unmodified lineslet durationProvider: () -> TimeIntervalprivate let prefetchChunkSize: Int64 = 1_048_576private let responseChunkSize: Int64 = 1_048_576private let seekPrimeBehindBytes: Int64 = 4 * 1_048_576private let cacheByteBudget: Int64private var prefetchTask: Task<Void, Never>?private var activePrefetchWindow: HTTPByteRange?private var activePrefetchPreferredOffset: Int64?private var recentSeekRange: HTTPByteRange?private var recentForegroundRange: HTTPByteRange?init(fetcher: HTTPRangeRemoteFetcher,contentLength: Int64,durationProvider: @escaping () -> TimeInterval,cacheByteBudget: Int64 = 64 * 1_048_576) {self.fetcher = fetcherself.contentLength = contentLengthself.durationProvider = durationProviderself.cacheByteBudget = cacheByteBudget}deinit {2 unmodified linesfunc data(for requestedRange: HTTPByteRange) async throws -> Data {let bounded = clamp(requestedRange)recentForegroundRange = boundedif let data = store.data(for: bounded) {#if DEBUGprint("[DreamioRangeCache] cache=hit range=\(bounded.start)-\(bounded.end)")1 unmodified linereturn data}let missingRanges = store.missingRanges(in: bounded)#if DEBUGlet missKind = missingRanges.count == 1 && missingRanges[0] == bounded ? "uncached" : "partial-miss"print("[DreamioRangeCache] cache=\(missKind) range=\(bounded.start)-\(bounded.end) missing=\(missingRanges.map { "\($0.start)-\($0.end)" }.joined(separator: ","))")#endifcancelPrefetchIfNeeded(forForegroundRange: bounded)for missingRange in missingRanges {let data = try await fetcher.fetch(range: missingRange)store.insert(data: data, at: missingRange.start)}prefetch(aroundByteOffset: bounded.end + 1, forceRestart: true)return store.data(for: bounded) ?? Data()}func responseRange(for requestedRange: HTTPByteRange) -> HTTPByteRange {5 unmodified lines}func prefetch(aroundByteOffset offset: Int64) {prefetch(aroundByteOffset: offset, forceRestart: false, startsAtWindowStart: false)}func prefetchForSeek(aroundByteOffset offset: Int64) {let window = seekPrimeWindow(aroundByteOffset: offset)recentSeekRange = windowprefetch(aroundByteOffset: offset,forceRestart: true,explicitWindow: window,startsAtWindowStart: true)}func seekPrimeWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {targetWindow(aroundByteOffset: offset, minimumBehind: seekPrimeBehindBytes)}func prefetch(aroundByteOffset offset: Int64, forceRestart: Bool) {prefetch(aroundByteOffset: offset, forceRestart: forceRestart, startsAtWindowStart: false)}private func prefetch(aroundByteOffset offset: Int64,forceRestart: Bool,explicitWindow: HTTPByteRange? = nil,startsAtWindowStart: Bool) {if !forceRestart, activePrefetchWindow?.contains(offset) == true, prefetchTask?.isCancelled == false {return}prefetchTask?.cancel()let window = explicitWindow ?? targetWindow(aroundByteOffset: offset)activePrefetchWindow = windowactivePrefetchPreferredOffset = offsetevictOverBudget(protecting: window)guard !store.hasData(for: window) else {activePrefetchWindow = nilactivePrefetchPreferredOffset = nilreturn}prefetchTask = Task(priority: startsAtWindowStart ? .userInitiated : .utility) { [weak self] inguard let self else {return}for chunk in self.prefetchChunks(in: window, preferredOffset: offset, startsAtWindowStart: startsAtWindowStart) {guard !Task.isCancelled else {return}1 unmodified linedo {let data = try await fetcher.fetch(range: chunk)store.insert(data: data, at: chunk.start)evictOverBudget(protecting: window)#if DEBUGprint("[DreamioRangeCache] fetched range=\(chunk.start)-\(chunk.end) bytes=\(data.count)")#endif37 unmodified lines}private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {targetWindow(aroundByteOffset: offset, minimumBehind: prefetchChunkSize)}private func targetWindow(aroundByteOffset offset: Int64, minimumBehind: Int64) -> HTTPByteRange {let bytesPerSecond = estimatedBytesPerSecond()let behind = max(minimumBehind, bytesPerSecond * 30)let ahead = max(prefetchChunkSize * 2, bytesPerSecond * 60)let raw = clamp(HTTPByteRange(start: offset - behind, end: offset + ahead))return HTTPByteRange(start: alignedChunkStart(for: raw.start), end: alignedChunkEnd(for: raw.end))}func prefetchChunks(in window: HTTPByteRange, preferredOffset offset: Int64) -> [HTTPByteRange] {prefetchChunks(in: window, preferredOffset: offset, startsAtWindowStart: false)}func prefetchChunks(in window: HTTPByteRange, preferredOffset offset: Int64, startsAtWindowStart: Bool) -> [HTTPByteRange] {let boundedOffset = max(window.start, min(window.end, offset))let windowStart = alignedChunkStart(for: window.start)let preferredStart = startsAtWindowStart ? windowStart : alignedChunkStart(for: boundedOffset)var chunks: [HTTPByteRange] = []var cursor = preferredStart3 unmodified linescursor = chunk.end + 1}cursor = windowStartwhile cursor < preferredStart {let chunk = HTTPByteRange(start: cursor, end: min(preferredStart - 1, cursor + prefetchChunkSize - 1))chunks.append(chunk)3 unmodified linesreturn chunks}private func evictOverBudget(protecting range: HTTPByteRange) {let headerRange = HTTPByteRange(start: 0, end: min(contentLength - 1, prefetchChunkSize - 1))let tailStart = max(0, contentLength - (4 * prefetchChunkSize))let tailRange = HTTPByteRange(start: tailStart, end: contentLength - 1)let protectedRanges = [range, recentSeekRange, recentForegroundRange, activePrefetchWindow, headerRange, tailRange].compactMap { $0 }let evicted = store.evict(toByteBudget: cacheByteBudget, preserving: protectedRanges)#if DEBUGif !evicted.isEmpty {print("[DreamioRangeCache] evicted reason=budget ranges=\(evicted.map { "\($0.start)-\($0.end)" }.joined(separator: ",")) protected=\(protectedRanges.map { "\($0.start)-\($0.end)" }.joined(separator: ","))")}#endif}private func alignedChunkStart(for offset: Int64) -> Int64 {max(0, (offset / prefetchChunkSize) * prefetchChunkSize)}private func alignedChunkEnd(for offset: Int64) -> Int64 {min(contentLength - 1, alignedChunkStart(for: offset) + prefetchChunkSize - 1)}private func estimatedBytesPerSecond() -> Int64 {let duration = durationProvider()guard duration > 1 else {
Dreamio/VLCNativePlaybackBackend.swift
150 unmodified lines15115215315415515615713 unmodified lines171172173174175176177426 unmodified lines604605606607608609150 unmodified linesreturn}let clamped = max(0, min(1, position))rangeCacheSession?.prefetch(aroundByteOffset: rangeCacheSession?.byteOffset(for: clamped) ?? 0)#if DEBUGif let byteOffset = rangeCacheSession?.byteOffset(for: clamped) {print("[DreamioVLC] seek targetPosition=\(clamped) byteOffset=\(byteOffset) mode=local-cache")13 unmodified lineslet nextTime = max(0, min(duration, currentTime + seconds))if duration > 0 {let nextPosition = Float(nextTime / duration)rangeCacheSession?.prefetch(aroundByteOffset: rangeCacheSession?.byteOffset(for: nextPosition) ?? 0)#if DEBUGif let byteOffset = rangeCacheSession?.byteOffset(for: nextPosition) {print("[DreamioVLC] jump seconds=\(seconds) target=\(nextTime) byteOffset=\(byteOffset) mode=local-cache")426 unmodified lines#if canImport(MobileVLCKit)extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {func mediaPlayerStateChanged(_ aNotification: Notification) {#if DEBUGlogPlaybackStateIfNeeded(stateName(mediaPlayer.state))#endif150 unmodified lines15115215315415515615713 unmodified lines171172173174175176177426 unmodified lines604605606607608609610611612613614615616150 unmodified linesreturn}let clamped = max(0, min(1, position))rangeCacheSession?.prefetchForSeek(aroundByteOffset: rangeCacheSession?.byteOffset(for: clamped) ?? 0)#if DEBUGif let byteOffset = rangeCacheSession?.byteOffset(for: clamped) {print("[DreamioVLC] seek targetPosition=\(clamped) byteOffset=\(byteOffset) mode=local-cache")13 unmodified lineslet nextTime = max(0, min(duration, currentTime + seconds))if duration > 0 {let nextPosition = Float(nextTime / duration)rangeCacheSession?.prefetchForSeek(aroundByteOffset: rangeCacheSession?.byteOffset(for: nextPosition) ?? 0)#if DEBUGif let byteOffset = rangeCacheSession?.byteOffset(for: nextPosition) {print("[DreamioVLC] jump seconds=\(seconds) target=\(nextTime) byteOffset=\(byteOffset) mode=local-cache")426 unmodified lines#if canImport(MobileVLCKit)extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {func mediaPlayerStateChanged(_ aNotification: Notification) {Task { @MainActor [weak self] inself?.handleMediaPlayerStateChanged()}}@MainActorprivate func handleMediaPlayerStateChanged() {#if DEBUGlogPlaybackStateIfNeeded(stateName(mediaPlayer.state))#endif
Tests/StreamResolverTests.swift
34 unmodified lines3536373839404142291 unmodified lines33433533633733833928 unmodified lines36836937037137237332 unmodified lines40640740840941041141241334 unmodified linestestSparseRangeStoreHitPartialHitAndMiss()testSparseRangeStoreEvictsOutsideWindow()testSparseRangeStoreTrimsOverlappingWindow()testRangeCacheSessionCapsResponseRange()testRangeCachePrefetchPrioritizesSeekOffset()await testRangeCacheForegroundMissReprioritizesPrefetch()await testRangeProbeFallsBackWhenServerIgnoresRange()await testRangeFetcherPreservesHeaders()291 unmodified linesassert(store.data(for: HTTPByteRange(start: 0, end: 5)) == nil, "Expected trimmed bytes outside the window to be evicted")}private static func testRangeCacheSessionCapsResponseRange() {let session = ProgressiveHTTPRangeCacheSession(fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),28 unmodified lines])}private static func testRangeCacheForegroundMissReprioritizesPrefetch() async {let queue = DispatchQueue(label: "dreamio.range-cache-test")var requestedRanges: [String] = []32 unmodified lineslet ranges = queue.sync { requestedRanges }assert(ranges.contains("bytes=51818977-52867552"), "Expected foreground VLC range to be fetched")assert(ranges.contains { range inrange.hasPrefix("bytes=51936225-")}, "Expected prefetch to restart near VLC's foreground range, got \(ranges)")session.cancelPrefetch()MockURLProtocol.handler = niltry? await Task.sleep(nanoseconds: 50_000_000)34 unmodified lines3536373839404142434445291 unmodified lines33733833934034134234334434534634734834935035135235335435535635735835936036136236336428 unmodified lines39339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144232 unmodified lines47547647747847948048148234 unmodified linestestSparseRangeStoreHitPartialHitAndMiss()testSparseRangeStoreEvictsOutsideWindow()testSparseRangeStoreTrimsOverlappingWindow()testSparseRangeStoreEvictsByBudgetWhilePreservingUsefulRanges()testRangeCacheSessionCapsResponseRange()testRangeCachePrefetchPrioritizesSeekOffset()testRangeCacheSeekPrimingIncludesObservedVLCStart()testRangeCachePrefetchUsesGlobalChunkBoundaries()await testRangeCacheForegroundMissReprioritizesPrefetch()await testRangeProbeFallsBackWhenServerIgnoresRange()await testRangeFetcherPreservesHeaders()291 unmodified linesassert(store.data(for: HTTPByteRange(start: 0, end: 5)) == nil, "Expected trimmed bytes outside the window to be evicted")}private static func testSparseRangeStoreEvictsByBudgetWhilePreservingUsefulRanges() {let store = SparseHTTPByteRangeStore()store.insert(data: Data(repeating: 1, count: 4), at: 0)store.insert(data: Data(repeating: 2, count: 4), at: 100)store.insert(data: Data(repeating: 3, count: 4), at: 200)let evicted = store.evict(toByteBudget: 8,preserving: [HTTPByteRange(start: 0, end: 3),HTTPByteRange(start: 190, end: 210)])assertEqual(evicted, [HTTPByteRange(start: 100, end: 103)])assertEqual(store.cachedRanges, [HTTPByteRange(start: 0, end: 3),HTTPByteRange(start: 200, end: 203)])}private static func testRangeCacheSessionCapsResponseRange() {let session = ProgressiveHTTPRangeCacheSession(fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),28 unmodified lines])}private static func testRangeCacheSeekPrimingIncludesObservedVLCStart() {let session = ProgressiveHTTPRangeCacheSession(fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),contentLength: 711_080_522,durationProvider: { 0 })let estimatedOffset: Int64 = 213_615_760let firstVLCRequest = HTTPByteRange(start: 212_942_432, end: 213_991_007)let window = session.seekPrimeWindow(aroundByteOffset: estimatedOffset)let chunks = session.prefetchChunks(in: window,preferredOffset: estimatedOffset,startsAtWindowStart: true)let chunkContainingVLCStart = chunks.firstIndex { $0.contains(firstVLCRequest.start) }let chunkContainingEstimatedOffset = chunks.firstIndex { $0.contains(estimatedOffset) }assert(chunkContainingVLCStart != nil, "Expected seek priming to include VLC's first request start")assert(chunkContainingEstimatedOffset != nil, "Expected seek priming to include the estimated offset")assert(chunkContainingVLCStart! <= chunkContainingEstimatedOffset!,"Expected bytes behind the seek target to be primed before ahead chunks")assertEqual(chunks[chunkContainingVLCStart!], HTTPByteRange(start: 212_860_928, end: 213_909_503))}private static func testRangeCachePrefetchUsesGlobalChunkBoundaries() {let session = ProgressiveHTTPRangeCacheSession(fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),contentLength: 711_080_522,durationProvider: { 0 })let chunks = session.prefetchChunks(in: HTTPByteRange(start: 213_278_260, end: 216_000_000),preferredOffset: 213_615_760)assert(chunks.allSatisfy { $0.start % 1_048_576 == 0 }, "Expected prefetch chunk starts to use stable global 1 MB boundaries: \(chunks)")assertEqual(chunks[0], HTTPByteRange(start: 212_860_928, end: 213_909_503))}private static func testRangeCacheForegroundMissReprioritizesPrefetch() async {let queue = DispatchQueue(label: "dreamio.range-cache-test")var requestedRanges: [String] = []32 unmodified lineslet ranges = queue.sync { requestedRanges }assert(ranges.contains("bytes=51818977-52867552"), "Expected foreground VLC range to be fetched")assert(ranges.contains { range inrange.hasPrefix("bytes=52428800-")}, "Expected prefetch to restart on a global chunk boundary near VLC's foreground range, got \(ranges)")session.cancelPrefetch()MockURLProtocol.handler = niltry? await Task.sleep(nanoseconds: 50_000_000)
Expected Impact for End-Users
Short backward or forward skips in MKV/direct-file playback should resume with fewer full foreground misses, especially when VLC asks slightly before Dreamio’s estimated byte position.
Validation
- Ran
swiftc -parse-as-library Tests/StreamResolverTests.swift Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/ProgressiveHTTPRangeCache.swift Dreamio/ExternalSubtitleTrackParser.swift -o /tmp/StreamResolverTests && /tmp/StreamResolverTests: passed. - Ran
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build: passed.
Issues, Limitations, and Mitigations
- No device playback check was run in this turn, so real MobileVLCKit timing still needs confirmation on hardware.
- The cache remains memory-only. The 64 MB budget limits growth while retaining the ranges most likely to matter after seeks and MKV index probes.
- The existing MobileVLCKit/OpenGLES warning is treated as internal unless it persists after the main-actor cleanup.
Follow-up Work
- Manually test MKV skip behavior on device and compare DEBUG logs for post-seek
hit,partial-miss, anduncachedreads. - Consider exposing cache budget tuning if device memory pressure shows up in longer playback sessions.
- Track any persistent VLCOpenGLES2VideoView warnings separately with device logs if they continue.
New Changes as of 2026-05-26 00:40 EDT
Summary of changes
Adjusted the cache to follow VLC's actual foreground read area even when those reads are hits, and changed foreground partial misses to fetch stable aligned chunks synchronously.
Why this change was made
Device logs showed a backward jump estimating byte 15936567, while VLC continued reading from about 27165812 through the warmed cache. Because those reads were hits, prefetch stayed near the estimate until VLC reached the cache edge and began partial misses.
Code diffs
Dreamio/ProgressiveHTTPRangeCache.swift
375 unmodified lines3763773783793803814 unmodified lines386387388389390391392393106 unmodified lines50050150250350450533 unmodified lines539540541542543544375 unmodified lines#if DEBUGprint("[DreamioRangeCache] cache=hit range=\(bounded.start)-\(bounded.end)")#endifreturn data}4 unmodified lines#endifcancelPrefetchIfNeeded(forForegroundRange: bounded)for missingRange in missingRanges {let data = try await fetcher.fetch(range: missingRange)store.insert(data: data, at: missingRange.start)}prefetch(aroundByteOffset: bounded.end + 1, forceRestart: true)return store.data(for: bounded) ?? Data()106 unmodified linescancelPrefetch()}private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {targetWindow(aroundByteOffset: offset, minimumBehind: prefetchChunkSize)}33 unmodified linesreturn chunks}private func evictOverBudget(protecting range: HTTPByteRange) {let headerRange = HTTPByteRange(start: 0, end: min(contentLength - 1, prefetchChunkSize - 1))let tailStart = max(0, contentLength - (4 * prefetchChunkSize))375 unmodified lines3763773783793803813824 unmodified lines387388389390391392393394395396397398399106 unmodified lines50650750850951051151251351451551651751851952052152252333 unmodified lines557558559560561562563564565566567568569570571572573574375 unmodified lines#if DEBUGprint("[DreamioRangeCache] cache=hit range=\(bounded.start)-\(bounded.end)")#endifprefetchAheadIfForegroundMoved(to: bounded)return data}4 unmodified lines#endifcancelPrefetchIfNeeded(forForegroundRange: bounded)for missingRange in missingRanges {for fetchRange in alignedChunks(covering: missingRange) where !store.hasData(for: fetchRange) {let data = try await fetcher.fetch(range: fetchRange)store.insert(data: data, at: fetchRange.start)#if DEBUGprint("[DreamioRangeCache] foreground fetched range=\(fetchRange.start)-\(fetchRange.end) bytes=\(data.count)")#endif}}prefetch(aroundByteOffset: bounded.end + 1, forceRestart: true)return store.data(for: bounded) ?? Data()106 unmodified linescancelPrefetch()}private func prefetchAheadIfForegroundMoved(to range: HTTPByteRange) {guard activePrefetchWindow?.contains(range.start) == true,let preferredOffset = activePrefetchPreferredOffset,abs(range.start - preferredOffset) >= responseChunkSize else {return}#if DEBUGprint("[DreamioRangeCache] prefetch follow-foreground from=\(preferredOffset) to=\(range.end + 1)")#endifprefetch(aroundByteOffset: range.end + 1, forceRestart: true)}private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {targetWindow(aroundByteOffset: offset, minimumBehind: prefetchChunkSize)}33 unmodified linesreturn chunks}func alignedChunks(covering range: HTTPByteRange) -> [HTTPByteRange] {let bounded = clamp(range)var chunks: [HTTPByteRange] = []var cursor = alignedChunkStart(for: bounded.start)while cursor <= bounded.end {let chunk = HTTPByteRange(start: cursor, end: min(contentLength - 1, cursor + prefetchChunkSize - 1))chunks.append(chunk)cursor = chunk.end + 1}return chunks}private func evictOverBudget(protecting range: HTTPByteRange) {let headerRange = HTTPByteRange(start: 0, end: min(contentLength - 1, prefetchChunkSize - 1))let tailStart = max(0, contentLength - (4 * prefetchChunkSize))
Tests/StreamResolverTests.swift
39 unmodified lines40414243444546390 unmodified lines43743843944044144230 unmodified lines4734744754764774784792 unmodified lines48248348448548648739 unmodified linestestRangeCachePrefetchPrioritizesSeekOffset()testRangeCacheSeekPrimingIncludesObservedVLCStart()testRangeCachePrefetchUsesGlobalChunkBoundaries()await testRangeCacheForegroundMissReprioritizesPrefetch()await testRangeProbeFallsBackWhenServerIgnoresRange()await testRangeFetcherPreservesHeaders()print("StreamResolverTests passed")390 unmodified linesassertEqual(chunks[0], HTTPByteRange(start: 212_860_928, end: 213_909_503))}private static func testRangeCacheForegroundMissReprioritizesPrefetch() async {let queue = DispatchQueue(label: "dreamio.range-cache-test")var requestedRanges: [String] = []30 unmodified linestry? await Task.sleep(nanoseconds: 50_000_000)let ranges = queue.sync { requestedRanges }assert(ranges.contains("bytes=51818977-52867552"), "Expected foreground VLC range to be fetched")assert(ranges.contains { range inrange.hasPrefix("bytes=52428800-")}, "Expected prefetch to restart on a global chunk boundary near VLC's foreground range, got \(ranges)")2 unmodified linestry? await Task.sleep(nanoseconds: 50_000_000)}private static func byteRange(fromHeader header: String, contentLength: Int64) -> HTTPByteRange {let value = header.replacingOccurrences(of: "bytes=", with: "")let pieces = value.split(separator: "-", maxSplits: 1).map(String.init)39 unmodified lines404142434445464748390 unmodified lines43944044144244344444544644744844945045145245345445545630 unmodified lines4874884894904914924932 unmodified lines49649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454539 unmodified linestestRangeCachePrefetchPrioritizesSeekOffset()testRangeCacheSeekPrimingIncludesObservedVLCStart()testRangeCachePrefetchUsesGlobalChunkBoundaries()testRangeCacheForegroundMissFetchesAlignedChunks()await testRangeCacheForegroundMissReprioritizesPrefetch()await testRangeCacheHitFollowsActualPostSeekReadArea()await testRangeProbeFallsBackWhenServerIgnoresRange()await testRangeFetcherPreservesHeaders()print("StreamResolverTests passed")390 unmodified linesassertEqual(chunks[0], HTTPByteRange(start: 212_860_928, end: 213_909_503))}private static func testRangeCacheForegroundMissFetchesAlignedChunks() {let session = ProgressiveHTTPRangeCacheSession(fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://example.test/video.mkv")!, headers: [:]),contentLength: 711_080_522,durationProvider: { 0 })let chunks = session.alignedChunks(covering: HTTPByteRange(start: 48_234_649, end: 49_185_907))assertEqual(chunks, [HTTPByteRange(start: 48_234_496, end: 49_283_071)])}private static func testRangeCacheForegroundMissReprioritizesPrefetch() async {let queue = DispatchQueue(label: "dreamio.range-cache-test")var requestedRanges: [String] = []30 unmodified linestry? await Task.sleep(nanoseconds: 50_000_000)let ranges = queue.sync { requestedRanges }assert(ranges.contains("bytes=51380224-52428799"), "Expected foreground VLC miss to fetch aligned cache chunks")assert(ranges.contains { range inrange.hasPrefix("bytes=52428800-")}, "Expected prefetch to restart on a global chunk boundary near VLC's foreground range, got \(ranges)")2 unmodified linestry? await Task.sleep(nanoseconds: 50_000_000)}private static func testRangeCacheHitFollowsActualPostSeekReadArea() async {let queue = DispatchQueue(label: "dreamio.range-cache-hit-follow-test")var requestedRanges: [String] = []MockURLProtocol.handler = { request inlet range = request.value(forHTTPHeaderField: "Range") ?? ""queue.sync {requestedRanges.append(range)}let byteRange = byteRange(fromHeader: range, contentLength: 80_000_000)let response = HTTPURLResponse(url: request.url!,statusCode: 206,httpVersion: nil,headerFields: ["Content-Range": "bytes \(byteRange.start)-\(byteRange.end)/80000000"])!return (Data(repeating: 1, count: Int(byteRange.length)), response)}let session = ProgressiveHTTPRangeCacheSession(fetcher: HTTPRangeRemoteFetcher(url: URL(string: "https://cdn.example.test/movie.mp4")!,headers: [:],session: mockSession()),contentLength: 80_000_000,durationProvider: { 100 })defer {session.cancelPrefetch()}session.store.insert(data: Data(repeating: 7, count: 1_048_576), at: 27_165_812)session.prefetchForSeek(aroundByteOffset: 15_936_567)_ = try? await session.data(for: HTTPByteRange(start: 27_165_812, end: 28_214_387))try? await Task.sleep(nanoseconds: 100_000_000)let ranges = queue.sync { requestedRanges }assert(ranges.contains { range inrange.hasPrefix("bytes=27262976-")}, "Expected a cache hit far from the seek estimate to restart prefetch near VLC's real read area, got \(ranges)")MockURLProtocol.handler = niltry? await Task.sleep(nanoseconds: 50_000_000)}private static func byteRange(fromHeader header: String, contentLength: Int64) -> HTTPByteRange {let value = header.replacingOccurrences(of: "bytes=", with: "")let pieces = value.split(separator: "-", maxSplits: 1).map(String.init)
Related issues or PRs
Beads issue dreamio-mi1.
New Changes as of May 26, 2026 at 10:52 AM EDT
Summary of changes
Updated the local HTTP range cache prefetch bookkeeping after reviewing fresh device logs from normal playback and a warmed-cache 15 second skip. Normal cold-cache misses are expected, but the warmed-cache skip showed the foreground read moving behind the active prefetch cursor while older prefetch state could still finish and mutate session state.
Why this change was made
The cache should follow the bytes VLC is actually reading after a skip, not keep stale prefetch state alive after a newer foreground read has reoriented the cache. This update makes canceled prefetch workers stop before inserting fetched bytes after cancellation and prevents stale workers from clearing newer active prefetch state.
Code diffs
Rendered with @pierre/diffs/ssr.
Dreamio/ProgressiveHTTPRangeCache.swift
349 unmodified lines35035135235335435585 unmodified lines44144244344444544615 unmodified lines46246346446546646710 unmodified lines4784794804814824831 unmodified line48548648748848949021 unmodified lines512513514515516517518519520521349 unmodified linesprivate var prefetchTask: Task<Void, Never>?private var activePrefetchWindow: HTTPByteRange?private var activePrefetchPreferredOffset: Int64?private var recentSeekRange: HTTPByteRange?private var recentForegroundRange: HTTPByteRange?85 unmodified lines}prefetchTask?.cancel()let window = explicitWindow ?? targetWindow(aroundByteOffset: offset)activePrefetchWindow = windowactivePrefetchPreferredOffset = offset15 unmodified linesif !store.hasData(for: chunk) {do {let data = try await fetcher.fetch(range: chunk)store.insert(data: data, at: chunk.start)evictOverBudget(protecting: window)#if DEBUG10 unmodified lines}}}self.activePrefetchWindow = nilself.activePrefetchPreferredOffset = nil}1 unmodified linefunc cancelPrefetch() {prefetchTask?.cancel()activePrefetchWindow = nilactivePrefetchPreferredOffset = nil}21 unmodified linesabs(range.start - preferredOffset) >= responseChunkSize else {return}#if DEBUGprint("[DreamioRangeCache] prefetch follow-foreground from=\(preferredOffset) to=\(range.end + 1)")#endifprefetch(aroundByteOffset: range.end + 1, forceRestart: true)}private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {349 unmodified lines35035135235335435535685 unmodified lines44244344444544644744844915 unmodified lines46546646746846947047147247310 unmodified lines4844854864874884894904914921 unmodified line49449549649749849950021 unmodified lines522523524525526527528529530531532533349 unmodified linesprivate var prefetchTask: Task<Void, Never>?private var activePrefetchWindow: HTTPByteRange?private var activePrefetchPreferredOffset: Int64?private var prefetchGeneration: UInt64 = 0private var recentSeekRange: HTTPByteRange?private var recentForegroundRange: HTTPByteRange?85 unmodified lines}prefetchTask?.cancel()prefetchGeneration += 1let generation = prefetchGenerationlet window = explicitWindow ?? targetWindow(aroundByteOffset: offset)activePrefetchWindow = windowactivePrefetchPreferredOffset = offset15 unmodified linesif !store.hasData(for: chunk) {do {let data = try await fetcher.fetch(range: chunk)guard !Task.isCancelled else {return}store.insert(data: data, at: chunk.start)evictOverBudget(protecting: window)#if DEBUG10 unmodified lines}}}guard self.prefetchGeneration == generation else {return}self.activePrefetchWindow = nilself.activePrefetchPreferredOffset = nil}1 unmodified linefunc cancelPrefetch() {prefetchTask?.cancel()prefetchGeneration += 1activePrefetchWindow = nilactivePrefetchPreferredOffset = nil}21 unmodified linesabs(range.start - preferredOffset) >= responseChunkSize else {return}let nextOffset = range.end + 1#if DEBUGlet reason = nextOffset < preferredOffset ? "reanchor-foreground" : "follow-foreground"print("[DreamioRangeCache] prefetch \(reason) from=\(preferredOffset) to=\(nextOffset)")#endifprefetch(aroundByteOffset: nextOffset, forceRestart: true)}private func targetWindow(aroundByteOffset offset: Int64) -> HTTPByteRange {
Related issues or PRs
Beads issue dreamio-2hw.