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

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 lines
380
381
382
383
384
385
386
387
388
389
390
391
392
27 unmodified lines
420
421
422
423
424
425
426
427
428
429
430
431
379 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 lines
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
27 unmodified lines
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
379 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 lines
100
101
102
103
104
105
106
107
108
109
110
111
112
113
99 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 lines
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
99 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 lines
9
10
11
12
13
14
385 unmodified lines
400
401
402
403
404
405
4 unmodified lines
410
411
412
413
414
415
416
417
418
419
15 unmodified lines
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
2 unmodified lines
453
454
455
456
457
458
8 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 = true
8 unmodified lines
9
10
11
12
13
14
15
385 unmodified lines
401
402
403
404
405
406
407
4 unmodified lines
412
413
414
415
416
417
418
419
420
421
422
423
15 unmodified lines
439
440
441
442
443
444
445
446
447
448
449
2 unmodified lines
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
8 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 lines
81
82
83
84
85
86
39 unmodified lines
126
127
128
129
130
131
132
10 unmodified lines
143
144
145
146
147
148
149
6 unmodified lines
156
157
158
159
160
161
162
163
164
165
166
167
168
169
13 unmodified lines
183
184
185
186
187
188
17 unmodified lines
206
207
208
209
210
211
4 unmodified lines
216
217
218
219
220
221
222
223
224
225
9 unmodified lines
235
236
237
238
239
240
241
443 unmodified lines
685
686
687
688
689
690
691
692
80 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 lines
81
82
83
84
85
86
87
39 unmodified lines
127
128
129
130
131
132
133
10 unmodified lines
144
145
146
147
148
149
150
151
6 unmodified lines
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
13 unmodified lines
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
17 unmodified lines
235
236
237
238
239
240
241
242
243
244
245
246
4 unmodified lines
251
252
253
254
255
256
257
258
259
260
261
262
263
9 unmodified lines
273
274
275
276
277
278
279
280
281
282
283
284
285
443 unmodified lines
729
730
731
732
733
734
735
736
737
738
739
740
741
80 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.