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.
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
URLRedactorwhere URLs are printed. Candidate summaries intentionally show only label, language, and file extension. - The VLC logs print
videoSubTitlesNames,videoSubTitlesIndexes, andcurrentVideoSubTitleIndexat the moments most relevant to track exposure.
Relevant Diff Snippets
Rendered with @pierre/diffs/ssr using one file diff per render.
DreamioWebViewController.swift
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 linesreturn true;});if (fresh.length === 0) {return;}try {window.webkit.messageHandlers.dreamioSubtitleCandidate.postMessage({pageUrl: window.location.href,subtitles: fresh});} catch (_) {}};333 unmodified linesreturn}guard let currentNativePlayer else {#if DEBUGprint("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player")3 unmodified lineslet forwarded = currentNativePlayer.addSubtitleCandidates(candidates)#if DEBUGprint("[DreamioSubtitles] discovered=\(candidates.count) forwarded=\(forwarded)")#endif}162 unmodified linesprivate 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 linesreturn 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 linesreturn}#if DEBUGprint("[DreamioSubtitles] native discovered=\(candidates.count) playerActive=\(currentNativePlayer != nil) candidates=\(SubtitleDebugFormatter.candidateSummary(candidates))")#endifguard let currentNativePlayer else {#if DEBUGprint("[DreamioSubtitles] discovered=\(candidates.count) forwarded=0 reason=no-active-native-player")3 unmodified lineslet forwarded = currentNativePlayer.addSubtitleCandidates(candidates)#if DEBUGprint("[DreamioSubtitles] discovered=\(candidates.count) forwarded=\(forwarded) reason=active-native-player")#endif}162 unmodified linesprivate 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.countlet deduped = debug?["deduped"] as? Int ?? parsedCandidates.countlet posted = debug?["forwarded"] as? Int ?? parsedCandidates.countlet pageURL = dictionary?["pageUrl"] as? Stringprint("[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 DEBUGlogSubtitleBridgeMessage(message.body, parsedCandidates: candidates)#endifhandleSubtitleCandidates(candidates)return}
NativePlayerViewController.swift
139 unmodified lines1401411421431441451468 unmodified lines15515615715815916016110 unmodified lines172173174175176177178139 unmodified lineslet pendingCandidates = candidates.filter { !attachedSubtitleURLs.contains($0.url) }guard !pendingCandidates.isEmpty else {#if DEBUGprint("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=0 duplicates=\(candidates.count)")#endifreturn 0}8 unmodified linesawait MainActor.run {guard !resolvedCandidates.isEmpty else {#if DEBUGprint("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=0 attached=0")#endifreturn}10 unmodified lines}#if DEBUGlet duplicateCount = candidates.count - pendingCandidates.count + resolvedCandidates.count - attachableCandidates.countprint("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=\(pendingCandidates.count) resolved=\(resolvedCandidates.count) attached=\(attachedCount) duplicates=\(duplicateCount)")#endif}}139 unmodified lines1401411421431441451468 unmodified lines15515615715815916016110 unmodified lines172173174175176177178139 unmodified lineslet pendingCandidates = candidates.filter { !attachedSubtitleURLs.contains($0.url) }guard !pendingCandidates.isEmpty else {#if DEBUGprint("[DreamioNativePlayer] subtitle candidates=\(candidates.count) pending=0 duplicates=\(candidates.count) resolved=0 attached=0 tracks=\(SubtitleDebugFormatter.trackSummary(backend.subtitleTracks)) selected=\(backend.selectedSubtitleTrackID)")#endifreturn 0}8 unmodified linesawait MainActor.run {guard !resolvedCandidates.isEmpty else {#if DEBUGprint("[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))")#endifreturn}10 unmodified lines}#if DEBUGlet duplicateCount = candidates.count - pendingCandidates.count + resolvedCandidates.count - attachableCandidates.countprint("[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
213 unmodified lines2142152162172182192205 unmodified lines22622722822923023123223323423512 unmodified lines248249250251252253213 unmodified linesmediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)attachedCount += 1#if DEBUGprint("[DreamioVLC] attached subtitle=\(URLRedactor.redactedURLString(candidate.url.absoluteString))")#endif}#if DEBUG5 unmodified linesreturn attachedCount}DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] inself?.onSubtitleTracksChange?()}return attachedCount}#endif}12 unmodified linescase .paused, .stopped, .ended:onStateChange?()case .esAdded:onSubtitleTracksChange?()default:break213 unmodified lines2142152162172182192202215 unmodified lines22722822923023123223323423523623723823924024124224324424524624712 unmodified lines260261262263264265266267268213 unmodified linesmediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)attachedCount += 1#if DEBUGprint("[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 linesreturn attachedCount}DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in#if DEBUGself?.logSubtitleTracks(reason: "delayed-refresh")#endifself?.onSubtitleTracksChange?()}return attachedCount}#if DEBUGprivate 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 linescase .paused, .stopped, .ended:onStateChange?()case .esAdded:#if DEBUGlogSubtitleTracks(reason: "esAdded")#endifonSubtitleTracksChange?()default:break
StreamCandidate.swift
39 unmodified lines40414243444539 unmodified lineslet name: String}enum PlaybackTimeFormatter {static func label(for seconds: TimeInterval) -> String {guard seconds.isFinite, seconds > 0 else {39 unmodified lines40414243444546474849505152535455565758596061626364656667686970717239 unmodified lineslet name: String}#if DEBUGenum SubtitleDebugFormatter {static func candidateSummary(_ candidates: [SubtitleCandidate]) -> String {guard !candidates.isEmpty else {return "[]"}return candidates.map { candidate inlet 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.labelreturn "{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: ", ")}}#endifenum 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=NOpassed.xcodebuild test -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'platform=iOS Simulator,name=iPhone 16'could not run because theDreamioscheme 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 discoveredentries, inspect the current Stremio/OpenSubtitles payload shape and extend discovery. - If logs show
playerActive=falsebefore 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
379 unmodified lines38038138238338438538638738838939039139227 unmodified lines420421422423424425426427428429430431379 unmodified linesprivate func captionsMenu() -> UIMenu {let selectedTrackID = backend.selectedSubtitleTrackIDlet trackActions = SubtitleOptionMapper.options(from: backend.subtitleTracks).map { track inUIAction(title: track.name,state: track.id == selectedTrackID ? .on : .off) { [weak self] _ inself?.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.isSeekablejumpBackButton.isEnabled = backend.isSeekablejumpForwardButton.isEnabled = backend.isSeekablecaptionsButton.isEnabled = !SubtitleOptionMapper.options(from: backend.subtitleTracks).isEmptycaptionsButton.menu = captionsMenu()elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"if !isScrubbing {379 unmodified lines38038138238338438538638738838939039139239339439539639739839940040140240340440540627 unmodified lines434435436437438439440441442443444445446447448449450379 unmodified linesprivate func captionsMenu() -> UIMenu {let selectedTrackID = backend.selectedSubtitleTrackIDlet tracks = backend.subtitleTrackslet options = SubtitleOptionMapper.options(from: tracks)#if DEBUGprint("[DreamioCaptions] build-menu tracks=\(SubtitleDebugFormatter.trackSummary(tracks)) options=\(SubtitleDebugFormatter.trackSummary(options)) selected=\(selectedTrackID)")#endiflet trackActions = options.map { track inUIAction(title: track.name,state: track.id == selectedTrackID ? .on : .off) { [weak self] _ inguard let self else {return}#if DEBUGprint("[DreamioCaptions] select-request id=\(track.id) name=\(track.name) before=\(self.backend.selectedSubtitleTrackID)")#endifself.backend.selectSubtitleTrack(id: track.id)#if DEBUGprint("[DreamioCaptions] select-result id=\(track.id) after=\(self.backend.selectedSubtitleTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks))")#endifself.refreshControls()}}27 unmodified lines}private func refreshControls() {let subtitleTracks = backend.subtitleTrackslet subtitleOptions = SubtitleOptionMapper.options(from: subtitleTracks)playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)scrubber.isEnabled = backend.isSeekablejumpBackButton.isEnabled = backend.isSeekablejumpForwardButton.isEnabled = backend.isSeekablecaptionsButton.isEnabled = !subtitleOptions.isEmptycaptionsButton.menu = captionsMenu()#if DEBUGprint("[DreamioCaptions] refresh enabled=\(captionsButton.isEnabled) tracks=\(SubtitleDebugFormatter.trackSummary(subtitleTracks)) selected=\(backend.selectedSubtitleTrackID)")#endifelapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"if !isScrubbing {
VLCNativePlaybackBackend.swift
99 unmodified lines10010110210310410510610710810911011111211399 unmodified linesfunc selectSubtitleTrack(id: Int32) {#if canImport(MobileVLCKit)mediaPlayer.currentVideoSubTitleIndex = idonSubtitleTracksChange?()#endif}func adjustSubtitleDelay(by seconds: TimeInterval) {#if canImport(MobileVLCKit)mediaPlayer.currentVideoSubTitleDelay += Int(seconds * 1_000_000)onSubtitleTracksChange?()#endif}99 unmodified lines10010110210310410510610710810911011111211311411511611711811912012112212312412599 unmodified linesfunc selectSubtitleTrack(id: Int32) {#if canImport(MobileVLCKit)#if DEBUGlogSubtitleTracks(reason: "before-select-\(id)")#endifmediaPlayer.currentVideoSubTitleIndex = id#if DEBUGlogSubtitleTracks(reason: "after-select-\(id)")#endifonSubtitleTracksChange?()#endif}func adjustSubtitleDelay(by seconds: TimeInterval) {#if canImport(MobileVLCKit)#if DEBUGprint("[DreamioVLC] subtitle delay before=\(subtitleDelay) delta=\(seconds)")#endifmediaPlayer.currentVideoSubTitleDelay += Int(seconds * 1_000_000)#if DEBUGprint("[DreamioVLC] subtitle delay after=\(subtitleDelay)")#endifonSubtitleTracksChange?()#endif}
Related issues or PRs
Related Beads issue: dreamio-c1m.