diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..a2b672a Binary files /dev/null and b/.DS_Store differ diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index e69de29..df78ee8 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -0,0 +1,15 @@ +{"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."}} +{"id":"int-6b806f87","kind":"field_change","created_at":"2026-05-25T09:49:39.908604Z","actor":"dirtydishes","issue_id":"dreamio-poo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented and validated native player controls, subtitle handling refinements, and close-flow cleanup."}} +{"id":"int-5d355e9b","kind":"field_change","created_at":"2026-05-25T09:51:17.04306Z","actor":"dirtydishes","issue_id":"dreamio-wgk","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} +{"id":"int-9ddb7b1a","kind":"field_change","created_at":"2026-05-25T10:18:30.826897Z","actor":"dirtydishes","issue_id":"dreamio-7w6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Streamlined native player controls into a compact bottom overlay and validated the simulator build."}} +{"id":"int-2a84633f","kind":"field_change","created_at":"2026-05-25T10:25:22.649574Z","actor":"dirtydishes","issue_id":"dreamio-88m","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented captions as a single-choice menu with None and selected loaded tracks, updated tests and turn documentation."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1cab55d..5ad5342 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,2 +1,15 @@ +{"_type":"issue","id":"dreamio-poo","title":"Native player controls captions and close flow","description":"Add and validate VLC-backed native playback transport controls, subtitle track controls, external subtitle discovery, and Stremio Web close cleanup after native playback dismisses.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T09:47:56Z","created_by":"dirtydishes","updated_at":"2026-05-25T09:49:40Z","started_at":"2026-05-25T09:48:00Z","closed_at":"2026-05-25T09:49:40Z","close_reason":"Implemented and validated native player controls, subtitle handling refinements, and close-flow cleanup.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-wgk","title":"Fix native player controls tap-to-show","description":"Native player controls can be hidden by tapping, but subsequent taps on the player do not bring them back. Investigate the overlay gesture handling and restore reliable tap-to-show/tap-to-hide behavior.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T09:27:58Z","created_by":"dirtydishes","updated_at":"2026-05-25T09:51:17Z","started_at":"2026-05-25T09:28:11Z","closed_at":"2026-05-25T09:51:17Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_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-88m","title":"Make caption selection states clearer","description":"The native player caption menu should behave like a simple single-choice menu with None and loaded caption tracks, making the current caption state visually obvious.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:22:12Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:25:23Z","started_at":"2026-05-25T10:22:48Z","closed_at":"2026-05-25T10:25:23Z","close_reason":"Implemented captions as a single-choice menu with None and selected loaded tracks, updated tests and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-7w6","title":"Streamline native player controls","description":"Make the native playback controls take up less screen space while preserving play, seek, jump, captions, and close actions.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:15:49Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:18:31Z","started_at":"2026-05-25T10:15:59Z","closed_at":"2026-05-25T10:18:31Z","close_reason":"Streamlined native player controls into a compact bottom overlay and validated the simulator build.","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..cd1e980 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,3 +94,124 @@ 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 repository work, use this decision flow: + +1. **Classify the task.** + - If the change is minor/trivial under the exemption list below, do not create a turn document. + - If the task changed code, configuration, tests, project files, or substantive docs inside the repo, create or update a turn document. + - If classification is ambiguous or mixed, ask the user before creating a turn document. +2. **Document substantive implementation work.** + - New work: create `docs/turns/YYYY-MM-DD-short-task-name.html`. + - Minor update to a previous substantive change: update that existing turn document instead of creating a duplicate. +3. **Complete the closeout for documented work.** + - Update Beads. + - Run relevant quality gates, or document any failures. + - Commit changes. + - Run `bd dolt push`. + - Run `git push`. + - Confirm `git status` shows the branch is up to date with `forgejo/`. + +The minor/trivial exemptions override the general turn-document rule. + +### Minor/Trivial Exemptions + +Do not create a turn document when the change 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, such as 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. + +### Turn Document Requirements + +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 `impeccable` is unavailable or blocked by an actual tool/file error, still create a well-structured standalone HTML file. + +Each turn document must include these sections: + +1. **Summary** +2. **Changes Made** +3. **Context** +4. **Important Implementation Details** +5. **Relevant Diff Snippets** (follow the **Rendered Diff Documentation** rule) +6. **Expected Impact for End-Users** +7. **Validation** +8. **Issues, Limitations, and Mitigations** +9. **Follow-up Work** + +For a minor update to a previous substantive change, add this section to the existing document: + +**"New Changes as of {time and date at which the change was made}"** +- **Summary of changes** +- **Why this change was made** +- **Code diffs** (follow the **Rendered Diff Documentation** rule) +- **Related issues or PRs** + +### Rendered Diff Documentation + +When turn documentation needs rendered code diffs, use `@pierre/diffs` through its ESM server-side renderer. + +Use `@pierre/diffs/ssr` with Node ESM imports. Do not test, load, or diagnose this package with CommonJS `require()`, because `@pierre/diffs` is ESM and `require('@pierre/diffs/ssr')` can falsely look like an export or package failure. + +Preferred availability check: + +```bash +node --input-type=module -e "import { preloadPatchDiff } from '@pierre/diffs/ssr'; console.log(typeof preloadPatchDiff)" +``` + +Preferred rendering pattern: + +```bash +node --input-type=module <<'NODE' +import { readFileSync, writeFileSync } from 'node:fs'; +import { preloadPatchDiff } from '@pierre/diffs/ssr'; + +const patch = readFileSync('/tmp/change.patch', 'utf8'); +const { prerenderedHTML } = await preloadPatchDiff({ + patch, + options: { diffType: 'unified' } +}); + +writeFileSync('/tmp/rendered-diff.html', prerenderedHTML); +NODE +``` + +`preloadPatchDiff` expects exactly one file diff per call. If a git diff contains multiple files, split it into one patch per file, render each file patch separately, and concatenate the rendered HTML into the turn document. + +Do not run `npx @pierre/diffs`; the package is a rendering library and does not expose a CLI executable. + +Only use a clearly labeled plain diff or code-block fallback when the ESM import-and-render pattern above fails because of a real tool, install, or runtime error. Document the failure briefly in the turn document. + +## Plan Mode Documentation + +When working in plan mode, do not modify implementation files. + +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** diff --git a/CLAUDE.md b/CLAUDE.md index cd553b9..633878c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,51 @@ -# Project Instructions for AI Agents +# Agent Instructions -This file provides instructions and context for AI coding agents working on this project. +This project uses **bd** (beads) for issue tracking. Run `bd prime` for full workflow context. + +> **Architecture in one line:** Issues live in a local Dolt database +> (`.beads/dolt/`); cross-machine sync uses `bd dolt push/pull` (a +> git-compatible protocol), stored under `refs/dolt/data` on your git +> remote — separate from `refs/heads/*` where your code lives. +> `.beads/issues.jsonl` is a passive export, not the wire protocol. +> +> See [SYNC_CONCEPTS.md](https://github.com/gastownhall/beads/blob/main/docs/SYNC_CONCEPTS.md) +> for the one-screen overview and anti-patterns (don't treat JSONL as the +> source of truth; don't `bd import` during normal operation; don't +> reach for third-party Dolt hosting before trying the default). + +## Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work atomically +bd close # Complete work +bd dolt push # Push beads data to remote +``` + +## Non-Interactive Shell Commands + +**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts. + +Shell commands like `cp`, `mv`, and `rm` may be aliased to include `-i` (interactive) mode on some systems, causing the agent to hang indefinitely waiting for y/n input. + +**Use these forms instead:** +```bash +# Force overwrite without prompting +cp -f source dest # NOT: cp source dest +mv -f source dest # NOT: mv source dest +rm -f file # NOT: rm file + +# For recursive operations +rm -rf directory # NOT: rm -r directory +cp -rf source dest # NOT: cp -r source dest +``` + +**Other commands that may prompt:** +- `scp` - use `-o BatchMode=yes` for non-interactive +- `ssh` - use `-o BatchMode=yes` to fail instead of prompting +- `apt-get` - use `-y` flag +- `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var ## Beads Issue Tracker @@ -50,21 +95,143 @@ bd close # Complete work - If push fails, resolve and retry until it succeeds +## Required Turn Documentation -## Build & Test +At the end of every completed implementation task, before final handoff, create a user-readable HTML document describing the work. -_Add your build and test commands here_ +This documentation is mandatory whenever code, configuration, tests, or project files were changed. -```bash -# Example: -# npm install -# npm test +### 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. Do not run `npx @pierre/diffs`; the package is a rendering library and does not expose a CLI executable. Generate rendered diff HTML with `@pierre/diffs/ssr`, usually `preloadPatchDiff`, and insert that rendered output into the turn document. `preloadPatchDiff` expects exactly one file diff per call, so split multi-file diffs into one patch per file and concatenate the rendered HTML. If `@pierre/diffs/ssr` 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 rendered `@pierre/diffs/ssr` output by default; do not use `npx @pierre/diffs`; 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/ ``` -## Architecture Overview +Use a clear timestamped filename: -_Add a brief overview of your project architecture_ +```text +docs/turns/YYYY-MM-DD-short-task-name.html +``` -## Conventions & Patterns +Example: -_Add your project-specific conventions here_ +```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/ssr` output by default; do not use `npx @pierre/diffs`; 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` 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..a775c5b 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..71b86a8 --- /dev/null +++ b/Dreamio.xcodeproj/xcuserdata/kell.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Dreamio.xcscheme_^#shared#^_ + + orderHint + 2 + + + + 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.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate b/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..d9f17eb Binary files /dev/null and b/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index 6d0376a..301ef99 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,315 @@ 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); + subtitleURLPattern.lastIndex = 0; + 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 +382,9 @@ final class DreamioWebViewController: UIViewController { } loadDreamio() + webView.evaluateJavaScript("navigator.userAgent") { [weak self] result, _ in + self?.userAgent = result as? String + } } private func loadDreamio() { @@ -77,6 +408,192 @@ 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]", + "[title*='Close' i]", + "[title*='Back' i]", + "button[class*='close' i]", + "button[class*='back' i]", + "[class*='close' i]", + "[class*='back' i]", + ".player button", + "[role='button']" + ]); + const locationLooksPlayer = /\/(player|stream)\b/i.test(window.location.pathname || "") || /player|stream/i.test(window.location.hash || ""); + const visibleBusyPlayer = Boolean(document.querySelector("video, .player, [class*='player' i], [class*='buffer' i]")); + const stillPlayer = locationLooksPlayer || (visibleBusyPlayer && /buffer|prepar|stream/i.test(document.body.innerText || "")); + return { clicked, stillPlayer, href: window.location.href }; + })(); + """# + + webView.evaluateJavaScript(script) { [weak self] result, error in + guard let self else { + return + } +#if DEBUG + if let error { + print("[DreamioCloseFlow] cleanup error=\(URLRedactor.redactedURLString(error.localizedDescription))") + } else { + print("[DreamioCloseFlow] cleanup result=\(String(describing: result))") + } +#endif + guard error == nil else { + self.loadDreamio() + return + } + if self.webView.canGoBack { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + let stillPlayerScript = #""" + (() => { + const locationLooksPlayer = /\/(player|stream)\b/i.test(window.location.pathname || "") || /player|stream/i.test(window.location.hash || ""); + const visibleBusyPlayer = Boolean(document.querySelector("video, .player, [class*='player' i], [class*='buffer' i]")); + return locationLooksPlayer || (visibleBusyPlayer && /buffer|prepar|stream/i.test(document.body.innerText || "")); + })() + """# + self.webView.evaluateJavaScript(stillPlayerScript) { result, _ in + if (result as? Bool) == true { + self.webView.goBack() + } + } + } + } + } + } + +#if DEBUG + private func logDiagnostic(type: String, payload: Any, pageURL: String?) { + let redactedPageURL = pageURL.map(redactedURLString) ?? "unknown" + let redactedPayload = redactDiagnosticValue(payload) + print("[DreamioDiagnostics] \(type) page=\(redactedPageURL) payload=\(redactedPayload)") + } + + private func redactDiagnosticValue(_ value: Any) -> Any { + switch value { + case let string as String: + return redactedURLString(string) + case let array as [Any]: + return array.map(redactDiagnosticValue) + case let dictionary as [String: Any]: + return dictionary.reduce(into: [String: Any]()) { result, entry in + result[entry.key] = redactDiagnosticValue(entry.value) + } + default: + return value + } + } + + private func redactedURLString(_ value: String) -> String { + URLRedactor.redactedURLString(value) + } +#endif } extension DreamioWebViewController: WKNavigationDelegate { @@ -99,11 +616,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 +661,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..6c30810 --- /dev/null +++ b/Dreamio/NativePlayerViewController.swift @@ -0,0 +1,421 @@ +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 = 18 + button.accessibilityLabel = "Close" + return button + }() + + private let controlsContainer: UIVisualEffectView = { + let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark)) + view.translatesAutoresizingMaskIntoConstraints = false + view.layer.cornerRadius = 16 + view.clipsToBounds = true + return view + }() + + private let tapSurfaceView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .clear + 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: 11, weight: .semibold) + label.text = "0:00" + return label + }() + + private let remainingLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .white + label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold) + 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 + slider.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 12), for: .normal) + slider.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 16), for: .highlighted) + 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(tapSurfaceView) + 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.showsMenuAsPrimaryAction = true + playPauseButton.layer.cornerRadius = 21 + 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 + tapSurfaceView.addGestureRecognizer(tap) + + let timeAndScrubRow = UIStackView(arrangedSubviews: [elapsedLabel, scrubber, remainingLabel]) + timeAndScrubRow.translatesAutoresizingMaskIntoConstraints = false + timeAndScrubRow.axis = .horizontal + timeAndScrubRow.alignment = .center + timeAndScrubRow.spacing = 8 + + let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton]) + controlRow.translatesAutoresizingMaskIntoConstraints = false + controlRow.axis = .horizontal + controlRow.alignment = .center + controlRow.distribution = .equalSpacing + controlRow.spacing = 14 + + let stack = UIStackView(arrangedSubviews: [timeAndScrubRow, controlRow]) + stack.translatesAutoresizingMaskIntoConstraints = false + stack.axis = .vertical + stack.spacing = 6 + 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), + + tapSurfaceView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tapSurfaceView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tapSurfaceView.topAnchor.constraint(equalTo: view.topAnchor), + tapSurfaceView.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: 36), + closeButton.heightAnchor.constraint(equalToConstant: 36), + closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10), + closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10), + + controlsContainer.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor), + controlsContainer.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -24), + controlsContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 430), + controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12), + + stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 12), + stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -12), + stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 8), + stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -10), + + elapsedLabel.widthAnchor.constraint(equalToConstant: 42), + remainingLabel.widthAnchor.constraint(equalToConstant: 42), + scrubber.widthAnchor.constraint(greaterThanOrEqualToConstant: 160), + + jumpBackButton.widthAnchor.constraint(equalToConstant: 36), + jumpBackButton.heightAnchor.constraint(equalToConstant: 36), + playPauseButton.widthAnchor.constraint(equalToConstant: 42), + playPauseButton.heightAnchor.constraint(equalToConstant: 42), + jumpForwardButton.widthAnchor.constraint(equalToConstant: 36), + jumpForwardButton.heightAnchor.constraint(equalToConstant: 36), + captionsButton.widthAnchor.constraint(equalToConstant: 36), + captionsButton.heightAnchor.constraint(equalToConstant: 36) + ]) + } + + 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() + } + } + + private func captionsMenu() -> UIMenu { + let selectedTrackID = backend.selectedSubtitleTrackID + let trackActions = SubtitleOptionMapper.options(from: backend.subtitleTracks).map { track in + UIAction( + title: track.name, + state: track.id == selectedTrackID ? .on : .off + ) { [weak self] _ in + self?.backend.selectSubtitleTrack(id: track.id) + self?.refreshControls() + } + } + + let delayActions = UIMenu( + title: "Delay", + options: .displayInline, + children: [ + UIAction(title: "Decrease 0.5s") { [weak self] _ in + self?.backend.adjustSubtitleDelay(by: -0.5) + self?.refreshControls() + }, + UIAction(title: "Increase 0.5s") { [weak self] _ in + self?.backend.adjustSubtitleDelay(by: 0.5) + self?.refreshControls() + }, + UIAction( + title: "Current: \(String(format: "%.1fs", backend.subtitleDelay))", + attributes: .disabled + ) { _ in } + ] + ) + + return UIMenu(title: "Captions", children: trackActions + [delayActions]) + } + + 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 + captionsButton.menu = captionsMenu() + 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() { + controlsContainer.isUserInteractionEnabled = true + closeButton.isUserInteractionEnabled = true + UIView.animate(withDuration: 0.18) { + self.controlsContainer.alpha = 1 + self.closeButton.alpha = 1 + } + scheduleControlsHide() + } + + private func hideControls() { + controlsContainer.isUserInteractionEnabled = false + closeButton.isUserInteractionEnabled = false + 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 = 18 + button.accessibilityLabel = label + return button + } + + private static func scrubberThumbImage(diameter: CGFloat) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = UIScreen.main.scale + return UIGraphicsImageRenderer(size: CGSize(width: diameter, height: diameter), format: format).image { context in + UIColor.white.setFill() + context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: CGSize(width: diameter, height: diameter))) + } + } +} diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift new file mode 100644 index 0000000..3371b54 --- /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 noneTrack = SubtitleTrack(id: -1, name: "None") + + static func options(from tracks: [SubtitleTrack]) -> [SubtitleTrack] { + [noneTrack] + 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..d891c6f --- /dev/null +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -0,0 +1,262 @@ +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) + attachedSubtitleURLs.removeAll() + 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 + } + guard !candidates.isEmpty else { + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in + self?.onSubtitleTracksChange?() + } + } +#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..e70fc2b --- /dev/null +++ b/Tests/StreamResolverTests.swift @@ -0,0 +1,126 @@ +import Foundation + +@main +struct StreamResolverTests { + static func main() { + testClassifierPrefersObservedDirectFile() + testResolverSelectsUnsupportedDirectURLAndHeaders() + testResolverRejectsHLSOnlyResponse() + testRedactorHandlesPercentEncodedPath() + testPlaybackTimeFormatting() + testSubtitleCandidateParsing() + testSubtitleOptionMappingIncludesNone() + 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 testSubtitleOptionMappingIncludesNone() { + let options = SubtitleOptionMapper.options(from: [ + SubtitleTrack(id: 2, name: "English"), + SubtitleTrack(id: 5, name: "Spanish") + ]) + + assertEqual(options.map(\.name), ["None", "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-caption-menu-selection-state.html b/docs/turns/2026-05-25-caption-menu-selection-state.html new file mode 100644 index 0000000..7d131af --- /dev/null +++ b/docs/turns/2026-05-25-caption-menu-selection-state.html @@ -0,0 +1,386 @@ + + + + + + Caption Menu Selection State + + + +
+
+

Caption Menu Selection State

+

Changed the native player captions control from a two-state-feeling action sheet into a single-choice captions menu with a clear None option and checked loaded tracks.

+
+ Date: 2026-05-25 + Issue: dreamio-88m + Area: Native player controls +
+
+ +
+

Summary

+

The captions control now presents the available caption choices as a proper menu. None represents captions being disabled, and any loaded caption track can be selected directly. UIKit marks the active choice with its selected state, so users do not have to infer state from a prefixed label.

+
+ +
+

Changes Made

+
    +
  • Replaced the captions action-sheet selector with a UIMenu attached to the captions button.
  • +
  • Changed the sentinel subtitle option label from Off to None.
  • +
  • Made each caption track a UIAction with .on state when it matches the backend-selected subtitle track ID.
  • +
  • Kept subtitle delay controls inside the same menu, separated from the track choices.
  • +
  • Updated the subtitle option mapping test name and expected labels.
  • +
+
+ +
+

Context

+

The previous captions action sheet had an Off row and then loaded tracks. The active row was communicated by changing its title to include Selected:, which made the control feel like two separate visual modes instead of a direct menu of mutually exclusive choices.

+
+ +
+

Important Implementation Details

+
    +
  • The backend contract stays the same: selecting ID -1 disables subtitles, and selecting a loaded track ID enables that track.
  • +
  • The captions button uses showsMenuAsPrimaryAction, so tapping it opens the menu directly.
  • +
  • The menu is rebuilt during refreshControls(), which keeps the checked row and current subtitle delay in sync after backend changes.
  • +
  • The loaded caption list still filters out negative IDs from backend tracks, then prepends the single None option.
  • +
+
+ +
+

Relevant Diff Snippets

+

Dreamio/NativePlayerViewController.swift

Dreamio/NativePlayerViewController.swift
-21+32
184 unmodified lines
185
186
187
188
189
190
191
122 unmodified lines
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
9 unmodified lines
351
352
353
354
355
356
184 unmodified lines
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)
playPauseButton.layer.cornerRadius = 21
scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown)
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
122 unmodified lines
}
}
+
@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() {
9 unmodified lines
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 {
184 unmodified lines
185
186
187
188
189
190
191
122 unmodified lines
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
9 unmodified lines
361
362
363
364
365
366
367
184 unmodified lines
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.showsMenuAsPrimaryAction = true
playPauseButton.layer.cornerRadius = 21
scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown)
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
122 unmodified lines
}
}
+
private func captionsMenu() -> UIMenu {
let selectedTrackID = backend.selectedSubtitleTrackID
let trackActions = SubtitleOptionMapper.options(from: backend.subtitleTracks).map { track in
UIAction(
title: track.name,
state: track.id == selectedTrackID ? .on : .off
) { [weak self] _ in
self?.backend.selectSubtitleTrack(id: track.id)
self?.refreshControls()
}
}
+
let delayActions = UIMenu(
title: "Delay",
options: .displayInline,
children: [
UIAction(title: "Decrease 0.5s") { [weak self] _ in
self?.backend.adjustSubtitleDelay(by: -0.5)
self?.refreshControls()
},
UIAction(title: "Increase 0.5s") { [weak self] _ in
self?.backend.adjustSubtitleDelay(by: 0.5)
self?.refreshControls()
},
UIAction(
title: "Current: \(String(format: "%.1fs", backend.subtitleDelay))",
attributes: .disabled
) { _ in }
]
)
+
return UIMenu(title: "Captions", children: trackActions + [delayActions])
}
+
private func startProgressUpdates() {
9 unmodified lines
jumpBackButton.isEnabled = backend.isSeekable
jumpForwardButton.isEnabled = backend.isSeekable
captionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmpty
captionsButton.menu = captionsMenu()
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
if !isScrubbing {
+

Dreamio/StreamCandidate.swift

Dreamio/StreamCandidate.swift
-2+2
58 unmodified lines
59
60
61
62
63
64
65
66
67
68
58 unmodified lines
}
+
enum SubtitleOptionMapper {
static let offTrack = SubtitleTrack(id: -1, name: "Off")
+
static func options(from tracks: [SubtitleTrack]) -> [SubtitleTrack] {
[offTrack] + tracks.filter { $0.id >= 0 }
}
}
+
58 unmodified lines
59
60
61
62
63
64
65
66
67
68
58 unmodified lines
}
+
enum SubtitleOptionMapper {
static let noneTrack = SubtitleTrack(id: -1, name: "None")
+
static func options(from tracks: [SubtitleTrack]) -> [SubtitleTrack] {
[noneTrack] + tracks.filter { $0.id >= 0 }
}
}
+
+

Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
-3+3
8 unmodified lines
9
10
11
12
13
14
15
94 unmodified lines
110
111
112
113
114
115
116
117
118
119
120
121
122
8 unmodified lines
testRedactorHandlesPercentEncodedPath()
testPlaybackTimeFormatting()
testSubtitleCandidateParsing()
testSubtitleOptionMappingIncludesOff()
print("StreamResolverTests passed")
}
+
94 unmodified lines
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)
}
+
8 unmodified lines
9
10
11
12
13
14
15
94 unmodified lines
110
111
112
113
114
115
116
117
118
119
120
121
122
8 unmodified lines
testRedactorHandlesPercentEncodedPath()
testPlaybackTimeFormatting()
testSubtitleCandidateParsing()
testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed")
}
+
94 unmodified lines
assertEqual(candidates[2].url.absoluteString, "https://cdn.example.test/movie.fr.ass?download=1")
}
+
private static func testSubtitleOptionMappingIncludesNone() {
let options = SubtitleOptionMapper.options(from: [
SubtitleTrack(id: 2, name: "English"),
SubtitleTrack(id: 5, name: "Spanish")
])
+
assertEqual(options.map(\.name), ["None", "English", "Spanish"])
assertEqual(options.first?.id, -1)
}
+
+
+ +
+

