Compare commits

..

No commits in common. "76433f126892a6c43bdb853b24c5180e3f43a635" and "f22df976e4d25f732705123e88aca1b935d49677" have entirely different histories.

30 changed files with 34 additions and 5071 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -1,15 +0,0 @@
{"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."}}

View file

@ -1,15 +1,2 @@
{"_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-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} {"_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}

3
.gitignore vendored
View file

@ -6,6 +6,3 @@
# Node tooling # Node tooling
node_modules/ node_modules/
# CocoaPods
Pods/

121
AGENTS.md
View file

@ -94,124 +94,3 @@ bd close <id> # Complete work
- NEVER say "ready to push when you are" - YOU must push - NEVER say "ready to push when you are" - YOU must push
- If push fails, resolve and retry until it succeeds - If push fails, resolve and retry until it succeeds
<!-- END BEADS INTEGRATION --> <!-- END BEADS INTEGRATION -->
## 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/<branch>`.
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**

191
CLAUDE.md
View file

@ -1,51 +1,6 @@
# Agent Instructions # Project Instructions for AI Agents
This project uses **bd** (beads) for issue tracking. Run `bd prime` for full workflow context. This file provides instructions and context for AI coding agents working on this project.
> **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 <id> # View issue details
bd update <id> --claim # Claim work atomically
bd close <id> # 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
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:7510c1e2 --> <!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:7510c1e2 -->
## Beads Issue Tracker ## Beads Issue Tracker
@ -95,143 +50,21 @@ bd close <id> # Complete work
- If push fails, resolve and retry until it succeeds - If push fails, resolve and retry until it succeeds
<!-- END BEADS INTEGRATION --> <!-- END BEADS INTEGRATION -->
## Required Turn Documentation
At the end of every completed implementation task, before final handoff, create a user-readable HTML document describing the work. ## Build & Test
This documentation is mandatory whenever code, configuration, tests, or project files were changed. _Add your build and test commands here_
### Precedence and classification ```bash
# Example:
Use this decision order before creating a turn document: # npm install
# npm test
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/
``` ```
Use a clear timestamped filename: ## Architecture Overview
```text _Add a brief overview of your project architecture_
docs/turns/YYYY-MM-DD-short-task-name.html
```
Example: ## Conventions & Patterns
```text _Add your project-specific conventions here_
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/<branch>`
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

View file

