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.
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
subtitleTracksso 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 SDHstaysEnglish 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, andfre/fra/fr. The helper also usesLocalefor 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
28 unmodified lines2930313228 unmodified lineslet name: String}typealias AudioTrack = SubtitleTrack28 unmodified lines29303132333435363738394041424344454647484950515253545556575859606162636428 unmodified lineslet 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
25 unmodified lines262728204 unmodified lines23623723823924024135 unmodified lines27827928028128238 unmodified lines32232332425 unmodified linesprivate var didUserSelectSubtitleTrack = falseprivate var autoSelectedSubtitleTrackID: Int32?private var externalSubtitleBaselineTrackIDs = Set<Int32>()204 unmodified linesvar subtitleTracks: [SubtitleTrack] {#if canImport(MobileVLCKit)return rawSubtitleTracks()#else[]#endif35 unmodified linesattachedSubtitleURLs.insert(candidate.url)externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs)hasPendingExternalSubtitleSelection = truemediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)attachedCount += 138 unmodified linesSubtitleTrack(id: index.int32Value, name: name)}}25 unmodified lines262728293031204 unmodified lines23924024124224324424524624724824925025125225335 unmodified lines28828929029129229338 unmodified lines33333433533633733833934034134234334434534634734834925 unmodified linesprivate var didUserSelectSubtitleTrack = falseprivate var autoSelectedSubtitleTrackID: Int32?private var externalSubtitleBaselineTrackIDs = Set<Int32>()private var hasPendingExternalSubtitleSelection = falseprivate var pendingExternalSubtitleDisplayNames: [String] = []private var externalSubtitleDisplayNamesByTrackID: [Int32: String] = [:]204 unmodified linesvar subtitleTracks: [SubtitleTrack] {#if canImport(MobileVLCKit)reconcileExternalSubtitleDisplayNames()return rawSubtitleTracks().map { track inSubtitleTrack(id: track.id,name: SubtitleDisplayName.name(forVLCTrackName: track.name,preservedName: externalSubtitleDisplayNamesByTrackID[track.id]))}#else[]#endif35 unmodified linesattachedSubtitleURLs.insert(candidate.url)externalSubtitleBaselineTrackIDs.formUnion(baselineTrackIDs)hasPendingExternalSubtitleSelection = truependingExternalSubtitleDisplayNames.append(SubtitleDisplayName.displayName(for: candidate))mediaPlayer.addPlaybackSlave(candidate.url, type: .subtitle, enforce: false)attachedCount += 138 unmodified linesSubtitleTrack(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
18 unmodified lines1920212223413 unmodified lines43843944044144218 unmodified linestestSubtitleCandidateDeduplicationPreservesLabels()testSubtitleCandidateDeduplicationUpgradesLabels()testSubtitleOptionMappingIncludesNone()print("StreamResolverTests passed")}413 unmodified linesassertEqual(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 lines19202122232425413 unmodified lines44044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948018 unmodified linestestSubtitleCandidateDeduplicationPreservesLabels()testSubtitleCandidateDeduplicationUpgradesLabels()testSubtitleDisplayNameNormalization()testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()testSubtitleOptionMappingIncludesNone()print("StreamResolverTests passed")}413 unmodified linesassertEqual(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-testspassed.xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -sdk iphonesimulator -configuration Debug build CODE_SIGNING_ALLOWED=NOpassed.- 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.