diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..bd5957a Binary files /dev/null and b/.DS_Store differ diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index e69de29..e8fa5cb 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -0,0 +1,11 @@ +{"id":"int-e5b00c6b","kind":"field_change","created_at":"2026-05-24T14:59:43.843679Z","actor":"dirtydishes","issue_id":"dreamio-4yn","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits."}} +{"id":"int-09793929","kind":"field_change","created_at":"2026-05-25T01:12:43.675806Z","actor":"dirtydishes","issue_id":"dreamio-a5b","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Moved @pierre/diffs to devDependencies and ignored node_modules."}} +{"id":"int-d8dc4ec5","kind":"field_change","created_at":"2026-05-25T01:25:35.590554Z","actor":"dirtydishes","issue_id":"dreamio-tnv","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier."}} +{"id":"int-a86e17e0","kind":"field_change","created_at":"2026-05-25T02:34:54.605755Z","actor":"dirtydishes","issue_id":"dreamio-evt","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented debug-only WKWebView inspection, token-safe playback diagnostics, navigation logging, validation build, and turn documentation."}} +{"id":"int-4d73c126","kind":"field_change","created_at":"2026-05-25T03:20:17.439589Z","actor":"dirtydishes","issue_id":"dreamio-l68","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK."}} +{"id":"int-3dbe205a","kind":"field_change","created_at":"2026-05-25T03:23:00.515861Z","actor":"dirtydishes","issue_id":"dreamio-2lp","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed Swift raw string escaping and guarded MobileVLCKit import for builds before pod install."}} +{"id":"int-23df9e14","kind":"field_change","created_at":"2026-05-25T03:41:03.811099Z","actor":"dirtydishes","issue_id":"dreamio-vxs","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Resolved native playback stream URLs before opening VLC, added resolver selection tests, and documented validation limits."}} +{"id":"int-76aa54ba","kind":"field_change","created_at":"2026-05-25T03:51:39.198446Z","actor":"dirtydishes","issue_id":"dreamio-8vi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} +{"id":"int-74805ffd","kind":"field_change","created_at":"2026-05-25T04:21:42.440755Z","actor":"dirtydishes","issue_id":"dreamio-2k5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added native backend availability guard, installed CocoaPods, generated workspace metadata, documented setup, and validated available checks."}} +{"id":"int-27a61615","kind":"field_change","created_at":"2026-05-25T04:44:35.633997Z","actor":"dirtydishes","issue_id":"dreamio-ija","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed MobileVLCKit linker failures by preparing the XCFramework slice before app linking and preserving the integration through pod install."}} +{"id":"int-fad68cb4","kind":"field_change","created_at":"2026-05-25T05:04:55.103302Z","actor":"dirtydishes","issue_id":"dreamio-mj8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented native VLC player controls, caption controls, subtitle candidate discovery, and close-flow cleanup."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1cab55d..dfac9eb 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,2 +1,11 @@ +{"_type":"issue","id":"dreamio-ija","title":"Fix MobileVLCKit linker dependency","description":"Dreamio fails to link because the MobileVLCKit framework is not found. Investigate how the dependency is configured and update the repository so the framework is available to Xcode builds.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:40:49Z","created_by":"dirtydishes","updated_at":"2026-05-25T04:44:36Z","started_at":"2026-05-25T04:40:57Z","closed_at":"2026-05-25T04:44:36Z","close_reason":"Fixed MobileVLCKit linker failures by preparing the XCFramework slice before app linking and preserving the integration through pod install.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-2k5","title":"Guard native playback when MobileVLCKit is unavailable","description":"Dreamio can currently present its native player from raw xcodeproj builds where MobileVLCKit is not linked, which leads to the fallback backend message instead of an actionable setup path. Add a runtime/build availability check, document the CocoaPods workspace requirement, and validate the fallback remains buildable.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:15:47Z","created_by":"dirtydishes","updated_at":"2026-05-25T04:21:42Z","started_at":"2026-05-25T04:15:56Z","closed_at":"2026-05-25T04:21:42Z","close_reason":"Added native backend availability guard, installed CocoaPods, generated workspace metadata, documented setup, and validated available checks.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-8vi","title":"Fix URL redaction crash on percent-encoded paths","description":"## Why\nDreamio can crash while logging WebKit navigation and playback URLs because URLRedactor writes raw replacement text back into URLComponents.percentEncodedPath.\n\n## What needs to be done\n- Update URL redaction to avoid assigning invalid characters to percentEncodedPath\n- Preserve token/path redaction behavior for diagnostics\n- Add a regression test covering percent-encoded path input similar to the Stremio crash logs\n\n## Acceptance criteria\n- Redacting a URL with percent-encoded path segments does not crash\n- Diagnostics still remove query strings/fragments and redact token-like path segments\n- Tests cover the regression","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:50:04Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:51:39Z","started_at":"2026-05-25T03:50:08Z","closed_at":"2026-05-25T03:51:39Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-vxs","title":"Resolve final media URLs before native playback","description":"Dreamio native playback can pass addon resolver URLs into VLC instead of the final direct media URL. Resolve known Stremio addon stream responses before presenting the native player, preserve needed headers, and make startup failure recoverable.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:36:14Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:41:04Z","started_at":"2026-05-25T03:36:19Z","closed_at":"2026-05-25T03:41:04Z","close_reason":"Resolved native playback stream URLs before opening VLC, added resolver selection tests, and documented validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-2lp","title":"Fix native playback build blockers","description":"Correct Swift string escaping for the injected stream bridge and allow the VLC backend source to compile before MobileVLCKit is installed by guarding the import with canImport.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:22:52Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:23:00Z","started_at":"2026-05-25T03:23:00Z","closed_at":"2026-05-25T03:23:00Z","close_reason":"Fixed Swift raw string escaping and guarded MobileVLCKit import for builds before pod install.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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-mj8","title":"Add native player controls and captions","description":"Implement a fuller VLC-backed native playback surface with transport controls, caption controls, external subtitle discovery, and a clean close flow back to Stremio episode selection.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:57:53Z","created_by":"dirtydishes","updated_at":"2026-05-25T05:04:55Z","started_at":"2026-05-25T04:57:57Z","closed_at":"2026-05-25T05:04:55Z","close_reason":"Implemented native VLC player controls, caption controls, subtitle candidate discovery, and close-flow cleanup.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-evt","title":"Enable WebView inspection and playback diagnostics","description":"Add development-only WKWebView inspection and token-safe playback diagnostics so Dreamio can debug hosted Stremio media failures without changing app navigation, login, or playback behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T02:30:26Z","created_by":"dirtydishes","updated_at":"2026-05-25T02:34:55Z","started_at":"2026-05-25T02:30:32Z","closed_at":"2026-05-25T02:34:55Z","close_reason":"Implemented debug-only WKWebView inspection, token-safe playback diagnostics, navigation logging, validation build, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-a5b","title":"Track HTML diff rendering tooling as dev dependency","description":"Move the HTML diff rendering package into devDependencies and ignore installed Node modules so the repo tracks reproducible tooling without vendoring dependencies.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:12:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:12:44Z","started_at":"2026-05-25T01:12:14Z","closed_at":"2026-05-25T01:12:44Z","close_reason":"Moved @pierre/diffs to devDependencies and ignored node_modules.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.gitignore b/.gitignore index 089a6b9..c4cf5cd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ # Node tooling node_modules/ + +# CocoaPods +Pods/ diff --git a/AGENTS.md b/AGENTS.md index bc2ae10..dd7b6e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,3 +94,145 @@ bd close # Complete work - NEVER say "ready to push when you are" - YOU must push - If push fails, resolve and retry until it succeeds + +## Required Turn Documentation + +At the end of every completed implementation task, before final handoff, create a user-readable HTML document describing the work. + +This documentation is mandatory whenever code, configuration, tests, or project files were changed. + +### Precedence and classification + +Use this decision order before creating a turn document: + +1. Check the minor/trivial exemption checklist below first. +2. If the task clearly matches an exemption, do not create a turn document. +3. If the task is a clearly substantive implementation change, create a turn document. +4. If classification is ambiguous or mixed, ask the user before creating a turn document. + +The minor/trivial exemptions override the general mandatory turn-document rule. + +For diff content in turn documentation (including "Code diffs" and "Relevant Diff Snippets"), use `@pierre/diffs` output by default. If `@pierre/diffs` is unavailable because of a real tool or blocking error, use a clearly labeled plain diff/code block fallback and note why. + +### No turn document for minor/trivial checklist matches + +Do not create a turn document when the change is minor/trivial and cleanly matches one of these categories: + +- `AGENTS.md` changes or other documentation-only changes +- Syntax-only fixes +- Refactor-only changes with no behavior change +- PR/conflict reconciliation work +- Issue-tracker-only updates such as `beads/issues.json` +- Support-file changes that only accompany one of the exempt categories above (for example lockfile or manifest updates required for docs-workflow changes) + +If a change does not cleanly fit either exempt or substantive buckets, ask the user before creating a turn document. + +### When making a minor update to a previous change, update the existing documentation instead of creating a new file. Use the following format: + +**"New Changes as of {time and date at which the change was made}"** +- **Summary of changes** +- **Why this change was made** +- **Code diffs** (use `@pierre/diffs` output by default; if unavailable, include a clearly labeled plain diff/code block and note why) +- **Related issues or PRs** + +Additionally, add a note to each section explaining why the changes were made. + +### Location + +Save the document in: + +```text +docs/turns/ +``` + +Use a clear timestamped filename: + +```text +docs/turns/YYYY-MM-DD-short-task-name.html +``` + +Example: + +```text +docs/turns/2026-05-14-add-market-replay-controls.html +``` + +### Format + +Use the `impeccable` skill to structure and style the document as clean, readable HTML. + +For this repository, `impeccable` is the styling and layout authority for turn documents when available. Do not apply global non-repo computer-task house styling to repository turn documents. + +If the `impeccable` skill is unavailable or blocked by an actual tool/file error, still create a well-structured standalone HTML file with: + +- A concise summary at the top +- A detailed explanation of what changed +- Relevant context or background +- Specific code snippets or examples when helpful +- Issues, limitations, tradeoffs, or mitigations +- Validation performed, including tests, builds, linters, or manual checks +- Any remaining follow-up work, with corresponding Beads issue IDs when applicable + +### Required Sections + +Each turn document must include these sections: + +1. **Summary** +2. **Changes Made** +3. **Context** +4. **Important Implementation Details** +5. **Relevant Diff Snippets** (render with `@pierre/diffs` output by default; if unavailable, include a clearly labeled plain diff/code block and note why) +6. **Expected Impact for End-Users** +7. **Validation** +8. **Issues, Limitations, and Mitigations** +9. **Follow-up Work** + +### Completion Rule + +A task that requires a turn document is not complete until: + +1. The Beads workflow is updated +2. The turn document is created in `docs/turns` +3. Relevant quality gates have passed or failures are documented +4. Changes are committed +5. `bd dolt push` succeeds +6. `git push forgejo ` succeeds +7. `git status` shows the branch is up to date with `forgejo/` + +For tasks that do require turn documentation, the document may be brief when scope is small, but it must clearly explain what changed and how it was validated. + +## Plan Mode Documentation + +When working in plan mode, do not modify implementation files. + +At the end of plan mode, provide a concise summary of the plan and ask the user whether they want to proceed with implementation. + +If the user asks to save the plan, create a user-readable HTML plan document in: + +```text +docs/plans/ +``` + +Use a clear timestamped filename: + +```text +docs/plans/YYYY-MM-DD-short-plan-name.html +``` + +The plan document should be labeled clearly as a plan and should include: + +1. **Plan Summary** +2. **Goals** +3. **Proposed Changes** +4. **Relevant Context** +5. **Implementation Steps** +6. **Risks, Limitations, and Mitigations** +7. **Open Questions** + +Always do the following when you finish a task, finish the beads workflow and and make a commit: +- Document the changes in a user-readable format +- Use the impeccable skill to structure the document as HTML +- Create a clear, concise summary of the changes at the top, followed by a detailed description of the changes, including any relevant context or background as well as specific code snippets or examples. +- Note any relevant issues or limitations that were addressed or mitigated by the changes. +- The HTML file should be stored in the `docs/turns` directory. It should include the current date and time, as well as a brief explanation of changes. e.g. docs/turns/YYYY-MM-DD-{description}.html + diff --git a/Dreamio.xcodeproj/project.pbxproj b/Dreamio.xcodeproj/project.pbxproj index 5324de1..af6a9dc 100644 --- a/Dreamio.xcodeproj/project.pbxproj +++ b/Dreamio.xcodeproj/project.pbxproj @@ -10,6 +10,12 @@ 6F2A2B362C00100100DREAMIO /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B332C00100100DREAMIO /* AppDelegate.swift */; }; 6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */; }; 6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */; }; + 6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */; }; + 6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */; }; + 6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */; }; + 6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */; }; + 6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */; }; + BA013CEC876B829A86AE8DCB /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -18,6 +24,14 @@ 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DreamioWebViewController.swift; sourceTree = ""; }; 6F2A2B392C00100100DREAMIO /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamCandidate.swift; sourceTree = ""; }; + 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlaybackBackend.swift; sourceTree = ""; }; + 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCNativePlaybackBackend.swift; sourceTree = ""; }; + 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; + 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -31,11 +45,31 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 5DEC645FC7F60E33F3A4E21E /* Frameworks */ = { + isa = PBXGroup; + children = ( + 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 6593E172E04E344E08B5CAA8 /* Pods */ = { + isa = PBXGroup; + children = ( + BF0A4D5BAC9400AEEF3B0181 /* Pods-Dreamio.debug.xcconfig */, + 701702B9C2BFBEDE36E7F0A3 /* Pods-Dreamio.release.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 6F2A2B272C00100100DREAMIO = { isa = PBXGroup; children = ( 6F2A2B322C00100100DREAMIO /* Dreamio */, 6F2A2B312C00100100DREAMIO /* Products */, + 6593E172E04E344E08B5CAA8 /* Pods */, + 5DEC645FC7F60E33F3A4E21E /* Frameworks */, ); sourceTree = ""; }; @@ -53,6 +87,11 @@ 6F2A2B332C00100100DREAMIO /* AppDelegate.swift */, 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */, 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */, + 6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */, + 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */, + 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */, + 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */, + 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */, 6F2A2B392C00100100DREAMIO /* Info.plist */, ); path = Dreamio; @@ -65,9 +104,12 @@ isa = PBXNativeTarget; buildConfigurationList = 6F2A2B412C00100100DREAMIO /* Build configuration list for PBXNativeTarget "Dreamio" */; buildPhases = ( + 9F808EDAD2C69568A9142D10 /* [CP] Check Pods Manifest.lock */, + 6F2A2B512C00250100DREAMIO /* [CP] Prepare MobileVLCKit XCFramework */, 6F2A2B2C2C00100100DREAMIO /* Sources */, 6F2A2B2D2C00100100DREAMIO /* Frameworks */, 6F2A2B2E2C00100100DREAMIO /* Resources */, + F26EA81D312D2AA38B06CF11 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -121,6 +163,68 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 6F2A2B512C00250100DREAMIO /* [CP] Prepare MobileVLCKit XCFramework */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/MobileVLCKit/MobileVLCKit-xcframeworks-input-files.xcfilelist", + ); + inputPaths = ( + ); + name = "[CP] Prepare MobileVLCKit XCFramework"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ -x \"${PODS_ROOT}/Target Support Files/MobileVLCKit/MobileVLCKit-xcframeworks.sh\" ]; then\n \"${PODS_ROOT}/Target Support Files/MobileVLCKit/MobileVLCKit-xcframeworks.sh\"\nelse\n echo \"error: MobileVLCKit is missing. Run 'pod install' and open Dreamio.xcworkspace, or rebuild after Pods are installed.\" >&2\n exit 1\nfi\n"; + showEnvVarsInLog = 0; + }; + 9F808EDAD2C69568A9142D10 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Dreamio-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + F26EA81D312D2AA38B06CF11 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Dreamio/Pods-Dreamio-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Dreamio/Pods-Dreamio-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Dreamio/Pods-Dreamio-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 6F2A2B2C2C00100100DREAMIO /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -129,6 +233,11 @@ 6F2A2B362C00100100DREAMIO /* AppDelegate.swift in Sources */, 6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */, 6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */, + 6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */, + 6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */, + 6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */, + 6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */, + 6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -171,7 +280,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -232,7 +341,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -252,10 +361,11 @@ }; 6F2A2B3E2C00100100DREAMIO /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = BF0A4D5BAC9400AEEF3B0181 /* Pods-Dreamio.debug.xcconfig */; buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = C3V8C7JRTL; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Dreamio/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -273,10 +383,11 @@ }; 6F2A2B3F2C00100100DREAMIO /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 701702B9C2BFBEDE36E7F0A3 /* Pods-Dreamio.release.xcconfig */; buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = C3V8C7JRTL; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Dreamio/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/Dreamio.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Dreamio.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Dreamio.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Dreamio.xcodeproj/project.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate b/Dreamio.xcodeproj/project.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..8e4627e Binary files /dev/null and b/Dreamio.xcodeproj/project.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Dreamio.xcodeproj/xcuserdata/kell.xcuserdatad/xcschemes/xcschememanagement.plist b/Dreamio.xcodeproj/xcuserdata/kell.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..8fbf89e --- /dev/null +++ b/Dreamio.xcodeproj/xcuserdata/kell.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Dreamio.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/Dreamio.xcworkspace/contents.xcworkspacedata b/Dreamio.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..312399b --- /dev/null +++ b/Dreamio.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index 6d0376a..06ecfe8 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -4,6 +4,8 @@ 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" } private lazy var webView: WKWebView = { @@ -12,6 +14,18 @@ final class DreamioWebViewController: UIViewController { configuration.allowsInlineMediaPlayback = true configuration.mediaTypesRequiringUserActionForPlayback = [] configuration.preferences.javaScriptCanOpenWindowsAutomatically = true + configuration.userContentController.add( + WeakScriptMessageHandler(delegate: self), + name: Constants.streamCandidateMessageHandler + ) + configuration.userContentController.addUserScript(Self.streamCandidateScript) +#if DEBUG + configuration.userContentController.add( + WeakScriptMessageHandler(delegate: self), + name: Constants.diagnosticsMessageHandler + ) + configuration.userContentController.addUserScript(Self.playbackDiagnosticsScript) +#endif let webView = WKWebView(frame: .zero, configuration: configuration) webView.translatesAutoresizingMaskIntoConstraints = false @@ -20,6 +34,11 @@ final class DreamioWebViewController: UIViewController { webView.navigationDelegate = self webView.uiDelegate = self webView.scrollView.contentInsetAdjustmentBehavior = .never +#if DEBUG + if #available(iOS 16.4, *) { + webView.isInspectable = true + } +#endif return webView }() @@ -31,6 +50,314 @@ final class DreamioWebViewController: UIViewController { }() private var progressObservation: NSKeyValueObservation? + private var userAgent: String? + private var lastNativePlaybackURL: URL? + private let streamResolver: StreamResolving = StremioStreamResolver() + + 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 subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig; + + const looksNative = (url) => { + if (!url || typeof url !== "string") { + return false; + } + const directMatch = nativePatterns.some((pattern) => pattern.test(url)); + const compatibleMatch = compatiblePatterns.some((pattern) => pattern.test(url)); + return directMatch || (!compatibleMatch && /\.(mkv|avi|webm)(?:[?#]|$)/i.test(url)); + }; + + const absoluteURL = (url) => { + try { + return new URL(url, window.location.href).href; + } catch (_) { + return ""; + } + }; + + const findResolverURL = () => { + const links = Array.from(document.querySelectorAll("a[href], [data-href], [data-url]")); + const match = links + .map((node) => node.getAttribute("href") || node.getAttribute("data-href") || node.getAttribute("data-url")) + .map(absoluteURL) + .find((url) => nativePatterns.some((pattern) => pattern.test(url))); + return match || ""; + }; + + const postCandidate = (rawURL, element) => { + const url = absoluteURL(rawURL); + if (!looksNative(url)) { + return; + } + 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 addSubtitleCandidate = (entry) => { + const rawURL = typeof entry === "string" ? entry : entry && (entry.url || entry.href || entry.src || entry.file || entry.download); + const url = absoluteURL(rawURL); + if (!url || !subtitleURLPattern.test(url)) { + subtitleURLPattern.lastIndex = 0; + return; + } + subtitleURLPattern.lastIndex = 0; + if (subtitleCandidates.some((candidate) => candidate.url === url)) { + return; + } + subtitleCandidates.push({ + url, + label: entry && (entry.label || entry.name || entry.title || entry.lang || entry.language) || "External Subtitle", + language: entry && (entry.lang || entry.language) || "" + }); + }; + + 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); + Object.values(payload).forEach(inspectSubtitlePayload); + } + }; + + const originalFetch = window.fetch; + if (originalFetch) { + window.fetch = async (...args) => { + const response = await originalFetch(...args); + try { + response.clone().text().then(inspectSubtitlePayload).catch(() => {}); + } catch (_) {} + return response; + }; + } + + const originalXHRSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.send = function(...args) { + try { + this.addEventListener("load", () => inspectSubtitlePayload(this.responseText)); + } catch (_) {} + return originalXHRSend.apply(this, args); + }; + + 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 HTMLVideoElement || node instanceof HTMLSourceElement) { + postCandidate(node.currentSrc || node.src || node.getAttribute("src"), node); + } + if (node.querySelectorAll) { + node.querySelectorAll("video, source").forEach(inspectMedia); + } + }; + + const srcDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, "src"); + if (srcDescriptor && srcDescriptor.set) { + Object.defineProperty(HTMLMediaElement.prototype, "src", { + get: srcDescriptor.get, + set(value) { + postCandidate(value, this); + return srcDescriptor.set.call(this, value); + } + }); + } + + const sourceSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLSourceElement.prototype, "src"); + if (sourceSrcDescriptor && sourceSrcDescriptor.set) { + Object.defineProperty(HTMLSourceElement.prototype, "src", { + get: sourceSrcDescriptor.get, + set(value) { + postCandidate(value, this); + return sourceSrcDescriptor.set.call(this, value); + } + }); + } + + const originalSetAttribute = Element.prototype.setAttribute; + Element.prototype.setAttribute = function(name, value) { + if (String(name).toLowerCase() === "src" && (this instanceof HTMLVideoElement || this instanceof HTMLSourceElement)) { + postCandidate(value, this); + } + return originalSetAttribute.call(this, name, value); + }; + + const originalLoad = HTMLMediaElement.prototype.load; + HTMLMediaElement.prototype.load = function() { + inspectMedia(this); + this.querySelectorAll("source").forEach(inspectMedia); + return originalLoad.call(this); + }; + + document.addEventListener("loadedmetadata", (event) => inspectMedia(event.target), true); + document.addEventListener("error", (event) => inspectMedia(event.target), true); + new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === "attributes" && mutation.attributeName === "src") { + inspectMedia(mutation.target); + } + mutation.addedNodes.forEach(inspectMedia); + }); + }).observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["src"] + }); + + inspectMedia(document); + })(); + """#, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + + +#if DEBUG + private static let playbackDiagnosticsScript = WKUserScript( + 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() @@ -54,6 +381,9 @@ final class DreamioWebViewController: UIViewController { } loadDreamio() + webView.evaluateJavaScript("navigator.userAgent") { [weak self] result, _ in + self?.userAgent = result as? String + } } private func loadDreamio() { @@ -77,6 +407,179 @@ final class DreamioWebViewController: UIViewController { }) 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 { + return + } + lastNativePlaybackURL = duplicateKey + +#if DEBUG + let classification = request.classification + print("[DreamioStream] class=\(classification.sourceKind.rawValue) container=\(classification.containerGuess.rawValue) reason=\(classification.reason) observed=\(classification.sanitizedObservedURL) resolver=\(classification.sanitizedResolverURL ?? "none")") +#endif + + Task { [weak self] in + await self?.resolveAndPresentNativePlayback(request) + } + } + + @MainActor + private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async { + guard VLCNativePlaybackBackend.isAvailable else { + lastNativePlaybackURL = 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: request.subtitleCandidates + ) + let player = NativePlayerViewController(request: resolvedRequest) + player.onDismiss = { [weak self] in + self?.lastNativePlaybackURL = nil + self?.cleanUpStremioPlayerAfterNativeDismiss() + } + present(player, animated: true) + } catch { +#if DEBUG + print("[DreamioStreamResolver] failure=\(URLRedactor.redactedURLString(error.localizedDescription)) resolver=\(request.resolverURL.map { URLRedactor.redactedURLString($0.absoluteString) } ?? "none")") +#endif + lastNativePlaybackURL = nil + showNativePlaybackResolutionFailure(error) + } + } + + 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]", + "button[class*='close' i]", + "button[class*='back' i]", + ".player button", + "[role='button']" + ]); + const stillPlayer = /player|stream|buffer|prepar/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) { + self.webView.evaluateJavaScript("(/player|stream|buffer|prepar/i).test(document.body.innerText || '')") { 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) + } +#endif } extension DreamioWebViewController: WKNavigationDelegate { @@ -99,11 +602,31 @@ extension DreamioWebViewController: WKNavigationDelegate { 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) } @@ -124,6 +647,45 @@ extension DreamioWebViewController: WKNavigationDelegate { } } +extension DreamioWebViewController: WKScriptMessageHandler { + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == Constants.streamCandidateMessageHandler, + let candidate = StreamCandidate(messageBody: message.body) { + handleStreamCandidate(candidate) + return + } + +#if DEBUG + guard message.name == Constants.diagnosticsMessageHandler, + let body = message.body as? [String: Any], + let type = body["type"] as? String + 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, diff --git a/Dreamio/NativePlaybackBackend.swift b/Dreamio/NativePlaybackBackend.swift new file mode 100644 index 0000000..57ec708 --- /dev/null +++ b/Dreamio/NativePlaybackBackend.swift @@ -0,0 +1,46 @@ +import UIKit + +protocol NativePlaybackBackend: AnyObject { + var view: UIView { get } + var onReady: (() -> Void)? { get set } + var onFailure: ((Error) -> Void)? { get set } + var onStateChange: (() -> Void)? { get set } + var onSubtitleTracksChange: (() -> Void)? { get set } + var isPlaying: Bool { get } + var isSeekable: Bool { get } + var duration: TimeInterval { get } + var currentTime: TimeInterval { get } + var remainingTime: TimeInterval { get } + var position: Float { get } + var subtitleTracks: [SubtitleTrack] { get } + var selectedSubtitleTrackID: Int32 { get } + var subtitleDelay: TimeInterval { get } + + func prepare(in viewController: UIViewController) + func play(request: NativePlaybackRequest) + func play() + func pause() + func togglePlayPause() + func seek(to position: Float) + func jump(by seconds: TimeInterval) + func selectSubtitleTrack(id: Int32) + func adjustSubtitleDelay(by seconds: TimeInterval) + func stop() +} + +enum NativePlaybackError: LocalizedError { + case backendUnavailable + case startupTimedOut + case playbackFailed + + var errorDescription: String? { + switch self { + case .backendUnavailable: + return "Native playback is not available in this build." + case .startupTimedOut: + return "Native playback did not start before the timeout." + case .playbackFailed: + return "VLC reported a playback error for this stream." + } + } +} diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift new file mode 100644 index 0000000..54de22d --- /dev/null +++ b/Dreamio/NativePlayerViewController.swift @@ -0,0 +1,375 @@ +import UIKit + +final class NativePlayerViewController: UIViewController { + private let request: NativePlaybackRequest + private var backend: NativePlaybackBackend + private var startupTimer: Timer? + private var controlsTimer: Timer? + private var progressTimer: Timer? + private var isScrubbing = false + var onDismiss: (() -> Void)? + + private let loadingView: UIActivityIndicatorView = { + let view = UIActivityIndicatorView(style: .large) + view.translatesAutoresizingMaskIntoConstraints = false + view.color = .white + view.startAnimating() + return view + }() + + private let closeButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(UIImage(systemName: "xmark"), for: .normal) + button.tintColor = .white + button.backgroundColor = UIColor.black.withAlphaComponent(0.45) + button.layer.cornerRadius = 22 + button.accessibilityLabel = "Close" + return button + }() + + private let controlsContainer: UIVisualEffectView = { + let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark)) + view.translatesAutoresizingMaskIntoConstraints = false + view.layer.cornerRadius = 12 + view.clipsToBounds = true + return view + }() + + private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause") + private let jumpBackButton = NativePlayerViewController.iconButton(systemName: "gobackward.15", label: "Jump Back 15 Seconds") + private let jumpForwardButton = NativePlayerViewController.iconButton(systemName: "goforward.15", label: "Jump Forward 15 Seconds") + private let captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Captions") + + private let elapsedLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .white + label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium) + label.text = "0:00" + return label + }() + + private let remainingLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .white + label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium) + label.textAlignment = .right + label.text = "-0:00" + return label + }() + + private let scrubber: UISlider = { + let slider = UISlider() + slider.translatesAutoresizingMaskIntoConstraints = false + slider.minimumValue = 0 + slider.maximumValue = 1 + slider.minimumTrackTintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 1) + slider.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.3) + slider.thumbTintColor = .white + return slider + }() + + private let failureLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .white + label.textAlignment = .center + label.numberOfLines = 0 + label.font = .preferredFont(forTextStyle: .body) + label.isHidden = true + return label + }() + + init(request: NativePlaybackRequest, backend: NativePlaybackBackend = VLCNativePlaybackBackend()) { + self.request = request + self.backend = backend + super.init(nibName: nil, bundle: nil) + modalPresentationStyle = .fullScreen + modalTransitionStyle = .crossDissolve + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + .allButUpsideDown + } + + override var prefersHomeIndicatorAutoHidden: Bool { + true + } + + override var prefersStatusBarHidden: Bool { + true + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .black + configureBackend() + configureLayout() + startStartupTimer() + backend.play(request: request) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + startupTimer?.invalidate() + controlsTimer?.invalidate() + progressTimer?.invalidate() + backend.stop() + onDismiss?() + } + + private func configureBackend() { + backend.prepare(in: self) + backend.view.translatesAutoresizingMaskIntoConstraints = false + backend.onReady = { [weak self] in + DispatchQueue.main.async { + self?.startupTimer?.invalidate() + self?.loadingView.stopAnimating() + self?.loadingView.isHidden = true + self?.startProgressUpdates() + self?.refreshControls() + self?.scheduleControlsHide() + } + } + backend.onFailure = { [weak self] error in + DispatchQueue.main.async { + self?.startupTimer?.invalidate() + self?.showFailure(error) + } + } + backend.onStateChange = { [weak self] in + DispatchQueue.main.async { + self?.refreshControls() + } + } + backend.onSubtitleTracksChange = { [weak self] in + DispatchQueue.main.async { + self?.refreshControls() + } + } + } + + private func startStartupTimer() { + startupTimer?.invalidate() + startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in + self?.backend.stop() + self?.showFailure(NativePlaybackError.startupTimedOut) + } + } + + private func configureLayout() { + view.addSubview(backend.view) + view.addSubview(loadingView) + view.addSubview(failureLabel) + view.addSubview(controlsContainer) + view.addSubview(closeButton) + closeButton.addTarget(self, action: #selector(close), for: .touchUpInside) + playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside) + jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside) + jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside) + captionsButton.addTarget(self, action: #selector(showCaptions), for: .touchUpInside) + scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown) + scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged) + scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel]) + + let tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility)) + tap.cancelsTouchesInView = false + view.addGestureRecognizer(tap) + + let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton]) + controlRow.translatesAutoresizingMaskIntoConstraints = false + controlRow.axis = .horizontal + controlRow.alignment = .center + controlRow.distribution = .equalCentering + controlRow.spacing = 18 + + let timeRow = UIStackView(arrangedSubviews: [elapsedLabel, remainingLabel]) + timeRow.translatesAutoresizingMaskIntoConstraints = false + timeRow.axis = .horizontal + timeRow.distribution = .fillEqually + + let stack = UIStackView(arrangedSubviews: [scrubber, timeRow, controlRow]) + stack.translatesAutoresizingMaskIntoConstraints = false + stack.axis = .vertical + stack.spacing = 8 + controlsContainer.contentView.addSubview(stack) + + NSLayoutConstraint.activate([ + backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + backend.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + backend.view.topAnchor.constraint(equalTo: view.topAnchor), + backend.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + + failureLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 28), + failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28), + failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + + closeButton.widthAnchor.constraint(equalToConstant: 44), + closeButton.heightAnchor.constraint(equalToConstant: 44), + closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), + closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12), + + controlsContainer.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 18), + controlsContainer.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -18), + controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18), + + stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 16), + stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -16), + stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 14), + stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -14), + + jumpBackButton.widthAnchor.constraint(equalToConstant: 44), + jumpBackButton.heightAnchor.constraint(equalToConstant: 44), + playPauseButton.widthAnchor.constraint(equalToConstant: 54), + playPauseButton.heightAnchor.constraint(equalToConstant: 54), + jumpForwardButton.widthAnchor.constraint(equalToConstant: 44), + jumpForwardButton.heightAnchor.constraint(equalToConstant: 44), + captionsButton.widthAnchor.constraint(equalToConstant: 44), + captionsButton.heightAnchor.constraint(equalToConstant: 44) + ]) + } + + private func showFailure(_ error: Error) { + loadingView.stopAnimating() + loadingView.isHidden = true + failureLabel.text = "Native playback could not start.\n\(error.localizedDescription)" + failureLabel.isHidden = false +#if DEBUG + print("[DreamioNativePlayer] error=\(URLRedactor.redactedURLString(error.localizedDescription))") +#endif + } + + @objc private func close() { + dismiss(animated: true) + } + + @objc private func togglePlayPause() { + backend.togglePlayPause() + revealControls() + } + + @objc private func jumpBack() { + backend.jump(by: -15) + revealControls() + } + + @objc private func jumpForward() { + backend.jump(by: 15) + revealControls() + } + + @objc private func scrubbingStarted() { + isScrubbing = true + controlsTimer?.invalidate() + } + + @objc private func scrubberChanged() { + elapsedLabel.text = PlaybackTimeFormatter.label(for: TimeInterval(scrubber.value) * backend.duration) + } + + @objc private func scrubbingEnded() { + backend.seek(to: scrubber.value) + isScrubbing = false + revealControls() + } + + @objc private func toggleControlsVisibility() { + if controlsContainer.alpha < 1 { + revealControls() + } else if backend.isPlaying { + hideControls() + } + } + + @objc private func showCaptions() { + revealControls() + let alert = UIAlertController(title: "Captions", message: nil, preferredStyle: .actionSheet) + SubtitleOptionMapper.options(from: backend.subtitleTracks).forEach { track in + let prefix = track.id == backend.selectedSubtitleTrackID ? "Selected: " : "" + alert.addAction(UIAlertAction(title: "\(prefix)\(track.name)", style: .default) { [weak self] _ in + self?.backend.selectSubtitleTrack(id: track.id) + }) + } + alert.addAction(UIAlertAction(title: "Delay -0.5s", style: .default) { [weak self] _ in + self?.backend.adjustSubtitleDelay(by: -0.5) + }) + alert.addAction(UIAlertAction(title: "Delay +0.5s", style: .default) { [weak self] _ in + self?.backend.adjustSubtitleDelay(by: 0.5) + }) + alert.addAction(UIAlertAction(title: "Current Delay: \(String(format: "%.1fs", backend.subtitleDelay))", style: .default)) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + if let popover = alert.popoverPresentationController { + popover.sourceView = captionsButton + popover.sourceRect = captionsButton.bounds + } + present(alert, animated: true) + } + + private func startProgressUpdates() { + progressTimer?.invalidate() + progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + self?.refreshControls() + } + } + + private func refreshControls() { + playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal) + scrubber.isEnabled = backend.isSeekable + jumpBackButton.isEnabled = backend.isSeekable + jumpForwardButton.isEnabled = backend.isSeekable + captionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmpty + elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime) + remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))" + if !isScrubbing { + scrubber.value = backend.position + } + [scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = backend.isSeekable ? 1 : 0.45 } + } + + private func revealControls() { + UIView.animate(withDuration: 0.18) { + self.controlsContainer.alpha = 1 + self.closeButton.alpha = 1 + } + scheduleControlsHide() + } + + private func hideControls() { + UIView.animate(withDuration: 0.24) { + self.controlsContainer.alpha = 0 + self.closeButton.alpha = 0 + } + } + + private func scheduleControlsHide() { + controlsTimer?.invalidate() + guard backend.isPlaying else { + return + } + controlsTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in + self?.hideControls() + } + } + + private static func iconButton(systemName: String, label: String) -> UIButton { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(UIImage(systemName: systemName), for: .normal) + button.tintColor = .white + button.backgroundColor = UIColor.black.withAlphaComponent(0.35) + button.layer.cornerRadius = 22 + button.accessibilityLabel = label + return button + } +} diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift new file mode 100644 index 0000000..11ab6b3 --- /dev/null +++ b/Dreamio/StreamCandidate.swift @@ -0,0 +1,371 @@ +import Foundation + +enum StreamSourceKind: String { + case debridio + case torrentio + case realDebrid + case directFile + case unknown +} + +enum StreamContainerGuess: String { + case hls + case mp4 + case mkv + case avi + case webm + case unknown +} + +struct NativePlaybackRequest { + let playbackURL: URL + let observedURL: URL + let resolverURL: URL? + let pageURL: URL? + let userAgent: String? + let referer: String + let headers: [String: String] + let classification: StreamClassification + let subtitleCandidates: [SubtitleCandidate] +} + +struct SubtitleCandidate: Equatable { + let url: URL + let label: String + let language: String? +} + +struct SubtitleTrack: Equatable { + let id: Int32 + let name: String +} + +enum PlaybackTimeFormatter { + static func label(for seconds: TimeInterval) -> String { + guard seconds.isFinite, seconds > 0 else { + return "0:00" + } + + let roundedSeconds = Int(seconds.rounded()) + let hours = roundedSeconds / 3600 + let minutes = (roundedSeconds % 3600) / 60 + let seconds = roundedSeconds % 60 + + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } + return String(format: "%d:%02d", minutes, seconds) + } +} + +enum SubtitleOptionMapper { + static let offTrack = SubtitleTrack(id: -1, name: "Off") + + static func options(from tracks: [SubtitleTrack]) -> [SubtitleTrack] { + [offTrack] + tracks.filter { $0.id >= 0 } + } +} + +struct StreamClassification { + let sourceKind: StreamSourceKind + let containerGuess: StreamContainerGuess + let reason: String + let shouldIntercept: Bool + let sanitizedObservedURL: String + let sanitizedResolverURL: String? +} + +struct StreamCandidate { + let observedURL: URL + let resolverURL: URL? + let pageURL: URL? + let subtitleCandidates: [SubtitleCandidate] + + init?(messageBody: Any) { + guard let body = messageBody as? [String: Any], + let observed = Self.url(from: body["url"]) + else { + return nil + } + + observedURL = observed + resolverURL = Self.url(from: body["resolverUrl"]) + pageURL = Self.url(from: body["pageUrl"]) + subtitleCandidates = SubtitleCandidateParser.candidates(in: body["subtitles"]) + } + + private static func url(from value: Any?) -> URL? { + guard let string = value as? String, !string.isEmpty else { + return nil + } + + return URL(string: string) + } +} + +enum SubtitleCandidateParser { + private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"] + private static let urlFields = ["url", "href", "src", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"] + private static let labelFields = ["label", "name", "title", "lang", "language", "id"] + + static func candidates(in payload: Any?) -> [SubtitleCandidate] { + var results: [SubtitleCandidate] = [] + collect(from: payload, into: &results) + + var seen = Set() + return results.filter { candidate in + let key = candidate.url.absoluteString + guard !seen.contains(key) else { + return false + } + seen.insert(key) + return true + } + } + + private static func collect(from value: Any?, into results: inout [SubtitleCandidate]) { + switch value { + case let dictionary as [String: Any]: + if let candidate = candidate(from: dictionary) { + results.append(candidate) + } + dictionary.values.forEach { collect(from: $0, into: &results) } + case let array as [Any]: + array.forEach { collect(from: $0, into: &results) } + case let string as String: + if let url = subtitleURL(from: string) { + results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil)) + } else { + extractSubtitleURLs(from: string).forEach { url in + results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil)) + } + } + default: + break + } + } + + private static func candidate(from dictionary: [String: Any]) -> SubtitleCandidate? { + guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first else { + return nil + } + + let label = labelFields.lazy.compactMap { dictionary[$0] as? String }.first + let language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String) + return SubtitleCandidate( + url: url, + label: label?.isEmpty == false ? label! : defaultLabel(for: url), + language: language + ) + } + + private static func subtitleURL(from string: String?) -> URL? { + guard let string, + let url = URL(string: string), + ["http", "https"].contains(url.scheme?.lowercased()) + else { + return nil + } + + let lowercased = url.absoluteString.lowercased() + guard supportedExtensions.contains(url.pathExtension.lowercased()) + || supportedExtensions.contains(where: { lowercased.contains(".\($0)?") || lowercased.contains(".\($0)&") }) + || lowercased.contains("subtitle") + || lowercased.contains("opensubtitles") + else { + return nil + } + + return url + } + + private static func defaultLabel(for url: URL) -> String { + let lastPathComponent = url.deletingPathExtension().lastPathComponent + return lastPathComponent.isEmpty ? "External Subtitle" : lastPathComponent + } + + private static func extractSubtitleURLs(from string: String) -> [URL] { + let pattern = #"https?://[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*"# + let range = NSRange(string.startIndex.. NativePlaybackRequest? { + let classification = classify(candidate: candidate) + guard classification.shouldIntercept else { + return nil + } + + return NativePlaybackRequest( + playbackURL: candidate.observedURL, + observedURL: candidate.observedURL, + resolverURL: candidate.resolverURL, + pageURL: candidate.pageURL, + userAgent: userAgent, + referer: referer, + headers: Self.defaultHeaders(userAgent: userAgent), + classification: classification, + subtitleCandidates: candidate.subtitleCandidates + ) + } + + static func defaultHeaders(userAgent: String?) -> [String: String] { + var headers = ["Referer": referer] + if let userAgent, !userAgent.isEmpty { + headers["User-Agent"] = userAgent + } + return headers + } + + static func isDirectPlayableFileURL(_ url: URL) -> Bool { + let container = containerGuess(for: url, resolverURL: nil) + return [.mp4, .mkv, .avi, .webm].contains(container) + } + + static func isWebKitCompatibleURL(_ url: URL) -> Bool { + let container = containerGuess(for: url, resolverURL: nil) + return container == .hls || container == .mp4 + } + + static func isKnownResolverURL(_ url: URL) -> Bool { + matches(url, host: "addon.debridio.com", pathPrefix: "/play/") + || matches(url, host: "torrentio.strem.fun", pathPrefix: "/resolve/") + } + + static func classify(candidate: StreamCandidate) -> StreamClassification { + let observed = candidate.observedURL + let resolver = candidate.resolverURL + let matchingURL = resolver ?? observed + let sourceKind = sourceKind(for: matchingURL, observedURL: observed) + let container = containerGuess(for: observed, resolverURL: resolver) + let knownDirectFile = sourceKind == .debridio || sourceKind == .torrentio || sourceKind == .realDebrid + let unsupportedContainer = [.mkv, .avi, .webm].contains(container) + let webCompatibleContainer = container == .hls || container == .mp4 + + let shouldIntercept = knownDirectFile || unsupportedContainer + let reason: String + if knownDirectFile { + reason = "known-direct-file-source" + } else if unsupportedContainer { + reason = "unsupported-container" + } else if webCompatibleContainer { + reason = "web-compatible-container" + } else { + reason = "no-native-rule" + } + + return StreamClassification( + sourceKind: sourceKind, + containerGuess: container, + reason: reason, + shouldIntercept: shouldIntercept, + sanitizedObservedURL: URLRedactor.redactedURLString(observed.absoluteString), + sanitizedResolverURL: resolver.map { URLRedactor.redactedURLString($0.absoluteString) } + ) + } + + private static func sourceKind(for url: URL, observedURL: URL) -> StreamSourceKind { + let values = [url, observedURL] + if values.contains(where: { matches($0, host: "addon.debridio.com", pathPrefix: "/play/") }) { + return .debridio + } + if values.contains(where: { matches($0, host: "torrentio.strem.fun", pathPrefix: "/resolve/") }) { + return .torrentio + } + if values.contains(where: { ($0.host ?? "").lowercased() == "download.real-debrid.com" }) { + return .realDebrid + } + if [.mkv, .avi, .webm].contains(containerGuess(for: observedURL, resolverURL: url)) { + return .directFile + } + return .unknown + } + + private static func matches(_ url: URL, host: String, pathPrefix: String) -> Bool { + (url.host ?? "").lowercased() == host && url.path.lowercased().hasPrefix(pathPrefix) + } + + private static func containerGuess(for observedURL: URL, resolverURL: URL?) -> StreamContainerGuess { + let values = [observedURL, resolverURL].compactMap { $0 } + if values.contains(where: { $0.pathExtension.lowercased() == "m3u8" || $0.absoluteString.lowercased().contains(".m3u8") }) { + return .hls + } + + for url in values { + let text = url.absoluteString.lowercased() + if url.pathExtension.lowercased() == "mp4" || text.contains(".mp4") { + return .mp4 + } + if url.pathExtension.lowercased() == "mkv" || text.contains(".mkv") { + return .mkv + } + if url.pathExtension.lowercased() == "avi" || text.contains(".avi") { + return .avi + } + if url.pathExtension.lowercased() == "webm" || text.contains(".webm") { + return .webm + } + } + + return .unknown + } +} + +enum URLRedactor { + static func redactedURLString(_ value: String) -> String { + guard var components = URLComponents(string: value), components.scheme != nil else { + return redactTokenLikeFragments(in: value) + } + + components.query = nil + components.fragment = nil + if !components.path.isEmpty { + components.path = redactTokenLikePathSegments(in: components.path) + } + return redactTokenLikeFragments(in: components.string ?? value) + } + + private static func redactTokenLikePathSegments(in path: String) -> String { + path + .split(separator: "/", omittingEmptySubsequences: false) + .map { segment -> String in + let text = String(segment) + if text.range(of: #"^[A-Za-z0-9_-]{24,}$"#, options: .regularExpression) != nil { + return "[redacted]" + } + return text + } + .joined(separator: "/") + } + + private static func redactTokenLikeFragments(in value: String) -> String { + let patterns = [ + #"(?i)((?:token|access_token|auth|signature|sig|key|apikey|api_key|jwt|session|password)=)([^&\s]+)"#, + #"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+"# + ] + + return patterns.reduce(value) { redacted, pattern in + redacted.replacingOccurrences( + of: pattern, + with: "$1[redacted]", + options: .regularExpression + ) + } + } +} diff --git a/Dreamio/StreamResolver.swift b/Dreamio/StreamResolver.swift new file mode 100644 index 0000000..1943dea --- /dev/null +++ b/Dreamio/StreamResolver.swift @@ -0,0 +1,167 @@ +import Foundation + +struct ResolvedNativeStream { + let playbackURL: URL + let headers: [String: String] + let source: String +} + +enum StreamResolverError: LocalizedError { + case noResolverURL + case httpStatus(Int) + case emptyResponse + case invalidResponse + case noPlayableStream + + var errorDescription: String? { + switch self { + case .noResolverURL: + return "Dreamio could not find an addon resolver URL for this stream." + case let .httpStatus(status): + return "The stream resolver returned HTTP \(status)." + case .emptyResponse: + return "The stream resolver returned an empty response." + case .invalidResponse: + return "The stream resolver returned data Dreamio could not parse." + case .noPlayableStream: + return "The resolver did not return a direct playable media URL." + } + } +} + +protocol StreamResolving { + func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream +} + +final class StremioStreamResolver: StreamResolving { + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream { + if StreamClassifier.isDirectPlayableFileURL(request.observedURL) { + return ResolvedNativeStream( + playbackURL: request.observedURL, + headers: request.headers, + source: "observed-direct-file" + ) + } + + let possibleResolverURL = request.resolverURL ?? request.observedURL + guard StreamClassifier.isKnownResolverURL(possibleResolverURL) else { + throw StreamResolverError.noResolverURL + } + + var urlRequest = URLRequest(url: possibleResolverURL) + urlRequest.setValue("application/json", forHTTPHeaderField: "Accept") + request.headers.forEach { key, value in + urlRequest.setValue(value, forHTTPHeaderField: key) + } + + let (data, response) = try await session.data(for: urlRequest) + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + throw StreamResolverError.httpStatus(httpResponse.statusCode) + } + if let finalURL = response.url, StreamClassifier.isDirectPlayableFileURL(finalURL) { + return ResolvedNativeStream( + playbackURL: finalURL, + headers: request.headers, + source: "resolver-redirect" + ) + } + guard !data.isEmpty else { + throw StreamResolverError.emptyResponse + } + + let payload = try parsePayload(from: data) + guard let stream = Self.bestPlayableStream(in: payload, fallbackHeaders: request.headers) else { + throw StreamResolverError.noPlayableStream + } + return stream + } + + private func parsePayload(from data: Data) throws -> Any { + do { + return try JSONSerialization.jsonObject(with: data) + } catch { + throw StreamResolverError.invalidResponse + } + } + + static func bestPlayableStream(in payload: Any, fallbackHeaders: [String: String]) -> ResolvedNativeStream? { + let streams = streamDictionaries(in: payload) + let candidates = streams.compactMap { stream -> ResolvedNativeStream? in + guard let url = directURL(in: stream) else { + return nil + } + guard StreamClassifier.isDirectPlayableFileURL(url) else { + return nil + } + return ResolvedNativeStream( + playbackURL: url, + headers: mergedHeaders(fallbackHeaders: fallbackHeaders, stream: stream), + source: "resolver-json" + ) + } + + return candidates.first { !StreamClassifier.isWebKitCompatibleURL($0.playbackURL) } ?? candidates.first + } + + private static func streamDictionaries(in payload: Any) -> [[String: Any]] { + if let dictionary = payload as? [String: Any], + let streams = dictionary["streams"] as? [[String: Any]] { + return streams + } + if let streams = payload as? [[String: Any]] { + return streams + } + return [] + } + + private static func directURL(in stream: [String: Any]) -> URL? { + let fields = ["url", "externalUrl", "externalURL", "file", "streamUrl", "streamURL"] + for field in fields { + if let value = stream[field] as? String, + let url = URL(string: value), + ["http", "https"].contains(url.scheme?.lowercased()) { + return url + } + } + return nil + } + + private static func mergedHeaders(fallbackHeaders: [String: String], stream: [String: Any]) -> [String: String] { + var headers = fallbackHeaders + headerDictionaries(in: stream).forEach { headerDictionary in + headerDictionary.forEach { key, value in + headers[key] = value + } + } + return headers + } + + private static func headerDictionaries(in stream: [String: Any]) -> [[String: String]] { + var dictionaries: [[String: String]] = [] + + if let headers = stream["headers"] as? [String: String] { + dictionaries.append(headers) + } + if let requestHeaders = stream["requestHeaders"] as? [String: String] { + dictionaries.append(requestHeaders) + } + if let behaviorHints = stream["behaviorHints"] as? [String: Any] { + if let headers = behaviorHints["headers"] as? [String: String] { + dictionaries.append(headers) + } + if let proxyHeaders = behaviorHints["proxyHeaders"] as? [String: Any], + let request = proxyHeaders["request"] as? [String: String] { + dictionaries.append(request) + } + } + + return dictionaries + } +} diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift new file mode 100644 index 0000000..167b241 --- /dev/null +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -0,0 +1,255 @@ +import UIKit + +#if canImport(MobileVLCKit) +import MobileVLCKit +#endif + +final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { + static var isAvailable: Bool { +#if canImport(MobileVLCKit) + true +#else + false +#endif + } + + let view = UIView() + var onReady: (() -> Void)? + var onFailure: ((Error) -> Void)? + var onStateChange: (() -> Void)? + var onSubtitleTracksChange: (() -> Void)? + +#if canImport(MobileVLCKit) + private let mediaPlayer = VLCMediaPlayer() +#endif + private var attachedSubtitleURLs = Set() + + override init() { + super.init() +#if canImport(MobileVLCKit) + mediaPlayer.delegate = self +#endif + view.backgroundColor = .black + } + + func prepare(in viewController: UIViewController) { +#if canImport(MobileVLCKit) + mediaPlayer.drawable = view +#endif + } + + func play(request: NativePlaybackRequest) { +#if canImport(MobileVLCKit) + let media = VLCMedia(url: request.playbackURL) + let headerValue = request.headers + .map { "\($0.key): \($0.value)" } + .joined(separator: "\r\n") + media.addOption(":http-referrer=\(request.referer)") + if let userAgent = request.userAgent { + media.addOption(":http-user-agent=\(userAgent)") + } + if !headerValue.isEmpty { + media.addOption(":http-header=\(headerValue)") + } + + mediaPlayer.media = media +#if DEBUG + print("[DreamioVLC] opening url=\(URLRedactor.redactedURLString(request.playbackURL.absoluteString))") +#endif + mediaPlayer.play() + attachSubtitles(request.subtitleCandidates) +#else + onFailure?(NativePlaybackError.backendUnavailable) +#endif + } + + func play() { +#if canImport(MobileVLCKit) + mediaPlayer.play() +#endif + } + + func pause() { +#if canImport(MobileVLCKit) + mediaPlayer.pause() +#endif + } + + func togglePlayPause() { + isPlaying ? pause() : play() + } + + func seek(to position: Float) { +#if canImport(MobileVLCKit) + guard isSeekable else { + return + } + mediaPlayer.position = max(0, min(1, position)) +#endif + } + + func jump(by seconds: TimeInterval) { +#if canImport(MobileVLCKit) + guard isSeekable else { + return + } + let nextTime = max(0, min(duration, currentTime + seconds)) + mediaPlayer.time = VLCTime(int: Int32(nextTime * 1000)) +#endif + } + + func selectSubtitleTrack(id: Int32) { +#if canImport(MobileVLCKit) + mediaPlayer.currentVideoSubTitleIndex = id + onSubtitleTracksChange?() +#endif + } + + func adjustSubtitleDelay(by seconds: TimeInterval) { +#if canImport(MobileVLCKit) + mediaPlayer.currentVideoSubTitleDelay += Int(seconds * 1_000_000) + onSubtitleTracksChange?() +#endif + } + + func stop() { +#if canImport(MobileVLCKit) + mediaPlayer.stop() + mediaPlayer.drawable = nil + mediaPlayer.media = nil +#endif + } + + var isPlaying: Bool { +#if canImport(MobileVLCKit) + mediaPlayer.isPlaying +#else + false +#endif + } + + var isSeekable: Bool { +#if canImport(MobileVLCKit) + mediaPlayer.isSeekable +#else + false +#endif + } + + var duration: TimeInterval { +#if canImport(MobileVLCKit) + TimeInterval(max(0, mediaPlayer.media?.length.intValue ?? 0)) / 1000 +#else + 0 +#endif + } + + var currentTime: TimeInterval { +#if canImport(MobileVLCKit) + TimeInterval(max(0, mediaPlayer.time.intValue)) / 1000 +#else + 0 +#endif + } + + var remainingTime: TimeInterval { + max(0, duration - currentTime) + } + + var position: Float { +#if canImport(MobileVLCKit) + mediaPlayer.position +#else + 0 +#endif + } + + var subtitleTracks: [SubtitleTrack] { +#if canImport(MobileVLCKit) + let names = mediaPlayer.videoSubTitlesNames as? [String] ?? [] + let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? [] + return zip(indexes, names).map { index, name in + SubtitleTrack(id: index.int32Value, name: name) + } +#else + [] +#endif + } + + var selectedSubtitleTrackID: Int32 { +#if canImport(MobileVLCKit) + mediaPlayer.currentVideoSubTitleIndex +#else + -1 +#endif + } + + var subtitleDelay: TimeInterval { +#if canImport(MobileVLCKit) + TimeInterval(mediaPlayer.currentVideoSubTitleDelay) / 1_000_000 +#else + 0 +#endif + } + +#if canImport(MobileVLCKit) + private func attachSubtitles(_ candidates: [SubtitleCandidate]) { + candidates.forEach { candidate in + guard !attachedSubtitleURLs.contains(candidate.url) else { + return + } + attachedSubtitleURLs.insert(candidate.url) + mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false) +#if DEBUG + print("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))") +#endif + } + } +#endif +} + +#if canImport(MobileVLCKit) +extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { + func mediaPlayerStateChanged(_ aNotification: Notification) { +#if DEBUG + print("[DreamioVLC] state=\(stateName(mediaPlayer.state))") +#endif + switch mediaPlayer.state { + case .buffering, .playing: + onReady?() + onStateChange?() + case .error: + onFailure?(NativePlaybackError.playbackFailed) + case .paused, .stopped, .ended: + onStateChange?() + case .esAdded: + onSubtitleTracksChange?() + default: + break + } + } + + private func stateName(_ state: VLCMediaPlayerState) -> String { + switch state { + case .opening: + return "opening" + case .buffering: + return "buffering" + case .playing: + return "playing" + case .ended: + return "ended" + case .stopped: + return "stopped" + case .error: + return "error" + case .paused: + return "paused" + case .esAdded: + return "elementary-stream-added" + @unknown default: + return "unknown" + } + } +} +#endif diff --git a/Podfile b/Podfile new file mode 100644 index 0000000..10c813d --- /dev/null +++ b/Podfile @@ -0,0 +1,42 @@ +platform :ios, '16.0' + +target 'Dreamio' do + use_frameworks! + + pod 'MobileVLCKit' +end + +post_install do |_installer| + project = Xcodeproj::Project.open('Dreamio.xcodeproj') + target = project.targets.find { |candidate| candidate.name == 'Dreamio' } + next unless target + + target.frameworks_build_phase.files.delete_if do |build_file| + build_file.file_ref&.display_name == 'Pods_Dreamio.framework' + end + + phase_name = '[CP] Prepare MobileVLCKit XCFramework' + phase = target.shell_script_build_phases.find { |candidate| candidate.name == phase_name } + phase ||= target.new_shell_script_build_phase(phase_name) + phase.shell_script = <<~SH + if [ -x "${PODS_ROOT}/Target Support Files/MobileVLCKit/MobileVLCKit-xcframeworks.sh" ]; then + "${PODS_ROOT}/Target Support Files/MobileVLCKit/MobileVLCKit-xcframeworks.sh" + else + echo "error: MobileVLCKit is missing. Run 'pod install' and open Dreamio.xcworkspace, or rebuild after Pods are installed." >&2 + exit 1 + fi + SH + phase.input_file_list_paths = [ + '${PODS_ROOT}/Target Support Files/MobileVLCKit/MobileVLCKit-xcframeworks-input-files.xcfilelist' + ] + phase.output_file_list_paths = [] + + check_phase = target.shell_script_build_phases.find { |candidate| candidate.name == '[CP] Check Pods Manifest.lock' } + if check_phase + target.build_phases.delete(phase) + insert_index = target.build_phases.index(check_phase) + 1 + target.build_phases.insert(insert_index, phase) + end + + project.save +end diff --git a/Podfile.lock b/Podfile.lock new file mode 100644 index 0000000..ad278f5 --- /dev/null +++ b/Podfile.lock @@ -0,0 +1,16 @@ +PODS: + - MobileVLCKit (3.7.3) + +DEPENDENCIES: + - MobileVLCKit + +SPEC REPOS: + trunk: + - MobileVLCKit + +SPEC CHECKSUMS: + MobileVLCKit: 73d7ddb52238b6885b70b0f281cae75a0a6e3ac0 + +PODFILE CHECKSUM: 5d4ff6c157e7ad147c7e642ebbe89238e6624e6b + +COCOAPODS: 1.16.2 diff --git a/README.md b/README.md index d18fe91..750a4f9 100644 --- a/README.md +++ b/README.md @@ -7,28 +7,81 @@ inside a UIKit host app, handles new-window navigation in the existing web view, allows inline media playback, and leaves playback viability to real-device testing. -## Running the MVP +## Running Dreamio -1. Open `Dreamio.xcodeproj` in Xcode. -2. Select the `Dreamio` scheme. -3. Pick a real iPhone or iPad device. -4. Set a development team for code signing if Xcode asks. -5. Build and run. +1. Install CocoaPods if needed. +2. Run `pod install`. +3. Open `Dreamio.xcworkspace` in Xcode. +4. Select the `Dreamio` scheme. +5. Pick a real iPhone or iPad device. +6. Set a development team for code signing if Xcode asks. +7. Build and run. -The repository machine currently has Command Line Tools selected instead of full -Xcode, so command-line `xcodebuild` validation is not available here. +Dreamio uses MobileVLCKit for native playback of direct-file streams that iOS +WebKit commonly cannot play, especially MKV, AVI, and WebM debrid URLs. Keep +using `Dreamio.xcworkspace` after installing pods so Xcode links the native +playback backend. -## MVP Validation Checklist +If the app says "Native playback needs CocoaPods" or a player screen says +"Native playback is not available in this build," the binary was built without +MobileVLCKit linked. To resolve it, install CocoaPods, run `pod install` from +this repository, open `Dreamio.xcworkspace` instead of `Dreamio.xcodeproj`, and +build the workspace. Direct MKV, AVI, and WebM playback depends on that +workspace build because the raw project intentionally keeps a fallback compile +path for environments where CocoaPods has not been installed yet. -- Cold launch loads hosted Stremio Web. -- Login completes and persists after app relaunch. -- Catalog and library navigation work. -- Addon install or configuration flows work, including redirects or popups. -- HLS direct stream playback works. -- MP4 direct stream playback works. -- Unsupported formats fail understandably. -- Fullscreen, rotation, pause/resume, and background/foreground behavior are - acceptable for v1. +On macOS, install CocoaPods with RubyGems: + +```bash +sudo gem install cocoapods +pod --version +pod install +open Dreamio.xcworkspace +``` + +If the gem install fails because of a local Ruby or permissions issue, another +common macOS option is Homebrew: + +```bash +brew install cocoapods +pod --version +pod install +open Dreamio.xcworkspace +``` + +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 +`pod install` generated `Dreamio.xcworkspace` plus `Podfile.lock` with +MobileVLCKit 3.7.3. The workspace builds from the command line when full Xcode +is selected for that command: + +```bash +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \ + xcodebuild -workspace Dreamio.xcworkspace \ + -scheme Dreamio \ + -configuration Debug \ + -sdk iphonesimulator \ + build +``` + +## Playback Validation Checklist + +1. Cold launch loads hosted Stremio Web. +2. Login completes and persists after app relaunch. +3. Catalog and library navigation work. +4. Addon install or configuration flows work, including redirects or popups. +5. HLS direct stream playback works. +6. MP4 direct stream playback works. +7. Debridio, Torrentio, and Real-Debrid MKV/AVI/WebM direct-file streams open + the native player before WebKit reaches its visible media failure state. +8. Closing the native player returns to the existing Stremio Web session. +9. DEBUG logs show sanitized stream classification and native player errors + without full debrid URLs, query strings, tokens, or long secret-like path + segments. Track playback results by device, iOS version, stream protocol, container, codec, subtitle type, HTTP status, and WebKit media error when available. diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift new file mode 100644 index 0000000..6cc5573 --- /dev/null +++ b/Tests/StreamResolverTests.swift @@ -0,0 +1,126 @@ +import Foundation + +@main +struct StreamResolverTests { + static func main() { + testClassifierPrefersObservedDirectFile() + testResolverSelectsUnsupportedDirectURLAndHeaders() + testResolverRejectsHLSOnlyResponse() + testRedactorHandlesPercentEncodedPath() + testPlaybackTimeFormatting() + testSubtitleCandidateParsing() + testSubtitleOptionMappingIncludesOff() + print("StreamResolverTests passed") + } + + private static func testClassifierPrefersObservedDirectFile() { + let body: [String: Any] = [ + "url": "https://cdn.example.test/movie.mkv?token=secret", + "resolverUrl": "https://addon.debridio.com/play/example" + ] + let candidate = StreamCandidate(messageBody: body)! + let request = StreamClassifier.playbackRequest(from: candidate, userAgent: "DreamioTest/1")! + + assertEqual(request.playbackURL.absoluteString, "https://cdn.example.test/movie.mkv?token=secret") + assertEqual(request.headers["Referer"], "https://web.stremio.com/") + assertEqual(request.headers["User-Agent"], "DreamioTest/1") + } + + private static func testResolverSelectsUnsupportedDirectURLAndHeaders() { + let payload: [String: Any] = [ + "streams": [ + [ + "url": "https://cdn.example.test/trailer.mp4" + ], + [ + "externalUrl": "https://cdn.example.test/movie.mkv?signature=secret", + "behaviorHints": [ + "proxyHeaders": [ + "request": [ + "Referer": "https://resolver.example.test/", + "User-Agent": "ResolverAgent/1" + ] + ] + ] + ] + ] + ] + + let stream = StremioStreamResolver.bestPlayableStream( + in: payload, + fallbackHeaders: ["Referer": "https://web.stremio.com/"] + )! + + assertEqual(stream.playbackURL.absoluteString, "https://cdn.example.test/movie.mkv?signature=secret") + assertEqual(stream.headers["Referer"], "https://resolver.example.test/") + assertEqual(stream.headers["User-Agent"], "ResolverAgent/1") + } + + private static func testResolverRejectsHLSOnlyResponse() { + let payload: [String: Any] = [ + "streams": [ + ["url": "https://cdn.example.test/live.m3u8"] + ] + ] + + let stream = StremioStreamResolver.bestPlayableStream( + in: payload, + fallbackHeaders: ["Referer": "https://web.stremio.com/"] + ) + + assert(stream == nil, "Expected HLS-only resolver response to stay out of native playback") + } + + private static func testRedactorHandlesPercentEncodedPath() { + let original = "https://cdn.example.test/video/abcdefghijklmnopqrstuvwxyz012345/%E2%9C%93.mp4?token=secret#fragment" + let redacted = URLRedactor.redactedURLString(original) + + assertEqual(redacted, "https://cdn.example.test/video/%5Bredacted%5D/%E2%9C%93.mp4") + } + + private static func testPlaybackTimeFormatting() { + assertEqual(PlaybackTimeFormatter.label(for: 0), "0:00") + assertEqual(PlaybackTimeFormatter.label(for: 65), "1:05") + assertEqual(PlaybackTimeFormatter.label(for: 3_725), "1:02:05") + } + + private static func testSubtitleCandidateParsing() { + let payload: [String: Any] = [ + "subtitles": [ + [ + "lang": "eng", + "url": "https://opensubtitles.example.test/download/subtitle.srt?token=secret" + ], + [ + "language": "Spanish", + "file": "https://cdn.example.test/movie.es.vtt" + ], + "https://cdn.example.test/ignored.txt" + ], + "nested": [ + "body": "metadata https://cdn.example.test/movie.fr.ass?download=1" + ] + ] + + let candidates = SubtitleCandidateParser.candidates(in: payload) + + assertEqual(candidates.count, 3) + assertEqual(candidates[0].language, "eng") + assertEqual(candidates[1].label, "Spanish") + assertEqual(candidates[2].url.absoluteString, "https://cdn.example.test/movie.fr.ass?download=1") + } + + private static func testSubtitleOptionMappingIncludesOff() { + let options = SubtitleOptionMapper.options(from: [ + SubtitleTrack(id: 2, name: "English"), + SubtitleTrack(id: 5, name: "Spanish") + ]) + + assertEqual(options.map(\.name), ["Off", "English", "Spanish"]) + assertEqual(options.first?.id, -1) + } + + 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-24-enable-web-inspector-playback-diagnostics.html b/docs/turns/2026-05-24-enable-web-inspector-playback-diagnostics.html new file mode 100644 index 0000000..116c281 --- /dev/null +++ b/docs/turns/2026-05-24-enable-web-inspector-playback-diagnostics.html @@ -0,0 +1,282 @@ + + + + + + Dreamio Web Inspector and Playback Diagnostics + + + + +
+
+
Repository implementation turn
+

Dreamio Web Inspector and Playback Diagnostics

+

Enabled development-only Safari inspection for Dreamio's WKWebView and added token-safe diagnostics for console warnings, promise rejections, video failures, navigation errors, and HTTP navigation statuses.

+
+ +
+

Summary

+

This pass improves observability without changing Dreamio's login, navigation, addon browsing, or playback behavior. Debug builds on iOS 16.4 and newer now opt the WebView into Safari Web Inspector, and page diagnostics flow back to Xcode logs with URL queries, fragments, bearer tokens, and long token-like path segments redacted.

+
+ +
+

Changes Made

+
    +
  • Enabled webView.isInspectable for DEBUG builds on iOS 16.4 and newer.
  • +
  • Installed a small WKUserScript at document start to observe console warnings, console errors, unhandled promise rejections, and dynamically inserted <video> elements.
  • +
  • Added native logging for WKNavigationDelegate response statuses and load failures.
  • +
  • Added native redaction helpers before diagnostic data is printed.
  • +
  • Kept all diagnostics behind #if DEBUG so release builds do not expose the inspection or message bridge surface.
  • +
+
+ +
+

Context

+

The current Debridio VOD failure appears to need browser-level evidence: stream URL shape, request headers, response metadata, MIME type, and JavaScript media errors. Before adding a native player path, Dreamio needs a reliable way to inspect hosted Stremio Web inside the app and collect media failure details from the page itself.

+
+ +
+

Important Implementation Details

+
    +
  • The diagnostics bridge posts messages through window.webkit.messageHandlers.dreamioDiagnostics.
  • +
  • The user script attaches to existing videos and videos inserted later through a MutationObserver.
  • +
  • Video diagnostics include networkState, readyState, currentSrc, media error code, and media error message.
  • +
  • The native logger strips URL query strings and fragments, redacts obvious token-like key values, redacts bearer credentials, and replaces long token-like path segments.
  • +
  • A weak message-handler wrapper avoids the common WKUserContentController retain cycle.
  • +
+
+ +
+

Relevant Diff Snippets

+

The snippets below are rendered with @pierre/diffs from diffs.com-compatible components.

+
+
+ +
+ +
+

Expected Impact for End-Users

+

There should be no visible behavior change for ordinary app use. For development builds, Safari should now show an inspectable Dreamio or web.stremio.com target while the app is foregrounded, making the playback failure much easier to diagnose.

+
+ +
+

Validation

+
    +
  • Ran DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -list -project Dreamio.xcodeproj to confirm the Dreamio scheme.
  • +
  • Ran DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -project Dreamio.xcodeproj -scheme Dreamio -configuration Debug -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build.
  • +
  • The Debug simulator build succeeded.
  • +
+
Manual real-device validation is still needed on kellcd: launch Dreamio, open Safari inspection, reproduce the Debridio VOD failure, and collect Console, Network, and media logs.
+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • This does not fix playback. It adds the evidence-gathering surface for the next diagnosis step.
  • +
  • Safari Web Inspector availability still depends on the device, iOS version, Safari settings, and the app being a debug/development build.
  • +
  • Redaction is intentionally conservative but cannot prove every possible secret shape is removed. It strips common URL and token forms before logging.
  • +
  • The first xcodebuild attempt failed because the active developer directory pointed at Command Line Tools. Re-running with DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer succeeded.
  • +
+
+ +
+

Follow-up Work

+
    +
  • Use Safari Web Inspector on kellcd to capture the failing stream request and media error details.
  • +
  • File a follow-up Beads issue if the evidence points to a native-player fallback, MIME/header adjustment, or hosted Stremio compatibility gap.
  • +
  • Consider adding a temporary debug menu to toggle diagnostics if the log volume gets noisy during broader testing.
  • +
+
+
+ + diff --git a/docs/turns/2026-05-24-native-debrid-playback.html b/docs/turns/2026-05-24-native-debrid-playback.html new file mode 100644 index 0000000..45865d8 --- /dev/null +++ b/docs/turns/2026-05-24-native-debrid-playback.html @@ -0,0 +1,307 @@ + + + + + + Native Debrid Playback + + + +
+
+

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

+

Native Direct-Stream Playback for Debrid Files

+

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

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

Summary

+

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

+
+ +
+

Changes Made

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

Context

+

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

+
+ +
+

Important Implementation Details

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

Relevant Diff Snippets

+

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

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

Expected Impact for End-Users

+

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

+
+ +
+

Validation

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

Issues, Limitations, and Mitigations

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

Follow-up Work

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

New Changes as of 2026-05-24 23:22 EDT

+

Summary of changes: Fixed the Swift build errors reported after the first handoff by converting the injected stream bridge into a raw multiline Swift string and guarding the MobileVLCKit import with canImport(MobileVLCKit).

+

Why this change was made: Swift was interpreting JavaScript regex backslashes as Swift string escapes, and the app could not compile from Dreamio.xcodeproj before CocoaPods had installed and linked MobileVLCKit. The fallback keeps the project buildable enough to show a native-player unavailable error until the workspace is set up with pods.

+

Code diffs: Plain diff fallback is used for the same reason noted above: @pierre/diffs is present as a library but has no runnable CLI exposed in this repo.

+
diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift
+-        source: """
++        source: #"""
+ ...
+-        """,
++        """#,
+
+diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift
++#if canImport(MobileVLCKit)
+ import MobileVLCKit
++#endif
+ ...
++#if canImport(MobileVLCKit)
+     private let mediaPlayer = VLCMediaPlayer()
++#endif
+ ...
++#else
++        onFailure?(NativePlaybackError.backendUnavailable)
++#endif
+

Related issues or PRs: Follow-up to Beads issue dreamio-l68.

+
+
+ + diff --git a/docs/turns/2026-05-25-fix-native-playback-resolution.html b/docs/turns/2026-05-25-fix-native-playback-resolution.html new file mode 100644 index 0000000..83fe380 --- /dev/null +++ b/docs/turns/2026-05-25-fix-native-playback-resolution.html @@ -0,0 +1,196 @@ + + + + + + Fix Native Playback Resolution + + + +
+
+

Fix Native Playback Resolution

+

Dreamio now resolves known Stremio addon stream responses before opening native playback, gives VLC the final direct media URL when one is available, and recovers visibly when stream resolution or VLC startup fails.

+
+ Beads: dreamio-vxs + Native playback + Stream resolution + 2026-05-25 +
+
+ +
+

Summary

+

Fixed the native playback path so direct-file selections no longer default to the Debridio or Torrentio resolver URL. Dreamio either uses the observed direct media URL immediately or fetches a known addon resolver and selects a playable direct URL from its Stremio JSON response.

+
+ +
+

Changes Made

+
    +
  • Changed native request creation to prefer the observed direct media URL instead of the resolver URL.
  • +
  • Added StremioStreamResolver to fetch known addon resolver URLs, parse streams JSON, inspect url, externalUrl, and related fields, and merge required request headers.
  • +
  • Stopped intercepted WebKit media elements before handing the selection to native playback.
  • +
  • Added a closeable resolver failure alert so bad, expired, or unresolvable streams return the user to Stremio.
  • +
  • Hardened VLC startup with state logging, a 20 second startup timeout, failure messages, and explicit media/drawable detachment on stop.
  • +
  • Added focused Swift tests for stream classification and resolver stream selection.
  • +
+
+ +
+

Context

+

The prior native playback bridge intercepted unsupported direct-file streams, but it could pass VLC the resolver/addon URL instead of a final file URL. That made VLC try to play an endpoint intended to return JSON or perform resolution, while WebKit could still continue attempting unsupported playback underneath.

+

The 127.0.0.1:11470 companion-service errors remain treated as secondary noise. This change focuses on the direct stream URL that Dreamio controls before opening MobileVLCKit.

+
+ +
+

Important Implementation Details

+
    +
  • NativePlaybackRequest now carries merged headers so resolver-provided Referer and User-Agent values can reach VLC.
  • +
  • StremioStreamResolver accepts resolver redirects to direct files and JSON responses containing direct stream fields.
  • +
  • The resolver selects unsupported direct files first, so MKV, AVI, and WebM go native while HLS-only resolver responses stay out of VLC.
  • +
  • Duplicate suppression keys by resolver when present, preventing repeated bridge posts during async resolution while still allowing retry after dismiss or failure.
  • +
+
+ +
+

Relevant Diff Snippets

+

Rendered with @pierre/diffs; this excerpt shows the core classifier change that stops preferring resolver URLs by default.

+
Dreamio/StreamCandidate.swift
-1+26
23 unmodified lines
24
25
26
27
28
29
45 unmodified lines
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
23 unmodified lines
let pageURL: URL?
let userAgent: String?
let referer: String
let classification: StreamClassification
}
+
45 unmodified lines
}
+
return NativePlaybackRequest(
playbackURL: candidate.resolverURL ?? candidate.observedURL,
observedURL: candidate.observedURL,
resolverURL: candidate.resolverURL,
pageURL: candidate.pageURL,
userAgent: userAgent,
referer: referer,
classification: classification
)
}
+
static func classify(candidate: StreamCandidate) -> StreamClassification {
let observed = candidate.observedURL
let resolver = candidate.resolverURL
23 unmodified lines
24
25
26
27
28
29
30
45 unmodified lines
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
23 unmodified lines
let pageURL: URL?
let userAgent: String?
let referer: String
let headers: [String: String]
let classification: StreamClassification
}
+
45 unmodified lines
}
+
return NativePlaybackRequest(
playbackURL: candidate.observedURL,
observedURL: candidate.observedURL,
resolverURL: candidate.resolverURL,
pageURL: candidate.pageURL,
userAgent: userAgent,
referer: referer,
headers: Self.defaultHeaders(userAgent: userAgent),
classification: classification
)
}
+
static func defaultHeaders(userAgent: String?) -> [String: String] {
var headers = ["Referer": referer]
if let userAgent, !userAgent.isEmpty {
headers["User-Agent"] = userAgent
}
return headers
}
+
static func isDirectPlayableFileURL(_ url: URL) -> Bool {
let container = containerGuess(for: url, resolverURL: nil)
return [.mp4, .mkv, .avi, .webm].contains(container)
}
+
static func isWebKitCompatibleURL(_ url: URL) -> Bool {
let container = containerGuess(for: url, resolverURL: nil)
return container == .hls || container == .mp4
}
+
static func isKnownResolverURL(_ url: URL) -> Bool {
matches(url, host: "addon.debridio.com", pathPrefix: "/play/")
|| matches(url, host: "torrentio.strem.fun", pathPrefix: "/resolve/")
}
+
static func classify(candidate: StreamCandidate) -> StreamClassification {
let observed = candidate.observedURL
let resolver = candidate.resolverURL
+
+ +
+

Expected Impact for End-Users

+

Users still pick streams inside Stremio Web. Direct MKV, AVI, WebM, and known debrid resolver selections should now open the native player with a final playable file URL when one can be resolved. When resolution or startup fails, Dreamio shows a visible failure state and stays usable.

+
+ +
+

Validation

+
    +
  • Ran swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests && /tmp/dreamio-stream-tests: passed.
  • +
  • Ran swiftc -typecheck Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift: passed.
  • +
  • Ran git diff --check: passed.
  • +
  • pod install could not be run because CocoaPods is not installed in this environment.
  • +
  • xcodebuild could not be run because the active developer directory is Command Line Tools, not full Xcode.
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
Manual real-device validation is still required for the South Park MKV-style stream and any live Debridio or Torrentio resolver behavior.
+
    +
  • Expired or revoked debrid URLs can still fail after successful resolution. The failure should now be visible and recoverable.
  • +
  • Resolver parsing covers common Stremio stream fields and headers, but unusual addon response shapes may need another field mapping.
  • +
  • MobileVLCKit codec limitations remain possible even when Dreamio supplies the correct URL.
  • +
+
+ +
+

Follow-up Work

+
    +
  • Run pod install and build Dreamio.xcworkspace on a machine with CocoaPods and full Xcode.
  • +
  • Validate a real Debridio or Torrentio MKV selection on device and confirm logs show [DreamioStreamResolver] with a final direct URL.
  • +
  • Add mappings for any addon-specific stream fields discovered during device testing.
  • +
+
+
+ + \ No newline at end of file diff --git a/docs/turns/2026-05-25-guard-native-playback-availability.html b/docs/turns/2026-05-25-guard-native-playback-availability.html new file mode 100644 index 0000000..1c33230 --- /dev/null +++ b/docs/turns/2026-05-25-guard-native-playback-availability.html @@ -0,0 +1,218 @@ + + + + + + Guard Native Playback Availability + + + +
+
+

Guard Native Playback Availability

+

Dreamio now checks whether the MobileVLCKit-backed native player is actually linked before presenting the full-screen native player. Raw project builds stay buildable, but they now show a setup alert instead of opening a black player that can only fail.

+
+ Beads: dreamio-2k5 + Native playback + CocoaPods setup + 2026-05-25 +
+
+ +
+

Summary

+

Fixed the unavailable native playback build path by exposing a build-time availability check on VLCNativePlaybackBackend and using it before Dreamio presents native playback. CocoaPods was installed through Homebrew, pod install was run, and the generated workspace now links MobileVLCKit.

+
+ +
+

Changes Made

+
    +
  • Added VLCNativePlaybackBackend.isAvailable, backed by the same canImport(MobileVLCKit) compile condition as the real VLC implementation.
  • +
  • Updated DreamioWebViewController to check native backend availability before resolving and presenting the native player.
  • +
  • Added an actionable setup alert for builds that do not link MobileVLCKit.
  • +
  • Updated the README to explain that the exact unavailable-build message means the binary was built without the CocoaPods workspace.
  • +
  • Installed CocoaPods 1.16.2 with Homebrew and ran pod install, generating Dreamio.xcworkspace and Podfile.lock with MobileVLCKit 3.7.3.
  • +
  • Disabled Xcode user script sandboxing for the project so CocoaPods can embed MobileVLCKit during the framework copy phase.
  • +
+
+ +
+

Context

+

The repository has a Podfile declaring MobileVLCKit, but this checkout did not have a generated Pods/ directory or Dreamio.xcworkspace. In that state, Swift takes the fallback compile path where canImport(MobileVLCKit) is false. Before this change, Dreamio could still present the native player, which then displayed the generic fallback error.

+
+ +
+

Important Implementation Details

+
    +
  • The fallback backend remains intact so opening Dreamio.xcodeproj directly still compiles.
  • +
  • The guard runs before stream resolution, avoiding unnecessary resolver network work when native playback cannot succeed in the current build.
  • +
  • The duplicate playback key is cleared when the guard blocks playback, so the user can retry after rebuilding the app correctly.
  • +
  • The generated workspace references Dreamio.xcodeproj and Pods/Pods.xcodeproj. The Pods/ directory remains ignored, while Podfile.lock and workspace metadata are tracked.
  • +
  • ENABLE_USER_SCRIPT_SANDBOXING is set to NO because the CocoaPods embed frameworks script uses rsync to copy the MobileVLCKit framework into the app bundle.
  • +
  • No public app-facing API changed.
  • +
+
+ +
+

Relevant Diff Snippets

+

@pierre/diffs is installed as a library dependency, but its package does not expose a runnable CLI in this checkout, and npx @pierre/diffs --help failed with "could not determine executable to run." The plain diff below is the fallback snippet for the core behavior change.

+
diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift
+@@ -368,6 +368,12 @@ final class DreamioWebViewController: UIViewController {
+     @MainActor
+     private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async {
++        guard VLCNativePlaybackBackend.isAvailable else {
++            lastNativePlaybackURL = nil
++            showNativePlaybackUnavailableAlert()
++            return
++        }
++
+         do {
+             let resolved = try await streamResolver.resolve(request: request)
+
+diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift
+@@ -5,6 +5,14 @@ import MobileVLCKit
+ #endif
+
+ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
++    static var isAvailable: Bool {
++#if canImport(MobileVLCKit)
++        true
++#else
++        false
++#endif
++    }
++
+     let view = UIView()
+     var onReady: (() -> Void)?
+     var onFailure: ((Error) -> Void)?
+
+ +
+

Expected Impact for End-Users

+

Users who accidentally run a raw .xcodeproj build will see a clear CocoaPods setup message instead of a black native player with an unavailable-build failure. Users who build from Dreamio.xcworkspace with MobileVLCKit linked should continue into VLC-backed direct-file playback.

+
+ +
+

Validation

+
    +
  • Ran swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests && /tmp/dreamio-stream-tests: passed.
  • +
  • Ran swiftc -typecheck Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift: passed.
  • +
  • Ran git diff --check: passed.
  • +
  • Ran HOMEBREW_NO_AUTO_UPDATE=1 brew install cocoapods: passed, installing CocoaPods 1.16.2.
  • +
  • Ran pod --version && pod install: passed, installing MobileVLCKit 3.7.3 and generating the workspace.
  • +
  • Ran DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build: passed.
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
The simulator workspace build passes. Real-device playback validation is still required for the actual VLC-backed stream behavior.
+
    +
  • The global xcode-select value still points at Command Line Tools because changing it requires sudo. Command-line builds can use DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer.
  • +
  • The Pods/ directory is intentionally ignored by git, so another checkout should run pod install after pulling.
  • +
  • The native player still depends on MobileVLCKit behavior once the workspace build is available.
  • +
+
+ +
+

Follow-up Work

+
    +
  • On device, select a direct MKV, AVI, or WebM stream and confirm the VLC-backed player starts.
  • +
+
+
+ + diff --git a/docs/turns/2026-05-25-native-player-controls-captions-close-flow.html b/docs/turns/2026-05-25-native-player-controls-captions-close-flow.html new file mode 100644 index 0000000..14256ed --- /dev/null +++ b/docs/turns/2026-05-25-native-player-controls-captions-close-flow.html @@ -0,0 +1,385 @@ + + + + + + Native Player Controls, Captions, and Close Flow + + + +
+
+

Turn document created May 25, 2026 at 01:02 EDT

+

Native Player Controls, Captions, and Close Flow

+

Dreamio now presents a fuller VLC-backed native playback surface: transport controls, scrubbing, caption selection and delay controls, best-effort external subtitle discovery, and cleanup that returns Stremio Web toward episode or stream selection after native playback closes.

+
+ +
+

Summary

+

Implemented native player controls on top of MobileVLCKit and expanded the web bridge so subtitle metadata discovered in Stremio Web can be carried into VLC. Closing the native player now stops the underlying web media and attempts to escape Stremio Web's stuck preparing or buffering player without forcing a full reload unless cleanup fails.

+
+ +
+

Changes Made

+
    +
  • Extended NativePlaybackBackend with player state, transport controls, seeking, subtitle track selection, and subtitle delay APIs.
  • +
  • Added a native overlay in NativePlayerViewController with close, play/pause, 15-second jumps, scrubber, elapsed and remaining labels, captions, tap-to-reveal, and auto-hide while playing.
  • +
  • Implemented MobileVLCKit-backed state reads and controls in VLCNativePlaybackBackend, including subtitle track mapping and remote subtitle attachment.
  • +
  • Added subtitle candidate parsing for Stremio/OpenSubtitles-like payloads and pure helper tests for time labels and caption option mapping.
  • +
  • Observed JavaScript fetch and XMLHttpRequest responses in Stremio Web to collect subtitle-like URLs before native playback opens.
  • +
  • Added a native dismiss cleanup script that pauses/removes in-page media, clicks visible close/back controls, and falls back to web history when the player state appears stuck.
  • +
+
+ +
+

Context

+

The previous native player surface was intentionally minimal: it opened VLC, showed a spinner, and exposed only a close button. That made unsupported containers playable, but it left users without ordinary playback affordances and could leave Stremio Web behind the modal in a preparing or buffering state.

+

This pass keeps the architecture pragmatic: Stremio Web remains the source of stream selection and metadata, while VLC handles native playback for containers WebKit cannot reliably play.

+
+ +
+

Important Implementation Details

+
    +
  • Subtitle discovery is best effort. The injected bridge watches web responses for URLs that look like subtitle assets or OpenSubtitles links, then includes up to the latest 20 candidates in the native stream candidate message.
  • +
  • VLC receives remote subtitles through addPlaybackSlave(_:type:.subtitle,enforce:). Embedded tracks are exposed from videoSubTitlesNames and videoSubTitlesIndexes.
  • +
  • The captions sheet always includes an explicit Off option, plus simple delay controls in half-second increments.
  • +
  • Seek and jump controls disable visually and functionally when VLC reports a non-seekable stream.
  • +
  • The close flow avoids a full reload during normal cleanup so the user can return to the selection context whenever Stremio's UI cooperates.
  • +
+
+ +
+

Relevant Diff Snippets

+

Rendered with @pierre/diffs/ssr using preloadPatchDiff, following the repository turn-document requirement to use diffs.com rendering for diff snippets.

+
+
Dreamio/NativePlaybackBackend.swift
+12
3 unmodified lines
4
5
6
7
8
9
10
11
3 unmodified lines
var view: UIView { get }
var onReady: (() -> Void)? { get set }
var onFailure: ((Error) -> Void)? { get set }
+
func prepare(in viewController: UIViewController)
func play(request: NativePlaybackRequest)
func stop()
}
3 unmodified lines
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
3 unmodified lines
var view: UIView { get }
var onReady: (() -> Void)? { get set }
var onFailure: ((Error) -> Void)? { get set }
var onStateChange: (() -> Void)? { get set }
var onSubtitleTracksChange: (() -> Void)? { get set }
var isPlaying: Bool { get }
var isSeekable: Bool { get }
var duration: TimeInterval { get }
var currentTime: TimeInterval { get }
var subtitleTracks: [SubtitleTrack] { get }
var selectedSubtitleTrackID: Int32 { get }
+
func prepare(in viewController: UIViewController)
func play(request: NativePlaybackRequest)
func togglePlayPause()
func seek(to position: Float)
func selectSubtitleTrack(id: Int32)
func adjustSubtitleDelay(by seconds: TimeInterval)
func stop()
}
+
Dreamio/DreamioWebViewController.swift
-1+13
112 unmodified lines
111
112
113
114
115
116
112 unmodified lines
pageUrl: window.location.href,
tagName: element && element.tagName ? element.tagName : "",
currentSrc: element && element.currentSrc ? element.currentSrc : ""
});
} catch (_) {}
};
112 unmodified lines
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
112 unmodified lines
pageUrl: window.location.href,
tagName: element && element.tagName ? element.tagName : "",
currentSrc: element && element.currentSrc ? element.currentSrc : "",
subtitles: subtitleCandidates.slice(-20)
});
} catch (_) {}
};
+
const originalFetch = window.fetch;
if (originalFetch) {
window.fetch = async (...args) => {
const response = await originalFetch(...args);
try {
response.clone().text().then(inspectSubtitlePayload).catch(() => {});
} catch (_) {}
return response;
};
}
+
Dreamio/NativePlayerViewController.swift
+8
269 unmodified lines
269 unmodified lines
269 unmodified lines
270
271
272
273
274
275
276
277
269 unmodified lines
SubtitleOptionMapper.options(from: backend.subtitleTracks).forEach { track in
let prefix = track.id == backend.selectedSubtitleTrackID ? "Selected: " : ""
alert.addAction(UIAlertAction(title: "\(prefix)\(track.name)", style: .default) { [weak self] _ in
self?.backend.selectSubtitleTrack(id: track.id)
})
}
alert.addAction(UIAlertAction(title: "Delay -0.5s", style: .default))
alert.addAction(UIAlertAction(title: "Delay +0.5s", style: .default))
+
+
+ +
+

