diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 32e8e02..ce656c0 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -38,3 +38,6 @@ {"id":"int-697dc66d","kind":"field_change","created_at":"2026-05-25T17:01:32.697187Z","actor":"dirtydishes","issue_id":"dreamio-0lt","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"not implementing now; user asked only to move previous work to the audio-track-selection branch"}} {"id":"int-c9b3bcd7","kind":"field_change","created_at":"2026-05-25T17:48:09.142384Z","actor":"dirtydishes","issue_id":"dreamio-ejh","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by preserving known external subtitle display names for generic VLC subtitle tracks and expanding language-code aliases."}} {"id":"int-12bf46aa","kind":"field_change","created_at":"2026-05-25T18:31:50.873069Z","actor":"dirtydishes","issue_id":"dreamio-kdf","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tracked Xcode user interface state files were removed from the git index, and existing ignore rules now cover regenerated xcuserdata files."}} +{"id":"int-c15b9cb9","kind":"field_change","created_at":"2026-05-25T19:07:23.629637Z","actor":"dirtydishes","issue_id":"dreamio-3yb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added centralized 30-second VLC media caching for native playback and validated the iOS build."}} +{"id":"int-e339ed64","kind":"field_change","created_at":"2026-05-25T20:22:40.999137Z","actor":"dirtydishes","issue_id":"dreamio-dsp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"implemented local native stream cache proxy with range cache tests and successful simulator build"}} +{"id":"int-79713eba","kind":"field_change","created_at":"2026-05-25T21:55:32.323229Z","actor":"dirtydishes","issue_id":"dreamio-6bv","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"handled VLC buffering follow-up by supporting HEAD probes, moving fetch work off listener queue, reducing foreground range size, and locking cache access"}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 58596f1..279eb8b 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,4 +1,6 @@ {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-6bv","title":"fix native stream proxy buffering after seek","description":"Investigate and fix VLC staying in buffering after native proxy-backed jump seeks. Logs show time remains pinned after jump while state repeatedly reports buffering.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T21:53:57Z","created_by":"dirtydishes","updated_at":"2026-05-25T21:55:32Z","started_at":"2026-05-25T21:54:01Z","closed_at":"2026-05-25T21:55:32Z","close_reason":"handled VLC buffering follow-up by supporting HEAD probes, moving fetch work off listener queue, reducing foreground range size, and locking cache access","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-dsp","title":"add local seek buffer proxy for native playback","description":"Implement a local HTTP range proxy/cache between VLC and direct-file streams so nearby seeks can use retained bytes, while preserving stream headers and subtitle behavior.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T20:18:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T20:22:41Z","started_at":"2026-05-25T20:18:47Z","closed_at":"2026-05-25T20:22:41Z","close_reason":"implemented local native stream cache proxy with range cache tests and successful simulator build","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-ejh","title":"Preserve external subtitle language names in VLC captions menu","description":"VLC can surface externally attached subtitle slaves as generic Track N labels even though Dreamio already knows the OpenSubtitles language metadata. Preserve and apply that metadata when building the native captions menu so users can distinguish subtitle languages.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T17:46:38Z","created_by":"dirtydishes","updated_at":"2026-05-25T17:48:09Z","started_at":"2026-05-25T17:46:43Z","closed_at":"2026-05-25T17:48:09Z","close_reason":"Fixed by preserving known external subtitle display names for generic VLC subtitle tracks and expanding language-code aliases.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-9sp","title":"Accept Stremio subtitle download URLs","description":"Runtime logs show Stremio external subtitle tracks using subs5.strem.io /en/download URLs. The subtitle bridge and Swift parser currently reject those URLs because they do not have a subtitle file extension and are not on an OpenSubtitles host, so native playback receives zero external subtitle candidates.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:32:04Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:33:55Z","started_at":"2026-05-25T16:32:10Z","closed_at":"2026-05-25T16:33:55Z","close_reason":"Accepted Stremio subtitle download URLs in the bridge, parser, resolver, and regression tests.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-433","title":"Filter false OpenSubtitles subtitle candidates","description":"Dreamio is treating addon artwork and OpenSubtitles addon endpoints as external subtitle candidates, which causes the native player UI to show only embedded subtitles. Tighten subtitle URL detection in the web bridge and Swift parser, and add regression coverage for the logged false positives.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:20:47Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:22:50Z","started_at":"2026-05-25T16:20:50Z","closed_at":"2026-05-25T16:22:50Z","close_reason":"Fixed by tightening OpenSubtitles subtitle URL filtering in the web bridge and Swift parser, plus adding regression coverage for logged artwork and addon endpoint false positives.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -24,6 +26,7 @@ {"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-3yb","title":"Add VLC seek buffer for native playback","description":"Configure balanced VLC media caching in the native playback backend so short seek jumps are less likely to feel like stream restarts while preserving existing playback controls, audio tracks, and subtitles.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T19:05:27Z","created_by":"dirtydishes","updated_at":"2026-05-25T19:07:24Z","started_at":"2026-05-25T19:05:31Z","closed_at":"2026-05-25T19:07:24Z","close_reason":"Added centralized 30-second VLC media caching for native playback and validated the iOS build.","comments":[{"id":"019e60b6-9b11-7c5f-af57-c9e54ac6129f","issue_id":"dreamio-3yb","author":"dirtydishes","text":"Device testing still shows repeated VLC buffering after 15-second jumps. Added DEBUG playback snapshots on state changes and delayed post-jump probes so the next pass can distinguish a stalled stream/range reconnect from a seek-state issue.","created_at":"2026-05-25T19:57:21Z"},{"id":"019e60c4-f824-79fe-b974-9fbe9fe91788","issue_id":"dreamio-3yb","author":"dirtydishes","text":"Latest device logs show VLC remains at the pre-jump time/position after a fixed skip while buffering. Added backend-only stalled jump recovery: after a short no-progress buffering window, reopen the same media with :start-time set to the requested target and reattach accepted subtitle candidates plus selected tracks where available.","created_at":"2026-05-25T20:13:02Z"}],"dependency_count":0,"dependent_count":0,"comment_count":2} {"_type":"issue","id":"dreamio-kdf","title":"Stop tracking Xcode user state","description":"Xcode user interface state files are machine-specific and currently tracked, which causes noisy local modifications and pull conflicts. Remove tracked xcuserstate files from the git index while keeping ignore rules in place.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T18:31:36Z","created_by":"dirtydishes","updated_at":"2026-05-25T18:31:51Z","started_at":"2026-05-25T18:31:39Z","closed_at":"2026-05-25T18:31:51Z","close_reason":"Tracked Xcode user interface state files were removed from the git index, and existing ignore rules now cover regenerated xcuserdata files.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-0lt","title":"add audio track selection","description":"Add native player support for viewing and switching available audio tracks during playback.","status":"closed","priority":2,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-25T17:00:53Z","created_by":"dirtydishes","updated_at":"2026-05-25T17:01:33Z","closed_at":"2026-05-25T17:01:33Z","close_reason":"not implementing now; user asked only to move previous work to the audio-track-selection branch","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-2ju","title":"Show OpenSubtitles languages in caption tracks","description":"Preserve external subtitle metadata after VLC attaches OpenSubtitles tracks so the captions menu shows useful language labels instead of generic VLC track names.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T16:52:44Z","created_by":"dirtydishes","updated_at":"2026-05-25T16:54:58Z","started_at":"2026-05-25T16:52:49Z","closed_at":"2026-05-25T16:54:58Z","close_reason":"Fixed by preserving OpenSubtitles subtitle display metadata through VLC external track attachment and adding display-name tests.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio.xcodeproj/project.pbxproj b/Dreamio.xcodeproj/project.pbxproj index af6a9dc..ed6a41a 100644 --- a/Dreamio.xcodeproj/project.pbxproj +++ b/Dreamio.xcodeproj/project.pbxproj @@ -15,7 +15,8 @@ 6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */; }; 6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */; }; 6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */; }; - BA013CEC876B829A86AE8DCB /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; }; + 6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */; }; + 8BC00A493F84BEC6714B8F14 /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -29,6 +30,7 @@ 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCNativePlaybackBackend.swift; sourceTree = ""; }; 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.swift; sourceTree = ""; }; + 6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveHTTPRangeCache.swift; sourceTree = ""; }; 701702B9C2BFBEDE36E7F0A3 /* Pods-Dreamio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.release.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.release.xcconfig"; sourceTree = ""; }; 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Dreamio.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BF0A4D5BAC9400AEEF3B0181 /* Pods-Dreamio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.debug.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.debug.xcconfig"; sourceTree = ""; }; @@ -39,6 +41,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 8BC00A493F84BEC6714B8F14 /* Pods_Dreamio.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -90,6 +93,7 @@ 6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */, 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */, 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */, + 6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */, 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */, 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */, 6F2A2B392C00100100DREAMIO /* Info.plist */, @@ -236,6 +240,7 @@ 6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */, 6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */, 6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */, + 6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */, 6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */, 6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */, ); diff --git a/Dreamio/ProgressiveHTTPRangeCache.swift b/Dreamio/ProgressiveHTTPRangeCache.swift new file mode 100644 index 0000000..baa6ba5 --- /dev/null +++ b/Dreamio/ProgressiveHTTPRangeCache.swift @@ -0,0 +1,464 @@ +import Foundation +import Network + +struct HTTPByteRange: Equatable { + let start: Int64 + let end: Int64 + + var length: Int64 { + max(0, end - start + 1) + } + + func overlapsOrTouches(_ other: HTTPByteRange) -> Bool { + start <= other.end + 1 && other.start <= end + 1 + } + + func merged(with other: HTTPByteRange) -> HTTPByteRange { + HTTPByteRange(start: min(start, other.start), end: max(end, other.end)) + } +} + +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 + } + + let body = trimmed.dropFirst("bytes ".count) + let pieces = body.split(separator: "/", maxSplits: 1).map(String.init) + guard pieces.count == 2 else { + return nil + } + + let rangePieces = pieces[0].split(separator: "-", maxSplits: 1).map(String.init) + guard rangePieces.count == 2, + let start = Int64(rangePieces[0]), + let end = Int64(rangePieces[1]), + start >= 0, + end >= start else { + return nil + } + + let total = pieces[1] == "*" ? nil : Int64(pieces[1]) + return HTTPContentRange(range: HTTPByteRange(start: start, end: end), totalLength: total) + } +} + +struct HTTPRangeProbeResult { + let isCacheable: Bool + let contentLength: Int64? + let fallbackReason: String? +} + +final class SparseHTTPByteRangeStore { + private struct Segment { + var range: HTTPByteRange + var data: Data + } + + private let lock = NSLock() + private var segments: [Segment] = [] + + var cachedRanges: [HTTPByteRange] { + lock.withLock { + segments.map(\.range) + } + } + + func insert(data: Data, at start: Int64) { + guard !data.isEmpty else { + return + } + + let insertedRange = HTTPByteRange(start: start, end: start + Int64(data.count) - 1) + lock.withLock { + segments.append(Segment(range: insertedRange, data: data)) + segments.sort { $0.range.start < $1.range.start } + mergeSegments() + } + } + + func data(for range: HTTPByteRange) -> Data? { + lock.withLock { + guard let firstIndex = segments.firstIndex(where: { $0.range.start <= range.start && $0.range.end >= range.start }) else { + return nil + } + + var cursor = range.start + var result = Data() + for segment in segments[firstIndex...] { + guard segment.range.start <= cursor, segment.range.end >= cursor else { + break + } + + let readEnd = min(segment.range.end, range.end) + let lower = Int(cursor - segment.range.start) + let upper = Int(readEnd - segment.range.start + 1) + result.append(segment.data.subdata(in: lower.. range.end { + return result + } + } + return nil + } + } + + func hasData(for range: HTTPByteRange) -> Bool { + data(for: range) != nil + } + + func evict(keeping window: HTTPByteRange) { + lock.withLock { + segments.removeAll { !$0.range.overlapsOrTouches(window) } + } + } + + private func mergeSegments() { + guard !segments.isEmpty else { + return + } + + var merged: [Segment] = [] + for segment in segments { + guard var previous = merged.popLast() else { + merged.append(segment) + continue + } + + guard previous.range.overlapsOrTouches(segment.range) else { + merged.append(previous) + merged.append(segment) + continue + } + + if segment.range.end > previous.range.end { + let overlap = max(0, previous.range.end - segment.range.start + 1) + if overlap < Int64(segment.data.count) { + previous.data.append(segment.data.dropFirst(Int(overlap))) + } + previous.range = previous.range.merged(with: segment.range) + } + merged.append(previous) + } + segments = merged + } +} + +final class HTTPRangeRemoteFetcher { + let url: URL + let headers: [String: String] + private let session: URLSession + + init(url: URL, headers: [String: String], session: URLSession = .shared) { + self.url = url + self.headers = headers + self.session = session + } + + func probe() async -> HTTPRangeProbeResult { + guard ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { + return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "non-http-url") + } + guard !url.path.lowercased().hasSuffix(".m3u8") else { + return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "hls-playlist") + } + + if let head = try? await response(for: request(method: "HEAD")), + (200..<400).contains(head.statusCode) { + let acceptsRanges = header("Accept-Ranges", in: head)?.lowercased().contains("bytes") == true + let length = header("Content-Length", in: head).flatMap(Int64.init) + if acceptsRanges, let length, length > 0 { + return HTTPRangeProbeResult(isCacheable: true, contentLength: length, fallbackReason: nil) + } + } + + var tinyRequest = request(method: "GET") + tinyRequest.setValue("bytes=0-0", forHTTPHeaderField: "Range") + do { + let (data, response) = try await session.data(for: tinyRequest) + guard let http = response as? HTTPURLResponse else { + return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "probe-non-http-response") + } + guard http.statusCode == 206, + let contentRange = header("Content-Range", in: http).flatMap(HTTPContentRange.parse), + data.count <= 1 else { + return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "range-probe-status-\(http.statusCode)") + } + return HTTPRangeProbeResult(isCacheable: true, contentLength: contentRange.totalLength, fallbackReason: nil) + } catch { + return HTTPRangeProbeResult(isCacheable: false, contentLength: nil, fallbackReason: "range-probe-error-\(error.localizedDescription)") + } + } + + func fetch(range: HTTPByteRange) async throws -> Data { + var rangeRequest = request(method: "GET") + rangeRequest.setValue("bytes=\(range.start)-\(range.end)", forHTTPHeaderField: "Range") + let (data, response) = try await session.data(for: rangeRequest) + guard let http = response as? HTTPURLResponse else { + throw HTTPRangeCacheError.remoteRejectedRange("non-http-response") + } + guard http.statusCode == 206 else { + throw HTTPRangeCacheError.remoteRejectedRange("status-\(http.statusCode)") + } + return data + } + + private func response(for request: URLRequest) async throws -> HTTPURLResponse? { + let (_, response) = try await session.data(for: request) + return response as? HTTPURLResponse + } + + private func request(method: String) -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = method + headers.forEach { key, value in + request.setValue(value, forHTTPHeaderField: key) + } + return request + } + + private func header(_ name: String, in response: HTTPURLResponse) -> String? { + response.value(forHTTPHeaderField: name) + } +} + +enum HTTPRangeCacheError: Error { + case remoteRejectedRange(String) + case serverUnavailable +} + +final class ProgressiveHTTPRangeCacheSession { + let id = UUID().uuidString + let store = SparseHTTPByteRangeStore() + let fetcher: HTTPRangeRemoteFetcher + let contentLength: Int64 + let durationProvider: () -> TimeInterval + private let prefetchChunkSize: Int64 = 1_048_576 + private var prefetchTask: Task? + + init(fetcher: HTTPRangeRemoteFetcher, contentLength: Int64, durationProvider: @escaping () -> TimeInterval) { + self.fetcher = fetcher + self.contentLength = contentLength + self.durationProvider = durationProvider + } + + func data(for requestedRange: HTTPByteRange) async throws -> Data { + let bounded = clamp(requestedRange) + if let data = store.data(for: bounded) { +#if DEBUG + print("[DreamioRangeCache] cache=hit range=\(bounded.start)-\(bounded.end)") +#endif + return data + } + +#if DEBUG + print("[DreamioRangeCache] cache=miss range=\(bounded.start)-\(bounded.end)") +#endif + 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 + } + + func prefetch(aroundByteOffset offset: Int64) { + prefetchTask?.cancel() + let window = targetWindow(aroundByteOffset: offset) + store.evict(keeping: window) + guard !store.hasData(for: window) else { + return + } + + prefetchTask = Task { [weak self] in + 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) + store.insert(data: data, at: chunk.start) +#if DEBUG + 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 + } + } + } + + func byteOffset(for position: Float) -> Int64 { + let clamped = max(0, min(1, position)) + return Int64(Float(contentLength) * clamped) + } + + 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)) + } + + private func estimatedBytesPerSecond() -> Int64 { + let duration = durationProvider() + guard duration > 1 else { + return 512_000 + } + return max(1, Int64(Double(contentLength) / duration)) + } + + private func clamp(_ range: HTTPByteRange) -> HTTPByteRange { + HTTPByteRange( + start: max(0, min(contentLength - 1, range.start)), + end: max(0, min(contentLength - 1, range.end)) + ) + } +} + +final class ProgressiveHTTPRangeCacheServer { + static let shared = ProgressiveHTTPRangeCacheServer() + + private let queue = DispatchQueue(label: "dreamio.range-cache.server") + private var listener: NWListener? + private var port: UInt16? + private var sessions: [String: ProgressiveHTTPRangeCacheSession] = [:] + + func localURL(for session: ProgressiveHTTPRangeCacheSession) throws -> URL { + try startIfNeeded() + sessions[session.id] = session + guard let port, + let url = URL(string: "http://127.0.0.1:\(port)/stream/\(session.id)") else { + throw HTTPRangeCacheError.serverUnavailable + } + return url + } + + private func startIfNeeded() throws { + guard listener == nil else { + return + } + + let listener = try NWListener(using: .tcp, on: .any) + listener.newConnectionHandler = { [weak self] connection in + self?.handle(connection) + } + listener.start(queue: queue) + self.listener = listener + self.port = listener.port.map { UInt16($0.rawValue) } + } + + private func handle(_ connection: NWConnection) { + connection.start(queue: queue) + connection.receive(minimumIncompleteLength: 1, maximumLength: 16_384) { [weak self] data, _, _, _ in + guard let self, let data, let requestText = String(data: data, encoding: .utf8) else { + connection.cancel() + return + } + Task { + await self.respond(to: requestText, on: connection) + } + } + } + + private func respond(to requestText: String, on connection: NWConnection) async { + guard let requestLine = requestText.components(separatedBy: "\r\n").first else { + send(status: "400 Bad Request", headers: [:], body: Data(), on: connection) + return + } + + let parts = requestLine.split(separator: " ") + guard parts.count >= 2, + parts[0] == "GET", + let path = parts[safe: 1], + path.hasPrefix("/stream/") else { + send(status: "404 Not Found", headers: [:], body: Data(), on: connection) + return + } + + let id = String(path.dropFirst("/stream/".count)) + guard let session = sessions[id] else { + send(status: "404 Not Found", headers: [:], body: Data(), on: connection) + return + } + + 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", + "Connection": "close" + ] + send(status: "206 Partial Content", headers: headers, body: data, on: connection) + } catch { + send(status: "502 Bad Gateway", headers: ["Connection": "close"], body: Data(), on: connection) + } + } + + private func parseRangeHeader(in request: String, contentLength: Int64) -> HTTPByteRange? { + let lines = request.components(separatedBy: "\r\n") + guard let line = lines.first(where: { $0.lowercased().hasPrefix("range:") }) else { + return nil + } + + let value = line.dropFirst("Range:".count).trimmingCharacters(in: .whitespaces) + guard value.lowercased().hasPrefix("bytes=") else { + return nil + } + + let rangeValue = value.dropFirst("bytes=".count) + let pieces = rangeValue.split(separator: "-", maxSplits: 1, omittingEmptySubsequences: false) + guard pieces.count == 2, + let start = Int64(pieces[0]) else { + return nil + } + let end = pieces[1].isEmpty ? contentLength - 1 : (Int64(pieces[1]) ?? contentLength - 1) + guard start >= 0, end >= start else { + return nil + } + return HTTPByteRange(start: start, end: min(end, contentLength - 1)) + } + + private func send(status: String, headers: [String: String], body: Data, on connection: NWConnection) { + var response = "HTTP/1.1 \(status)\r\n" + headers.forEach { key, value in + response += "\(key): \(value)\r\n" + } + response += "\r\n" + var payload = Data(response.utf8) + payload.append(body) + connection.send(content: payload, completion: .contentProcessed { _ in + connection.cancel() + }) + } +} + +private extension NSLock { + func withLock(_ body: () -> T) -> T { + lock() + defer { unlock() } + return body() + } +} + +private extension Array { + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index c3c2318..3741b70 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -23,6 +23,10 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { #if canImport(MobileVLCKit) private let mediaPlayer = VLCMediaPlayer() #endif + private var rangeCacheSession: ProgressiveHTTPRangeCacheSession? + private var playbackStartupTask: Task? + private var lastLoggedState: String? + private var lastBufferingLogTime: Date? private var attachedSubtitleURLs = Set() private var didAutoSelectSubtitleTrack = false private var didUserSelectSubtitleTrack = false @@ -48,6 +52,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { func play(request: NativePlaybackRequest) { #if canImport(MobileVLCKit) + playbackStartupTask?.cancel() attachedSubtitleURLs.removeAll() didAutoSelectSubtitleTrack = false didUserSelectSubtitleTrack = false @@ -56,23 +61,63 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { hasPendingExternalSubtitleSelection = false pendingExternalSubtitleDisplayNames.removeAll() externalSubtitleDisplayNamesByTrackID.removeAll() - let media = VLCMedia(url: request.playbackURL) - let headerValue = request.headers - .map { "\($0.key): \($0.value)" } - .joined(separator: "\r\n") - media.addOption(":http-referrer=\(request.referer)") - if let userAgent = request.userAgent { - media.addOption(":http-user-agent=\(userAgent)") - } - if !headerValue.isEmpty { - media.addOption(":http-header=\(headerValue)") - } - - mediaPlayer.media = media + rangeCacheSession = nil + lastLoggedState = nil + lastBufferingLogTime = nil #if DEBUG - print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") + print("[DreamioVLC] cache-probe url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") #endif - mediaPlayer.play() + playbackStartupTask = Task { [weak self] in + guard let self else { + return + } + let fetcher = HTTPRangeRemoteFetcher(url: request.playbackURL, headers: request.headers) + let probe = await fetcher.probe() + guard !Task.isCancelled else { + return + } + + if probe.isCacheable, let contentLength = probe.contentLength, contentLength > 0 { + do { + 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 + } catch { +#if DEBUG + print("[DreamioVLC] cache fallback reason=local-server-error-\(error)") +#endif + } + } else { +#if DEBUG + print("[DreamioVLC] cache fallback reason=\(probe.fallbackReason ?? "unknown")") +#endif + } + + await MainActor.run { + self.startVLCMedia( + url: request.playbackURL, + request: request, + playbackMode: "direct", + cachingMilliseconds: 2500, + includeRemoteHTTPOptions: true + ) + } + } #else onFailure?(NativePlaybackError.backendUnavailable) #endif @@ -99,7 +144,16 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { guard isSeekable else { return } - mediaPlayer.position = max(0, min(1, position)) + let clamped = max(0, min(1, position)) + rangeCacheSession?.prefetch(aroundByteOffset: rangeCacheSession?.byteOffset(for: clamped) ?? 0) +#if DEBUG + if let byteOffset = rangeCacheSession?.byteOffset(for: clamped) { + print("[DreamioVLC] seek targetPosition=\(clamped) byteOffset=\(byteOffset) mode=local-cache") + } else { + print("[DreamioVLC] seek targetPosition=\(clamped) mode=direct") + } +#endif + mediaPlayer.position = clamped #endif } @@ -109,6 +163,17 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { return } let nextTime = max(0, min(duration, currentTime + seconds)) + if duration > 0 { + let nextPosition = Float(nextTime / duration) + rangeCacheSession?.prefetch(aroundByteOffset: rangeCacheSession?.byteOffset(for: nextPosition) ?? 0) +#if DEBUG + if let byteOffset = rangeCacheSession?.byteOffset(for: nextPosition) { + print("[DreamioVLC] jump seconds=\(seconds) target=\(nextTime) byteOffset=\(byteOffset) mode=local-cache") + } else { + print("[DreamioVLC] jump seconds=\(seconds) target=\(nextTime) mode=direct") + } +#endif + } mediaPlayer.time = VLCTime(int: Int32(nextTime * 1000)) #endif } @@ -165,6 +230,8 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { func stop() { #if canImport(MobileVLCKit) + playbackStartupTask?.cancel() + rangeCacheSession = nil mediaPlayer.stop() mediaPlayer.drawable = nil mediaPlayer.media = nil @@ -269,6 +336,40 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { } #if canImport(MobileVLCKit) + private func startVLCMedia( + url: URL, + request: NativePlaybackRequest, + playbackMode: String, + cachingMilliseconds: Int, + includeRemoteHTTPOptions: Bool + ) { + let media = VLCMedia(url: url) + media.addOption(":network-caching=\(cachingMilliseconds)") + if includeRemoteHTTPOptions { + media.addOption(":http-reconnect") + addRemoteHeaders(to: media, request: request) + } + + mediaPlayer.media = media +#if DEBUG + print("[DreamioVLC] opening mode=\(playbackMode) cachingMs=\(cachingMilliseconds) url=\(URLRedactor.redactedURLString(url.absoluteString))") +#endif + mediaPlayer.play() + } + + private func addRemoteHeaders(to media: VLCMedia, request: NativePlaybackRequest) { + let headerValue = request.headers + .map { "\($0.key): \($0.value)" } + .joined(separator: "\r\n") + media.addOption(":http-referrer=\(request.referer)") + if let userAgent = request.userAgent { + media.addOption(":http-user-agent=\(userAgent)") + } + if !headerValue.isEmpty { + media.addOption(":http-header=\(headerValue)") + } + } + private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int { var attachedCount = 0 var duplicateCount = 0 @@ -430,7 +531,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { func mediaPlayerStateChanged(_ aNotification: Notification) { #if DEBUG - print("[DreamioVLC] state=\(stateName(mediaPlayer.state))") + logPlaybackStateIfNeeded(stateName(mediaPlayer.state)) #endif switch mediaPlayer.state { case .buffering, .playing: @@ -477,5 +578,24 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { return "unknown" } } + +#if DEBUG + private func logPlaybackStateIfNeeded(_ state: String) { + if state == "buffering" { + let now = Date() + if lastLoggedState == state, + let lastBufferingLogTime, + now.timeIntervalSince(lastBufferingLogTime) < 2 { + return + } + lastBufferingLogTime = now + } + + if lastLoggedState != state || state == "buffering" { + print("[DreamioVLC] state=\(state)") + lastLoggedState = state + } + } +#endif } #endif diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index c846579..dfb0e70 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -24,6 +24,12 @@ struct StreamResolverTests { testSubtitleDisplayNameNormalization() testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks() testSubtitleOptionMappingIncludesNone() + testContentRangeParsing() + testSparseRangeStoreMergesOverlaps() + testSparseRangeStoreHitPartialHitAndMiss() + testSparseRangeStoreEvictsOutsideWindow() + await testRangeProbeFallsBackWhenServerIgnoresRange() + await testRangeFetcherPreservesHeaders() print("StreamResolverTests passed") } @@ -267,6 +273,107 @@ struct StreamResolverTests { assert(SubtitleResolver.isDirectSubtitleFile(candidates[0].url), "Expected Stremio subtitle downloads to be attachable without another resolver hop") } + private static func testContentRangeParsing() { + let parsed = HTTPContentRange.parse("bytes 10-19/100") + + assertEqual(parsed?.range.start, 10) + assertEqual(parsed?.range.end, 19) + assertEqual(parsed?.totalLength, 100) + assert(HTTPContentRange.parse("items 10-19/100") == nil, "Expected non-byte content range to be rejected") + assert(HTTPContentRange.parse("bytes 20-10/100") == nil, "Expected invalid content range to be rejected") + } + + private static func testSparseRangeStoreMergesOverlaps() { + let store = SparseHTTPByteRangeStore() + + store.insert(data: Data([0, 1, 2, 3]), at: 0) + store.insert(data: Data([3, 4, 5]), at: 3) + + assertEqual(store.cachedRanges, [HTTPByteRange(start: 0, end: 5)]) + assertEqual(Array(store.data(for: HTTPByteRange(start: 0, end: 5)) ?? Data()), [0, 1, 2, 3, 4, 5]) + } + + private static func testSparseRangeStoreHitPartialHitAndMiss() { + let store = SparseHTTPByteRangeStore() + + store.insert(data: Data([10, 11, 12, 13]), at: 10) + + assertEqual(Array(store.data(for: HTTPByteRange(start: 10, end: 13)) ?? Data()), [10, 11, 12, 13]) + assert(store.data(for: HTTPByteRange(start: 11, end: 14)) == nil, "Expected partial hit to miss") + assert(store.data(for: HTTPByteRange(start: 20, end: 21)) == nil, "Expected uncached range to miss") + } + + private static func testSparseRangeStoreEvictsOutsideWindow() { + let store = SparseHTTPByteRangeStore() + + store.insert(data: Data([0, 1, 2]), at: 0) + store.insert(data: Data([10, 11, 12]), at: 10) + store.evict(keeping: HTTPByteRange(start: 9, end: 12)) + + assertEqual(store.cachedRanges, [HTTPByteRange(start: 10, end: 12)]) + } + + private static func testRangeProbeFallsBackWhenServerIgnoresRange() async { + MockURLProtocol.handler = { request in + if request.httpMethod == "HEAD" { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Length": "4"] + )! + return (Data(), response) + } + assertEqual(request.value(forHTTPHeaderField: "Range"), "bytes=0-0") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Length": "4"] + )! + return (Data([1, 2, 3, 4]), response) + } + + let fetcher = HTTPRangeRemoteFetcher( + url: URL(string: "https://cdn.example.test/movie.mp4")!, + headers: [:], + session: mockSession() + ) + let probe = await fetcher.probe() + + assertEqual(probe.isCacheable, false) + assertEqual(probe.fallbackReason, "range-probe-status-200") + } + + 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") + let response = HTTPURLResponse( + url: request.url!, + statusCode: 206, + httpVersion: nil, + headerFields: ["Content-Range": "bytes 5-7/20"] + )! + return (Data([5, 6, 7]), response) + } + + let fetcher = HTTPRangeRemoteFetcher( + url: URL(string: "https://cdn.example.test/movie.mp4")!, + headers: [ + "User-Agent": "DreamioTest/1", + "Referer": "https://web.stremio.com/", + "Cookie": "session=abc" + ], + session: mockSession() + ) + let data = try? await fetcher.fetch(range: HTTPByteRange(start: 5, end: 7)) + + assertEqual(Array(data ?? Data()), [5, 6, 7]) + } + private static func testOpenSubtitlesV3DownloadResponseResolution() { let payload = """ { @@ -517,6 +624,7 @@ struct StreamResolverTests { } private final class MockURLProtocol: URLProtocol { + static var handler: ((URLRequest) throws -> (Data, HTTPURLResponse))? static var handlers: [String: (status: Int, url: URL, data: Data)] = [:] override class func canInit(with request: URLRequest) -> Bool { @@ -528,6 +636,18 @@ private final class MockURLProtocol: URLProtocol { } override func startLoading() { + if let handler = Self.handler { + do { + let (data, response) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + return + } + guard let url = request.url, let handler = Self.handlers[url.absoluteString], let response = HTTPURLResponse( diff --git a/docs/turns/2026-05-25-vlc-local-range-cache.html b/docs/turns/2026-05-25-vlc-local-range-cache.html new file mode 100644 index 0000000..9edee5d --- /dev/null +++ b/docs/turns/2026-05-25-vlc-local-range-cache.html @@ -0,0 +1,296 @@ + + + + + +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
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

  • 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.
+
+ + \ No newline at end of file