Expected Impact for End-Users

+

Users should see a clearer captions menu: None when captions are disabled, or the selected caption track with the platform checkmark when captions are enabled. Choosing another row immediately switches the active caption state.

+
+ +
+

Validation

+
+

Passed: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build

+
+

Also attempted a standalone Swift test binary for StreamResolverTests. The binary compiled after including the resolver source, but an existing subtitle parser assertion failed with Expected eng, got nil. That failure is unrelated to this menu-state change and remains documented here as a current test-suite limitation.

+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • No simulator UI recording was performed, so visual validation is based on UIKit menu semantics and successful compilation.
  • +
  • The standalone resolver test command currently exposes an unrelated subtitle language parsing failure. The app build still succeeds.
  • +
  • The menu depends on UIKit UIAction selected-state rendering, which is appropriate for the app's iOS deployment target.
  • +
+
+ +
+

Follow-up Work

+
    +
  • Investigate the existing subtitle parser test failure where lang does not populate the first candidate language.
  • +
  • Add a UI-level regression check for opening the captions menu once native-player UI automation exists.
  • +
+
+
+ + 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-fix-native-player-controls-tap.html b/docs/turns/2026-05-25-fix-native-player-controls-tap.html new file mode 100644 index 0000000..7436753 --- /dev/null +++ b/docs/turns/2026-05-25-fix-native-player-controls-tap.html @@ -0,0 +1,265 @@ + + + + + + Fix Native Player Controls Tap-to-Show + + + +
+
+
Turn document · 2026-05-25 05:28 EDT
+

Fix Native Player Controls Tap-to-Show

+

Native player controls can now be brought back after they auto-hide or are hidden by tapping the player. The fix gives player taps a reliable full-screen gesture surface above the VLC video view while keeping visible controls interactive.

+
+ Issue: dreamio-wgk + File: Dreamio/NativePlayerViewController.swift + Validation: Xcode build passed +
+
+ +
+

Summary

+

Fixed the native playback overlay so hidden controls are not effectively gone forever. A transparent tap surface now receives taps over the video, and hidden control views stop intercepting touches until they are visible again.

+
+ +
+

Changes Made

+
    +
  • Added a full-screen transparent tapSurfaceView above the VLC drawable and below the loading, failure, controls, and close-button layers.
  • +
  • Moved the tap gesture recognizer from the root view to that tap surface so player taps are handled consistently.
  • +
  • Disabled user interaction on the controls container and close button while they are hidden, then re-enabled it when controls are revealed.
  • +
+
+ +
+

Context

+

The native player uses MobileVLCKit for video rendering and an overlay built in UIKit for playback controls. Before this change, the gesture recognizer was attached to the root view. Once controls faded out, the visible controls had alpha zero but still occupied their layout area, and the video drawable could also interfere with root-level tap handling. That left some taps with no route back to revealControls().

