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 @@
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.
+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.
+Rendered with @pierre/diffs/ssr and preloadPatchDiff. These snippets cover the incremental follow-up edits made in this turn.
35 unmodified lines363738394041125 unmodified lines1671681691701711729 unmodified lines18218318418518618718819 unmodified lines208209210211212213124 unmodified lines3383393403413423432 unmodified lines34634734834935035135 unmodified linesreturn 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 = falseview.addGestureRecognizer(tap)+let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton])controlRow.translatesAutoresizingMaskIntoConstraints = false19 unmodified linesbackend.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 = 1self.closeButton.alpha = 12 unmodified lines}+private func hideControls() {UIView.animate(withDuration: 0.24) {self.controlsContainer.alpha = 0self.closeButton.alpha = 035 unmodified lines36373839404142434445464748125 unmodified lines1741751761771781791809 unmodified lines19019119219319419519619 unmodified lines216217218219220221222223224225226124 unmodified lines3513523533543553563573582 unmodified lines36136236336436536636736835 unmodified linesreturn view}()+private let tapSurfaceView: UIView = {let view = UIView()view.translatesAutoresizingMaskIntoConstraints = falseview.backgroundColor = .clearreturn 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 = falsetapSurfaceView.addGestureRecognizer(tap)+let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton])controlRow.translatesAutoresizingMaskIntoConstraints = false19 unmodified linesbackend.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 = truecloseButton.isUserInteractionEnabled = trueUIView.animate(withDuration: 0.18) {self.controlsContainer.alpha = 1self.closeButton.alpha = 12 unmodified lines}+private func hideControls() {controlsContainer.isUserInteractionEnabled = falsecloseButton.isUserInteractionEnabled = falseUIView.animate(withDuration: 0.24) {self.controlsContainer.alpha = 0self.closeButton.alpha = 0
39 unmodified lines404142434445158 unmodified lines20420520620720820939 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 linesprint("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")#endif}}#endif}39 unmodified lines40414243444546158 unmodified lines20520620720820921021121221321421521639 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 linesprint("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")#endif}guard !candidates.isEmpty else {return}DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] inself?.onSubtitleTracksChange?()}}#endif}
121 unmodified lines122123124125126127389 unmodified lines51751851952052152252352452552652752815 unmodified lines544545546547548549550121 unmodified linesconst 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 linesconst 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, _ inif (result as? Bool) == true {self.webView.goBack()}121 unmodified lines122123124125126127128389 unmodified lines51851952052152252352452552652752852953053153253353453515 unmodified lines551552553554555556557558559560561562563564121 unmodified linesconst 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 linesconst 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, _ inif (result as? Bool) == true {self.webView.goBack()}
Related Beads issue: dreamio-poo. No pull request was created in this local workflow.