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

# Native Player Controls, Captions, and Close Flow

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

## Summary

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

## Changes Made

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

## Context

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

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

## Important Implementation Details

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

## Relevant Diff Snippets

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

Dreamio/NativePlaybackBackend.swift
+12

    3 unmodified lines45678910113 unmodified lines    var view: UIView { get }    var onReady: (() -> Void)? { get set }    var onFailure: ((Error) -> Void)? { get set }
                func prepare(in viewController: UIViewController)    func play(request: NativePlaybackRequest)    func stop()}3 unmodified lines45678910111213141516171819202122233 unmodified lines    var view: UIView { get }    var onReady: (() -> Void)? { get set }    var onFailure: ((Error) -> Void)? { get set }    var onStateChange: (() -> Void)? { get set }    var onSubtitleTracksChange: (() -> Void)? { get set }    var isPlaying: Bool { get }    var isSeekable: Bool { get }    var duration: TimeInterval { get }    var currentTime: TimeInterval { get }    var subtitleTracks: [SubtitleTrack] { get }    var selectedSubtitleTrackID: Int32 { get }
                func prepare(in viewController: UIViewController)    func play(request: NativePlaybackRequest)    func togglePlayPause()    func seek(to position: Float)    func selectSubtitleTrack(id: Int32)    func adjustSubtitleDelay(by seconds: TimeInterval)    func stop()}

Dreamio/DreamioWebViewController.swift
-1+13

    112 unmodified lines111112113114115116112 unmodified lines                pageUrl: window.location.href,                tagName: element && element.tagName ? element.tagName : "",                currentSrc: element && element.currentSrc ? element.currentSrc : ""              });            } catch (_) {}          };112 unmodified lines113114115116117118119120121122123124125126127128129130112 unmodified lines                pageUrl: window.location.href,                tagName: element && element.tagName ? element.tagName : "",                currentSrc: element && element.currentSrc ? element.currentSrc : "",                subtitles: subtitleCandidates.slice(-20)              });            } catch (_) {}          };
                      const originalFetch = window.fetch;          if (originalFetch) {            window.fetch = async (...args) => {              const response = await originalFetch(...args);              try {                response.clone().text().then(inspectSubtitlePayload).catch(() => {});              } catch (_) {}              return response;            };          }

Dreamio/NativePlayerViewController.swift
+8

    269 unmodified lines269 unmodified lines269 unmodified lines270271272273274275276277269 unmodified lines        SubtitleOptionMapper.options(from: backend.subtitleTracks).forEach { track in            let prefix = track.id == backend.selectedSubtitleTrackID ? "Selected: " : ""            alert.addAction(UIAlertAction(title: "\(prefix)\(track.name)", style: .default) { [weak self] _ in                self?.backend.selectSubtitleTrack(id: track.id)            })        }        alert.addAction(UIAlertAction(title: "Delay -0.5s", style: .default))        alert.addAction(UIAlertAction(title: "Delay +0.5s", style: .default))

## Expected Impact for End-Users

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

## Validation

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

## Issues, Limitations, and Mitigations

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

## Follow-up Work

- Validate embedded and external subtitles on a real device with representative Stremio and OpenSubtitles addons.
- Consider a richer caption settings panel if users need style controls or exact delay entry.
- Add a UI test harness or injectable mock backend for exercising native player overlay behavior without MobileVLCKit.

## 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 lines363738394041125 unmodified lines1671681691701711729 unmodified lines18218318418518618718819 unmodified lines208209210211212213124 unmodified lines3383393403413423432 unmodified lines34634734834935035135 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 = false19 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 = 12 unmodified lines    }
        private func hideControls() {        UIView.animate(withDuration: 0.24) {            self.controlsContainer.alpha = 0            self.closeButton.alpha = 035 unmodified lines36373839404142434445464748125 unmodified lines1741751761771781791809 unmodified lines19019119219319419519619 unmodified lines216217218219220221222223224225226124 unmodified lines3513523533543553563573582 unmodified lines36136236336436536636736835 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 = false19 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 = 12 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 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 lines            print("[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 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 lines122123124125126127389 unmodified lines51751851952052152252352452552652752815 unmodified lines544545546547548549550121 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 lines122123124125126127128389 unmodified lines51851952052152252352452552652752852953053153253353453515 unmodified lines551552553554555556557558559560561562563564121 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.