Turn document, 2026-05-25

# Prove Native Subtitle Pipeline

Added targeted DEBUG-only logging across subtitle discovery, web-to-native forwarding, native resolution, VLC subtitle attachment, and VLC track exposure. The change is diagnostic only and keeps URL output redacted.

Issue: dreamio-e9p Scope: diagnostics only Validation: build passed

## Summary

The native subtitle path now reports enough DEBUG data to tell whether subtitles disappear during web discovery, bridge forwarding, native player timing, subtitle resolution, VLC attachment, or VLC track enumeration.

## Changes Made

- Added web bridge metadata for subtitle discovery, dedupe, and post counts.
- Logged subtitle bridge messages after native parsing, including whether a native player is active.
- Logged native player forwarding, resolution, duplicate filtering, attachment counts, resulting subtitle tracks, and selected track id.
- Logged VLC track state after `addPlaybackSlave`, after the delayed refresh, and when VLC reports `.esAdded`.
- Added DEBUG-only formatting helpers for subtitle candidates and tracks that include labels, languages, extensions, names, indexes, and selected ids without exposing full subtitle URLs.

## Context

The current failure mode has already shown native playback starting with `subtitles=0`. This pass avoids behavior changes and instead makes the next Xcode run produce proof about which stage has zero subtitles or loses them.

## Important Implementation Details

- Swift logs are wrapped in `#if DEBUG`, so release behavior is unchanged.
- The injected web script now includes debug count metadata in subtitle bridge messages. It still sends the same subtitle payload shape used by native parsing.
- Full URLs remain redacted through existing `URLRedactor` where URLs are printed. Candidate summaries intentionally show only label, language, and file extension.
- The VLC logs print `videoSubTitlesNames`, `videoSubTitlesIndexes`, and `currentVideoSubTitleIndex` at the moments most relevant to track exposure.

## Relevant Diff Snippets

Rendered with `@pierre/diffs/ssr` using one file diff per render.

### DreamioWebViewController.swift

