Dreamio turn document

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.

May 25, 2026 Beads issue dreamio-2ju Subtitle display names 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 was 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 ยท subtitle display-name helper

Dreamio/StreamCandidate.swift
+32
28 unmodified lines
29
30
31
32
28 unmodified lines
let name: String
}
typealias AudioTrack = SubtitleTrack
28 unmodified lines
29
30
31
32
33
34
35
36
37
38
39
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
28 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",
"nld": "nl", "dan": "da"
]
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
}
}
typealias AudioTrack = SubtitleTrack
diff --git a/Dreamio/StreamCandidate.swift b/Dreamio/StreamCandidate.swift
index 2ad4c5e..7ec90c1 100644
--- a/Dreamio/StreamCandidate.swift
+++ b/Dreamio/StreamCandidate.swift
@@ -29,6 +29,58 @@ struct SubtitleTrack: Equatable {
     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",
+        "nld": "nl", "dan": "da"
+    ]
+
+    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
+    }
+}

 typealias AudioTrack = SubtitleTrack

Dreamio/VLCNativePlaybackBackend.swift ยท preserve names for new VLC tracks

Dreamio/VLCNativePlaybackBackend.swift
-1+28
25 unmodified lines
26
27
28
204 unmodified lines
236
237
238
239
240
241
35 unmodified lines
278
279
280
281
282
38 unmodified lines
322
323
324
25 unmodified lines
private var didUserSelectSubtitleTrack = false
private var autoSelectedSubtitleTrackID: Int32?
private var externalSubtitleBaselineTrackIDs = Set<Int32>()
204 unmodified lines
var subtitleTracks: [SubtitleTrack] {
#if canImport(MobileVLCKit)
return rawSubtitleTracks()
#else
[]
#endif
35 unmodified lines
attachedSubtitleURLs.insert(candidate.url)
externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs)
hasPendingExternalSubtitleSelection = true
mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)
attachedCount += 1
38 unmodified lines
SubtitleTrack(id: index.int32Value, name: name)
}
}
25 unmodified lines
26
27
28
29
30
31
204 unmodified lines
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
35 unmodified lines
288
289
290
291
292
293
38 unmodified lines
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
25 unmodified lines
private var didUserSelectSubtitleTrack = false
private var autoSelectedSubtitleTrackID: Int32?
private var externalSubtitleBaselineTrackIDs = Set<Int32>()
private var hasPendingExternalSubtitleSelection = false
private var pendingExternalSubtitleDisplayNames: [String] = []
private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]
204 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
[]
#endif
35 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
38 unmodified lines
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 }
.filter { SubtitleDisplayName.isGenericLabel($0.name) }
.sorted { $0.id < $1.id }
.forEach { externalSubtitleDisplayNamesByTrackID[$0.id] = pendingExternalSubtitleDisplayNames.removeFirst() }
}
diff --git a/Dreamio/VLCNativePlaybackBackend.swift b/Dreamio/VLCNativePlaybackBackend.swift
index 8582f62..d7f3c44 100644
--- a/Dreamio/VLCNativePlaybackBackend.swift
+++ b/Dreamio/VLCNativePlaybackBackend.swift
@@ -26,6 +26,9 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
     private var didUserSelectSubtitleTrack = false
     private var autoSelectedSubtitleTrackID: Int32?
     private var externalSubtitleBaselineTrackIDs = Set<Int32>()
+    private var hasPendingExternalSubtitleSelection = false
+    private var pendingExternalSubtitleDisplayNames: [String] = []
+    private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]
@@ -236,7 +239,14 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
     var subtitleTracks: [SubtitleTrack] {
 #if canImport(MobileVLCKit)
-        return rawSubtitleTracks()
+        reconcileExternalSubtitleDisplayNames()
+        return rawSubtitleTracks().map { track in
+            SubtitleTrack(
+                id: track.id,
+                name: SubtitleDisplayName.name(
+                    forVLCTrackName: track.name,
+                    preservedName: externalSubtitleDisplayNamesByTrackID[track.id]
+                )
+            )
+        }
 #else
         []
 #endif
@@ -278,6 +288,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
             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
@@ -322,6 +333,20 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
             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 }
+            .filter { SubtitleDisplayName.isGenericLabel($0.name) }
+            .sorted { $0.id < $1.id }
+            .forEach { externalSubtitleDisplayNamesByTrackID[$0.id] = pendingExternalSubtitleDisplayNames.removeFirst() }
+    }

Tests/StreamResolverTests.swift ยท display-name coverage

Tests/StreamResolverTests.swift
+38
18 unmodified lines
19
20
21
22
23
413 unmodified lines
438
439
440
441
442
18 unmodified lines
testSubtitleCandidateDeduplicationPreservesLabels()
testSubtitleCandidateDeduplicationUpgradesLabels()
testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed")
}
413 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)
18 unmodified lines
19
20
21
22
23
24
25
413 unmodified lines
440
441
442
443
444
445
446
447
448
449
450
451
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
18 unmodified lines
testSubtitleCandidateDeduplicationPreservesLabels()
testSubtitleCandidateDeduplicationUpgradesLabels()
testSubtitleDisplayNameNormalization()
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
testSubtitleOptionMappingIncludesNone()
print("StreamResolverTests passed")
}
413 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: "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)
diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift
index 4a4d03e..2bd7932 100644
--- a/Tests/StreamResolverTests.swift
+++ b/Tests/StreamResolverTests.swift
@@ -19,6 +19,8 @@ struct StreamResolverTests {
         testSubtitleCandidateDeduplicationPreservesLabels()
         testSubtitleCandidateDeduplicationUpgradesLabels()
+        testSubtitleDisplayNameNormalization()
+        testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
         testSubtitleOptionMappingIncludesNone()
         print("StreamResolverTests passed")
     }
@@ -438,6 +440,50 @@ struct StreamResolverTests {
         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: "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)

Diffs are generated with @pierre/diffs/ssr at documentation time. Each snippet is intentionally scoped and contained inside its own diff shell so the page remains readable as a static HTML artifact.

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

  • 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 passed.
  • Manual app verification with a live OpenSubtitles playback source was not performed in that turn.

Issues, Limitations, and Mitigations

  • The external ID mapping assumes VLC exposes newly attached subtitle tracks in the same order Dreamio attached them. The mapping 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 for dreamio-2ju.
  • 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.