@ -10,12 +10,6 @@
6F2A2B362C00100100DREAMIO /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B332C00100100DREAMIO /* AppDelegate.swift */; }; 6F2A2B362C00100100DREAMIO /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B332C00100100DREAMIO /* AppDelegate.swift */; };
6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */; }; 6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */; };
6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */; }; 6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */; };
6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */; };
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */; };
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */; };
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */; };
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 */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@ -24,14 +18,6 @@
6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; }; 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DreamioWebViewController.swift; sourceTree = "<group>"; }; 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DreamioWebViewController.swift; sourceTree = "<group>"; };
6F2A2B392C00100100DREAMIO /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 6F2A2B392C00100100DREAMIO /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamCandidate.swift; sourceTree = "<group>"; };
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlaybackBackend.swift; sourceTree = "<group>"; };
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCNativePlaybackBackend.swift; sourceTree = "<group>"; };
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = "<group>"; };
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.swift; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -45,31 +31,11 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
5DEC645FC7F60E33F3A4E21E /* Frameworks */ = {
isa = PBXGroup;
children = (
908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
6593E172E04E344E08B5CAA8 /* Pods */ = {
isa = PBXGroup;
children = (
BF0A4D5BAC9400AEEF3B0181 /* Pods-Dreamio.debug.xcconfig */,
701702B9C2BFBEDE36E7F0A3 /* Pods-Dreamio.release.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
6F2A2B272C00100100DREAMIO = { 6F2A2B272C00100100DREAMIO = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
6F2A2B322C00100100DREAMIO /* Dreamio */, 6F2A2B322C00100100DREAMIO /* Dreamio */,
6F2A2B312C00100100DREAMIO /* Products */, 6F2A2B312C00100100DREAMIO /* Products */,
6593E172E04E344E08B5CAA8 /* Pods */,
5DEC645FC7F60E33F3A4E21E /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -87,11 +53,6 @@
6F2A2B332C00100100DREAMIO /* AppDelegate.swift */, 6F2A2B332C00100100DREAMIO /* AppDelegate.swift */,
6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */, 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */,
6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */, 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */,
6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */,
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,
6F2A2B392C00100100DREAMIO /* Info.plist */, 6F2A2B392C00100100DREAMIO /* Info.plist */,
); );
path = Dreamio; path = Dreamio;
@ -104,12 +65,9 @@
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 6F2A2B412C00100100DREAMIO /* Build configuration list for PBXNativeTarget "Dreamio" */; buildConfigurationList = 6F2A2B412C00100100DREAMIO /* Build configuration list for PBXNativeTarget "Dreamio" */;
buildPhases = ( buildPhases = (
9F808EDAD2C69568A9142D10 /* [CP] Check Pods Manifest.lock */,
6F2A2B512C00250100DREAMIO /* [CP] Prepare MobileVLCKit XCFramework */,
6F2A2B2C2C00100100DREAMIO /* Sources */, 6F2A2B2C2C00100100DREAMIO /* Sources */,
6F2A2B2D2C00100100DREAMIO /* Frameworks */, 6F2A2B2D2C00100100DREAMIO /* Frameworks */,
6F2A2B2E2C00100100DREAMIO /* Resources */, 6F2A2B2E2C00100100DREAMIO /* Resources */,
F26EA81D312D2AA38B06CF11 /* [CP] Embed Pods Frameworks */,
); );
buildRules = ( buildRules = (
); );
@ -163,68 +121,6 @@
}; };
/* End PBXResourcesBuildPhase section */ /* 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 */ /* Begin PBXSourcesBuildPhase section */
6F2A2B2C2C00100100DREAMIO /* Sources */ = { 6F2A2B2C2C00100100DREAMIO /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
@ -233,11 +129,6 @@
6F2A2B362C00100100DREAMIO /* AppDelegate.swift in Sources */, 6F2A2B362C00100100DREAMIO /* AppDelegate.swift in Sources */,
6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */, 6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */,
6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */, 6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */,
6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */,
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */,
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */,
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */,
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -280,7 +171,7 @@
DEBUG_INFORMATION_FORMAT = dwarf; DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO; GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
@ -341,7 +232,7 @@
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17; GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@ -361,11 +252,10 @@
}; };
6F2A2B3E2C00100100DREAMIO /* Debug */ = { 6F2A2B3E2C00100100DREAMIO /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = BF0A4D5BAC9400AEEF3B0181 /* Pods-Dreamio.debug.xcconfig */;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = C3V8C7JRTL; DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Dreamio/Info.plist; INFOPLIST_FILE = Dreamio/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -383,11 +273,10 @@
}; };
6F2A2B3F2C00100100DREAMIO /* Release */ = { 6F2A2B3F2C00100100DREAMIO /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = 701702B9C2BFBEDE36E7F0A3 /* Pods-Dreamio.release.xcconfig */;
buildSettings = { buildSettings = {
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = C3V8C7JRTL; DEVELOPMENT_TEAM = "";
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Dreamio/Info.plist; INFOPLIST_FILE = Dreamio/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (

View file

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>Dreamio.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
</dict>
</dict>
</plist>

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Dreamio.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View file

@ -4,8 +4,6 @@ import WebKit
final class DreamioWebViewController: UIViewController { final class DreamioWebViewController: UIViewController {
private enum Constants { private enum Constants {
static let stremioWebURL = URL(string: "https://web.stremio.com/")! static let stremioWebURL = URL(string: "https://web.stremio.com/")!
static let diagnosticsMessageHandler = "dreamioDiagnostics"
static let streamCandidateMessageHandler = "dreamioStreamCandidate"
} }
private lazy var webView: WKWebView = { private lazy var webView: WKWebView = {
@ -14,18 +12,6 @@ final class DreamioWebViewController: UIViewController {
configuration.allowsInlineMediaPlayback = true configuration.allowsInlineMediaPlayback = true
configuration.mediaTypesRequiringUserActionForPlayback = [] configuration.mediaTypesRequiringUserActionForPlayback = []
configuration.preferences.javaScriptCanOpenWindowsAutomatically = true configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
configuration.userContentController.add(
WeakScriptMessageHandler(delegate: self),
name: Constants.streamCandidateMessageHandler
)
configuration.userContentController.addUserScript(Self.streamCandidateScript)
#if DEBUG
configuration.userContentController.add(
WeakScriptMessageHandler(delegate: self),
name: Constants.diagnosticsMessageHandler
)
configuration.userContentController.addUserScript(Self.playbackDiagnosticsScript)
#endif
let webView = WKWebView(frame: .zero, configuration: configuration) let webView = WKWebView(frame: .zero, configuration: configuration)
webView.translatesAutoresizingMaskIntoConstraints = false webView.translatesAutoresizingMaskIntoConstraints = false
@ -34,11 +20,6 @@ final class DreamioWebViewController: UIViewController {
webView.navigationDelegate = self webView.navigationDelegate = self
webView.uiDelegate = self webView.uiDelegate = self
webView.scrollView.contentInsetAdjustmentBehavior = .never webView.scrollView.contentInsetAdjustmentBehavior = .never
#if DEBUG
if #available(iOS 16.4, *) {
webView.isInspectable = true
}
#endif
return webView return webView
}() }()
@ -50,315 +31,6 @@ final class DreamioWebViewController: UIViewController {
}() }()
private var progressObservation: NSKeyValueObservation? 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() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
@ -382,9 +54,6 @@ final class DreamioWebViewController: UIViewController {
} }
loadDreamio() loadDreamio()
webView.evaluateJavaScript("navigator.userAgent") { [weak self] result, _ in
self?.userAgent = result as? String
}
} }
private func loadDreamio() { private func loadDreamio() {
@ -408,192 +77,6 @@ final class DreamioWebViewController: UIViewController {
}) })
present(alert, animated: true) present(alert, animated: true)
} }
private func handleStreamCandidate(_ candidate: StreamCandidate) {
guard let request = StreamClassifier.playbackRequest(from: candidate, userAgent: userAgent) else {
return
}
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 { extension DreamioWebViewController: WKNavigationDelegate {
@ -616,31 +99,11 @@ extension DreamioWebViewController: WKNavigationDelegate {
decisionHandler(.allow) 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) { 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) showLoadFailure(error)
} }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: 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) showLoadFailure(error)
} }
@ -661,45 +124,6 @@ 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 { extension DreamioWebViewController: WKUIDelegate {
func webView( func webView(
_ webView: WKWebView, _ webView: WKWebView,

View file

@ -1,46 +0,0 @@
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."
}
}
}

View file

@ -1,421 +0,0 @@
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)))
}
}
}

