diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 0ec7fb2..6169034 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -4,3 +4,4 @@ {"id":"int-a86e17e0","kind":"field_change","created_at":"2026-05-25T02:34:54.605755Z","actor":"dirtydishes","issue_id":"dreamio-evt","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented debug-only WKWebView inspection, token-safe playback diagnostics, navigation logging, validation build, and turn documentation."}} {"id":"int-4d73c126","kind":"field_change","created_at":"2026-05-25T03:20:17.439589Z","actor":"dirtydishes","issue_id":"dreamio-l68","extra":{"field":"status","new_value":"closed","old_value":"in_progress","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."}} {"id":"int-3dbe205a","kind":"field_change","created_at":"2026-05-25T03:23:00.515861Z","actor":"dirtydishes","issue_id":"dreamio-2lp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed Swift raw string escaping and guarded MobileVLCKit import for builds before pod install."}} +{"id":"int-23df9e14","kind":"field_change","created_at":"2026-05-25T03:41:03.811099Z","actor":"dirtydishes","issue_id":"dreamio-vxs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Resolved native playback stream URLs before opening VLC, added resolver selection tests, and documented validation limits."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1c6ced4..a8d45a0 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_type":"issue","id":"dreamio-vxs","title":"Resolve final media URLs before native playback","description":"Dreamio native playback can pass addon resolver URLs into VLC instead of the final direct media URL. Resolve known Stremio addon stream responses before presenting the native player, preserve needed headers, and make startup failure recoverable.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:36:14Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:41:04Z","started_at":"2026-05-25T03:36:19Z","closed_at":"2026-05-25T03:41:04Z","close_reason":"Resolved native playback stream URLs before opening VLC, added resolver selection tests, and documented validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-2lp","title":"Fix native playback build blockers","description":"Correct Swift string escaping for the injected stream bridge and allow the VLC backend source to compile before MobileVLCKit is installed by guarding the import with canImport.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:22:52Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:23:00Z","started_at":"2026-05-25T03:23:00Z","closed_at":"2026-05-25T03:23:00Z","close_reason":"Fixed Swift raw string escaping and guarded MobileVLCKit import for builds before pod install.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_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} diff --git a/Dreamio.xcodeproj/project.pbxproj b/Dreamio.xcodeproj/project.pbxproj index 6736b6b..37a31d6 100644 --- a/Dreamio.xcodeproj/project.pbxproj +++ b/Dreamio.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */; }; 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 */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -26,6 +27,7 @@ 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlaybackBackend.swift; sourceTree = ""; }; 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -62,6 +64,7 @@ 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */, 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */, 6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */, + 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */, 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */, 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */, 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */, @@ -142,6 +145,7 @@ 6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */, 6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */, 6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */, + 6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */, 6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */, 6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */, 6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */, diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index e7c8823..6d5399e 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -52,6 +52,7 @@ final class DreamioWebViewController: UIViewController { private var progressObservation: NSKeyValueObservation? private var userAgent: String? private var lastNativePlaybackURL: URL? + private let streamResolver: StreamResolving = StremioStreamResolver() private static let streamCandidateScript = WKUserScript( source: #""" @@ -103,6 +104,7 @@ final class DreamioWebViewController: UIViewController { if (!looksNative(url)) { return; } + stopNativeHandledMedia(element); try { window.webkit.messageHandlers.dreamioStreamCandidate.postMessage({ url, @@ -114,6 +116,23 @@ final class DreamioWebViewController: UIViewController { } catch (_) {} }; + const stopNativeHandledMedia = (element) => { + const media = element instanceof HTMLVideoElement + ? element + : element && element.parentElement instanceof HTMLVideoElement + ? element.parentElement + : null; + if (!media) { + return; + } + try { media.pause(); } catch (_) {} + try { media.removeAttribute("src"); } catch (_) {} + try { + media.querySelectorAll("source").forEach((source) => source.removeAttribute("src")); + } catch (_) {} + try { media.load(); } catch (_) {} + }; + const inspectMedia = (node) => { if (!node) { return; @@ -331,21 +350,61 @@ final class DreamioWebViewController: UIViewController { return } - if lastNativePlaybackURL == request.playbackURL { + let duplicateKey = request.resolverURL ?? request.playbackURL + if lastNativePlaybackURL == duplicateKey { return } - lastNativePlaybackURL = request.playbackURL + lastNativePlaybackURL = duplicateKey #if DEBUG let classification = request.classification print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")") #endif - let player = NativePlayerViewController(request: request) - player.onDismiss = { [weak self] in - self?.lastNativePlaybackURL = nil + Task { [weak self] in + await self?.resolveAndPresentNativePlayback(request) } - present(player, animated: true) + } + + @MainActor + private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async { + do { + let resolved = try await streamResolver.resolve(request: request) +#if DEBUG + print("[DreamioStreamResolver] source=\(resolved.source) playback=\(URLRedactor.redactedURLString(resolved.playbackURL.absoluteString))") +#endif + let resolvedRequest = NativePlaybackRequest( + playbackURL: resolved.playbackURL, + observedURL: request.observedURL, + resolverURL: request.resolverURL, + pageURL: request.pageURL, + userAgent: request.userAgent, + referer: request.referer, + headers: resolved.headers, + classification: request.classification + ) + let player = NativePlayerViewController(request: resolvedRequest) + player.onDismiss = { [weak self] in + self?.lastNativePlaybackURL = nil + } + present(player, animated: true) + } catch { +#if DEBUG + print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")") +#endif + lastNativePlaybackURL = nil + showNativePlaybackResolutionFailure(error) + } + } + + private func showNativePlaybackResolutionFailure(_ error: Error) { + let alert = UIAlertController( + title: "Could not open stream", + message: error.localizedDescription, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Close", style: .cancel)) + present(alert, animated: true) } #if DEBUG diff --git a/Dreamio/NativePlaybackBackend.swift b/Dreamio/NativePlaybackBackend.swift index 52f5d0f..6d44993 100644 --- a/Dreamio/NativePlaybackBackend.swift +++ b/Dreamio/NativePlaybackBackend.swift @@ -12,11 +12,17 @@ protocol NativePlaybackBackend: AnyObject { enum NativePlaybackError: LocalizedError { case backendUnavailable + case startupTimedOut + case playbackFailed var errorDescription: String? { switch self { case .backendUnavailable: return "Native playback is not available in this build." + case .startupTimedOut: + return "Native playback did not start before the timeout." + case .playbackFailed: + return "VLC reported a playback error for this stream." } } } diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index 58ea001..4f14c4c 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -3,6 +3,7 @@ import UIKit final class NativePlayerViewController: UIViewController { private let request: NativePlaybackRequest private var backend: NativePlaybackBackend + private var startupTimer: Timer? var onDismiss: (() -> Void)? private let loadingView: UIActivityIndicatorView = { @@ -66,11 +67,13 @@ final class NativePlayerViewController: UIViewController { view.backgroundColor = .black configureBackend() configureLayout() + startStartupTimer() backend.play(request: request) } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) + startupTimer?.invalidate() backend.stop() onDismiss?() } @@ -80,17 +83,27 @@ final class NativePlayerViewController: UIViewController { backend.view.translatesAutoresizingMaskIntoConstraints = false backend.onReady = { [weak self] in DispatchQueue.main.async { + self?.startupTimer?.invalidate() self?.loadingView.stopAnimating() self?.loadingView.isHidden = true } } backend.onFailure = { [weak self] error in DispatchQueue.main.async { + self?.startupTimer?.invalidate() self?.showFailure(error) } } } + private func startStartupTimer() { + startupTimer?.invalidate() + startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in + self?.backend.stop() + self?.showFailure(NativePlaybackError.startupTimedOut) + } + } + private func configureLayout() { view.addSubview(backend.view) view.addSubview(loadingView) diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index 917c6f4..5afb693 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -24,6 +24,7 @@ struct NativePlaybackRequest { let pageURL: URL? let userAgent: String? let referer: String + let headers: [String: String] let classification: StreamClassification } @@ -75,16 +76,40 @@ enum StreamClassifier { } return NativePlaybackRequest( - playbackURL: candidate.resolverURL ?? candidate.observedURL, + playbackURL: candidate.observedURL, observedURL: candidate.observedURL, resolverURL: candidate.resolverURL, pageURL: candidate.pageURL, userAgent: userAgent, referer: referer, + headers: Self.defaultHeaders(userAgent: userAgent), classification: classification ) } + static func defaultHeaders(userAgent: String?) -> [String: String] { + var headers = ["Referer": referer] + if let userAgent, !userAgent.isEmpty { + headers["User-Agent"] = userAgent + } + return headers + } + + static func isDirectPlayableFileURL(_ url: URL) -> Bool { + let container = containerGuess(for: url, resolverURL: nil) + return [.mp4, .mkv, .avi, .webm].contains(container) + } + + static func isWebKitCompatibleURL(_ url: URL) -> Bool { + let container = containerGuess(for: url, resolverURL: nil) + return container == .hls || container == .mp4 + } + + static func isKnownResolverURL(_ url: URL) -> Bool { + matches(url, host: "addon.debridio.com", pathPrefix: "/play/") + || matches(url, host: "torrentio.strem.fun", pathPrefix: "/resolve/") + } + static func classify(candidate: StreamCandidate) -> StreamClassification { let observed = candidate.observedURL let resolver = candidate.resolverURL diff --git a/Dreamio/StreamResolver.swift b/Dreamio/StreamResolver.swift new file mode 100644 index 0000000..1943dea --- /dev/null +++ b/Dreamio/StreamResolver.swift @@ -0,0 +1,167 @@ +import Foundation + +struct ResolvedNativeStream { + let playbackURL: URL + let headers: [String: String] + let source: String +} + +enum StreamResolverError: LocalizedError { + case noResolverURL + case httpStatus(Int) + case emptyResponse + case invalidResponse + case noPlayableStream + + var errorDescription: String? { + switch self { + case .noResolverURL: + return "Dreamio could not find an addon resolver URL for this stream." + case let .httpStatus(status): + return "The stream resolver returned HTTP \(status)." + case .emptyResponse: + return "The stream resolver returned an empty response." + case .invalidResponse: + return "The stream resolver returned data Dreamio could not parse." + case .noPlayableStream: + return "The resolver did not return a direct playable media URL." + } + } +} + +protocol StreamResolving { + func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream +} + +final class StremioStreamResolver: StreamResolving { + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream { + if StreamClassifier.isDirectPlayableFileURL(request.observedURL) { + return ResolvedNativeStream( + playbackURL: request.observedURL, + headers: request.headers, + source: "observed-direct-file" + ) + } + + let possibleResolverURL = request.resolverURL ?? request.observedURL + guard StreamClassifier.isKnownResolverURL(possibleResolverURL) else { + throw StreamResolverError.noResolverURL + } + + var urlRequest = URLRequest(url: possibleResolverURL) + urlRequest.setValue("application/json", forHTTPHeaderField: "Accept") + request.headers.forEach { key, value in + urlRequest.setValue(value, forHTTPHeaderField: key) + } + + let (data, response) = try await session.data(for: urlRequest) + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + throw StreamResolverError.httpStatus(httpResponse.statusCode) + } + if let finalURL = response.url, StreamClassifier.isDirectPlayableFileURL(finalURL) { + return ResolvedNativeStream( + playbackURL: finalURL, + headers: request.headers, + source: "resolver-redirect" + ) + } + guard !data.isEmpty else { + throw StreamResolverError.emptyResponse + } + + let payload = try parsePayload(from: data) + guard let stream = Self.bestPlayableStream(in: payload, fallbackHeaders: request.headers) else { + throw StreamResolverError.noPlayableStream + } + return stream + } + + private func parsePayload(from data: Data) throws -> Any { + do { + return try JSONSerialization.jsonObject(with: data) + } catch { + throw StreamResolverError.invalidResponse + } + } + + static func bestPlayableStream(in payload: Any, fallbackHeaders: [String: String]) -> ResolvedNativeStream? { + let streams = streamDictionaries(in: payload) + let candidates = streams.compactMap { stream -> ResolvedNativeStream? in + guard let url = directURL(in: stream) else { + return nil + } + guard StreamClassifier.isDirectPlayableFileURL(url) else { + return nil + } + return ResolvedNativeStream( + playbackURL: url, + headers: mergedHeaders(fallbackHeaders: fallbackHeaders, stream: stream), + source: "resolver-json" + ) + } + + return candidates.first { !StreamClassifier.isWebKitCompatibleURL($0.playbackURL) } ?? candidates.first + } + + private static func streamDictionaries(in payload: Any) -> [[String: Any]] { + if let dictionary = payload as? [String: Any], + let streams = dictionary["streams"] as? [[String: Any]] { + return streams + } + if let streams = payload as? [[String: Any]] { + return streams + } + return [] + } + + private static func directURL(in stream: [String: Any]) -> URL? { + let fields = ["url", "externalUrl", "externalURL", "file", "streamUrl", "streamURL"] + for field in fields { + if let value = stream[field] as? String, + let url = URL(string: value), + ["http", "https"].contains(url.scheme?.lowercased()) { + return url + } + } + return nil + } + + private static func mergedHeaders(fallbackHeaders: [String: String], stream: [String: Any]) -> [String: String] { + var headers = fallbackHeaders + headerDictionaries(in: stream).forEach { headerDictionary in + headerDictionary.forEach { key, value in + headers[key] = value + } + } + return headers + } + + private static func headerDictionaries(in stream: [String: Any]) -> [[String: String]] { + var dictionaries: [[String: String]] = [] + + if let headers = stream["headers"] as? [String: String] { + dictionaries.append(headers) + } + if let requestHeaders = stream["requestHeaders"] as? [String: String] { + dictionaries.append(requestHeaders) + } + if let behaviorHints = stream["behaviorHints"] as? [String: Any] { + if let headers = behaviorHints["headers"] as? [String: String] { + dictionaries.append(headers) + } + if let proxyHeaders = behaviorHints["proxyHeaders"] as? [String: Any], + let request = proxyHeaders["request"] as? [String: String] { + dictionaries.append(request) + } + } + + return dictionaries + } +} diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index 2ed0132..2390133 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -30,21 +30,21 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { func play(request: NativePlaybackRequest) { #if canImport(MobileVLCKit) let media = VLCMedia(url: request.playbackURL) - var headers = ["Referer": request.referer] - if let userAgent = request.userAgent { - headers["User-Agent"] = userAgent - } - - let headerValue = headers + 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)") } - media.addOption(":http-header=\(headerValue)") + if !headerValue.isEmpty { + media.addOption(":http-header=\(headerValue)") + } mediaPlayer.media = media +#if DEBUG + print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") +#endif mediaPlayer.play() #else onFailure?(NativePlaybackError.backendUnavailable) @@ -54,6 +54,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { func stop() { #if canImport(MobileVLCKit) mediaPlayer.stop() + mediaPlayer.drawable = nil mediaPlayer.media = nil #endif } @@ -62,14 +63,40 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { #if canImport(MobileVLCKit) extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { func mediaPlayerStateChanged(_ aNotification: Notification) { +#if DEBUG + print("[DreamioVLC] state=\(stateName(mediaPlayer.state))") +#endif switch mediaPlayer.state { - case .opening, .buffering, .playing: + case .buffering, .playing: onReady?() case .error: - onFailure?(NativePlaybackError.backendUnavailable) + onFailure?(NativePlaybackError.playbackFailed) default: break } } + + private func stateName(_ state: VLCMediaPlayerState) -> String { + switch state { + case .opening: + return "opening" + case .buffering: + return "buffering" + case .playing: + return "playing" + case .ended: + return "ended" + case .stopped: + return "stopped" + case .error: + return "error" + case .paused: + return "paused" + case .esAdded: + return "elementary-stream-added" + @unknown default: + return "unknown" + } + } } #endif diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift new file mode 100644 index 0000000..a849ed8 --- /dev/null +++ b/Tests/StreamResolverTests.swift @@ -0,0 +1,73 @@ +import Foundation + +@main +struct StreamResolverTests { + static func main() { + testClassifierPrefersObservedDirectFile() + testResolverSelectsUnsupportedDirectURLAndHeaders() + testResolverRejectsHLSOnlyResponse() + print("StreamResolverTests passed") + } + + private static func testClassifierPrefersObservedDirectFile() { + let body: [String: Any] = [ + "url": "https://cdn.example.test/movie.mkv?token=secret", + "resolverUrl": "https://addon.debridio.com/play/example" + ] + let candidate = StreamCandidate(messageBody: body)! + let request = StreamClassifier.playbackRequest(from: candidate, userAgent: "DreamioTest/1")! + + assertEqual(request.playbackURL.absoluteString, "https://cdn.example.test/movie.mkv?token=secret") + assertEqual(request.headers["Referer"], "https://web.stremio.com/") + assertEqual(request.headers["User-Agent"], "DreamioTest/1") + } + + private static func testResolverSelectsUnsupportedDirectURLAndHeaders() { + let payload: [String: Any] = [ + "streams": [ + [ + "url": "https://cdn.example.test/trailer.mp4" + ], + [ + "externalUrl": "https://cdn.example.test/movie.mkv?signature=secret", + "behaviorHints": [ + "proxyHeaders": [ + "request": [ + "Referer": "https://resolver.example.test/", + "User-Agent": "ResolverAgent/1" + ] + ] + ] + ] + ] + ] + + let stream = StremioStreamResolver.bestPlayableStream( + in: payload, + fallbackHeaders: ["Referer": "https://web.stremio.com/"] + )! + + assertEqual(stream.playbackURL.absoluteString, "https://cdn.example.test/movie.mkv?signature=secret") + assertEqual(stream.headers["Referer"], "https://resolver.example.test/") + assertEqual(stream.headers["User-Agent"], "ResolverAgent/1") + } + + private static func testResolverRejectsHLSOnlyResponse() { + let payload: [String: Any] = [ + "streams": [ + ["url": "https://cdn.example.test/live.m3u8"] + ] + ] + + let stream = StremioStreamResolver.bestPlayableStream( + in: payload, + fallbackHeaders: ["Referer": "https://web.stremio.com/"] + ) + + assert(stream == nil, "Expected HLS-only resolver response to stay out of native playback") + } + + private static func assertEqual(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) { + assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line) + } +} diff --git a/docs/turns/2026-05-25-fix-native-playback-resolution.html b/docs/turns/2026-05-25-fix-native-playback-resolution.html new file mode 100644 index 0000000..83fe380 --- /dev/null +++ b/docs/turns/2026-05-25-fix-native-playback-resolution.html @@ -0,0 +1,196 @@ + + + + + + Fix Native Playback Resolution + + + +
+
+

Fix Native Playback Resolution

+

Dreamio now resolves known Stremio addon stream responses before opening native playback, gives VLC the final direct media URL when one is available, and recovers visibly when stream resolution or VLC startup fails.

+
+ Beads: dreamio-vxs + Native playback + Stream resolution + 2026-05-25 +
+
+ +
+

Summary

+

Fixed the native playback path so direct-file selections no longer default to the Debridio or Torrentio resolver URL. Dreamio either uses the observed direct media URL immediately or fetches a known addon resolver and selects a playable direct URL from its Stremio JSON response.

+
+ +
+

Changes Made

+
    +
  • Changed native request creation to prefer the observed direct media URL instead of the resolver URL.
  • +
  • Added StremioStreamResolver to fetch known addon resolver URLs, parse streams JSON, inspect url, externalUrl, and related fields, and merge required request headers.
  • +
  • Stopped intercepted WebKit media elements before handing the selection to native playback.
  • +
  • Added a closeable resolver failure alert so bad, expired, or unresolvable streams return the user to Stremio.
  • +
  • Hardened VLC startup with state logging, a 20 second startup timeout, failure messages, and explicit media/drawable detachment on stop.
  • +
  • Added focused Swift tests for stream classification and resolver stream selection.
  • +
+
+ +
+

Context

+

The prior native playback bridge intercepted unsupported direct-file streams, but it could pass VLC the resolver/addon URL instead of a final file URL. That made VLC try to play an endpoint intended to return JSON or perform resolution, while WebKit could still continue attempting unsupported playback underneath.

+

The 127.0.0.1:11470 companion-service errors remain treated as secondary noise. This change focuses on the direct stream URL that Dreamio controls before opening MobileVLCKit.

+
+ +
+

Important Implementation Details

+
    +
  • NativePlaybackRequest now carries merged headers so resolver-provided Referer and User-Agent values can reach VLC.
  • +
  • StremioStreamResolver accepts resolver redirects to direct files and JSON responses containing direct stream fields.
  • +
  • The resolver selects unsupported direct files first, so MKV, AVI, and WebM go native while HLS-only resolver responses stay out of VLC.
  • +
  • Duplicate suppression keys by resolver when present, preventing repeated bridge posts during async resolution while still allowing retry after dismiss or failure.
  • +
+
+ +
+

Relevant Diff Snippets

+

Rendered with @pierre/diffs; this excerpt shows the core classifier change that stops preferring resolver URLs by default.

+
Dreamio/StreamCandidate.swift
-1+26
23 unmodified lines
24
25
26
27
28
29
45 unmodified lines
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
23 unmodified lines
let pageURL: URL?
let userAgent: String?
let referer: String
let classification: StreamClassification
}
+
45 unmodified lines
}
+
return NativePlaybackRequest(
playbackURL: candidate.resolverURL ?? candidate.observedURL,
observedURL: candidate.observedURL,
resolverURL: candidate.resolverURL,
pageURL: candidate.pageURL,
userAgent: userAgent,
referer: referer,
classification: classification
)
}
+
static func classify(candidate: StreamCandidate) -> StreamClassification {
let observed = candidate.observedURL
let resolver = candidate.resolverURL
23 unmodified lines
24
25
26
27
28
29
30
45 unmodified lines
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
23 unmodified lines
let pageURL: URL?
let userAgent: String?
let referer: String
let headers: [String: String]
let classification: StreamClassification
}
+
45 unmodified lines
}
+
return NativePlaybackRequest(
playbackURL: candidate.observedURL,
observedURL: candidate.observedURL,
resolverURL: candidate.resolverURL,
pageURL: candidate.pageURL,
userAgent: userAgent,
referer: referer,
headers: Self.defaultHeaders(userAgent: userAgent),
classification: classification
)
}
+
static func defaultHeaders(userAgent: String?) -> [String: String] {
var headers = ["Referer": referer]
if let userAgent, !userAgent.isEmpty {
headers["User-Agent"] = userAgent
}
return headers
}
+
static func isDirectPlayableFileURL(_ url: URL) -> Bool {
let container = containerGuess(for: url, resolverURL: nil)
return [.mp4, .mkv, .avi, .webm].contains(container)
}
+
static func isWebKitCompatibleURL(_ url: URL) -> Bool {
let container = containerGuess(for: url, resolverURL: nil)
return container == .hls || container == .mp4
}
+
static func isKnownResolverURL(_ url: URL) -> Bool {
matches(url, host: "addon.debridio.com", pathPrefix: "/play/")
|| matches(url, host: "torrentio.strem.fun", pathPrefix: "/resolve/")
}
+
static func classify(candidate: StreamCandidate) -> StreamClassification {
let observed = candidate.observedURL
let resolver = candidate.resolverURL
+
+ +
+

Expected Impact for End-Users

+

Users still pick streams inside Stremio Web. Direct MKV, AVI, WebM, and known debrid resolver selections should now open the native player with a final playable file URL when one can be resolved. When resolution or startup fails, Dreamio shows a visible failure state and stays usable.

+
+ +
+

Validation

+
    +
  • Ran swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests && /tmp/dreamio-stream-tests: passed.
  • +
  • Ran swiftc -typecheck Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift: passed.
  • +
  • Ran git diff --check: passed.
  • +
  • pod install could not be run because CocoaPods is not installed in this environment.
  • +
  • xcodebuild could not be run because the active developer directory is Command Line Tools, not full Xcode.
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
Manual real-device validation is still required for the South Park MKV-style stream and any live Debridio or Torrentio resolver behavior.
+
    +
  • Expired or revoked debrid URLs can still fail after successful resolution. The failure should now be visible and recoverable.
  • +
  • Resolver parsing covers common Stremio stream fields and headers, but unusual addon response shapes may need another field mapping.
  • +
  • MobileVLCKit codec limitations remain possible even when Dreamio supplies the correct URL.
  • +
+
+ +
+

Follow-up Work

+
    +
  • Run pod install and build Dreamio.xcworkspace on a machine with CocoaPods and full Xcode.
  • +
  • Validate a real Debridio or Torrentio MKV selection on device and confirm logs show [DreamioStreamResolver] with a final direct URL.
  • +
  • Add mappings for any addon-specific stream fields discovered during device testing.
  • +
+
+
+ + \ No newline at end of file