Turn document

Fix Native External Subtitle Rendering

Dreamio now treats parsed external subtitle files as a reliable first-class playback path when MobileVLCKit accepts an import but exposes no subtitle tracks.

Summary

External subtitles were already being discovered, cached, parsed, and listed, but playback could still show no captions when VLC reported tracks=[]. This change keeps Dreamio's parsed overlay selected in that case and makes overlay refreshes follow playback position changes more aggressively.

Changes Made

Context

MobileVLCKit can accept subtitle imports through the existing input-slave and addPlaybackSlave paths but still fail to expose them as selectable tracks. Dreamio already has enough parsed subtitle data to render captions itself, so the durable fallback is to keep that overlay active instead of waiting for VLC to publish a track that may never appear.

Important Implementation Details

Relevant Diff Snippets

Dreamio/NativePlayerViewController.swift

Dreamio/NativePlayerViewController.swift
-119+101
14 unmodified lines
15
16
17
18
19
20
368 unmodified lines
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
4 unmodified lines
409
410
411
412
413
414
415
416
417
418
419
19 unmodified lines
439
440
441
442
443
444
445
446
447
448
449
3 unmodified lines
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
4 unmodified lines
474
475
476
477
478
479
480
481
482
483
4 unmodified lines
488
489
490
491
492
493
494
495
496
497
498
82 unmodified lines
581
582
583
584
585
586
62 unmodified lines
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
36 unmodified lines
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
14 unmodified lines
private var nextExternalSubtitleTrackID = 1
private var audioMenuSignature: String?
private var captionsMenuSignature: String?
var onDismiss: (() -> Void)?
private let loadingView: UIActivityIndicatorView = {
368 unmodified lines
@objc private func togglePlayPause() {
backend.togglePlayPause()
revealControls()
}
@objc private func jumpBack() {
backend.jump(by: -15)
revealControls()
}
@objc private func jumpForward() {
backend.jump(by: 15)
revealControls()
}
4 unmodified lines
@objc private func scrubberChanged() {
elapsedLabel.text = PlaybackTimeFormatter.label(for: TimeInterval(scrubber.value) * backend.duration)
}
@objc private func scrubbingEnded() {
backend.seek(to: scrubber.value)
isScrubbing = false
revealControls()
}
19 unmodified lines
guard let self else {
return
}
self.selectedExternalSubtitleTrackID = nil
self.subtitleOverlayLabel.isHidden = true
self.backend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id)
self.captionsMenuSignature = nil
self.refreshControls()
}
let backendActions = backendOptions.map { track in
UIAction(
3 unmodified lines
guard let self else {
return
}
self.selectedExternalSubtitleTrackID = nil
self.subtitleOverlayLabel.isHidden = true
#if DEBUG
print("[DreamioCaptions] select-request id=\(track.id) name=\(track.name) before=\(self.backend.selectedSubtitleTrackID)")
#endif
self.backend.selectSubtitleTrack(id: track.id)
#if DEBUG
print("[DreamioCaptions] select-result id=\(track.id) after=\(self.backend.selectedSubtitleTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks))")
#endif
self.captionsMenuSignature = nil
self.refreshControls()
}
}
let externalActions = externalSubtitleTracks.map { track in
4 unmodified lines
guard let self else {
return
}
self.selectedExternalSubtitleTrackID = track.id
self.backend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id)
self.captionsMenuSignature = nil
self.refreshControls()
}
}
4 unmodified lines
UIAction(title: "Decrease 0.5s") { [weak self] _ in
self?.backend.adjustSubtitleDelay(by: -0.5)
self?.captionsMenuSignature = nil
self?.refreshControls()
},
UIAction(title: "Increase 0.5s") { [weak self] _ in
self?.backend.adjustSubtitleDelay(by: 0.5)
self?.captionsMenuSignature = nil
self?.refreshControls()
},
UIAction(
82 unmodified lines
}
private func updateCaptionsMenuIfNeeded(subtitleTracks: [SubtitleTrack]) {
let selectedTrackID = backend.selectedSubtitleTrackID
let signature = captionsMenuSignatureValue(
tracks: subtitleTracks,
62 unmodified lines
parsedExternalSubtitleURLs.insert(candidate.url)
externalSubtitleTracks.append(track)
nextExternalSubtitleTrackID += 1
if selectedExternalSubtitleTrackID == nil,
!backend.subtitleTracks.contains(where: { $0.id >= 0 }) {
selectedExternalSubtitleTrackID = track.id
}
#if DEBUG
print("[DreamioCaptions] parsed external subtitle id=\(track.id) name=\(track.name) cues=\(track.cues.count)")
#endif
}
if !candidates.isEmpty {
captionsMenuSignature = nil
}
}
private func updateExternalSubtitleOverlay() {
guard let selectedExternalSubtitleTrackID,
backend.selectedSubtitleTrackID < 0,
let track = externalSubtitleTracks.first(where: { $0.id == selectedExternalSubtitleTrackID })
else {
subtitleOverlayLabel.isHidden = true
return
}
let adjustedTime = backend.currentTime - backend.subtitleDelay
guard let cue = track.cues.first(where: { adjustedTime >= $0.start && adjustedTime <= $0.end }) else {
subtitleOverlayLabel.isHidden = true
return
}
subtitleOverlayLabel.text = " \(cue.text) "
subtitleOverlayLabel.isHidden = false
}
private func hideControls() {
controlsContainer.isUserInteractionEnabled = false
36 unmodified lines
}
}
}
private struct ExternalSubtitleTrack {
let id: Int
let name: String
let cues: [ExternalSubtitleCue]
}
private struct ExternalSubtitleCue {
let start: TimeInterval
let end: TimeInterval
let text: String
}
private enum ExternalSubtitleTrackParser {
static func track(from candidate: SubtitleCandidate, id: Int) -> ExternalSubtitleTrack? {
guard let text = try? String(contentsOf: candidate.url, encoding: .utf8) else {
return nil
}
let cues = parseCues(from: text)
guard !cues.isEmpty else {
return nil
}
return ExternalSubtitleTrack(
id: id,
name: SubtitleDisplayName.displayName(for: candidate),
cues: cues
)
}
private static func parseCues(from text: String) -> [ExternalSubtitleCue] {
let normalized = text
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
let blocks = normalized.components(separatedBy: "\n\n")
return blocks.compactMap(parseCueBlock)
}
private static func parseCueBlock(_ block: String) -> ExternalSubtitleCue? {
let lines = block
.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && !$0.lowercased().hasPrefix("webvtt") }
guard !lines.isEmpty else {
return nil
}
guard let timingIndex = lines.firstIndex(where: { $0.contains("-->") }) else {
return nil
}
let timingParts = lines[timingIndex].components(separatedBy: "-->")
guard timingParts.count == 2,
let start = parseTimestamp(timingParts[0]),
let end = parseTimestamp(timingParts[1])
else {
return nil
}
let cueText = lines
.dropFirst(timingIndex + 1)
.map(cleanCueText)
.filter { !$0.isEmpty }
.joined(separator: "\n")
guard !cueText.isEmpty else {
return nil
}
return ExternalSubtitleCue(start: start, end: end, text: cueText)
}
private static func parseTimestamp(_ value: String) -> TimeInterval? {
let timestamp = value
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: ",", with: ".")
.components(separatedBy: .whitespaces)
.first ?? ""
let pieces = timestamp.split(separator: ":").map(String.init)
guard let secondsPiece = pieces.last,
let seconds = Double(secondsPiece)
else {
return nil
}
let minutes = pieces.count >= 2 ? Double(pieces[pieces.count - 2]) ?? 0 : 0
let hours = pieces.count >= 3 ? Double(pieces[pieces.count - 3]) ?? 0 : 0
return hours * 3600 + minutes * 60 + seconds
}
private static func cleanCueText(_ value: String) -> String {
value
.replacingOccurrences(of: #"<[^>]+>"#, with: "", options: .regularExpression)
.replacingOccurrences(of: #"\{\\[^}]+\}"#, with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
14 unmodified lines
15
16
17
18
19
20
21
368 unmodified lines
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
4 unmodified lines
413
414
415
416
417
418
419
420
421
422
423
424
425
19 unmodified lines
445
446
447
448
449
450
451
3 unmodified lines
455
456
457
458
459
460
461
462
463
464
465
466
467
4 unmodified lines
472
473
474
475
476
477
478
4 unmodified lines
483
484
485
486
487
488
489
490
491
492
493
494
495
82 unmodified lines
578
579
580
581
582
583
584
62 unmodified lines
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
36 unmodified lines
800
801
802
14 unmodified lines
private var nextExternalSubtitleTrackID = 1
private var audioMenuSignature: String?
private var captionsMenuSignature: String?
private var overlayDebugSignature: String?
var onDismiss: (() -> Void)?
private let loadingView: UIActivityIndicatorView = {
368 unmodified lines
@objc private func togglePlayPause() {
backend.togglePlayPause()
updateExternalSubtitleOverlay()
revealControls()
}
@objc private func jumpBack() {
backend.jump(by: -15)
updateExternalSubtitleOverlay()
revealControls()
}
@objc private func jumpForward() {
backend.jump(by: 15)
updateExternalSubtitleOverlay()
revealControls()
}
4 unmodified lines
@objc private func scrubberChanged() {
elapsedLabel.text = PlaybackTimeFormatter.label(for: TimeInterval(scrubber.value) * backend.duration)
updateExternalSubtitleOverlay(playbackTime: TimeInterval(scrubber.value) * backend.duration)
}
@objc private func scrubbingEnded() {
backend.seek(to: scrubber.value)
isScrubbing = false
updateExternalSubtitleOverlay()
revealControls()
}
19 unmodified lines
guard let self else {
return
}
self.selectNoSubtitleTrack()
}
let backendActions = backendOptions.map { track in
UIAction(
3 unmodified lines
guard let self else {
return
}
#if DEBUG
print("[DreamioCaptions] select-request id=\(track.id) name=\(track.name) before=\(self.backend.selectedSubtitleTrackID)")
#endif
self.selectVLCSubtitleTrack(track)
#if DEBUG
print("[DreamioCaptions] select-result id=\(track.id) after=\(self.backend.selectedSubtitleTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks))")
#endif
}
}
let externalActions = externalSubtitleTracks.map { track in
4 unmodified lines
guard let self else {
return
}
self.selectExternalSubtitleTrack(track)
}
}
4 unmodified lines
UIAction(title: "Decrease 0.5s") { [weak self] _ in
self?.backend.adjustSubtitleDelay(by: -0.5)
self?.captionsMenuSignature = nil
self?.updateExternalSubtitleOverlay()
self?.refreshControls()
},
UIAction(title: "Increase 0.5s") { [weak self] _ in
self?.backend.adjustSubtitleDelay(by: 0.5)
self?.captionsMenuSignature = nil
self?.updateExternalSubtitleOverlay()
self?.refreshControls()
},
UIAction(
82 unmodified lines
}
private func updateCaptionsMenuIfNeeded(subtitleTracks: [SubtitleTrack]) {
ensureExternalSubtitleSelectionIfNeeded(subtitleTracks: subtitleTracks)
let selectedTrackID = backend.selectedSubtitleTrackID
let signature = captionsMenuSignatureValue(
tracks: subtitleTracks,
62 unmodified lines
parsedExternalSubtitleURLs.insert(candidate.url)
externalSubtitleTracks.append(track)
nextExternalSubtitleTrackID += 1
ensureExternalSubtitleSelectionIfNeeded(subtitleTracks: backend.subtitleTracks)
#if DEBUG
print("[DreamioCaptions] parsed external subtitle id=\(track.id) name=\(track.name) cues=\(track.cues.count)")
#endif
}
if !candidates.isEmpty {
captionsMenuSignature = nil
updateExternalSubtitleOverlay()
}
}
private func selectNoSubtitleTrack() {
selectedExternalSubtitleTrackID = nil
hideExternalSubtitleOverlay(reason: "none-selected")
backend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id)
captionsMenuSignature = nil
refreshControls()
}
private func selectVLCSubtitleTrack(_ track: SubtitleTrack) {
selectedExternalSubtitleTrackID = nil
hideExternalSubtitleOverlay(reason: "vlc-selected-\(track.id)")
backend.selectSubtitleTrack(id: track.id)
captionsMenuSignature = nil
refreshControls()
}
private func selectExternalSubtitleTrack(_ track: ExternalSubtitleTrack) {
selectedExternalSubtitleTrackID = track.id
backend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id)
captionsMenuSignature = nil
#if DEBUG
print("[DreamioCaptions] selected external subtitle id=\(track.id) name=\(track.name) cues=\(track.cues.count) vlcSelected=\(backend.selectedSubtitleTrackID)")
#endif
updateExternalSubtitleOverlay()
refreshControls()
}
private func ensureExternalSubtitleSelectionIfNeeded(subtitleTracks: [SubtitleTrack]) {
guard selectedExternalSubtitleTrackID == nil,
!subtitleTracks.contains(where: { $0.id >= 0 }),
let firstExternalTrack = externalSubtitleTracks.first
else {
return
}
selectedExternalSubtitleTrackID = firstExternalTrack.id
backend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id)
#if DEBUG
print("[DreamioCaptions] selected external subtitle id=\(firstExternalTrack.id) name=\(firstExternalTrack.name) reason=no-vlc-tracks cues=\(firstExternalTrack.cues.count)")
#endif
}
private func updateExternalSubtitleOverlay(playbackTime: TimeInterval? = nil) {
guard let selectedExternalSubtitleTrackID,
backend.selectedSubtitleTrackID < 0,
let track = externalSubtitleTracks.first(where: { $0.id == selectedExternalSubtitleTrackID })
else {
hideExternalSubtitleOverlay(reason: "no-external-selected")
return
}
let currentTime = playbackTime ?? backend.currentTime
let adjustedTime = currentTime - backend.subtitleDelay
guard let cue = track.cue(at: adjustedTime) else {
hideExternalSubtitleOverlay(
reason: "miss-track-\(track.id)-time-\(String(format: "%.3f", adjustedTime))",
currentTime: currentTime,
adjustedTime: adjustedTime,
trackID: track.id
)
return
}
subtitleOverlayLabel.text = " \(cue.text) "
subtitleOverlayLabel.isHidden = false
#if DEBUG
logOverlayState(
signature: "hit-\(track.id)-\(cue.start)-\(cue.end)-\(cue.text.count)",
message: "[DreamioCaptions] overlay hit external=\(track.id) current=\(String(format: "%.3f", currentTime)) adjusted=\(String(format: "%.3f", adjustedTime)) cue=\(String(format: "%.3f", cue.start))-\(String(format: "%.3f", cue.end)) textLength=\(cue.text.count)"
)
#endif
}
private func hideExternalSubtitleOverlay(
reason: String,
currentTime: TimeInterval? = nil,
adjustedTime: TimeInterval? = nil,
trackID: Int? = nil
) {
subtitleOverlayLabel.isHidden = true
#if DEBUG
let current = currentTime ?? backend.currentTime
let adjusted = adjustedTime ?? current - backend.subtitleDelay
let selectedTrack = trackID ?? selectedExternalSubtitleTrackID
logOverlayState(
signature: "hide-\(reason)",
message: "[DreamioCaptions] overlay miss reason=\(reason) external=\(selectedTrack.map(String.init) ?? "none") current=\(String(format: "%.3f", current)) adjusted=\(String(format: "%.3f", adjusted)) textLength=0"
)
#endif
}
#if DEBUG
private func logOverlayState(signature: String, message: String) {
guard signature != overlayDebugSignature else {
return
}
overlayDebugSignature = signature
print(message)
}
#endif
private func hideControls() {
controlsContainer.isUserInteractionEnabled = false
36 unmodified lines
}
}
}

Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+60
25 unmodified lines
26
27
28
29
30
31
592 unmodified lines
624
625
626
627
628
629
25 unmodified lines
testSubtitleDisplayNameNormalization()
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
testSubtitleOptionMappingIncludesNone()
testContentRangeParsing()
testSparseRangeStoreMergesOverlaps()
testSparseRangeStoreHitPartialHitAndMiss()
592 unmodified lines
assertEqual(options.first?.id, -1)
}
private static func testSubtitleDisplayNameNormalization() {
assertEqual(
SubtitleDisplayName.displayName(for: SubtitleCandidate(
25 unmodified lines
26
27
28
29
30
31
32
33
34
35
592 unmodified lines
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
25 unmodified lines
testSubtitleDisplayNameNormalization()
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
testSubtitleOptionMappingIncludesNone()
testExternalSubtitleParserHandlesCRLFSRT()
testExternalSubtitleCueLookupBoundaries()
testExternalSubtitleParserCleansMultilineCueText()
testExternalSubtitleParserHandlesSouthParkFirstCueTiming()
testContentRangeParsing()
testSparseRangeStoreMergesOverlaps()
testSparseRangeStoreHitPartialHitAndMiss()
592 unmodified lines
assertEqual(options.first?.id, -1)
}
private static func testExternalSubtitleParserHandlesCRLFSRT() {
let body = "1\r\n00:00:01,000 --> 00:00:02,500\r\nHello from CRLF\r\n\r\n"
let cues = ExternalSubtitleTrackParser.parseCues(from: body)
assertEqual(cues.count, 1)
assertEqual(cues[0].start, 1)
assertEqual(cues[0].end, 2.5)
assertEqual(cues[0].text, "Hello from CRLF")
}
private static func testExternalSubtitleCueLookupBoundaries() {
let track = ExternalSubtitleTrack(
id: 1,
name: "English",
cues: [
ExternalSubtitleCue(start: 7.101, end: 9.25, text: "First cue")
]
)
assert(track.cue(at: 7.100) == nil, "Expected time before first cue to hide overlay")
assertEqual(track.cue(at: 7.101)?.text, "First cue")
assertEqual(track.cue(at: 8.0)?.text, "First cue")
assert(track.cue(at: 9.25) == nil, "Expected cue end boundary to hide overlay")
assert(track.cue(at: 9.251) == nil, "Expected time after cue end to hide overlay")
}
private static func testExternalSubtitleParserCleansMultilineCueText() {
let body = """
1
00:00:03,000 --> 00:00:05,000
<i>Hello</i>
{\\an8}there
"""
let cues = ExternalSubtitleTrackParser.parseCues(from: body)
assertEqual(cues.count, 1)
assertEqual(cues[0].text, "Hello\nthere")
}
private static func testExternalSubtitleParserHandlesSouthParkFirstCueTiming() {
let body = """
1
00:00:07,101 --> 00:00:09,103
I'm going down to South Park
"""
let cues = ExternalSubtitleTrackParser.parseCues(from: body)
let track = ExternalSubtitleTrack(id: 1, name: "English", cues: cues)
assertEqual(cues.count, 1)
assertEqual(cues[0].start, 7.101)
assert(track.cue(at: 7.100) == nil, "Expected no text before the South Park-style first cue")
assertEqual(track.cue(at: 7.101)?.text, "I'm going down to South Park")
}
private static func testSubtitleDisplayNameNormalization() {
assertEqual(
SubtitleDisplayName.displayName(for: SubtitleCandidate(

Dreamio.xcodeproj/project.pbxproj

Dreamio.xcodeproj/project.pbxproj
+4
15 unmodified lines
16
17
18
19
20
21
9 unmodified lines
31
32
33
34
35
36
57 unmodified lines
94
95
96
97
98
99
141 unmodified lines
241
242
243
244
245
246
15 unmodified lines
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */; };
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */; };
6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */; };
8BC00A493F84BEC6714B8F14 /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; };
/* End PBXBuildFile section */
9 unmodified lines
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = "<group>"; };
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.swift; sourceTree = "<group>"; };
6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveHTTPRangeCache.swift; sourceTree = "<group>"; };
701702B9C2BFBEDE36E7F0A3 /* Pods-Dreamio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.release.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.release.xcconfig"; sourceTree = "<group>"; };
908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Dreamio.framework; sourceTree = BUILT_PRODUCTS_DIR; };
BF0A4D5BAC9400AEEF3B0181 /* Pods-Dreamio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.debug.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.debug.xcconfig"; sourceTree = "<group>"; };
57 unmodified lines
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,
6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */,
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,
6F2A2B392C00100100DREAMIO /* Info.plist */,
141 unmodified lines
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */,
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */,
6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */,
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */,
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */,
);
15 unmodified lines
16
17
18
19
20
21
22
9 unmodified lines
32
33
34
35
36
37
38
57 unmodified lines
96
97
98
99
100
101
102
141 unmodified lines
244
245
246
247
248
249
250
15 unmodified lines
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */; };
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */; };
6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */; };
6F2A2B542C00100100DREAMIO /* ExternalSubtitleTrackParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B552C00100100DREAMIO /* ExternalSubtitleTrackParser.swift */; };
8BC00A493F84BEC6714B8F14 /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; };
/* End PBXBuildFile section */
9 unmodified lines
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = "<group>"; };
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.swift; sourceTree = "<group>"; };
6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveHTTPRangeCache.swift; sourceTree = "<group>"; };
6F2A2B552C00100100DREAMIO /* ExternalSubtitleTrackParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalSubtitleTrackParser.swift; sourceTree = "<group>"; };
701702B9C2BFBEDE36E7F0A3 /* Pods-Dreamio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.release.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.release.xcconfig"; sourceTree = "<group>"; };
908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Dreamio.framework; sourceTree = BUILT_PRODUCTS_DIR; };
BF0A4D5BAC9400AEEF3B0181 /* Pods-Dreamio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Dreamio.debug.xcconfig"; path = "Target Support Files/Pods-Dreamio/Pods-Dreamio.debug.xcconfig"; sourceTree = "<group>"; };
57 unmodified lines
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,
6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */,
6F2A2B552C00100100DREAMIO /* ExternalSubtitleTrackParser.swift */,
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,
6F2A2B392C00100100DREAMIO /* Info.plist */,
141 unmodified lines
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */,
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */,
6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */,
6F2A2B542C00100100DREAMIO /* ExternalSubtitleTrackParser.swift in Sources */,
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */,
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */,
);

Expected Impact for End-Users

When a stream has external subtitles that VLC accepts but does not expose as tracks, Dreamio should still show captions through its native overlay. The first subtitle text around 00:00:07.101 should appear instead of silently leaving captions blank.

Validation

Passed: swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/ProgressiveHTTPRangeCache.swift Dreamio/ExternalSubtitleTrackParser.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests && /tmp/dreamio-stream-tests
Passed: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build
Setup note: the first build attempt failed because the worktree was missing CocoaPods support files. Running pod install restored Pods/Target Support Files, and the build then succeeded.

Issues, Limitations, and Mitigations

Follow-up Work