Expected Impact for End-Users

+

Users should be able to control native VLC playback without leaving the app: pause, resume, jump, scrub when possible, switch captions, turn captions off, and make small caption timing corrections. After closing native playback, Stremio Web should more reliably return to episode or stream selection rather than remaining on a stale preparing or buffering player.

+
+ +
+

Validation

+
    +
  • Passed pure Swift tests with swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests && /tmp/dreamio-stream-tests.
  • +
  • Passed simulator build with xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -sdk iphonesimulator -configuration Debug build.
  • +
  • The Xcode build still reports the existing warning that the MobileVLCKit prepare script has no declared outputs.
  • +
  • Ran bd dolt push; Beads reported that no Dolt remote is configured, so issue data remains stored locally and in the committed Beads export.
  • +
  • Manual real-device playback and subtitle validation was not performed in this terminal session.
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • External subtitle support depends on the hosted Stremio Web app exposing subtitle URLs in fetch/XHR responses before native playback starts. If not, VLC will still show embedded tracks.
  • +
  • The close flow uses visible button heuristics because Stremio Web does not provide a native close API to Dreamio. It falls back to web history and only reloads if JavaScript cleanup errors.
  • +
  • The captions sheet is intentionally basic for this pass. It exposes track selection and simple delay adjustments but not full subtitle styling.
  • +
+
+ +
+

Follow-up Work

+
    +
  • Validate embedded and external subtitles on a real device with representative Stremio and OpenSubtitles addons.
  • +
  • Consider a richer caption settings panel if users need style controls or exact delay entry.
  • +
  • Add a UI test harness or injectable mock backend for exercising native player overlay behavior without MobileVLCKit.
  • +
+
+
+ +