View file

@ -1,371 +0,0 @@
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<String>()
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..<string.endIndex, in: string)
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
return []
}
return regex.matches(in: string, range: range).compactMap { match in
guard let range = Range(match.range, in: string) else {
return nil
}
return subtitleURL(from: String(string[range]))
}
}
}
enum StreamClassifier {
static let referer = "https://web.stremio.com/"
static func playbackRequest(
from candidate: StreamCandidate,
userAgent: String?
) -> NativePlaybackRequest? {
let classification = classify(candidate: candidate)
guard classification.shouldIntercept else {
return nil
}
return NativePlaybackRequest(
playbackURL: candidate.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
)
}
}
}

View file

@ -1,167 +0,0 @@
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
}
}

View file

@ -1,262 +0,0 @@
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<URL>()
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

42
Podfile
View file

@ -1,42 +0,0 @@
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

View file

@ -1,16 +0,0 @@
PODS:
- MobileVLCKit (3.7.3)
DEPENDENCIES:
- MobileVLCKit
SPEC REPOS:
trunk:
- MobileVLCKit
SPEC CHECKSUMS:
MobileVLCKit: 73d7ddb52238b6885b70b0f281cae75a0a6e3ac0
PODFILE CHECKSUM: 5d4ff6c157e7ad147c7e642ebbe89238e6624e6b
COCOAPODS: 1.16.2

View file

@ -7,81 +7,28 @@ inside a UIKit host app, handles new-window navigation in the existing web view,
allows inline media playback, and leaves playback viability to real-device allows inline media playback, and leaves playback viability to real-device
testing. testing.
## Running Dreamio ## Running the MVP
1. Install CocoaPods if needed. 1. Open `Dreamio.xcodeproj` in Xcode.
2. Run `pod install`. 2. Select the `Dreamio` scheme.
3. Open `Dreamio.xcworkspace` in Xcode. 3. Pick a real iPhone or iPad device.
4. Select the `Dreamio` scheme. 4. Set a development team for code signing if Xcode asks.
5. Pick a real iPhone or iPad device. 5. Build and run.
6. Set a development team for code signing if Xcode asks.
7. Build and run.
Dreamio uses MobileVLCKit for native playback of direct-file streams that iOS The repository machine currently has Command Line Tools selected instead of full
WebKit commonly cannot play, especially MKV, AVI, and WebM debrid URLs. Keep Xcode, so command-line `xcodebuild` validation is not available here.
using `Dreamio.xcworkspace` after installing pods so Xcode links the native
playback backend.
If the app says "Native playback needs CocoaPods" or a player screen says ## MVP Validation Checklist
"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.
On macOS, install CocoaPods with RubyGems: - Cold launch loads hosted Stremio Web.
- Login completes and persists after app relaunch.
```bash - Catalog and library navigation work.
sudo gem install cocoapods - Addon install or configuration flows work, including redirects or popups.
pod --version - HLS direct stream playback works.
pod install - MP4 direct stream playback works.
open Dreamio.xcworkspace - Unsupported formats fail understandably.
``` - Fullscreen, rotation, pause/resume, and background/foreground behavior are
acceptable for v1.
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, Track playback results by device, iOS version, stream protocol, container,
codec, subtitle type, HTTP status, and WebKit media error when available. codec, subtitle type, HTTP status, and WebKit media error when available.

View file

@ -1,126 +0,0 @@
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<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {
assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)
}
}

View file

