mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
fix native external subtitle overlay fallback
This commit is contained in:
parent
d2e55e1f8a
commit
150b671105
5 changed files with 562 additions and 119 deletions
|
|
@ -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 = "<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>"; };
|
||||
|
|
@ -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 */,
|
||||
);
|
||||
|
|
|
|||
102
Dreamio/ExternalSubtitleTrackParser.swift
Normal file
102
Dreamio/ExternalSubtitleTrackParser.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
<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(
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue