Merge pull request #4 from dirtydishes/lavender/native-player-fixup

add native player controls, captions, and close cleanup
This commit is contained in:
dirtydishes 2026-05-25 06:09:31 -04:00 committed by GitHub
commit 87344168ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 593 additions and 86 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -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."}}

View file

@ -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":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T09:27:58Z","created_by":"dirtydishes","updated_at":"2026-05-25T09:51:17Z","started_at":"2026-05-25T09:28:11Z","closed_at":"2026-05-25T09:51:17Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-ija","title":"Fix MobileVLCKit linker dependency","description":"Dreamio fails to link because the MobileVLCKit framework is not found. Investigate how the dependency is configured and update the repository so the framework is available to Xcode builds.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:40:49Z","created_by":"dirtydishes","updated_at":"2026-05-25T04:44:36Z","started_at":"2026-05-25T04:40:57Z","closed_at":"2026-05-25T04:44:36Z","close_reason":"Fixed MobileVLCKit linker failures by preparing the XCFramework slice before app linking and preserving the integration through pod install.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-2k5","title":"Guard native playback when MobileVLCKit is unavailable","description":"Dreamio can currently present its native player from raw xcodeproj builds where MobileVLCKit is not linked, which leads to the fallback backend message instead of an actionable setup path. Add a runtime/build availability check, document the CocoaPods workspace requirement, and validate the fallback remains buildable.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T04:15:47Z","created_by":"dirtydishes","updated_at":"2026-05-25T04:21:42Z","started_at":"2026-05-25T04:15:56Z","closed_at":"2026-05-25T04:21:42Z","close_reason":"Added native backend availability guard, installed CocoaPods, generated workspace metadata, documented setup, and validated available checks.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"dreamio-8vi","title":"Fix URL redaction crash on percent-encoded paths","description":"## Why\nDreamio can crash while logging WebKit navigation and playback URLs because URLRedactor writes raw replacement text back into URLComponents.percentEncodedPath.\n\n## What needs to be done\n- Update URL redaction to avoid assigning invalid characters to percentEncodedPath\n- Preserve token/path redaction behavior for diagnostics\n- Add a regression test covering percent-encoded path input similar to the Stremio crash logs\n\n## Acceptance criteria\n- Redacting a URL with percent-encoded path segments does not crash\n- Diagnostics still remove query strings/fragments and redact token-like path segments\n- Tests cover the regression","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T03:50:04Z","created_by":"dirtydishes","updated_at":"2026-05-25T03:51:39Z","started_at":"2026-05-25T03:50:08Z","closed_at":"2026-05-25T03:51:39Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}

144
AGENTS.md
View file

@ -97,83 +97,43 @@ bd close <id> # 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/<branch>`.
### 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/<branch>`
### 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

View file

@ -7,7 +7,7 @@
<key>Dreamio.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
<integer>2</integer>
</dict>
</dict>
</dict>

View file

@ -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()
}

View file

@ -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

View file

@ -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
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long