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 */; };
|
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */; };
|
||||||
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */; };
|
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B512C00100100DREAMIO /* StreamResolver.swift */; };
|
||||||
6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.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 */; };
|
8BC00A493F84BEC6714B8F14 /* Pods_Dreamio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 908FA15B08AB341C116BAB46 /* Pods_Dreamio.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
|
@ -31,6 +32,7 @@
|
||||||
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePlayerViewController.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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; };
|
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>"; };
|
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 */,
|
6F2A2B512C00100100DREAMIO /* StreamResolver.swift */,
|
||||||
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,
|
6F2A2B472C00100100DREAMIO /* NativePlaybackBackend.swift */,
|
||||||
6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */,
|
6F2A2B532C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift */,
|
||||||
|
6F2A2B552C00100100DREAMIO /* ExternalSubtitleTrackParser.swift */,
|
||||||
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,
|
6F2A2B482C00100100DREAMIO /* VLCNativePlaybackBackend.swift */,
|
||||||
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,
|
6F2A2B492C00100100DREAMIO /* NativePlayerViewController.swift */,
|
||||||
6F2A2B392C00100100DREAMIO /* Info.plist */,
|
6F2A2B392C00100100DREAMIO /* Info.plist */,
|
||||||
|
|
@ -241,6 +244,7 @@
|
||||||
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */,
|
6F2A2B502C00100100DREAMIO /* StreamResolver.swift in Sources */,
|
||||||
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */,
|
6F2A2B432C00100100DREAMIO /* NativePlaybackBackend.swift in Sources */,
|
||||||
6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */,
|
6F2A2B522C00100100DREAMIO /* ProgressiveHTTPRangeCache.swift in Sources */,
|
||||||
|
6F2A2B542C00100100DREAMIO /* ExternalSubtitleTrackParser.swift in Sources */,
|
||||||
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */,
|
6F2A2B442C00100100DREAMIO /* VLCNativePlaybackBackend.swift in Sources */,
|
||||||
6F2A2B452C00100100DREAMIO /* NativePlayerViewController.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 nextExternalSubtitleTrackID = 1
|
||||||
private var audioMenuSignature: String?
|
private var audioMenuSignature: String?
|
||||||
private var captionsMenuSignature: String?
|
private var captionsMenuSignature: String?
|
||||||
|
private var overlayDebugSignature: String?
|
||||||
var onDismiss: (() -> Void)?
|
var onDismiss: (() -> Void)?
|
||||||
|
|
||||||
private let loadingView: UIActivityIndicatorView = {
|
private let loadingView: UIActivityIndicatorView = {
|
||||||
|
|
@ -389,16 +390,19 @@ final class NativePlayerViewController: UIViewController {
|
||||||
|
|
||||||
@objc private func togglePlayPause() {
|
@objc private func togglePlayPause() {
|
||||||
backend.togglePlayPause()
|
backend.togglePlayPause()
|
||||||
|
updateExternalSubtitleOverlay()
|
||||||
revealControls()
|
revealControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func jumpBack() {
|
@objc private func jumpBack() {
|
||||||
backend.jump(by: -15)
|
backend.jump(by: -15)
|
||||||
|
updateExternalSubtitleOverlay()
|
||||||
revealControls()
|
revealControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func jumpForward() {
|
@objc private func jumpForward() {
|
||||||
backend.jump(by: 15)
|
backend.jump(by: 15)
|
||||||
|
updateExternalSubtitleOverlay()
|
||||||
revealControls()
|
revealControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -409,11 +413,13 @@ final class NativePlayerViewController: UIViewController {
|
||||||
|
|
||||||
@objc private func scrubberChanged() {
|
@objc private func scrubberChanged() {
|
||||||
elapsedLabel.text = PlaybackTimeFormatter.label(for: TimeInterval(scrubber.value) * backend.duration)
|
elapsedLabel.text = PlaybackTimeFormatter.label(for: TimeInterval(scrubber.value) * backend.duration)
|
||||||
|
updateExternalSubtitleOverlay(playbackTime: TimeInterval(scrubber.value) * backend.duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func scrubbingEnded() {
|
@objc private func scrubbingEnded() {
|
||||||
backend.seek(to: scrubber.value)
|
backend.seek(to: scrubber.value)
|
||||||
isScrubbing = false
|
isScrubbing = false
|
||||||
|
updateExternalSubtitleOverlay()
|
||||||
revealControls()
|
revealControls()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -439,11 +445,7 @@ final class NativePlayerViewController: UIViewController {
|
||||||
guard let self else {
|
guard let self else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.selectedExternalSubtitleTrackID = nil
|
self.selectNoSubtitleTrack()
|
||||||
self.subtitleOverlayLabel.isHidden = true
|
|
||||||
self.backend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id)
|
|
||||||
self.captionsMenuSignature = nil
|
|
||||||
self.refreshControls()
|
|
||||||
}
|
}
|
||||||
let backendActions = backendOptions.map { track in
|
let backendActions = backendOptions.map { track in
|
||||||
UIAction(
|
UIAction(
|
||||||
|
|
@ -453,17 +455,13 @@ final class NativePlayerViewController: UIViewController {
|
||||||
guard let self else {
|
guard let self else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.selectedExternalSubtitleTrackID = nil
|
|
||||||
self.subtitleOverlayLabel.isHidden = true
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[DreamioCaptions] select-request id=\(track.id) name=\(track.name) before=\(self.backend.selectedSubtitleTrackID)")
|
print("[DreamioCaptions] select-request id=\(track.id) name=\(track.name) before=\(self.backend.selectedSubtitleTrackID)")
|
||||||
#endif
|
#endif
|
||||||
self.backend.selectSubtitleTrack(id: track.id)
|
self.selectVLCSubtitleTrack(track)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[DreamioCaptions] select-result id=\(track.id) after=\(self.backend.selectedSubtitleTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks))")
|
print("[DreamioCaptions] select-result id=\(track.id) after=\(self.backend.selectedSubtitleTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.subtitleTracks))")
|
||||||
#endif
|
#endif
|
||||||
self.captionsMenuSignature = nil
|
|
||||||
self.refreshControls()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let externalActions = externalSubtitleTracks.map { track in
|
let externalActions = externalSubtitleTracks.map { track in
|
||||||
|
|
@ -474,10 +472,7 @@ final class NativePlayerViewController: UIViewController {
|
||||||
guard let self else {
|
guard let self else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.selectedExternalSubtitleTrackID = track.id
|
self.selectExternalSubtitleTrack(track)
|
||||||
self.backend.selectSubtitleTrack(id: SubtitleOptionMapper.noneTrack.id)
|
|
||||||
self.captionsMenuSignature = nil
|
|
||||||
self.refreshControls()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -488,11 +483,13 @@ final class NativePlayerViewController: UIViewController {
|
||||||
UIAction(title: "Decrease 0.5s") { [weak self] _ in
|
UIAction(title: "Decrease 0.5s") { [weak self] _ in
|
||||||
self?.backend.adjustSubtitleDelay(by: -0.5)
|
self?.backend.adjustSubtitleDelay(by: -0.5)
|
||||||
self?.captionsMenuSignature = nil
|
self?.captionsMenuSignature = nil
|
||||||
|
self?.updateExternalSubtitleOverlay()
|
||||||
self?.refreshControls()
|
self?.refreshControls()
|
||||||
},
|
},
|
||||||
UIAction(title: "Increase 0.5s") { [weak self] _ in
|
UIAction(title: "Increase 0.5s") { [weak self] _ in
|
||||||
self?.backend.adjustSubtitleDelay(by: 0.5)
|
self?.backend.adjustSubtitleDelay(by: 0.5)
|
||||||
self?.captionsMenuSignature = nil
|
self?.captionsMenuSignature = nil
|
||||||
|
self?.updateExternalSubtitleOverlay()
|
||||||
self?.refreshControls()
|
self?.refreshControls()
|
||||||
},
|
},
|
||||||
UIAction(
|
UIAction(
|
||||||
|
|
@ -581,6 +578,7 @@ final class NativePlayerViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateCaptionsMenuIfNeeded(subtitleTracks: [SubtitleTrack]) {
|
private func updateCaptionsMenuIfNeeded(subtitleTracks: [SubtitleTrack]) {
|
||||||
|
ensureExternalSubtitleSelectionIfNeeded(subtitleTracks: subtitleTracks)
|
||||||
let selectedTrackID = backend.selectedSubtitleTrackID
|
let selectedTrackID = backend.selectedSubtitleTrackID
|
||||||
let signature = captionsMenuSignatureValue(
|
let signature = captionsMenuSignatureValue(
|
||||||
tracks: subtitleTracks,
|
tracks: subtitleTracks,
|
||||||
|
|
@ -649,38 +647,118 @@ final class NativePlayerViewController: UIViewController {
|
||||||
parsedExternalSubtitleURLs.insert(candidate.url)
|
parsedExternalSubtitleURLs.insert(candidate.url)
|
||||||
externalSubtitleTracks.append(track)
|
externalSubtitleTracks.append(track)
|
||||||
nextExternalSubtitleTrackID += 1
|
nextExternalSubtitleTrackID += 1
|
||||||
if selectedExternalSubtitleTrackID == nil,
|
ensureExternalSubtitleSelectionIfNeeded(subtitleTracks: backend.subtitleTracks)
|
||||||
!backend.subtitleTracks.contains(where: { $0.id >= 0 }) {
|
|
||||||
selectedExternalSubtitleTrackID = track.id
|
|
||||||
}
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("[DreamioCaptions] parsed external subtitle id=\(track.id) name=\(track.name) cues=\(track.cues.count)")
|
print("[DreamioCaptions] parsed external subtitle id=\(track.id) name=\(track.name) cues=\(track.cues.count)")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
if !candidates.isEmpty {
|
if !candidates.isEmpty {
|
||||||
captionsMenuSignature = nil
|
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,
|
guard let selectedExternalSubtitleTrackID,
|
||||||
backend.selectedSubtitleTrackID < 0,
|
backend.selectedSubtitleTrackID < 0,
|
||||||
let track = externalSubtitleTracks.first(where: { $0.id == selectedExternalSubtitleTrackID })
|
let track = externalSubtitleTracks.first(where: { $0.id == selectedExternalSubtitleTrackID })
|
||||||
else {
|
else {
|
||||||
subtitleOverlayLabel.isHidden = true
|
hideExternalSubtitleOverlay(reason: "no-external-selected")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let adjustedTime = backend.currentTime - backend.subtitleDelay
|
let currentTime = playbackTime ?? backend.currentTime
|
||||||
guard let cue = track.cues.first(where: { adjustedTime >= $0.start && adjustedTime <= $0.end }) else {
|
let adjustedTime = currentTime - backend.subtitleDelay
|
||||||
subtitleOverlayLabel.isHidden = true
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
subtitleOverlayLabel.text = " \(cue.text) "
|
subtitleOverlayLabel.text = " \(cue.text) "
|
||||||
subtitleOverlayLabel.isHidden = false
|
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() {
|
private func hideControls() {
|
||||||
controlsContainer.isUserInteractionEnabled = false
|
controlsContainer.isUserInteractionEnabled = false
|
||||||
closeButton.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()
|
testSubtitleDisplayNameNormalization()
|
||||||
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
|
testSubtitleDisplayNameUsesPreservedNamesForGenericVLCTracks()
|
||||||
testSubtitleOptionMappingIncludesNone()
|
testSubtitleOptionMappingIncludesNone()
|
||||||
|
testExternalSubtitleParserHandlesCRLFSRT()
|
||||||
|
testExternalSubtitleCueLookupBoundaries()
|
||||||
|
testExternalSubtitleParserCleansMultilineCueText()
|
||||||
|
testExternalSubtitleParserHandlesSouthParkFirstCueTiming()
|
||||||
testContentRangeParsing()
|
testContentRangeParsing()
|
||||||
testSparseRangeStoreMergesOverlaps()
|
testSparseRangeStoreMergesOverlaps()
|
||||||
testSparseRangeStoreHitPartialHitAndMiss()
|
testSparseRangeStoreHitPartialHitAndMiss()
|
||||||
|
|
@ -624,6 +628,62 @@ struct StreamResolverTests {
|
||||||
assertEqual(options.first?.id, -1)
|
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() {
|
private static func testSubtitleDisplayNameNormalization() {
|
||||||
assertEqual(
|
assertEqual(
|
||||||
SubtitleDisplayName.displayName(for: SubtitleCandidate(
|
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