From 1563a4c06782a285f527616fde57dea46d0ad4d5 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Sun, 24 May 2026 22:36:06 -0400 Subject: [PATCH 01/16] add webview inspection diagnostics --- .beads/issues.jsonl | 2 + Dreamio/DreamioWebViewController.swift | 225 ++++++++++++++ ...le-web-inspector-playback-diagnostics.html | 282 ++++++++++++++++++ 3 files changed, 509 insertions(+) create mode 100644 docs/turns/2026-05-24-enable-web-inspector-playback-diagnostics.html diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1cab55d..7bfcea4 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,2 +1,4 @@ +{"_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-evt","title":"Enable WebView inspection and playback diagnostics","description":"Add development-only WKWebView inspection and token-safe playback diagnostics so Dreamio can debug hosted Stremio media failures without changing app navigation, login, or playback behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T02:30:26Z","created_by":"dirtydishes","updated_at":"2026-05-25T02:34:55Z","started_at":"2026-05-25T02:30:32Z","closed_at":"2026-05-25T02:34:55Z","close_reason":"Implemented debug-only WKWebView inspection, token-safe playback diagnostics, navigation logging, validation build, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-a5b","title":"Track HTML diff rendering tooling as dev dependency","description":"Move the HTML diff rendering package into devDependencies and ignore installed Node modules so the repo tracks reproducible tooling without vendoring dependencies.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:12:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:12:44Z","started_at":"2026-05-25T01:12:14Z","closed_at":"2026-05-25T01:12:44Z","close_reason":"Moved @pierre/diffs to devDependencies and ignored node_modules.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index 6d0376a..eac3597 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -4,6 +4,7 @@ import WebKit final class DreamioWebViewController: UIViewController { private enum Constants { static let stremioWebURL = URL(string: "https://web.stremio.com/")! + static let diagnosticsMessageHandler = "dreamioDiagnostics" } private lazy var webView: WKWebView = { @@ -12,6 +13,13 @@ final class DreamioWebViewController: UIViewController { configuration.allowsInlineMediaPlayback = true configuration.mediaTypesRequiringUserActionForPlayback = [] configuration.preferences.javaScriptCanOpenWindowsAutomatically = true +#if DEBUG + configuration.userContentController.add( + WeakScriptMessageHandler(delegate: self), + name: Constants.diagnosticsMessageHandler + ) + configuration.userContentController.addUserScript(Self.playbackDiagnosticsScript) +#endif let webView = WKWebView(frame: .zero, configuration: configuration) webView.translatesAutoresizingMaskIntoConstraints = false @@ -20,6 +28,11 @@ final class DreamioWebViewController: UIViewController { webView.navigationDelegate = self webView.uiDelegate = self webView.scrollView.contentInsetAdjustmentBehavior = .never +#if DEBUG + if #available(iOS 16.4, *) { + webView.isInspectable = true + } +#endif return webView }() @@ -32,6 +45,96 @@ final class DreamioWebViewController: UIViewController { private var progressObservation: NSKeyValueObservation? +#if DEBUG + private static let playbackDiagnosticsScript = WKUserScript( + source: """ + (() => { + if (window.__dreamioPlaybackDiagnosticsInstalled) { + return; + } + window.__dreamioPlaybackDiagnosticsInstalled = true; + + const post = (type, payload = {}) => { + try { + window.webkit.messageHandlers.dreamioDiagnostics.postMessage({ + type, + payload, + href: window.location.href + }); + } catch (_) {} + }; + + const describeValue = (value) => { + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack + }; + } + if (typeof value === "string") { + return value; + } + try { + return JSON.stringify(value); + } catch (_) { + return String(value); + } + }; + + ["error", "warn"].forEach((level) => { + const original = console[level]; + console[level] = (...args) => { + post(`console.${level}`, { args: args.map(describeValue) }); + original.apply(console, args); + }; + }); + + window.addEventListener("unhandledrejection", (event) => { + post("unhandledrejection", { + reason: describeValue(event.reason) + }); + }); + + const videoState = (video) => ({ + currentSrc: video.currentSrc || video.src || "", + networkState: video.networkState, + readyState: video.readyState, + errorCode: video.error ? video.error.code : null, + errorMessage: video.error ? video.error.message : null + }); + + const attachVideoDiagnostics = (video) => { + if (!video || video.__dreamioDiagnosticsAttached) { + return; + } + video.__dreamioDiagnosticsAttached = true; + video.addEventListener("error", () => { + post("video.error", videoState(video)); + }, true); + }; + + document.querySelectorAll("video").forEach(attachVideoDiagnostics); + + new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node instanceof HTMLVideoElement) { + attachVideoDiagnostics(node); + } + if (node.querySelectorAll) { + node.querySelectorAll("video").forEach(attachVideoDiagnostics); + } + }); + }); + }).observe(document.documentElement, { childList: true, subtree: true }); + })(); + """, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) +#endif + override func viewDidLoad() { super.viewDidLoad() @@ -77,6 +180,70 @@ final class DreamioWebViewController: UIViewController { }) present(alert, animated: true) } + +#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 { + guard var components = URLComponents(string: value), components.scheme != nil else { + return redactTokenLikeFragments(in: value) + } + + components.query = nil + components.fragment = nil + if let path = components.percentEncodedPath.nilIfEmpty { + components.percentEncodedPath = redactTokenLikePathSegments(in: path) + } + return redactTokenLikeFragments(in: components.string ?? value) + } + + private 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 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 + ) + } + } +#endif } extension DreamioWebViewController: WKNavigationDelegate { @@ -99,11 +266,31 @@ extension DreamioWebViewController: WKNavigationDelegate { decisionHandler(.allow) } + func webView( + _ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void + ) { +#if DEBUG + if let response = navigationResponse.response as? HTTPURLResponse { + let url = response.url?.absoluteString ?? "unknown" + print("[DreamioNavigation] status=\(response.statusCode) url=\(redactedURLString(url))") + } +#endif + decisionHandler(.allow) + } + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { +#if DEBUG + print("[DreamioNavigation] didFail url=\(redactedURLString(webView.url?.absoluteString ?? "unknown")) error=\(redactedURLString(error.localizedDescription))") +#endif showLoadFailure(error) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { +#if DEBUG + print("[DreamioNavigation] didFailProvisional url=\(redactedURLString(webView.url?.absoluteString ?? "unknown")) error=\(redactedURLString(error.localizedDescription))") +#endif showLoadFailure(error) } @@ -124,6 +311,44 @@ extension DreamioWebViewController: WKNavigationDelegate { } } +#if DEBUG +extension DreamioWebViewController: WKScriptMessageHandler { + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + 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 + ) + } +} + +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) + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} +#endif + extension DreamioWebViewController: WKUIDelegate { func webView( _ webView: WKWebView, diff --git a/docs/turns/2026-05-24-enable-web-inspector-playback-diagnostics.html b/docs/turns/2026-05-24-enable-web-inspector-playback-diagnostics.html new file mode 100644 index 0000000..116c281 --- /dev/null +++ b/docs/turns/2026-05-24-enable-web-inspector-playback-diagnostics.html @@ -0,0 +1,282 @@ + + + + + + Dreamio Web Inspector and Playback Diagnostics + + + + +
+
+
Repository implementation turn
+

Dreamio Web Inspector and Playback Diagnostics

+

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

+
+ +
+

Summary

+

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

+
+ +
+

Changes Made

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

Context

+

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

+
+ +
+

Important Implementation Details

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

Relevant Diff Snippets

+

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

+
+
+ +
+ +
+

Expected Impact for End-Users

+

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

+
+ +
+

Validation

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

Issues, Limitations, and Mitigations

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

Follow-up Work

+
    +
  • Use Safari Web Inspector on kellcd to capture the failing stream request and media error details.
  • +
  • File a follow-up Beads issue if the evidence points to a native-player fallback, MIME/header adjustment, or hosted Stremio compatibility gap.
  • +
  • Consider adding a temporary debug menu to toggle diagnostics if the log volume gets noisy during broader testing.
  • +
+
+
+ + From 3df2e2b8337174ac35955c32e349c3e07ca8815f Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Sun, 24 May 2026 22:43:43 -0400 Subject: [PATCH 02/16] add webview inspection diagnostics --- .DS_Store | Bin 0 -> 6148 bytes .beads/interactions.jsonl | 4 + AGENTS.md | 142 ++++++++++++++++++ Dreamio.xcodeproj/project.pbxproj | 4 +- .../contents.xcworkspacedata | 7 + .../UserInterfaceState.xcuserstate | Bin 0 -> 14430 bytes .../xcschemes/xcschememanagement.plist | 14 ++ 7 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 .DS_Store create mode 100644 Dreamio.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Dreamio.xcodeproj/project.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 Dreamio.xcodeproj/xcuserdata/kell.xcuserdatad/xcschemes/xcschememanagement.plist diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..bd5957a280c6cb7fd64525b993b069f75d064085 GIT binary patch literal 6148 zcmeHKOG*SW5PhYyXmB&`T;>GA+@Kxe3FZPi(}N$TNh63Z`v~63Gl&cE0N%k@si@P~ zi@}A6R6*)hQdQ~XK_@8yGQVwRz!X5mrYLHRhz^gs_Db-CD0z++S4cR1?z&nAw3kaRHCIyDKW&-IUY(}U9dN_bVw;aqWS;>sEL1qR*$VBJ9L literal 0 HcmV?d00001 diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index e69de29..9750577 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -0,0 +1,4 @@ +{"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."}} diff --git a/AGENTS.md b/AGENTS.md index bc2ae10..dd7b6e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,3 +94,145 @@ bd close # Complete work - NEVER say "ready to push when you are" - YOU must push - If push fails, resolve and retry until it succeeds + +## Required Turn Documentation + +At the end of every completed implementation task, before final handoff, create a user-readable HTML document describing the work. + +This documentation is mandatory whenever code, configuration, tests, or project files were changed. + +### Precedence and classification + +Use this decision order before creating a turn document: + +1. Check the minor/trivial exemption checklist below first. +2. If the task clearly matches an exemption, do not create a turn document. +3. If the task is a clearly substantive implementation change, create a turn document. +4. If classification is ambiguous or mixed, ask the user before creating a turn document. + +The minor/trivial exemptions override the general mandatory turn-document rule. + +For diff content in turn documentation (including "Code diffs" and "Relevant Diff Snippets"), use `@pierre/diffs` output by default. If `@pierre/diffs` is unavailable because of a real tool or blocking error, use a clearly labeled plain diff/code block fallback and note why. + +### No turn document for minor/trivial checklist matches + +Do not create a turn document when the change is minor/trivial and cleanly matches one of these categories: + +- `AGENTS.md` changes or other documentation-only changes +- Syntax-only fixes +- Refactor-only changes with no behavior change +- PR/conflict reconciliation work +- Issue-tracker-only updates such as `beads/issues.json` +- Support-file changes that only accompany one of the exempt categories above (for example lockfile or manifest updates required for docs-workflow changes) + +If a change does not cleanly fit either exempt or substantive buckets, ask the user before creating a turn document. + +### When making a minor update to a previous change, update the existing documentation instead of creating a new file. Use the following format: + +**"New Changes as of {time and date at which the change was made}"** +- **Summary of changes** +- **Why this change was made** +- **Code diffs** (use `@pierre/diffs` output by default; if unavailable, include a clearly labeled plain diff/code block and note why) +- **Related issues or PRs** + +Additionally, add a note to each section explaining why the changes were made. + +### Location + +Save the document in: + +```text +docs/turns/ +``` + +Use a clear timestamped filename: + +```text +docs/turns/YYYY-MM-DD-short-task-name.html +``` + +Example: + +```text +docs/turns/2026-05-14-add-market-replay-controls.html +``` + +### Format + +Use the `impeccable` skill to structure and style the document as clean, readable HTML. + +For this repository, `impeccable` is the styling and layout authority for turn documents when available. Do not apply global non-repo computer-task house styling to repository turn documents. + +If the `impeccable` skill is unavailable or blocked by an actual tool/file error, still create a well-structured standalone HTML file with: + +- A concise summary at the top +- A detailed explanation of what changed +- Relevant context or background +- Specific code snippets or examples when helpful +- Issues, limitations, tradeoffs, or mitigations +- Validation performed, including tests, builds, linters, or manual checks +- Any remaining follow-up work, with corresponding Beads issue IDs when applicable + +### Required Sections + +Each turn document must include these sections: + +1. **Summary** +2. **Changes Made** +3. **Context** +4. **Important Implementation Details** +5. **Relevant Diff Snippets** (render with `@pierre/diffs` output by default; if unavailable, include a clearly labeled plain diff/code block and note why) +6. **Expected Impact for End-Users** +7. **Validation** +8. **Issues, Limitations, and Mitigations** +9. **Follow-up Work** + +### Completion Rule + +A task that requires a turn document is not complete until: + +1. The Beads workflow is updated +2. The turn document is created in `docs/turns` +3. Relevant quality gates have passed or failures are documented +4. Changes are committed +5. `bd dolt push` succeeds +6. `git push forgejo ` succeeds +7. `git status` shows the branch is up to date with `forgejo/` + +For tasks that do require turn documentation, the document may be brief when scope is small, but it must clearly explain what changed and how it was validated. + +## Plan Mode Documentation + +When working in plan mode, do not modify implementation files. + +At the end of plan mode, provide a concise summary of the plan and ask the user whether they want to proceed with implementation. + +If the user asks to save the plan, create a user-readable HTML plan document in: + +```text +docs/plans/ +``` + +Use a clear timestamped filename: + +```text +docs/plans/YYYY-MM-DD-short-plan-name.html +``` + +The plan document should be labeled clearly as a plan and should include: + +1. **Plan Summary** +2. **Goals** +3. **Proposed Changes** +4. **Relevant Context** +5. **Implementation Steps** +6. **Risks, Limitations, and Mitigations** +7. **Open Questions** + +Always do the following when you finish a task, finish the beads workflow and and make a commit: +- Document the changes in a user-readable format +- Use the impeccable skill to structure the document as HTML +- Create a clear, concise summary of the changes at the top, followed by a detailed description of the changes, including any relevant context or background as well as specific code snippets or examples. +- Note any relevant issues or limitations that were addressed or mitigated by the changes. +- The HTML file should be stored in the `docs/turns` directory. It should include the current date and time, as well as a brief explanation of changes. e.g. docs/turns/YYYY-MM-DD-{description}.html + diff --git a/Dreamio.xcodeproj/project.pbxproj b/Dreamio.xcodeproj/project.pbxproj index 5324de1..38deda2 100644 --- a/Dreamio.xcodeproj/project.pbxproj +++ b/Dreamio.xcodeproj/project.pbxproj @@ -255,7 +255,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = C3V8C7JRTL; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Dreamio/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -276,7 +276,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = C3V8C7JRTL; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Dreamio/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/Dreamio.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Dreamio.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Dreamio.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Dreamio.xcodeproj/project.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate b/Dreamio.xcodeproj/project.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..8e4627ee0b576f8d5f7d8af5cdc7826acea46562 GIT binary patch literal 14430 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 N`j#IQ`9I7X{|7tbKfnM0 literal 0 HcmV?d00001 diff --git a/Dreamio.xcodeproj/xcuserdata/kell.xcuserdatad/xcschemes/xcschememanagement.plist b/Dreamio.xcodeproj/xcuserdata/kell.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..8fbf89e --- /dev/null +++ b/Dreamio.xcodeproj/xcuserdata/kell.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Dreamio.xcscheme_^#shared#^_ + + orderHint + 0 + + + + From d28540ce9823ed7c8ea5bfeec044836203b496a9 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Sun, 24 May 2026 23:20:32 -0400 Subject: [PATCH 03/16] add native debrid stream playback --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + .gitignore | 3 + Dreamio.xcodeproj/project.pbxproj | 16 + Dreamio/DreamioWebViewController.swift | 221 +++++++++++--- Dreamio/NativePlaybackBackend.swift | 22 ++ Dreamio/NativePlayerViewController.swift | 134 +++++++++ Dreamio/StreamCandidate.swift | 208 +++++++++++++ Dreamio/VLCNativePlaybackBackend.swift | 58 ++++ Podfile | 7 + README.md | 47 +-- .../2026-05-24-native-debrid-playback.html | 280 ++++++++++++++++++ 12 files changed, 936 insertions(+), 62 deletions(-) create mode 100644 Dreamio/NativePlaybackBackend.swift create mode 100644 Dreamio/NativePlayerViewController.swift create mode 100644 Dreamio/StreamCandidate.swift create mode 100644 Dreamio/VLCNativePlaybackBackend.swift create mode 100644 Podfile create mode 100644 docs/turns/2026-05-24-native-debrid-playback.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 9750577..79cf19a 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -2,3 +2,4 @@ {"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."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 7bfcea4..fdf5733 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_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-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} diff --git a/.gitignore b/.gitignore index 089a6b9..c4cf5cd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ # Node tooling node_modules/ + +# CocoaPods +Pods/ diff --git a/Dreamio.xcodeproj/project.pbxproj b/Dreamio.xcodeproj/project.pbxproj index 38deda2..6736b6b 100644 --- a/Dreamio.xcodeproj/project.pbxproj +++ b/Dreamio.xcodeproj/project.pbxproj @@ -10,6 +10,10 @@ 6F2A2B362C00100100DREAMIO /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B332C00100100DREAMIO /* AppDelegate.swift */; }; 6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */; }; 6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */; }; + 6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */; }; + 6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */; }; + 6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */; }; + 6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -18,6 +22,10 @@ 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DreamioWebViewController.swift; sourceTree = ""; }; 6F2A2B392C00100100DREAMIO /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamCandidate.swift; sourceTree = ""; }; + 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlaybackBackend.swift; sourceTree = ""; }; + 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCNativePlaybackBackend.swift; sourceTree = ""; }; + 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -53,6 +61,10 @@ 6F2A2B332C00100100DREAMIO /* AppDelegate.swift */, 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */, 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */, + 6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */, + 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */, + 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */, + 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */, 6F2A2B392C00100100DREAMIO /* Info.plist */, ); path = Dreamio; @@ -129,6 +141,10 @@ 6F2A2B362C00100100DREAMIO /* AppDelegate.swift in Sources */, 6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */, 6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */, + 6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */, + 6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */, + 6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */, + 6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index eac3597..b07e657 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -5,6 +5,7 @@ final class DreamioWebViewController: UIViewController { private enum Constants { static let stremioWebURL = URL(string: "https://web.stremio.com/")! static let diagnosticsMessageHandler = "dreamioDiagnostics" + static let streamCandidateMessageHandler = "dreamioStreamCandidate" } private lazy var webView: WKWebView = { @@ -13,6 +14,11 @@ final class DreamioWebViewController: UIViewController { configuration.allowsInlineMediaPlayback = true configuration.mediaTypesRequiringUserActionForPlayback = [] configuration.preferences.javaScriptCanOpenWindowsAutomatically = true + configuration.userContentController.add( + WeakScriptMessageHandler(delegate: self), + name: Constants.streamCandidateMessageHandler + ) + configuration.userContentController.addUserScript(Self.streamCandidateScript) #if DEBUG configuration.userContentController.add( WeakScriptMessageHandler(delegate: self), @@ -44,6 +50,142 @@ final class DreamioWebViewController: UIViewController { }() private var progressObservation: NSKeyValueObservation? + private var userAgent: String? + private var lastNativePlaybackURL: URL? + + 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 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; + } + 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 : "" + }); + } 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( @@ -157,6 +299,9 @@ final class DreamioWebViewController: UIViewController { } loadDreamio() + webView.evaluateJavaScript("navigator.userAgent") { [weak self] result, _ in + self?.userAgent = result as? String + } } private func loadDreamio() { @@ -181,6 +326,28 @@ final class DreamioWebViewController: UIViewController { present(alert, animated: true) } + private func handleStreamCandidate(_ candidate: StreamCandidate) { + guard let request = StreamClassifier.playbackRequest(from: candidate, userAgent: userAgent) else { + return + } + + if lastNativePlaybackURL == request.playbackURL { + return + } + lastNativePlaybackURL = request.playbackURL + +#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 + + let player = NativePlayerViewController(request: request) + player.onDismiss = { [weak self] in + self?.lastNativePlaybackURL = nil + } + present(player, animated: true) + } + #if DEBUG private func logDiagnostic(type: String, payload: Any, pageURL: String?) { let redactedPageURL = pageURL.map(redactedURLString) ?? "unknown" @@ -204,44 +371,7 @@ final class DreamioWebViewController: UIViewController { } private 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 let path = components.percentEncodedPath.nilIfEmpty { - components.percentEncodedPath = redactTokenLikePathSegments(in: path) - } - return redactTokenLikeFragments(in: components.string ?? value) - } - - private 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 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 - ) - } + URLRedactor.redactedURLString(value) } #endif } @@ -311,9 +441,15 @@ extension DreamioWebViewController: WKNavigationDelegate { } } -#if DEBUG 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 @@ -326,6 +462,7 @@ extension DreamioWebViewController: WKScriptMessageHandler { payload: body["payload"] ?? [:], pageURL: body["href"] as? String ) +#endif } } @@ -342,12 +479,6 @@ private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler { } } -private extension String { - var nilIfEmpty: String? { - isEmpty ? nil : self - } -} -#endif extension DreamioWebViewController: WKUIDelegate { func webView( diff --git a/Dreamio/NativePlaybackBackend.swift b/Dreamio/NativePlaybackBackend.swift new file mode 100644 index 0000000..52f5d0f --- /dev/null +++ b/Dreamio/NativePlaybackBackend.swift @@ -0,0 +1,22 @@ +import UIKit + +protocol NativePlaybackBackend: AnyObject { + var view: UIView { get } + var onReady: (() -> Void)? { get set } + var onFailure: ((Error) -> Void)? { get set } + + func prepare(in viewController: UIViewController) + func play(request: NativePlaybackRequest) + func stop() +} + +enum NativePlaybackError: LocalizedError { + case backendUnavailable + + var errorDescription: String? { + switch self { + case .backendUnavailable: + return "Native playback is not available in this build." + } + } +} diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift new file mode 100644 index 0000000..58ea001 --- /dev/null +++ b/Dreamio/NativePlayerViewController.swift @@ -0,0 +1,134 @@ +import UIKit + +final class NativePlayerViewController: UIViewController { + private let request: NativePlaybackRequest + private var backend: NativePlaybackBackend + var onDismiss: (() -> Void)? + + private let loadingView: UIActivityIndicatorView = { + let view = UIActivityIndicatorView(style: .large) + view.translatesAutoresizingMaskIntoConstraints = false + view.color = .white + view.startAnimating() + return view + }() + + private let closeButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(UIImage(systemName: "xmark"), for: .normal) + button.tintColor = .white + button.backgroundColor = UIColor.black.withAlphaComponent(0.45) + button.layer.cornerRadius = 22 + button.accessibilityLabel = "Close" + return button + }() + + private let 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() + backend.play(request: request) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + backend.stop() + onDismiss?() + } + + private func configureBackend() { + backend.prepare(in: self) + backend.view.translatesAutoresizingMaskIntoConstraints = false + backend.onReady = { [weak self] in + DispatchQueue.main.async { + self?.loadingView.stopAnimating() + self?.loadingView.isHidden = true + } + } + backend.onFailure = { [weak self] error in + DispatchQueue.main.async { + self?.showFailure(error) + } + } + } + + private func configureLayout() { + view.addSubview(backend.view) + view.addSubview(loadingView) + view.addSubview(failureLabel) + view.addSubview(closeButton) + closeButton.addTarget(self, action: #selector(close), for: .touchUpInside) + + NSLayoutConstraint.activate([ + backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + backend.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + backend.view.topAnchor.constraint(equalTo: view.topAnchor), + backend.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + + failureLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 28), + failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28), + failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + + closeButton.widthAnchor.constraint(equalToConstant: 44), + closeButton.heightAnchor.constraint(equalToConstant: 44), + closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), + closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12) + ]) + } + + 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) + } +} diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift new file mode 100644 index 0000000..917c6f4 --- /dev/null +++ b/Dreamio/StreamCandidate.swift @@ -0,0 +1,208 @@ +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 classification: StreamClassification +} + +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? + + 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"]) + } + + private static func url(from value: Any?) -> URL? { + guard let string = value as? String, !string.isEmpty else { + return nil + } + + return URL(string: string) + } +} + +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.resolverURL ?? candidate.observedURL, + observedURL: candidate.observedURL, + resolverURL: candidate.resolverURL, + pageURL: candidate.pageURL, + userAgent: userAgent, + referer: referer, + classification: classification + ) + } + + static func classify(candidate: StreamCandidate) -> StreamClassification { + let observed = candidate.observedURL + let resolver = candidate.resolverURL + 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.percentEncodedPath.isEmpty { + components.percentEncodedPath = redactTokenLikePathSegments(in: components.percentEncodedPath) + } + return redactTokenLikeFragments(in: components.string ?? value) + } + + private static func redactTokenLikePathSegments(in path: String) -> String { + path + .split(separator: "/", omittingEmptySubsequences: false) + .map { segment -> String in + let text = String(segment) + if text.range(of: #"^[A-Za-z0-9_-]{24,}$"#, options: .regularExpression) != nil { + return "[redacted]" + } + return text + } + .joined(separator: "/") + } + + private static func redactTokenLikeFragments(in value: String) -> String { + let patterns = [ + #"(?i)((?:token|access_token|auth|signature|sig|key|apikey|api_key|jwt|session|password)=)([^&\s]+)"#, + #"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+"# + ] + + return patterns.reduce(value) { redacted, pattern in + redacted.replacingOccurrences( + of: pattern, + with: "$1[redacted]", + options: .regularExpression + ) + } + } +} diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift new file mode 100644 index 0000000..44ca5bc --- /dev/null +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -0,0 +1,58 @@ +import UIKit +import MobileVLCKit + +final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { + let view = UIView() + var onReady: (() -> Void)? + var onFailure: ((Error) -> Void)? + + private let mediaPlayer = VLCMediaPlayer() + + override init() { + super.init() + mediaPlayer.delegate = self + view.backgroundColor = .black + } + + func prepare(in viewController: UIViewController) { + mediaPlayer.drawable = view + } + + func play(request: NativePlaybackRequest) { + let media = VLCMedia(url: request.playbackURL) + var headers = ["Referer": request.referer] + if let userAgent = request.userAgent { + headers["User-Agent"] = userAgent + } + + let headerValue = 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)") + } + media.addOption(":http-header=\(headerValue)") + + mediaPlayer.media = media + mediaPlayer.play() + } + + func stop() { + mediaPlayer.stop() + mediaPlayer.media = nil + } +} + +extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { + func mediaPlayerStateChanged(_ aNotification: Notification) { + switch mediaPlayer.state { + case .opening, .buffering, .playing: + onReady?() + case .error: + onFailure?(NativePlaybackError.backendUnavailable) + default: + break + } + } +} diff --git a/Podfile b/Podfile new file mode 100644 index 0000000..a89a361 --- /dev/null +++ b/Podfile @@ -0,0 +1,7 @@ +platform :ios, '16.0' + +target 'Dreamio' do + use_frameworks! + + pod 'MobileVLCKit' +end diff --git a/README.md b/README.md index d18fe91..ddf7daf 100644 --- a/README.md +++ b/README.md @@ -7,28 +7,41 @@ inside a UIKit host app, handles new-window navigation in the existing web view, allows inline media playback, and leaves playback viability to real-device testing. -## Running the MVP +## Running Dreamio -1. Open `Dreamio.xcodeproj` in Xcode. -2. Select the `Dreamio` scheme. -3. Pick a real iPhone or iPad device. -4. Set a development team for code signing if Xcode asks. -5. Build and run. +1. Install CocoaPods if needed. +2. Run `pod install`. +3. Open `Dreamio.xcworkspace` in Xcode. +4. Select the `Dreamio` scheme. +5. Pick a real iPhone or iPad device. +6. Set a development team for code signing if Xcode asks. +7. Build and run. + +Dreamio uses MobileVLCKit for native playback of direct-file streams that iOS +WebKit commonly cannot play, especially MKV, AVI, and WebM debrid URLs. Keep +using `Dreamio.xcworkspace` after installing pods so Xcode links the native +playback backend. + +## Validation Notes The repository machine currently has Command Line Tools selected instead of full -Xcode, so command-line `xcodebuild` validation is not available here. +Xcode, and CocoaPods is not installed, so command-line `pod install` and +`xcodebuild` validation are not available here. -## MVP Validation Checklist +## Playback Validation Checklist -- Cold launch loads hosted Stremio Web. -- Login completes and persists after app relaunch. -- Catalog and library navigation work. -- Addon install or configuration flows work, including redirects or popups. -- HLS direct stream playback works. -- MP4 direct stream playback works. -- Unsupported formats fail understandably. -- Fullscreen, rotation, pause/resume, and background/foreground behavior are - acceptable for v1. +1. Cold launch loads hosted Stremio Web. +2. Login completes and persists after app relaunch. +3. Catalog and library navigation work. +4. Addon install or configuration flows work, including redirects or popups. +5. HLS direct stream playback works. +6. MP4 direct stream playback works. +7. Debridio, Torrentio, and Real-Debrid MKV/AVI/WebM direct-file streams open + the native player before WebKit reaches its visible media failure state. +8. Closing the native player returns to the existing Stremio Web session. +9. DEBUG logs show sanitized stream classification and native player errors + without full debrid URLs, query strings, tokens, or long secret-like path + segments. Track playback results by device, iOS version, stream protocol, container, codec, subtitle type, HTTP status, and WebKit media error when available. diff --git a/docs/turns/2026-05-24-native-debrid-playback.html b/docs/turns/2026-05-24-native-debrid-playback.html new file mode 100644 index 0000000..02cf2e5 --- /dev/null +++ b/docs/turns/2026-05-24-native-debrid-playback.html @@ -0,0 +1,280 @@ + + + + + + Native Debrid Playback + + + +
+
+

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

