diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 9750577..79cf19a 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -2,3 +2,4 @@ {"id":"int-09793929","kind":"field_change","created_at":"2026-05-25T01:12:43.675806Z","actor":"dirtydishes","issue_id":"dreamio-a5b","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Moved @pierre/diffs to devDependencies and ignored node_modules."}} {"id":"int-d8dc4ec5","kind":"field_change","created_at":"2026-05-25T01:25:35.590554Z","actor":"dirtydishes","issue_id":"dreamio-tnv","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier."}} {"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."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 7bfcea4..fdf5733 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_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-evt","title":"Enable WebView inspection and playback diagnostics","description":"Add development-only WKWebView inspection and token-safe playback diagnostics so Dreamio can debug hosted Stremio media failures without changing app navigation, login, or playback behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T02:30:26Z","created_by":"dirtydishes","updated_at":"2026-05-25T02:34:55Z","started_at":"2026-05-25T02:30:32Z","closed_at":"2026-05-25T02:34:55Z","close_reason":"Implemented debug-only WKWebView inspection, token-safe playback diagnostics, navigation logging, validation build, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.gitignore b/.gitignore index 089a6b9..c4cf5cd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ # Node tooling node_modules/ + +# CocoaPods +Pods/ diff --git a/Dreamio.xcodeproj/project.pbxproj b/Dreamio.xcodeproj/project.pbxproj index 38deda2..6736b6b 100644 --- a/Dreamio.xcodeproj/project.pbxproj +++ b/Dreamio.xcodeproj/project.pbxproj @@ -10,6 +10,10 @@ 6F2A2B362C00100100DREAMIO /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B332C00100100DREAMIO /* AppDelegate.swift */; }; 6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */; }; 6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */; }; + 6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */; }; + 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 */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -18,6 +22,10 @@ 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DreamioWebViewController.swift; sourceTree = ""; }; 6F2A2B392C00100100DREAMIO /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamCandidate.swift; sourceTree = ""; }; + 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -53,6 +61,10 @@ 6F2A2B332C00100100DREAMIO /* AppDelegate.swift */, 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */, 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */, + 6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */, + 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */, + 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */, + 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */, 6F2A2B392C00100100DREAMIO /* Info.plist */, ); path = Dreamio; @@ -129,6 +141,10 @@ 6F2A2B362C00100100DREAMIO /* AppDelegate.swift in Sources */, 6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */, 6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */, + 6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */, + 6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */, + 6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */, + 6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index eac3597..b07e657 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -5,6 +5,7 @@ final class DreamioWebViewController: UIViewController { private enum Constants { static let stremioWebURL = URL(string: "https://web.stremio.com/")! static let diagnosticsMessageHandler = "dreamioDiagnostics" + static let streamCandidateMessageHandler = "dreamioStreamCandidate" } private lazy var webView: WKWebView = { @@ -13,6 +14,11 @@ final class DreamioWebViewController: UIViewController { configuration.allowsInlineMediaPlayback = true configuration.mediaTypesRequiringUserActionForPlayback = [] configuration.preferences.javaScriptCanOpenWindowsAutomatically = true + configuration.userContentController.add( + WeakScriptMessageHandler(delegate: self), + name: Constants.streamCandidateMessageHandler + ) + configuration.userContentController.addUserScript(Self.streamCandidateScript) #if DEBUG configuration.userContentController.add( WeakScriptMessageHandler(delegate: self), @@ -44,6 +50,142 @@ final class DreamioWebViewController: UIViewController { }() private var progressObservation: NSKeyValueObservation? + private var userAgent: String? + private var lastNativePlaybackURL: URL? + + private static let streamCandidateScript = WKUserScript( + source: """ + (() => { + if (window.__dreamioStreamBridgeInstalled) { + return; + } + window.__dreamioStreamBridgeInstalled = true; + + const nativePatterns = [ + /\/\/addon\.debridio\.com\/play\//i, + /\/\/torrentio\.strem\.fun\/resolve\//i, + /\/\/download\.real-debrid\.com\//i, + /\.(mkv|avi|webm)(?:[?#]|$)/i + ]; + const compatiblePatterns = [ + /\.m3u8(?:[?#]|$)/i, + /\.mp4(?:[?#]|$)/i + ]; + + const looksNative = (url) => { + if (!url || typeof url !== "string") { + return false; + } + const directMatch = nativePatterns.some((pattern) => pattern.test(url)); + const compatibleMatch = compatiblePatterns.some((pattern) => pattern.test(url)); + return directMatch || (!compatibleMatch && /\.(mkv|avi|webm)(?:[?#]|$)/i.test(url)); + }; + + const absoluteURL = (url) => { + try { + return new URL(url, window.location.href).href; + } catch (_) { + return ""; + } + }; + + const findResolverURL = () => { + const links = Array.from(document.querySelectorAll("a[href], [data-href], [data-url]")); + const match = links + .map((node) => node.getAttribute("href") || node.getAttribute("data-href") || node.getAttribute("data-url")) + .map(absoluteURL) + .find((url) => nativePatterns.some((pattern) => pattern.test(url))); + return match || ""; + }; + + const postCandidate = (rawURL, element) => { + const url = absoluteURL(rawURL); + if (!looksNative(url)) { + return; + } + try { + window.webkit.messageHandlers.dreamioStreamCandidate.postMessage({ + url, + resolverUrl: findResolverURL(), + pageUrl: window.location.href, + tagName: element && element.tagName ? element.tagName : "", + currentSrc: element && element.currentSrc ? element.currentSrc : "" + }); + } catch (_) {} + }; + + const inspectMedia = (node) => { + if (!node) { + return; + } + if (node instanceof HTMLVideoElement || node instanceof HTMLSourceElement) { + postCandidate(node.currentSrc || node.src || node.getAttribute("src"), node); + } + if (node.querySelectorAll) { + node.querySelectorAll("video, source").forEach(inspectMedia); + } + }; + + const srcDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, "src"); + if (srcDescriptor && srcDescriptor.set) { + Object.defineProperty(HTMLMediaElement.prototype, "src", { + get: srcDescriptor.get, + set(value) { + postCandidate(value, this); + return srcDescriptor.set.call(this, value); + } + }); + } + + const sourceSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLSourceElement.prototype, "src"); + if (sourceSrcDescriptor && sourceSrcDescriptor.set) { + Object.defineProperty(HTMLSourceElement.prototype, "src", { + get: sourceSrcDescriptor.get, + set(value) { + postCandidate(value, this); + return sourceSrcDescriptor.set.call(this, value); + } + }); + } + + const originalSetAttribute = Element.prototype.setAttribute; + Element.prototype.setAttribute = function(name, value) { + if (String(name).toLowerCase() === "src" && (this instanceof HTMLVideoElement || this instanceof HTMLSourceElement)) { + postCandidate(value, this); + } + return originalSetAttribute.call(this, name, value); + }; + + const originalLoad = HTMLMediaElement.prototype.load; + HTMLMediaElement.prototype.load = function() { + inspectMedia(this); + this.querySelectorAll("source").forEach(inspectMedia); + return originalLoad.call(this); + }; + + document.addEventListener("loadedmetadata", (event) => inspectMedia(event.target), true); + document.addEventListener("error", (event) => inspectMedia(event.target), true); + new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === "attributes" && mutation.attributeName === "src") { + inspectMedia(mutation.target); + } + mutation.addedNodes.forEach(inspectMedia); + }); + }).observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["src"] + }); + + inspectMedia(document); + })(); + """, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + #if DEBUG private static let playbackDiagnosticsScript = WKUserScript( @@ -157,6 +299,9 @@ final class DreamioWebViewController: UIViewController { } loadDreamio() + webView.evaluateJavaScript("navigator.userAgent") { [weak self] result, _ in + self?.userAgent = result as? String + } } private func loadDreamio() { @@ -181,6 +326,28 @@ final class DreamioWebViewController: UIViewController { present(alert, animated: true) } + private func handleStreamCandidate(_ candidate: StreamCandidate) { + guard let request = StreamClassifier.playbackRequest(from: candidate, userAgent: userAgent) else { + return + } + + if lastNativePlaybackURL == request.playbackURL { + return + } + lastNativePlaybackURL = request.playbackURL + +#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 + } + present(player, animated: true) + } + #if DEBUG private func logDiagnostic(type: String, payload: Any, pageURL: String?) { let redactedPageURL = pageURL.map(redactedURLString) ?? "unknown" @@ -204,44 +371,7 @@ final class DreamioWebViewController: UIViewController { } private func redactedURLString(_ value: String) -> String { - guard var components = URLComponents(string: value), components.scheme != nil else { - return redactTokenLikeFragments(in: value) - } - - components.query = nil - components.fragment = nil - if let path = components.percentEncodedPath.nilIfEmpty { - components.percentEncodedPath = redactTokenLikePathSegments(in: path) - } - return redactTokenLikeFragments(in: components.string ?? value) - } - - private func redactTokenLikePathSegments(in path: String) -> String { - path - .split(separator: "/", omittingEmptySubsequences: false) - .map { segment -> String in - let text = String(segment) - if text.range(of: #"^[A-Za-z0-9_-]{24,}$"#, options: .regularExpression) != nil { - return "[redacted]" - } - return text - } - .joined(separator: "/") - } - - private func redactTokenLikeFragments(in value: String) -> String { - let patterns = [ - #"(?i)((?:token|access_token|auth|signature|sig|key|apikey|api_key|jwt|session|password)=)([^&\s]+)"#, - #"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+"# - ] - - return patterns.reduce(value) { redacted, pattern in - redacted.replacingOccurrences( - of: pattern, - with: "$1[redacted]", - options: .regularExpression - ) - } + URLRedactor.redactedURLString(value) } #endif } @@ -311,9 +441,15 @@ extension DreamioWebViewController: WKNavigationDelegate { } } -#if DEBUG extension DreamioWebViewController: WKScriptMessageHandler { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == Constants.streamCandidateMessageHandler, + let candidate = StreamCandidate(messageBody: message.body) { + handleStreamCandidate(candidate) + return + } + +#if DEBUG guard message.name == Constants.diagnosticsMessageHandler, let body = message.body as? [String: Any], let type = body["type"] as? String @@ -326,6 +462,7 @@ extension DreamioWebViewController: WKScriptMessageHandler { payload: body["payload"] ?? [:], pageURL: body["href"] as? String ) +#endif } } @@ -342,12 +479,6 @@ private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { } } -private extension String { - var nilIfEmpty: String? { - isEmpty ? nil : self - } -} -#endif extension DreamioWebViewController: WKUIDelegate { func webView( diff --git a/Dreamio/NativePlaybackBackend.swift b/Dreamio/NativePlaybackBackend.swift new file mode 100644 index 0000000..52f5d0f --- /dev/null +++ b/Dreamio/NativePlaybackBackend.swift @@ -0,0 +1,22 @@ +import UIKit + +protocol NativePlaybackBackend: AnyObject { + var view: UIView { get } + var onReady: (() -> Void)? { get set } + var onFailure: ((Error) -> Void)? { get set } + + func prepare(in viewController: UIViewController) + func play(request: NativePlaybackRequest) + func stop() +} + +enum NativePlaybackError: LocalizedError { + case backendUnavailable + + var errorDescription: String? { + switch self { + case .backendUnavailable: + return "Native playback is not available in this build." + } + } +} diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift new file mode 100644 index 0000000..58ea001 --- /dev/null +++ b/Dreamio/NativePlayerViewController.swift @@ -0,0 +1,134 @@ +import UIKit + +final class NativePlayerViewController: UIViewController { + private let request: NativePlaybackRequest + private var backend: NativePlaybackBackend + var onDismiss: (() -> Void)? + + private let loadingView: UIActivityIndicatorView = { + let view = UIActivityIndicatorView(style: .large) + view.translatesAutoresizingMaskIntoConstraints = false + view.color = .white + view.startAnimating() + return view + }() + + private let closeButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(UIImage(systemName: "xmark"), for: .normal) + button.tintColor = .white + button.backgroundColor = UIColor.black.withAlphaComponent(0.45) + button.layer.cornerRadius = 22 + button.accessibilityLabel = "Close" + return button + }() + + private let failureLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .white + label.textAlignment = .center + label.numberOfLines = 0 + label.font = .preferredFont(forTextStyle: .body) + label.isHidden = true + return label + }() + + init(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) { + self.request = request + self.backend = backend + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + modalTransitionStyle = .crossDissolve + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + .allButUpsideDown + } + + override var prefersHomeIndicatorAutoHidden: Bool { + true + } + + override var prefersStatusBarHidden: Bool { + true + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .black + configureBackend() + configureLayout() + backend.play(request: request) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + backend.stop() + onDismiss?() + } + + private func configureBackend() { + backend.prepare(in: self) + backend.view.translatesAutoresizingMaskIntoConstraints = false + backend.onReady = { [weak self] in + DispatchQueue.main.async { + self?.loadingView.stopAnimating() + self?.loadingView.isHidden = true + } + } + backend.onFailure = { [weak self] error in + DispatchQueue.main.async { + self?.showFailure(error) + } + } + } + + private func configureLayout() { + view.addSubview(backend.view) + view.addSubview(loadingView) + view.addSubview(failureLabel) + view.addSubview(closeButton) + closeButton.addTarget(self, action: #selector(close), for: .touchUpInside) + + NSLayoutConstraint.activate([ + backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + backend.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + backend.view.topAnchor.constraint(equalTo: view.topAnchor), + backend.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + + failureLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 28), + failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28), + failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + + closeButton.widthAnchor.constraint(equalToConstant: 44), + closeButton.heightAnchor.constraint(equalToConstant: 44), + closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), + closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12) + ]) + } + + private func showFailure(_ error: Error) { + loadingView.stopAnimating() + loadingView.isHidden = true + failureLabel.text = "Native playback could not start.\n\(error.localizedDescription)" + failureLabel.isHidden = false +#if DEBUG + print("[DreamioNativePlayer] error=\(URLRedactor.redactedURLString(error.localizedDescription))") +#endif + } + + @objc private func close() { + dismiss(animated: true) + } +} diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift new file mode 100644 index 0000000..917c6f4 --- /dev/null +++ b/Dreamio/StreamCandidate.swift @@ -0,0 +1,208 @@ +import Foundation + +enum StreamSourceKind: String { + case debridio + case torrentio + case realDebrid + case directFile + case unknown +} + +enum StreamContainerGuess: String { + case hls + case mp4 + case mkv + case avi + case webm + case unknown +} + +struct NativePlaybackRequest { + let playbackURL: URL + let observedURL: URL + let resolverURL: URL? + let pageURL: URL? + let userAgent: String? + let referer: String + let classification: StreamClassification +} + +struct StreamClassification { + let sourceKind: StreamSourceKind + let containerGuess: StreamContainerGuess + let reason: String + let shouldIntercept: Bool + let sanitizedObservedURL: String + let sanitizedResolverURL: String? +} + +struct StreamCandidate { + let observedURL: URL + let resolverURL: URL? + let pageURL: URL? + + init?(messageBody: Any) { + guard let body = messageBody as? [String: Any], + let observed = Self.url(from: body["url"]) + else { + return nil + } + + observedURL = observed + resolverURL = Self.url(from: body["resolverUrl"]) + pageURL = Self.url(from: body["pageUrl"]) + } + + private static func url(from value: Any?) -> URL? { + guard let string = value as? String, !string.isEmpty else { + return nil + } + + return URL(string: string) + } +} + +enum StreamClassifier { + static let referer = "https://web.stremio.com/" + + static func playbackRequest( + from candidate: StreamCandidate, + userAgent: String? + ) -> NativePlaybackRequest? { + let classification = classify(candidate: candidate) + guard classification.shouldIntercept else { + return nil + } + + 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 + let matchingURL = resolver ?? observed + let sourceKind = sourceKind(for: matchingURL, observedURL: observed) + let container = containerGuess(for: observed, resolverURL: resolver) + let knownDirectFile = sourceKind == .debridio || sourceKind == .torrentio || sourceKind == .realDebrid + let unsupportedContainer = [.mkv, .avi, .webm].contains(container) + let webCompatibleContainer = container == .hls || container == .mp4 + + let shouldIntercept = knownDirectFile || unsupportedContainer + let reason: String + if knownDirectFile { + reason = "known-direct-file-source" + } else if unsupportedContainer { + reason = "unsupported-container" + } else if webCompatibleContainer { + reason = "web-compatible-container" + } else { + reason = "no-native-rule" + } + + return StreamClassification( + sourceKind: sourceKind, + containerGuess: container, + reason: reason, + shouldIntercept: shouldIntercept, + sanitizedObservedURL: URLRedactor.redactedURLString(observed.absoluteString), + sanitizedResolverURL: resolver.map { URLRedactor.redactedURLString($0.absoluteString) } + ) + } + + private static func sourceKind(for url: URL, observedURL: URL) -> StreamSourceKind { + let values = [url, observedURL] + if values.contains(where: { matches($0, host: "addon.debridio.com", pathPrefix: "/play/") }) { + return .debridio + } + if values.contains(where: { matches($0, host: "torrentio.strem.fun", pathPrefix: "/resolve/") }) { + return .torrentio + } + if values.contains(where: { ($0.host ?? "").lowercased() == "download.real-debrid.com" }) { + return .realDebrid + } + if [.mkv, .avi, .webm].contains(containerGuess(for: observedURL, resolverURL: url)) { + return .directFile + } + return .unknown + } + + private static func matches(_ url: URL, host: String, pathPrefix: String) -> Bool { + (url.host ?? "").lowercased() == host && url.path.lowercased().hasPrefix(pathPrefix) + } + + private static func containerGuess(for observedURL: URL, resolverURL: URL?) -> StreamContainerGuess { + let values = [observedURL, resolverURL].compactMap { $0 } + if values.contains(where: { $0.pathExtension.lowercased() == "m3u8" || $0.absoluteString.lowercased().contains(".m3u8") }) { + return .hls + } + + for url in values { + let text = url.absoluteString.lowercased() + if url.pathExtension.lowercased() == "mp4" || text.contains(".mp4") { + return .mp4 + } + if url.pathExtension.lowercased() == "mkv" || text.contains(".mkv") { + return .mkv + } + if url.pathExtension.lowercased() == "avi" || text.contains(".avi") { + return .avi + } + if url.pathExtension.lowercased() == "webm" || text.contains(".webm") { + return .webm + } + } + + return .unknown + } +} + +enum URLRedactor { + static func redactedURLString(_ value: String) -> String { + guard var components = URLComponents(string: value), components.scheme != nil else { + return redactTokenLikeFragments(in: value) + } + + components.query = nil + components.fragment = nil + if !components.percentEncodedPath.isEmpty { + components.percentEncodedPath = redactTokenLikePathSegments(in: components.percentEncodedPath) + } + return redactTokenLikeFragments(in: components.string ?? value) + } + + private static func redactTokenLikePathSegments(in path: String) -> String { + path + .split(separator: "/", omittingEmptySubsequences: false) + .map { segment -> String in + let text = String(segment) + if text.range(of: #"^[A-Za-z0-9_-]{24,}$"#, options: .regularExpression) != nil { + return "[redacted]" + } + return text + } + .joined(separator: "/") + } + + private static func redactTokenLikeFragments(in value: String) -> String { + let patterns = [ + #"(?i)((?:token|access_token|auth|signature|sig|key|apikey|api_key|jwt|session|password)=)([^&\s]+)"#, + #"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+"# + ] + + return patterns.reduce(value) { redacted, pattern in + redacted.replacingOccurrences( + of: pattern, + with: "$1[redacted]", + options: .regularExpression + ) + } + } +} diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift new file mode 100644 index 0000000..44ca5bc --- /dev/null +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -0,0 +1,58 @@ +import UIKit +import MobileVLCKit + +final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { + let view = UIView() + var onReady: (() -> Void)? + var onFailure: ((Error) -> Void)? + + private let mediaPlayer = VLCMediaPlayer() + + override init() { + super.init() + mediaPlayer.delegate = self + view.backgroundColor = .black + } + + func prepare(in viewController: UIViewController) { + mediaPlayer.drawable = view + } + + func play(request: NativePlaybackRequest) { + let media = VLCMedia(url: request.playbackURL) + var headers = ["Referer": request.referer] + if let userAgent = request.userAgent { + headers["User-Agent"] = userAgent + } + + let headerValue = 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)") + + mediaPlayer.media = media + mediaPlayer.play() + } + + func stop() { + mediaPlayer.stop() + mediaPlayer.media = nil + } +} + +extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { + func mediaPlayerStateChanged(_ aNotification: Notification) { + switch mediaPlayer.state { + case .opening, .buffering, .playing: + onReady?() + case .error: + onFailure?(NativePlaybackError.backendUnavailable) + default: + break + } + } +} diff --git a/Podfile b/Podfile new file mode 100644 index 0000000..a89a361 --- /dev/null +++ b/Podfile @@ -0,0 +1,7 @@ +platform :ios, '16.0' + +target 'Dreamio' do + use_frameworks! + + pod 'MobileVLCKit' +end diff --git a/README.md b/README.md index d18fe91..ddf7daf 100644 --- a/README.md +++ b/README.md @@ -7,28 +7,41 @@ inside a UIKit host app, handles new-window navigation in the existing web view, allows inline media playback, and leaves playback viability to real-device testing. -## Running the MVP +## Running Dreamio -1. Open `Dreamio.xcodeproj` in Xcode. -2. Select the `Dreamio` scheme. -3. Pick a real iPhone or iPad device. -4. Set a development team for code signing if Xcode asks. -5. Build and run. +1. Install CocoaPods if needed. +2. Run `pod install`. +3. Open `Dreamio.xcworkspace` in Xcode. +4. Select the `Dreamio` scheme. +5. Pick a real iPhone or iPad device. +6. Set a development team for code signing if Xcode asks. +7. Build and run. + +Dreamio uses MobileVLCKit for native playback of direct-file streams that iOS +WebKit commonly cannot play, especially MKV, AVI, and WebM debrid URLs. Keep +using `Dreamio.xcworkspace` after installing pods so Xcode links the native +playback backend. + +## Validation Notes The repository machine currently has Command Line Tools selected instead of full -Xcode, so command-line `xcodebuild` validation is not available here. +Xcode, and CocoaPods is not installed, so command-line `pod install` and +`xcodebuild` validation are not available here. -## MVP Validation Checklist +## Playback Validation Checklist -- Cold launch loads hosted Stremio Web. -- Login completes and persists after app relaunch. -- Catalog and library navigation work. -- Addon install or configuration flows work, including redirects or popups. -- HLS direct stream playback works. -- MP4 direct stream playback works. -- Unsupported formats fail understandably. -- Fullscreen, rotation, pause/resume, and background/foreground behavior are - acceptable for v1. +1. Cold launch loads hosted Stremio Web. +2. Login completes and persists after app relaunch. +3. Catalog and library navigation work. +4. Addon install or configuration flows work, including redirects or popups. +5. HLS direct stream playback works. +6. MP4 direct stream playback works. +7. Debridio, Torrentio, and Real-Debrid MKV/AVI/WebM direct-file streams open + the native player before WebKit reaches its visible media failure state. +8. Closing the native player returns to the existing Stremio Web session. +9. DEBUG logs show sanitized stream classification and native player errors + without full debrid URLs, query strings, tokens, or long secret-like path + segments. Track playback results by device, iOS version, stream protocol, container, codec, subtitle type, HTTP status, and WebKit media error when available. diff --git a/docs/turns/2026-05-24-native-debrid-playback.html b/docs/turns/2026-05-24-native-debrid-playback.html new file mode 100644 index 0000000..02cf2e5 --- /dev/null +++ b/docs/turns/2026-05-24-native-debrid-playback.html @@ -0,0 +1,280 @@ + + + + + + Native Debrid Playback + + + +
+
+

Dreamio turn document · 2026-05-24 23:18 EDT · Beads issue dreamio-l68

+

Native Direct-Stream Playback for Debrid Files

+

Added a production WebKit-to-native playback path for direct-file debrid streams, with MKV, AVI, and WebM candidates routed into a new MobileVLCKit-backed player while ordinary HLS and MP4 web playback stay in Stremio Web.

+
+ WKWebView bridge + Stream classification + MobileVLCKit backend + Sanitized diagnostics +
+
+ +
+

Summary

+

This change keeps Stremio Web as Dreamio's main browsing and account UI, but intercepts direct-file stream URLs that iOS WebKit is likely to reject. Matching streams now open in a native fullscreen-style player with a close button and failure state.

+
+ +
+

Changes Made

+
    +
  • Added a production JavaScript bridge in DreamioWebViewController that observes video/source URLs, direct src assignment, setAttribute("src"), mutations, and load().
  • +
  • Added stream classification for Debridio, Torrentio, Real-Debrid, MKV, AVI, WebM, HLS, and MP4 candidates.
  • +
  • Added redacted URL diagnostics that strip query strings, fragments, and long token-like path segments before DEBUG logging.
  • +
  • Added NativePlayerViewController, NativePlaybackBackend, and the first backend implementation, VLCNativePlaybackBackend.
  • +
  • Added a CocoaPods Podfile for MobileVLCKit and ignored generated Pods/ content.
  • +
  • Updated README workflow instructions to use pod install and Dreamio.xcworkspace.
  • +
+
+ +
+

Context

+

Dreamio started as a thin UIKit wrapper around hosted Stremio Web. That remains the product shape: login, browsing, addon setup, stream selection, popups, and compatible web media playback still belong to the web app. This work adds a native escape hatch only for direct-file streams that are likely to fail in iOS WebKit.

+
+ +
+

Important Implementation Details

+
    +
  • The bridge allows ordinary HLS and MP4 playback to continue in WebKit unless the URL also matches a known direct-file debrid rule.
  • +
  • Native playback prefers the resolver URL when one is available, which avoids unnecessarily reusing short-lived observed CDN links.
  • +
  • The native playback request carries the current user agent when available and sets Referer: https://web.stremio.com/.
  • +
  • The native player clears duplicate suppression on dismissal, so selecting the same stream again can reopen playback.
  • +
  • MobileVLCKit is behind a small protocol so the player controller is not permanently coupled to VLC.
  • +
+
+ +
+

Relevant Diff Snippets

+

Repository instructions prefer @pierre/diffs output. The package is installed as a library, but npx @pierre/diffs --help failed because it exposes no executable in this repo. This section uses a clearly labeled plain diff fallback.

+
diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift
++ static let streamCandidateMessageHandler = "dreamioStreamCandidate"
++ configuration.userContentController.add(
++     WeakScriptMessageHandler(delegate: self),
++     name: Constants.streamCandidateMessageHandler
++ )
++ configuration.userContentController.addUserScript(Self.streamCandidateScript)
++
++ const nativePatterns = [
++   /\/\/addon\.debridio\.com\/play\//i,
++   /\/\/torrentio\.strem\.fun\/resolve\//i,
++   /\/\/download\.real-debrid\.com\//i,
++   /\.(mkv|avi|webm)(?:[?#]|$)/i
++ ];
++
++ private func handleStreamCandidate(_ candidate: StreamCandidate) {
++     guard let request = StreamClassifier.playbackRequest(from: candidate, userAgent: userAgent) else {
++         return
++     }
++     let player = NativePlayerViewController(request: request)
++     present(player, animated: true)
++ }
+
+diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift
++ enum StreamClassifier {
++     static func playbackRequest(
++         from candidate: StreamCandidate,
++         userAgent: String?
++     ) -> NativePlaybackRequest? {
++         let classification = classify(candidate: candidate)
++         guard classification.shouldIntercept else { return nil }
++         return NativePlaybackRequest(
++             playbackURL: candidate.resolverURL ?? candidate.observedURL,
++             observedURL: candidate.observedURL,
++             resolverURL: candidate.resolverURL,
++             pageURL: candidate.pageURL,
++             userAgent: userAgent,
++             referer: "https://web.stremio.com/",
++             classification: classification
++         )
++     }
++ }
+
+diff --git a/Podfile b/Podfile
++ platform :ios, '16.0'
++ target 'Dreamio' do
++   use_frameworks!
++   pod 'MobileVLCKit'
++ end
+
+ +
+

Expected Impact for End-Users

+

Users should keep using Dreamio through the Stremio Web interface, but direct debrid MKV, AVI, and WebM streams should now open in native playback instead of falling through to WebKit's unsupported media failure path. Closing the native player returns to the same web session.

+
+ +
+

Validation

+
    +
  • Passed: JavaScript bridge syntax was checked with node --check.
  • +
  • Passed: Swift Foundation-only classifier file type-checked with xcrun swiftc -typecheck Dreamio/StreamCandidate.swift.
  • +
  • Passed: Whitespace validation passed with git diff --check.
  • +
  • Blocked: pod install could not run because CocoaPods is not installed on this machine.
  • +
  • Blocked: iOS build validation could not run because active developer tools are Command Line Tools and the iPhoneOS SDK is unavailable.
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • Native playback improves container support, but it cannot guarantee every codec, audio format, subtitle format, HDR variant, or expired debrid URL will play.
  • +
  • The workspace and lockfile are not generated here because CocoaPods is unavailable. The README now makes the required local workflow explicit.
  • +
  • Manual real-device validation is still required for actual Debridio, Torrentio, and Real-Debrid streams.
  • +
  • DEBUG logs are intentionally sanitized and should not include full debrid URLs, query strings, tokens, signed paths, or long secret-like path segments.
  • +
+
+ +
+

Follow-up Work

+
    +
  • Install CocoaPods locally, run pod install, commit the resulting Podfile.lock and workspace metadata if appropriate.
  • +
  • Open Dreamio.xcworkspace in full Xcode and build on a real iOS device.
  • +
  • Validate sample Debridio, Torrentio, and Real-Debrid URLs, including HTTP 206 direct download responses.
  • +
  • Consider adding a tiny XCTest target for classifier behavior once the project has a test bundle.
  • +
+
+
+ +