From b016d5a91041f4d9be731f35c50ec946b337f203 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 25 May 2026 19:28:57 -0400 Subject: [PATCH] fix native external subtitle overlay fallback --- Dreamio.xcodeproj/project.pbxproj | 4 + Dreamio/ExternalSubtitleTrackParser.swift | 102 ++++++ Dreamio/NativePlayerViewController.swift | 220 ++++++------- Tests/StreamResolverTests.swift | 60 ++++ ...ix-native-external-subtitle-rendering.html | 295 ++++++++++++++++++ 5 files changed, 562 insertions(+), 119 deletions(-) create mode 100644 Dreamio/ExternalSubtitleTrackParser.swift create mode 100644 docs/turns/2026-05-25-fix-native-external-subtitle-rendering.html diff --git a/Dreamio.xcodeproj/project.pbxproj b/Dreamio.xcodeproj/project.pbxproj index ed6a41a..08ec997 100644 --- a/Dreamio.xcodeproj/project.pbxproj +++ b/Dreamio.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 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 */ @@ -31,6 +32,7 @@ 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = ""; }; 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamResolver.swift; sourceTree = ""; }; 6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveHTTPRangeCache.swift; sourceTree = ""; }; + 6F2A2B552C00100100DREAMIO /* ExternalSubtitleTrackParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalSubtitleTrackParser.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -94,6 +96,7 @@ 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */, 6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */, 6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */, + 6F2A2B552C00100100DREAMIO /* ExternalSubtitleTrackParser.swift */, 6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */, 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */, 6F2A2B392C00100100DREAMIO /* Info.plist */, @@ -241,6 +244,7 @@ 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 */, ); diff --git a/Dreamio/ExternalSubtitleTrackParser.swift b/Dreamio/ExternalSubtitleTrackParser.swift new file mode 100644 index 0000000..6a3c92f --- /dev/null +++ b/Dreamio/ExternalSubtitleTrackParser.swift @@ -0,0 +1,102 @@ +import Foundation + +struct ExternalSubtitleTrack { + let id: Int + let name: String + let cues: [ExternalSubtitleCue] + + func cue(at playbackTime: TimeInterval) -> ExternalSubtitleCue? { + cues.first { playbackTime >= $0.start && playbackTime < $0.end } + } +} + +struct ExternalSubtitleCue { + let start: TimeInterval + let end: TimeInterval + let text: String +} + +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 + ) + } + + 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]), + end > start + 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) + } +} diff --git a/Dreamio/NativePlayerViewController.swift b/Dreamio/NativePlayerViewController.swift index d810e94..bbecedd 100644 --- a/Dreamio/NativePlayerViewController.swift +++ b/Dreamio/NativePlayerViewController.swift @@ -15,6 +15,7 @@ final class NativePlayerViewController: UIViewController { private var nextExternalSubtitleTrackID = 1 private var audioMenuSignature: String? private var captionsMenuSignature: String? + private var overlayDebugSignature: String? var onDismiss: (() -> Void)? private let loadingView: UIActivityIndicatorView = { @@ -389,16 +390,19 @@ final class NativePlayerViewController: UIViewController { @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() } @@ -409,11 +413,13 @@ final class NativePlayerViewController: UIViewController { @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() } @@ -439,11 +445,7 @@ final class NativePlayerViewController: UIViewController { guard let self else { return } - self.selectedExternalSubtitleTrackID = nil - self.subtitleOverlayLabel.isHidden = true - self.backend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id) - self.captionsMenuSignature = nil - self.refreshControls() + self.selectNoSubtitleTrack() } let backendActions = backendOptions.map { track in UIAction( @@ -453,17 +455,13 @@ final class NativePlayerViewController: UIViewController { 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) + self.selectVLCSubtitleTrack(track) #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 @@ -474,10 +472,7 @@ final class NativePlayerViewController: UIViewController { guard let self else { return } - self.selectedExternalSubtitleTrackID = track.id - self.backend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id) - self.captionsMenuSignature = nil - self.refreshControls() + self.selectExternalSubtitleTrack(track) } } @@ -488,11 +483,13 @@ final class NativePlayerViewController: UIViewController { 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( @@ -581,6 +578,7 @@ final class NativePlayerViewController: UIViewController { } private func updateCaptionsMenuIfNeeded(subtitleTracks: [SubtitleTrack]) { + ensureExternalSubtitleSelectionIfNeeded(subtitleTracks: subtitleTracks) let selectedTrackID = backend.selectedSubtitleTrackID let signature = captionsMenuSignatureValue( tracks: subtitleTracks, @@ -649,38 +647,118 @@ final class NativePlayerViewController: UIViewController { parsedExternalSubtitleURLs.insert(candidate.url) externalSubtitleTracks.append(track) nextExternalSubtitleTrackID += 1 - if selectedExternalSubtitleTrackID == nil, - !backend.subtitleTracks.contains(where: { $0.id >= 0 }) { - selectedExternalSubtitleTrackID = track.id - } + 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 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 { - subtitleOverlayLabel.isHidden = true + hideExternalSubtitleOverlay(reason: "no-external-selected") return } - let adjustedTime = backend.currentTime - backend.subtitleDelay - guard let cue = track.cues.first(where: { adjustedTime >= $0.start && adjustedTime <= $0.end }) else { - subtitleOverlayLabel.isHidden = true + 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 closeButton.isUserInteractionEnabled = false @@ -722,99 +800,3 @@ final class NativePlayerViewController: UIViewController { } } } - -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) - } -} diff --git a/Tests/StreamResolverTests.swift b/Tests/StreamResolverTests.swift index 09986e0..d27b863 100644 --- a/Tests/StreamResolverTests.swift +++ b/Tests/StreamResolverTests.swift @@ -26,6 +26,10 @@ struct StreamResolverTests { testSubtitleDisplayNameNormalization() testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks() testSubtitleOptionMappingIncludesNone() + testExternalSubtitleParserHandlesCRLFSRT() + testExternalSubtitleCueLookupBoundaries() + testExternalSubtitleParserCleansMultilineCueText() + testExternalSubtitleParserHandlesSouthParkFirstCueTiming() testContentRangeParsing() testSparseRangeStoreMergesOverlaps() testSparseRangeStoreHitPartialHitAndMiss() @@ -624,6 +628,62 @@ struct StreamResolverTests { 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 + Hello + {\\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( diff --git a/docs/turns/2026-05-25-fix-native-external-subtitle-rendering.html b/docs/turns/2026-05-25-fix-native-external-subtitle-rendering.html new file mode 100644 index 0000000..0d68e8a --- /dev/null +++ b/docs/turns/2026-05-25-fix-native-external-subtitle-rendering.html @@ -0,0 +1,295 @@ + + + + + +Fix Native External Subtitle Rendering + + + +
+
+
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 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

  • 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.
+
+ + \ No newline at end of file