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

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

Relevant Diff Snippets

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

DreamioWebViewController.swift

Dreamio/DreamioWebViewController.swift
-3+37
126 unmodified lines
127
128
129
130
131
132
2 unmodified lines
135
136
137
138
139
140
141
142
143
144
145
146
333 unmodified lines
480
481
482
483
484
485
3 unmodified lines
489
490
491
492
493
494
495
162 unmodified lines
658
659
660
661
662
663
71 unmodified lines
735
736
737
738
739
740
741
126 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 lines
127
128
129
130
131
132
133
2 unmodified lines
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
333 unmodified lines
497
498
499
500
501
502
503
504
505
3 unmodified lines
509
510
511
512
513
514
515
162 unmodified lines
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
71 unmodified lines
765
766
767
768
769
770
771
772
773
774
775
126 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 lines
140
141
142
143
144
145
146
8 unmodified lines
155
156
157
158
159
160
161
10 unmodified lines
172
173
174
175
176
177
178
139 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 lines
140
141
142
143
144
145
146
8 unmodified lines
155
156
157
158
159
160
161
10 unmodified lines
172
173
174
175
176
177
178
139 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 lines
214
215
216
217
218
219
220
5 unmodified lines
226
227
228
229
230
231
232
233
234
235
12 unmodified lines
248
249
250
251
252
253
213 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 DEBUG
5 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:
break
213 unmodified lines
214
215
216
217
218
219
220
221
5 unmodified lines
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
12 unmodified lines
260
261
262
263
264
265
266
267
268
213 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 DEBUG
5 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 lines
40
41
42
43
44
45
39 unmodified lines
let name: String
}
enum PlaybackTimeFormatter {
static func label(for seconds: TimeInterval) -> String {
guard seconds.isFinite, seconds > 0 else {
39 unmodified lines
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
39 unmodified lines
let name: String
}
#if DEBUG
enum 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

Issues, Limitations, and Mitigations

Follow-up Work