import UIKit import WebKit final class DreamioWebViewController: UIViewController { private enum Constants { static let stremioWebURL = URL(string: "https://web.stremio.com/")! static let diagnosticsMessageHandler = "dreamioDiagnostics" static let streamCandidateMessageHandler = "dreamioStreamCandidate" static let subtitleCandidateMessageHandler = "dreamioSubtitleCandidate" } private lazy var webView: WKWebView = { let configuration = WKWebViewConfiguration() configuration.defaultWebpagePreferences.allowsContentJavaScript = true configuration.allowsInlineMediaPlayback = true configuration.mediaTypesRequiringUserActionForPlayback = [] configuration.preferences.javaScriptCanOpenWindowsAutomatically = true configuration.userContentController.add( WeakScriptMessageHandler(delegate: self), name: Constants.streamCandidateMessageHandler ) configuration.userContentController.add( WeakScriptMessageHandler(delegate: self), name: Constants.subtitleCandidateMessageHandler ) configuration.userContentController.addUserScript(Self.streamCandidateScript) #if DEBUG configuration.userContentController.add( WeakScriptMessageHandler(delegate: self), name: Constants.diagnosticsMessageHandler ) configuration.userContentController.addUserScript(Self.playbackDiagnosticsScript) #endif let webView = WKWebView(frame: .zero, configuration: configuration) webView.translatesAutoresizingMaskIntoConstraints = false webView.allowsBackForwardNavigationGestures = true webView.customUserAgent = "Dreamio/0.1 WKWebView" webView.navigationDelegate = self webView.uiDelegate = self webView.scrollView.contentInsetAdjustmentBehavior = .never #if DEBUG if #available(iOS 16.4, *) { webView.isInspectable = true } #endif return webView }() private let progressView: UIProgressView = { let view = UIProgressView(progressViewStyle: .bar) view.translatesAutoresizingMaskIntoConstraints = false view.tintColor = UIColor(red: 0.55, green: 0.35, blue: 0.95, alpha: 1.0) return view }() private var progressObservation: NSKeyValueObservation? private var userAgent: String? private var lastNativePlaybackURL: URL? private var pendingSubtitleCandidatesByStreamKey: [URL: [SubtitleCandidate]] = [:] private var currentNativePlaybackKey: URL? private weak var currentNativePlayer: NativePlayerViewController? private let streamResolver: StreamResolving = StremioStreamResolver() private let remoteServerStore = RemoteStremioServerStore() private let remoteServerValidator = RemoteStremioServerValidator() private lazy var remoteServerButton: UIButton = { let button = UIButton(type: .system) button.translatesAutoresizingMaskIntoConstraints = false button.setImage(UIImage(systemName: "server.rack"), for: .normal) button.tintColor = UIColor(red: 0.55, green: 0.35, blue: 0.95, alpha: 1.0) button.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.72) button.layer.cornerRadius = 22 button.layer.borderColor = UIColor.label.withAlphaComponent(0.12).cgColor button.layer.borderWidth = 1 button.accessibilityLabel = "Remote Stremio Server" button.accessibilityHint = "Opens advanced settings for a self-hosted Stremio Server." button.addTarget(self, action: #selector(showRemoteServerMenu), for: .touchUpInside) return button }() 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 subtitleCandidates = []; const postedSubtitleURLs = new Set(); const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig; const subtitleSignalPattern = /subtitle|subtitles|opensubtitles|vtt|srt|ass|ssa/i; const subtitleExtensions = new Set(["srt", "vtt", "ass", "ssa", "sub"]); const nonSubtitleExtensions = new Set([ "aac", "avi", "bmp", "css", "gif", "heic", "ico", "jpeg", "jpg", "js", "json", "m4a", "m4v", "mkv", "mov", "mp3", "mp4", "mpeg", "mpg", "png", "svg", "ts", "webm", "webp" ]); const subtitleObjectKeys = [ "attributes", "files", "file_id", "url", "download", "link", "file", "file_name", "filename", "language", "lang" ]; 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 isOpenSubtitlesManifestID = (url) => { try { const parsed = new URL(url, window.location.href); return /opensubtitles/i.test(parsed.hostname) && /\/manifest\.json(?:_\d+)?$/i.test(parsed.pathname); } catch (_) { return false; } }; const isDirectSubtitleFileURL = (url) => { try { const parsed = new URL(url, window.location.href); const extension = parsed.pathname.split(".").pop().toLowerCase(); return subtitleExtensions.has(extension) || Array.from(subtitleExtensions).some((ext) => parsed.href.toLowerCase().includes(`.${ext}?`) || parsed.href.toLowerCase().includes(`.${ext}&`)); } catch (_) { return false; } }; const isProbablyNonSubtitleAssetURL = (url) => { try { const extension = new URL(url, window.location.href).pathname.split(".").pop().toLowerCase(); return nonSubtitleExtensions.has(extension); } catch (_) { return false; } }; const isOpenSubtitlesDownloadURL = (url) => { try { const parsed = new URL(url, window.location.href); const host = parsed.hostname.toLowerCase(); const path = parsed.pathname.toLowerCase(); if (!host.includes("opensubtitles")) { return false; } if (/\/manifest\.json(?:_\d+)?$/i.test(path)) { return false; } return /\/api\/v1\/download(?:\/|$)/i.test(path) || /\/download(?:\/|$)/i.test(path) || /\/subtitles?(?:\/|$)/i.test(path); } catch (_) { return false; } }; const isStremioSubtitleDownloadURL = (url) => { try { const parsed = new URL(url, window.location.href); const host = parsed.hostname.toLowerCase(); const path = parsed.pathname.toLowerCase(); return host === "strem.io" || host.endsWith(".strem.io") ? /\/[a-z]{2,3}\/download(?:\/|$)/i.test(path) || /\/download(?:\/|$)/i.test(path) : false; } catch (_) { return false; } }; const isSubtitleURL = (url) => { if (!url || isOpenSubtitlesManifestID(url)) { return false; } return !isProbablyNonSubtitleAssetURL(url) && (isDirectSubtitleFileURL(url) || isOpenSubtitlesDownloadURL(url) || isStremioSubtitleDownloadURL(url)); }; 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; } stopNativeHandledMedia(element); 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 : "", subtitles: subtitleCandidates.slice(-20) }); } catch (_) {} }; const postSubtitleCandidates = (candidates, debug = {}) => { const discoveredCount = candidates.length; const fresh = candidates.filter((candidate) => { const key = candidate && (candidate.url || candidate.link || candidate.download || candidate.file || candidate.file_id); if (!key) { return false; } if (postedSubtitleURLs.has(String(key))) { return false; } postedSubtitleURLs.add(String(key)); return true; }); if (fresh.length === 0) { try { window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({ pageUrl: window.location.href, subtitles: [], debug: { discovered: discoveredCount, deduped: 0, forwarded: 0, ...debug } }); } catch (_) {} return; } try { window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({ pageUrl: window.location.href, subtitles: fresh, debug: { discovered: discoveredCount, deduped: fresh.length, forwarded: fresh.length, ...debug } }); } catch (_) {} }; const addSubtitleCandidate = (entry) => { const rawURL = typeof entry === "string" ? entry : entry && ( entry.url || entry.href || entry.src || entry.link || entry.file || entry.download || entry.externalUrl || entry.externalURL || entry.fileUrl || entry.fileURL ); let url = absoluteURL(rawURL); if ((!url || isOpenSubtitlesManifestID(url)) && entry && entry.file_id) { url = `https://api.opensubtitles.com/api/v1/download/${encodeURIComponent(String(entry.file_id))}`; } if (!isSubtitleURL(url)) { return; } if (subtitleCandidates.some((candidate) => candidate.url === url)) { return; } const candidate = { url, label: entry && (entry.label || entry.name || entry.title || entry.lang || entry.language) || "External Subtitle", language: entry && (entry.lang || entry.language) || "" }; subtitleCandidates.push(candidate); postSubtitleCandidates([candidate], { discovered: 1, totalKnown: subtitleCandidates.length }); }; const inspectTrack = (track) => { if (!track) { return; } if (track instanceof HTMLTrackElement) { addSubtitleCandidate({ url: track.src || track.getAttribute("src") || "", label: track.label || track.srclang || "External Subtitle", language: track.srclang || "" }); return; } const source = track.src || track.url || ""; if (source) { addSubtitleCandidate({ url: source, label: track.label || track.language || track.kind || "External Subtitle", language: track.language || "" }); } }; const inspectTextTracks = (media) => { try { Array.from(media.textTracks || []).forEach(inspectTrack); } catch (_) {} try { media.querySelectorAll("track").forEach(inspectTrack); } catch (_) {} }; const postSubtitleInspection = (source, url, beforeCount, afterCount, payloadLength) => { if (afterCount > beforeCount) { return; } postSubtitleCandidates([], { source, inspected: true, url: url || "", payloadLength: payloadLength || 0, totalKnown: subtitleCandidates.length }); }; const inspectSubtitlePayload = (payload) => { if (!payload) { return; } if (typeof payload === "string") { const matches = payload.match(subtitleURLPattern) || []; subtitleURLPattern.lastIndex = 0; matches.forEach(addSubtitleCandidate); try { inspectSubtitlePayload(JSON.parse(payload)); } catch (_) {} return; } if (Array.isArray(payload)) { payload.forEach(inspectSubtitlePayload); return; } if (typeof payload === "object") { addSubtitleCandidate(payload); const likelySubtitlePayload = subtitleObjectKeys.some((key) => Object.prototype.hasOwnProperty.call(payload, key)); if (likelySubtitlePayload) { postSubtitleCandidates([payload], { source: "payload-object", totalKnown: subtitleCandidates.length }); } Object.values(payload).forEach(inspectSubtitlePayload); } }; const inspectSubtitleText = (source, url, text) => { const beforeCount = subtitleCandidates.length; inspectSubtitlePayload(text); postSubtitleInspection(source, url, beforeCount, subtitleCandidates.length, text ? text.length : 0); }; const inspectMessagePayload = (source, payload) => { const beforeCount = subtitleCandidates.length; inspectSubtitlePayload(payload); postSubtitleInspection(source, "", beforeCount, subtitleCandidates.length, 0); }; const originalFetch = window.fetch; if (originalFetch) { window.fetch = async (...args) => { const response = await originalFetch(...args); try { const contentType = response.headers && response.headers.get("content-type") || ""; const url = response.url || ""; subtitleURLPattern.lastIndex = 0; const shouldInspect = !contentType || /json|text|javascript|xml|subtitle|vtt|srt/i.test(contentType) || subtitleURLPattern.test(url) || subtitleSignalPattern.test(url); if (shouldInspect) { subtitleURLPattern.lastIndex = 0; response.clone().text().then((text) => { inspectSubtitleText("fetch", url, text); }).catch(() => {}); } } catch (_) {} return response; }; } const originalXHRSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function(...args) { try { this.addEventListener("load", () => { try { const responseType = this.responseType || ""; if (responseType && responseType !== "text") { return; } const url = this.responseURL || ""; const text = this.responseText || ""; if (subtitleSignalPattern.test(url) || subtitleSignalPattern.test(text)) { inspectSubtitleText("xhr", url, text); } else { inspectSubtitlePayload(text); } } catch (_) {} }); } catch (_) {} return originalXHRSend.apply(this, args); }; const originalWindowPostMessage = window.postMessage; if (originalWindowPostMessage) { window.postMessage = function(message, targetOrigin, transfer) { try { inspectMessagePayload("window.postMessage", message); } catch (_) {} return originalWindowPostMessage.apply(this, arguments); }; } window.addEventListener("message", (event) => { try { inspectMessagePayload("window.message", event.data); } catch (_) {} }, true); const OriginalWorker = window.Worker; if (OriginalWorker) { window.Worker = function(...args) { const worker = new OriginalWorker(...args); try { const originalWorkerPostMessage = worker.postMessage; worker.postMessage = function(message, transfer) { try { inspectMessagePayload("worker.postMessage", message); } catch (_) {} return originalWorkerPostMessage.apply(this, arguments); }; worker.addEventListener("message", (event) => { try { inspectMessagePayload("worker.message", event.data); } catch (_) {} }, true); } catch (_) {} return worker; }; try { window.Worker.prototype = OriginalWorker.prototype; } catch (_) {} } if (window.MessagePort && window.MessagePort.prototype) { const originalPortPostMessage = window.MessagePort.prototype.postMessage; if (originalPortPostMessage) { window.MessagePort.prototype.postMessage = function(message, transfer) { try { inspectMessagePayload("message-port.postMessage", message); } catch (_) {} return originalPortPostMessage.apply(this, arguments); }; } const originalPortAddEventListener = window.MessagePort.prototype.addEventListener; if (originalPortAddEventListener) { window.MessagePort.prototype.addEventListener = function(type, listener, options) { if (type === "message" && typeof listener === "function") { const wrapped = function(event) { try { inspectMessagePayload("message-port.message", event && event.data); } catch (_) {} return listener.apply(this, arguments); }; return originalPortAddEventListener.call(this, type, wrapped, options); } return originalPortAddEventListener.apply(this, arguments); }; } } const OriginalBroadcastChannel = window.BroadcastChannel; if (OriginalBroadcastChannel) { window.BroadcastChannel = function(...args) { const channel = new OriginalBroadcastChannel(...args); try { const originalBroadcastPostMessage = channel.postMessage; channel.postMessage = function(message) { try { inspectMessagePayload("broadcast-channel.postMessage", message); } catch (_) {} return originalBroadcastPostMessage.apply(this, arguments); }; channel.addEventListener("message", (event) => { try { inspectMessagePayload("broadcast-channel.message", event.data); } catch (_) {} }, true); } catch (_) {} return channel; }; try { window.BroadcastChannel.prototype = OriginalBroadcastChannel.prototype; } catch (_) {} } const stopNativeHandledMedia = (element) => { const media = element instanceof HTMLVideoElement ? element : element && element.parentElement instanceof HTMLVideoElement ? element.parentElement : null; if (!media) { return; } try { media.pause(); } catch (_) {} try { media.removeAttribute("src"); } catch (_) {} try { media.querySelectorAll("source").forEach((source) => source.removeAttribute("src")); } catch (_) {} try { media.load(); } catch (_) {} }; const inspectMedia = (node) => { if (!node) { return; } if (node instanceof HTMLTrackElement) { inspectTrack(node); } if (node instanceof HTMLVideoElement || node instanceof HTMLSourceElement) { postCandidate(node.currentSrc || node.src || node.getAttribute("src"), node); } if (node.querySelectorAll) { node.querySelectorAll("video, source, track").forEach(inspectMedia); } if (node instanceof HTMLVideoElement) { inspectTextTracks(node); } }; 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 trackSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLTrackElement.prototype, "src"); if (trackSrcDescriptor && trackSrcDescriptor.set) { Object.defineProperty(HTMLTrackElement.prototype, "src", { get: trackSrcDescriptor.get, set(value) { addSubtitleCandidate({ url: value, label: this.label || this.srclang || "External Subtitle", language: this.srclang || "" }); return trackSrcDescriptor.set.call(this, value); } }); } const originalSetAttribute = Element.prototype.setAttribute; Element.prototype.setAttribute = function(name, value) { if (String(name).toLowerCase() === "src") { if (this instanceof HTMLVideoElement || this instanceof HTMLSourceElement) { postCandidate(value, this); } if (this instanceof HTMLTrackElement) { addSubtitleCandidate({ url: value, label: this.label || this.srclang || "External Subtitle", language: this.srclang || "" }); } } return originalSetAttribute.call(this, name, value); }; const originalLoad = HTMLMediaElement.prototype.load; HTMLMediaElement.prototype.load = function() { inspectMedia(this); this.querySelectorAll("source").forEach(inspectMedia); inspectTextTracks(this); return originalLoad.call(this); }; document.addEventListener("addtrack", (event) => { inspectTrack(event.track || event.target); }, true); 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", "label", "srclang"] }); inspectMedia(document); })(); """#, injectionTime: .atDocumentStart, forMainFrameOnly: false ) #if DEBUG private static let playbackDiagnosticsScript = WKUserScript( source: """ (() => { if (window.__dreamioPlaybackDiagnosticsInstalled) { return; } window.__dreamioPlaybackDiagnosticsInstalled = true; const post = (type, payload = {}) => { try { window.webkit.messageHandlers.dreamioDiagnostics.postMessage({ type, payload, href: window.location.href }); } catch (_) {} }; const describeValue = (value) => { if (value instanceof Error) { return { name: value.name, message: value.message, stack: value.stack }; } if (typeof value === "string") { return value; } try { return JSON.stringify(value); } catch (_) { return String(value); } }; ["error", "warn"].forEach((level) => { const original = console[level]; console[level] = (...args) => { post(`console.${level}`, { args: args.map(describeValue) }); original.apply(console, args); }; }); window.addEventListener("unhandledrejection", (event) => { post("unhandledrejection", { reason: describeValue(event.reason) }); }); const videoState = (video) => ({ currentSrc: video.currentSrc || video.src || "", networkState: video.networkState, readyState: video.readyState, errorCode: video.error ? video.error.code : null, errorMessage: video.error ? video.error.message : null }); const attachVideoDiagnostics = (video) => { if (!video || video.__dreamioDiagnosticsAttached) { return; } video.__dreamioDiagnosticsAttached = true; video.addEventListener("error", () => { post("video.error", videoState(video)); }, true); }; document.querySelectorAll("video").forEach(attachVideoDiagnostics); new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node instanceof HTMLVideoElement) { attachVideoDiagnostics(node); } if (node.querySelectorAll) { node.querySelectorAll("video").forEach(attachVideoDiagnostics); } }); }); }).observe(document.documentElement, { childList: true, subtree: true }); })(); """, injectionTime: .atDocumentStart, forMainFrameOnly: false ) #endif override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground view.addSubview(webView) view.addSubview(progressView) view.addSubview(remoteServerButton) NSLayoutConstraint.activate([ webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), webView.topAnchor.constraint(equalTo: view.topAnchor), webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), progressView.leadingAnchor.constraint(equalTo: view.leadingAnchor), progressView.trailingAnchor.constraint(equalTo: view.trailingAnchor), progressView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), remoteServerButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), remoteServerButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -12), remoteServerButton.widthAnchor.constraint(equalToConstant: 44), remoteServerButton.heightAnchor.constraint(equalToConstant: 44) ]) progressObservation = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] webView, _ in self?.updateProgress(webView.estimatedProgress) } loadDreamio() webView.evaluateJavaScript("navigator.userAgent") { [weak self] result, _ in self?.userAgent = result as? String } } private func loadDreamio() { let url = RemoteStremioServerConfiguration.stremioWebURL( baseURL: Constants.stremioWebURL, serverURL: remoteServerStore.serverURL ) let request = URLRequest(url: url) webView.load(request) } private func updateProgress(_ progress: Double) { progressView.isHidden = progress >= 1.0 progressView.setProgress(Float(progress), animated: true) } private func showLoadFailure(_ error: Error) { let alert = UIAlertController( title: "Could not load Dreamio", message: error.localizedDescription, preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "Retry", style: .default) { [weak self] _ in self?.loadDreamio() }) present(alert, animated: true) } @objc private func showRemoteServerMenu() { let currentURL = remoteServerStore.serverURL let currentDisplay = RemoteStremioServerConfiguration.redactedDisplayString(for: currentURL) let message = currentURL == nil ? "Optional power-user feature. Configure your own Stremio Server; Dreamio does not provide or hardcode a server." : "Current server: \(currentDisplay)\nDreamio passes this to Stremio Web as streamingServerUrl and keeps native VLC playback as fallback." let alert = UIAlertController( title: "Remote Stremio Server", message: message, preferredStyle: .actionSheet ) alert.addAction(UIAlertAction(title: "Configure Server URL", style: .default) { [weak self] _ in self?.showRemoteServerConfigurationPrompt(prefill: currentURL) }) if let currentURL { alert.addAction(UIAlertAction(title: "Test Connection", style: .default) { [weak self] _ in self?.testRemoteServer(currentURL) }) alert.addAction(UIAlertAction(title: "Reload Stremio Web", style: .default) { [weak self] _ in self?.loadDreamio() }) alert.addAction(UIAlertAction(title: "Clear Dreamio Override", style: .destructive) { [weak self] _ in self?.remoteServerStore.clear() self?.loadDreamio() self?.showRemoteServerNotice( title: "Remote server override cleared", message: "Dreamio will stop injecting a streamingServerUrl on load. If Stremio Web already saved this server, remove or change it in Stremio Web Settings > Streaming." ) }) } alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) if let popover = alert.popoverPresentationController { popover.sourceView = remoteServerButton popover.sourceRect = remoteServerButton.bounds } present(alert, animated: true) } private func showRemoteServerConfigurationPrompt(prefill: URL?) { let alert = UIAlertController( title: "Configure Remote Stremio Server", message: "Enter the base URL for a Stremio Server you run yourself. HTTPS is recommended for remote servers; localhost and private-network HTTP are allowed for advanced setups.", preferredStyle: .alert ) alert.addTextField { textField in textField.placeholder = "https://stremio.example.com:12470/" textField.text = prefill?.absoluteString textField.keyboardType = .URL textField.autocapitalizationType = .none textField.autocorrectionType = .no textField.clearButtonMode = .whileEditing } alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) alert.addAction(UIAlertAction(title: "Save", style: .default) { [weak self, weak alert] _ in let input = alert?.textFields?.first?.text ?? "" self?.saveRemoteServerInput(input) }) present(alert, animated: true) } private func saveRemoteServerInput(_ input: String, allowInsecureRemoteHTTP: Bool = false) { do { let configuration = try RemoteStremioServerConfiguration( input: input, allowInsecureRemoteHTTP: allowInsecureRemoteHTTP ) remoteServerStore.save(serverURL: configuration.baseURL) loadDreamio() showRemoteServerSavedAlert(configuration.baseURL) } catch let validationError as RemoteStremioServerURLValidationError { if case .insecureRemoteHTTP = validationError, !allowInsecureRemoteHTTP { showInsecureHTTPConfirmation(input: input, message: validationError.localizedDescription) } else { showRemoteServerNotice(title: "Invalid Stremio Server URL", message: validationError.localizedDescription) } } catch { showRemoteServerNotice(title: "Invalid Stremio Server URL", message: error.localizedDescription) } } private func showInsecureHTTPConfirmation(input: String, message: String) { let alert = UIAlertController( title: "Use insecure HTTP?", message: "\(message)\n\nOnly continue if this server is reachable through a trusted VPN, tunnel, or private network. The server can see stream URLs sent to it.", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "Edit URL", style: .cancel) { [weak self] _ in self?.showRemoteServerConfigurationPrompt(prefill: nil) }) alert.addAction(UIAlertAction(title: "Use HTTP Anyway", style: .destructive) { [weak self] _ in self?.saveRemoteServerInput(input, allowInsecureRemoteHTTP: true) }) present(alert, animated: true) } private func showRemoteServerSavedAlert(_ serverURL: URL) { let displayURL = RemoteStremioServerConfiguration.redactedDisplayString(for: serverURL) let alert = UIAlertController( title: "Remote server saved", message: "Dreamio reloaded Stremio Web with \(displayURL). Test the connection if this is a new server.", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "Test Connection", style: .default) { [weak self] _ in self?.testRemoteServer(serverURL) }) alert.addAction(UIAlertAction(title: "OK", style: .cancel)) present(alert, animated: true) } private func testRemoteServer(_ serverURL: URL) { let progress = UIAlertController( title: "Testing Remote Stremio Server", message: RemoteStremioServerConfiguration.redactedDisplayString(for: serverURL), preferredStyle: .alert ) present(progress, animated: true) Task { [weak self, weak progress] in guard let self else { return } do { let summary = try await remoteServerValidator.validate(serverURL: serverURL) await MainActor.run { progress?.dismiss(animated: true) { self.showRemoteServerTestResult(summary) } } } catch { await MainActor.run { progress?.dismiss(animated: true) { self.showRemoteServerNotice( title: "Remote server test failed", message: error.localizedDescription ) } } } } } private func showRemoteServerTestResult(_ summary: RemoteStremioServerValidationSummary) { let reportedBaseURL = summary.reportedBaseURL.map { RemoteStremioServerConfiguration.redactedDisplayString(for: $0) } ?? "Not reported" let message = "Server version: \(summary.serverVersion)\nSettings endpoint: \(RemoteStremioServerConfiguration.redactedDisplayString(for: summary.settingsEndpoint))\nReported base URL: \(reportedBaseURL)\nTranscoding setting advertised: \(summary.hasTranscodingSetting ? "Yes" : "No")" showRemoteServerNotice(title: "Remote server is reachable", message: message) } private func showRemoteServerNotice(title: String, message: String) { let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default)) present(alert, animated: true) } private func handleStreamCandidate(_ candidate: StreamCandidate) { guard let request = StreamClassifier.playbackRequest(from: candidate, userAgent: userAgent) else { return } let duplicateKey = request.resolverURL ?? request.playbackURL if lastNativePlaybackURL == duplicateKey { mergeSubtitleCandidates(candidate.subtitleCandidates, for: duplicateKey) return } lastNativePlaybackURL = duplicateKey currentNativePlaybackKey = duplicateKey mergeSubtitleCandidates(request.subtitleCandidates, for: duplicateKey) let mergedSubtitleCandidates = subtitleCandidates(for: duplicateKey) #if DEBUG let classification = request.classification print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) subtitles=\(mergedSubtitleCandidates.count) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")") #endif let playbackRequest = NativePlaybackRequest( playbackURL: request.playbackURL, observedURL: request.observedURL, resolverURL: request.resolverURL, pageURL: request.pageURL, userAgent: request.userAgent, referer: request.referer, headers: request.headers, classification: request.classification, subtitleCandidates: mergedSubtitleCandidates ) Task { [weak self] in await self?.resolveAndPresentNativePlayback(playbackRequest, streamKey: duplicateKey) } } private func handleSubtitleCandidates(_ candidates: [SubtitleCandidate]) { guard !candidates.isEmpty else { return } let streamKey = currentNativePlaybackKey ?? lastNativePlaybackURL if let streamKey { mergeSubtitleCandidates(candidates, for: streamKey) } #if DEBUG print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) streamKey=\(streamKey.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none") candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))") #endif guard let currentNativePlayer else { #if DEBUG print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player buffered=\(streamKey != nil)") #endif return } let forwarded = currentNativePlayer.addSubtitleCandidates(candidates) #if DEBUG print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=\(forwarded) reason=active-native-player") #endif } @MainActor private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest, streamKey: URL) async { guard VLCNativePlaybackBackend.isAvailable else { lastNativePlaybackURL = nil currentNativePlaybackKey = nil showNativePlaybackUnavailableAlert() return } do { let resolved = try await streamResolver.resolve(request: request) #if DEBUG print("[DreamioStreamResolver] source=\(resolved.source) playback=\(URLRedactor.redactedURLString(resolved.playbackURL.absoluteString))") #endif let resolvedRequest = NativePlaybackRequest( playbackURL: resolved.playbackURL, observedURL: request.observedURL, resolverURL: request.resolverURL, pageURL: request.pageURL, userAgent: request.userAgent, referer: request.referer, headers: resolved.headers, classification: request.classification, subtitleCandidates: subtitleCandidates(for: streamKey) ) let player = NativePlayerViewController(request: resolvedRequest) player.onDismiss = { [weak self] in self?.lastNativePlaybackURL = nil self?.currentNativePlaybackKey = nil self?.currentNativePlayer = nil self?.pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey) self?.cleanUpStremioPlayerAfterNativeDismiss() } present(player, animated: true) { [weak self, weak player] in guard let self, let player else { return } self.currentNativePlayer = player let lateBufferedCandidates = self.subtitleCandidates(for: streamKey) let forwarded = player.addSubtitleCandidates(lateBufferedCandidates) #if DEBUG print("[DreamioSubtitles] presented buffered=\(lateBufferedCandidates.count) forwarded=\(forwarded) streamKey=\(URLRedactor.redactedURLString(streamKey.absoluteString))") #endif } } catch { #if DEBUG print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")") #endif lastNativePlaybackURL = nil currentNativePlaybackKey = nil pendingSubtitleCandidatesByStreamKey.removeValue(forKey: streamKey) showNativePlaybackResolutionFailure(error) } } private func mergeSubtitleCandidates(_ candidates: [SubtitleCandidate], for streamKey: URL) { guard !candidates.isEmpty else { return } let existing = pendingSubtitleCandidatesByStreamKey[streamKey] ?? [] pendingSubtitleCandidatesByStreamKey[streamKey] = Self.mergedSubtitleCandidates(existing + candidates) } private func subtitleCandidates(for streamKey: URL) -> [SubtitleCandidate] { pendingSubtitleCandidatesByStreamKey[streamKey] ?? [] } private static func mergedSubtitleCandidates(_ candidates: [SubtitleCandidate]) -> [SubtitleCandidate] { var orderedKeys: [String] = [] var bestByURL: [String: SubtitleCandidate] = [:] candidates.forEach { candidate in let key = candidate.url.absoluteString if bestByURL[key] == nil { orderedKeys.append(key) bestByURL[key] = candidate } else if let current = bestByURL[key], subtitleCandidateScore(candidate) > subtitleCandidateScore(current) { bestByURL[key] = candidate } } return orderedKeys.compactMap { bestByURL[$0] } } private static func subtitleCandidateScore(_ candidate: SubtitleCandidate) -> Int { let hasUsefulLabel = !candidate.label.isEmpty && candidate.label != candidate.url.deletingPathExtension().lastPathComponent return (hasUsefulLabel ? 2 : 0) + ((candidate.language?.isEmpty == false) ? 1 : 0) } private func showNativePlaybackResolutionFailure(_ error: Error) { let alert = UIAlertController( title: "Could not open stream", message: error.localizedDescription, preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "Close", style: .cancel)) present(alert, animated: true) } private func showNativePlaybackUnavailableAlert() { let alert = UIAlertController( title: "Native playback needs CocoaPods", message: "This build was opened from Dreamio.xcodeproj or built before MobileVLCKit was installed. Run pod install, open Dreamio.xcworkspace, then build again to play MKV, AVI, and WebM streams.", preferredStyle: .alert ) alert.addAction(UIAlertAction(title: "Close", style: .cancel)) present(alert, animated: true) } private func cleanUpStremioPlayerAfterNativeDismiss() { let script = #""" (() => { const stopMedia = () => { document.querySelectorAll("video, audio").forEach((media) => { try { media.pause(); } catch (_) {} try { media.removeAttribute("src"); } catch (_) {} try { media.querySelectorAll("source").forEach((source) => source.removeAttribute("src")); } catch (_) {} try { media.load(); } catch (_) {} }); }; const clickVisible = (selectors) => { for (const selector of selectors) { const nodes = Array.from(document.querySelectorAll(selector)); const match = nodes.find((node) => { const style = window.getComputedStyle(node); const rect = node.getBoundingClientRect(); return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; }); if (match) { try { match.click(); return true; } catch (_) {} } } return false; }; stopMedia(); const clicked = clickVisible([ "[aria-label*='Close' i]", "[aria-label*='Back' i]", "[title*='Close' i]", "[title*='Back' i]", "button[class*='close' i]", "button[class*='back' i]", "[class*='close' i]", "[class*='back' i]", ".player button", "[role='button']" ]); const locationLooksPlayer = /\/(player|stream)\b/i.test(window.location.pathname || "") || /player|stream/i.test(window.location.hash || ""); const visibleBusyPlayer = Boolean(document.querySelector("video, .player, [class*='player' i], [class*='buffer' i]")); const stillPlayer = locationLooksPlayer || (visibleBusyPlayer && /buffer|prepar|stream/i.test(document.body.innerText || "")); return { clicked, stillPlayer, href: window.location.href }; })(); """# webView.evaluateJavaScript(script) { [weak self] result, error in guard let self else { return } #if DEBUG if let error { print("[DreamioCloseFlow] cleanup error=\(URLRedactor.redactedURLString(error.localizedDescription))") } else { print("[DreamioCloseFlow] cleanup result=\(String(describing: result))") } #endif guard error == nil else { self.loadDreamio() return } if self.webView.canGoBack { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { let stillPlayerScript = #""" (() => { const locationLooksPlayer = /\/(player|stream)\b/i.test(window.location.pathname || "") || /player|stream/i.test(window.location.hash || ""); const visibleBusyPlayer = Boolean(document.querySelector("video, .player, [class*='player' i], [class*='buffer' i]")); return locationLooksPlayer || (visibleBusyPlayer && /buffer|prepar|stream/i.test(document.body.innerText || "")); })() """# self.webView.evaluateJavaScript(stillPlayerScript) { result, _ in if (result as? Bool) == true { self.webView.goBack() } } } } } } #if DEBUG private func logDiagnostic(type: String, payload: Any, pageURL: String?) { let redactedPageURL = pageURL.map(redactedURLString) ?? "unknown" let redactedPayload = redactDiagnosticValue(payload) print("[DreamioDiagnostics] \(type) page=\(redactedPageURL) payload=\(redactedPayload)") } private func redactDiagnosticValue(_ value: Any) -> Any { switch value { case let string as String: return redactedURLString(string) case let array as [Any]: return array.map(redactDiagnosticValue) case let dictionary as [String: Any]: return dictionary.reduce(into: [String: Any]()) { result, entry in result[entry.key] = redactDiagnosticValue(entry.value) } default: return value } } private func redactedURLString(_ value: String) -> String { URLRedactor.redactedURLString(value) } private func logSubtitleBridgeMessage(_ body: Any, parsedCandidates: [SubtitleCandidate]) { let dictionary = body as? [String: Any] let debug = dictionary?["debug"] as? [String: Any] let discovered = debug?["discovered"] as? Int ?? parsedCandidates.count let deduped = debug?["deduped"] as? Int ?? parsedCandidates.count let posted = debug?["forwarded"] as? Int ?? parsedCandidates.count let source = debug?["source"] as? String ?? "bridge" let inspected = debug?["inspected"] as? Bool ?? false let inspectedURL = (debug?["url"] as? String).map(redactedURLString) ?? "none" let payloadLength = debug?["payloadLength"] as? Int ?? 0 let totalKnown = debug?["totalKnown"] as? Int ?? parsedCandidates.count let pageURL = dictionary?["pageUrl"] as? String print("[DreamioSubtitles] bridge source=\(source) inspected=\(inspected) discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) totalKnown=\(totalKnown) payloadLength=\(payloadLength) playerActive=\(currentNativePlayer != nil) inspectedURL=\(inspectedURL) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))") } #endif } extension DreamioWebViewController: WKNavigationDelegate { func webView( _ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void ) { guard let url = navigationAction.request.url else { decisionHandler(.cancel) return } if shouldOpenExternally(url: url, navigationType: navigationAction.navigationType) { UIApplication.shared.open(url) decisionHandler(.cancel) return } decisionHandler(.allow) } func webView( _ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void ) { #if DEBUG if let response = navigationResponse.response as? HTTPURLResponse { let url = response.url?.absoluteString ?? "unknown" print("[DreamioNavigation] status=\(response.statusCode) url=\(redactedURLString(url))") } #endif decisionHandler(.allow) } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { #if DEBUG print("[DreamioNavigation] didFail url=\(redactedURLString(webView.url?.absoluteString ?? "unknown")) error=\(redactedURLString(error.localizedDescription))") #endif showLoadFailure(error) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { #if DEBUG print("[DreamioNavigation] didFailProvisional url=\(redactedURLString(webView.url?.absoluteString ?? "unknown")) error=\(redactedURLString(error.localizedDescription))") #endif showLoadFailure(error) } private func shouldOpenExternally(url: URL, navigationType: WKNavigationType) -> Bool { guard let scheme = url.scheme?.lowercased() else { return false } if ["http", "https"].contains(scheme) { return false } if ["mailto", "tel", "sms"].contains(scheme) { return true } return navigationType == .linkActivated } } 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 message.name == Constants.subtitleCandidateMessageHandler { let candidates = SubtitleCandidateParser.candidates(in: message.body) #if DEBUG logSubtitleBridgeMessage(message.body, parsedCandidates: candidates) #endif handleSubtitleCandidates(candidates) return } #if DEBUG guard message.name == Constants.diagnosticsMessageHandler, let body = message.body as? [String: Any], let type = body["type"] as? String else { return } logDiagnostic( type: type, payload: body["payload"] ?? [:], pageURL: body["href"] as? String ) #endif } } private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { weak var delegate: WKScriptMessageHandler? init(delegate: WKScriptMessageHandler) { self.delegate = delegate super.init() } func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { delegate?.userContentController(userContentController, didReceive: message) } } extension DreamioWebViewController: WKUIDelegate { func webView( _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { if navigationAction.targetFrame == nil { webView.load(navigationAction.request) } return nil } }