Dreamio/DreamioWebViewController.swift
-3+37

    126 unmodified lines1271281291301311322 unmodified lines135136137138139140141142143144145146333 unmodified lines4804814824834844853 unmodified lines489490491492493494495162 unmodified lines65865966066166266371 unmodified lines735736737738739740741126 unmodified lines          };
              const postSubtitleCandidates = (candidates) => {            const fresh = candidates.filter((candidate) => {              if (postedSubtitleURLs.has(candidate.url)) {                return false;2 unmodified lines              return true;            });            if (fresh.length === 0) {              return;            }            try {              window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({                pageUrl: window.location.href,                subtitles: fresh              });            } catch (_) {}          };333 unmodified lines            return        }
            guard let currentNativePlayer else {#if DEBUG            print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player")3 unmodified lines
            let forwarded = currentNativePlayer.addSubtitleCandidates(candidates)#if DEBUG        print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=\(forwarded)")#endif    }
    162 unmodified lines    private func redactedURLString(_ value: String) -> String {        URLRedactor.redactedURLString(value)    }#endif}
    71 unmodified lines        }
            if message.name == Constants.subtitleCandidateMessageHandler {            handleSubtitleCandidates(SubtitleCandidateParser.candidates(in: message.body))            return        }
    126 unmodified lines1271281291301311321332 unmodified lines136137138139140141142143144145146147148149150151152153154155156157158159160161162163333 unmodified lines4974984995005015025035045053 unmodified lines509510511512513514515162 unmodified lines67867968068168268368468568668768868969069169269371 unmodified lines765766767768769770771772773774775126 unmodified lines          };
              const postSubtitleCandidates = (candidates) => {            const discoveredCount = candidates.length;            const fresh = candidates.filter((candidate) => {              if (postedSubtitleURLs.has(candidate.url)) {                return false;2 unmodified lines              return true;            });            if (fresh.length === 0) {              try {                window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({                  pageUrl: window.location.href,                  subtitles: [],                  debug: {                    discovered: discoveredCount,                    deduped: 0,                    forwarded: 0                  }                });              } catch (_) {}              return;            }            try {              window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({                pageUrl: window.location.href,                subtitles: fresh,                debug: {                  discovered: discoveredCount,                  deduped: fresh.length,                  forwarded: fresh.length                }              });            } catch (_) {}          };333 unmodified lines            return        }
    #if DEBUG        print("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")#endif        guard let currentNativePlayer else {#if DEBUG            print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player")3 unmodified lines
            let forwarded = currentNativePlayer.addSubtitleCandidates(candidates)#if DEBUG        print("[DreamioSubtitles] discovered=\(candidates.count) forwarded=\(forwarded) reason=active-native-player")#endif    }
    162 unmodified lines    private func redactedURLString(_ value: String) -> String {        URLRedactor.redactedURLString(value)    }
        private func logSubtitleBridgeMessage(_ body: Any, parsedCandidates: [SubtitleCandidate]) {        let dictionary = body as? [String: Any]        let debug = dictionary?["debug"] as? [String: Any]        let discovered = debug?["discovered"] as? Int ?? parsedCandidates.count        let deduped = debug?["deduped"] as? Int ?? parsedCandidates.count        let posted = debug?["forwarded"] as? Int ?? parsedCandidates.count        let pageURL = dictionary?["pageUrl"] as? String        print("[DreamioSubtitles] bridge discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) playerActive=\(currentNativePlayer != nil) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))")    }#endif}
    71 unmodified lines        }
            if message.name == Constants.subtitleCandidateMessageHandler {            let candidates = SubtitleCandidateParser.candidates(in: message.body)#if DEBUG            logSubtitleBridgeMessage(message.body, parsedCandidates: candidates)#endif            handleSubtitleCandidates(candidates)            return        }

### NativePlayerViewController.swift

Dreamio/NativePlayerViewController.swift
-3+3

    139 unmodified lines1401411421431441451468 unmodified lines15515615715815916016110 unmodified lines172173174175176177178139 unmodified lines        let pendingCandidates = candidates.filter { !attachedSubtitleURLs.contains($0.url) }        guard !pendingCandidates.isEmpty else {#if DEBUG            print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=0 duplicates=\(candidates.count)")#endif            return 0        }8 unmodified lines            await MainActor.run {                guard !resolvedCandidates.isEmpty else {#if DEBUG                    print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=0 attached=0")#endif                    return                }10 unmodified lines                }#if DEBUG                let duplicateCount = candidates.count - pendingCandidates.count + resolvedCandidates.count - attachableCandidates.count                print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=\(resolvedCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")#endif            }        }139 unmodified lines1401411421431441451468 unmodified lines15515615715815916016110 unmodified lines172173174175176177178139 unmodified lines        let pendingCandidates = candidates.filter { !attachedSubtitleURLs.contains($0.url) }        guard !pendingCandidates.isEmpty else {#if DEBUG            print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=0 duplicates=\(candidates.count) resolved=0 attached=0 tracks=\(SubtitleDebugFormatter.trackSummary(backend.subtitleTracks)) selected=\(backend.selectedSubtitleTrackID)")#endif            return 0        }8 unmodified lines            await MainActor.run {                guard !resolvedCandidates.isEmpty else {#if DEBUG                    print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=0 attached=0 tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks)) selected=\(self.backend.selectedSubtitleTrackID) candidates=\(SubtitleDebugFormatter.candidateSummary(pendingCandidates))")#endif                    return                }10 unmodified lines                }#if DEBUG                let duplicateCount = candidates.count - pendingCandidates.count + resolvedCandidates.count - attachableCandidates.count                print("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=\(resolvedCandidates.count) attachable=\(attachableCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks)) selected=\(self.backend.selectedSubtitleTrackID) resolvedCandidates=\(SubtitleDebugFormatter.candidateSummary(resolvedCandidates))")#endif            }        }

### VLCNativePlaybackBackend.swift

Dreamio/VLCNativePlaybackBackend.swift
-1+16

    213 unmodified lines2142152162172182192205 unmodified lines22622722822923023123223323423512 unmodified lines248249250251252253213 unmodified lines            mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)            attachedCount += 1#if DEBUG            print("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")#endif        }#if DEBUG5 unmodified lines            return attachedCount        }        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in            self?.onSubtitleTracksChange?()        }        return attachedCount    }#endif}
    12 unmodified lines        case .paused, .stopped, .ended:            onStateChange?()        case .esAdded:            onSubtitleTracksChange?()        default:            break213 unmodified lines2142152162172182192202215 unmodified lines22722822923023123223323423523623723823924024124224324424524624712 unmodified lines260261262263264265266267268213 unmodified lines            mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)            attachedCount += 1#if DEBUG            print("[DreamioVLC] addPlaybackSlave subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString)) label=\(candidate.label) language=\(candidate.language ?? "unknown") ext=\(candidate.url.pathExtension.lowercased())")            logSubtitleTracks(reason: "after-addPlaybackSlave")#endif        }#if DEBUG5 unmodified lines            return attachedCount        }        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in            #if DEBUG            self?.logSubtitleTracks(reason: "delayed-refresh")            #endif            self?.onSubtitleTracksChange?()        }        return attachedCount    }
    #if DEBUG    private func logSubtitleTracks(reason: String) {        let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []        let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? []        print("[DreamioVLC] subtitle tracks reason=\(reason) names=\(names) indexes=\(indexes.map { $0.int32Value }) selected=\(mediaPlayer.currentVideoSubTitleIndex)")    }#endif#endif}
    12 unmodified lines        case .paused, .stopped, .ended:            onStateChange?()        case .esAdded:#if DEBUG            logSubtitleTracks(reason: "esAdded")#endif            onSubtitleTracksChange?()        default:            break

