mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
add native debrid stream playback
This commit is contained in:
parent
3df2e2b833
commit
d28540ce98
12 changed files with 936 additions and 62 deletions
|
|
@ -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-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-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-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."}}
|
||||||
|
|
|
||||||
|
|
@ -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-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-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}
|
{"_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
3
.gitignore
vendored
|
|
@ -6,3 +6,6 @@
|
||||||
|
|
||||||
# Node tooling
|
# Node tooling
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# CocoaPods
|
||||||
|
Pods/
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@
|
||||||
6F2A2B362C00100100DREAMIO /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B332C00100100DREAMIO /* AppDelegate.swift */; };
|
6F2A2B362C00100100DREAMIO /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B332C00100100DREAMIO /* AppDelegate.swift */; };
|
||||||
6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */; };
|
6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */; };
|
||||||
6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
|
@ -18,6 +22,10 @@
|
||||||
6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
|
@ -53,6 +61,10 @@
|
||||||
6F2A2B332C00100100DREAMIO /* AppDelegate.swift */,
|
6F2A2B332C00100100DREAMIO /* AppDelegate.swift */,
|
||||||
6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */,
|
6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */,
|
||||||
6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */,
|
6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */,
|
||||||
|
6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */,
|
||||||
|
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,
|
||||||
|
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,
|
||||||
|
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,
|
||||||
6F2A2B392C00100100DREAMIO /* Info.plist */,
|
6F2A2B392C00100100DREAMIO /* Info.plist */,
|
||||||
);
|
);
|
||||||
path = Dreamio;
|
path = Dreamio;
|
||||||
|
|
@ -129,6 +141,10 @@
|
||||||
6F2A2B362C00100100DREAMIO /* AppDelegate.swift in Sources */,
|
6F2A2B362C00100100DREAMIO /* AppDelegate.swift in Sources */,
|
||||||
6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */,
|
6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */,
|
||||||
6F2A2B382C00100100DREAMIO /* DreamioWebViewController.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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ final class DreamioWebViewController: UIViewController {
|
||||||
private enum Constants {
|
private enum Constants {
|
||||||
static let stremioWebURL = URL(string: "https://web.stremio.com/")!
|
static let stremioWebURL = URL(string: "https://web.stremio.com/")!
|
||||||
static let diagnosticsMessageHandler = "dreamioDiagnostics"
|
static let diagnosticsMessageHandler = "dreamioDiagnostics"
|
||||||
|
static let streamCandidateMessageHandler = "dreamioStreamCandidate"
|
||||||
}
|
}
|
||||||
|
|
||||||
private lazy var webView: WKWebView = {
|
private lazy var webView: WKWebView = {
|
||||||
|
|
@ -13,6 +14,11 @@ final class DreamioWebViewController: UIViewController {
|
||||||
configuration.allowsInlineMediaPlayback = true
|
configuration.allowsInlineMediaPlayback = true
|
||||||
configuration.mediaTypesRequiringUserActionForPlayback = []
|
configuration.mediaTypesRequiringUserActionForPlayback = []
|
||||||
configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
|
configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
|
||||||
|
configuration.userContentController.add(
|
||||||
|
WeakScriptMessageHandler(delegate: self),
|
||||||
|
name: Constants.streamCandidateMessageHandler
|
||||||
|
)
|
||||||
|
configuration.userContentController.addUserScript(Self.streamCandidateScript)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
configuration.userContentController.add(
|
configuration.userContentController.add(
|
||||||
WeakScriptMessageHandler(delegate: self),
|
WeakScriptMessageHandler(delegate: self),
|
||||||
|
|
@ -44,6 +50,142 @@ final class DreamioWebViewController: UIViewController {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private var progressObservation: NSKeyValueObservation?
|
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
|
#if DEBUG
|
||||||
private static let playbackDiagnosticsScript = WKUserScript(
|
private static let playbackDiagnosticsScript = WKUserScript(
|
||||||
|
|
@ -157,6 +299,9 @@ final class DreamioWebViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
loadDreamio()
|
loadDreamio()
|
||||||
|
webView.evaluateJavaScript("navigator.userAgent") { [weak self] result, _ in
|
||||||
|
self?.userAgent = result as? String
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadDreamio() {
|
private func loadDreamio() {
|
||||||
|
|
@ -181,6 +326,28 @@ final class DreamioWebViewController: UIViewController {
|
||||||
present(alert, animated: true)
|
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
|
#if DEBUG
|
||||||
private func logDiagnostic(type: String, payload: Any, pageURL: String?) {
|
private func logDiagnostic(type: String, payload: Any, pageURL: String?) {
|
||||||
let redactedPageURL = pageURL.map(redactedURLString) ?? "unknown"
|
let redactedPageURL = pageURL.map(redactedURLString) ?? "unknown"
|
||||||
|
|
@ -204,44 +371,7 @@ final class DreamioWebViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func redactedURLString(_ value: String) -> String {
|
private func redactedURLString(_ value: String) -> String {
|
||||||
guard var components = URLComponents(string: value), components.scheme != nil else {
|
URLRedactor.redactedURLString(value)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
@ -311,9 +441,15 @@ extension DreamioWebViewController: WKNavigationDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
extension DreamioWebViewController: WKScriptMessageHandler {
|
extension DreamioWebViewController: WKScriptMessageHandler {
|
||||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
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,
|
guard message.name == Constants.diagnosticsMessageHandler,
|
||||||
let body = message.body as? [String: Any],
|
let body = message.body as? [String: Any],
|
||||||
let type = body["type"] as? String
|
let type = body["type"] as? String
|
||||||
|
|
@ -326,6 +462,7 @@ extension DreamioWebViewController: WKScriptMessageHandler {
|
||||||
payload: body["payload"] ?? [:],
|
payload: body["payload"] ?? [:],
|
||||||
pageURL: body["href"] as? String
|
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 {
|
extension DreamioWebViewController: WKUIDelegate {
|
||||||
func webView(
|
func webView(
|
||||||
|
|
|
||||||
22
Dreamio/NativePlaybackBackend.swift
Normal file
22
Dreamio/NativePlaybackBackend.swift
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
Dreamio/NativePlayerViewController.swift
Normal file
134
Dreamio/NativePlayerViewController.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
208
Dreamio/StreamCandidate.swift
Normal file
208
Dreamio/StreamCandidate.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
Dreamio/VLCNativePlaybackBackend.swift
Normal file
58
Dreamio/VLCNativePlaybackBackend.swift
Normal 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
7
Podfile
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
platform :ios, '16.0'
|
||||||
|
|
||||||
|
target 'Dreamio' do
|
||||||
|
use_frameworks!
|
||||||
|
|
||||||
|
pod 'MobileVLCKit'
|
||||||
|
end
|
||||||
47
README.md
47
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
|
allows inline media playback, and leaves playback viability to real-device
|
||||||
testing.
|
testing.
|
||||||
|
|
||||||
## Running the MVP
|
## Running Dreamio
|
||||||
|
|
||||||
1. Open `Dreamio.xcodeproj` in Xcode.
|
1. Install CocoaPods if needed.
|
||||||
2. Select the `Dreamio` scheme.
|
2. Run `pod install`.
|
||||||
3. Pick a real iPhone or iPad device.
|
3. Open `Dreamio.xcworkspace` in Xcode.
|
||||||
4. Set a development team for code signing if Xcode asks.
|
4. Select the `Dreamio` scheme.
|
||||||
5. Build and run.
|
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
|
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.
|
1. Cold launch loads hosted Stremio Web.
|
||||||
- Login completes and persists after app relaunch.
|
2. Login completes and persists after app relaunch.
|
||||||
- Catalog and library navigation work.
|
3. Catalog and library navigation work.
|
||||||
- Addon install or configuration flows work, including redirects or popups.
|
4. Addon install or configuration flows work, including redirects or popups.
|
||||||
- HLS direct stream playback works.
|
5. HLS direct stream playback works.
|
||||||
- MP4 direct stream playback works.
|
6. MP4 direct stream playback works.
|
||||||
- Unsupported formats fail understandably.
|
7. Debridio, Torrentio, and Real-Debrid MKV/AVI/WebM direct-file streams open
|
||||||
- Fullscreen, rotation, pause/resume, and background/foreground behavior are
|
the native player before WebKit reaches its visible media failure state.
|
||||||
acceptable for v1.
|
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,
|
Track playback results by device, iOS version, stream protocol, container,
|
||||||
codec, subtitle type, HTTP status, and WebKit media error when available.
|
codec, subtitle type, HTTP status, and WebKit media error when available.
|
||||||
|
|
|
||||||
280
docs/turns/2026-05-24-native-debrid-playback.html
Normal file
280
docs/turns/2026-05-24-native-debrid-playback.html
Normal 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue