diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 952d1bb..27ba9b0 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -45,3 +45,4 @@ {"id":"int-c55d4a7b","kind":"field_change","created_at":"2026-05-27T04:20:08.84278Z","actor":"dirtydishes","issue_id":"dreamio-e2q","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed by gating native startup loading until VLC readiness and startup subtitle candidate processing both complete."}} {"id":"int-bfb2d962","kind":"field_change","created_at":"2026-05-27T04:30:15.810274Z","actor":"dirtydishes","issue_id":"dreamio-69r","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented AVAudioSession warm-up around native VLC pause/resume and validated simulator build."}} {"id":"int-b32c40f0","kind":"field_change","created_at":"2026-05-27T06:14:56.320425Z","actor":"dirtydishes","issue_id":"dreamio-0pi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented MobileVLCKit resume sync changes: removed repeated resume seek-holding, simplified streaming cache options, retained DEBUG resume observation, updated tests, and documented the turn. Real-device validation is tracked in dreamio-yny."}} +{"id":"int-1b7a4753","kind":"field_change","created_at":"2026-05-27T17:03:31.966463Z","actor":"dirtydishes","issue_id":"dreamio-dc6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented optional user-configured Remote Stremio Server support with URL validation, Stremio Web streamingServerUrl injection, connection testing, docs, tests, and follow-up issue dreamio-8i5 for real-device media validation."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 438ca75..d36ba64 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -2,6 +2,7 @@ {"_type":"issue","id":"dreamio-btc","title":"Bound VLC range cache probe startup latency","description":"After enabling MKV range cache probing, some Torrentio/Real-Debrid MKV streams log cache-probe but never reach opening mode before the native-player startup timeout. Add a bounded probe/local-cache startup path that falls back to direct playback when the range probe is slow or inconclusive.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-26T12:14:02Z","created_by":"dirtydishes","updated_at":"2026-05-26T12:16:53Z","started_at":"2026-05-26T12:14:11Z","closed_at":"2026-05-26T12:16:53Z","close_reason":"Added a short timeout to range-cache probe requests so slow MKV HEAD/range probes fall back to direct VLC startup instead of tripping the native-player startup timeout.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-mun","title":"fix vlc cache loopback port startup","description":"Device logs showed local-cache playback opening http://127.0.0.1:0, because the NWListener ephemeral port was read before the listener reached ready. Wait for the real assigned port before returning the local cache URL.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T22:32:41Z","created_by":"dirtydishes","updated_at":"2026-05-25T22:33:15Z","started_at":"2026-05-25T22:33:14Z","closed_at":"2026-05-25T22:33:15Z","close_reason":"Wait for NWListener ready state before returning the local cache URL; verified tests and simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-8i5","title":"Validate remote Stremio Server playback on device","description":"Run Dreamio on real iPhone/iPad with a user-owned Stremio Server from server-docker and representative legal test streams. Capture whether HTTP/HLS/MP4/MKV/torrent paths are WebKit-playable, server-proxied/transcoded, or fall back to MobileVLCKit.","acceptance_criteria":"Document results for direct MP4, HLS, MKV with common codecs, MKV with unsupported audio/subtitles, and torrent-backed sample; note server version, TLS setup, Stremio Web behavior, WebKit result, and VLC fallback behavior.","status":"open","priority":1,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-27T16:56:57Z","created_by":"dirtydishes","updated_at":"2026-05-27T16:56:57Z","dependencies":[{"issue_id":"dreamio-8i5","depends_on_id":"dreamio-dc6","type":"blocks","created_at":"2026-05-27T12:57:04Z","created_by":"dirtydishes","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-yny","title":"Validate VLC resume audio sync on device","description":"Run manual real-device validation for MobileVLCKit direct-stream pause and resume after removing repeated seek-holding. Capture DEBUG resume-observation logs if audio still lags video.","acceptance_criteria":"Problematic direct-file streams are tested on a real iPhone or iPad; resume audio/video timing is recorded; DEBUG resume-observation logs are attached or summarized if lag persists.","status":"open","priority":1,"issue_type":"task","owner":"dishes@dpdrm.com","created_at":"2026-05-27T06:14:11Z","created_by":"dirtydishes","updated_at":"2026-05-27T06:14:11Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-0pi","title":"Fix MobileVLCKit streaming resume audio sync","description":"Research MobileVLCKit streaming pause/resume behavior and adjust native VLC playback so audio does not lag behind video on every resume.","acceptance_criteria":"MobileVLCKit streaming resume behavior is researched; repeated resume seek-holding is removed or replaced; relevant tests pass; changes are documented.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T05:47:32Z","created_by":"dirtydishes","updated_at":"2026-05-27T06:14:56Z","started_at":"2026-05-27T05:47:35Z","closed_at":"2026-05-27T06:14:56Z","close_reason":"Implemented MobileVLCKit resume sync changes: removed repeated resume seek-holding, simplified streaming cache options, retained DEBUG resume observation, updated tests, and documented the turn. Real-device validation is tracked in dreamio-yny.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-69r","title":"Fix audio lag after native video resume","description":"Audio takes a moment to resume after pausing and playing native video; previous attempts did not resolve the lag.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T04:26:52Z","created_by":"dirtydishes","updated_at":"2026-05-27T04:30:16Z","started_at":"2026-05-27T04:26:56Z","closed_at":"2026-05-27T04:30:16Z","close_reason":"Implemented AVAudioSession warm-up around native VLC pause/resume and validated simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -50,6 +51,7 @@ {"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-dc6","title":"Add optional remote Stremio Server support","description":"Implement a power-user Dreamio setting for user-supplied Stremio Server URLs, wire hosted Stremio Web to that URL without hardcoded service addresses, add validation/routing guardrails, tests, and documentation.","acceptance_criteria":"Users can configure, validate, save, clear, and reload a self-hosted Stremio Server URL; Dreamio passes it to web.stremio.com via the existing streamingServerUrl flow; no Dreamio-hosted URL is hardcoded; URL validation and logging avoid leaking tokens; docs explain server-docker capabilities and limitations.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T16:29:54Z","created_by":"dirtydishes","updated_at":"2026-05-27T17:03:32Z","started_at":"2026-05-27T16:29:59Z","closed_at":"2026-05-27T17:03:32Z","close_reason":"Implemented optional user-configured Remote Stremio Server support with URL validation, Stremio Web streamingServerUrl injection, connection testing, docs, tests, and follow-up issue dreamio-8i5 for real-device media validation.","dependency_count":0,"dependent_count":1,"comment_count":0} {"_type":"issue","id":"dreamio-ccn","title":"Start media buffering before caption loading","description":"Ensure native media playback starts buffering immediately and subtitle resolution/attachment runs in the background so captions do not delay playback startup.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T04:07:30Z","created_by":"dirtydishes","updated_at":"2026-05-27T04:09:21Z","started_at":"2026-05-27T04:07:31Z","closed_at":"2026-05-27T04:09:21Z","close_reason":"Implemented playback-first native startup and background parallel subtitle resolution.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-e3u","title":"Harden VLC play pause synchronization","description":"Implement state-aware MobileVLCKit play/pause handling, instrumentation, readiness gating, conservative caching, and validation for pause/audio lag.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T03:43:10Z","created_by":"dirtydishes","updated_at":"2026-05-27T03:47:00Z","started_at":"2026-05-27T03:43:55Z","closed_at":"2026-05-27T03:47:00Z","close_reason":"Implemented VLC playback state hardening, instrumentation, ready-once reporting, refresh throttling, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-060","title":"Improve native player controls experience","description":"Implement Liquid Glass-inspired native player UI improvements, touch target updates, scrubbing feedback, gestures, loading and failure states, menu polish, accessibility, and validation.","acceptance_criteria":"Native player controls are modernized; touch targets and scrubbing improve; gestures, loading/failure affordances, menu labels, visual polish, device adaptation, and accessibility are implemented; build validation is run.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-27T01:51:52Z","created_by":"dirtydishes","updated_at":"2026-05-27T01:56:02Z","started_at":"2026-05-27T01:51:57Z","closed_at":"2026-05-27T01:56:02Z","close_reason":"Implemented native player Liquid Glass UX improvements and validated simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio.xcodeproj/project.pbxproj b/Dreamio.xcodeproj/project.pbxproj index af6a9dc..9a918d1 100644 --- a/Dreamio.xcodeproj/project.pbxproj +++ b/Dreamio.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */; }; 6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */; }; 6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */; }; + 6F2A2B522C00100100DREAMIO /* RemoteStremioServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B532C00100100DREAMIO /* RemoteStremioServer.swift */; }; BA013CEC876B829A86AE8DCB /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; }; /* End PBXBuildFile section */ @@ -29,6 +30,7 @@ 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCNativePlaybackBackend.swift; sourceTree = ""; }; 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.swift; sourceTree = ""; }; + 6F2A2B532C00100100DREAMIO /* RemoteStremioServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteStremioServer.swift; sourceTree = ""; }; 701702B9C2BFBEDE36E7F0A3 /* Pods-Dreamio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.release.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.release.xcconfig"; sourceTree = ""; }; 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Dreamio.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BF0A4D5BAC9400AEEF3B0181 /* Pods-Dreamio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.debug.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.debug.xcconfig"; sourceTree = ""; }; @@ -89,6 +91,7 @@ 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */, 6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */, 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */, + 6F2A2B532C00100100DREAMIO /* RemoteStremioServer.swift */, 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */, 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */, 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */, @@ -235,6 +238,7 @@ 6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */, 6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */, 6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */, + 6F2A2B522C00100100DREAMIO /* RemoteStremioServer.swift in Sources */, 6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */, 6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */, 6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */, diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index 7ba97c4..08946ce 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -61,6 +61,23 @@ final class DreamioWebViewController: UIViewController { 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: #""" @@ -723,6 +740,7 @@ final class DreamioWebViewController: UIViewController { view.backgroundColor = .systemBackground view.addSubview(webView) view.addSubview(progressView) + view.addSubview(remoteServerButton) NSLayoutConstraint.activate([ webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -731,7 +749,11 @@ final class DreamioWebViewController: UIViewController { 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) + 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 @@ -745,7 +767,11 @@ final class DreamioWebViewController: UIViewController { } private func loadDreamio() { - let request = URLRequest(url: Constants.stremioWebURL) + let url = RemoteStremioServerConfiguration.stremioWebURL( + baseURL: Constants.stremioWebURL, + serverURL: remoteServerStore.serverURL + ) + let request = URLRequest(url: url) webView.load(request) } @@ -766,6 +792,162 @@ final class DreamioWebViewController: UIViewController { 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 diff --git a/Dreamio/RemoteStremioServer.swift b/Dreamio/RemoteStremioServer.swift new file mode 100644 index 0000000..bc5fe5f --- /dev/null +++ b/Dreamio/RemoteStremioServer.swift @@ -0,0 +1,239 @@ +import Foundation + +enum RemoteStremioServerURLValidationError: LocalizedError, Equatable { + case empty + case unsupportedScheme(String) + case missingHost + case containsCredentials + case containsQueryOrFragment + case insecureRemoteHTTP(String) + + var errorDescription: String? { + switch self { + case .empty: + return "Enter the base URL for your Stremio Server." + case let .unsupportedScheme(scheme): + return "Stremio Server URLs must use http or https, not \(scheme)." + case .missingHost: + return "The Stremio Server URL needs a host name or IP address." + case .containsCredentials: + return "Do not include usernames, passwords, or tokens in the Stremio Server URL." + case .containsQueryOrFragment: + return "Use the Stremio Server base URL only, without query strings or fragments." + case let .insecureRemoteHTTP(host): + return "HTTP is only accepted automatically for localhost or private-network addresses. Use HTTPS for \(host), or explicitly allow insecure HTTP." + } + } +} + +struct RemoteStremioServerConfiguration: Equatable { + static let streamingServerQueryItem = "streamingServerUrl" + + let baseURL: URL + + init(input: String, allowInsecureRemoteHTTP: Bool = false) throws { + baseURL = try Self.normalizedURL(from: input, allowInsecureRemoteHTTP: allowInsecureRemoteHTTP) + } + + static func normalizedURL(from input: String, allowInsecureRemoteHTTP: Bool = false) throws -> URL { + var value = input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { + throw RemoteStremioServerURLValidationError.empty + } + + if !value.contains("://") { + value = "https://\(value)" + } + + guard var components = URLComponents(string: value) else { + throw RemoteStremioServerURLValidationError.missingHost + } + + let scheme = components.scheme?.lowercased() ?? "" + guard ["http", "https"].contains(scheme) else { + throw RemoteStremioServerURLValidationError.unsupportedScheme(scheme.isEmpty ? "missing scheme" : scheme) + } + components.scheme = scheme + + guard let host = components.host, !host.isEmpty else { + throw RemoteStremioServerURLValidationError.missingHost + } + + guard components.user == nil, components.password == nil else { + throw RemoteStremioServerURLValidationError.containsCredentials + } + + guard components.query == nil, components.fragment == nil else { + throw RemoteStremioServerURLValidationError.containsQueryOrFragment + } + + if scheme == "http", !isLocalOrPrivateHost(host), !allowInsecureRemoteHTTP { + throw RemoteStremioServerURLValidationError.insecureRemoteHTTP(host) + } + + if components.path.isEmpty { + components.path = "/" + } else if !components.path.hasSuffix("/") { + components.path += "/" + } + + guard let url = components.url else { + throw RemoteStremioServerURLValidationError.missingHost + } + + return url + } + + static func stremioWebURL(baseURL: URL, serverURL: URL?) -> URL { + guard let serverURL, + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) + else { + return baseURL + } + + var queryItems = components.queryItems?.filter { $0.name != streamingServerQueryItem } ?? [] + queryItems.append(URLQueryItem(name: streamingServerQueryItem, value: serverURL.absoluteString)) + components.queryItems = queryItems + return components.url ?? baseURL + } + + static func settingsEndpoint(for serverURL: URL) -> URL { + serverURL.appendingPathComponent("settings") + } + + static func redactedDisplayString(for serverURL: URL?) -> String { + guard let serverURL, + var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) + else { + return "Not configured" + } + + components.user = nil + components.password = nil + components.query = nil + components.fragment = nil + return components.url?.absoluteString ?? "Configured server" + } + + static func isLocalOrPrivateHost(_ host: String) -> Bool { + let lowercased = host.lowercased() + if lowercased == "localhost" || lowercased == "::1" || lowercased == "[::1]" { + return true + } + + if lowercased.hasPrefix("fe80:") || lowercased.hasPrefix("fc") || lowercased.hasPrefix("fd") { + return true + } + + let octets = lowercased.split(separator: ".").compactMap { Int($0) } + guard octets.count == 4 else { + return false + } + + if octets[0] == 10 || octets[0] == 127 { + return true + } + if octets[0] == 192, octets[1] == 168 { + return true + } + if octets[0] == 172, (16...31).contains(octets[1]) { + return true + } + return false + } +} + +struct RemoteStremioServerStore { + static let storageKey = "Dreamio.RemoteStremioServer.baseURL" + + private let userDefaults: UserDefaults + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + var serverURL: URL? { + guard let value = userDefaults.string(forKey: Self.storageKey), + let url = try? RemoteStremioServerConfiguration.normalizedURL( + from: value, + allowInsecureRemoteHTTP: true + ) + else { + return nil + } + return url + } + + func save(serverURL: URL) { + userDefaults.set(serverURL.absoluteString, forKey: Self.storageKey) + } + + func clear() { + userDefaults.removeObject(forKey: Self.storageKey) + } +} + +struct RemoteStremioServerValidationSummary: Equatable { + let serverURL: URL + let settingsEndpoint: URL + let serverVersion: String + let reportedBaseURL: URL? + let hasTranscodingSetting: Bool +} + +enum RemoteStremioServerValidationError: LocalizedError { + case invalidResponse + case httpStatus(Int) + case missingServerVersion + + var errorDescription: String? { + switch self { + case .invalidResponse: + return "The server did not return a valid Stremio Server settings response." + case let .httpStatus(status): + return "The Stremio Server settings endpoint returned HTTP \(status)." + case .missingServerVersion: + return "The settings response did not include a Stremio Server version." + } + } +} + +final class RemoteStremioServerValidator { + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func validate(serverURL: URL) async throws -> RemoteStremioServerValidationSummary { + let endpoint = RemoteStremioServerConfiguration.settingsEndpoint(for: serverURL) + var request = URLRequest(url: endpoint) + request.timeoutInterval = 10 + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await session.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw RemoteStremioServerValidationError.invalidResponse + } + guard (200...299).contains(httpResponse.statusCode) else { + throw RemoteStremioServerValidationError.httpStatus(httpResponse.statusCode) + } + guard let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let values = payload["values"] as? [String: Any] + else { + throw RemoteStremioServerValidationError.invalidResponse + } + guard let serverVersion = values["serverVersion"] as? String, !serverVersion.isEmpty else { + throw RemoteStremioServerValidationError.missingServerVersion + } + + let reportedBaseURL = (payload["baseUrl"] as? String).flatMap(URL.init(string:)) + return RemoteStremioServerValidationSummary( + serverURL: serverURL, + settingsEndpoint: endpoint, + serverVersion: serverVersion, + reportedBaseURL: reportedBaseURL, + hasTranscodingSetting: values.keys.contains("transcodeProfile") + ) + } +} diff --git a/README.md b/README.md index 750a4f9..8cf0558 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,47 @@ open Dreamio.xcworkspace The official CocoaPods getting started guide documents the RubyGems install path: https://guides.cocoapods.org/using/getting-started.html +## Optional Remote Stremio Server + +Dreamio includes an advanced, opt-in **Remote Stremio Server** setting for users +who run their own Stremio Server, such as the official +[`stremio/server`](https://github.com/Stremio/server-docker) Docker image. Tap +the server button in the top-right corner of Dreamio to configure, test, reload, +or clear a user-provided server URL. + +Dreamio does **not** provide, bundle, or hardcode a Stremio Server address. The +configured URL is stored locally and passed to hosted Stremio Web with its +existing `streamingServerUrl` query-parameter flow so Stremio Web can add and +select that server in Settings > Streaming. + +What the official Docker image exposes: + +- `11470` for HTTP and `12470` for HTTPS. +- `/settings`, `/network-info`, `/device-info`, `/casting`, torrent creation, + statistics, proxy, and media streaming endpoints used by Stremio Web/Core. +- `FFMPEG_BIN`, `FFPROBE_BIN`, `APP_PATH`, and `NO_CORS` environment variables. +- A Docker build with `ffmpeg-jellyfin`, which enables server-side media work + when the upstream Stremio Server and selected stream path support it. + +A remote server can help with torrent-backed streams, proxy-header streams, +archive/FTP/YouTube handling, and some transcoding-capable playback paths. It is +not guaranteed to turn every MKV into a WebKit-friendly HLS or MP4 stream. Keep +MobileVLCKit native playback available as the fallback for MKV, AVI, WebM, codec, +audio-track, subtitle, or server-transcoding failures. + +Security and privacy notes: + +- Prefer HTTPS for remote servers. Dreamio only accepts HTTP automatically for + localhost and private-network addresses; public HTTP requires explicit + confirmation. Use a trusted certificate or reverse proxy for remote HTTPS; + self-signed certificates may fail in iOS networking and `WKWebView`. +- Do not put usernames, passwords, tokens, query strings, or fragments in the + configured base URL. +- A configured server can see stream URLs sent to it. Run a server you trust. +- Clearing the Dreamio override stops Dreamio from injecting `streamingServerUrl` + on load. If Stremio Web already saved the URL in its own profile settings, + remove or change it in Stremio Web Settings > Streaming. + ## Validation Notes CocoaPods 1.16.2 was installed with Homebrew on this repository machine, and diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index 90302b2..5d42f96 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -27,6 +27,9 @@ struct StreamResolverTests { testNativePlaybackTogglePolicy() testNativePlaybackAudioSessionPolicy() testNativePlaybackStreamingOptionsPolicy() + testRemoteStremioServerURLNormalization() + testRemoteStremioServerWebURLInjection() + await testRemoteStremioServerValidatorReadsSettingsEndpoint() print("StreamResolverTests passed") } @@ -536,6 +539,89 @@ struct StreamResolverTests { assertEqual(NativePlaybackStreamingOptionsPolicy.mediaOptions().contains(":live-caching=1000"), false) } + private static func testRemoteStremioServerURLNormalization() { + let serverURL = try! RemoteStremioServerConfiguration.normalizedURL(from: "stremio.example.test:12470") + assertEqual(serverURL.absoluteString, "https://stremio.example.test:12470/") + + let reverseProxyURL = try! RemoteStremioServerConfiguration.normalizedURL(from: "https://media.example.test/stremio") + assertEqual(reverseProxyURL.absoluteString, "https://media.example.test/stremio/") + + let localHTTPURL = try! RemoteStremioServerConfiguration.normalizedURL(from: "http://192.168.1.10:11470") + assertEqual(localHTTPURL.absoluteString, "http://192.168.1.10:11470/") + + do { + _ = try RemoteStremioServerConfiguration.normalizedURL(from: "http://public.example.test:11470") + assertionFailure("Expected remote HTTP to require an explicit override") + } catch RemoteStremioServerURLValidationError.insecureRemoteHTTP(let host) { + assertEqual(host, "public.example.test") + } catch { + assertionFailure("Unexpected error: \(error)") + } + + do { + _ = try RemoteStremioServerConfiguration.normalizedURL(from: "https://user:secret@stremio.example.test:12470/") + assertionFailure("Expected credentials to be rejected") + } catch RemoteStremioServerURLValidationError.containsCredentials { + } catch { + assertionFailure("Unexpected error: \(error)") + } + + do { + _ = try RemoteStremioServerConfiguration.normalizedURL(from: "https://stremio.example.test:12470/?token=secret") + assertionFailure("Expected query strings to be rejected") + } catch RemoteStremioServerURLValidationError.containsQueryOrFragment { + } catch { + assertionFailure("Unexpected error: \(error)") + } + } + + private static func testRemoteStremioServerWebURLInjection() { + let webURL = URL(string: "https://web.stremio.com/")! + let serverURL = URL(string: "https://stremio.example.test:12470/")! + let configuredURL = RemoteStremioServerConfiguration.stremioWebURL( + baseURL: webURL, + serverURL: serverURL + ) + let queryItems = URLComponents(url: configuredURL, resolvingAgainstBaseURL: false)?.queryItems ?? [] + assertEqual( + queryItems.first(where: { $0.name == RemoteStremioServerConfiguration.streamingServerQueryItem })?.value, + "https://stremio.example.test:12470/" + ) + assertEqual( + RemoteStremioServerConfiguration.stremioWebURL(baseURL: webURL, serverURL: nil).absoluteString, + "https://web.stremio.com/" + ) + } + + private static func testRemoteStremioServerValidatorReadsSettingsEndpoint() async { + let session = mockSession() + let serverURL = URL(string: "https://stremio.example.test:12470/")! + let endpoint = URL(string: "https://stremio.example.test:12470/settings")! + let body = #""" + { + "baseUrl": "https://stremio.example.test:12470/", + "values": { + "serverVersion": "4.20.8", + "appPath": "/root/.stremio-server", + "cacheRoot": "/root/.stremio-server", + "cacheSize": 2147483648, + "remoteHttps": "", + "transcodeProfile": null + } + } + """# + MockURLProtocol.handlers = [ + endpoint.absoluteString: (status: 200, url: endpoint, data: Data(body.utf8)) + ] + + let summary = try! await RemoteStremioServerValidator(session: session).validate(serverURL: serverURL) + + assertEqual(summary.serverVersion, "4.20.8") + assertEqual(summary.settingsEndpoint.absoluteString, "https://stremio.example.test:12470/settings") + assertEqual(summary.reportedBaseURL?.absoluteString, "https://stremio.example.test:12470/") + assertEqual(summary.hasTranscodingSetting, true) + } + private static func assertEqual(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) { assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line) } diff --git a/docs/turns/2026-05-27-remote-stremio-server.html b/docs/turns/2026-05-27-remote-stremio-server.html new file mode 100644 index 0000000..da11081 --- /dev/null +++ b/docs/turns/2026-05-27-remote-stremio-server.html @@ -0,0 +1,690 @@ + + + + + + Remote Stremio Server Power User Support + + + + + + +
+
+

Dreamio turn document

+

Remote Stremio Server

+

Dreamio now has an advanced, user-owned Stremio Server configuration path that passes a self-hosted server into Stremio Web without hardcoded infrastructure.

+
+ 2026-05-27 + Beads issue dreamio-dc6 + Follow-up dreamio-8i5 +
+
+ +
+
+

Summary

+

Implemented the optional Remote Stremio Server feature as a power-user control. Users can save, validate, reload, or clear their own server URL. Dreamio injects it through Stremio Web's existing streamingServerUrl query flow and keeps native VLC playback available as the reliable fallback.

+
+ +
+

Changes Made

+
    +
  • Added RemoteStremioServer.swift for URL normalization, private-network HTTP policy, local storage, safe display redaction, and /settings validation.
  • +
  • Added a compact advanced server button to DreamioWebViewController with configure, test, reload, and clear actions.
  • +
  • Changed Dreamio launch URLs so a configured server is passed to hosted Stremio Web as streamingServerUrl.
  • +
  • Added standalone Swift coverage for URL policy, Web URL injection, and settings-endpoint validation.
  • +
  • Documented what Stremio/server-docker exposes, what it can help with, and where transcoding expectations must remain cautious.
  • +
+
+ +
+

Context

+

Upstream research showed that Stremio/server-docker exposes HTTP on 11470, HTTPS on 12470, and server endpoints such as /settings, /network-info, /device-info, /casting, torrent creation, statistics, proxy, and stream URLs. Stremio Web already supports a streamingServerUrl query parameter that dispatches AddServerUrl and updates profile settings. That existing integration is less brittle than rewriting stream URLs inside Dreamio.

+
+ +
+

Important Implementation Details

+
    +
  • No Dreamio-owned server URL is hardcoded. The only persistent value is the user's local setting.
  • +
  • URLs without a scheme default to HTTPS. HTTP is allowed automatically for localhost and private-network addresses, while public HTTP requires explicit confirmation.
  • +
  • Credentials, query strings, and fragments are rejected so tokens are not stored as part of the server base URL.
  • +
  • The connection test reads /settings, requires values.serverVersion, reports the upstream baseUrl, and notes whether transcodeProfile is advertised.
  • +
  • Clearing Dreamio's override stops future injection. The UI explains that Stremio Web may still retain the URL in its own Settings > Streaming profile state.
  • +
+
+ +
+

Relevant Diff Snippets

+
+
+

Dreamio/RemoteStremioServer.swift ยท validation, storage, and server health checks

+
+
Dreamio/RemoteStremioServer.swift
+239
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
import Foundation
+
enum RemoteStremioServerURLValidationError: LocalizedError, Equatable {
case empty
case unsupportedScheme(String)
case missingHost
case containsCredentials
case containsQueryOrFragment
case insecureRemoteHTTP(String)
+
var errorDescription: String? {
switch self {
case .empty:
return "Enter the base URL for your Stremio Server."
case let .unsupportedScheme(scheme):
return "Stremio Server URLs must use http or https, not \(scheme)."
case .missingHost:
return "The Stremio Server URL needs a host name or IP address."
case .containsCredentials:
return "Do not include usernames, passwords, or tokens in the Stremio Server URL."
case .containsQueryOrFragment:
return "Use the Stremio Server base URL only, without query strings or fragments."
case let .insecureRemoteHTTP(host):
return "HTTP is only accepted automatically for localhost or private-network addresses. Use HTTPS for \(host), or explicitly allow insecure HTTP."
}
}
}
+
struct RemoteStremioServerConfiguration: Equatable {
static let streamingServerQueryItem = "streamingServerUrl"
+
let baseURL: URL
+
init(input: String, allowInsecureRemoteHTTP: Bool = false) throws {
baseURL = try Self.normalizedURL(from: input, allowInsecureRemoteHTTP: allowInsecureRemoteHTTP)
}
+
static func normalizedURL(from input: String, allowInsecureRemoteHTTP: Bool = false) throws -> URL {
var value = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else {
throw RemoteStremioServerURLValidationError.empty
}
+
if !value.contains("://") {
value = "https://\(value)"
}
+
guard var components = URLComponents(string: value) else {
throw RemoteStremioServerURLValidationError.missingHost
}
+
let scheme = components.scheme?.lowercased() ?? ""
guard ["http", "https"].contains(scheme) else {
throw RemoteStremioServerURLValidationError.unsupportedScheme(scheme.isEmpty ? "missing scheme" : scheme)
}
components.scheme = scheme
+
guard let host = components.host, !host.isEmpty else {
throw RemoteStremioServerURLValidationError.missingHost
}
+
guard components.user == nil, components.password == nil else {
throw RemoteStremioServerURLValidationError.containsCredentials
}
+
guard components.query == nil, components.fragment == nil else {
throw RemoteStremioServerURLValidationError.containsQueryOrFragment
}
+
if scheme == "http", !isLocalOrPrivateHost(host), !allowInsecureRemoteHTTP {
throw RemoteStremioServerURLValidationError.insecureRemoteHTTP(host)
}
+
if components.path.isEmpty {
components.path = "/"
} else if !components.path.hasSuffix("/") {
components.path += "/"
}
+
guard let url = components.url else {
throw RemoteStremioServerURLValidationError.missingHost
}
+
return url
}
+
static func stremioWebURL(baseURL: URL, serverURL: URL?) -> URL {
guard let serverURL,
var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)
else {
return baseURL
}
+
var queryItems = components.queryItems?.filter { $0.name != streamingServerQueryItem } ?? []
queryItems.append(URLQueryItem(name: streamingServerQueryItem, value: serverURL.absoluteString))
components.queryItems = queryItems
return components.url ?? baseURL
}
+
static func settingsEndpoint(for serverURL: URL) -> URL {
serverURL.appendingPathComponent("settings")
}
+
static func redactedDisplayString(for serverURL: URL?) -> String {
guard let serverURL,
var components = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)
else {
return "Not configured"
}
+
components.user = nil
components.password = nil
components.query = nil
components.fragment = nil
return components.url?.absoluteString ?? "Configured server"
}
+
static func isLocalOrPrivateHost(_ host: String) -> Bool {
let lowercased = host.lowercased()
if lowercased == "localhost" || lowercased == "::1" || lowercased == "[::1]" {
return true
}
+
if lowercased.hasPrefix("fe80:") || lowercased.hasPrefix("fc") || lowercased.hasPrefix("fd") {
return true
}
+
let octets = lowercased.split(separator: ".").compactMap { Int($0) }
guard octets.count == 4 else {
return false
}
+
if octets[0] == 10 || octets[0] == 127 {
return true
}
if octets[0] == 192, octets[1] == 168 {
return true
}
if octets[0] == 172, (16...31).contains(octets[1]) {
return true
}
return false
}
}
+
struct RemoteStremioServerStore {
static let storageKey = "Dreamio.RemoteStremioServer.baseURL"
+
private let userDefaults: UserDefaults
+
init(userDefaults: UserDefaults = .standard) {
self.userDefaults = userDefaults
}
+
var serverURL: URL? {
guard let value = userDefaults.string(forKey: Self.storageKey),
let url = try? RemoteStremioServerConfiguration.normalizedURL(
from: value,
allowInsecureRemoteHTTP: true
)
else {
return nil
}
return url
}
+
func save(serverURL: URL) {
userDefaults.set(serverURL.absoluteString, forKey: Self.storageKey)
}
+
func clear() {
userDefaults.removeObject(forKey: Self.storageKey)
}
}
+
struct RemoteStremioServerValidationSummary: Equatable {
let serverURL: URL
let settingsEndpoint: URL
let serverVersion: String
let reportedBaseURL: URL?
let hasTranscodingSetting: Bool
}
+
enum RemoteStremioServerValidationError: LocalizedError {
case invalidResponse
case httpStatus(Int)
case missingServerVersion
+
var errorDescription: String? {
switch self {
case .invalidResponse:
return "The server did not return a valid Stremio Server settings response."
case let .httpStatus(status):
return "The Stremio Server settings endpoint returned HTTP \(status)."
case .missingServerVersion:
return "The settings response did not include a Stremio Server version."
}
}
}
+
final class RemoteStremioServerValidator {
private let session: URLSession
+
init(session: URLSession = .shared) {
self.session = session
}
+
func validate(serverURL: URL) async throws -> RemoteStremioServerValidationSummary {
let endpoint = RemoteStremioServerConfiguration.settingsEndpoint(for: serverURL)
var request = URLRequest(url: endpoint)
request.timeoutInterval = 10
request.setValue("application/json", forHTTPHeaderField: "Accept")
+
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw RemoteStremioServerValidationError.invalidResponse
}
guard (200...299).contains(httpResponse.statusCode) else {
throw RemoteStremioServerValidationError.httpStatus(httpResponse.statusCode)
}
guard let payload = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let values = payload["values"] as? [String: Any]
else {
throw RemoteStremioServerValidationError.invalidResponse
}
guard let serverVersion = values["serverVersion"] as? String, !serverVersion.isEmpty else {
throw RemoteStremioServerValidationError.missingServerVersion
}
+
let reportedBaseURL = (payload["baseUrl"] as? String).flatMap(URL.init(string:))
return RemoteStremioServerValidationSummary(
serverURL: serverURL,
settingsEndpoint: endpoint,
serverVersion: serverVersion,
reportedBaseURL: reportedBaseURL,
hasTranscodingSetting: values.keys.contains("transcodeProfile")
)
}
}
+
+
+
+
+

Dreamio/DreamioWebViewController.swift ยท advanced user controls and Stremio Web injection

+
+
Dreamio/DreamioWebViewController.swift
-2+184
60 unmodified lines
61
62
63
64
65
66
656 unmodified lines
723
724
725
726
727
728
2 unmodified lines
731
732
733
734
735
736
737
7 unmodified lines
745
746
747
748
749
750
751
14 unmodified lines
766
767
768
769
770
771
60 unmodified lines
private var currentNativePlaybackKey: URL?
private weak var currentNativePlayer: NativePlayerViewController?
private let streamResolver: StreamResolving = StremioStreamResolver()
+
private static let streamCandidateScript = WKUserScript(
source: #"""
656 unmodified lines
view.backgroundColor = .systemBackground
view.addSubview(webView)
view.addSubview(progressView)
+
NSLayoutConstraint.activate([
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
2 unmodified lines
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)
])
+
progressObservation = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] webView, _ in
7 unmodified lines
}
+
private func loadDreamio() {
let request = URLRequest(url: Constants.stremioWebURL)
webView.load(request)
}
+
14 unmodified lines
present(alert, animated: true)
}
+
private func handleStreamCandidate(_ candidate: StreamCandidate) {
guard let request = StreamClassifier.playbackRequest(from: candidate, userAgent: userAgent) else {
return
60 unmodified lines
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
656 unmodified lines
740
741
742
743
744
745
746
2 unmodified lines
749
750
751
752
753
754
755
756
757
758
759
7 unmodified lines
767
768
769
770
771
772
773
774
775
776
777
14 unmodified lines
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
60 unmodified lines
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: #"""
656 unmodified lines
view.backgroundColor = .systemBackground
view.addSubview(webView)
view.addSubview(progressView)
view.addSubview(remoteServerButton)
+
NSLayoutConstraint.activate([
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
2 unmodified lines
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
7 unmodified lines
}
+
private func loadDreamio() {
let url = RemoteStremioServerConfiguration.stremioWebURL(
baseURL: Constants.stremioWebURL,
serverURL: remoteServerStore.serverURL
)
let request = URLRequest(url: url)
webView.load(request)
}
+
14 unmodified lines
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
+
+
+
+
+

Tests/StreamResolverTests.swift ยท routing and validation coverage

+
+
Tests/StreamResolverTests.swift
+86
26 unmodified lines
27
28
29
30
31
32
503 unmodified lines
536
537
538
539
540
541
26 unmodified lines
testNativePlaybackTogglePolicy()
testNativePlaybackAudioSessionPolicy()
testNativePlaybackStreamingOptionsPolicy()
print("StreamResolverTests passed")
}
+
503 unmodified lines
assertEqual(NativePlaybackStreamingOptionsPolicy.mediaOptions().contains(":live-caching=1000"), false)
}
+
private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
}
26 unmodified lines
27
28
29
30
31
32
33
34
35
503 unmodified lines
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
26 unmodified lines
testNativePlaybackTogglePolicy()
testNativePlaybackAudioSessionPolicy()
testNativePlaybackStreamingOptionsPolicy()
testRemoteStremioServerURLNormalization()
testRemoteStremioServerWebURLInjection()
await testRemoteStremioServerValidatorReadsSettingsEndpoint()
print("StreamResolverTests passed")
}
+
503 unmodified lines
assertEqual(NativePlaybackStreamingOptionsPolicy.mediaOptions().contains(":live-caching=1000"), false)
}
+
private static func testRemoteStremioServerURLNormalization() {
let serverURL = try! RemoteStremioServerConfiguration.normalizedURL(from: "stremio.example.test:12470")
assertEqual(serverURL.absoluteString, "https://stremio.example.test:12470/")
+
let reverseProxyURL = try! RemoteStremioServerConfiguration.normalizedURL(from: "https://media.example.test/stremio")
assertEqual(reverseProxyURL.absoluteString, "https://media.example.test/stremio/")
+
let localHTTPURL = try! RemoteStremioServerConfiguration.normalizedURL(from: "http://192.168.1.10:11470")
assertEqual(localHTTPURL.absoluteString, "http://192.168.1.10:11470/")
+
do {
_ = try RemoteStremioServerConfiguration.normalizedURL(from: "http://public.example.test:11470")
assertionFailure("Expected remote HTTP to require an explicit override")
} catch RemoteStremioServerURLValidationError.insecureRemoteHTTP(let host) {
assertEqual(host, "public.example.test")
} catch {
assertionFailure("Unexpected error: \(error)")
}
+
do {
_ = try RemoteStremioServerConfiguration.normalizedURL(from: "https://user:secret@stremio.example.test:12470/")
assertionFailure("Expected credentials to be rejected")
} catch RemoteStremioServerURLValidationError.containsCredentials {
} catch {
assertionFailure("Unexpected error: \(error)")
}
+
do {
_ = try RemoteStremioServerConfiguration.normalizedURL(from: "https://stremio.example.test:12470/?token=secret")
assertionFailure("Expected query strings to be rejected")
} catch RemoteStremioServerURLValidationError.containsQueryOrFragment {
} catch {
assertionFailure("Unexpected error: \(error)")
}
}
+
private static func testRemoteStremioServerWebURLInjection() {
let webURL = URL(string: "https://web.stremio.com/")!
let serverURL = URL(string: "https://stremio.example.test:12470/")!
let configuredURL = RemoteStremioServerConfiguration.stremioWebURL(
baseURL: webURL,
serverURL: serverURL
)
let queryItems = URLComponents(url: configuredURL, resolvingAgainstBaseURL: false)?.queryItems ?? []
assertEqual(
queryItems.first(where: { $0.name == RemoteStremioServerConfiguration.streamingServerQueryItem })?.value,
"https://stremio.example.test:12470/"
)
assertEqual(
RemoteStremioServerConfiguration.stremioWebURL(baseURL: webURL, serverURL: nil).absoluteString,
"https://web.stremio.com/"
)
}
+
private static func testRemoteStremioServerValidatorReadsSettingsEndpoint() async {
let session = mockSession()
let serverURL = URL(string: "https://stremio.example.test:12470/")!
let endpoint = URL(string: "https://stremio.example.test:12470/settings")!
let body = #"""
{
"baseUrl": "https://stremio.example.test:12470/",
"values": {
"serverVersion": "4.20.8",
"appPath": "/root/.stremio-server",
"cacheRoot": "/root/.stremio-server",
"cacheSize": 2147483648,
"remoteHttps": "",
"transcodeProfile": null
}
}
"""#
MockURLProtocol.handlers = [
endpoint.absoluteString: (status: 200, url: endpoint, data: Data(body.utf8))
]
+
let summary = try! await RemoteStremioServerValidator(session: session).validate(serverURL: serverURL)
+
assertEqual(summary.serverVersion, "4.20.8")
assertEqual(summary.settingsEndpoint.absoluteString, "https://stremio.example.test:12470/settings")
assertEqual(summary.reportedBaseURL?.absoluteString, "https://stremio.example.test:12470/")
assertEqual(summary.hasTranscodingSetting, true)
}
+
private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
}
+
+
+
+
+

README.md ยท power-user setup and limitations

+
+
README.md
+41
51 unmodified lines
52
53
54
55
56
57
51 unmodified lines
The official CocoaPods getting started guide documents the RubyGems install
path: https://guides.cocoapods.org/using/getting-started.html
+
## Validation Notes
+
CocoaPods 1.16.2 was installed with Homebrew on this repository machine, and
51 unmodified lines
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
51 unmodified lines
The official CocoaPods getting started guide documents the RubyGems install
path: https://guides.cocoapods.org/using/getting-started.html
+
## Optional Remote Stremio Server
+
Dreamio includes an advanced, opt-in **Remote Stremio Server** setting for users
who run their own Stremio Server, such as the official
[`stremio/server`](https://github.com/Stremio/server-docker) Docker image. Tap
the server button in the top-right corner of Dreamio to configure, test, reload,
or clear a user-provided server URL.
+
Dreamio does **not** provide, bundle, or hardcode a Stremio Server address. The
configured URL is stored locally and passed to hosted Stremio Web with its
existing `streamingServerUrl` query-parameter flow so Stremio Web can add and
select that server in Settings > Streaming.
+
What the official Docker image exposes:
+
- `11470` for HTTP and `12470` for HTTPS.
- `/settings`, `/network-info`, `/device-info`, `/casting`, torrent creation,
statistics, proxy, and media streaming endpoints used by Stremio Web/Core.
- `FFMPEG_BIN`, `FFPROBE_BIN`, `APP_PATH`, and `NO_CORS` environment variables.
- A Docker build with `ffmpeg-jellyfin`, which enables server-side media work
when the upstream Stremio Server and selected stream path support it.
+
A remote server can help with torrent-backed streams, proxy-header streams,
archive/FTP/YouTube handling, and some transcoding-capable playback paths. It is
not guaranteed to turn every MKV into a WebKit-friendly HLS or MP4 stream. Keep
MobileVLCKit native playback available as the fallback for MKV, AVI, WebM, codec,
audio-track, subtitle, or server-transcoding failures.
+
Security and privacy notes:
+
- Prefer HTTPS for remote servers. Dreamio only accepts HTTP automatically for
localhost and private-network addresses; public HTTP requires explicit
confirmation. Use a trusted certificate or reverse proxy for remote HTTPS;
self-signed certificates may fail in iOS networking and `WKWebView`.
- Do not put usernames, passwords, tokens, query strings, or fragments in the
configured base URL.
- A configured server can see stream URLs sent to it. Run a server you trust.
- Clearing the Dreamio override stops Dreamio from injecting `streamingServerUrl`
on load. If Stremio Web already saved the URL in its own profile settings,
remove or change it in Stremio Web Settings > Streaming.
+
## Validation Notes
+
CocoaPods 1.16.2 was installed with Homebrew on this repository machine, and
+
+
+
+
+

Diffs were rendered with @pierre/diffs/ssr. Each file diff is contained in its own shell so the document remains a readable static artifact.

+
+ +
+

Expected Impact for End-Users

+

Power users can now point Dreamio at their own Stremio Server, including a Docker deployment behind their own HTTPS endpoint. The feature may help with torrent-backed, proxy-header, archive, FTP, YouTube, and some transcoding-capable paths, but users still get Dreamio's MobileVLCKit fallback for MKV, AVI, WebM, codec, audio, subtitle, or server limitations.

+
+ +
+

Validation

+
    +
  • Passed: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun --sdk iphonesimulator swiftc -target arm64-apple-ios18.0-simulator Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/RemoteStremioServer.swift Dreamio/NativePlaybackBackend.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests-ios.
  • +
  • Passed through simulator spawn: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun simctl spawn 881FA280-CAA8-49B5-85BB-E7D393CCA6D2 /tmp/dreamio-stream-tests-ios.
  • +
  • Passed: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build.
  • +
  • Not run: real-device media playback against a user-owned Stremio Server. That requires a server, TLS setup, and legal representative streams, and is tracked by dreamio-8i5.
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • Server-side MKV to WebKit-friendly output is not guaranteed. The README now states this explicitly and Dreamio keeps native VLC fallback behavior.
  • +
  • Self-signed or untrusted certificates may fail in iOS networking and WKWebView. The documentation recommends a trusted certificate or reverse proxy.
  • +
  • The Stremio Web profile can save the server independently of Dreamio. The clear action explains how to remove that saved value inside Stremio Web.
  • +
  • Public HTTP can expose stream URLs. Dreamio blocks it by default and requires an explicit warning confirmation.
  • +
+
+ +
+

Follow-up Work

+
    +
  • Complete dreamio-8i5: validate direct MP4, HLS, MKV, unsupported audio/subtitle MKV, and torrent-backed samples on real iPhone or iPad with a user-owned server.
  • +
  • Consider a richer first-run explanation if the compact server button is too discoverable for a power-user feature or too subtle for testers.
  • +
  • After device validation, adjust routing copy or fallback behavior if Stremio Server reliably emits WebKit-friendly HLS for specific stream classes.
  • +
+
+
+
+ +