Dreamio Turn Document · May 25, 2026

# Show OpenSubtitles Languages in Caption Tracks

External subtitles attached through VLC now keep Dreamio's parsed language metadata, so the captions menu can show useful names like `English`, `Spanish`, or `English SDH` instead of generic VLC labels such as `Track 1`.

Issue: dreamio-2ju Scope: subtitle display names Validation: tests and simulator build passed

## Summary

Dreamio already parsed OpenSubtitles labels and language codes, but VLC exposed attached external subtitle tracks later with generic names. This change preserves each external candidate's best display name, maps it onto the new VLC track ID once it appears, and returns the preserved name whenever VLC's reported name is generic or empty.

## Changes Made

- Added `SubtitleDisplayName`, an internal helper for turning subtitle labels and language metadata into menu-ready names.
- Added VLC backend state for pending external subtitle display names and track-ID-to-display-name mappings.
- Updated `subtitleTracks` so generic VLC names are replaced only when Dreamio has a preserved external subtitle name for that specific track ID.
- Added unit coverage for language-code normalization, meaningful labels, filename fallback, and menu option names.

## Context

OpenSubtitles and Stremio payloads often carry useful metadata such as `eng`, `Spanish`, or `English SDH`. The parser kept that metadata on `SubtitleCandidate`, but once `addPlaybackSlave` handed a URL to VLC, the eventual track list could contain only `Track 1`, `Track 2`, or an empty string. The captions menu reads from `backend.subtitleTracks`, so the safest fix is to improve that backend output.

## Important Implementation Details

- Labels are trusted when they are meaningful. `English SDH` stays `English SDH`.
- Generic labels such as `Track 1`, `External Subtitle`, empty strings, and numeric-only labels fall back to language metadata.
- Common language codes are normalized explicitly, including `eng`/`en`, `spa`/`es`, and `fre`/`fra`/`fr`. The helper also uses `Locale` for broader short-code support.
- The VLC backend captures baseline subtitle track IDs before attachment, then assigns preserved names to newly visible external track IDs in attachment order.
- Embedded tracks are left alone unless they match a newly mapped external ID and VLC reports a generic or empty name.

## Relevant Diff Snippets

### Dreamio/StreamCandidate.swift

Dreamio/StreamCandidate.swift
+102

    39 unmodified lines40414243444539 unmodified lines    let name: String}
    #if DEBUGenum SubtitleDebugFormatter {    static func candidateSummary(_ candidates: [SubtitleCandidate]) -> String {39 unmodified lines40414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614739 unmodified lines    let name: String}
    enum SubtitleDisplayName {    private static let genericLabels = [        "external subtitle",        "subtitle",        "unknown"    ]
        private static let languageCodeAliases = [        "eng": "en",        "en": "en",        "spa": "es",        "es": "es",        "fre": "fr",        "fra": "fr",        "fr": "fr"    ]
        static func displayName(for candidate: SubtitleCandidate) -> String {        if let label = meaningfulDisplayText(candidate.label) {            return label        }
            if let languageName = languageName(for: candidate.language) {            return languageName        }
            return fallbackName(from: candidate)    }
        static func name(forVLCTrackName trackName: String, preservedName: String?) -> String {        guard isGenericLabel(trackName), let preservedName else {            return trackName        }        return preservedName    }
        static func isGenericLabel(_ value: String) -> Bool {        let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)        guard !normalized.isEmpty else {            return true        }
            let lowercased = normalized.lowercased()        if genericLabels.contains(lowercased) {            return true        }        if Int(normalized) != nil {            return true        }        if lowercased.range(of: #"^track\s*\d+$"#, options: .regularExpression) != nil {            return true        }        return false    }
        private static func meaningfulDisplayText(_ value: String?) -> String? {        guard let value else {            return nil        }        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)        guard !isGenericLabel(trimmed) else {            return nil        }        guard trimmed.count <= 3 else {            return trimmed        }        return languageName(for: trimmed) ?? trimmed    }
        private static func languageName(for value: String?) -> String? {        guard let value else {            return nil        }
            let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)        guard !trimmed.isEmpty, Int(trimmed) == nil else {            return nil        }
            if trimmed.count > 3 {            return trimmed.capitalized        }
            let lowercased = trimmed.lowercased()        let languageCode = languageCodeAliases[lowercased] ?? lowercased        guard let name = Locale.current.localizedString(forLanguageCode: languageCode) else {            return nil        }        return name.capitalized    }
        private static func fallbackName(from candidate: SubtitleCandidate) -> String {        let trimmedLabel = candidate.label.trimmingCharacters(in: .whitespacesAndNewlines)        if !isGenericLabel(trimmedLabel) {            return trimmedLabel        }
            let fileName = candidate.url.deletingPathExtension().lastPathComponent        return fileName.isEmpty ? "External Subtitle" : fileName    }}
    #if DEBUGenum SubtitleDebugFormatter {    static func candidateSummary(_ candidates: [SubtitleCandidate]) -> String {