+
+ +
+

Important Implementation Details

+

The tap surface is inserted immediately after backend.view, which keeps it above the video but below the actual controls. This preserves normal button and slider behavior when controls are visible while making the rest of the player a reliable tap target.

+

When hideControls() runs, the controls and close button are also made non-interactive. This matters because alpha-zero UIKit views can still participate in hit testing unless interaction is disabled or the views are hidden.

+
+ +
+

Relevant Diff Snippets

+

The diff below is rendered with @pierre/diffs/ssr using preloadPatchDiff.

+
+
Dreamio/NativePlayerViewController.swift
-1+18
35 unmodified lines
36
37
38
39
40
41
125 unmodified lines
167
168
169
170
171
172
9 unmodified lines
182
183
184
185
186
187
188
19 unmodified lines
208
209
210
211
212
213
124 unmodified lines
338
339
340
341
342
343
2 unmodified lines
346
347
348
349
350
351
35 unmodified lines
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")
125 unmodified lines
+
private func configureLayout() {
view.addSubview(backend.view)
view.addSubview(loadingView)
view.addSubview(failureLabel)
view.addSubview(controlsContainer)
9 unmodified lines
+
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
19 unmodified lines
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),
+
124 unmodified lines
}
+
private func revealControls() {
UIView.animate(withDuration: 0.18) {
self.controlsContainer.alpha = 1
self.closeButton.alpha = 1
2 unmodified lines
}
+
private func hideControls() {
UIView.animate(withDuration: 0.24) {
self.controlsContainer.alpha = 0
self.closeButton.alpha = 0
35 unmodified lines
36
37
38
39
40
41
42
43
44
45
46
47
48
125 unmodified lines
174
175
176
177
178
179
180
9 unmodified lines
190
191
192
193
194
195
196
19 unmodified lines
216
217
218
219
220
221
222
223
224
225
226
124 unmodified lines
351
352
353
354
355
356
357
358
2 unmodified lines
361
362
363
364
365
366
367
368
35 unmodified lines
return view
}()
+
private let tapSurfaceView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .clear
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")
125 unmodified lines
+
private func configureLayout() {
view.addSubview(backend.view)
view.addSubview(tapSurfaceView)
view.addSubview(loadingView)
view.addSubview(failureLabel)
view.addSubview(controlsContainer)
9 unmodified lines
+
let tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility))
tap.cancelsTouchesInView = false
tapSurfaceView.addGestureRecognizer(tap)
+
let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton])
controlRow.translatesAutoresizingMaskIntoConstraints = false
19 unmodified lines
backend.view.topAnchor.constraint(equalTo: view.topAnchor),
backend.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+
tapSurfaceView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tapSurfaceView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tapSurfaceView.topAnchor.constraint(equalTo: view.topAnchor),
tapSurfaceView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+
loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
+
124 unmodified lines
}
+
private func revealControls() {
controlsContainer.isUserInteractionEnabled = true
closeButton.isUserInteractionEnabled = true
UIView.animate(withDuration: 0.18) {
self.controlsContainer.alpha = 1
self.closeButton.alpha = 1
2 unmodified lines
}
+
private func hideControls() {
controlsContainer.isUserInteractionEnabled = false
closeButton.isUserInteractionEnabled = false
UIView.animate(withDuration: 0.24) {
self.controlsContainer.alpha = 0
self.closeButton.alpha = 0
+
+
+ +
+

Expected Impact for End-Users

+

Users should be able to tap the native player to hide controls and tap the video again to bring them back. Auto-hidden controls should behave the same way, so playback is no longer trapped in a controls-hidden state.

+
+ +
+

Validation

+
+

Passed: xcodebuild build -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS'

+
+

The build succeeded for the Dreamio scheme against a generic iOS destination. Manual on-device interaction was not run in this turn, so the remaining risk is limited to real touch behavior across physical device sizes.

+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • The fix is intentionally scoped to tap routing and hidden overlay hit testing. It does not change VLC playback state, seeking, captions, or close behavior.
  • +
  • Manual device testing is still useful because UIKit gesture delivery around embedded native video surfaces can vary with presentation details.
  • +
  • The Xcode build reports an existing warning that the MobileVLCKit preparation script has no declared outputs. This was not introduced by the tap fix.
  • +
+
+ +
+

Follow-up Work

+
    +
  • No new follow-up issue is required for this fix.
  • +
  • Optional future improvement: add an injectable player overlay test harness so tap-to-show behavior can be exercised without launching MobileVLCKit on a device.
  • +
+
+
+ + \ 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..fbaaf5e --- /dev/null +++ b/docs/turns/2026-05-25-native-player-controls-captions-close-flow.html @@ -0,0 +1,606 @@ + + + + + + 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.
  • +
+
+
+

New Changes as of May 25, 2026 at 05:49 EDT

+

Summary of changes

+

After the broader native-player pass, I tightened three follow-up details: taps now use a dedicated transparent surface so hidden controls do not steal overlay button touches, VLC clears per-playback subtitle attachment bookkeeping and refreshes the captions list after remote subtitle slaves are added, and the Stremio close cleanup uses a more specific stuck-player check before falling back to history navigation.

+

Why this change was made

+

The adjustments reduce two practical failure modes: controls becoming hard to re-open or press after auto-hide, and the captions sheet missing remote subtitle tracks until VLC finishes exposing them. The close-flow probe was narrowed so Dreamio is less likely to go back merely because unrelated page text contains stream-related words.

+

Code diffs

+

Rendered with @pierre/diffs/ssr and preloadPatchDiff. These snippets cover the incremental follow-up edits made in this turn.

+
Dreamio/NativePlayerViewController.swift
-1+18
35 unmodified lines
36
37
38
39
40
41
125 unmodified lines
167
168
169
170
171
172
9 unmodified lines
182
183
184
185
186
187
188
19 unmodified lines
208
209
210
211
212
213
124 unmodified lines
338
339
340
341
342
343
2 unmodified lines
346
347
348
349
350
351
35 unmodified lines
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")
125 unmodified lines
+
private func configureLayout() {
view.addSubview(backend.view)
view.addSubview(loadingView)
view.addSubview(failureLabel)
view.addSubview(controlsContainer)
9 unmodified lines
+
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
19 unmodified lines
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),
+
124 unmodified lines
}
+
private func revealControls() {
UIView.animate(withDuration: 0.18) {
self.controlsContainer.alpha = 1
self.closeButton.alpha = 1
2 unmodified lines
}
+
private func hideControls() {
UIView.animate(withDuration: 0.24) {
self.controlsContainer.alpha = 0
self.closeButton.alpha = 0
35 unmodified lines
36
37
38
39
40
41
42
43
44
45
46
47
48
125 unmodified lines
174
175
176
177
178
179
180
9 unmodified lines
190
191
192
193
194
195
196
19 unmodified lines
216
217
218
219
220
221
222
223
224
225
226
124 unmodified lines
351
352
353
354
355
356
357
358
2 unmodified lines
361
362
363
364
365
366
367
368
35 unmodified lines
return view
}()
+
private let tapSurfaceView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .clear
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")
125 unmodified lines
+
private func configureLayout() {
view.addSubview(backend.view)
view.addSubview(tapSurfaceView)
view.addSubview(loadingView)
view.addSubview(failureLabel)
view.addSubview(controlsContainer)
9 unmodified lines
+
let tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility))
tap.cancelsTouchesInView = false
tapSurfaceView.addGestureRecognizer(tap)
+
let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton])
controlRow.translatesAutoresizingMaskIntoConstraints = false
19 unmodified lines
backend.view.topAnchor.constraint(equalTo: view.topAnchor),
backend.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+
tapSurfaceView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tapSurfaceView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tapSurfaceView.topAnchor.constraint(equalTo: view.topAnchor),
tapSurfaceView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+
loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
+
124 unmodified lines
}
+
private func revealControls() {
controlsContainer.isUserInteractionEnabled = true
closeButton.isUserInteractionEnabled = true
UIView.animate(withDuration: 0.18) {
self.controlsContainer.alpha = 1
self.closeButton.alpha = 1
2 unmodified lines
}
+
private func hideControls() {
controlsContainer.isUserInteractionEnabled = false
closeButton.isUserInteractionEnabled = false
UIView.animate(withDuration: 0.24) {
self.controlsContainer.alpha = 0
self.closeButton.alpha = 0
+
Dreamio/VLCNativePlaybackBackend.swift
+7
39 unmodified lines
40
41
42
43
44
45
158 unmodified lines
204
205
206
207
208
209
39 unmodified lines
+
func play(request: NativePlaybackRequest) {
#if canImport(MobileVLCKit)
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
158 unmodified lines
print("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
#endif
}
}
#endif
}
39 unmodified lines
40
41
42
43
44
45
46
158 unmodified lines
205
206
207
208
209
210
211
212
213
214
215
216
39 unmodified lines
+
func play(request: NativePlaybackRequest) {
#if canImport(MobileVLCKit)
attachedSubtitleURLs.removeAll()
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
158 unmodified lines
print("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")
#endif
}
guard !candidates.isEmpty else {
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.onSubtitleTracksChange?()
}
}
#endif
}
+
Dreamio/DreamioWebViewController.swift
-2+16
121 unmodified lines
122
123
124
125
126
127
389 unmodified lines
517
518
519
520
521
522
523
524
525
526
527
528
15 unmodified lines
544
545
546
547
548
549
550
121 unmodified lines
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;
389 unmodified lines
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 };
})();
"""#
15 unmodified lines
}
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()
}
121 unmodified lines
122
123
124
125
126
127
128
389 unmodified lines
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
15 unmodified lines
551
552
553
554
555
556
557
558
559
560
561
562
563
564
121 unmodified lines
const addSubtitleCandidate = (entry) => {
const rawURL = typeof entry === "string" ? entry : entry && (entry.url || entry.href || entry.src || entry.file || entry.download);
const url = absoluteURL(rawURL);
subtitleURLPattern.lastIndex = 0;
if (!url || !subtitleURLPattern.test(url)) {
subtitleURLPattern.lastIndex = 0;
return;
389 unmodified lines
const clicked = clickVisible([
"[aria-label*='Close' i]",
"[aria-label*='Back' i]",
"[title*='Close' i]",
"[title*='Back' i]",
"button[class*='close' i]",
"button[class*='back' i]",
"[class*='close' i]",
"[class*='back' i]",
".player button",
"[role='button']"
]);
const locationLooksPlayer = /\/(player|stream)\b/i.test(window.location.pathname || "") || /player|stream/i.test(window.location.hash || "");
const visibleBusyPlayer = Boolean(document.querySelector("video, .player, [class*='player' i], [class*='buffer' i]"));
const stillPlayer = locationLooksPlayer || (visibleBusyPlayer && /buffer|prepar|stream/i.test(document.body.innerText || ""));
return { clicked, stillPlayer, href: window.location.href };
})();
"""#
15 unmodified lines
}
if self.webView.canGoBack {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
let stillPlayerScript = #"""
(() => {
const locationLooksPlayer = /\/(player|stream)\b/i.test(window.location.pathname || "") || /player|stream/i.test(window.location.hash || "");
const visibleBusyPlayer = Boolean(document.querySelector("video, .player, [class*='player' i], [class*='buffer' i]"));
return locationLooksPlayer || (visibleBusyPlayer && /buffer|prepar|stream/i.test(document.body.innerText || ""));
})()
"""#
self.webView.evaluateJavaScript(stillPlayerScript) { result, _ in
if (result as? Bool) == true {
self.webView.goBack()
}
+

Related issues or PRs

+

Related Beads issue: dreamio-poo. No pull request was created in this local workflow.

+
+ +
+ + diff --git a/docs/turns/2026-05-25-streamline-native-player-controls.html b/docs/turns/2026-05-25-streamline-native-player-controls.html new file mode 100644 index 0000000..a4541b8 --- /dev/null +++ b/docs/turns/2026-05-25-streamline-native-player-controls.html @@ -0,0 +1,236 @@ + + + + + + Streamline Native Player Controls + + + +
+
+

Streamline Native Player Controls

+

The native playback overlay was reduced from a large bottom control panel into a compact, centered control pill that leaves more of the video visible while preserving playback, seeking, jump, captions, and close actions.

+
+ +
+

Summary

+

Dreamio's native player controls now occupy much less vertical and horizontal space. The bottom controls use tighter padding, smaller circular buttons, smaller time labels, and a slimmer scrubber thumb so the screen feels more like a native iOS video player.

+
+ +
+

Changes Made

+
    +
  • Reworked the controls from a wide full-width panel into a centered compact overlay capped at 430 points.
  • +
  • Combined elapsed time, scrubber, and remaining time into one horizontal row.
  • +
  • Reduced button sizes while keeping circular targets for close, jump, play/pause, and captions.
  • +
  • Reduced control padding and spacing to lower the overlay height.
  • +
  • Added custom scrubber thumb images for a slimmer native-feeling slider.
  • +
+
+ +
+

Context

+

The previous player overlay was visually heavy and could feel like it took over the bottom half of the playback surface. The requested direction was to make it much more streamlined and closer to a native player experience.

+
+ +
+

Important Implementation Details

+

The behavior lives in Dreamio/NativePlayerViewController.swift. This change only adjusts the UIKit control layout and visual treatment. It does not change VLC playback, stream resolution, subtitle selection behavior, timers, or dismiss behavior.

+

The compact overlay keeps the scrubber usable by giving it a minimum width while allowing the container to shrink to its content and stay within the safe area.

+
+ +
+

Relevant Diff Snippets

+

The rendered diff below was generated with @pierre/diffs/ssr.

+
Dreamio/NativePlayerViewController.swift
-36+54
22 unmodified lines
23
24
25
26
27
28
29
1 unmodified line
31
32
33
34
35
36
37
14 unmodified lines
52
53
54
55
56
57
58
2 unmodified lines
61
62
63
64
65
66
67
7 unmodified lines
75
76
77
78
79
80
103 unmodified lines
184
185
186
187
188
189
2 unmodified lines
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
14 unmodified lines
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
129 unmodified lines
385
386
387
388
389
390
391
392
22 unmodified lines
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
}()
1 unmodified line
private let controlsContainer: UIVisualEffectView = {
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.cornerRadius = 12
view.clipsToBounds = true
return view
}()
14 unmodified lines
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .white
label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium)
label.text = "0:00"
return label
}()
2 unmodified lines
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
7 unmodified lines
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
}()
+
103 unmodified lines
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])
2 unmodified lines
tap.cancelsTouchesInView = false
tapSurfaceView.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([
14 unmodified lines
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)
])
}
+
129 unmodified lines
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
}
}
22 unmodified lines
23
24
25
26
27
28
29
1 unmodified line
31
32
33
34
35
36
37
14 unmodified lines
52
53
54
55
56
57
58
2 unmodified lines
61
62
63
64
65
66
67
7 unmodified lines
75
76
77
78
79
80
81
82
103 unmodified lines
186
187
188
189
190
191
192
2 unmodified lines
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
14 unmodified lines
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
129 unmodified lines
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
22 unmodified lines
button.setImage(UIImage(systemName: "xmark"), for: .normal)
button.tintColor = .white
button.backgroundColor = UIColor.black.withAlphaComponent(0.45)
button.layer.cornerRadius = 18
button.accessibilityLabel = "Close"
return button
}()
1 unmodified line
private let controlsContainer: UIVisualEffectView = {
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.cornerRadius = 16
view.clipsToBounds = true
return view
}()
14 unmodified lines
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .white
label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold)
label.text = "0:00"
return label
}()
2 unmodified lines
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.textColor = .white
label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold)
label.textAlignment = .right
label.text = "-0:00"
return label
7 unmodified lines
slider.minimumTrackTintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 1)
slider.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.3)
slider.thumbTintColor = .white
slider.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 12), for: .normal)
slider.setThumbImage(NativePlayerViewController.scrubberThumbImage(diameter: 16), for: .highlighted)
return slider
}()
+
103 unmodified lines
jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)
jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)
captionsButton.addTarget(self, action: #selector(showCaptions), for: .touchUpInside)
playPauseButton.layer.cornerRadius = 21
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])
2 unmodified lines
tap.cancelsTouchesInView = false
tapSurfaceView.addGestureRecognizer(tap)
+
let timeAndScrubRow = UIStackView(arrangedSubviews: [elapsedLabel, scrubber, remainingLabel])
timeAndScrubRow.translatesAutoresizingMaskIntoConstraints = false
timeAndScrubRow.axis = .horizontal
timeAndScrubRow.alignment = .center
timeAndScrubRow.spacing = 8
+
let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton])
controlRow.translatesAutoresizingMaskIntoConstraints = false
controlRow.axis = .horizontal
controlRow.alignment = .center
controlRow.distribution = .equalSpacing
controlRow.spacing = 14
+
let stack = UIStackView(arrangedSubviews: [timeAndScrubRow, controlRow])
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .vertical
stack.spacing = 6
controlsContainer.contentView.addSubview(stack)
+
NSLayoutConstraint.activate([
14 unmodified lines
failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28),
failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
+
closeButton.widthAnchor.constraint(equalToConstant: 36),
closeButton.heightAnchor.constraint(equalToConstant: 36),
closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),
closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10),
+
controlsContainer.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor),
controlsContainer.widthAnchor.constraint(lessThanOrEqualTo: view.safeAreaLayoutGuide.widthAnchor, constant: -24),
controlsContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 430),
controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12),
+
stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 12),
stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -12),
stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 8),
stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -10),
+
elapsedLabel.widthAnchor.constraint(equalToConstant: 42),
remainingLabel.widthAnchor.constraint(equalToConstant: 42),
scrubber.widthAnchor.constraint(greaterThanOrEqualToConstant: 160),
+
jumpBackButton.widthAnchor.constraint(equalToConstant: 36),
jumpBackButton.heightAnchor.constraint(equalToConstant: 36),
playPauseButton.widthAnchor.constraint(equalToConstant: 42),
playPauseButton.heightAnchor.constraint(equalToConstant: 42),
jumpForwardButton.widthAnchor.constraint(equalToConstant: 36),
jumpForwardButton.heightAnchor.constraint(equalToConstant: 36),
captionsButton.widthAnchor.constraint(equalToConstant: 36),
captionsButton.heightAnchor.constraint(equalToConstant: 36)
])
}
+
129 unmodified lines
button.setImage(UIImage(systemName: systemName), for: .normal)
button.tintColor = .white
button.backgroundColor = UIColor.black.withAlphaComponent(0.35)
button.layer.cornerRadius = 18
button.accessibilityLabel = label
return button
}
+
private static func scrubberThumbImage(diameter: CGFloat) -> UIImage {
let format = UIGraphicsImageRendererFormat()
format.scale = UIScreen.main.scale
return UIGraphicsImageRenderer(size: CGSize(width: diameter, height: diameter), format: format).image { context in
UIColor.white.setFill()
context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: CGSize(width: diameter, height: diameter)))
}
}
}
+
+ +
+

Expected Impact for End-Users

+

Users should see more video and less chrome when controls are visible. Playback controls remain familiar, but the overlay is quieter and less intrusive, especially on smaller phones or landscape playback.

+
+ +
+

Validation

+

Passed:

+
    +
  • xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -sdk iphonesimulator -configuration Debug build
  • +
+

The build completed successfully. Xcode still reports the existing MobileVLCKit script-phase warning about missing outputs; this was not introduced by this player UI change.

+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • No simulator playback walkthrough was performed in this pass, so the exact visual feel should still be checked on device or simulator with real playback.
  • +
  • The controls are intentionally smaller. Accessibility labels remain in place, but future work could add larger pointer or VoiceOver-specific affordances if needed.
  • +
+
+ +
+

Follow-up Work

+

No required follow-up Beads issues were created. A useful next polish pass would be a simulator/device visual check during active playback, especially in portrait and landscape.

+
+
+ + \ No newline at end of file