# 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

- Added per-playback state to track whether Dreamio has already auto-selected subtitles.
- Added per-playback state to detect when the user manually chooses a caption option, including disabling captions.
- Auto-selects the first subtitle track with a non-negative VLC track id when VLC reports `.esAdded` or after an external subtitle attach refresh.
- Added a debug log line for automatic subtitle selection so future device logs should show the selected track id and reason.

## 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

- The selection guard only runs when `currentVideoSubTitleIndex` is below zero, so it will not replace an already active subtitle track.
- The auto-selection runs only once per playback item.
- User interaction wins: once `selectSubtitleTrack(id:)` is called from the captions menu, Dreamio stops automatic caption selection for that playback item.
- The state resets in `play(request:)`, alongside the existing attached subtitle URL reset.

## Relevant Diff Snippets

Rendered with `@pierre/diffs/ssr`.

Dreamio/VLCNativePlaybackBackend.swift
+22

    22 unmodified lines23242526272812 unmodified lines41424344454653 unmodified lines100101102103104105133 unmodified lines2392402412422432449 unmodified lines25425525625725825912 unmodified lines27227327427527627722 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)")#endif133 unmodified lines            return attachedCount        }        DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in            #if DEBUG            self?.logSubtitleTracks(reason: "delayed-refresh")            #endif9 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")#endif22 unmodified lines232425262728293012 unmodified lines434445464748495053 unmodified lines104105106107108109110133 unmodified lines2442452462472482492509 unmodified lines26026126226326426526626726826927027127227327427527627727827928012 unmodified lines29329429529629729829922 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)")#endif133 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")            #endif9 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

- Ran `xcodebuild -scheme Dreamio -project Dreamio.xcodeproj -destination 'generic/platform=iOS' build`.
- The build succeeded against MobileVLCKit.

## Issues, Limitations, and Mitigations

- This was validated by build, not by replaying the exact Real-Debrid stream on device in this turn.
- If a file has multiple embedded subtitle tracks, Dreamio chooses the first selectable track VLC exposes. The captions menu remains available for manual switching.
- If a stream intentionally starts with subtitles disabled and the user never touches the captions menu, Dreamio will now enable the first discovered track by default.

## Follow-up Work

- Test the same South Park MKV on device and confirm the logs include `[DreamioVLC] auto-select subtitle` followed by a non-negative selected subtitle id.
- Consider a future setting for “auto-enable embedded subtitles” if users want control over the default behavior.

## 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 lines25262728293014 unmodified lines45464748495054 unmodified lines105106107108109110159 unmodified lines2702712722732742752762772782796 unmodified lines28628728828929029124 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)")#endif159 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 lines2526272829303114 unmodified lines4647484950515254 unmodified lines107108109110111112113159 unmodified lines2732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043056 unmodified lines31231331431531631731824 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)")#endif159 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 lines199200201202203204102 unmodified lines30730830931031131231331431531631719 unmodified lines3373383393403413423433443453462 unmodified lines3493503513523533543553563577 unmodified lines365366367368369370371198 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 lines199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235102 unmodified lines33833934034134234334434534634734834935035135235335419 unmodified lines3743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064072 unmodified lines4104114124134144154164174184194204214227 unmodified lines430431432433434435436198 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`.