diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 32e8e02..54832af 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -38,3 +38,4 @@ {"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."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 58596f1..057e22e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -24,6 +24,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..b76e932 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 /* NativeStreamCacheProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B532C00100100DREAMIO /* NativeStreamCacheProxy.swift */; }; + B6C42C187A771A50D200AD84 /* 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 /* NativeStreamCacheProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeStreamCacheProxy.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 = ( + B6C42C187A771A50D200AD84 /* Pods_Dreamio.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -91,6 +94,7 @@ 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */, 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */, 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */, + 6F2A2B532C00100100DREAMIO /* NativeStreamCacheProxy.swift */, 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */, 6F2A2B392C00100100DREAMIO /* Info.plist */, ); @@ -237,6 +241,7 @@ 6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */, 6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */, 6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */, + 6F2A2B522C00100100DREAMIO /* NativeStreamCacheProxy.swift in Sources */, 6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index 1679f1f..c333cd7 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -11,6 +11,7 @@ final class NativePlayerViewController: UIViewController { private var attachedSubtitleURLs: Set private var audioMenuSignature: String? private var captionsMenuSignature: String? + private var streamCacheProxy: NativeStreamCacheProxy? var onDismiss: (() -> Void)? private let loadingView: UIActivityIndicatorView = { @@ -137,7 +138,8 @@ final class NativePlayerViewController: UIViewController { configureBackend() configureLayout() startStartupTimer() - backend.play(request: request) + let playbackRequest = startProxyPlaybackRequest(for: request) + backend.play(request: playbackRequest) addSubtitleCandidates(request.subtitleCandidates) } @@ -192,9 +194,28 @@ final class NativePlayerViewController: UIViewController { controlsTimer?.invalidate() progressTimer?.invalidate() backend.stop() + streamCacheProxy?.stop() + streamCacheProxy = nil onDismiss?() } + private func startProxyPlaybackRequest(for request: NativePlaybackRequest) -> NativePlaybackRequest { + guard request.playbackURL.scheme?.lowercased().hasPrefix("http") == true else { + return request + } + let proxy = NativeStreamCacheProxy(session: NativeStreamCacheProxy.Session(request: request)) + do { + let proxyURL = try proxy.start() + streamCacheProxy = proxy + return request.withPlaybackURL(proxyURL) + } catch { +#if DEBUG + print("[DreamioStreamProxy] start-failed error=\(error.localizedDescription)") +#endif + return request + } + } + private func resolveSubtitleCandidates(_ candidates: [SubtitleCandidate]) async -> [SubtitleCandidate] { var resolved: [SubtitleCandidate] = [] for candidate in candidates { diff --git a/Dreamio/NativeStreamCacheProxy.swift b/Dreamio/NativeStreamCacheProxy.swift new file mode 100644 index 0000000..9200a71 --- /dev/null +++ b/Dreamio/NativeStreamCacheProxy.swift @@ -0,0 +1,399 @@ +import Foundation +import Network + +struct HTTPRange: Equatable { + let start: Int64 + let end: Int64? + + var headerValue: String { + if let end { + return "bytes=\(start)-\(end)" + } + return "bytes=\(start)-" + } + + var length: Int64? { + guard let end else { + return nil + } + return max(0, end - start + 1) + } + + static func parse(_ value: String?) -> HTTPRange? { + guard let value else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.lowercased().hasPrefix("bytes=") else { + return nil + } + let spec = String(trimmed.dropFirst(6)) + guard !spec.contains(","), let dash = spec.firstIndex(of: "-") else { + return nil + } + let startPart = spec[..= 0 else { + return nil + } + if endPart.isEmpty { + return HTTPRange(start: start, end: nil) + } + guard let end = Int64(endPart), end >= start else { + return nil + } + return HTTPRange(start: start, end: end) + } + + static func contentRange(start: Int64, end: Int64, totalLength: Int64?) -> String { + let total = totalLength.map(String.init) ?? "*" + return "bytes \(start)-\(end)/\(total)" + } +} + +final class CachedRangeStore { + struct Lookup { + let data: Data + let isComplete: Bool + } + + private struct Chunk { + let start: Int64 + let end: Int64 + let fileURL: URL + var lastAccess: Date + } + + private let directory: URL + private let byteBudget: Int64 + private let fileManager: FileManager + private var chunks: [Chunk] = [] + + init(sessionID: String, byteBudget: Int64, fileManager: FileManager = .default) { + self.byteBudget = byteBudget + self.fileManager = fileManager + directory = fileManager.temporaryDirectory + .appendingPathComponent("DreamioNativeStreamCache", isDirectory: true) + .appendingPathComponent(sessionID, isDirectory: true) + try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + } + + func lookup(range: HTTPRange, maximumLength: Int64) -> Lookup? { + let requestedEnd = range.end ?? range.start + maximumLength - 1 + var cursor = range.start + var data = Data() + var touchedIndexes = Set() + + for (index, chunk) in chunks.sorted(by: { $0.start < $1.start }).enumerated() { + guard chunk.end >= cursor, chunk.start <= cursor else { + continue + } + let readStart = max(cursor, chunk.start) + let readEnd = min(requestedEnd, chunk.end) + guard readEnd >= readStart, + let chunkData = try? Data(contentsOf: chunk.fileURL) else { + continue + } + let lower = Int(readStart - chunk.start) + let upper = Int(readEnd - chunk.start + 1) + data.append(chunkData.subdata(in: lower.. requestedEnd { + break + } + } + + guard !data.isEmpty else { + return nil + } + let now = Date() + for index in touchedIndexes where chunks.indices.contains(index) { + chunks[index].lastAccess = now + } + return Lookup(data: data, isComplete: cursor > requestedEnd) + } + + func store(data: Data, start: Int64) { + guard !data.isEmpty else { + return + } + let end = start + Int64(data.count) - 1 + let fileURL = directory.appendingPathComponent("\(start)-\(end).chunk") + do { + try data.write(to: fileURL, options: .atomic) + chunks.append(Chunk(start: start, end: end, fileURL: fileURL, lastAccess: Date())) + chunks.sort { $0.start < $1.start } + evictIfNeeded() + } catch { +#if DEBUG + print("[DreamioStreamProxy] cache-store-failed range=\(start)-\(end) error=\(error.localizedDescription)") +#endif + } + } + + func evictKeepingBytesNear(offset: Int64) { + let lowerBound = max(0, offset - byteBudget) + let removed = chunks.filter { $0.end < lowerBound } + chunks.removeAll { $0.end < lowerBound } + removed.forEach { try? fileManager.removeItem(at: $0.fileURL) } +#if DEBUG + if !removed.isEmpty { + print("[DreamioStreamProxy] eviction removed=\(removed.count) lowerBound=\(lowerBound)") + } +#endif + evictIfNeeded() + } + + func removeAll() { + try? fileManager.removeItem(at: directory) + chunks.removeAll() + } + + private func evictIfNeeded() { + var total = chunks.reduce(Int64(0)) { $0 + ($1.end - $1.start + 1) } + guard total > byteBudget else { + return + } + for chunk in chunks.sorted(by: { $0.lastAccess < $1.lastAccess }) { + guard total > byteBudget else { + break + } + try? fileManager.removeItem(at: chunk.fileURL) + chunks.removeAll { $0.fileURL == chunk.fileURL } + total -= chunk.end - chunk.start + 1 +#if DEBUG + print("[DreamioStreamProxy] eviction range=\(chunk.start)-\(chunk.end) remainingBytes=\(total)") +#endif + } + } +} + +final class NativeStreamCacheProxy { + struct Session { + let id: String + let upstreamURL: URL + let headers: [String: String] + let referer: String + let userAgent: String? + let estimatedBitrate: Int64? + + init(request: NativePlaybackRequest) { + id = UUID().uuidString + upstreamURL = request.playbackURL + headers = request.headers + referer = request.referer + userAgent = request.userAgent + estimatedBitrate = nil + } + } + + private struct UpstreamResponse { + let statusCode: Int + let headers: [AnyHashable: Any] + let data: Data + } + + private let session: Session + private let store: CachedRangeStore + private let fetchLength: Int64 + private let prefetchLength: Int64 + private var listener: NWListener? + private let queue = DispatchQueue(label: "dreamio.native-stream-cache-proxy") + + init(session: Session, byteBudget: Int64? = nil, fetchLength: Int64 = 8 * 1024 * 1024) { + self.session = session + self.fetchLength = fetchLength + prefetchLength = fetchLength + let budget = byteBudget ?? max(30 * 1024 * 1024, (session.estimatedBitrate ?? 0) * 30 / 8) + store = CachedRangeStore(sessionID: session.id, byteBudget: budget) + } + + func start() throws -> URL { + let listener = try NWListener(using: .tcp, on: .any) + self.listener = listener + listener.newConnectionHandler = { [weak self] connection in + self?.handle(connection: connection) + } + listener.start(queue: queue) + guard let port = listener.port else { + throw URLError(.cannotConnectToHost) + } +#if DEBUG + print("[DreamioStreamProxy] start port=\(port.rawValue) upstream=\(URLRedactor.redactedURLString(session.upstreamURL.absoluteString))") +#endif + return URL(string: "http://127.0.0.1:\(port.rawValue)/stream/\(session.id)")! + } + + func stop() { +#if DEBUG + print("[DreamioStreamProxy] stop session=\(session.id)") +#endif + listener?.cancel() + listener = nil + store.removeAll() + } + + func upstreamRequest(for range: HTTPRange?) -> URLRequest { + var request = URLRequest(url: session.upstreamURL) + for (key, value) in session.headers { + request.setValue(value, forHTTPHeaderField: key) + } + request.setValue(session.referer, forHTTPHeaderField: "Referer") + if let userAgent = session.userAgent { + request.setValue(userAgent, forHTTPHeaderField: "User-Agent") + } + if let range { + request.setValue(range.headerValue, forHTTPHeaderField: "Range") + } + return request + } + + static func responseStatusForUpstreamStatus(_ statusCode: Int) -> Int { + statusCode == 206 ? 206 : statusCode + } + + private func handle(connection: NWConnection) { + connection.start(queue: queue) + connection.receive(minimumIncompleteLength: 1, maximumLength: 16 * 1024) { [weak self] data, _, _, _ in + guard let self, let data, let request = HTTPRequest(data: data) else { + connection.cancel() + return + } + let range = HTTPRange.parse(request.headers["range"]) + self.respond(to: range, on: connection) + } + } + + private func respond(to requestedRange: HTTPRange?, on connection: NWConnection) { + let range = requestedRange ?? HTTPRange(start: 0, end: fetchLength - 1) + let maximumLength = range.length ?? fetchLength + if let lookup = store.lookup(range: range, maximumLength: maximumLength), lookup.isComplete { +#if DEBUG + print("[DreamioStreamProxy] range-hit range=\(range.headerValue) bytes=\(lookup.data.count)") +#endif + send(data: lookup.data, statusCode: 206, rangeStart: range.start, totalLength: nil, contentType: nil, on: connection) + prefetch(after: range.start + Int64(lookup.data.count)) + return + } + +#if DEBUG + print("[DreamioStreamProxy] range-miss range=\(range.headerValue)") +#endif + let fetchRange = HTTPRange(start: range.start, end: range.start + maximumLength - 1) + guard let response = fetch(range: fetchRange) else { + sendStatus(502, on: connection) + return + } +#if DEBUG + print("[DreamioStreamProxy] upstream status=\(response.statusCode) range=\(fetchRange.headerValue)") +#endif + let responseStatus = Self.responseStatusForUpstreamStatus(response.statusCode) + if responseStatus == 206 { + store.store(data: response.data, start: range.start) + store.evictKeepingBytesNear(offset: range.start) + let contentType = response.headers["Content-Type"] as? String + let totalLength = totalLength(from: response.headers["Content-Range"] as? String) + send(data: response.data, statusCode: 206, rangeStart: range.start, totalLength: totalLength, contentType: contentType, on: connection) + prefetch(after: range.start + Int64(response.data.count)) + } else { +#if DEBUG + print("[DreamioStreamProxy] pass-through fallback reason=upstream-returned-\(response.statusCode)") +#endif + send(data: response.data, statusCode: response.statusCode, headers: response.headers, on: connection) + } + } + + private func prefetch(after offset: Int64) { + let range = HTTPRange(start: offset, end: offset + prefetchLength - 1) + queue.async { [weak self] in + guard let self, self.store.lookup(range: range, maximumLength: self.prefetchLength)?.isComplete != true else { + return + } + guard let response = self.fetch(range: range), response.statusCode == 206 else { + return + } + self.store.store(data: response.data, start: offset) + } + } + + private func fetch(range: HTTPRange) -> UpstreamResponse? { + let semaphore = DispatchSemaphore(value: 0) + var result: UpstreamResponse? + URLSession.shared.dataTask(with: upstreamRequest(for: range)) { data, response, _ in + if let http = response as? HTTPURLResponse, let data { + result = UpstreamResponse(statusCode: http.statusCode, headers: http.allHeaderFields, data: data) + } + semaphore.signal() + }.resume() + semaphore.wait() + return result + } + + private func send(data: Data, statusCode: Int, rangeStart: Int64, totalLength: Int64?, contentType: String?, on connection: NWConnection) { + let end = rangeStart + Int64(data.count) - 1 + var headers = [ + "Content-Length": "\(data.count)", + "Content-Range": HTTPRange.contentRange(start: rangeStart, end: end, totalLength: totalLength), + "Accept-Ranges": "bytes", + "Connection": "close" + ] + if let contentType { + headers["Content-Type"] = contentType + } + send(statusCode: statusCode, headers: headers, body: data, on: connection) + } + + private func send(data: Data, statusCode: Int, headers upstreamHeaders: [AnyHashable: Any], on connection: NWConnection) { + var headers = [ + "Content-Length": "\(data.count)", + "Accept-Ranges": "none", + "Connection": "close" + ] + if let contentType = upstreamHeaders["Content-Type"] as? String { + headers["Content-Type"] = contentType + } + send(statusCode: statusCode, headers: headers, body: data, on: connection) + } + + private func sendStatus(_ statusCode: Int, on connection: NWConnection) { + send(statusCode: statusCode, headers: ["Content-Length": "0", "Connection": "close"], body: Data(), on: connection) + } + + private func send(statusCode: Int, headers: [String: String], body: Data, on connection: NWConnection) { + let reason = statusCode == 206 ? "Partial Content" : statusCode == 200 ? "OK" : "Bad Gateway" + let headerLines = headers.map { "\($0.key): \($0.value)" }.joined(separator: "\r\n") + var response = Data("HTTP/1.1 \(statusCode) \(reason)\r\n\(headerLines)\r\n\r\n".utf8) + response.append(body) + connection.send(content: response, completion: .contentProcessed { _ in connection.cancel() }) + } + + private func totalLength(from contentRange: String?) -> Int64? { + guard let contentRange, let slash = contentRange.lastIndex(of: "/") else { + return nil + } + let value = contentRange[contentRange.index(after: slash)...] + return Int64(value) + } +} + +private struct HTTPRequest { + let headers: [String: String] + + init?(data: Data) { + guard let string = String(data: data, encoding: .utf8), + let headerBlock = string.components(separatedBy: "\r\n\r\n").first else { + return nil + } + var headers: [String: String] = [:] + for line in headerBlock.components(separatedBy: "\r\n").dropFirst() { + guard let separator = line.firstIndex(of: ":") else { + continue + } + let key = line[.. NativePlaybackRequest { + NativePlaybackRequest( + playbackURL: playbackURL, + observedURL: observedURL, + resolverURL: resolverURL, + pageURL: pageURL, + userAgent: userAgent, + referer: referer, + headers: headers, + classification: classification, + subtitleCandidates: subtitleCandidates + ) + } } struct SubtitleCandidate: Equatable { diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index c846579..2549bac 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -24,6 +24,12 @@ struct StreamResolverTests { testSubtitleDisplayNameNormalization() testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks() testSubtitleOptionMappingIncludesNone() + testHTTPRangeParsing() + testContentRangeFormatting() + testCacheLookupAcrossChunkBoundaries() + testCacheEvictionOutsideByteBudget() + testProxyForwardsUpstreamHeaders() + testProxyPassThroughFallbackStatus() print("StreamResolverTests passed") } @@ -40,6 +46,85 @@ struct StreamResolverTests { assertEqual(request.headers["User-Agent"], "DreamioTest/1") } + private static func testHTTPRangeParsing() { + assertEqual(HTTPRange.parse("bytes=10-20"), HTTPRange(start: 10, end: 20)) + assertEqual(HTTPRange.parse("bytes=10-"), HTTPRange(start: 10, end: nil)) + assert(HTTPRange.parse("items=10-20") == nil, "Expected non-byte range to be rejected") + assert(HTTPRange.parse("bytes=-20") == nil, "Expected suffix ranges to be rejected for v1") + assert(HTTPRange.parse("bytes=20-10") == nil, "Expected inverted ranges to be rejected") + assert(HTTPRange.parse("bytes=1-2,3-4") == nil, "Expected multipart ranges to be rejected") + } + + private static func testContentRangeFormatting() { + assertEqual(HTTPRange.contentRange(start: 10, end: 20, totalLength: 100), "bytes 10-20/100") + assertEqual(HTTPRange.contentRange(start: 10, end: 20, totalLength: nil), "bytes 10-20/*") + } + + private static func testCacheLookupAcrossChunkBoundaries() { + let store = CachedRangeStore(sessionID: "test-\(UUID().uuidString)", byteBudget: 1024) + defer { store.removeAll() } + store.store(data: Data("abc".utf8), start: 0) + store.store(data: Data("def".utf8), start: 3) + + let lookup = store.lookup(range: HTTPRange(start: 0, end: 5), maximumLength: 6) + + assertEqual(String(data: lookup?.data ?? Data(), encoding: .utf8), "abcdef") + assertEqual(lookup?.isComplete, true) + } + + private static func testCacheEvictionOutsideByteBudget() { + let store = CachedRangeStore(sessionID: "test-\(UUID().uuidString)", byteBudget: 6) + defer { store.removeAll() } + store.store(data: Data("abcdef".utf8), start: 0) + store.store(data: Data("ghijkl".utf8), start: 6) + + let oldLookup = store.lookup(range: HTTPRange(start: 0, end: 5), maximumLength: 6) + let newLookup = store.lookup(range: HTTPRange(start: 6, end: 11), maximumLength: 6) + + assert(oldLookup == nil, "Expected old chunk to be evicted outside the byte budget") + assertEqual(String(data: newLookup?.data ?? Data(), encoding: .utf8), "ghijkl") + } + + private static func testProxyForwardsUpstreamHeaders() { + let proxy = NativeStreamCacheProxy(session: NativeStreamCacheProxy.Session(request: proxyTestRequest())) + let upstreamRequest = proxy.upstreamRequest(for: HTTPRange(start: 12, end: 34)) + + assertEqual(upstreamRequest.value(forHTTPHeaderField: "Range"), "bytes=12-34") + assertEqual(upstreamRequest.value(forHTTPHeaderField: "Referer"), "https://resolver.example.test/") + assertEqual(upstreamRequest.value(forHTTPHeaderField: "User-Agent"), "DreamioTest/1") + assertEqual(upstreamRequest.value(forHTTPHeaderField: "Authorization"), "Bearer secret") + } + + private static func testProxyPassThroughFallbackStatus() { + assertEqual(NativeStreamCacheProxy.responseStatusForUpstreamStatus(206), 206) + assertEqual(NativeStreamCacheProxy.responseStatusForUpstreamStatus(200), 200) + } + + private static func proxyTestRequest() -> NativePlaybackRequest { + NativePlaybackRequest( + playbackURL: URL(string: "https://cdn.example.test/movie.mkv")!, + observedURL: URL(string: "https://cdn.example.test/movie.mkv")!, + resolverURL: URL(string: "https://resolver.example.test/play")!, + pageURL: nil, + userAgent: "DreamioTest/1", + referer: "https://resolver.example.test/", + headers: [ + "Referer": "https://resolver.example.test/", + "User-Agent": "DreamioTest/1", + "Authorization": "Bearer secret" + ], + classification: StreamClassification( + sourceKind: .directFile, + containerGuess: .mkv, + reason: "test", + shouldIntercept: true, + sanitizedObservedURL: "https://cdn.example.test/movie.mkv", + sanitizedResolverURL: nil + ), + subtitleCandidates: [] + ) + } + private static func testResolverSelectsUnsupportedDirectURLAndHeaders() { let payload: [String: Any] = [ "streams": [ diff --git a/docs/turns/2026-05-25-local-seek-buffer-vlc-playback.html b/docs/turns/2026-05-25-local-seek-buffer-vlc-playback.html new file mode 100644 index 0000000..5ed8643 --- /dev/null +++ b/docs/turns/2026-05-25-local-seek-buffer-vlc-playback.html @@ -0,0 +1,321 @@ + + + + + +Local Seek Buffer for VLC Playback + + + +
+
+
Dreamio turn document ยท May 25, 2026
+

Local Seek Buffer for VLC Playback

+

Added a local HTTP range proxy/cache for native VLC playback so direct-file streams can reuse recently fetched bytes for short rewinds and opportunistically warm nearby forward ranges.

+
+

Summary

Dreamio now starts a per-playback loopback proxy before handing a direct HTTP stream to VLC. VLC receives a localhost URL, while the proxy forwards authenticated byte-range requests upstream, stores bounded temporary chunks, serves complete range hits locally, and cleans up the cache when the native player dismisses.

+

Changes Made

  • Added NativeStreamCacheProxy, NativeStreamCacheProxy.Session, CachedRangeStore, and HTTPRange.
  • Started the proxy from NativePlayerViewController before backend.play(request:) and replaced only playbackURL with the local stream URL.
  • Kept subtitle candidates and subtitle attachment untouched, so existing external subtitle URLs still flow directly to VLC.
  • Added NativePlaybackRequest.withPlaybackURL(_:) to derive the VLC-facing request without rebuilding headers or metadata by hand.
  • Added test coverage for range parsing, content range formatting, chunk lookup, eviction, forwarded headers, and fallback status behavior.
  • Registered the new Swift source file in the Xcode project.
+

Context

VLC was previously opened directly on debrid or direct-file URLs. That means small seeks depended entirely on VLC and the upstream server. The new proxy gives Dreamio a narrow local buffer for HTTP/HTTPS direct-file streams without changing Stremio interception or subtitle discovery contracts.

+

Important Implementation Details

  • The proxy listens on 127.0.0.1 using NWListener and exposes /stream/<session id>.
  • Upstream requests preserve Referer, User-Agent, and all request headers from NativePlaybackRequest, plus the VLC-requested Range.
  • Successful upstream 206 responses are stored as temp chunk files under FileManager.default.temporaryDirectory/DreamioNativeStreamCache/<session id>.
  • Complete cache hits return 206 Partial Content with Content-Range, Content-Length, and Accept-Ranges.
  • If upstream returns a non-range status such as 200, the proxy passes that body/status back and logs that instant nearby seek cannot be guaranteed.
  • The current v1 uses a conservative byte budget fallback and fixed range windows. Bitrate-derived sizing is scaffolded through the session but not yet estimated from media metadata.
+

Relevant Diff Snippets

Dreamio/NativePlayerViewController.swift

Dreamio/NativePlayerViewController.swift
-1+22
10 unmodified lines
11
12
13
14
15
16
120 unmodified lines
137
138
139
140
141
142
143
48 unmodified lines
192
193
194
195
196
197
198
199
200
10 unmodified lines
private var attachedSubtitleURLs: Set<URL>
private var audioMenuSignature: String?
private var captionsMenuSignature: String?
var onDismiss: (() -> Void)?
+
private let loadingView: UIActivityIndicatorView = {
120 unmodified lines
configureBackend()
configureLayout()
startStartupTimer()
backend.play(request: request)
addSubtitleCandidates(request.subtitleCandidates)
}
+
48 unmodified lines
controlsTimer?.invalidate()
progressTimer?.invalidate()
backend.stop()
onDismiss?()
}
+
private func resolveSubtitleCandidates(_ candidates: [SubtitleCandidate]) async -> [SubtitleCandidate] {
var resolved: [SubtitleCandidate] = []
for candidate in candidates {
10 unmodified lines
11
12
13
14
15
16
17
120 unmodified lines
138
139
140
141
142
143
144
145
48 unmodified lines
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
10 unmodified lines
private var attachedSubtitleURLs: Set<URL>
private var audioMenuSignature: String?
private var captionsMenuSignature: String?
private var streamCacheProxy: NativeStreamCacheProxy?
var onDismiss: (() -> Void)?
+
private let loadingView: UIActivityIndicatorView = {
120 unmodified lines
configureBackend()
configureLayout()
startStartupTimer()
let playbackRequest = startProxyPlaybackRequest(for: request)
backend.play(request: playbackRequest)
addSubtitleCandidates(request.subtitleCandidates)
}
+
48 unmodified lines
controlsTimer?.invalidate()
progressTimer?.invalidate()
backend.stop()
streamCacheProxy?.stop()
streamCacheProxy = nil
onDismiss?()
}
+
private func startProxyPlaybackRequest(for request: NativePlaybackRequest) -> NativePlaybackRequest {
guard request.playbackURL.scheme?.lowercased().hasPrefix("http") == true else {
return request
}
let proxy = NativeStreamCacheProxy(session: NativeStreamCacheProxy.Session(request: request))
do {
let proxyURL = try proxy.start()
streamCacheProxy = proxy
return request.withPlaybackURL(proxyURL)
} catch {
#if DEBUG
print("[DreamioStreamProxy] start-failed error=\(error.localizedDescription)")
#endif
return request
}
}
+
private func resolveSubtitleCandidates(_ candidates: [SubtitleCandidate]) async -> [SubtitleCandidate] {
var resolved: [SubtitleCandidate] = []
for candidate in candidates {

Dreamio/StreamCandidate.swift

Dreamio/StreamCandidate.swift
+14
26 unmodified lines
27
28
29
30
31
32
26 unmodified lines
let headers: [String: String]
let classification: StreamClassification
let subtitleCandidates: [SubtitleCandidate]
}
+
struct SubtitleCandidate: Equatable {
26 unmodified lines
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
26 unmodified lines
let headers: [String: String]
let classification: StreamClassification
let subtitleCandidates: [SubtitleCandidate]
+
func withPlaybackURL(_ playbackURL: URL) -> NativePlaybackRequest {
NativePlaybackRequest(
playbackURL: playbackURL,
observedURL: observedURL,
resolverURL: resolverURL,
pageURL: pageURL,
userAgent: userAgent,
referer: referer,
headers: headers,
classification: classification,
subtitleCandidates: subtitleCandidates
)
}
}
+
struct SubtitleCandidate: Equatable {

Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+85
23 unmodified lines
24
25
26
27
28
29
10 unmodified lines
40
41
42
43
44
45
23 unmodified lines
testSubtitleDisplayNameNormalization()
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed")
}
+
10 unmodified lines
assertEqual(request.headers["User-Agent"], "DreamioTest/1")
}
+
private static func testResolverSelectsUnsupportedDirectURLAndHeaders() {
let payload: [String: Any] = [
"streams": [
23 unmodified lines
24
25
26
27
28
29
30
31
32
33
34
35
10 unmodified lines
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
23 unmodified lines
testSubtitleDisplayNameNormalization()
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
testSubtitleOptionMappingIncludesNone()
testHTTPRangeParsing()
testContentRangeFormatting()
testCacheLookupAcrossChunkBoundaries()
testCacheEvictionOutsideByteBudget()
testProxyForwardsUpstreamHeaders()
testProxyPassThroughFallbackStatus()
print("StreamResolverTests passed")
}
+
10 unmodified lines
assertEqual(request.headers["User-Agent"], "DreamioTest/1")
}
+
private static func testHTTPRangeParsing() {
assertEqual(HTTPRange.parse("bytes=10-20"), HTTPRange(start: 10, end: 20))
assertEqual(HTTPRange.parse("bytes=10-"), HTTPRange(start: 10, end: nil))
assert(HTTPRange.parse("items=10-20") == nil, "Expected non-byte range to be rejected")
assert(HTTPRange.parse("bytes=-20") == nil, "Expected suffix ranges to be rejected for v1")
assert(HTTPRange.parse("bytes=20-10") == nil, "Expected inverted ranges to be rejected")
assert(HTTPRange.parse("bytes=1-2,3-4") == nil, "Expected multipart ranges to be rejected")
}
+
private static func testContentRangeFormatting() {
assertEqual(HTTPRange.contentRange(start: 10, end: 20, totalLength: 100), "bytes 10-20/100")
assertEqual(HTTPRange.contentRange(start: 10, end: 20, totalLength: nil), "bytes 10-20/*")
}
+
private static func testCacheLookupAcrossChunkBoundaries() {
let store = CachedRangeStore(sessionID: "test-\(UUID().uuidString)", byteBudget: 1024)
defer { store.removeAll() }
store.store(data: Data("abc".utf8), start: 0)
store.store(data: Data("def".utf8), start: 3)
+
let lookup = store.lookup(range: HTTPRange(start: 0, end: 5), maximumLength: 6)
+
assertEqual(String(data: lookup?.data ?? Data(), encoding: .utf8), "abcdef")
assertEqual(lookup?.isComplete, true)
}
+
private static func testCacheEvictionOutsideByteBudget() {
let store = CachedRangeStore(sessionID: "test-\(UUID().uuidString)", byteBudget: 6)
defer { store.removeAll() }
store.store(data: Data("abcdef".utf8), start: 0)
store.store(data: Data("ghijkl".utf8), start: 6)
+
let oldLookup = store.lookup(range: HTTPRange(start: 0, end: 5), maximumLength: 6)
let newLookup = store.lookup(range: HTTPRange(start: 6, end: 11), maximumLength: 6)
+
assert(oldLookup == nil, "Expected old chunk to be evicted outside the byte budget")
assertEqual(String(data: newLookup?.data ?? Data(), encoding: .utf8), "ghijkl")
}
+
private static func testProxyForwardsUpstreamHeaders() {
let proxy = NativeStreamCacheProxy(session: NativeStreamCacheProxy.Session(request: proxyTestRequest()))
let upstreamRequest = proxy.upstreamRequest(for: HTTPRange(start: 12, end: 34))
+
assertEqual(upstreamRequest.value(forHTTPHeaderField: "Range"), "bytes=12-34")
assertEqual(upstreamRequest.value(forHTTPHeaderField: "Referer"), "https://resolver.example.test/")
assertEqual(upstreamRequest.value(forHTTPHeaderField: "User-Agent"), "DreamioTest/1")
assertEqual(upstreamRequest.value(forHTTPHeaderField: "Authorization"), "Bearer secret")
}
+
private static func testProxyPassThroughFallbackStatus() {
assertEqual(NativeStreamCacheProxy.responseStatusForUpstreamStatus(206), 206)
assertEqual(NativeStreamCacheProxy.responseStatusForUpstreamStatus(200), 200)
}
+
private static func proxyTestRequest() -> NativePlaybackRequest {
NativePlaybackRequest(
playbackURL: URL(string: "https://cdn.example.test/movie.mkv")!,
observedURL: URL(string: "https://cdn.example.test/movie.mkv")!,
resolverURL: URL(string: "https://resolver.example.test/play")!,
pageURL: nil,
userAgent: "DreamioTest/1",
referer: "https://resolver.example.test/",
headers: [
"Referer": "https://resolver.example.test/",
"User-Agent": "DreamioTest/1",
"Authorization": "Bearer secret"
],
classification: StreamClassification(
sourceKind: .directFile,
containerGuess: .mkv,
reason: "test",
shouldIntercept: true,
sanitizedObservedURL: "https://cdn.example.test/movie.mkv",
sanitizedResolverURL: nil
),
subtitleCandidates: []
)
}
+
private static func testResolverSelectsUnsupportedDirectURLAndHeaders() {
let payload: [String: Any] = [
"streams": [

Dreamio.xcodeproj/project.pbxproj

Dreamio.xcodeproj/project.pbxproj
-1+6
14 unmodified lines
15
16
17
18
19
20
21
7 unmodified lines
29
30
31
32
33
34
4 unmodified lines
39
40
41
42
43
44
46 unmodified lines
91
92
93
94
95
96
140 unmodified lines
237
238
239
240
241
242
14 unmodified lines
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 */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
7 unmodified lines
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCNativePlaybackBackend.swift; sourceTree = "<group>"; };
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = "<group>"; };
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.swift; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
4 unmodified lines
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
46 unmodified lines
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,
6F2A2B392C00100100DREAMIO /* Info.plist */,
);
140 unmodified lines
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */,
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */,
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */,
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
14 unmodified lines
15
16
17
18
19
20
21
22
7 unmodified lines
30
31
32
33
34
35
36
4 unmodified lines
41
42
43
44
45
46
47
46 unmodified lines
94
95
96
97
98
99
100
140 unmodified lines
241
242
243
244
245
246
247
14 unmodified lines
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 */; };
6F2A2B522C00100100DREAMIO /* NativeStreamCacheProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B532C00100100DREAMIO /* NativeStreamCacheProxy.swift */; };
B6C42C187A771A50D200AD84 /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
7 unmodified lines
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCNativePlaybackBackend.swift; sourceTree = "<group>"; };
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = "<group>"; };
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.swift; sourceTree = "<group>"; };
6F2A2B532C00100100DREAMIO /* NativeStreamCacheProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeStreamCacheProxy.swift; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
4 unmodified lines
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
B6C42C187A771A50D200AD84 /* Pods_Dreamio.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
46 unmodified lines
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,
6F2A2B532C00100100DREAMIO /* NativeStreamCacheProxy.swift */,
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,
6F2A2B392C00100100DREAMIO /* Info.plist */,
);
140 unmodified lines
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */,
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */,
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */,
6F2A2B522C00100100DREAMIO /* NativeStreamCacheProxy.swift in Sources */,
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
+