### Dreamio/VLCNativePlaybackBackend.swift

Dreamio/VLCNativePlaybackBackend.swift
-5+41

    27 unmodified lines28293031323317 unmodified lines515253545556142 unmodified lines19920020120220320420520620720820 unmodified lines2292302312322332342352 unmodified lines23823924024124224324 unmodified lines26826927027127227327 unmodified lines    private var autoSelectedSubtitleTrackID: Int32?    private var externalSubtitleBaselineTrackIDs = Set<Int32>()    private var hasPendingExternalSubtitleSelection = false
        override init() {        super.init()17 unmodified lines        autoSelectedSubtitleTrackID = nil        externalSubtitleBaselineTrackIDs.removeAll()        hasPendingExternalSubtitleSelection = false        let media = VLCMedia(url: request.playbackURL)        let headerValue = request.headers            .map { "\($0.key): \($0.value)" }142 unmodified lines
        var subtitleTracks: [SubtitleTrack] {#if canImport(MobileVLCKit)        let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []        let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? []        return zip(indexes, names).map { index, name in            SubtitleTrack(id: index.int32Value, name: name)        }#else        []20 unmodified lines    private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {        var attachedCount = 0        var duplicateCount = 0        let baselineTrackIDs = Set(subtitleTracks.filter { $0.id >= 0 }.map(\.id))        candidates.forEach { candidate in            guard !attachedSubtitleURLs.contains(candidate.url) else {                duplicateCount += 12 unmodified lines            attachedSubtitleURLs.insert(candidate.url)            externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs)            hasPendingExternalSubtitleSelection = true            mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)            attachedCount += 1#if DEBUG24 unmodified lines        return attachedCount    }
    #if DEBUG    private func logSubtitleTracks(reason: String) {        let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []27 unmodified lines282930313233343517 unmodified lines5354555657585960142 unmodified lines20320420520620720820921021121221321421521621720 unmodified lines2382392402412422432442 unmodified lines24724824925025125225324 unmodified lines27827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830927 unmodified lines    private var autoSelectedSubtitleTrackID: Int32?    private var externalSubtitleBaselineTrackIDs = Set<Int32>()    private var hasPendingExternalSubtitleSelection = false    private var pendingExternalSubtitleDisplayNames: [String] = []    private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]
        override init() {        super.init()17 unmodified lines        autoSelectedSubtitleTrackID = nil        externalSubtitleBaselineTrackIDs.removeAll()        hasPendingExternalSubtitleSelection = false        pendingExternalSubtitleDisplayNames.removeAll()        externalSubtitleDisplayNamesByTrackID.removeAll()        let media = VLCMedia(url: request.playbackURL)        let headerValue = request.headers            .map { "\($0.key): \($0.value)" }142 unmodified lines
        var subtitleTracks: [SubtitleTrack] {#if canImport(MobileVLCKit)        reconcileExternalSubtitleDisplayNames()        return rawSubtitleTracks().map { track in            SubtitleTrack(                id: track.id,                name: SubtitleDisplayName.name(                    forVLCTrackName: track.name,                    preservedName: externalSubtitleDisplayNamesByTrackID[track.id]                )            )        }#else        []20 unmodified lines    private func attachSubtitles(_ candidates: [SubtitleCandidate]) -> Int {        var attachedCount = 0        var duplicateCount = 0        let baselineTrackIDs = Set(rawSubtitleTracks().filter { $0.id >= 0 }.map(\.id))        candidates.forEach { candidate in            guard !attachedSubtitleURLs.contains(candidate.url) else {                duplicateCount += 12 unmodified lines            attachedSubtitleURLs.insert(candidate.url)            externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs)            hasPendingExternalSubtitleSelection = true            pendingExternalSubtitleDisplayNames.append(SubtitleDisplayName.displayName(for: candidate))            mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)            attachedCount += 1#if DEBUG24 unmodified lines        return attachedCount    }
        private func rawSubtitleTracks() -> [SubtitleTrack] {        let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []        let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? []        return zip(indexes, names).map { index, name in            SubtitleTrack(id: index.int32Value, name: name)        }    }
        private func reconcileExternalSubtitleDisplayNames() {        guard !pendingExternalSubtitleDisplayNames.isEmpty else {            return        }
            rawSubtitleTracks()            .filter { $0.id >= 0 }            .filter { !externalSubtitleBaselineTrackIDs.contains($0.id) }            .filter { externalSubtitleDisplayNamesByTrackID[$0.id] == nil }            .sorted { $0.id < $1.id }            .forEach { track in                guard !pendingExternalSubtitleDisplayNames.isEmpty else {                    return                }                externalSubtitleDisplayNamesByTrackID[track.id] = pendingExternalSubtitleDisplayNames.removeFirst()            }    }
    #if DEBUG    private func logSubtitleTracks(reason: String) {        let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []

### Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+52

    20 unmodified lines212223242526410 unmodified lines43743843944044144220 unmodified lines        await testSubtitleResolverRejectsNonSubtitleAPIResponse()        testSubtitleCandidateDeduplicationPreservesLabels()        testSubtitleCandidateDeduplicationUpgradesLabels()        testSubtitleOptionMappingIncludesNone()        print("StreamResolverTests passed")    }410 unmodified lines        assertEqual(options.first?.id, -1)    }
        private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {        assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)    }20 unmodified lines2122232425262728410 unmodified lines43944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349420 unmodified lines        await testSubtitleResolverRejectsNonSubtitleAPIResponse()        testSubtitleCandidateDeduplicationPreservesLabels()        testSubtitleCandidateDeduplicationUpgradesLabels()        testSubtitleDisplayNameNormalization()        testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()        testSubtitleOptionMappingIncludesNone()        print("StreamResolverTests passed")    }410 unmodified lines        assertEqual(options.first?.id, -1)    }
        private static func testSubtitleDisplayNameNormalization() {        assertEqual(            SubtitleDisplayName.displayName(for: SubtitleCandidate(                url: URL(string: "https://opensubtitles.example.test/download/subtitle.srt")!,                label: "Track 1",                language: "eng"            )),            "English"        )        assertEqual(            SubtitleDisplayName.displayName(for: SubtitleCandidate(                url: URL(string: "https://opensubtitles.example.test/download/subtitle.srt")!,                label: "Track 2",                language: "Spanish"            )),            "Spanish"        )        assertEqual(            SubtitleDisplayName.displayName(for: SubtitleCandidate(                url: URL(string: "https://opensubtitles.example.test/download/subtitle.srt")!,                label: "English SDH",                language: "eng"            )),            "English SDH"        )        assertEqual(            SubtitleDisplayName.displayName(for: SubtitleCandidate(                url: URL(string: "https://cdn.example.test/subtitles/movie.es.srt")!,                label: "External Subtitle",                language: nil            )),            "movie.es"        )    }
        private static func testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks() {        let options = SubtitleOptionMapper.options(from: [            SubtitleTrack(                id: 3,                name: SubtitleDisplayName.name(forVLCTrackName: "Track 1", preservedName: "English")            ),            SubtitleTrack(                id: 4,                name: SubtitleDisplayName.name(forVLCTrackName: "Commentary", preservedName: "Spanish")            )        ])
            assertEqual(options.map(\.name), ["None", "English", "Commentary"])    }
        private static func assertEqual<T: Equatable>(_ actual: T?, _ expected: T, file: StaticString = #file, line: UInt = #line) {        assert(actual == expected, "Expected \(String(describing: expected)), got \(String(describing: actual))", file: file, line: line)    }

## Expected Impact for End-Users

Users should see clearer captions menu choices after external OpenSubtitles tracks attach. Instead of guessing between `Track 1` and `Track 2`, they should see language-aware labels that make track selection understandable at a glance.

## Validation

- Passed: `swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests && /tmp/dreamio-stream-tests`
- Passed: `xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -sdk iphonesimulator -configuration Debug build CODE_SIGNING_ALLOWED=NO`

Manual app verification with a live OpenSubtitles playback source was not performed in this turn.

## Issues, Limitations, and Mitigations

- The external ID mapping assumes VLC exposes newly attached subtitle tracks in the same order Dreamio attached them. This matches the intended flow and is constrained to tracks outside the captured baseline.
- If VLC later returns a meaningful track name for an external subtitle, Dreamio keeps VLC's name instead of overriding it.
- The language helper covers common OpenSubtitles/Stremio codes directly and delegates broader short-code support to `Locale`.

## Follow-up Work

No required follow-up was filed. A useful future improvement would be a lightweight integration test seam around VLC subtitle-track reconciliation, if the backend gets a mockable media-player adapter later.