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.swiftso 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
14 unmodified lines151617181920368 unmodified lines3893903913923933943953963973983994004014024034044 unmodified lines40941041141241341441541641741841919 unmodified lines4394404414424434444454464474484493 unmodified lines4534544554564574584594604614624634644654664674684694 unmodified lines4744754764774784794804814824834 unmodified lines48848949049149249349449549649749882 unmodified lines58158258358458558662 unmodified lines64965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468536 unmodified lines72272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982014 unmodified linesprivate var nextExternalSubtitleTrackID = 1private 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 = falserevealControls()}19 unmodified linesguard let self else {return}self.selectedExternalSubtitleTrackID = nilself.subtitleOverlayLabel.isHidden = trueself.backend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id)self.captionsMenuSignature = nilself.refreshControls()}let backendActions = backendOptions.map { track inUIAction(3 unmodified linesguard let self else {return}self.selectedExternalSubtitleTrackID = nilself.subtitleOverlayLabel.isHidden = true#if DEBUGprint("[DreamioCaptions] select-request id=\(track.id) name=\(track.name) before=\(self.backend.selectedSubtitleTrackID)")#endifself.backend.selectSubtitleTrack(id: track.id)#if DEBUGprint("[DreamioCaptions] select-result id=\(track.id) after=\(self.backend.selectedSubtitleTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks))")#endifself.captionsMenuSignature = nilself.refreshControls()}}let externalActions = externalSubtitleTracks.map { track in4 unmodified linesguard let self else {return}self.selectedExternalSubtitleTrackID = track.idself.backend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id)self.captionsMenuSignature = nilself.refreshControls()}}4 unmodified linesUIAction(title: "Decrease 0.5s") { [weak self] _ inself?.backend.adjustSubtitleDelay(by: -0.5)self?.captionsMenuSignature = nilself?.refreshControls()},UIAction(title: "Increase 0.5s") { [weak self] _ inself?.backend.adjustSubtitleDelay(by: 0.5)self?.captionsMenuSignature = nilself?.refreshControls()},UIAction(82 unmodified lines}private func updateCaptionsMenuIfNeeded(subtitleTracks: [SubtitleTrack]) {let selectedTrackID = backend.selectedSubtitleTrackIDlet signature = captionsMenuSignatureValue(tracks: subtitleTracks,62 unmodified linesparsedExternalSubtitleURLs.insert(candidate.url)externalSubtitleTracks.append(track)nextExternalSubtitleTrackID += 1if selectedExternalSubtitleTrackID == nil,!backend.subtitleTracks.contains(where: { $0.id >= 0 }) {selectedExternalSubtitleTrackID = track.id}#if DEBUGprint("[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 = truereturn}let adjustedTime = backend.currentTime - backend.subtitleDelayguard let cue = track.cues.first(where: { adjustedTime >= $0.start && adjustedTime <= $0.end }) else {subtitleOverlayLabel.isHidden = truereturn}subtitleOverlayLabel.text = " \(cue.text) "subtitleOverlayLabel.isHidden = false}private func hideControls() {controlsContainer.isUserInteractionEnabled = false36 unmodified lines}}}private struct ExternalSubtitleTrack {let id: Intlet name: Stringlet cues: [ExternalSubtitleCue]}private struct ExternalSubtitleCue {let start: TimeIntervallet end: TimeIntervallet 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 : 0let hours = pieces.count >= 3 ? Double(pieces[pieces.count - 3]) ?? 0 : 0return 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 linesprivate var nextExternalSubtitleTrackID = 1private 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 = falseupdateExternalSubtitleOverlay()revealControls()}19 unmodified linesguard let self else {return}self.selectNoSubtitleTrack()}let backendActions = backendOptions.map { track inUIAction(3 unmodified linesguard let self else {return}#if DEBUGprint("[DreamioCaptions] select-request id=\(track.id) name=\(track.name) before=\(self.backend.selectedSubtitleTrackID)")#endifself.selectVLCSubtitleTrack(track)#if DEBUGprint("[DreamioCaptions] select-result id=\(track.id) after=\(self.backend.selectedSubtitleTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks))")#endif}}let externalActions = externalSubtitleTracks.map { track in4 unmodified linesguard let self else {return}self.selectExternalSubtitleTrack(track)}}4 unmodified linesUIAction(title: "Decrease 0.5s") { [weak self] _ inself?.backend.adjustSubtitleDelay(by: -0.5)self?.captionsMenuSignature = nilself?.updateExternalSubtitleOverlay()self?.refreshControls()},UIAction(title: "Increase 0.5s") { [weak self] _ inself?.backend.adjustSubtitleDelay(by: 0.5)self?.captionsMenuSignature = nilself?.updateExternalSubtitleOverlay()self?.refreshControls()},UIAction(82 unmodified lines}private func updateCaptionsMenuIfNeeded(subtitleTracks: [SubtitleTrack]) {ensureExternalSubtitleSelectionIfNeeded(subtitleTracks: subtitleTracks)let selectedTrackID = backend.selectedSubtitleTrackIDlet signature = captionsMenuSignatureValue(tracks: subtitleTracks,62 unmodified linesparsedExternalSubtitleURLs.insert(candidate.url)externalSubtitleTracks.append(track)nextExternalSubtitleTrackID += 1ensureExternalSubtitleSelectionIfNeeded(subtitleTracks: backend.subtitleTracks)#if DEBUGprint("[DreamioCaptions] parsed external subtitle id=\(track.id) name=\(track.name) cues=\(track.cues.count)")#endif}if !candidates.isEmpty {captionsMenuSignature = nilupdateExternalSubtitleOverlay()}}private func selectNoSubtitleTrack() {selectedExternalSubtitleTrackID = nilhideExternalSubtitleOverlay(reason: "none-selected")backend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id)captionsMenuSignature = nilrefreshControls()}private func selectVLCSubtitleTrack(_ track: SubtitleTrack) {selectedExternalSubtitleTrackID = nilhideExternalSubtitleOverlay(reason: "vlc-selected-\(track.id)")backend.selectSubtitleTrack(id: track.id)captionsMenuSignature = nilrefreshControls()}private func selectExternalSubtitleTrack(_ track: ExternalSubtitleTrack) {selectedExternalSubtitleTrackID = track.idbackend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id)captionsMenuSignature = nil#if DEBUGprint("[DreamioCaptions] selected external subtitle id=\(track.id) name=\(track.name) cues=\(track.cues.count) vlcSelected=\(backend.selectedSubtitleTrackID)")#endifupdateExternalSubtitleOverlay()refreshControls()}private func ensureExternalSubtitleSelectionIfNeeded(subtitleTracks: [SubtitleTrack]) {guard selectedExternalSubtitleTrackID == nil,!subtitleTracks.contains(where: { $0.id >= 0 }),let firstExternalTrack = externalSubtitleTracks.firstelse {return}selectedExternalSubtitleTrackID = firstExternalTrack.idbackend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id)#if DEBUGprint("[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.currentTimelet adjustedTime = currentTime - backend.subtitleDelayguard 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 DEBUGlogOverlayState(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 DEBUGlet current = currentTime ?? backend.currentTimelet adjusted = adjustedTime ?? current - backend.subtitleDelaylet selectedTrack = trackID ?? selectedExternalSubtitleTrackIDlogOverlayState(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 DEBUGprivate func logOverlayState(signature: String, message: String) {guard signature != overlayDebugSignature else {return}overlayDebugSignature = signatureprint(message)}#endifprivate func hideControls() {controlsContainer.isUserInteractionEnabled = false36 unmodified lines}}}
Tests/StreamResolverTests.swift
25 unmodified lines262728293031592 unmodified lines62462562662762862925 unmodified linestestSubtitleDisplayNameNormalization()testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()testSubtitleOptionMappingIncludesNone()testContentRangeParsing()testSparseRangeStoreMergesOverlaps()testSparseRangeStoreHitPartialHitAndMiss()592 unmodified linesassertEqual(options.first?.id, -1)}private static func testSubtitleDisplayNameNormalization() {assertEqual(SubtitleDisplayName.displayName(for: SubtitleCandidate(25 unmodified lines26272829303132333435592 unmodified lines62862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868925 unmodified linestestSubtitleDisplayNameNormalization()testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()testSubtitleOptionMappingIncludesNone()testExternalSubtitleParserHandlesCRLFSRT()testExternalSubtitleCueLookupBoundaries()testExternalSubtitleParserCleansMultilineCueText()testExternalSubtitleParserHandlesSouthParkFirstCueTiming()testContentRangeParsing()testSparseRangeStoreMergesOverlaps()testSparseRangeStoreHitPartialHitAndMiss()592 unmodified linesassertEqual(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 = """100: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 = """100:00:07,101 --> 00:00:09,103I'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
15 unmodified lines1617181920219 unmodified lines31323334353657 unmodified lines949596979899141 unmodified lines24124224324424524615 unmodified lines6F2A2B452C00100100DREAMIO /* 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 lines6F2A2B492C00100100DREAMIO /* 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 lines6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */,6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,6F2A2B392C00100100DREAMIO /* Info.plist */,141 unmodified lines6F2A2B502C00100100DREAMIO /* 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 lines6F2A2B452C00100100DREAMIO /* 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 lines6F2A2B492C00100100DREAMIO /* 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 lines6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */,6F2A2B552C00100100DREAMIO /* ExternalSubtitleTrackParser.swift */,6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,6F2A2B392C00100100DREAMIO /* Info.plist */,141 unmodified lines6F2A2B502C00100100DREAMIO /* 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
swiftc Dreamio/StreamCandidate.swift Dreamio/StreamResolver.swift Dreamio/ProgressiveHTTPRangeCache.swift Dreamio/ExternalSubtitleTrackParser.swift Tests/StreamResolverTests.swift -o /tmp/dreamio-stream-tests && /tmp/dreamio-stream-testsDEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO buildpod 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.