@ -1,282 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dreamio Web Inspector and Playback Diagnostics</title>
<style>
:root {
color-scheme: light;
--ink: #202126;
--muted: #62646f;
--paper: #f8f7fb;
--panel: #ffffff;
--line: #ddd8e8;
--accent: #6f4fd8;
--accent-soft: #eee9ff;
--code: #2d2638;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background:
linear-gradient(180deg, rgba(111, 79, 216, 0.08), rgba(111, 79, 216, 0) 260px),
var(--paper);
color: var(--ink);
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main {
width: min(1040px, calc(100vw - 40px));
margin: 0 auto;
padding: 56px 0 72px;
}
header {
max-width: 820px;
margin-bottom: 34px;
}
.eyebrow {
margin-bottom: 10px;
color: var(--accent);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
h1,
h2 {
line-height: 1.15;
margin: 0;
}
h1 {
max-width: 780px;
font-size: clamp(2rem, 5vw, 4.4rem);
letter-spacing: 0;
}
h2 {
margin-bottom: 12px;
font-size: 1.25rem;
}
p {
margin: 0 0 12px;
}
.summary {
max-width: 760px;
margin-top: 18px;
color: var(--muted);
font-size: 1.08rem;
}
section {
padding: 26px 0;
border-top: 1px solid var(--line);
}
ul {
margin: 0;
padding-left: 1.2rem;
}
li + li {
margin-top: 6px;
}
code {
color: var(--code);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.92em;
}
pre {
overflow: auto;
padding: 16px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
.callout {
margin-top: 14px;
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--accent-soft);
}
.diff-shell {
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
}
</style>
<script type="module">
import { FileDiff } from "https://esm.sh/@pierre/diffs";
const snippets = [
{
id: "inspector-diff",
oldFile: {
name: "DreamioWebViewController.swift",
contents: `let webView = WKWebView(frame: .zero, configuration: configuration)
webView.translatesAutoresizingMaskIntoConstraints = false
webView.allowsBackForwardNavigationGestures = true
webView.customUserAgent = "Dreamio/0.1 WKWebView"`
},
newFile: {
name: "DreamioWebViewController.swift",
contents: `let webView = WKWebView(frame: .zero, configuration: configuration)
webView.translatesAutoresizingMaskIntoConstraints = false
webView.allowsBackForwardNavigationGestures = true
webView.customUserAgent = "Dreamio/0.1 WKWebView"
#if DEBUG
if #available(iOS 16.4, *) {
webView.isInspectable = true
}
#endif`
}
},
{
id: "diagnostics-diff",
oldFile: {
name: "DreamioWebViewController.swift",
contents: `configuration.preferences.javaScriptCanOpenWindowsAutomatically = true`
},
newFile: {
name: "DreamioWebViewController.swift",
contents: `configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
#if DEBUG
configuration.userContentController.add(
WeakScriptMessageHandler(delegate: self),
name: Constants.diagnosticsMessageHandler
)
configuration.userContentController.addUserScript(Self.playbackDiagnosticsScript)
#endif`
}
},
{
id: "navigation-diff",
oldFile: {
name: "DreamioWebViewController.swift",
contents: `func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
showLoadFailure(error)
}`
},
newFile: {
name: "DreamioWebViewController.swift",
contents: `func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
#if DEBUG
print("[DreamioNavigation] didFail url=... error=...")
#endif
showLoadFailure(error)
}`
}
}
];
for (const snippet of snippets) {
const target = document.getElementById(snippet.id);
if (!target) continue;
new FileDiff({ theme: "github-light" }).render({
oldFile: snippet.oldFile,
newFile: snippet.newFile,
containerWrapper: target
});
}
</script>
</head>
<body>
<main>
<header>
<div class="eyebrow">Repository implementation turn</div>
<h1>Dreamio Web Inspector and Playback Diagnostics</h1>
<p class="summary">Enabled development-only Safari inspection for Dreamio's <code>WKWebView</code> and added token-safe diagnostics for console warnings, promise rejections, video failures, navigation errors, and HTTP navigation statuses.</p>
</header>
<section>
<h2>Summary</h2>
<p>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.</p>
</section>
<section>
<h2>Changes Made</h2>
<ul>
<li>Enabled <code>webView.isInspectable</code> for <code>DEBUG</code> builds on iOS 16.4 and newer.</li>
<li>Installed a small <code>WKUserScript</code> at document start to observe console warnings, console errors, unhandled promise rejections, and dynamically inserted <code>&lt;video&gt;</code> elements.</li>
<li>Added native logging for <code>WKNavigationDelegate</code> response statuses and load failures.</li>
<li>Added native redaction helpers before diagnostic data is printed.</li>
<li>Kept all diagnostics behind <code>#if DEBUG</code> so release builds do not expose the inspection or message bridge surface.</li>
</ul>
</section>
<section>
<h2>Context</h2>
<p>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.</p>
</section>
<section>
<h2>Important Implementation Details</h2>
<ul>
<li>The diagnostics bridge posts messages through <code>window.webkit.messageHandlers.dreamioDiagnostics</code>.</li>
<li>The user script attaches to existing videos and videos inserted later through a <code>MutationObserver</code>.</li>
<li>Video diagnostics include <code>networkState</code>, <code>readyState</code>, <code>currentSrc</code>, media error code, and media error message.</li>
<li>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.</li>
<li>A weak message-handler wrapper avoids the common <code>WKUserContentController</code> retain cycle.</li>
</ul>
</section>
<section>
<h2>Relevant Diff Snippets</h2>
<p>The snippets below are rendered with <code>@pierre/diffs</code> from diffs.com-compatible components.</p>
<div class="diff-shell" id="inspector-diff"></div>
<div class="diff-shell" id="diagnostics-diff" style="margin-top: 14px;"></div>
<div class="diff-shell" id="navigation-diff" style="margin-top: 14px;"></div>
</section>
<section>
<h2>Expected Impact for End-Users</h2>
<p>There should be no visible behavior change for ordinary app use. For development builds, Safari should now show an inspectable Dreamio or <code>web.stremio.com</code> target while the app is foregrounded, making the playback failure much easier to diagnose.</p>
</section>
<section>
<h2>Validation</h2>
<ul>
<li>Ran <code>DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -list -project Dreamio.xcodeproj</code> to confirm the <code>Dreamio</code> scheme.</li>
<li>Ran <code>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</code>.</li>
<li>The Debug simulator build succeeded.</li>
</ul>
<div class="callout">Manual real-device validation is still needed on <code>kellcd</code>: launch Dreamio, open Safari inspection, reproduce the Debridio VOD failure, and collect Console, Network, and media logs.</div>
</section>
<section>
<h2>Issues, Limitations, and Mitigations</h2>
<ul>
<li>This does not fix playback. It adds the evidence-gathering surface for the next diagnosis step.</li>
<li>Safari Web Inspector availability still depends on the device, iOS version, Safari settings, and the app being a debug/development build.</li>
<li>Redaction is intentionally conservative but cannot prove every possible secret shape is removed. It strips common URL and token forms before logging.</li>
<li>The first <code>xcodebuild</code> attempt failed because the active developer directory pointed at Command Line Tools. Re-running with <code>DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer</code> succeeded.</li>
</ul>
</section>
<section>
<h2>Follow-up Work</h2>
<ul>
<li>Use Safari Web Inspector on <code>kellcd</code> to capture the failing stream request and media error details.</li>
<li>File a follow-up Beads issue if the evidence points to a native-player fallback, MIME/header adjustment, or hosted Stremio compatibility gap.</li>
<li>Consider adding a temporary debug menu to toggle diagnostics if the log volume gets noisy during broader testing.</li>
</ul>
</section>
</main>
</body>
</html>

View file

@ -1,307 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Native Debrid Playback</title>
<style>
:root {
color-scheme: light;
--bg: oklch(0.985 0.006 285);
--surface: oklch(0.955 0.01 285);
--ink: oklch(0.22 0.025 285);
--muted: oklch(0.48 0.025 285);
--line: oklch(0.86 0.018 285);
--accent: oklch(0.52 0.18 292);
--accent-soft: oklch(0.92 0.035 292);
--good: oklch(0.52 0.12 154);
--warn: oklch(0.63 0.13 72);
--code-bg: oklch(0.18 0.018 285);
--code-ink: oklch(0.94 0.01 285);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
line-height: 1.55;
}
main {
width: min(980px, calc(100% - 32px));
margin: 0 auto;
padding: 44px 0 64px;
}
header {
border-bottom: 1px solid var(--line);
margin-bottom: 30px;
padding-bottom: 24px;
}
h1 {
font-size: 2.1rem;
line-height: 1.12;
margin: 0 0 12px;
letter-spacing: 0;
}
h2 {
font-size: 1.08rem;
margin: 34px 0 10px;
letter-spacing: 0;
}
p {
max-width: 72ch;
margin: 0 0 14px;
}
ul {
margin: 10px 0 0;
padding-left: 22px;
}
li {
margin: 7px 0;
max-width: 76ch;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.94em;
}
pre {
margin: 14px 0 0;
overflow-x: auto;
border-radius: 8px;
background: var(--code-bg);
color: var(--code-ink);
padding: 16px;
line-height: 1.45;
}
.summary {
border: 1px solid var(--line);
background: var(--surface);
border-radius: 8px;
padding: 18px 20px;
}
.meta {
color: var(--muted);
font-size: 0.95rem;
margin: 0;
}
.pill-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.pill {
border: 1px solid var(--line);
border-radius: 999px;
background: var(--accent-soft);
color: var(--ink);
font-size: 0.88rem;
padding: 5px 10px;
}
.note {
border: 1px solid var(--line);
border-radius: 8px;
background: oklch(0.975 0.008 285);
padding: 14px 16px;
color: var(--muted);
}
.status {
color: var(--good);
font-weight: 650;
}
.warning {
color: var(--warn);
font-weight: 650;
}
.diffs-fallback {
border: 1px solid oklch(0.32 0.025 285);
}
</style>
</head>
<body>
<main>
<header>
<p class="meta">Dreamio turn document · 2026-05-24 23:18 EDT · Beads issue <code>dreamio-l68</code></p>
<h1>Native Direct-Stream Playback for Debrid Files</h1>
<p class="summary">Added a production WebKit-to-native playback path for direct-file debrid streams, with MKV, AVI, and WebM candidates routed into a new MobileVLCKit-backed player while ordinary HLS and MP4 web playback stay in Stremio Web.</p>
<div class="pill-row">
<span class="pill">WKWebView bridge</span>
<span class="pill">Stream classification</span>
<span class="pill">MobileVLCKit backend</span>
<span class="pill">Sanitized diagnostics</span>
</div>
</header>
<section>
<h2>Summary</h2>
<p>This change keeps Stremio Web as Dreamio's main browsing and account UI, but intercepts direct-file stream URLs that iOS WebKit is likely to reject. Matching streams now open in a native fullscreen-style player with a close button and failure state.</p>
</section>
<section>
<h2>Changes Made</h2>
<ul>
<li>Added a production JavaScript bridge in <code>DreamioWebViewController</code> that observes video/source URLs, direct <code>src</code> assignment, <code>setAttribute("src")</code>, mutations, and <code>load()</code>.</li>
<li>Added stream classification for Debridio, Torrentio, Real-Debrid, MKV, AVI, WebM, HLS, and MP4 candidates.</li>
<li>Added redacted URL diagnostics that strip query strings, fragments, and long token-like path segments before DEBUG logging.</li>
<li>Added <code>NativePlayerViewController</code>, <code>NativePlaybackBackend</code>, and the first backend implementation, <code>VLCNativePlaybackBackend</code>.</li>
<li>Added a CocoaPods <code>Podfile</code> for <code>MobileVLCKit</code> and ignored generated <code>Pods/</code> content.</li>
<li>Updated README workflow instructions to use <code>pod install</code> and <code>Dreamio.xcworkspace</code>.</li>
</ul>
</section>
<section>
<h2>Context</h2>
<p>Dreamio started as a thin UIKit wrapper around hosted Stremio Web. That remains the product shape: login, browsing, addon setup, stream selection, popups, and compatible web media playback still belong to the web app. This work adds a native escape hatch only for direct-file streams that are likely to fail in iOS WebKit.</p>
</section>
<section>
<h2>Important Implementation Details</h2>
<ul>
<li>The bridge allows ordinary HLS and MP4 playback to continue in WebKit unless the URL also matches a known direct-file debrid rule.</li>
<li>Native playback prefers the resolver URL when one is available, which avoids unnecessarily reusing short-lived observed CDN links.</li>
<li>The native playback request carries the current user agent when available and sets <code>Referer: https://web.stremio.com/</code>.</li>
<li>The native player clears duplicate suppression on dismissal, so selecting the same stream again can reopen playback.</li>
<li>MobileVLCKit is behind a small protocol so the player controller is not permanently coupled to VLC.</li>
</ul>
</section>
<section>
<h2>Relevant Diff Snippets</h2>
<p class="note">Repository instructions prefer <code>@pierre/diffs</code> output. The package is installed as a library, but <code>npx @pierre/diffs --help</code> failed because it exposes no executable in this repo. This section uses a clearly labeled plain diff fallback.</p>
<pre class="diffs-fallback"><code>diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift
+ static let streamCandidateMessageHandler = "dreamioStreamCandidate"
+ configuration.userContentController.add(
+ WeakScriptMessageHandler(delegate: self),
+ name: Constants.streamCandidateMessageHandler
+ )
+ configuration.userContentController.addUserScript(Self.streamCandidateScript)
+
+ const nativePatterns = [
+ /\/\/addon\.debridio\.com\/play\//i,
+ /\/\/torrentio\.strem\.fun\/resolve\//i,
+ /\/\/download\.real-debrid\.com\//i,
+ /\.(mkv|avi|webm)(?:[?#]|$)/i
+ ];
+
+ private func handleStreamCandidate(_ candidate: StreamCandidate) {
+ guard let request = StreamClassifier.playbackRequest(from: candidate, userAgent: userAgent) else {
+ return
+ }
+ let player = NativePlayerViewController(request: request)
+ present(player, animated: true)
+ }
diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift
+ enum StreamClassifier {
+ static func playbackRequest(
+ from candidate: StreamCandidate,
+ userAgent: String?
+ ) -> NativePlaybackRequest? {
+ let classification = classify(candidate: candidate)
+ guard classification.shouldIntercept else { return nil }
+ return NativePlaybackRequest(
+ playbackURL: candidate.resolverURL ?? candidate.observedURL,
+ observedURL: candidate.observedURL,
+ resolverURL: candidate.resolverURL,
+ pageURL: candidate.pageURL,
+ userAgent: userAgent,
+ referer: "https://web.stremio.com/",
+ classification: classification
+ )
+ }
+ }
diff --git a/Podfile b/Podfile
+ platform :ios, '16.0'
+ target 'Dreamio' do
+ use_frameworks!
+ pod 'MobileVLCKit'
+ end</code></pre>
</section>
<section>
<h2>Expected Impact for End-Users</h2>
<p>Users should keep using Dreamio through the Stremio Web interface, but direct debrid MKV, AVI, and WebM streams should now open in native playback instead of falling through to WebKit's unsupported media failure path. Closing the native player returns to the same web session.</p>
</section>
<section>
<h2>Validation</h2>
<ul>
<li><span class="status">Passed:</span> JavaScript bridge syntax was checked with <code>node --check</code>.</li>
<li><span class="status">Passed:</span> Swift Foundation-only classifier file type-checked with <code>xcrun swiftc -typecheck Dreamio/StreamCandidate.swift</code>.</li>
<li><span class="status">Passed:</span> Whitespace validation passed with <code>git diff --check</code>.</li>
<li><span class="warning">Blocked:</span> <code>pod install</code> could not run because CocoaPods is not installed on this machine.</li>
<li><span class="warning">Blocked:</span> iOS build validation could not run because active developer tools are Command Line Tools and the iPhoneOS SDK is unavailable.</li>
</ul>
</section>
<section>
<h2>Issues, Limitations, and Mitigations</h2>
<ul>
<li>Native playback improves container support, but it cannot guarantee every codec, audio format, subtitle format, HDR variant, or expired debrid URL will play.</li>
<li>The workspace and lockfile are not generated here because CocoaPods is unavailable. The README now makes the required local workflow explicit.</li>
<li>Manual real-device validation is still required for actual Debridio, Torrentio, and Real-Debrid streams.</li>
<li>DEBUG logs are intentionally sanitized and should not include full debrid URLs, query strings, tokens, signed paths, or long secret-like path segments.</li>
</ul>
</section>
<section>
<h2>Follow-up Work</h2>
<ul>
<li>Install CocoaPods locally, run <code>pod install</code>, commit the resulting <code>Podfile.lock</code> and workspace metadata if appropriate.</li>
<li>Open <code>Dreamio.xcworkspace</code> in full Xcode and build on a real iOS device.</li>
<li>Validate sample Debridio, Torrentio, and Real-Debrid URLs, including HTTP 206 direct download responses.</li>
<li>Consider adding a tiny XCTest target for classifier behavior once the project has a test bundle.</li>
</ul>
</section>
<section>
<h2>New Changes as of 2026-05-24 23:22 EDT</h2>
<p><strong>Summary of changes:</strong> 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 <code>canImport(MobileVLCKit)</code>.</p>
<p><strong>Why this change was made:</strong> Swift was interpreting JavaScript regex backslashes as Swift string escapes, and the app could not compile from <code>Dreamio.xcodeproj</code> before CocoaPods had installed and linked <code>MobileVLCKit</code>. The fallback keeps the project buildable enough to show a native-player unavailable error until the workspace is set up with pods.</p>
<p><strong>Code diffs:</strong> Plain diff fallback is used for the same reason noted above: <code>@pierre/diffs</code> is present as a library but has no runnable CLI exposed in this repo.</p>
<pre class="diffs-fallback"><code>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</code></pre>
<p><strong>Related issues or PRs:</strong> Follow-up to Beads issue <code>dreamio-l68</code>.</p>
</section>
</main>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,218 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Guard Native Playback Availability</title>
<style>
:root {
color-scheme: light;
--ink: oklch(23% 0.024 282);
--muted: oklch(48% 0.03 282);
--paper: oklch(98% 0.007 285);
--panel: oklch(95% 0.012 285);
--line: oklch(84% 0.026 285);
--accent: oklch(56% 0.16 292);
--warn: oklch(62% 0.14 58);
--good: oklch(54% 0.13 160);
}
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
line-height: 1.55;
}
main {
max-width: 1040px;
margin: 0 auto;
padding: 56px 28px 72px;
}
header {
margin-bottom: 34px;
}
h1 {
max-width: 820px;
margin: 0 0 16px;
font-size: clamp(2rem, 4vw, 3.8rem);
line-height: 1;
letter-spacing: 0;
}
h2 {
margin: 34px 0 10px;
font-size: 1.25rem;
}
p {
max-width: 74ch;
}
ul {
max-width: 78ch;
padding-left: 1.2rem;
}
code {
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.95em;
background: var(--panel);
padding: 0.08rem 0.28rem;
border-radius: 4px;
}
pre {
max-width: 100%;
overflow: auto;
border: 1px solid var(--line);
border-radius: 8px;
background: oklch(99% 0.004 285);
padding: 16px;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.88rem;
line-height: 1.45;
}
.summary {
max-width: 76ch;
color: var(--muted);
font-size: 1.08rem;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 18px;
}
.pill {
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 10px;
color: var(--muted);
background: oklch(96% 0.01 285);
font-size: 0.9rem;
}
.callout {
max-width: 78ch;
border: 1px solid oklch(78% 0.08 58);
background: oklch(95% 0.035 68);
border-radius: 8px;
padding: 14px 16px;
}
.ok {
border-color: oklch(78% 0.08 160);
background: oklch(95% 0.032 160);
}
</style>
</head>
<body>
<main>
<header>
<h1>Guard Native Playback Availability</h1>
<p class="summary">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.</p>
<div class="meta">
<span class="pill">Beads: dreamio-2k5</span>
<span class="pill">Native playback</span>
<span class="pill">CocoaPods setup</span>
<span class="pill">2026-05-25</span>
</div>
</header>
<section>
<h2>Summary</h2>
<p>Fixed the unavailable native playback build path by exposing a build-time availability check on <code>VLCNativePlaybackBackend</code> and using it before Dreamio presents native playback. CocoaPods was installed through Homebrew, <code>pod install</code> was run, and the generated workspace now links MobileVLCKit.</p>
</section>
<section>
<h2>Changes Made</h2>
<ul>
<li>Added <code>VLCNativePlaybackBackend.isAvailable</code>, backed by the same <code>canImport(MobileVLCKit)</code> compile condition as the real VLC implementation.</li>
<li>Updated <code>DreamioWebViewController</code> to check native backend availability before resolving and presenting the native player.</li>
<li>Added an actionable setup alert for builds that do not link <code>MobileVLCKit</code>.</li>
<li>Updated the README to explain that the exact unavailable-build message means the binary was built without the CocoaPods workspace.</li>
<li>Installed CocoaPods 1.16.2 with Homebrew and ran <code>pod install</code>, generating <code>Dreamio.xcworkspace</code> and <code>Podfile.lock</code> with MobileVLCKit 3.7.3.</li>
<li>Disabled Xcode user script sandboxing for the project so CocoaPods can embed MobileVLCKit during the framework copy phase.</li>
</ul>
</section>
<section>
<h2>Context</h2>
<p>The repository has a <code>Podfile</code> declaring <code>MobileVLCKit</code>, but this checkout did not have a generated <code>Pods/</code> directory or <code>Dreamio.xcworkspace</code>. In that state, Swift takes the fallback compile path where <code>canImport(MobileVLCKit)</code> is false. Before this change, Dreamio could still present the native player, which then displayed the generic fallback error.</p>
</section>
<section>
<h2>Important Implementation Details</h2>
<ul>
<li>The fallback backend remains intact so opening <code>Dreamio.xcodeproj</code> directly still compiles.</li>
<li>The guard runs before stream resolution, avoiding unnecessary resolver network work when native playback cannot succeed in the current build.</li>
<li>The duplicate playback key is cleared when the guard blocks playback, so the user can retry after rebuilding the app correctly.</li>
<li>The generated workspace references <code>Dreamio.xcodeproj</code> and <code>Pods/Pods.xcodeproj</code>. The <code>Pods/</code> directory remains ignored, while <code>Podfile.lock</code> and workspace metadata are tracked.</li>
<li><code>ENABLE_USER_SCRIPT_SANDBOXING</code> is set to <code>NO</code> because the CocoaPods embed frameworks script uses <code>rsync</code> to copy the MobileVLCKit framework into the app bundle.</li>
<li>No public app-facing API changed.</li>
</ul>
</section>
<section>
<h2>Relevant Diff Snippets</h2>
<p><code>@pierre/diffs</code> is installed as a library dependency, but its package does not expose a runnable CLI in this checkout, and <code>npx @pierre/diffs --help</code> failed with "could not determine executable to run." The plain diff below is the fallback snippet for the core behavior change.</p>
<pre><code>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: (() -&gt; Void)?
var onFailure: ((Error) -&gt; Void)?</code></pre>
</section>
<section>
<h2>Expected Impact for End-Users</h2>
<p>Users who accidentally run a raw <code>.xcodeproj</code> build will see a clear CocoaPods setup message instead of a black native player with an unavailable-build failure. Users who build from <code>Dreamio.xcworkspace</code> with <code>MobileVLCKit</code> linked should continue into VLC-backed direct-file playback.</p>
</section>
<section>
<h2>Validation</h2>
<ul>
<li>Ran <code>swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests &amp;&amp; /tmp/dreamio-stream-tests</code>: passed.</li>
<li>Ran <code>swiftc -typecheck Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift</code>: passed.</li>
<li>Ran <code>git diff --check</code>: passed.</li>
<li>Ran <code>HOMEBREW_NO_AUTO_UPDATE=1 brew install cocoapods</code>: passed, installing CocoaPods 1.16.2.</li>
<li>Ran <code>pod --version &amp;&amp; pod install</code>: passed, installing MobileVLCKit 3.7.3 and generating the workspace.</li>
<li>Ran <code>DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -configuration Debug -sdk iphonesimulator build</code>: passed.</li>
</ul>
</section>
<section>
<h2>Issues, Limitations, and Mitigations</h2>
<div class="callout">The simulator workspace build passes. Real-device playback validation is still required for the actual VLC-backed stream behavior.</div>
<ul>
<li>The global <code>xcode-select</code> value still points at Command Line Tools because changing it requires sudo. Command-line builds can use <code>DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer</code>.</li>
<li>The <code>Pods/</code> directory is intentionally ignored by git, so another checkout should run <code>pod install</code> after pulling.</li>
<li>The native player still depends on MobileVLCKit behavior once the workspace build is available.</li>
</ul>
</section>
<section>
<h2>Follow-up Work</h2>
<ul>
<li>On device, select a direct MKV, AVI, or WebM stream and confirm the VLC-backed player starts.</li>
</ul>
</section>
</main>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long