+

Native Direct-Stream Playback for Debrid Files

+

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

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

Summary

+

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

+
+ +
+

Changes Made

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

Context

+

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

+
+ +
+

Important Implementation Details

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

Relevant Diff Snippets

+

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

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

Expected Impact for End-Users

+

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

+
+ +
+

Validation

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

Issues, Limitations, and Mitigations

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

Follow-up Work

+
    +
  • Install CocoaPods locally, run pod install, commit the resulting Podfile.lock and workspace metadata if appropriate.
  • +
  • Open Dreamio.xcworkspace in full Xcode and build on a real iOS device.
  • +
  • Validate sample Debridio, Torrentio, and Real-Debrid URLs, including HTTP 206 direct download responses.
  • +
  • Consider adding a tiny XCTest target for classifier behavior once the project has a test bundle.
  • +
+
+
+ + From b15e4d640efaf45d044570d735640af17f90fee7 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Sun, 24 May 2026 23:23:14 -0400 Subject: [PATCH 04/16] fix native playback build blockers --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 4 +-- Dreamio/VLCNativePlaybackBackend.swift | 17 ++++++++++++ .../2026-05-24-native-debrid-playback.html | 27 +++++++++++++++++++ 5 files changed, 48 insertions(+), 2 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 79cf19a..0ec7fb2 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -3,3 +3,4 @@ {"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."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index fdf5733..1c6ced4 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_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} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index b07e657..e7c8823 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -54,7 +54,7 @@ final class DreamioWebViewController: UIViewController { private var lastNativePlaybackURL: URL? private static let streamCandidateScript = WKUserScript( - source: """ + source: #""" (() => { if (window.__dreamioStreamBridgeInstalled) { return; @@ -181,7 +181,7 @@ final class DreamioWebViewController: UIViewController { inspectMedia(document); })(); - """, + """#, injectionTime: .atDocumentStart, forMainFrameOnly: false ) diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index 44ca5bc..2ed0132 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -1,24 +1,34 @@ import UIKit + +#if canImport(MobileVLCKit) import MobileVLCKit +#endif final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { let view = UIView() var onReady: (() -> Void)? var onFailure: ((Error) -> Void)? +#if canImport(MobileVLCKit) private let mediaPlayer = VLCMediaPlayer() +#endif override init() { super.init() +#if canImport(MobileVLCKit) mediaPlayer.delegate = self +#endif view.backgroundColor = .black } func prepare(in viewController: UIViewController) { +#if canImport(MobileVLCKit) mediaPlayer.drawable = view +#endif } func play(request: NativePlaybackRequest) { +#if canImport(MobileVLCKit) let media = VLCMedia(url: request.playbackURL) var headers = ["Referer": request.referer] if let userAgent = request.userAgent { @@ -36,14 +46,20 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { mediaPlayer.media = media mediaPlayer.play() +#else + onFailure?(NativePlaybackError.backendUnavailable) +#endif } func stop() { +#if canImport(MobileVLCKit) mediaPlayer.stop() mediaPlayer.media = nil +#endif } } +#if canImport(MobileVLCKit) extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { func mediaPlayerStateChanged(_ aNotification: Notification) { switch mediaPlayer.state { @@ -56,3 +72,4 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { } } } +#endif diff --git a/docs/turns/2026-05-24-native-debrid-playback.html b/docs/turns/2026-05-24-native-debrid-playback.html index 02cf2e5..45865d8 100644 --- a/docs/turns/2026-05-24-native-debrid-playback.html +++ b/docs/turns/2026-05-24-native-debrid-playback.html @@ -275,6 +275,33 @@ diff --git a/Podfile b/Podfile
  • Consider adding a tiny XCTest target for classifier behavior once the project has a test bundle.
  • + +
    +

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

    +

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

    +

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

    +

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

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

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

    +
    From d46004a98e354f413c36c965f0be147c5e24698f Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Sun, 24 May 2026 23:41:30 -0400 Subject: [PATCH 05/16] fix native playback stream resolution --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio.xcodeproj/project.pbxproj | 4 + Dreamio/DreamioWebViewController.swift | 71 ++++++- Dreamio/NativePlaybackBackend.swift | 6 + Dreamio/NativePlayerViewController.swift | 13 ++ Dreamio/StreamCandidate.swift | 27 ++- Dreamio/StreamResolver.swift | 167 +++++++++++++++ Dreamio/VLCNativePlaybackBackend.swift | 45 +++- Tests/StreamResolverTests.swift | 73 +++++++ ...-05-25-fix-native-playback-resolution.html | 196 ++++++++++++++++++ 11 files changed, 588 insertions(+), 16 deletions(-) create mode 100644 Dreamio/StreamResolver.swift create mode 100644 Tests/StreamResolverTests.swift create mode 100644 docs/turns/2026-05-25-fix-native-playback-resolution.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 0ec7fb2..6169034 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -4,3 +4,4 @@ {"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."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1c6ced4..a8d45a0 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_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} diff --git a/Dreamio.xcodeproj/project.pbxproj b/Dreamio.xcodeproj/project.pbxproj index 6736b6b..37a31d6 100644 --- a/Dreamio.xcodeproj/project.pbxproj +++ b/Dreamio.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 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 */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -26,6 +27,7 @@ 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlaybackBackend.swift; sourceTree = ""; }; 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCNativePlaybackBackend.swift; sourceTree = ""; }; 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; + 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -62,6 +64,7 @@ 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */, 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */, 6F2A2B462C00100100DREAMIO /* StreamCandidate.swift */, + 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */, 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */, 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */, 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */, @@ -142,6 +145,7 @@ 6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */, 6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */, 6F2A2B422C00100100DREAMIO /* StreamCandidate.swift in Sources */, + 6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */, 6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */, 6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */, 6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */, diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index e7c8823..6d5399e 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -52,6 +52,7 @@ final class DreamioWebViewController: UIViewController { private var progressObservation: NSKeyValueObservation? private var userAgent: String? private var lastNativePlaybackURL: URL? + private let streamResolver: StreamResolving = StremioStreamResolver() private static let streamCandidateScript = WKUserScript( source: #""" @@ -103,6 +104,7 @@ final class DreamioWebViewController: UIViewController { if (!looksNative(url)) { return; } + stopNativeHandledMedia(element); try { window.webkit.messageHandlers.dreamioStreamCandidate.postMessage({ url, @@ -114,6 +116,23 @@ final class DreamioWebViewController: UIViewController { } catch (_) {} }; + const stopNativeHandledMedia = (element) => { + const media = element instanceof HTMLVideoElement + ? element + : element && element.parentElement instanceof HTMLVideoElement + ? element.parentElement + : null; + if (!media) { + return; + } + try { media.pause(); } catch (_) {} + try { media.removeAttribute("src"); } catch (_) {} + try { + media.querySelectorAll("source").forEach((source) => source.removeAttribute("src")); + } catch (_) {} + try { media.load(); } catch (_) {} + }; + const inspectMedia = (node) => { if (!node) { return; @@ -331,21 +350,61 @@ final class DreamioWebViewController: UIViewController { return } - if lastNativePlaybackURL == request.playbackURL { + let duplicateKey = request.resolverURL ?? request.playbackURL + if lastNativePlaybackURL == duplicateKey { return } - lastNativePlaybackURL = request.playbackURL + 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 - let player = NativePlayerViewController(request: request) - player.onDismiss = { [weak self] in - self?.lastNativePlaybackURL = nil + Task { [weak self] in + await self?.resolveAndPresentNativePlayback(request) } - present(player, animated: true) + } + + @MainActor + private func resolveAndPresentNativePlayback(_ request: NativePlaybackRequest) async { + 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 + ) + let player = NativePlayerViewController(request: resolvedRequest) + player.onDismiss = { [weak self] in + self?.lastNativePlaybackURL = nil + } + 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) } #if DEBUG diff --git a/Dreamio/NativePlaybackBackend.swift b/Dreamio/NativePlaybackBackend.swift index 52f5d0f..6d44993 100644 --- a/Dreamio/NativePlaybackBackend.swift +++ b/Dreamio/NativePlaybackBackend.swift @@ -12,11 +12,17 @@ protocol NativePlaybackBackend: AnyObject { enum NativePlaybackError: LocalizedError { case backendUnavailable + case startupTimedOut + case playbackFailed var errorDescription: String? { switch self { case .backendUnavailable: return "Native playback is not available in this build." + case .startupTimedOut: + return "Native playback did not start before the timeout." + case .playbackFailed: + return "VLC reported a playback error for this stream." } } } diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index 58ea001..4f14c4c 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -3,6 +3,7 @@ import UIKit final class NativePlayerViewController: UIViewController { private let request: NativePlaybackRequest private var backend: NativePlaybackBackend + private var startupTimer: Timer? var onDismiss: (() -> Void)? private let loadingView: UIActivityIndicatorView = { @@ -66,11 +67,13 @@ final class NativePlayerViewController: UIViewController { view.backgroundColor = .black configureBackend() configureLayout() + startStartupTimer() backend.play(request: request) } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) + startupTimer?.invalidate() backend.stop() onDismiss?() } @@ -80,17 +83,27 @@ final class NativePlayerViewController: UIViewController { backend.view.translatesAutoresizingMaskIntoConstraints = false backend.onReady = { [weak self] in DispatchQueue.main.async { + self?.startupTimer?.invalidate() self?.loadingView.stopAnimating() self?.loadingView.isHidden = true } } backend.onFailure = { [weak self] error in DispatchQueue.main.async { + self?.startupTimer?.invalidate() self?.showFailure(error) } } } + private func startStartupTimer() { + startupTimer?.invalidate() + startupTimer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { [weak self] _ in + self?.backend.stop() + self?.showFailure(NativePlaybackError.startupTimedOut) + } + } + private func configureLayout() { view.addSubview(backend.view) view.addSubview(loadingView) diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index 917c6f4..5afb693 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -24,6 +24,7 @@ struct NativePlaybackRequest { let pageURL: URL? let userAgent: String? let referer: String + let headers: [String: String] let classification: StreamClassification } @@ -75,16 +76,40 @@ enum StreamClassifier { } return NativePlaybackRequest( - playbackURL: candidate.resolverURL ?? candidate.observedURL, + playbackURL: candidate.observedURL, observedURL: candidate.observedURL, resolverURL: candidate.resolverURL, pageURL: candidate.pageURL, userAgent: userAgent, referer: referer, + headers: Self.defaultHeaders(userAgent: userAgent), classification: classification ) } + static func defaultHeaders(userAgent: String?) -> [String: String] { + var headers = ["Referer": referer] + if let userAgent, !userAgent.isEmpty { + headers["User-Agent"] = userAgent + } + return headers + } + + static func isDirectPlayableFileURL(_ url: URL) -> Bool { + let container = containerGuess(for: url, resolverURL: nil) + return [.mp4, .mkv, .avi, .webm].contains(container) + } + + static func isWebKitCompatibleURL(_ url: URL) -> Bool { + let container = containerGuess(for: url, resolverURL: nil) + return container == .hls || container == .mp4 + } + + static func isKnownResolverURL(_ url: URL) -> Bool { + matches(url, host: "addon.debridio.com", pathPrefix: "/play/") + || matches(url, host: "torrentio.strem.fun", pathPrefix: "/resolve/") + } + static func classify(candidate: StreamCandidate) -> StreamClassification { let observed = candidate.observedURL let resolver = candidate.resolverURL diff --git a/Dreamio/StreamResolver.swift b/Dreamio/StreamResolver.swift new file mode 100644 index 0000000..1943dea --- /dev/null +++ b/Dreamio/StreamResolver.swift @@ -0,0 +1,167 @@ +import Foundation + +struct ResolvedNativeStream { + let playbackURL: URL + let headers: [String: String] + let source: String +} + +enum StreamResolverError: LocalizedError { + case noResolverURL + case httpStatus(Int) + case emptyResponse + case invalidResponse + case noPlayableStream + + var errorDescription: String? { + switch self { + case .noResolverURL: + return "Dreamio could not find an addon resolver URL for this stream." + case let .httpStatus(status): + return "The stream resolver returned HTTP \(status)." + case .emptyResponse: + return "The stream resolver returned an empty response." + case .invalidResponse: + return "The stream resolver returned data Dreamio could not parse." + case .noPlayableStream: + return "The resolver did not return a direct playable media URL." + } + } +} + +protocol StreamResolving { + func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream +} + +final class StremioStreamResolver: StreamResolving { + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func resolve(request: NativePlaybackRequest) async throws -> ResolvedNativeStream { + if StreamClassifier.isDirectPlayableFileURL(request.observedURL) { + return ResolvedNativeStream( + playbackURL: request.observedURL, + headers: request.headers, + source: "observed-direct-file" + ) + } + + let possibleResolverURL = request.resolverURL ?? request.observedURL + guard StreamClassifier.isKnownResolverURL(possibleResolverURL) else { + throw StreamResolverError.noResolverURL + } + + var urlRequest = URLRequest(url: possibleResolverURL) + urlRequest.setValue("application/json", forHTTPHeaderField: "Accept") + request.headers.forEach { key, value in + urlRequest.setValue(value, forHTTPHeaderField: key) + } + + let (data, response) = try await session.data(for: urlRequest) + if let httpResponse = response as? HTTPURLResponse, + !(200...299).contains(httpResponse.statusCode) { + throw StreamResolverError.httpStatus(httpResponse.statusCode) + } + if let finalURL = response.url, StreamClassifier.isDirectPlayableFileURL(finalURL) { + return ResolvedNativeStream( + playbackURL: finalURL, + headers: request.headers, + source: "resolver-redirect" + ) + } + guard !data.isEmpty else { + throw StreamResolverError.emptyResponse + } + + let payload = try parsePayload(from: data) + guard let stream = Self.bestPlayableStream(in: payload, fallbackHeaders: request.headers) else { + throw StreamResolverError.noPlayableStream + } + return stream + } + + private func parsePayload(from data: Data) throws -> Any { + do { + return try JSONSerialization.jsonObject(with: data) + } catch { + throw StreamResolverError.invalidResponse + } + } + + static func bestPlayableStream(in payload: Any, fallbackHeaders: [String: String]) -> ResolvedNativeStream? { + let streams = streamDictionaries(in: payload) + let candidates = streams.compactMap { stream -> ResolvedNativeStream? in + guard let url = directURL(in: stream) else { + return nil + } + guard StreamClassifier.isDirectPlayableFileURL(url) else { + return nil + } + return ResolvedNativeStream( + playbackURL: url, + headers: mergedHeaders(fallbackHeaders: fallbackHeaders, stream: stream), + source: "resolver-json" + ) + } + + return candidates.first { !StreamClassifier.isWebKitCompatibleURL($0.playbackURL) } ?? candidates.first + } + + private static func streamDictionaries(in payload: Any) -> [[String: Any]] { + if let dictionary = payload as? [String: Any], + let streams = dictionary["streams"] as? [[String: Any]] { + return streams + } + if let streams = payload as? [[String: Any]] { + return streams + } + return [] + } + + private static func directURL(in stream: [String: Any]) -> URL? { + let fields = ["url", "externalUrl", "externalURL", "file", "streamUrl", "streamURL"] + for field in fields { + if let value = stream[field] as? String, + let url = URL(string: value), + ["http", "https"].contains(url.scheme?.lowercased()) { + return url + } + } + return nil + } + + private static func mergedHeaders(fallbackHeaders: [String: String], stream: [String: Any]) -> [String: String] { + var headers = fallbackHeaders + headerDictionaries(in: stream).forEach { headerDictionary in + headerDictionary.forEach { key, value in + headers[key] = value + } + } + return headers + } + + private static func headerDictionaries(in stream: [String: Any]) -> [[String: String]] { + var dictionaries: [[String: String]] = [] + + if let headers = stream["headers"] as? [String: String] { + dictionaries.append(headers) + } + if let requestHeaders = stream["requestHeaders"] as? [String: String] { + dictionaries.append(requestHeaders) + } + if let behaviorHints = stream["behaviorHints"] as? [String: Any] { + if let headers = behaviorHints["headers"] as? [String: String] { + dictionaries.append(headers) + } + if let proxyHeaders = behaviorHints["proxyHeaders"] as? [String: Any], + let request = proxyHeaders["request"] as? [String: String] { + dictionaries.append(request) + } + } + + return dictionaries + } +} diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index 2ed0132..2390133 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -30,21 +30,21 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { func play(request: NativePlaybackRequest) { #if canImport(MobileVLCKit) let media = VLCMedia(url: request.playbackURL) - var headers = ["Referer": request.referer] - if let userAgent = request.userAgent { - headers["User-Agent"] = userAgent - } - - let headerValue = headers + 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)") } - media.addOption(":http-header=\(headerValue)") + 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() #else onFailure?(NativePlaybackError.backendUnavailable) @@ -54,6 +54,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { func stop() { #if canImport(MobileVLCKit) mediaPlayer.stop() + mediaPlayer.drawable = nil mediaPlayer.media = nil #endif } @@ -62,14 +63,40 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { #if canImport(MobileVLCKit) extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { func mediaPlayerStateChanged(_ aNotification: Notification) { +#if DEBUG + print("[DreamioVLC] state=\(stateName(mediaPlayer.state))") +#endif switch mediaPlayer.state { - case .opening, .buffering, .playing: + case .buffering, .playing: onReady?() case .error: - onFailure?(NativePlaybackError.backendUnavailable) + onFailure?(NativePlaybackError.playbackFailed) default: break } } + + private func stateName(_ state: VLCMediaPlayerState) -> String { + switch state { + case .opening: + return "opening" + case .buffering: + return "buffering" + case .playing: + return "playing" + case .ended: + return "ended" + case .stopped: + return "stopped" + case .error: + return "error" + case .paused: + return "paused" + case .esAdded: + return "elementary-stream-added" + @unknown default: + return "unknown" + } + } } #endif diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift new file mode 100644 index 0000000..a849ed8 --- /dev/null +++ b/Tests/StreamResolverTests.swift @@ -0,0 +1,73 @@ +import Foundation + +@main +struct StreamResolverTests { + static func main() { + testClassifierPrefersObservedDirectFile() + testResolverSelectsUnsupportedDirectURLAndHeaders() + testResolverRejectsHLSOnlyResponse() + 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 assertEqual(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) { + assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line) + } +} diff --git a/docs/turns/2026-05-25-fix-native-playback-resolution.html b/docs/turns/2026-05-25-fix-native-playback-resolution.html new file mode 100644 index 0000000..83fe380 --- /dev/null +++ b/docs/turns/2026-05-25-fix-native-playback-resolution.html @@ -0,0 +1,196 @@ + + + + + + Fix Native Playback Resolution + + + +
    +
    +

    Fix Native Playback Resolution

    +

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

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

    Summary

    +

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

    +
    + +
    +

    Changes Made

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

    Context

    +

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

    +

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

    +
    + +
    +

    Important Implementation Details

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

    Relevant Diff Snippets

    +

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

    +
    Dreamio/StreamCandidate.swift
    -1+26
    23 unmodified lines
    24
    25
    26
    27
    28
    29
    45 unmodified lines
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    23 unmodified lines
    let pageURL: URL?
    let userAgent: String?
    let referer: String
    let classification: StreamClassification
    }
    +
    45 unmodified lines
    }
    +
    return NativePlaybackRequest(
    playbackURL: candidate.resolverURL ?? candidate.observedURL,
    observedURL: candidate.observedURL,
    resolverURL: candidate.resolverURL,
    pageURL: candidate.pageURL,
    userAgent: userAgent,
    referer: referer,
    classification: classification
    )
    }
    +
    static func classify(candidate: StreamCandidate) -> StreamClassification {
    let observed = candidate.observedURL
    let resolver = candidate.resolverURL
    23 unmodified lines
    24
    25
    26
    27
    28
    29
    30
    45 unmodified lines
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    23 unmodified lines
    let pageURL: URL?
    let userAgent: String?
    let referer: String
    let headers: [String: String]
    let classification: StreamClassification
    }
    +
    45 unmodified lines
    }
    +
    return NativePlaybackRequest(
    playbackURL: candidate.observedURL,
    observedURL: candidate.observedURL,
    resolverURL: candidate.resolverURL,
    pageURL: candidate.pageURL,
    userAgent: userAgent,
    referer: referer,
    headers: Self.defaultHeaders(userAgent: userAgent),
    classification: classification
    )
    }
    +
    static func defaultHeaders(userAgent: String?) -> [String: String] {
    var headers = ["Referer": referer]
    if let userAgent, !userAgent.isEmpty {
    headers["User-Agent"] = userAgent
    }
    return headers
    }
    +
    static func isDirectPlayableFileURL(_ url: URL) -> Bool {
    let container = containerGuess(for: url, resolverURL: nil)
    return [.mp4, .mkv, .avi, .webm].contains(container)
    }
    +
    static func isWebKitCompatibleURL(_ url: URL) -> Bool {
    let container = containerGuess(for: url, resolverURL: nil)
    return container == .hls || container == .mp4
    }
    +
    static func isKnownResolverURL(_ url: URL) -> Bool {
    matches(url, host: "addon.debridio.com", pathPrefix: "/play/")
    || matches(url, host: "torrentio.strem.fun", pathPrefix: "/resolve/")
    }
    +
    static func classify(candidate: StreamCandidate) -> StreamClassification {
    let observed = candidate.observedURL
    let resolver = candidate.resolverURL
    +
    + +
    +

    Expected Impact for End-Users

    +

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

    +
    + +
    +

    Validation

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

    Issues, Limitations, and Mitigations

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

    Follow-up Work

    +
      +
    • Run pod install and build Dreamio.xcworkspace on a machine with CocoaPods and full Xcode.
    • +
    • Validate a real Debridio or Torrentio MKV selection on device and confirm logs show [DreamioStreamResolver] with a final direct URL.
    • +
    • Add mappings for any addon-specific stream fields discovered during device testing.
    • +
    +
    +
    + + \ No newline at end of file From 6e220e6df9539c8cfbb5a722e5c38e57a40366b5 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Sun, 24 May 2026 23:51:47 -0400 Subject: [PATCH 06/16] fix url redaction crash --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/StreamCandidate.swift | 4 ++-- Tests/StreamResolverTests.swift | 8 ++++++++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 6169034..9b76e6a 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -5,3 +5,4 @@ {"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"}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index a8d45a0..5f2e4d6 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_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} diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index 5afb693..279cf5f 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -197,8 +197,8 @@ enum URLRedactor { components.query = nil components.fragment = nil - if !components.percentEncodedPath.isEmpty { - components.percentEncodedPath = redactTokenLikePathSegments(in: components.percentEncodedPath) + if !components.path.isEmpty { + components.path = redactTokenLikePathSegments(in: components.path) } return redactTokenLikeFragments(in: components.string ?? value) } diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index a849ed8..101bbf4 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -6,6 +6,7 @@ struct StreamResolverTests { testClassifierPrefersObservedDirectFile() testResolverSelectsUnsupportedDirectURLAndHeaders() testResolverRejectsHLSOnlyResponse() + testRedactorHandlesPercentEncodedPath() print("StreamResolverTests passed") } @@ -67,6 +68,13 @@ struct StreamResolverTests { 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 assertEqual(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) { assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line) } From 511224bcd406bf35c575c1a6062f9d10811033c5 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 00:24:13 -0400 Subject: [PATCH 07/16] fix native playback cocoa pods build --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio.xcodeproj/project.pbxproj | 75 +++++- Dreamio.xcworkspace/contents.xcworkspacedata | 10 + Dreamio/DreamioWebViewController.swift | 16 ++ Dreamio/VLCNativePlaybackBackend.swift | 8 + Podfile.lock | 16 ++ README.md | 46 +++- ...25-guard-native-playback-availability.html | 218 ++++++++++++++++++ 9 files changed, 386 insertions(+), 5 deletions(-) create mode 100644 Dreamio.xcworkspace/contents.xcworkspacedata create mode 100644 Podfile.lock create mode 100644 docs/turns/2026-05-25-guard-native-playback-availability.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 9b76e6a..503e0cc 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -6,3 +6,4 @@ {"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."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 5f2e4d6..14a1d76 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_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} diff --git a/Dreamio.xcodeproj/project.pbxproj b/Dreamio.xcodeproj/project.pbxproj index 37a31d6..d006627 100644 --- a/Dreamio.xcodeproj/project.pbxproj +++ b/Dreamio.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 2771CB8C5035D4D119051FEA /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; }; 6F2A2B362C00100100DREAMIO /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B332C00100100DREAMIO /* AppDelegate.swift */; }; 6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */; }; 6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */; }; @@ -28,6 +29,9 @@ 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCNativePlaybackBackend.swift; sourceTree = ""; }; 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.swift; sourceTree = ""; }; + 701702B9C2BFBEDE36E7F0A3 /* Pods-Dreamio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.release.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.release.xcconfig"; sourceTree = ""; }; + 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Dreamio.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BF0A4D5BAC9400AEEF3B0181 /* Pods-Dreamio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.debug.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -35,17 +39,38 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 2771CB8C5035D4D119051FEA /* Pods_Dreamio.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 5DEC645FC7F60E33F3A4E21E /* Frameworks */ = { + isa = PBXGroup; + children = ( + 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 6593E172E04E344E08B5CAA8 /* Pods */ = { + isa = PBXGroup; + children = ( + BF0A4D5BAC9400AEEF3B0181 /* Pods-Dreamio.debug.xcconfig */, + 701702B9C2BFBEDE36E7F0A3 /* Pods-Dreamio.release.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 6F2A2B272C00100100DREAMIO = { isa = PBXGroup; children = ( 6F2A2B322C00100100DREAMIO /* Dreamio */, 6F2A2B312C00100100DREAMIO /* Products */, + 6593E172E04E344E08B5CAA8 /* Pods */, + 5DEC645FC7F60E33F3A4E21E /* Frameworks */, ); sourceTree = ""; }; @@ -80,9 +105,11 @@ isa = PBXNativeTarget; buildConfigurationList = 6F2A2B412C00100100DREAMIO /* Build configuration list for PBXNativeTarget "Dreamio" */; buildPhases = ( + 9F808EDAD2C69568A9142D10 /* [CP] Check Pods Manifest.lock */, 6F2A2B2C2C00100100DREAMIO /* Sources */, 6F2A2B2D2C00100100DREAMIO /* Frameworks */, 6F2A2B2E2C00100100DREAMIO /* Resources */, + F26EA81D312D2AA38B06CF11 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -136,6 +163,48 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 9F808EDAD2C69568A9142D10 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Dreamio-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + F26EA81D312D2AA38B06CF11 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Dreamio/Pods-Dreamio-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Dreamio/Pods-Dreamio-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Dreamio/Pods-Dreamio-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 6F2A2B2C2C00100100DREAMIO /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -191,7 +260,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -252,7 +321,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -272,6 +341,7 @@ }; 6F2A2B3E2C00100100DREAMIO /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = BF0A4D5BAC9400AEEF3B0181 /* Pods-Dreamio.debug.xcconfig */; buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -293,6 +363,7 @@ }; 6F2A2B3F2C00100100DREAMIO /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 701702B9C2BFBEDE36E7F0A3 /* Pods-Dreamio.release.xcconfig */; buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; diff --git a/Dreamio.xcworkspace/contents.xcworkspacedata b/Dreamio.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..312399b --- /dev/null +++ b/Dreamio.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index 6d5399e..97da38e 100644 --- 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) #if DEBUG @@ -407,6 +413,16 @@ final class DreamioWebViewController: UIViewController { 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) + } + #if DEBUG private func logDiagnostic(type: String, payload: Any, pageURL: String?) { let redactedPageURL = pageURL.map(redactedURLString) ?? "unknown" diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index 2390133..2d8e67a 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -5,6 +5,14 @@ import MobileVLCKit #endif final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { + static var isAvailable: Bool { +#if canImport(MobileVLCKit) + true +#else + false +#endif + } + let view = UIView() var onReady: (() -> Void)? var onFailure: ((Error) -> Void)? diff --git a/Podfile.lock b/Podfile.lock new file mode 100644 index 0000000..f409370 --- /dev/null +++ b/Podfile.lock @@ -0,0 +1,16 @@ +PODS: + - MobileVLCKit (3.7.3) + +DEPENDENCIES: + - MobileVLCKit + +SPEC REPOS: + trunk: + - MobileVLCKit + +SPEC CHECKSUMS: + MobileVLCKit: 73d7ddb52238b6885b70b0f281cae75a0a6e3ac0 + +PODFILE CHECKSUM: 1e4ca4475e4e798e59c235cee9233ad9691c3bee + +COCOAPODS: 1.16.2 diff --git a/README.md b/README.md index ddf7daf..750a4f9 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,51 @@ WebKit commonly cannot play, especially MKV, AVI, and WebM debrid URLs. Keep using `Dreamio.xcworkspace` after installing pods so Xcode links the native playback backend. +If the app says "Native playback needs CocoaPods" or a player screen says +"Native playback is not available in this build," the binary was built without +MobileVLCKit linked. To resolve it, install CocoaPods, run `pod install` from +this repository, open `Dreamio.xcworkspace` instead of `Dreamio.xcodeproj`, and +build the workspace. Direct MKV, AVI, and WebM playback depends on that +workspace build because the raw project intentionally keeps a fallback compile +path for environments where CocoaPods has not been installed yet. + +On macOS, install CocoaPods with RubyGems: + +```bash +sudo gem install cocoapods +pod --version +pod install +open Dreamio.xcworkspace +``` + +If the gem install fails because of a local Ruby or permissions issue, another +common macOS option is Homebrew: + +```bash +brew install cocoapods +pod --version +pod install +open Dreamio.xcworkspace +``` + +The official CocoaPods getting started guide documents the RubyGems install +path: https://guides.cocoapods.org/using/getting-started.html + ## Validation Notes -The repository machine currently has Command Line Tools selected instead of full -Xcode, and CocoaPods is not installed, so command-line `pod install` and -`xcodebuild` validation are not available here. +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 diff --git a/docs/turns/2026-05-25-guard-native-playback-availability.html b/docs/turns/2026-05-25-guard-native-playback-availability.html new file mode 100644 index 0000000..1c33230 --- /dev/null +++ b/docs/turns/2026-05-25-guard-native-playback-availability.html @@ -0,0 +1,218 @@ + + + + + + Guard Native Playback Availability + + + +
    +
    +

    Guard Native Playback Availability

    +

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

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

    Summary

    +

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

    +
    + +
    +

    Changes Made

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

    Context

    +

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

    +
    + +
    +

    Important Implementation Details

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

    Relevant Diff Snippets

    +

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

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

    Expected Impact for End-Users

    +

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

    +
    + +
    +

    Validation

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

    Issues, Limitations, and Mitigations

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

    Follow-up Work

    +
      +
    • On device, select a direct MKV, AVI, or WebM stream and confirm the VLC-backed player starts.
    • +
    +
    +
    + + From 75e76e14d41a2648f3f703cdd783c4e8314ae429 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 00:44:53 -0400 Subject: [PATCH 08/16] fix mobilevlckit project builds --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio.xcodeproj/project.pbxproj | 24 +++++++++++++++++++-- Podfile | 35 +++++++++++++++++++++++++++++++ Podfile.lock | 2 +- 5 files changed, 60 insertions(+), 3 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 503e0cc..81948ce 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -7,3 +7,4 @@ {"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."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 14a1d76..3b465b4 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_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} diff --git a/Dreamio.xcodeproj/project.pbxproj b/Dreamio.xcodeproj/project.pbxproj index d006627..af6a9dc 100644 --- a/Dreamio.xcodeproj/project.pbxproj +++ b/Dreamio.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 2771CB8C5035D4D119051FEA /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; }; 6F2A2B362C00100100DREAMIO /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B332C00100100DREAMIO /* AppDelegate.swift */; }; 6F2A2B372C00100100DREAMIO /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B342C00100100DREAMIO /* SceneDelegate.swift */; }; 6F2A2B382C00100100DREAMIO /* DreamioWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B352C00100100DREAMIO /* DreamioWebViewController.swift */; }; @@ -16,6 +15,7 @@ 6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */; }; 6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */; }; 6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */; }; + BA013CEC876B829A86AE8DCB /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -39,7 +39,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 2771CB8C5035D4D119051FEA /* Pods_Dreamio.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -106,6 +105,7 @@ buildConfigurationList = 6F2A2B412C00100100DREAMIO /* Build configuration list for PBXNativeTarget "Dreamio" */; buildPhases = ( 9F808EDAD2C69568A9142D10 /* [CP] Check Pods Manifest.lock */, + 6F2A2B512C00250100DREAMIO /* [CP] Prepare MobileVLCKit XCFramework */, 6F2A2B2C2C00100100DREAMIO /* Sources */, 6F2A2B2D2C00100100DREAMIO /* Frameworks */, 6F2A2B2E2C00100100DREAMIO /* Resources */, @@ -164,6 +164,26 @@ /* 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; diff --git a/Podfile b/Podfile index a89a361..10c813d 100644 --- a/Podfile +++ b/Podfile @@ -5,3 +5,38 @@ target 'Dreamio' do pod 'MobileVLCKit' end + +post_install do |_installer| + project = Xcodeproj::Project.open('Dreamio.xcodeproj') + target = project.targets.find { |candidate| candidate.name == 'Dreamio' } + next unless target + + target.frameworks_build_phase.files.delete_if do |build_file| + build_file.file_ref&.display_name == 'Pods_Dreamio.framework' + end + + phase_name = '[CP] Prepare MobileVLCKit XCFramework' + phase = target.shell_script_build_phases.find { |candidate| candidate.name == phase_name } + phase ||= target.new_shell_script_build_phase(phase_name) + phase.shell_script = <<~SH + if [ -x "${PODS_ROOT}/Target Support Files/MobileVLCKit/MobileVLCKit-xcframeworks.sh" ]; then + "${PODS_ROOT}/Target Support Files/MobileVLCKit/MobileVLCKit-xcframeworks.sh" + else + echo "error: MobileVLCKit is missing. Run 'pod install' and open Dreamio.xcworkspace, or rebuild after Pods are installed." >&2 + exit 1 + fi + SH + phase.input_file_list_paths = [ + '${PODS_ROOT}/Target Support Files/MobileVLCKit/MobileVLCKit-xcframeworks-input-files.xcfilelist' + ] + phase.output_file_list_paths = [] + + check_phase = target.shell_script_build_phases.find { |candidate| candidate.name == '[CP] Check Pods Manifest.lock' } + if check_phase + target.build_phases.delete(phase) + insert_index = target.build_phases.index(check_phase) + 1 + target.build_phases.insert(insert_index, phase) + end + + project.save +end diff --git a/Podfile.lock b/Podfile.lock index f409370..ad278f5 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -11,6 +11,6 @@ SPEC REPOS: SPEC CHECKSUMS: MobileVLCKit: 73d7ddb52238b6885b70b0f281cae75a0a6e3ac0 -PODFILE CHECKSUM: 1e4ca4475e4e798e59c235cee9233ad9691c3bee +PODFILE CHECKSUM: 5d4ff6c157e7ad147c7e642ebbe89238e6624e6b COCOAPODS: 1.16.2 From 419ffae415cb4f6b43cf45bcbb297eb55a17bb89 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 01:05:13 -0400 Subject: [PATCH 09/16] add native player controls and captions --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/DreamioWebViewController.swift | 135 +++++- Dreamio/NativePlaybackBackend.swift | 18 + Dreamio/NativePlayerViewController.swift | 230 ++++++++++- Dreamio/StreamCandidate.swift | 140 ++++++- Dreamio/VLCNativePlaybackBackend.swift | 145 +++++++ Tests/StreamResolverTests.swift | 45 ++ ...e-player-controls-captions-close-flow.html | 385 ++++++++++++++++++ 9 files changed, 1096 insertions(+), 4 deletions(-) create mode 100644 docs/turns/2026-05-25-native-player-controls-captions-close-flow.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 81948ce..e8fa5cb 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -8,3 +8,4 @@ {"id":"int-76aa54ba","kind":"field_change","created_at":"2026-05-25T03:51:39.198446Z","actor":"dirtydishes","issue_id":"dreamio-8vi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} {"id":"int-74805ffd","kind":"field_change","created_at":"2026-05-25T04:21:42.440755Z","actor":"dirtydishes","issue_id":"dreamio-2k5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added native backend availability guard, installed CocoaPods, generated workspace metadata, documented setup, and validated available checks."}} {"id":"int-27a61615","kind":"field_change","created_at":"2026-05-25T04:44:35.633997Z","actor":"dirtydishes","issue_id":"dreamio-ija","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed MobileVLCKit linker failures by preparing the XCFramework slice before app linking and preserving the integration through pod install."}} +{"id":"int-fad68cb4","kind":"field_change","created_at":"2026-05-25T05:04:55.103302Z","actor":"dirtydishes","issue_id":"dreamio-mj8","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented native VLC player controls, caption controls, subtitle candidate discovery, and close-flow cleanup."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3b465b4..dfac9eb 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -6,5 +6,6 @@ {"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-mj8","title":"Add native player controls and captions","description":"Implement a fuller VLC-backed native playback surface with transport controls, caption controls, external subtitle discovery, and a clean close flow back to Stremio episode selection.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:57:53Z","created_by":"dirtydishes","updated_at":"2026-05-25T05:04:55Z","started_at":"2026-05-25T04:57:57Z","closed_at":"2026-05-25T05:04:55Z","close_reason":"Implemented native VLC player controls, caption controls, subtitle candidate discovery, and close-flow cleanup.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-evt","title":"Enable WebView inspection and playback diagnostics","description":"Add development-only WKWebView inspection and token-safe playback diagnostics so Dreamio can debug hosted Stremio media failures without changing app navigation, login, or playback behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T02:30:26Z","created_by":"dirtydishes","updated_at":"2026-05-25T02:34:55Z","started_at":"2026-05-25T02:30:32Z","closed_at":"2026-05-25T02:34:55Z","close_reason":"Implemented debug-only WKWebView inspection, token-safe playback diagnostics, navigation logging, validation build, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-a5b","title":"Track HTML diff rendering tooling as dev dependency","description":"Move the HTML diff rendering package into devDependencies and ignore installed Node modules so the repo tracks reproducible tooling without vendoring dependencies.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:12:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:12:44Z","started_at":"2026-05-25T01:12:14Z","closed_at":"2026-05-25T01:12:44Z","close_reason":"Moved @pierre/diffs to devDependencies and ignored node_modules.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index 97da38e..06ecfe8 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -72,6 +72,8 @@ final class DreamioWebViewController: UIViewController { /\.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") { @@ -111,11 +113,72 @@ final class DreamioWebViewController: UIViewController { resolverUrl: findResolverURL(), pageUrl: window.location.href, tagName: element && element.tagName ? element.tagName : "", - currentSrc: element && element.currentSrc ? element.currentSrc : "" + currentSrc: element && element.currentSrc ? element.currentSrc : "", + subtitles: subtitleCandidates.slice(-20) }); } catch (_) {} }; + const addSubtitleCandidate = (entry) => { + const rawURL = typeof entry === "string" ? entry : entry && (entry.url || entry.href || entry.src || entry.file || entry.download); + const url = absoluteURL(rawURL); + if (!url || !subtitleURLPattern.test(url)) { + subtitleURLPattern.lastIndex = 0; + return; + } + subtitleURLPattern.lastIndex = 0; + if (subtitleCandidates.some((candidate) => candidate.url === url)) { + return; + } + subtitleCandidates.push({ + url, + label: entry && (entry.label || entry.name || entry.title || entry.lang || entry.language) || "External Subtitle", + language: entry && (entry.lang || entry.language) || "" + }); + }; + + const inspectSubtitlePayload = (payload) => { + if (!payload) { + return; + } + if (typeof payload === "string") { + const matches = payload.match(subtitleURLPattern) || []; + subtitleURLPattern.lastIndex = 0; + matches.forEach(addSubtitleCandidate); + try { + inspectSubtitlePayload(JSON.parse(payload)); + } catch (_) {} + return; + } + if (Array.isArray(payload)) { + payload.forEach(inspectSubtitlePayload); + return; + } + if (typeof payload === "object") { + addSubtitleCandidate(payload); + Object.values(payload).forEach(inspectSubtitlePayload); + } + }; + + const originalFetch = window.fetch; + if (originalFetch) { + window.fetch = async (...args) => { + const response = await originalFetch(...args); + try { + response.clone().text().then(inspectSubtitlePayload).catch(() => {}); + } catch (_) {} + return response; + }; + } + + const originalXHRSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.send = function(...args) { + try { + this.addEventListener("load", () => inspectSubtitlePayload(this.responseText)); + } catch (_) {} + return originalXHRSend.apply(this, args); + }; + const stopNativeHandledMedia = (element) => { const media = element instanceof HTMLVideoElement ? element @@ -387,11 +450,13 @@ final class DreamioWebViewController: UIViewController { userAgent: request.userAgent, referer: request.referer, headers: resolved.headers, - classification: request.classification + 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 { @@ -423,6 +488,72 @@ final class DreamioWebViewController: UIViewController { present(alert, animated: true) } + private func cleanUpStremioPlayerAfterNativeDismiss() { + let script = #""" + (() => { + const stopMedia = () => { + document.querySelectorAll("video, audio").forEach((media) => { + try { media.pause(); } catch (_) {} + try { media.removeAttribute("src"); } catch (_) {} + try { media.querySelectorAll("source").forEach((source) => source.removeAttribute("src")); } catch (_) {} + try { media.load(); } catch (_) {} + }); + }; + const clickVisible = (selectors) => { + for (const selector of selectors) { + const nodes = Array.from(document.querySelectorAll(selector)); + const match = nodes.find((node) => { + const style = window.getComputedStyle(node); + const rect = node.getBoundingClientRect(); + return style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0; + }); + if (match) { + try { match.click(); return true; } catch (_) {} + } + } + return false; + }; + stopMedia(); + const clicked = clickVisible([ + "[aria-label*='Close' i]", + "[aria-label*='Back' i]", + "button[class*='close' i]", + "button[class*='back' i]", + ".player button", + "[role='button']" + ]); + const stillPlayer = /player|stream|buffer|prepar/i.test(document.body.innerText || ""); + return { clicked, stillPlayer, href: window.location.href }; + })(); + """# + + webView.evaluateJavaScript(script) { [weak self] result, error in + guard let self else { + return + } +#if DEBUG + if let error { + print("[DreamioCloseFlow] cleanup error=\(URLRedactor.redactedURLString(error.localizedDescription))") + } else { + print("[DreamioCloseFlow] cleanup result=\(String(describing: result))") + } +#endif + guard error == nil else { + self.loadDreamio() + return + } + if self.webView.canGoBack { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + self.webView.evaluateJavaScript("(/player|stream|buffer|prepar/i).test(document.body.innerText || '')") { result, _ in + if (result as? Bool) == true { + self.webView.goBack() + } + } + } + } + } + } + #if DEBUG private func logDiagnostic(type: String, payload: Any, pageURL: String?) { let redactedPageURL = pageURL.map(redactedURLString) ?? "unknown" diff --git a/Dreamio/NativePlaybackBackend.swift b/Dreamio/NativePlaybackBackend.swift index 6d44993..57ec708 100644 --- a/Dreamio/NativePlaybackBackend.swift +++ b/Dreamio/NativePlaybackBackend.swift @@ -4,9 +4,27 @@ 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() } diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index 4f14c4c..54de22d 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -4,6 +4,9 @@ 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 = { @@ -25,6 +28,49 @@ final class NativePlayerViewController: UIViewController { return button }() + private let controlsContainer: UIVisualEffectView = { + let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark)) + view.translatesAutoresizingMaskIntoConstraints = false + view.layer.cornerRadius = 12 + view.clipsToBounds = true + return view + }() + + private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause") + private let jumpBackButton = NativePlayerViewController.iconButton(systemName: "gobackward.15", label: "Jump Back 15 Seconds") + private let jumpForwardButton = NativePlayerViewController.iconButton(systemName: "goforward.15", label: "Jump Forward 15 Seconds") + private let captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Captions") + + private let elapsedLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .white + label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium) + label.text = "0:00" + return label + }() + + private let remainingLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = .white + label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium) + label.textAlignment = .right + label.text = "-0:00" + return label + }() + + private let scrubber: UISlider = { + let slider = UISlider() + slider.translatesAutoresizingMaskIntoConstraints = false + slider.minimumValue = 0 + slider.maximumValue = 1 + slider.minimumTrackTintColor = UIColor(red: 0.64, green: 0.48, blue: 1.0, alpha: 1) + slider.maximumTrackTintColor = UIColor.white.withAlphaComponent(0.3) + slider.thumbTintColor = .white + return slider + }() + private let failureLabel: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false @@ -74,6 +120,8 @@ final class NativePlayerViewController: UIViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) startupTimer?.invalidate() + controlsTimer?.invalidate() + progressTimer?.invalidate() backend.stop() onDismiss?() } @@ -86,6 +134,9 @@ final class NativePlayerViewController: UIViewController { self?.startupTimer?.invalidate() self?.loadingView.stopAnimating() self?.loadingView.isHidden = true + self?.startProgressUpdates() + self?.refreshControls() + self?.scheduleControlsHide() } } backend.onFailure = { [weak self] error in @@ -94,6 +145,16 @@ final class NativePlayerViewController: UIViewController { 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() { @@ -108,8 +169,38 @@ final class NativePlayerViewController: UIViewController { view.addSubview(backend.view) view.addSubview(loadingView) view.addSubview(failureLabel) + view.addSubview(controlsContainer) view.addSubview(closeButton) closeButton.addTarget(self, action: #selector(close), for: .touchUpInside) + playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside) + jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside) + jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside) + captionsButton.addTarget(self, action: #selector(showCaptions), for: .touchUpInside) + scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown) + scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged) + scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel]) + + let tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility)) + tap.cancelsTouchesInView = false + view.addGestureRecognizer(tap) + + let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton]) + controlRow.translatesAutoresizingMaskIntoConstraints = false + controlRow.axis = .horizontal + controlRow.alignment = .center + controlRow.distribution = .equalCentering + controlRow.spacing = 18 + + let timeRow = UIStackView(arrangedSubviews: [elapsedLabel, remainingLabel]) + timeRow.translatesAutoresizingMaskIntoConstraints = false + timeRow.axis = .horizontal + timeRow.distribution = .fillEqually + + let stack = UIStackView(arrangedSubviews: [scrubber, timeRow, controlRow]) + stack.translatesAutoresizingMaskIntoConstraints = false + stack.axis = .vertical + stack.spacing = 8 + controlsContainer.contentView.addSubview(stack) NSLayoutConstraint.activate([ backend.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -127,7 +218,25 @@ final class NativePlayerViewController: UIViewController { closeButton.widthAnchor.constraint(equalToConstant: 44), closeButton.heightAnchor.constraint(equalToConstant: 44), closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), - closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12) + closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12), + + controlsContainer.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 18), + controlsContainer.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -18), + controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18), + + stack.leadingAnchor.constraint(equalTo: controlsContainer.contentView.leadingAnchor, constant: 16), + stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -16), + stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 14), + stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -14), + + jumpBackButton.widthAnchor.constraint(equalToConstant: 44), + jumpBackButton.heightAnchor.constraint(equalToConstant: 44), + playPauseButton.widthAnchor.constraint(equalToConstant: 54), + playPauseButton.heightAnchor.constraint(equalToConstant: 54), + jumpForwardButton.widthAnchor.constraint(equalToConstant: 44), + jumpForwardButton.heightAnchor.constraint(equalToConstant: 44), + captionsButton.widthAnchor.constraint(equalToConstant: 44), + captionsButton.heightAnchor.constraint(equalToConstant: 44) ]) } @@ -144,4 +253,123 @@ final class NativePlayerViewController: UIViewController { @objc private func close() { dismiss(animated: true) } + + @objc private func togglePlayPause() { + backend.togglePlayPause() + revealControls() + } + + @objc private func jumpBack() { + backend.jump(by: -15) + revealControls() + } + + @objc private func jumpForward() { + backend.jump(by: 15) + revealControls() + } + + @objc private func scrubbingStarted() { + isScrubbing = true + controlsTimer?.invalidate() + } + + @objc private func scrubberChanged() { + elapsedLabel.text = PlaybackTimeFormatter.label(for: TimeInterval(scrubber.value) * backend.duration) + } + + @objc private func scrubbingEnded() { + backend.seek(to: scrubber.value) + isScrubbing = false + revealControls() + } + + @objc private func toggleControlsVisibility() { + if controlsContainer.alpha < 1 { + revealControls() + } else if backend.isPlaying { + hideControls() + } + } + + @objc private func showCaptions() { + revealControls() + let alert = UIAlertController(title: "Captions", message: nil, preferredStyle: .actionSheet) + SubtitleOptionMapper.options(from: backend.subtitleTracks).forEach { track in + let prefix = track.id == backend.selectedSubtitleTrackID ? "Selected: " : "" + alert.addAction(UIAlertAction(title: "\(prefix)\(track.name)", style: .default) { [weak self] _ in + self?.backend.selectSubtitleTrack(id: track.id) + }) + } + alert.addAction(UIAlertAction(title: "Delay -0.5s", style: .default) { [weak self] _ in + self?.backend.adjustSubtitleDelay(by: -0.5) + }) + alert.addAction(UIAlertAction(title: "Delay +0.5s", style: .default) { [weak self] _ in + self?.backend.adjustSubtitleDelay(by: 0.5) + }) + alert.addAction(UIAlertAction(title: "Current Delay: \(String(format: "%.1fs", backend.subtitleDelay))", style: .default)) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + if let popover = alert.popoverPresentationController { + popover.sourceView = captionsButton + popover.sourceRect = captionsButton.bounds + } + present(alert, animated: true) + } + + private func startProgressUpdates() { + progressTimer?.invalidate() + progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + self?.refreshControls() + } + } + + private func refreshControls() { + playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal) + scrubber.isEnabled = backend.isSeekable + jumpBackButton.isEnabled = backend.isSeekable + jumpForwardButton.isEnabled = backend.isSeekable + captionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmpty + elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime) + remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))" + if !isScrubbing { + scrubber.value = backend.position + } + [scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = backend.isSeekable ? 1 : 0.45 } + } + + private func revealControls() { + UIView.animate(withDuration: 0.18) { + self.controlsContainer.alpha = 1 + self.closeButton.alpha = 1 + } + scheduleControlsHide() + } + + private func hideControls() { + UIView.animate(withDuration: 0.24) { + self.controlsContainer.alpha = 0 + self.closeButton.alpha = 0 + } + } + + private func scheduleControlsHide() { + controlsTimer?.invalidate() + guard backend.isPlaying else { + return + } + controlsTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in + self?.hideControls() + } + } + + private static func iconButton(systemName: String, label: String) -> UIButton { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(UIImage(systemName: systemName), for: .normal) + button.tintColor = .white + button.backgroundColor = UIColor.black.withAlphaComponent(0.35) + button.layer.cornerRadius = 22 + button.accessibilityLabel = label + return button + } } diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index 279cf5f..11ab6b3 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -26,6 +26,44 @@ struct NativePlaybackRequest { let referer: String let headers: [String: String] let classification: StreamClassification + let subtitleCandidates: [SubtitleCandidate] +} + +struct SubtitleCandidate: Equatable { + let url: URL + let label: String + let language: String? +} + +struct SubtitleTrack: Equatable { + let id: Int32 + let name: String +} + +enum PlaybackTimeFormatter { + static func label(for seconds: TimeInterval) -> String { + guard seconds.isFinite, seconds > 0 else { + return "0:00" + } + + let roundedSeconds = Int(seconds.rounded()) + let hours = roundedSeconds / 3600 + let minutes = (roundedSeconds % 3600) / 60 + let seconds = roundedSeconds % 60 + + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } + return String(format: "%d:%02d", minutes, seconds) + } +} + +enum SubtitleOptionMapper { + static let offTrack = SubtitleTrack(id: -1, name: "Off") + + static func options(from tracks: [SubtitleTrack]) -> [SubtitleTrack] { + [offTrack] + tracks.filter { $0.id >= 0 } + } } struct StreamClassification { @@ -41,6 +79,7 @@ struct StreamCandidate { let observedURL: URL let resolverURL: URL? let pageURL: URL? + let subtitleCandidates: [SubtitleCandidate] init?(messageBody: Any) { guard let body = messageBody as? [String: Any], @@ -52,6 +91,7 @@ struct StreamCandidate { 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? { @@ -63,6 +103,103 @@ struct StreamCandidate { } } +enum SubtitleCandidateParser { + private static let supportedExtensions = ["srt", "vtt", "ass", "ssa", "sub"] + private static let urlFields = ["url", "href", "src", "subtitles", "subtitle", "subtitleUrl", "subtitleURL", "file", "download"] + private static let labelFields = ["label", "name", "title", "lang", "language", "id"] + + static func candidates(in payload: Any?) -> [SubtitleCandidate] { + var results: [SubtitleCandidate] = [] + collect(from: payload, into: &results) + + var seen = Set() + return results.filter { candidate in + let key = candidate.url.absoluteString + guard !seen.contains(key) else { + return false + } + seen.insert(key) + return true + } + } + + private static func collect(from value: Any?, into results: inout [SubtitleCandidate]) { + switch value { + case let dictionary as [String: Any]: + if let candidate = candidate(from: dictionary) { + results.append(candidate) + } + dictionary.values.forEach { collect(from: $0, into: &results) } + case let array as [Any]: + array.forEach { collect(from: $0, into: &results) } + case let string as String: + if let url = subtitleURL(from: string) { + results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil)) + } else { + extractSubtitleURLs(from: string).forEach { url in + results.append(SubtitleCandidate(url: url, label: defaultLabel(for: url), language: nil)) + } + } + default: + break + } + } + + private static func candidate(from dictionary: [String: Any]) -> SubtitleCandidate? { + guard let url = urlFields.lazy.compactMap({ subtitleURL(from: dictionary[$0] as? String) }).first else { + return nil + } + + let label = labelFields.lazy.compactMap { dictionary[$0] as? String }.first + let language = (dictionary["lang"] as? String) ?? (dictionary["language"] as? String) + return SubtitleCandidate( + url: url, + label: label?.isEmpty == false ? label! : defaultLabel(for: url), + language: language + ) + } + + private static func subtitleURL(from string: String?) -> URL? { + guard let string, + let url = URL(string: string), + ["http", "https"].contains(url.scheme?.lowercased()) + else { + return nil + } + + let lowercased = url.absoluteString.lowercased() + guard supportedExtensions.contains(url.pathExtension.lowercased()) + || supportedExtensions.contains(where: { lowercased.contains(".\($0)?") || lowercased.contains(".\($0)&") }) + || lowercased.contains("subtitle") + || lowercased.contains("opensubtitles") + else { + return nil + } + + return url + } + + private static func defaultLabel(for url: URL) -> String { + let lastPathComponent = url.deletingPathExtension().lastPathComponent + return lastPathComponent.isEmpty ? "External Subtitle" : lastPathComponent + } + + private static func extractSubtitleURLs(from string: String) -> [URL] { + let pattern = #"https?://[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*"# + let range = NSRange(string.startIndex.. Void)? var onFailure: ((Error) -> Void)? + var onStateChange: (() -> Void)? + var onSubtitleTracksChange: (() -> Void)? #if canImport(MobileVLCKit) private let mediaPlayer = VLCMediaPlayer() #endif + private var attachedSubtitleURLs = Set() override init() { super.init() @@ -54,11 +57,61 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { 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() @@ -66,6 +119,93 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { mediaPlayer.media = nil #endif } + + var isPlaying: Bool { +#if canImport(MobileVLCKit) + mediaPlayer.isPlaying +#else + false +#endif + } + + var isSeekable: Bool { +#if canImport(MobileVLCKit) + mediaPlayer.isSeekable +#else + false +#endif + } + + var duration: TimeInterval { +#if canImport(MobileVLCKit) + TimeInterval(max(0, mediaPlayer.media?.length.intValue ?? 0)) / 1000 +#else + 0 +#endif + } + + var currentTime: TimeInterval { +#if canImport(MobileVLCKit) + TimeInterval(max(0, mediaPlayer.time.intValue)) / 1000 +#else + 0 +#endif + } + + var remainingTime: TimeInterval { + max(0, duration - currentTime) + } + + var position: Float { +#if canImport(MobileVLCKit) + mediaPlayer.position +#else + 0 +#endif + } + + var subtitleTracks: [SubtitleTrack] { +#if canImport(MobileVLCKit) + let names = mediaPlayer.videoSubTitlesNames as? [String] ?? [] + let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? [] + return zip(indexes, names).map { index, name in + SubtitleTrack(id: index.int32Value, name: name) + } +#else + [] +#endif + } + + var selectedSubtitleTrackID: Int32 { +#if canImport(MobileVLCKit) + mediaPlayer.currentVideoSubTitleIndex +#else + -1 +#endif + } + + var subtitleDelay: TimeInterval { +#if canImport(MobileVLCKit) + TimeInterval(mediaPlayer.currentVideoSubTitleDelay) / 1_000_000 +#else + 0 +#endif + } + +#if canImport(MobileVLCKit) + private func attachSubtitles(_ candidates: [SubtitleCandidate]) { + candidates.forEach { candidate in + guard !attachedSubtitleURLs.contains(candidate.url) else { + return + } + attachedSubtitleURLs.insert(candidate.url) + mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false) +#if DEBUG + print("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))") +#endif + } + } +#endif } #if canImport(MobileVLCKit) @@ -77,8 +217,13 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate { switch mediaPlayer.state { case .buffering, .playing: onReady?() + onStateChange?() case .error: onFailure?(NativePlaybackError.playbackFailed) + case .paused, .stopped, .ended: + onStateChange?() + case .esAdded: + onSubtitleTracksChange?() default: break } diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index 101bbf4..6cc5573 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -7,6 +7,9 @@ struct StreamResolverTests { testResolverSelectsUnsupportedDirectURLAndHeaders() testResolverRejectsHLSOnlyResponse() testRedactorHandlesPercentEncodedPath() + testPlaybackTimeFormatting() + testSubtitleCandidateParsing() + testSubtitleOptionMappingIncludesOff() print("StreamResolverTests passed") } @@ -75,6 +78,48 @@ struct StreamResolverTests { assertEqual(redacted, "https://cdn.example.test/video/%5Bredacted%5D/%E2%9C%93.mp4") } + private static func testPlaybackTimeFormatting() { + assertEqual(PlaybackTimeFormatter.label(for: 0), "0:00") + assertEqual(PlaybackTimeFormatter.label(for: 65), "1:05") + assertEqual(PlaybackTimeFormatter.label(for: 3_725), "1:02:05") + } + + private static func testSubtitleCandidateParsing() { + let payload: [String: Any] = [ + "subtitles": [ + [ + "lang": "eng", + "url": "https://opensubtitles.example.test/download/subtitle.srt?token=secret" + ], + [ + "language": "Spanish", + "file": "https://cdn.example.test/movie.es.vtt" + ], + "https://cdn.example.test/ignored.txt" + ], + "nested": [ + "body": "metadata https://cdn.example.test/movie.fr.ass?download=1" + ] + ] + + let candidates = SubtitleCandidateParser.candidates(in: payload) + + assertEqual(candidates.count, 3) + assertEqual(candidates[0].language, "eng") + assertEqual(candidates[1].label, "Spanish") + assertEqual(candidates[2].url.absoluteString, "https://cdn.example.test/movie.fr.ass?download=1") + } + + private static func testSubtitleOptionMappingIncludesOff() { + let options = SubtitleOptionMapper.options(from: [ + SubtitleTrack(id: 2, name: "English"), + SubtitleTrack(id: 5, name: "Spanish") + ]) + + assertEqual(options.map(\.name), ["Off", "English", "Spanish"]) + assertEqual(options.first?.id, -1) + } + private static func assertEqual(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) { assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line) } diff --git a/docs/turns/2026-05-25-native-player-controls-captions-close-flow.html b/docs/turns/2026-05-25-native-player-controls-captions-close-flow.html new file mode 100644 index 0000000..14256ed --- /dev/null +++ b/docs/turns/2026-05-25-native-player-controls-captions-close-flow.html @@ -0,0 +1,385 @@ + + + + + + Native Player Controls, Captions, and Close Flow + + + +
    +
    +

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

    +

    Native Player Controls, Captions, and Close Flow

    +

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

    +
    + +
    +

    Summary

    +

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

    +
    + +
    +

    Changes Made

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

    Context

    +

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

    +

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

    +
    + +
    +

    Important Implementation Details

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

    Relevant Diff Snippets

    +

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

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

    Expected Impact for End-Users

    +

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

    +
    + +
    +

    Validation

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

    Issues, Limitations, and Mitigations

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

    Follow-up Work

    +
      +
    • Validate embedded and external subtitles on a real device with representative Stremio and OpenSubtitles addons.
    • +
    • Consider a richer caption settings panel if users need style controls or exact delay entry.
    • +
    • Add a UI test harness or injectable mock backend for exercising native player overlay behavior without MobileVLCKit.
    • +
    +
    +
    + + From 8d4dd0870a9d237f01b2b8f2dddf68c47a09fb67 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 05:20:27 -0400 Subject: [PATCH 10/16] sync agent instructions --- AGENTS.md | 9 ++- CLAUDE.md | 191 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 183 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dd7b6e2..633878c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ Use this decision order before creating a turn document: The minor/trivial exemptions override the general mandatory turn-document rule. -For diff content in turn documentation (including "Code diffs" and "Relevant Diff Snippets"), use `@pierre/diffs` output by default. If `@pierre/diffs` is unavailable because of a real tool or blocking error, use a clearly labeled plain diff/code block fallback and note why. +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 @@ -132,7 +132,7 @@ If a change does not cleanly fit either exempt or substantive buckets, ask the u **"New Changes as of {time and date at which the change was made}"** - **Summary of changes** - **Why this change was made** -- **Code diffs** (use `@pierre/diffs` output by default; if unavailable, include a clearly labeled plain diff/code block and note why) +- **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. @@ -181,7 +181,7 @@ Each turn document must include these sections: 2. **Changes Made** 3. **Context** 4. **Important Implementation Details** -5. **Relevant Diff Snippets** (render with `@pierre/diffs` output by default; if unavailable, include a clearly labeled plain diff/code block and note why) +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** @@ -196,7 +196,7 @@ A task that requires a turn document is not complete until: 3. Relevant quality gates have passed or failures are documented 4. Changes are committed 5. `bd dolt push` succeeds -6. `git push forgejo ` succeeds +6. `git push` succeeds 7. `git status` shows the branch is up to date with `forgejo/` For tasks that do require turn documentation, the document may be brief when scope is small, but it must clearly explain what changed and how it was validated. @@ -235,4 +235,3 @@ Always do the following when you finish a task, finish the beads workflow and an - Create a clear, concise summary of the changes at the top, followed by a detailed description of the changes, including any relevant context or background as well as specific code snippets or examples. - Note any relevant issues or limitations that were addressed or mitigated by the changes. - The HTML file should be stored in the `docs/turns` directory. It should include the current date and time, as well as a brief explanation of changes. e.g. docs/turns/YYYY-MM-DD-{description}.html - diff --git a/CLAUDE.md b/CLAUDE.md index cd553b9..633878c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,51 @@ -# Project Instructions for AI Agents +# Agent Instructions -This file provides instructions and context for AI coding agents working on this project. +This project uses **bd** (beads) for issue tracking. Run `bd prime` for full workflow context. + +> **Architecture in one line:** Issues live in a local Dolt database +> (`.beads/dolt/`); cross-machine sync uses `bd dolt push/pull` (a +> git-compatible protocol), stored under `refs/dolt/data` on your git +> remote — separate from `refs/heads/*` where your code lives. +> `.beads/issues.jsonl` is a passive export, not the wire protocol. +> +> See [SYNC_CONCEPTS.md](https://github.com/gastownhall/beads/blob/main/docs/SYNC_CONCEPTS.md) +> for the one-screen overview and anti-patterns (don't treat JSONL as the +> source of truth; don't `bd import` during normal operation; don't +> reach for third-party Dolt hosting before trying the default). + +## Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work atomically +bd close # Complete work +bd dolt push # Push beads data to remote +``` + +## Non-Interactive Shell Commands + +**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts. + +Shell commands like `cp`, `mv`, and `rm` may be aliased to include `-i` (interactive) mode on some systems, causing the agent to hang indefinitely waiting for y/n input. + +**Use these forms instead:** +```bash +# Force overwrite without prompting +cp -f source dest # NOT: cp source dest +mv -f source dest # NOT: mv source dest +rm -f file # NOT: rm file + +# For recursive operations +rm -rf directory # NOT: rm -r directory +cp -rf source dest # NOT: cp -r source dest +``` + +**Other commands that may prompt:** +- `scp` - use `-o BatchMode=yes` for non-interactive +- `ssh` - use `-o BatchMode=yes` to fail instead of prompting +- `apt-get` - use `-y` flag +- `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var ## Beads Issue Tracker @@ -50,21 +95,143 @@ bd close # Complete work - If push fails, resolve and retry until it succeeds +## Required Turn Documentation -## Build & Test +At the end of every completed implementation task, before final handoff, create a user-readable HTML document describing the work. -_Add your build and test commands here_ +This documentation is mandatory whenever code, configuration, tests, or project files were changed. -```bash -# Example: -# npm install -# npm test +### Precedence and classification + +Use this decision order before creating a turn document: + +1. Check the minor/trivial exemption checklist below first. +2. If the task clearly matches an exemption, do not create a turn document. +3. If the task is a clearly substantive implementation change, create a turn document. +4. If classification is ambiguous or mixed, ask the user before creating a turn document. + +The minor/trivial exemptions override the general mandatory turn-document rule. + +For diff content in turn documentation (including "Code diffs" and "Relevant Diff Snippets"), use `@pierre/diffs` output by default. Do not run `npx @pierre/diffs`; the package is a rendering library and does not expose a CLI executable. Generate rendered diff HTML with `@pierre/diffs/ssr`, usually `preloadPatchDiff`, and insert that rendered output into the turn document. `preloadPatchDiff` expects exactly one file diff per call, so split multi-file diffs into one patch per file and concatenate the rendered HTML. If `@pierre/diffs/ssr` is unavailable because of a real tool or blocking error, use a clearly labeled plain diff/code block fallback and note why. + +### No turn document for minor/trivial checklist matches + +Do not create a turn document when the change is minor/trivial and cleanly matches one of these categories: + +- `AGENTS.md` changes or other documentation-only changes +- Syntax-only fixes +- Refactor-only changes with no behavior change +- PR/conflict reconciliation work +- Issue-tracker-only updates such as `beads/issues.json` +- Support-file changes that only accompany one of the exempt categories above (for example lockfile or manifest updates required for docs-workflow changes) + +If a change does not cleanly fit either exempt or substantive buckets, ask the user before creating a turn document. + +### When making a minor update to a previous change, update the existing documentation instead of creating a new file. Use the following format: + +**"New Changes as of {time and date at which the change was made}"** +- **Summary of changes** +- **Why this change was made** +- **Code diffs** (use rendered `@pierre/diffs/ssr` output by default; do not use `npx @pierre/diffs`; if unavailable, include a clearly labeled plain diff/code block and note why) +- **Related issues or PRs** + +Additionally, add a note to each section explaining why the changes were made. + +### Location + +Save the document in: + +```text +docs/turns/ ``` -## Architecture Overview +Use a clear timestamped filename: -_Add a brief overview of your project architecture_ +```text +docs/turns/YYYY-MM-DD-short-task-name.html +``` -## Conventions & Patterns +Example: -_Add your project-specific conventions here_ +```text +docs/turns/2026-05-14-add-market-replay-controls.html +``` + +### Format + +Use the `impeccable` skill to structure and style the document as clean, readable HTML. + +For this repository, `impeccable` is the styling and layout authority for turn documents when available. Do not apply global non-repo computer-task house styling to repository turn documents. + +If the `impeccable` skill is unavailable or blocked by an actual tool/file error, still create a well-structured standalone HTML file with: + +- A concise summary at the top +- A detailed explanation of what changed +- Relevant context or background +- Specific code snippets or examples when helpful +- Issues, limitations, tradeoffs, or mitigations +- Validation performed, including tests, builds, linters, or manual checks +- Any remaining follow-up work, with corresponding Beads issue IDs when applicable + +### Required Sections + +Each turn document must include these sections: + +1. **Summary** +2. **Changes Made** +3. **Context** +4. **Important Implementation Details** +5. **Relevant Diff Snippets** (render with `@pierre/diffs/ssr` output by default; do not use `npx @pierre/diffs`; if unavailable, include a clearly labeled plain diff/code block and note why) +6. **Expected Impact for End-Users** +7. **Validation** +8. **Issues, Limitations, and Mitigations** +9. **Follow-up Work** + +### Completion Rule + +A task that requires a turn document is not complete until: + +1. The Beads workflow is updated +2. The turn document is created in `docs/turns` +3. Relevant quality gates have passed or failures are documented +4. Changes are committed +5. `bd dolt push` succeeds +6. `git push` succeeds +7. `git status` shows the branch is up to date with `forgejo/` + +For tasks that do require turn documentation, the document may be brief when scope is small, but it must clearly explain what changed and how it was validated. + +## Plan Mode Documentation + +When working in plan mode, do not modify implementation files. + +At the end of plan mode, provide a concise summary of the plan and ask the user whether they want to proceed with implementation. + +If the user asks to save the plan, create a user-readable HTML plan document in: + +```text +docs/plans/ +``` + +Use a clear timestamped filename: + +```text +docs/plans/YYYY-MM-DD-short-plan-name.html +``` + +The plan document should be labeled clearly as a plan and should include: + +1. **Plan Summary** +2. **Goals** +3. **Proposed Changes** +4. **Relevant Context** +5. **Implementation Steps** +6. **Risks, Limitations, and Mitigations** +7. **Open Questions** + +Always do the following when you finish a task, finish the beads workflow and and make a commit: +- Document the changes in a user-readable format +- Use the impeccable skill to structure the document as HTML +- Create a clear, concise summary of the changes at the top, followed by a detailed description of the changes, including any relevant context or background as well as specific code snippets or examples. +- Note any relevant issues or limitations that were addressed or mitigated by the changes. +- The HTML file should be stored in the `docs/turns` directory. It should include the current date and time, as well as a brief explanation of changes. e.g. docs/turns/YYYY-MM-DD-{description}.html From 51be00f8e8dcdc22fe72dc1e6552beec620290de Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 05:20:27 -0400 Subject: [PATCH 11/16] sync agent instructions --- AGENTS.md | 9 ++- CLAUDE.md | 191 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 183 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dd7b6e2..633878c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ Use this decision order before creating a turn document: The minor/trivial exemptions override the general mandatory turn-document rule. -For diff content in turn documentation (including "Code diffs" and "Relevant Diff Snippets"), use `@pierre/diffs` output by default. If `@pierre/diffs` is unavailable because of a real tool or blocking error, use a clearly labeled plain diff/code block fallback and note why. +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 @@ -132,7 +132,7 @@ If a change does not cleanly fit either exempt or substantive buckets, ask the u **"New Changes as of {time and date at which the change was made}"** - **Summary of changes** - **Why this change was made** -- **Code diffs** (use `@pierre/diffs` output by default; if unavailable, include a clearly labeled plain diff/code block and note why) +- **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. @@ -181,7 +181,7 @@ Each turn document must include these sections: 2. **Changes Made** 3. **Context** 4. **Important Implementation Details** -5. **Relevant Diff Snippets** (render with `@pierre/diffs` output by default; if unavailable, include a clearly labeled plain diff/code block and note why) +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** @@ -196,7 +196,7 @@ A task that requires a turn document is not complete until: 3. Relevant quality gates have passed or failures are documented 4. Changes are committed 5. `bd dolt push` succeeds -6. `git push forgejo ` succeeds +6. `git push` succeeds 7. `git status` shows the branch is up to date with `forgejo/` For tasks that do require turn documentation, the document may be brief when scope is small, but it must clearly explain what changed and how it was validated. @@ -235,4 +235,3 @@ Always do the following when you finish a task, finish the beads workflow and an - Create a clear, concise summary of the changes at the top, followed by a detailed description of the changes, including any relevant context or background as well as specific code snippets or examples. - Note any relevant issues or limitations that were addressed or mitigated by the changes. - The HTML file should be stored in the `docs/turns` directory. It should include the current date and time, as well as a brief explanation of changes. e.g. docs/turns/YYYY-MM-DD-{description}.html - diff --git a/CLAUDE.md b/CLAUDE.md index cd553b9..633878c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,51 @@ -# Project Instructions for AI Agents +# Agent Instructions -This file provides instructions and context for AI coding agents working on this project. +This project uses **bd** (beads) for issue tracking. Run `bd prime` for full workflow context. + +> **Architecture in one line:** Issues live in a local Dolt database +> (`.beads/dolt/`); cross-machine sync uses `bd dolt push/pull` (a +> git-compatible protocol), stored under `refs/dolt/data` on your git +> remote — separate from `refs/heads/*` where your code lives. +> `.beads/issues.jsonl` is a passive export, not the wire protocol. +> +> See [SYNC_CONCEPTS.md](https://github.com/gastownhall/beads/blob/main/docs/SYNC_CONCEPTS.md) +> for the one-screen overview and anti-patterns (don't treat JSONL as the +> source of truth; don't `bd import` during normal operation; don't +> reach for third-party Dolt hosting before trying the default). + +## Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work atomically +bd close # Complete work +bd dolt push # Push beads data to remote +``` + +## Non-Interactive Shell Commands + +**ALWAYS use non-interactive flags** with file operations to avoid hanging on confirmation prompts. + +Shell commands like `cp`, `mv`, and `rm` may be aliased to include `-i` (interactive) mode on some systems, causing the agent to hang indefinitely waiting for y/n input. + +**Use these forms instead:** +```bash +# Force overwrite without prompting +cp -f source dest # NOT: cp source dest +mv -f source dest # NOT: mv source dest +rm -f file # NOT: rm file + +# For recursive operations +rm -rf directory # NOT: rm -r directory +cp -rf source dest # NOT: cp -r source dest +``` + +**Other commands that may prompt:** +- `scp` - use `-o BatchMode=yes` for non-interactive +- `ssh` - use `-o BatchMode=yes` to fail instead of prompting +- `apt-get` - use `-y` flag +- `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var ## Beads Issue Tracker @@ -50,21 +95,143 @@ bd close # Complete work - If push fails, resolve and retry until it succeeds +## Required Turn Documentation -## Build & Test +At the end of every completed implementation task, before final handoff, create a user-readable HTML document describing the work. -_Add your build and test commands here_ +This documentation is mandatory whenever code, configuration, tests, or project files were changed. -```bash -# Example: -# npm install -# npm test +### Precedence and classification + +Use this decision order before creating a turn document: + +1. Check the minor/trivial exemption checklist below first. +2. If the task clearly matches an exemption, do not create a turn document. +3. If the task is a clearly substantive implementation change, create a turn document. +4. If classification is ambiguous or mixed, ask the user before creating a turn document. + +The minor/trivial exemptions override the general mandatory turn-document rule. + +For diff content in turn documentation (including "Code diffs" and "Relevant Diff Snippets"), use `@pierre/diffs` output by default. Do not run `npx @pierre/diffs`; the package is a rendering library and does not expose a CLI executable. Generate rendered diff HTML with `@pierre/diffs/ssr`, usually `preloadPatchDiff`, and insert that rendered output into the turn document. `preloadPatchDiff` expects exactly one file diff per call, so split multi-file diffs into one patch per file and concatenate the rendered HTML. If `@pierre/diffs/ssr` is unavailable because of a real tool or blocking error, use a clearly labeled plain diff/code block fallback and note why. + +### No turn document for minor/trivial checklist matches + +Do not create a turn document when the change is minor/trivial and cleanly matches one of these categories: + +- `AGENTS.md` changes or other documentation-only changes +- Syntax-only fixes +- Refactor-only changes with no behavior change +- PR/conflict reconciliation work +- Issue-tracker-only updates such as `beads/issues.json` +- Support-file changes that only accompany one of the exempt categories above (for example lockfile or manifest updates required for docs-workflow changes) + +If a change does not cleanly fit either exempt or substantive buckets, ask the user before creating a turn document. + +### When making a minor update to a previous change, update the existing documentation instead of creating a new file. Use the following format: + +**"New Changes as of {time and date at which the change was made}"** +- **Summary of changes** +- **Why this change was made** +- **Code diffs** (use rendered `@pierre/diffs/ssr` output by default; do not use `npx @pierre/diffs`; if unavailable, include a clearly labeled plain diff/code block and note why) +- **Related issues or PRs** + +Additionally, add a note to each section explaining why the changes were made. + +### Location + +Save the document in: + +```text +docs/turns/ ``` -## Architecture Overview +Use a clear timestamped filename: -_Add a brief overview of your project architecture_ +```text +docs/turns/YYYY-MM-DD-short-task-name.html +``` -## Conventions & Patterns +Example: -_Add your project-specific conventions here_ +```text +docs/turns/2026-05-14-add-market-replay-controls.html +``` + +### Format + +Use the `impeccable` skill to structure and style the document as clean, readable HTML. + +For this repository, `impeccable` is the styling and layout authority for turn documents when available. Do not apply global non-repo computer-task house styling to repository turn documents. + +If the `impeccable` skill is unavailable or blocked by an actual tool/file error, still create a well-structured standalone HTML file with: + +- A concise summary at the top +- A detailed explanation of what changed +- Relevant context or background +- Specific code snippets or examples when helpful +- Issues, limitations, tradeoffs, or mitigations +- Validation performed, including tests, builds, linters, or manual checks +- Any remaining follow-up work, with corresponding Beads issue IDs when applicable + +### Required Sections + +Each turn document must include these sections: + +1. **Summary** +2. **Changes Made** +3. **Context** +4. **Important Implementation Details** +5. **Relevant Diff Snippets** (render with `@pierre/diffs/ssr` output by default; do not use `npx @pierre/diffs`; if unavailable, include a clearly labeled plain diff/code block and note why) +6. **Expected Impact for End-Users** +7. **Validation** +8. **Issues, Limitations, and Mitigations** +9. **Follow-up Work** + +### Completion Rule + +A task that requires a turn document is not complete until: + +1. The Beads workflow is updated +2. The turn document is created in `docs/turns` +3. Relevant quality gates have passed or failures are documented +4. Changes are committed +5. `bd dolt push` succeeds +6. `git push` succeeds +7. `git status` shows the branch is up to date with `forgejo/` + +For tasks that do require turn documentation, the document may be brief when scope is small, but it must clearly explain what changed and how it was validated. + +## Plan Mode Documentation + +When working in plan mode, do not modify implementation files. + +At the end of plan mode, provide a concise summary of the plan and ask the user whether they want to proceed with implementation. + +If the user asks to save the plan, create a user-readable HTML plan document in: + +```text +docs/plans/ +``` + +Use a clear timestamped filename: + +```text +docs/plans/YYYY-MM-DD-short-plan-name.html +``` + +The plan document should be labeled clearly as a plan and should include: + +1. **Plan Summary** +2. **Goals** +3. **Proposed Changes** +4. **Relevant Context** +5. **Implementation Steps** +6. **Risks, Limitations, and Mitigations** +7. **Open Questions** + +Always do the following when you finish a task, finish the beads workflow and and make a commit: +- Document the changes in a user-readable format +- Use the impeccable skill to structure the document as HTML +- Create a clear, concise summary of the changes at the top, followed by a detailed description of the changes, including any relevant context or background as well as specific code snippets or examples. +- Note any relevant issues or limitations that were addressed or mitigated by the changes. +- The HTML file should be stored in the `docs/turns` directory. It should include the current date and time, as well as a brief explanation of changes. e.g. docs/turns/YYYY-MM-DD-{description}.html From fdeacce3892876eb346d8220fcf842166fd306bb Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 05:49:59 -0400 Subject: [PATCH 12/16] add native player controls captions and close cleanup --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 2 + Dreamio/DreamioWebViewController.swift | 18 +- Dreamio/NativePlayerViewController.swift | 19 +- Dreamio/VLCNativePlaybackBackend.swift | 7 + ...e-player-controls-captions-close-flow.html | 221 ++++++++++++++++++ 6 files changed, 265 insertions(+), 3 deletions(-) diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index e8fa5cb..876c137 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -9,3 +9,4 @@ {"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."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index dfac9eb..f61e815 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,5 @@ +{"_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":"in_progress","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:28:11Z","started_at":"2026-05-25T09:28:11Z","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} diff --git a/Dreamio/DreamioWebViewController.swift b/Dreamio/DreamioWebViewController.swift index 06ecfe8..301ef99 100644 --- a/Dreamio/DreamioWebViewController.swift +++ b/Dreamio/DreamioWebViewController.swift @@ -122,6 +122,7 @@ final class DreamioWebViewController: UIViewController { 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; @@ -517,12 +518,18 @@ final class DreamioWebViewController: UIViewController { 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 stillPlayer = /player|stream|buffer|prepar/i.test(document.body.innerText || ""); + 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 }; })(); """# @@ -544,7 +551,14 @@ final class DreamioWebViewController: UIViewController { } if self.webView.canGoBack { DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { - self.webView.evaluateJavaScript("(/player|stream|buffer|prepar/i).test(document.body.innerText || '')") { result, _ in + 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() } diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index 54de22d..a8d5fa5 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -36,6 +36,13 @@ final class NativePlayerViewController: UIViewController { 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") @@ -167,6 +174,7 @@ final class NativePlayerViewController: UIViewController { private func configureLayout() { view.addSubview(backend.view) + view.addSubview(tapSurfaceView) view.addSubview(loadingView) view.addSubview(failureLabel) view.addSubview(controlsContainer) @@ -182,7 +190,7 @@ final class NativePlayerViewController: UIViewController { let tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility)) tap.cancelsTouchesInView = false - view.addGestureRecognizer(tap) + tapSurfaceView.addGestureRecognizer(tap) let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton]) controlRow.translatesAutoresizingMaskIntoConstraints = false @@ -208,6 +216,11 @@ final class NativePlayerViewController: UIViewController { 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), @@ -338,6 +351,8 @@ final class NativePlayerViewController: UIViewController { } private func revealControls() { + controlsContainer.isUserInteractionEnabled = true + closeButton.isUserInteractionEnabled = true UIView.animate(withDuration: 0.18) { self.controlsContainer.alpha = 1 self.closeButton.alpha = 1 @@ -346,6 +361,8 @@ final class NativePlayerViewController: UIViewController { } private func hideControls() { + controlsContainer.isUserInteractionEnabled = false + closeButton.isUserInteractionEnabled = false UIView.animate(withDuration: 0.24) { self.controlsContainer.alpha = 0 self.closeButton.alpha = 0 diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift index 167b241..d891c6f 100644 --- a/Dreamio/VLCNativePlaybackBackend.swift +++ b/Dreamio/VLCNativePlaybackBackend.swift @@ -40,6 +40,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { func play(request: NativePlaybackRequest) { #if canImport(MobileVLCKit) + attachedSubtitleURLs.removeAll() let media = VLCMedia(url: request.playbackURL) let headerValue = request.headers .map { "\($0.key): \($0.value)" } @@ -204,6 +205,12 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend { 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 } diff --git a/docs/turns/2026-05-25-native-player-controls-captions-close-flow.html b/docs/turns/2026-05-25-native-player-controls-captions-close-flow.html index 14256ed..fbaaf5e 100644 --- a/docs/turns/2026-05-25-native-player-controls-captions-close-flow.html +++ b/docs/turns/2026-05-25-native-player-controls-captions-close-flow.html @@ -380,6 +380,227 @@
  • Add a UI test harness or injectable mock backend for exercising native player overlay behavior without MobileVLCKit.
  • +
    +

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

    +

    Summary of changes

    +

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

    +

    Why this change was made

    +

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

    +

    Code diffs

    +

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

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

    Related issues or PRs

    +

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

    +
    + From e813b1964b4f5daa46bdc826c6c3ecf121049cdc Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 05:51:08 -0400 Subject: [PATCH 13/16] add native player controls captions and close cleanup --- .DS_Store | Bin 6148 -> 6148 bytes AGENTS.md | 144 ++++------ .../UserInterfaceState.xcuserstate | Bin 14430 -> 29246 bytes .../xcschemes/xcschememanagement.plist | 2 +- .../UserInterfaceState.xcuserstate | Bin 0 -> 12918 bytes ...-05-25-fix-native-player-controls-tap.html | 265 ++++++++++++++++++ 6 files changed, 328 insertions(+), 83 deletions(-) create mode 100644 Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 docs/turns/2026-05-25-fix-native-player-controls-tap.html diff --git a/.DS_Store b/.DS_Store index bd5957a280c6cb7fd64525b993b069f75d064085..a2b672a1f7dd25bc57b8c62da89c114e8de975f4 100644 GIT binary patch delta 282 zcmZoMXfc=|#>B)qu~2NHo+2a1#DLw4m>3y3Ci5_wFzwznxrR|=auy>O+lr?uEv%0w zS23D!K$yoJflAaS-(loq`@jGM!jlCUMODig@)?R4vKfjQ3K$X@k{MES(hY-?^K%P8 zN`T-Y5Q9}}VyMi`cX3I|$xi~R;JDQ6{*CF;aYuZrQ*f$Qz%aWY1KAYB`mu~2NHo+2a5#DLw5ER%VdOqf#3C)Y4(OwMBBV!M6c<>uRmldG6a zI3Ud9jzA@9lkYI`F;3Y0kXemo^9SZ{jGNgx_&I=P0Y$zuPv#e~g1? Fm;u4;BEkRw diff --git a/AGENTS.md b/AGENTS.md index 633878c..cd1e980 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,83 +97,43 @@ bd close # Complete work ## Required Turn Documentation -At the end of every completed implementation task, before final handoff, create a user-readable HTML document describing the work. +At the end of repository work, use this decision flow: -This documentation is mandatory whenever code, configuration, tests, or project files were changed. +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/`. -### Precedence and classification +The minor/trivial exemptions override the general turn-document rule. -Use this decision order before creating a turn document: +### Minor/Trivial Exemptions -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: +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 (for example lockfile or manifest updates required for docs-workflow changes) +- 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. -### When making a minor update to a previous change, update the existing documentation instead of creating a new file. Use the following format: +### Turn Document Requirements -**"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** +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. -Additionally, add a note to each section explaining why the changes were made. - -### Location - -Save the document in: - -```text -docs/turns/ -``` - -Use a clear timestamped filename: - -```text -docs/turns/YYYY-MM-DD-short-task-name.html -``` - -Example: - -```text -docs/turns/2026-05-14-add-market-replay-controls.html -``` - -### Format - -Use the `impeccable` skill to structure and style the document as clean, readable HTML. - -For this repository, `impeccable` is the styling and layout authority for turn documents when available. Do not apply global non-repo computer-task house styling to repository turn documents. - -If the `impeccable` skill is unavailable or blocked by an actual tool/file error, still create a well-structured standalone HTML file with: - -- A concise summary at the top -- A detailed explanation of what changed -- Relevant context or background -- Specific code snippets or examples when helpful -- Issues, limitations, tradeoffs, or mitigations -- Validation performed, including tests, builds, linters, or manual checks -- Any remaining follow-up work, with corresponding Beads issue IDs when applicable - -### Required Sections +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: @@ -181,32 +141,59 @@ Each turn document must include these sections: 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) +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** -### Completion Rule +For a minor update to a previous substantive change, add this section to the existing document: -A task that requires a turn document is not complete until: +**"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** -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/` +### Rendered Diff Documentation -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. +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. -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 @@ -228,10 +215,3 @@ The plan document should be labeled clearly as a plan and should include: 5. **Implementation Steps** 6. **Risks, Limitations, and Mitigations** 7. **Open Questions** - -Always do the following when you finish a task, finish the beads workflow and and make a commit: -- Document the changes in a user-readable format -- Use the impeccable skill to structure the document as HTML -- Create a clear, concise summary of the changes at the top, followed by a detailed description of the changes, including any relevant context or background as well as specific code snippets or examples. -- Note any relevant issues or limitations that were addressed or mitigated by the changes. -- The HTML file should be stored in the `docs/turns` directory. It should include the current date and time, as well as a brief explanation of changes. e.g. docs/turns/YYYY-MM-DD-{description}.html diff --git a/Dreamio.xcodeproj/project.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate b/Dreamio.xcodeproj/project.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate index 8e4627ee0b576f8d5f7d8af5cdc7826acea46562..a775c5b0e42b79db79d1661e315de7e1dab1ec77 100644 GIT binary patch literal 29246 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.xcscheme_^#shared#^_ orderHint - 0 + 2 diff --git a/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate b/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..d6bf1d49dfd1fac2270ca837eff743ff2781346e GIT binary patch literal 12918 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Μ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 O;XgAX{EvK2Qv44zFHdv; literal 0 HcmV?d00001 diff --git a/docs/turns/2026-05-25-fix-native-player-controls-tap.html b/docs/turns/2026-05-25-fix-native-player-controls-tap.html new file mode 100644 index 0000000..7436753 --- /dev/null +++ b/docs/turns/2026-05-25-fix-native-player-controls-tap.html @@ -0,0 +1,265 @@ + + + + + + Fix Native Player Controls Tap-to-Show + + + +
    +
    +
    Turn document · 2026-05-25 05:28 EDT
    +

    Fix Native Player Controls Tap-to-Show

    +

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

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

    Summary

    +

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

    +
    + +
    +

    Changes Made

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

    Context

    +

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

    +
    + +
    +

    Important Implementation Details

    +

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

    +

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

    +
    + +
    +

    Relevant Diff Snippets

    +

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

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

    Expected Impact for End-Users

    +

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

    +
    + +
    +

    Validation

    +
    +

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

    +
    +

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

    +
    + +
    +

    Issues, Limitations, and Mitigations

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

    Follow-up Work

    +
      +
    • No new follow-up issue is required for this fix.
    • +
    • Optional future improvement: add an injectable player overlay test harness so tap-to-show behavior can be exercised without launching MobileVLCKit on a device.
    • +
    +
    +
    + + \ No newline at end of file From c0a017ceb25b72b6ba8aa925d18519256939f1fe Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 06:19:04 -0400 Subject: [PATCH 14/16] streamline native player controls --- .beads/interactions.jsonl | 2 + .beads/issues.jsonl | 1 + Dreamio/NativePlayerViewController.swift | 84 ++++--- ...-25-streamline-native-player-controls.html | 236 ++++++++++++++++++ 4 files changed, 290 insertions(+), 33 deletions(-) create mode 100644 docs/turns/2026-05-25-streamline-native-player-controls.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 876c137..3899c3b 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -10,3 +10,5 @@ {"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."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index f97ee4a..b297ba4 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -8,6 +8,7 @@ {"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-7w6","title":"Streamline native player controls","description":"Make the native playback controls take up less screen space while preserving play, seek, jump, captions, and close actions.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T10:15:49Z","created_by":"dirtydishes","updated_at":"2026-05-25T10:18:31Z","started_at":"2026-05-25T10:15:59Z","closed_at":"2026-05-25T10:18:31Z","close_reason":"Streamlined native player controls into a compact bottom overlay and validated the simulator build.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-mj8","title":"Add native player controls and captions","description":"Implement a fuller VLC-backed native playback surface with transport controls, caption controls, external subtitle discovery, and a clean close flow back to Stremio episode selection.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:57:53Z","created_by":"dirtydishes","updated_at":"2026-05-25T05:04:55Z","started_at":"2026-05-25T04:57:57Z","closed_at":"2026-05-25T05:04:55Z","close_reason":"Implemented native VLC player controls, caption controls, subtitle candidate discovery, and close-flow cleanup.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-evt","title":"Enable WebView inspection and playback diagnostics","description":"Add development-only WKWebView inspection and token-safe playback diagnostics so Dreamio can debug hosted Stremio media failures without changing app navigation, login, or playback behavior.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T02:30:26Z","created_by":"dirtydishes","updated_at":"2026-05-25T02:34:55Z","started_at":"2026-05-25T02:30:32Z","closed_at":"2026-05-25T02:34:55Z","close_reason":"Implemented debug-only WKWebView inspection, token-safe playback diagnostics, navigation logging, validation build, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-a5b","title":"Track HTML diff rendering tooling as dev dependency","description":"Move the HTML diff rendering package into devDependencies and ignore installed Node modules so the repo tracks reproducible tooling without vendoring dependencies.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:12:07Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:12:44Z","started_at":"2026-05-25T01:12:14Z","closed_at":"2026-05-25T01:12:44Z","close_reason":"Moved @pierre/diffs to devDependencies and ignored node_modules.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index a8d5fa5..f5127fa 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -23,7 +23,7 @@ final class NativePlayerViewController: UIViewController { button.setImage(UIImage(systemName: "xmark"), for: .normal) button.tintColor = .white button.backgroundColor = UIColor.black.withAlphaComponent(0.45) - button.layer.cornerRadius = 22 + button.layer.cornerRadius = 18 button.accessibilityLabel = "Close" return button }() @@ -31,7 +31,7 @@ final class NativePlayerViewController: UIViewController { private let controlsContainer: UIVisualEffectView = { let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark)) view.translatesAutoresizingMaskIntoConstraints = false - view.layer.cornerRadius = 12 + view.layer.cornerRadius = 16 view.clipsToBounds = true return view }() @@ -52,7 +52,7 @@ final class NativePlayerViewController: UIViewController { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.textColor = .white - label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium) + label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold) label.text = "0:00" return label }() @@ -61,7 +61,7 @@ final class NativePlayerViewController: UIViewController { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.textColor = .white - label.font = .monospacedDigitSystemFont(ofSize: 13, weight: .medium) + label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold) label.textAlignment = .right label.text = "-0:00" return label @@ -75,6 +75,8 @@ final class NativePlayerViewController: UIViewController { 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 }() @@ -184,6 +186,7 @@ final class NativePlayerViewController: UIViewController { jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside) jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside) captionsButton.addTarget(self, action: #selector(showCaptions), for: .touchUpInside) + playPauseButton.layer.cornerRadius = 21 scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown) scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged) scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel]) @@ -192,22 +195,23 @@ final class NativePlayerViewController: UIViewController { 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 = .equalCentering - controlRow.spacing = 18 + controlRow.distribution = .equalSpacing + controlRow.spacing = 14 - let timeRow = UIStackView(arrangedSubviews: [elapsedLabel, remainingLabel]) - timeRow.translatesAutoresizingMaskIntoConstraints = false - timeRow.axis = .horizontal - timeRow.distribution = .fillEqually - - let stack = UIStackView(arrangedSubviews: [scrubber, timeRow, controlRow]) + let stack = UIStackView(arrangedSubviews: [timeAndScrubRow, controlRow]) stack.translatesAutoresizingMaskIntoConstraints = false stack.axis = .vertical - stack.spacing = 8 + stack.spacing = 6 controlsContainer.contentView.addSubview(stack) NSLayoutConstraint.activate([ @@ -228,28 +232,33 @@ final class NativePlayerViewController: UIViewController { failureLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -28), failureLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), - closeButton.widthAnchor.constraint(equalToConstant: 44), - closeButton.heightAnchor.constraint(equalToConstant: 44), - closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 12), - closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12), + 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.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 18), - controlsContainer.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -18), - controlsContainer.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -18), + 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: 16), - stack.trailingAnchor.constraint(equalTo: controlsContainer.contentView.trailingAnchor, constant: -16), - stack.topAnchor.constraint(equalTo: controlsContainer.contentView.topAnchor, constant: 14), - stack.bottomAnchor.constraint(equalTo: controlsContainer.contentView.bottomAnchor, constant: -14), + 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), - jumpBackButton.widthAnchor.constraint(equalToConstant: 44), - jumpBackButton.heightAnchor.constraint(equalToConstant: 44), - playPauseButton.widthAnchor.constraint(equalToConstant: 54), - playPauseButton.heightAnchor.constraint(equalToConstant: 54), - jumpForwardButton.widthAnchor.constraint(equalToConstant: 44), - jumpForwardButton.heightAnchor.constraint(equalToConstant: 44), - captionsButton.widthAnchor.constraint(equalToConstant: 44), - captionsButton.heightAnchor.constraint(equalToConstant: 44) + 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) ]) } @@ -385,8 +394,17 @@ final class NativePlayerViewController: UIViewController { button.setImage(UIImage(systemName: systemName), for: .normal) button.tintColor = .white button.backgroundColor = UIColor.black.withAlphaComponent(0.35) - button.layer.cornerRadius = 22 + button.layer.cornerRadius = 18 button.accessibilityLabel = label return button } + + private static func scrubberThumbImage(diameter: CGFloat) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = UIScreen.main.scale + return UIGraphicsImageRenderer(size: CGSize(width: diameter, height: diameter), format: format).image { context in + UIColor.white.setFill() + context.cgContext.fillEllipse(in: CGRect(origin: .zero, size: CGSize(width: diameter, height: diameter))) + } + } } diff --git a/docs/turns/2026-05-25-streamline-native-player-controls.html b/docs/turns/2026-05-25-streamline-native-player-controls.html new file mode 100644 index 0000000..a4541b8 --- /dev/null +++ b/docs/turns/2026-05-25-streamline-native-player-controls.html @@ -0,0 +1,236 @@ + + + + + + Streamline Native Player Controls + + + +
    +
    +

    Streamline Native Player Controls

    +

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

    +
    + +
    +

    Summary

    +

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

    +
    + +
    +

    Changes Made

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

    Context

    +

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

    +
    + +
    +

    Important Implementation Details

    +

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

    +

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

    +
    + +
    +

    Relevant Diff Snippets

    +

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

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

    Expected Impact for End-Users

    +

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

    +
    + +
    +

    Validation

    +

    Passed:

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

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

    +
    + +
    +

    Issues, Limitations, and Mitigations

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

    Follow-up Work

    +

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

    +
    +
    + + \ No newline at end of file From da7501d12b41a22a9a609810c4fbc71c37bcd13a Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 06:25:36 -0400 Subject: [PATCH 15/16] make caption menu state clearer --- .beads/interactions.jsonl | 1 + .beads/issues.jsonl | 1 + Dreamio/NativePlayerViewController.swift | 53 ++- Dreamio/StreamCandidate.swift | 4 +- Tests/StreamResolverTests.swift | 6 +- ...26-05-25-caption-menu-selection-state.html | 386 ++++++++++++++++++ 6 files changed, 425 insertions(+), 26 deletions(-) create mode 100644 docs/turns/2026-05-25-caption-menu-selection-state.html diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 3899c3b..df78ee8 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -12,3 +12,4 @@ {"id":"int-6b806f87","kind":"field_change","created_at":"2026-05-25T09:49:39.908604Z","actor":"dirtydishes","issue_id":"dreamio-poo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented and validated native player controls, subtitle handling refinements, and close-flow cleanup."}} {"id":"int-5d355e9b","kind":"field_change","created_at":"2026-05-25T09:51:17.04306Z","actor":"dirtydishes","issue_id":"dreamio-wgk","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} {"id":"int-9ddb7b1a","kind":"field_change","created_at":"2026-05-25T10:18:30.826897Z","actor":"dirtydishes","issue_id":"dreamio-7w6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Streamlined native player controls into a compact bottom overlay and validated the simulator build."}} +{"id":"int-2a84633f","kind":"field_change","created_at":"2026-05-25T10:25:22.649574Z","actor":"dirtydishes","issue_id":"dreamio-88m","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented captions as a single-choice menu with None and selected loaded tracks, updated tests and turn documentation."}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index b297ba4..5ad5342 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -8,6 +8,7 @@ {"_type":"issue","id":"dreamio-l68","title":"Add native playback for direct debrid streams","description":"Implement a WKWebView JavaScript bridge that detects direct-file debrid media URLs and routes unsupported containers to a native player backend, initially MobileVLCKit, while preserving normal Stremio Web playback for compatible streams.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:13:19Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:20:17Z","started_at":"2026-05-25T03:13:28Z","closed_at":"2026-05-25T03:20:17Z","close_reason":"Implemented native direct-stream bridge, classification, MobileVLCKit backend wiring, CocoaPods workflow docs, and turn documentation. Full iOS build is blocked locally by missing CocoaPods and iPhoneOS SDK.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-tnv","title":"Fix iOS bundle identifier install failure","description":"Xcode built Dreamio.app without a valid CFBundleIdentifier, causing device install to fail with CoreDeviceError 3000/3002. Investigate project bundle settings, fix the source configuration, validate the app bundle Info.plist, and document the change.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T01:23:00Z","created_by":"dirtydishes","updated_at":"2026-05-25T01:25:36Z","started_at":"2026-05-25T01:23:07Z","closed_at":"2026-05-25T01:25:36Z","close_reason":"Added bundle metadata to Info.plist and validated processed app bundle identifier.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"dreamio-4yn","title":"Build WKWebView MVP shell","description":"Create the first Dreamio MVP implementation: a minimal iOS WKWebView wrapper around hosted Stremio Web, with configuration, launch behavior, diagnostics, and documentation for real-device viability testing.","acceptance_criteria":"App project exists; WKWebView loads hosted Stremio Web; external/new-window navigation is handled; basic diagnostics and manual test documentation exist; quality gates are run or documented.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-24T14:55:12Z","created_by":"dirtydishes","updated_at":"2026-05-24T14:59:44Z","closed_at":"2026-05-24T14:59:44Z","close_reason":"Implemented the MVP WKWebView iOS shell, added run and validation documentation, and recorded current validation limits.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"dreamio-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} diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index f5127fa..6c30810 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -185,7 +185,7 @@ final class NativePlayerViewController: UIViewController { playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside) jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside) jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside) - captionsButton.addTarget(self, action: #selector(showCaptions), for: .touchUpInside) + captionsButton.showsMenuAsPrimaryAction = true playPauseButton.layer.cornerRadius = 21 scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown) scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged) @@ -314,28 +314,38 @@ final class NativePlayerViewController: UIViewController { } } - @objc private func showCaptions() { - revealControls() - let alert = UIAlertController(title: "Captions", message: nil, preferredStyle: .actionSheet) - SubtitleOptionMapper.options(from: backend.subtitleTracks).forEach { track in - let prefix = track.id == backend.selectedSubtitleTrackID ? "Selected: " : "" - alert.addAction(UIAlertAction(title: "\(prefix)\(track.name)", style: .default) { [weak self] _ in + 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() + } } - alert.addAction(UIAlertAction(title: "Delay -0.5s", style: .default) { [weak self] _ in - self?.backend.adjustSubtitleDelay(by: -0.5) - }) - alert.addAction(UIAlertAction(title: "Delay +0.5s", style: .default) { [weak self] _ in - self?.backend.adjustSubtitleDelay(by: 0.5) - }) - alert.addAction(UIAlertAction(title: "Current Delay: \(String(format: "%.1fs", backend.subtitleDelay))", style: .default)) - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - if let popover = alert.popoverPresentationController { - popover.sourceView = captionsButton - popover.sourceRect = captionsButton.bounds - } - present(alert, animated: true) + + 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() { @@ -351,6 +361,7 @@ final class NativePlayerViewController: UIViewController { 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 { diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift index 11ab6b3..3371b54 100644 --- a/Dreamio/StreamCandidate.swift +++ b/Dreamio/StreamCandidate.swift @@ -59,10 +59,10 @@ enum PlaybackTimeFormatter { } enum SubtitleOptionMapper { - static let offTrack = SubtitleTrack(id: -1, name: "Off") + static let noneTrack = SubtitleTrack(id: -1, name: "None") static func options(from tracks: [SubtitleTrack]) -> [SubtitleTrack] { - [offTrack] + tracks.filter { $0.id >= 0 } + [noneTrack] + tracks.filter { $0.id >= 0 } } } diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index 6cc5573..e70fc2b 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -9,7 +9,7 @@ struct StreamResolverTests { testRedactorHandlesPercentEncodedPath() testPlaybackTimeFormatting() testSubtitleCandidateParsing() - testSubtitleOptionMappingIncludesOff() + testSubtitleOptionMappingIncludesNone() print("StreamResolverTests passed") } @@ -110,13 +110,13 @@ struct StreamResolverTests { assertEqual(candidates[2].url.absoluteString, "https://cdn.example.test/movie.fr.ass?download=1") } - private static func testSubtitleOptionMappingIncludesOff() { + private static func testSubtitleOptionMappingIncludesNone() { let options = SubtitleOptionMapper.options(from: [ SubtitleTrack(id: 2, name: "English"), SubtitleTrack(id: 5, name: "Spanish") ]) - assertEqual(options.map(\.name), ["Off", "English", "Spanish"]) + assertEqual(options.map(\.name), ["None", "English", "Spanish"]) assertEqual(options.first?.id, -1) } diff --git a/docs/turns/2026-05-25-caption-menu-selection-state.html b/docs/turns/2026-05-25-caption-menu-selection-state.html new file mode 100644 index 0000000..7d131af --- /dev/null +++ b/docs/turns/2026-05-25-caption-menu-selection-state.html @@ -0,0 +1,386 @@ + + + + + + Caption Menu Selection State + + + +
    +
    +

    Caption Menu Selection State

    +

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

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

    Summary

    +

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

    +
    + +
    +

    Changes Made

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

    Context

    +

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

    +
    + +
    +

    Important Implementation Details

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

    Relevant Diff Snippets

    +

    Dreamio/NativePlayerViewController.swift

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

    Dreamio/StreamCandidate.swift

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

    Tests/StreamResolverTests.swift

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

    Expected Impact for End-Users

    +

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

    +
    + +
    +

    Validation

    +
    +

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

    +
    +

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

    +
    + +
    +

    Issues, Limitations, and Mitigations

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

    Follow-up Work

    +
      +
    • Investigate the existing subtitle parser test failure where lang does not populate the first candidate language.
    • +
    • Add a UI-level regression check for opening the captions menu once native-player UI automation exists.
    • +
    +
    +
    + + From 66f66faf28f41edf60150c616b6e48755a57a607 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 06:32:47 -0400 Subject: [PATCH 16/16] make caption menu state clearer --- .../UserInterfaceState.xcuserstate | Bin 12918 -> 13271 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate b/Dreamio.xcworkspace/xcuserdata/kell.xcuserdatad/UserInterfaceState.xcuserstate index d6bf1d49dfd1fac2270ca837eff743ff2781346e..d9f17eb4523631b8d54e314b7255fa4cc45052cd 100644 GIT binary patch delta 6422 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