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

- Moved external subtitle track, cue, and parser types into `Dreamio/ExternalSubtitleTrackParser.swift` so tests can exercise them without UIKit.
- Kept auto-selection of the first parsed external subtitle whenever no VLC-visible subtitle track exists.
- Made external subtitle selection explicitly disable VLC subtitles, while selecting None or a VLC track hides Dreamio's overlay.
- Refreshed overlay text on progress refreshes, seek scrubbing, jump actions, play/pause toggles, delay changes, and caption selection.
- Added DEBUG overlay logs for selected external tracks, hit/miss state, playback time, adjusted subtitle time, and displayed cue text length.
- Added parser and cue lookup coverage for CRLF SRT, cue boundaries, multiline cleanup, and a first cue starting at `00:00:07,101`.

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

- Cue lookup now lives on `ExternalSubtitleTrack.cue(at:)` and uses start-inclusive, end-exclusive matching.
- Overlay timing still uses `backend.currentTime - backend.subtitleDelay`, preserving the existing delay control.
- The captions menu can still use VLC-visible tracks when MobileVLCKit exposes them. The fallback only takes over when no selectable VLC subtitle track exists or the user chooses an external parsed track.
- VLC attachment attempts were left in place, so embedded and VLC-visible subtitle tracks continue to work.

## Relevant Diff Snippets

### Dreamio/NativePlayerViewController.swift

Dreamio/NativePlayerViewController.swift
-119+101

    14 unmodified lines151617181920368 unmodified lines3893903913923933943953963973983994004014024034044 unmodified lines40941041141241341441541641741841919 unmodified lines4394404414424434444454464474484493 unmodified lines4534544554564574584594604614624634644654664674684694 unmodified lines4744754764774784794804814824834 unmodified lines48848949049149249349449549649749882 unmodified lines58158258358458558662 unmodified lines64965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468536 unmodified lines72272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982014 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 in4 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 = false36 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 lines15161718192021368 unmodified lines3903913923933943953963973983994004014024034044054064074084 unmodified lines41341441541641741841942042142242342442519 unmodified lines4454464474484494504513 unmodified lines4554564574584594604614624634644654664674 unmodified lines4724734744754764774784 unmodified lines48348448548648748848949049149249349449582 unmodified lines57857958058158258358462 unmodified lines64764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276336 unmodified lines80080180214 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 in4 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 = false36 unmodified lines        }    }}

### Tests/StreamResolverTests.swift

Tests/StreamResolverTests.swift
+60

    25 unmodified lines262728293031592 unmodified lines62462562662762862925 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 lines26272829303132333435592 unmodified lines62862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868925 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 lines1617181920219 unmodified lines31323334353657 unmodified lines949596979899141 unmodified lines24124224324424524615 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 lines161718192021229 unmodified lines3233343536373857 unmodified lines96979899100101102141 unmodified lines24424524624724824925015 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

- Manual device validation with the South Park stream is still needed to confirm MobileVLCKit's real-device behavior and visual overlay placement.
- DEBUG overlay logs may be chatty during subtitle misses because they include time-specific miss state. They are DEBUG-only and should help confirm the fallback path during device testing.
- The parser coverage focuses on SRT-style cues. ASS/SSA styling is stripped only at the simple text-cleanup level already supported by the existing implementation.

## Follow-up Work

- Validate on device with the provided South Park stream and confirm the first cue renders around `00:00:07.101`.
- Create a Beads issue if device logs show VLC-visible subtitle tracks racing with the external overlay selection.
- Consider a richer ASS/SSA parser if user-provided samples depend on advanced positioning or styling.