Expected Impact for End-Users

Short backward jumps should be more likely to resume quickly once playback has warmed the rolling cache. Short forward jumps can benefit from read-ahead when upstream speed allows. Seeking outside the retained window still falls back to upstream range fetching instead of wedging the player.

+

Validation

  • Passed: swiftc -parse-as-library Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/NativeStreamCacheProxy.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-resolver-tests && /tmp/dreamio-stream-resolver-tests.
  • Passed after dependency setup: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build.
  • Setup note: the first build failed because local CocoaPods support files were missing. I ran pod install, which restored untracked local Pods/ contents without changing tracked project files, then reran the build successfully.
  • Not performed: manual device validation with a real debrid stream was not available in this environment.
+

Issues, Limitations, and Mitigations

  • v1 targets direct-file HTTP/HTTPS streams, not HLS playlists.
  • The proxy only guarantees bytes it has actually fetched and retained. It cannot instantly serve unwatched earlier offsets.
  • Forward read-ahead is best effort and bounded; slow upstream servers can still buffer.
  • Upstream servers that ignore byte ranges fall back to pass-through, with debug logging to explain why local seeks are not guaranteed.
  • The current listener implementation is intentionally small and should be stress-tested on device before widening the cache window or range concurrency.
+

Follow-up Work

  • Create a device validation issue for real debrid MKV/MP4 seek behavior across short back, short forward, and far seeks.
  • Estimate bitrate from duration/content length when available so the default cache window can more closely mean 30 seconds of media.
  • Stream upstream range responses incrementally instead of buffering the whole fetched slice before replying.
  • Add structured DEBUG counters for hit ratio, evictions, and upstream range latency.
+
+ + \ No newline at end of file