From 8d4dd0870a9d237f01b2b8f2dddf68c47a09fb67 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 05:20:27 -0400 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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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Μ*1ztF6LJOzx!m(_B%1+Q@sg%lB0(bxJc>BvaX}b)bQ(g-=p3Iyftq3GyO0~| zKLdOQ;j;ut%tBObn1U+MJR#p~MsMsK%Q0K=RmgD@?C+(p!`A&&q(>*$?DKg$-2S@ATm+ynexBo+G{yObd_zHTe!iis zv;@9#7jTceh*<64@ZXW+W_$;}6W@jJ#`oZR@qOTE576`J1@uCC5xtmhrkBtybSsA# zAHomgf8a;(qxhe&DUZVzY@;{O-Sk#^+Yr{%X+zk^ZHWTr>s;(QRlXZQ8em=sEiAA5}Q7{f#y=(-J;sf{~y_|;V2)85yUs~tE zlsVQXI-k7gmce==j7qc>0`+XaZ=hdj;LuxS#5eFe9C~~cAI5Ltx9JXg1-+79wF|$C z-^1_Ycj?vi8hR~!!pNz$9&eEK^A=pm^C5gamBQ6r(UCY%40*Yw4>-3I2&;uY*shkk z*nT4O`3xUHX;9Nb1;~NYn0}o;*dhRx5!tWsQK)Y5*Yvt!{0+VSFP|BOfbj$V5g!Al z69_lX6&tw&72qKKYyc7{PdU+Hx^uqb(DB$y1{(N z!YtBKfH2^6jlKYwldrFcXIx%ddfm=p(P)xblHA}ug;h*M2?cSaz;{mv0J|Dn7~(cx+i z&aP@La6q*)HU?N~p=0cF+>l&G36zG1bG15MKygH;#%tFJuG`l=hGbm-feVBUgaCAg z>Xs0?YZxB@2LdC`KC%1o-jN4jV}K{xO|$xE@rsM|ltiS!DiJY}&>QJZA;|VJdNaL+ z>(M#@tTK?2%UHi3Y#$r|{#71yQ z5D9lYc4iSftAGMU)IXify` z<-|i)0G{=dUeZV4Oh|D&GrM$bzJfxP(VWx-^(RDrR=5vO_PRZNW8y9V7Ti9}Ul!?e ztc^ZT_tF>X%k*iw&!w0WbzdPfK&;|)Ep#}R1EY>C?13MMOcJ2a0Fg;nk=3x8HFzCa zyKroj7JhvCEWqIB_<+m@OR|of1&0Qpp0Us<6eMA@@%TgJY%W0lrOd>${}6r-@I?Q` zXe(Kd<^qZT0!6|&8_9VmX1haV6W9HTh;YbgkcL|ZpxvBrh z^1@bK0aP-%k{%dym21cdlr!X7aviyz>?Fe^L=Vze=pp(leT}}p8|9K4$Zq((2~hbo z`UZUyeh&+XTnE^1z}v+kz(xSrP3+160OAnFcs6PgXE}2{eO&-!#)YUQ;a~zlrl4pN zIJR&tfbVcXl-I%W69>k_@sIPNaAz(-d@NSnO90r8ko%zadw{-0-=^=3kO#>_y9ykU9VV&uve75GyG67f!jq(yzvyagsk6#9*|IenXGaum4YHm@hrL zDXc3dfk^^v#w0Q#`Yrt~#E2OQ{ht2tU!7q_`kcThxjrW{sq`5A3Gg1H0=)M#=dAz7 zGi2`E7@S);AU*EHoXR#ccT z9QH5<#t0E1n*GN`kPwr_h2Iml@f*aLd6*elv6Yz&g&<>Nau_=^X$S-PMTRglgyVKG zxlA5j$K(&;_#rqdT04X%Q1gO%b{UWE1R^5}eWI{zp#Z%&P}tt&>0ZtYM@B1Urg5W{ zG3Cru=9D3vFoY9_uxJP;?SdC>CX-}11)2u<^k%>)pmPm>ZUaFDFc7<%i|2tR_iBM* zfr2i!HT*J(v(v}1Rfd>G{&9Gr$f^)Ky z!#$<};2txdX&l1ILs${bVvLh%J06BH3s4EOa0tsNe8}n4s76h*hJ<1+G6NUW`KO1w zgojhY5C2)kE0_QezCD;fU~o;ZY4hp=)8rww7%5LOT2^dX!9*^~bT zZcF$D^>e=*wiUbD$%@A2h_uPdQoF@b&{o z7jXG3_KB0uJz5gREOkP-hT$Bp*~h!m0p>hr6EGT^&_3pTNT3bQFdz*suV?$ZxoQR| z_~<>}QHHRNhjGk>%ti2)RJ1X~TnzAU+>MCUUBYaMd7FT_l(~#^gzZDvFbw`FcqZ>I z^ZUGmVJO6TnD9zegkxf^Vy@+a>DA0NLpW;)n?jt0@m2*krHpn^53hW&U6o#6XAd}~ zenC^Tg@5%7PE>$Pjg}NZ{inn0e3MZDP`opN_HTuEtd^2n;GM`v0Q~GHuad*$ZSpR> z3;8jS?MD~_kB6y?G#kiSHhGn|py~ZARk8u;c$#@aG%eV#JX57wP z4sSGG#azQ&$LwT6%p=Sj%*S!EI7i%^xTSGt#9b1%JMQ(kBk?3&5-*Ea#7~IV$6Moz z;!EO7p7>nTZvN zRf!#m=O$j9xH)l4;`YRA6YoiUC~B5%q|AMLy9g(K^v(qLAoT(LT|r=$j-_k|8NKsW7QH$&qwQ()6U6Nfk*A zNsUQONzF;SlkQ2nFX@4#2gMSxTAVJ<5NpLbV!L>fI9EJXJY76fTq&*=*NU6Pi^Z&X zh1e_Z6|WKxiq8{SbT|in|QnUa`E-zka(APxA+e6qvEH;qvF@aZ;Ia%za#!g z{E7H8@#o?%C34AZNxx*Bgi6klY>=EMIbX6>a+M?`xm9wza5ht)aumQ)H$gwscot4 zsf$vVq`Fewshz2tQg2UvRf&~irBta@W-1NJDrLQLo^rl&v9ed`Q?677lmp7u%0cBB z%5}=k$}P%m%I(U_l~*XQQeLCHPPtRLU-^mh$23ElJ8ga1^=S{Ky_WW2+Of2s(|%3+ zU4>Lc6{kv2iBw{hPNi2FRVG!o%BspyO;+Wp3RFd^T2;HMS4CCZRX3{cRqa*1rTR>D zRQ0Xud)1Gs-_=M>)N$$rb&h(vdcJzGnpJnJJ?b8HuX>ewje4#6O!Zl6s@|c#RlP_3 zj{0-;7wRw7U#Cmb)#;jaUAiGXE8UzvGks1vo8F!7$*9bjn-R=7BV(5)Ni$hfu9>Ep zp{dYRX=*gRnpK+hnoXJuG#6>MYA)4WrrDu+T=SvkOU+lBqne*Jzi58bCTJ&WZCbl_ zvNlhfuPxLTYaQA$?NseF?P*%4wnf{fZPzZ+F43N@?bI&QdbB;-wb~oC4{M*+ey)qt zW$32sPS>s1ZP0DgU8dWiyGnPB?mFGwx`%a->7LN-(LJp@pnF4iSogN>UERmJPjyFh zU+BKl{gx@t)MZ*T^E0PsR%g~`&dEGA^R!H7W=m#UW_xB&W-xPA<|Ub-%)2t5%{-iW zEc55gUo(H#BR$c_=^gr+`bvGZzE#(0Hx!4&zKWQWzU+Nm6zqr@?~9`bwk#ZS+8e(k@b^FWlA?`OgfX^WHgyf z*(R$g$5d&mHr1Num`*j-n;J}QriG@(rlqDHQ?JQqT4`EkI@@%SX|rjIX`AUb(?_On zO+TA{HT`Zz=0tOnSz=B$%gr;)XPR#{-)nxr{Gj<^^Yi8x&HK%x=7ZTu*%Pw$+3-q1 zwk}A=3>^0eIv(L;P%3hznA^W`S-PzA%KcD?U_NNwwCEb#5 zDY4XBS}d)W1s2xQXIW_pT2@=uTGm@OTF$pzXxVJpV!6_Cm*pRpr!5C8hb`||-nV>c z`NVS6@~!22%a4|yEWcROtqyCu)o(r9dX9C2b(8f%>&4b#>n+whtan@Qvp!(mW8G(c z!TOT*W$SC!H?413-?hGPJ!1XNhHWC7*e12fY)YHjmSNM`^tK$E!#3M?s%@UF(dM+Z z+S+Z4Z7y5C&2J0ZR@v6r*4oaron@o8b8L6o-pxtQnU>R?vm@u(oMZN6dxkyFUSY4c z&$GANm)cj@SKHUv2kmFrH`y<=Z?UuwV1evkbz`&0I3?ECEd?W6XC_CxkB1)h%( 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