### StreamCandidate.swift

Dreamio/StreamCandidate.swift
+27

    39 unmodified lines40414243444539 unmodified lines    let name: String}
    enum PlaybackTimeFormatter {    static func label(for seconds: TimeInterval) -> String {        guard seconds.isFinite, seconds > 0 else {39 unmodified lines40414243444546474849505152535455565758596061626364656667686970717239 unmodified lines    let name: String}
    #if DEBUGenum SubtitleDebugFormatter {    static func candidateSummary(_ candidates: [SubtitleCandidate]) -> String {        guard !candidates.isEmpty else {            return "[]"        }
            return candidates.map { candidate in            let extensionLabel = candidate.url.pathExtension.isEmpty ? "none" : candidate.url.pathExtension.lowercased()            let language = candidate.language?.isEmpty == false ? candidate.language! : "unknown"            let label = candidate.label.isEmpty ? "External Subtitle" : candidate.label            return "{label=\(label), language=\(language), ext=\(extensionLabel)}"        }.joined(separator: ", ")    }
        static func trackSummary(_ tracks: [SubtitleTrack]) -> String {        guard !tracks.isEmpty else {            return "[]"        }
            return tracks.map { track in            "{id=\(track.id), name=\(track.name)}"        }.joined(separator: ", ")    }}#endif
    enum PlaybackTimeFormatter {    static func label(for seconds: TimeInterval) -> String {        guard seconds.isFinite, seconds > 0 else {

## Expected Impact for End-Users

No user-facing behavior should change. Debug builds should provide clearer Xcode logs for the next playback attempt, making it faster to identify the actual subtitle failure point before changing playback behavior.

## Validation

- `xcodebuild build -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO` passed.
- `xcodebuild test -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'platform=iOS Simulator,name=iPhone 16'` could not run because the `Dreamio` scheme is not configured for the test action.

## Issues, Limitations, and Mitigations

- This does not fix subtitle playback by design. It proves where the next fix belongs.
- The web script can only log subtitle candidates it detects through the existing discovery paths. If the next run shows no bridge discovery logs, Stremio or OpenSubtitles payload discovery is still the likely target.
- Duplicate-only subtitle bridge messages now post an empty subtitle list with debug metadata. Native handling ignores empty parsed candidates, so playback behavior remains unchanged.

## Follow-up Work

- If logs show no `[DreamioSubtitles] bridge discovered` entries, inspect the current Stremio/OpenSubtitles payload shape and extend discovery.
- If logs show `playerActive=false` before native playback exists, add a small pending subtitle queue rather than dropping early subtitle messages.
- If logs show resolution failures, improve resolver support for the failing OpenSubtitles response format.
- If logs show VLC attachment succeeds but track arrays stay empty, test VLC subtitle slave behavior for the resolved file type and timing.

## New Changes as of 2026-05-25 10:19 AM EDT

### Summary of changes

Added DEBUG-only proof logs for the captions menu selection path after the latest Xcode run showed VLC exposing an embedded subtitle track but no new logs during selection.

### Why this change was made

The previous diagnostics proved external subtitles were absent and VLC exposed an embedded `English (SDH)` track. The missing proof was whether tapping the captions menu fires a native action and whether VLC accepts the selected track id.

### Code diffs

### NativePlayerViewController.swift

Dreamio/NativePlayerViewController.swift
-4+23

    379 unmodified lines38038138238338438538638738838939039139227 unmodified lines420421422423424425426427428429430431379 unmodified lines
        private func captionsMenu() -> UIMenu {        let selectedTrackID = backend.selectedSubtitleTrackID        let trackActions = SubtitleOptionMapper.options(from: backend.subtitleTracks).map { track in            UIAction(                title: track.name,                state: track.id == selectedTrackID ? .on : .off            ) { [weak self] _ in                self?.backend.selectSubtitleTrack(id: track.id)                self?.refreshControls()            }        }
    27 unmodified lines    }
        private func refreshControls() {        playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)        scrubber.isEnabled = backend.isSeekable        jumpBackButton.isEnabled = backend.isSeekable        jumpForwardButton.isEnabled = backend.isSeekable        captionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmpty        captionsButton.menu = captionsMenu()        elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)        remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"        if !isScrubbing {379 unmodified lines38038138238338438538638738838939039139239339439539639739839940040140240340440540627 unmodified lines434435436437438439440441442443444445446447448449450379 unmodified lines
        private func captionsMenu() -> UIMenu {        let selectedTrackID = backend.selectedSubtitleTrackID        let tracks = backend.subtitleTracks        let options = SubtitleOptionMapper.options(from: tracks)#if DEBUG        print("[DreamioCaptions] build-menu tracks=\(SubtitleDebugFormatter.trackSummary(tracks)) options=\(SubtitleDebugFormatter.trackSummary(options)) selected=\(selectedTrackID)")#endif        let trackActions = options.map { track in            UIAction(                title: track.name,                state: track.id == selectedTrackID ? .on : .off            ) { [weak self] _ in                guard let self else {                    return                }#if DEBUG                print("[DreamioCaptions] select-request id=\(track.id) name=\(track.name) before=\(self.backend.selectedSubtitleTrackID)")#endif                self.backend.selectSubtitleTrack(id: track.id)#if DEBUG                print("[DreamioCaptions] select-result id=\(track.id) after=\(self.backend.selectedSubtitleTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks))")#endif                self.refreshControls()            }        }
    27 unmodified lines    }
        private func refreshControls() {        let subtitleTracks = backend.subtitleTracks        let subtitleOptions = SubtitleOptionMapper.options(from: subtitleTracks)        playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)        scrubber.isEnabled = backend.isSeekable        jumpBackButton.isEnabled = backend.isSeekable        jumpForwardButton.isEnabled = backend.isSeekable        captionsButton.isEnabled = !subtitleOptions.isEmpty        captionsButton.menu = captionsMenu()#if DEBUG        print("[DreamioCaptions] refresh enabled=\(captionsButton.isEnabled) tracks=\(SubtitleDebugFormatter.trackSummary(subtitleTracks)) selected=\(backend.selectedSubtitleTrackID)")#endif        elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)        remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"        if !isScrubbing {

### VLCNativePlaybackBackend.swift

Dreamio/VLCNativePlaybackBackend.swift
+12

    99 unmodified lines10010110210310410510610710810911011111211399 unmodified lines
        func selectSubtitleTrack(id: Int32) {#if canImport(MobileVLCKit)        mediaPlayer.currentVideoSubTitleIndex = id        onSubtitleTracksChange?()#endif    }
        func adjustSubtitleDelay(by seconds: TimeInterval) {#if canImport(MobileVLCKit)        mediaPlayer.currentVideoSubTitleDelay += Int(seconds * 1_000_000)        onSubtitleTracksChange?()#endif    }99 unmodified lines10010110210310410510610710810911011111211311411511611711811912012112212312412599 unmodified lines
        func selectSubtitleTrack(id: Int32) {#if canImport(MobileVLCKit)#if DEBUG        logSubtitleTracks(reason: "before-select-\(id)")#endif        mediaPlayer.currentVideoSubTitleIndex = id#if DEBUG        logSubtitleTracks(reason: "after-select-\(id)")#endif        onSubtitleTracksChange?()#endif    }
        func adjustSubtitleDelay(by seconds: TimeInterval) {#if canImport(MobileVLCKit)#if DEBUG        print("[DreamioVLC] subtitle delay before=\(subtitleDelay) delta=\(seconds)")#endif        mediaPlayer.currentVideoSubTitleDelay += Int(seconds * 1_000_000)#if DEBUG        print("[DreamioVLC] subtitle delay after=\(subtitleDelay)")#endif        onSubtitleTracksChange?()#endif    }

### Related issues or PRs

Related Beads issue: `dreamio-c1m`.

## New Changes as of 2026-05-25 10:26 AM EDT

### Summary of changes

Stabilized captions menu rebuilding so progress refreshes no longer replace the `UIMenu` every half second while the user is trying to select a subtitle track.

### Why this change was made

The latest logs showed VLC exposing the embedded subtitle track and the native menu eventually seeing it, but no selection action fired. The repeated `build-menu` and `refresh` logs showed the progress timer was constantly recreating the menu.

### Code diffs

### NativePlayerViewController.swift

Dreamio/NativePlayerViewController.swift
-6+36

    8 unmodified lines91011121314385 unmodified lines4004014024034044054 unmodified lines41041141241341441541641741841915 unmodified lines4354364374384394404414424434444454464474484494502 unmodified lines4534544554564574588 unmodified lines    private var progressTimer: Timer?    private var isScrubbing = false    private var attachedSubtitleURLs: Set<URL>    var onDismiss: (() -> Void)?
        private let loadingView: UIActivityIndicatorView = {385 unmodified lines#if DEBUG                print("[DreamioCaptions] select-result id=\(track.id) after=\(self.backend.selectedSubtitleTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks))")#endif                self.refreshControls()            }        }4 unmodified lines            children: [                UIAction(title: "Decrease 0.5s") { [weak self] _ in                    self?.backend.adjustSubtitleDelay(by: -0.5)                    self?.refreshControls()                },                UIAction(title: "Increase 0.5s") { [weak self] _ in                    self?.backend.adjustSubtitleDelay(by: 0.5)                    self?.refreshControls()                },                UIAction(15 unmodified lines
        private func refreshControls() {        let subtitleTracks = backend.subtitleTracks        let subtitleOptions = SubtitleOptionMapper.options(from: subtitleTracks)        playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)        scrubber.isEnabled = backend.isSeekable        jumpBackButton.isEnabled = backend.isSeekable        jumpForwardButton.isEnabled = backend.isSeekable        captionsButton.isEnabled = !subtitleOptions.isEmpty        captionsButton.menu = captionsMenu()#if DEBUG        print("[DreamioCaptions] refresh enabled=\(captionsButton.isEnabled) tracks=\(SubtitleDebugFormatter.trackSummary(subtitleTracks)) selected=\(backend.selectedSubtitleTrackID)")#endif        elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)        remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"        if !isScrubbing {2 unmodified lines        [scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = backend.isSeekable ? 1 : 0.45 }    }
        private func revealControls() {        controlsContainer.isUserInteractionEnabled = true        closeButton.isUserInteractionEnabled = true8 unmodified lines9101112131415385 unmodified lines4014024034044054064074 unmodified lines41241341441541641741841942042142242315 unmodified lines4394404414424434444454464474484492 unmodified lines4524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874888 unmodified lines    private var progressTimer: Timer?    private var isScrubbing = false    private var attachedSubtitleURLs: Set<URL>    private var captionsMenuSignature: String?    var onDismiss: (() -> Void)?
        private let loadingView: UIActivityIndicatorView = {385 unmodified lines#if DEBUG                print("[DreamioCaptions] select-result id=\(track.id) after=\(self.backend.selectedSubtitleTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks))")#endif                self.captionsMenuSignature = nil                self.refreshControls()            }        }4 unmodified lines            children: [                UIAction(title: "Decrease 0.5s") { [weak self] _ in                    self?.backend.adjustSubtitleDelay(by: -0.5)                    self?.captionsMenuSignature = nil                    self?.refreshControls()                },                UIAction(title: "Increase 0.5s") { [weak self] _ in                    self?.backend.adjustSubtitleDelay(by: 0.5)                    self?.captionsMenuSignature = nil                    self?.refreshControls()                },                UIAction(15 unmodified lines
        private func refreshControls() {        let subtitleTracks = backend.subtitleTracks        playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)        scrubber.isEnabled = backend.isSeekable        jumpBackButton.isEnabled = backend.isSeekable        jumpForwardButton.isEnabled = backend.isSeekable        updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)        elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)        remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"        if !isScrubbing {2 unmodified lines        [scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = backend.isSeekable ? 1 : 0.45 }    }
        private func updateCaptionsMenuIfNeeded(subtitleTracks: [SubtitleTrack]) {        let selectedTrackID = backend.selectedSubtitleTrackID        let signature = captionsMenuSignatureValue(            tracks: subtitleTracks,            selectedTrackID: selectedTrackID,            delay: backend.subtitleDelay        )        let hasSelectableTrack = subtitleTracks.contains { $0.id >= 0 }        captionsButton.isEnabled = hasSelectableTrack        guard signature != captionsMenuSignature else {            return        }
            captionsMenuSignature = signature        captionsButton.menu = captionsMenu()#if DEBUG        print("[DreamioCaptions] refresh-menu enabled=\(captionsButton.isEnabled) tracks=\(SubtitleDebugFormatter.trackSummary(subtitleTracks)) selected=\(selectedTrackID)")#endif    }
        private func captionsMenuSignatureValue(        tracks: [SubtitleTrack],        selectedTrackID: Int32,        delay: TimeInterval    ) -> String {        let trackSignature = tracks            .map { "\($0.id):\($0.name)" }            .joined(separator: "|")        return "\(trackSignature)#selected=\(selectedTrackID)#delay=\(String(format: "%.1f", delay))"    }
        private func revealControls() {        controlsContainer.isUserInteractionEnabled = true        closeButton.isUserInteractionEnabled = true

### Related issues or PRs

Related Beads issue: `dreamio-bd9`.

## New Changes as of 2026-05-25 10:32 AM EDT

### Summary of changes

Extended subtitle discovery diagnostics upstream of the native captions menu so subtitle-shaped fetch and XHR responses now log even when they produce zero parseable external candidates.

### Why this change was made

The latest run confirmed the app only receives VLC embedded captions for this stream. The remaining unknown is whether Stremio is making subtitle addon requests that the injected parser misses, or whether no external subtitle payload is requested at all.

### Code diffs

### DreamioWebViewController.swift

Dreamio/DreamioWebViewController.swift
-8+57

    80 unmodified lines81828384858639 unmodified lines12612712812913013113210 unmodified lines1431441451461471481496 unmodified lines15615715815916016116216316416516616716816913 unmodified lines18318418518618718817 unmodified lines2062072082092102114 unmodified lines2162172182192202212222232242259 unmodified lines235236237238239240241443 unmodified lines68568668768868969069169280 unmodified lines          const subtitleCandidates = [];          const postedSubtitleURLs = new Set();          const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;
              const looksNative = (url) => {            if (!url || typeof url !== "string") {39 unmodified lines            } catch (_) {}          };
              const postSubtitleCandidates = (candidates) => {            const discoveredCount = candidates.length;            const fresh = candidates.filter((candidate) => {              if (postedSubtitleURLs.has(candidate.url)) {10 unmodified lines                  debug: {                    discovered: discoveredCount,                    deduped: 0,                    forwarded: 0                  }                });              } catch (_) {}6 unmodified lines                debug: {                  discovered: discoveredCount,                  deduped: fresh.length,                  forwarded: fresh.length                }              });            } catch (_) {}          };
              const addSubtitleCandidate = (entry) => {            const rawURL = typeof entry === "string" ? entry : entry && (entry.url || entry.href || entry.src || entry.file || entry.download);            const url = absoluteURL(rawURL);            subtitleURLPattern.lastIndex = 0;            if (!url || !subtitleURLPattern.test(url)) {13 unmodified lines            postSubtitleCandidates([candidate]);          };
              const inspectSubtitlePayload = (payload) => {            if (!payload) {              return;17 unmodified lines            }          };
              const originalFetch = window.fetch;          if (originalFetch) {            window.fetch = async (...args) => {4 unmodified lines                subtitleURLPattern.lastIndex = 0;                const shouldInspect = !contentType                  || /json|text|javascript|xml|subtitle|vtt|srt/i.test(contentType)                  || subtitleURLPattern.test(url);                if (shouldInspect) {                  subtitleURLPattern.lastIndex = 0;                  response.clone().text().then(inspectSubtitlePayload).catch(() => {});                }              } catch (_) {}              return response;9 unmodified lines                  if (responseType && responseType !== "text") {                    return;                  }                  inspectSubtitlePayload(this.responseText);                } catch (_) {}              });            } catch (_) {}443 unmodified lines        let discovered = debug?["discovered"] as? Int ?? parsedCandidates.count        let deduped = debug?["deduped"] as? Int ?? parsedCandidates.count        let posted = debug?["forwarded"] as? Int ?? parsedCandidates.count        let pageURL = dictionary?["pageUrl"] as? String        print("[DreamioSubtitles] bridge discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) playerActive=\(currentNativePlayer != nil) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))")    }#endif}80 unmodified lines8182838485868739 unmodified lines12712812913013113213310 unmodified lines1441451461471481491501516 unmodified lines15815916016116216316416516616716816917017117217317417517617717817918018118218318418513 unmodified lines19920020120220320420520620720820921021121221321421521621717 unmodified lines2352362372382392402412422432442452464 unmodified lines2512522532542552562572582592602612622639 unmodified lines273274275276277278279280281282283284285443 unmodified lines72973073173273373473573673773873974074180 unmodified lines          const subtitleCandidates = [];          const postedSubtitleURLs = new Set();          const subtitleURLPattern = /https?:\/\/[^\s"'<>]+(?:\.srt|\.vtt|\.ass|\.ssa|\.sub|opensubtitles|subtitle)[^\s"'<>]*/ig;          const subtitleSignalPattern = /subtitle|subtitles|opensubtitles|vtt|srt|ass|ssa/i;
              const looksNative = (url) => {            if (!url || typeof url !== "string") {39 unmodified lines            } catch (_) {}          };
              const postSubtitleCandidates = (candidates, debug = {}) => {            const discoveredCount = candidates.length;            const fresh = candidates.filter((candidate) => {              if (postedSubtitleURLs.has(candidate.url)) {10 unmodified lines                  debug: {                    discovered: discoveredCount,                    deduped: 0,                    forwarded: 0,                    ...debug                  }                });              } catch (_) {}6 unmodified lines                debug: {                  discovered: discoveredCount,                  deduped: fresh.length,                  forwarded: fresh.length,                  ...debug                }              });            } catch (_) {}          };
              const addSubtitleCandidate = (entry) => {            const rawURL = typeof entry === "string"              ? entry              : entry && (                entry.url ||                entry.href ||                entry.src ||                entry.link ||                entry.file ||                entry.download ||                entry.externalUrl ||                entry.externalURL ||                entry.fileUrl ||                entry.fileURL              );            const url = absoluteURL(rawURL);            subtitleURLPattern.lastIndex = 0;            if (!url || !subtitleURLPattern.test(url)) {13 unmodified lines            postSubtitleCandidates([candidate]);          };
              const postSubtitleInspection = (source, url, beforeCount, afterCount, payloadLength) => {            if (afterCount > beforeCount) {              return;            }            postSubtitleCandidates([], {              source,              inspected: true,              url: url || "",              payloadLength: payloadLength || 0,              totalKnown: subtitleCandidates.length            });          };
              const inspectSubtitlePayload = (payload) => {            if (!payload) {              return;17 unmodified lines            }          };
              const inspectSubtitleText = (source, url, text) => {            const beforeCount = subtitleCandidates.length;            inspectSubtitlePayload(text);            postSubtitleInspection(source, url, beforeCount, subtitleCandidates.length, text ? text.length : 0);          };
              const originalFetch = window.fetch;          if (originalFetch) {            window.fetch = async (...args) => {4 unmodified lines                subtitleURLPattern.lastIndex = 0;                const shouldInspect = !contentType                  || /json|text|javascript|xml|subtitle|vtt|srt/i.test(contentType)                  || subtitleURLPattern.test(url)                  || subtitleSignalPattern.test(url);                if (shouldInspect) {                  subtitleURLPattern.lastIndex = 0;                  response.clone().text().then((text) => {                    inspectSubtitleText("fetch", url, text);                  }).catch(() => {});                }              } catch (_) {}              return response;9 unmodified lines                  if (responseType && responseType !== "text") {                    return;                  }                  const url = this.responseURL || "";                  const text = this.responseText || "";                  if (subtitleSignalPattern.test(url) || subtitleSignalPattern.test(text)) {                    inspectSubtitleText("xhr", url, text);                  } else {                    inspectSubtitlePayload(text);                  }                } catch (_) {}              });            } catch (_) {}443 unmodified lines        let discovered = debug?["discovered"] as? Int ?? parsedCandidates.count        let deduped = debug?["deduped"] as? Int ?? parsedCandidates.count        let posted = debug?["forwarded"] as? Int ?? parsedCandidates.count        let source = debug?["source"] as? String ?? "bridge"        let inspected = debug?["inspected"] as? Bool ?? false        let inspectedURL = (debug?["url"] as? String).map(redactedURLString) ?? "none"        let payloadLength = debug?["payloadLength"] as? Int ?? 0        let totalKnown = debug?["totalKnown"] as? Int ?? parsedCandidates.count        let pageURL = dictionary?["pageUrl"] as? String        print("[DreamioSubtitles] bridge source=\(source) inspected=\(inspected) discovered=\(discovered) deduped=\(deduped) posted=\(posted) parsed=\(parsedCandidates.count) totalKnown=\(totalKnown) payloadLength=\(payloadLength) playerActive=\(currentNativePlayer != nil) inspectedURL=\(inspectedURL) page=\(pageURL.map(redactedURLString) ?? "unknown") candidates=\(SubtitleDebugFormatter.candidateSummary(parsedCandidates))")    }#endif}

### Related issues or PRs

Related Beads issue: `dreamio-ese`.