Auto-select VLC subtitle tracks

Turn document for dreamio-djc, created May 25, 2026.

Dreamio now asks MobileVLCKit to enable the first real subtitle track when VLC discovers embedded MKV subtitles and playback is still on “Disable.” This targets the log pattern where VLC sees English (SDH) but leaves selected=-1.

Summary

Fixed the remaining native playback subtitle issue for streams that provide no external subtitle candidates but do contain embedded subtitle tracks. VLC can discover those tracks after playback starts; Dreamio now auto-selects the first selectable one once it appears.

Changes Made

Context

The reported logs showed subtitle candidates=0 from the native player, followed by VLC reporting subtitle tracks named Disable and English (SDH) - [English] with selected track -1. That means stream parsing and VLC track discovery were both working; the remaining gap was that Dreamio never changed VLC away from the disabled subtitle track.

Important Implementation Details

Relevant Diff Snippets

Rendered with @pierre/diffs/ssr.

Dreamio/VLCNativePlaybackBackend.swift
+22
22 unmodified lines
23
24
25
26
27
28
12 unmodified lines
41
42
43
44
45
46
53 unmodified lines
100
101
102
103
104
105
133 unmodified lines
239
240
241
242
243
244
9 unmodified lines
254
255
256
257
258
259
12 unmodified lines
272
273
274
275
276
277
22 unmodified lines
private let mediaPlayer = VLCMediaPlayer()
#endif
private var attachedSubtitleURLs = Set<URL>()
override init() {
super.init()
12 unmodified lines
func play(request: NativePlaybackRequest) {
#if canImport(MobileVLCKit)
attachedSubtitleURLs.removeAll()
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
53 unmodified lines
func selectSubtitleTrack(id: Int32) {
#if canImport(MobileVLCKit)
#if DEBUG
logSubtitleTracks(reason: "before-select-\(id)")
#endif
133 unmodified lines
return attachedCount
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
#if DEBUG
self?.logSubtitleTracks(reason: "delayed-refresh")
#endif
9 unmodified lines
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
22 unmodified lines
23
24
25
26
27
28
29
30
12 unmodified lines
43
44
45
46
47
48
49
50
53 unmodified lines
104
105
106
107
108
109
110
133 unmodified lines
244
245
246
247
248
249
250
9 unmodified lines
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
12 unmodified lines
293
294
295
296
297
298
299
22 unmodified lines
private let mediaPlayer = VLCMediaPlayer()
#endif
private var attachedSubtitleURLs = Set<URL>()
private var didAutoSelectSubtitleTrack = false
private var didUserSelectSubtitleTrack = false
override init() {
super.init()
12 unmodified lines
func play(request: NativePlaybackRequest) {
#if canImport(MobileVLCKit)
attachedSubtitleURLs.removeAll()
didAutoSelectSubtitleTrack = false
didUserSelectSubtitleTrack = false
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
53 unmodified lines
func selectSubtitleTrack(id: Int32) {
#if canImport(MobileVLCKit)
didUserSelectSubtitleTrack = true
#if DEBUG
logSubtitleTracks(reason: "before-select-\(id)")
#endif
133 unmodified lines
return attachedCount
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in
self?.selectInitialSubtitleTrackIfNeeded(reason: "delayed-refresh")
#if DEBUG
self?.logSubtitleTracks(reason: "delayed-refresh")
#endif
9 unmodified lines
print("[DreamioVLC] subtitle tracks reason=\(reason) names=\(names) indexes=\(indexes.map { $0.int32Value }) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
}
#endif
private func selectInitialSubtitleTrackIfNeeded(reason: String) {
guard !didUserSelectSubtitleTrack,
!didAutoSelectSubtitleTrack,
mediaPlayer.currentVideoSubTitleIndex < 0,
let track = subtitleTracks.first(where: { $0.id >= 0 }) else {
return
}
didAutoSelectSubtitleTrack = true
#if DEBUG
print("[DreamioVLC] auto-select subtitle id=\(track.id) name=\(track.name) reason=\(reason)")
#endif
mediaPlayer.currentVideoSubTitleIndex = track.id
}
#endif
}
12 unmodified lines
case .paused, .stopped, .ended:
onStateChange?()
case .esAdded:
selectInitialSubtitleTrackIfNeeded(reason: "esAdded")
#if DEBUG
logSubtitleTracks(reason: "esAdded")
#endif

Expected Impact for End-Users

MKV streams with embedded subtitles should show captions automatically instead of requiring the user to open the captions menu and pick the embedded track manually. Users can still disable captions or switch tracks afterward.

Validation

Issues, Limitations, and Mitigations

Follow-up Work

New Changes as of May 25, 2026 at 10:45 AM EDT

Summary of changes: Added a timed re-apply loop for the automatically selected VLC subtitle track. Dreamio now remembers the auto-selected track id and re-sends it after short delays and when VLC reports buffering or playing.

Why this change was made: Follow-up device logs showed VLC selected track 3, but subtitles still did not render. That points to a MobileVLCKit timing issue after elementary stream discovery, so the selected embedded track is re-applied after playback settles.

Code diffs:

Dreamio/VLCNativePlaybackBackend.swift
+27
24 unmodified lines
25
26
27
28
29
30
14 unmodified lines
45
46
47
48
49
50
54 unmodified lines
105
106
107
108
109
110
159 unmodified lines
270
271
272
273
274
275
276
277
278
279
6 unmodified lines
286
287
288
289
290
291
24 unmodified lines
private var attachedSubtitleURLs = Set<URL>()
private var didAutoSelectSubtitleTrack = false
private var didUserSelectSubtitleTrack = false
override init() {
super.init()
14 unmodified lines
attachedSubtitleURLs.removeAll()
didAutoSelectSubtitleTrack = false
didUserSelectSubtitleTrack = false
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
54 unmodified lines
func selectSubtitleTrack(id: Int32) {
#if canImport(MobileVLCKit)
didUserSelectSubtitleTrack = true
#if DEBUG
logSubtitleTracks(reason: "before-select-\(id)")
#endif
159 unmodified lines
}
didAutoSelectSubtitleTrack = true
#if DEBUG
print("[DreamioVLC] auto-select subtitle id=\(track.id) name=\(track.name) reason=\(reason)")
#endif
mediaPlayer.currentVideoSubTitleIndex = track.id
}
#endif
}
6 unmodified lines
#endif
switch mediaPlayer.state {
case .buffering, .playing:
onReady?()
onStateChange?()
case .error:
24 unmodified lines
25
26
27
28
29
30
31
14 unmodified lines
46
47
48
49
50
51
52
54 unmodified lines
107
108
109
110
111
112
113
159 unmodified lines
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
6 unmodified lines
312
313
314
315
316
317
318
24 unmodified lines
private var attachedSubtitleURLs = Set<URL>()
private var didAutoSelectSubtitleTrack = false
private var didUserSelectSubtitleTrack = false
private var autoSelectedSubtitleTrackID: Int32?
override init() {
super.init()
14 unmodified lines
attachedSubtitleURLs.removeAll()
didAutoSelectSubtitleTrack = false
didUserSelectSubtitleTrack = false
autoSelectedSubtitleTrackID = nil
let media = VLCMedia(url: request.playbackURL)
let headerValue = request.headers
.map { "\($0.key): \($0.value)" }
54 unmodified lines
func selectSubtitleTrack(id: Int32) {
#if canImport(MobileVLCKit)
didUserSelectSubtitleTrack = true
autoSelectedSubtitleTrackID = nil
#if DEBUG
logSubtitleTracks(reason: "before-select-\(id)")
#endif
159 unmodified lines
}
didAutoSelectSubtitleTrack = true
autoSelectedSubtitleTrackID = track.id
#if DEBUG
print("[DreamioVLC] auto-select subtitle id=\(track.id) name=\(track.name) reason=\(reason)")
#endif
mediaPlayer.currentVideoSubTitleIndex = track.id
scheduleAutoSubtitleSelectionReapply(trackID: track.id)
}
private func scheduleAutoSubtitleSelectionReapply(trackID: Int32) {
[0.3, 1.0, 2.0, 4.0].forEach { delay in
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.reapplyAutoSelectedSubtitleTrackIfNeeded(reason: "delayed-\(String(format: "%.1f", delay))")
}
}
}
private func reapplyAutoSelectedSubtitleTrackIfNeeded(reason: String) {
guard !didUserSelectSubtitleTrack,
let trackID = autoSelectedSubtitleTrackID,
subtitleTracks.contains(where: { $0.id == trackID }) else {
return
}
mediaPlayer.currentVideoSubTitleIndex = trackID
#if DEBUG
print("[DreamioVLC] reapply subtitle id=\(trackID) reason=\(reason) selected=\(mediaPlayer.currentVideoSubTitleIndex)")
#endif
}
#endif
}
6 unmodified lines
#endif
switch mediaPlayer.state {
case .buffering, .playing:
reapplyAutoSelectedSubtitleTrackIfNeeded(reason: stateName(mediaPlayer.state))
onReady?()
onStateChange?()
case .error:

Related issues or PRs: Beads issue dreamio-ppj.

New Changes as of May 25, 2026 at 10:51 AM EDT

Summary of changes: Added browser text-track capture for OpenSubtitles V3 subtitles. The web bridge now inspects <track> elements, HTMLTrackElement.src assignments, setAttribute("src", ...) calls, video.textTracks, and late addtrack events.

Why this change was made: The device logs showed embedded subtitles were visible, but OpenSubtitles V3 options still never reached native playback. That means the external subtitle candidates were being missed before VLC, likely because Stremio attached them as browser text tracks rather than including them in the original stream candidate.

Code diffs:

Dreamio/DreamioWebViewController.swift
-4+69
198 unmodified lines
199
200
201
202
203
204
102 unmodified lines
307
308
309
310
311
312
313
314
315
316
317
19 unmodified lines
337
338
339
340
341
342
343
344
345
346
2 unmodified lines
349
350
351
352
353
354
355
356
357
7 unmodified lines
365
366
367
368
369
370
371
198 unmodified lines
postSubtitleCandidates([candidate]);
};
const postSubtitleInspection = (source, url, beforeCount, afterCount, payloadLength) => {
if (afterCount > beforeCount) {
return;
102 unmodified lines
if (!node) {
return;
}
if (node instanceof HTMLVideoElement || node instanceof HTMLSourceElement) {
postCandidate(node.currentSrc || node.src || node.getAttribute("src"), node);
}
if (node.querySelectorAll) {
node.querySelectorAll("video, source").forEach(inspectMedia);
}
};
19 unmodified lines
});
}
const originalSetAttribute = Element.prototype.setAttribute;
Element.prototype.setAttribute = function(name, value) {
if (String(name).toLowerCase() === "src" && (this instanceof HTMLVideoElement || this instanceof HTMLSourceElement)) {
postCandidate(value, this);
}
return originalSetAttribute.call(this, name, value);
};
2 unmodified lines
HTMLMediaElement.prototype.load = function() {
inspectMedia(this);
this.querySelectorAll("source").forEach(inspectMedia);
return originalLoad.call(this);
};
document.addEventListener("loadedmetadata", (event) => inspectMedia(event.target), true);
document.addEventListener("error", (event) => inspectMedia(event.target), true);
new MutationObserver((mutations) => {
7 unmodified lines
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["src"]
});
inspectMedia(document);
198 unmodified lines
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
102 unmodified lines
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
19 unmodified lines
374
375
376
377
378
379
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
407
2 unmodified lines
410
411
412
413
414
415
416
417
418
419
420
421
422
7 unmodified lines
430
431
432
433
434
435
436
198 unmodified lines
postSubtitleCandidates([candidate]);
};
const inspectTrack = (track) => {
if (!track) {
return;
}
if (track instanceof HTMLTrackElement) {
addSubtitleCandidate({
url: track.src || track.getAttribute("src") || "",
label: track.label || track.srclang || "External Subtitle",
language: track.srclang || ""
});
return;
}
const source = track.src || track.url || "";
if (source) {
addSubtitleCandidate({
url: source,
label: track.label || track.language || track.kind || "External Subtitle",
language: track.language || ""
});
}
};
const inspectTextTracks = (media) => {
try {
Array.from(media.textTracks || []).forEach(inspectTrack);
} catch (_) {}
try {
media.querySelectorAll("track").forEach(inspectTrack);
} catch (_) {}
};
const postSubtitleInspection = (source, url, beforeCount, afterCount, payloadLength) => {
if (afterCount > beforeCount) {
return;
102 unmodified lines
if (!node) {
return;
}
if (node instanceof HTMLTrackElement) {
inspectTrack(node);
}
if (node instanceof HTMLVideoElement || node instanceof HTMLSourceElement) {
postCandidate(node.currentSrc || node.src || node.getAttribute("src"), node);
}
if (node.querySelectorAll) {
node.querySelectorAll("video, source, track").forEach(inspectMedia);
}
if (node instanceof HTMLVideoElement) {
inspectTextTracks(node);
}
};
19 unmodified lines
});
}
const trackSrcDescriptor = Object.getOwnPropertyDescriptor(HTMLTrackElement.prototype, "src");
if (trackSrcDescriptor && trackSrcDescriptor.set) {
Object.defineProperty(HTMLTrackElement.prototype, "src", {
get: trackSrcDescriptor.get,
set(value) {
addSubtitleCandidate({
url: value,
label: this.label || this.srclang || "External Subtitle",
language: this.srclang || ""
});
return trackSrcDescriptor.set.call(this, value);
}
});
}
const originalSetAttribute = Element.prototype.setAttribute;
Element.prototype.setAttribute = function(name, value) {
if (String(name).toLowerCase() === "src") {
if (this instanceof HTMLVideoElement || this instanceof HTMLSourceElement) {
postCandidate(value, this);
}
if (this instanceof HTMLTrackElement) {
addSubtitleCandidate({
url: value,
label: this.label || this.srclang || "External Subtitle",
language: this.srclang || ""
});
}
}
return originalSetAttribute.call(this, name, value);
};
2 unmodified lines
HTMLMediaElement.prototype.load = function() {
inspectMedia(this);
this.querySelectorAll("source").forEach(inspectMedia);
inspectTextTracks(this);
return originalLoad.call(this);
};
document.addEventListener("addtrack", (event) => {
inspectTrack(event.track || event.target);
}, true);
document.addEventListener("loadedmetadata", (event) => inspectMedia(event.target), true);
document.addEventListener("error", (event) => inspectMedia(event.target), true);
new MutationObserver((mutations) => {
7 unmodified lines
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["src", "label", "srclang"]
});
inspectMedia(document);

Related issues or PRs: Beads issue dreamio-3xi.