add native debrid stream playback

This commit is contained in:
dirtydishes 2026-05-24 23:20:32 -04:00
parent 3df2e2b833
commit d28540ce98
12 changed files with 936 additions and 62 deletions

View file

@ -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."}}

View file

@ -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}

3
.gitignore vendored
View file

@ -6,3 +6,6 @@
# Node tooling
node_modules/
# CocoaPods
Pods/

View file

@ -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 = "<group>"; };
6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DreamioWebViewController.swift; sourceTree = "<group>"; };
6F2A2B392C00100100DREAMIO /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamCandidate.swift; sourceTree = "<group>"; };
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlaybackBackend.swift; sourceTree = "<group>"; };
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>"; };
/* 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;
};

View file

@ -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(

View file

@ -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."
}
}
}

View file

@ -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)
}
}

View file

@ -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
)
}
}
}

View file

@ -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
}
}
}

7
Podfile Normal file
View file

@ -0,0 +1,7 @@
platform :ios, '16.0'
target 'Dreamio' do
use_frameworks!
pod 'MobileVLCKit'
end

View file

@ -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.

View file

@ -0,0 +1,280 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Native Debrid Playback</title>
<style>
:root {
color-scheme: light;
--bg: oklch(0.985 0.006 285);
--surface: oklch(0.955 0.01 285);
--ink: oklch(0.22 0.025 285);
--muted: oklch(0.48 0.025 285);
--line: oklch(0.86 0.018 285);
--accent: oklch(0.52 0.18 292);
--accent-soft: oklch(0.92 0.035 292);
--good: oklch(0.52 0.12 154);
--warn: oklch(0.63 0.13 72);
--code-bg: oklch(0.18 0.018 285);
--code-ink: oklch(0.94 0.01 285);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
line-height: 1.55;
}
main {
width: min(980px, calc(100% - 32px));
margin: 0 auto;
padding: 44px 0 64px;
}
header {
border-bottom: 1px solid var(--line);
margin-bottom: 30px;
padding-bottom: 24px;
}
h1 {
font-size: 2.1rem;
line-height: 1.12;
margin: 0 0 12px;
letter-spacing: 0;
}
h2 {
font-size: 1.08rem;
margin: 34px 0 10px;
letter-spacing: 0;
}
p {
max-width: 72ch;
margin: 0 0 14px;
}
ul {
margin: 10px 0 0;
padding-left: 22px;
}
li {
margin: 7px 0;
max-width: 76ch;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.94em;
}
pre {
margin: 14px 0 0;
overflow-x: auto;
border-radius: 8px;
background: var(--code-bg);
color: var(--code-ink);
padding: 16px;
line-height: 1.45;
}
.summary {
border: 1px solid var(--line);
background: var(--surface);
border-radius: 8px;
padding: 18px 20px;
}
.meta {
color: var(--muted);
font-size: 0.95rem;
margin: 0;
}
.pill-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.pill {
border: 1px solid var(--line);
border-radius: 999px;
background: var(--accent-soft);
color: var(--ink);
font-size: 0.88rem;
padding: 5px 10px;
}
.note {
border: 1px solid var(--line);
border-radius: 8px;
background: oklch(0.975 0.008 285);
padding: 14px 16px;
color: var(--muted);
}
.status {
color: var(--good);
font-weight: 650;
}
.warning {
color: var(--warn);
font-weight: 650;
}
.diffs-fallback {
border: 1px solid oklch(0.32 0.025 285);
}
</style>
</head>
<body>
<main>
<header>
<p class="meta">Dreamio turn document · 2026-05-24 23:18 EDT · Beads issue <code>dreamio-l68</code></p>
<h1>Native Direct-Stream Playback for Debrid Files</h1>
<p class="summary">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.</p>
<div class="pill-row">
<span class="pill">WKWebView bridge</span>
<span class="pill">Stream classification</span>
<span class="pill">MobileVLCKit backend</span>
<span class="pill">Sanitized diagnostics</span>
</div>
</header>
<section>
<h2>Summary</h2>
<p>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.</p>
</section>
<section>
<h2>Changes Made</h2>
<ul>
<li>Added a production JavaScript bridge in <code>DreamioWebViewController</code> that observes video/source URLs, direct <code>src</code> assignment, <code>setAttribute("src")</code>, mutations, and <code>load()</code>.</li>
<li>Added stream classification for Debridio, Torrentio, Real-Debrid, MKV, AVI, WebM, HLS, and MP4 candidates.</li>
<li>Added redacted URL diagnostics that strip query strings, fragments, and long token-like path segments before DEBUG logging.</li>
<li>Added <code>NativePlayerViewController</code>, <code>NativePlaybackBackend</code>, and the first backend implementation, <code>VLCNativePlaybackBackend</code>.</li>
<li>Added a CocoaPods <code>Podfile</code> for <code>MobileVLCKit</code> and ignored generated <code>Pods/</code> content.</li>
<li>Updated README workflow instructions to use <code>pod install</code> and <code>Dreamio.xcworkspace</code>.</li>
</ul>
</section>
<section>
<h2>Context</h2>
<p>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.</p>
</section>
<section>
<h2>Important Implementation Details</h2>
<ul>
<li>The bridge allows ordinary HLS and MP4 playback to continue in WebKit unless the URL also matches a known direct-file debrid rule.</li>
<li>Native playback prefers the resolver URL when one is available, which avoids unnecessarily reusing short-lived observed CDN links.</li>
<li>The native playback request carries the current user agent when available and sets <code>Referer: https://web.stremio.com/</code>.</li>
<li>The native player clears duplicate suppression on dismissal, so selecting the same stream again can reopen playback.</li>
<li>MobileVLCKit is behind a small protocol so the player controller is not permanently coupled to VLC.</li>
</ul>
</section>
<section>
<h2>Relevant Diff Snippets</h2>
<p class="note">Repository instructions prefer <code>@pierre/diffs</code> output. The package is installed as a library, but <code>npx @pierre/diffs --help</code> failed because it exposes no executable in this repo. This section uses a clearly labeled plain diff fallback.</p>
<pre class="diffs-fallback"><code>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</code></pre>
</section>
<section>
<h2>Expected Impact for End-Users</h2>
<p>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.</p>
</section>
<section>
<h2>Validation</h2>
<ul>
<li><span class="status">Passed:</span> JavaScript bridge syntax was checked with <code>node --check</code>.</li>
<li><span class="status">Passed:</span> Swift Foundation-only classifier file type-checked with <code>xcrun swiftc -typecheck Dreamio/StreamCandidate.swift</code>.</li>
<li><span class="status">Passed:</span> Whitespace validation passed with <code>git diff --check</code>.</li>
<li><span class="warning">Blocked:</span> <code>pod install</code> could not run because CocoaPods is not installed on this machine.</li>
<li><span class="warning">Blocked:</span> iOS build validation could not run because active developer tools are Command Line Tools and the iPhoneOS SDK is unavailable.</li>
</ul>
</section>
<section>
<h2>Issues, Limitations, and Mitigations</h2>
<ul>
<li>Native playback improves container support, but it cannot guarantee every codec, audio format, subtitle format, HDR variant, or expired debrid URL will play.</li>
<li>The workspace and lockfile are not generated here because CocoaPods is unavailable. The README now makes the required local workflow explicit.</li>
<li>Manual real-device validation is still required for actual Debridio, Torrentio, and Real-Debrid streams.</li>
<li>DEBUG logs are intentionally sanitized and should not include full debrid URLs, query strings, tokens, signed paths, or long secret-like path segments.</li>
</ul>
</section>
<section>
<h2>Follow-up Work</h2>
<ul>
<li>Install CocoaPods locally, run <code>pod install</code>, commit the resulting <code>Podfile.lock</code> and workspace metadata if appropriate.</li>
<li>Open <code>Dreamio.xcworkspace</code> in full Xcode and build on a real iOS device.</li>
<li>Validate sample Debridio, Torrentio, and Real-Debrid URLs, including HTTP 206 direct download responses.</li>
<li>Consider adding a tiny XCTest target for classifier behavior once the project has a test bundle.</li>
</ul>
</section>
</main>
</body>
</html>