mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 21:38:15 +00:00
merge main into subtitle labels
This commit is contained in:
commit
1b14f4970a
6 changed files with 567 additions and 7 deletions
|
|
@ -23,6 +23,7 @@
|
||||||
{"id":"int-4e095d3f","kind":"field_change","created_at":"2026-05-25T14:38:21.968713Z","actor":"dirtydishes","issue_id":"dreamio-djc","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Auto-select the first discovered VLC subtitle track when playback is still disabled, while preserving manual caption choices."}}
|
{"id":"int-4e095d3f","kind":"field_change","created_at":"2026-05-25T14:38:21.968713Z","actor":"dirtydishes","issue_id":"dreamio-djc","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Auto-select the first discovered VLC subtitle track when playback is still disabled, while preserving manual caption choices."}}
|
||||||
{"id":"int-96629c65","kind":"field_change","created_at":"2026-05-25T14:45:38.521113Z","actor":"dirtydishes","issue_id":"dreamio-ppj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing."}}
|
{"id":"int-96629c65","kind":"field_change","created_at":"2026-05-25T14:45:38.521113Z","actor":"dirtydishes","issue_id":"dreamio-ppj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing."}}
|
||||||
{"id":"int-027cec57","kind":"field_change","created_at":"2026-05-25T14:51:44.599319Z","actor":"dirtydishes","issue_id":"dreamio-3xi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback."}}
|
{"id":"int-027cec57","kind":"field_change","created_at":"2026-05-25T14:51:44.599319Z","actor":"dirtydishes","issue_id":"dreamio-3xi","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback."}}
|
||||||
|
{"id":"int-8f943c34","kind":"field_change","created_at":"2026-05-25T15:01:35.610049Z","actor":"dirtydishes","issue_id":"dreamio-bao","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented native audio track discovery and selection with a far-left audio menu in the VLC-backed player."}}
|
||||||
{"id":"int-3acaadff","kind":"field_change","created_at":"2026-05-25T15:09:02.023077Z","actor":"dirtydishes","issue_id":"dreamio-h5n","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Limited VLC auto-subtitle reapply to real selection recovery while keeping bounded delayed startup confirmations."}}
|
{"id":"int-3acaadff","kind":"field_change","created_at":"2026-05-25T15:09:02.023077Z","actor":"dirtydishes","issue_id":"dreamio-h5n","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Limited VLC auto-subtitle reapply to real selection recovery while keeping bounded delayed startup confirmations."}}
|
||||||
{"id":"int-c526b5ae","kind":"field_change","created_at":"2026-05-25T15:32:37.748454Z","actor":"dirtydishes","issue_id":"dreamio-dow","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation."}}
|
{"id":"int-c526b5ae","kind":"field_change","created_at":"2026-05-25T15:32:37.748454Z","actor":"dirtydishes","issue_id":"dreamio-dow","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented stream-keyed subtitle buffering, OpenSubtitles parser/resolver hardening, VLC refresh behavior, and focused validation."}}
|
||||||
{"id":"int-320e7321","kind":"field_change","created_at":"2026-05-25T15:53:52.866657Z","actor":"dirtydishes","issue_id":"dreamio-hzj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Hardened OpenSubtitles candidate discovery, nested payload resolution, VLC external subtitle visibility selection, diagnostics, tests, and turn documentation."}}
|
{"id":"int-320e7321","kind":"field_change","created_at":"2026-05-25T15:53:52.866657Z","actor":"dirtydishes","issue_id":"dreamio-hzj","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Hardened OpenSubtitles candidate discovery, nested payload resolution, VLC external subtitle visibility selection, diagnostics, tests, and turn documentation."}}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,15 @@ protocol NativePlaybackBackend: AnyObject {
|
||||||
var onFailure: ((Error) -> Void)? { get set }
|
var onFailure: ((Error) -> Void)? { get set }
|
||||||
var onStateChange: (() -> Void)? { get set }
|
var onStateChange: (() -> Void)? { get set }
|
||||||
var onSubtitleTracksChange: (() -> Void)? { get set }
|
var onSubtitleTracksChange: (() -> Void)? { get set }
|
||||||
|
var onAudioTracksChange: (() -> Void)? { get set }
|
||||||
var isPlaying: Bool { get }
|
var isPlaying: Bool { get }
|
||||||
var isSeekable: Bool { get }
|
var isSeekable: Bool { get }
|
||||||
var duration: TimeInterval { get }
|
var duration: TimeInterval { get }
|
||||||
var currentTime: TimeInterval { get }
|
var currentTime: TimeInterval { get }
|
||||||
var remainingTime: TimeInterval { get }
|
var remainingTime: TimeInterval { get }
|
||||||
var position: Float { get }
|
var position: Float { get }
|
||||||
|
var audioTracks: [AudioTrack] { get }
|
||||||
|
var selectedAudioTrackID: Int32 { get }
|
||||||
var subtitleTracks: [SubtitleTrack] { get }
|
var subtitleTracks: [SubtitleTrack] { get }
|
||||||
var selectedSubtitleTrackID: Int32 { get }
|
var selectedSubtitleTrackID: Int32 { get }
|
||||||
var subtitleDelay: TimeInterval { get }
|
var subtitleDelay: TimeInterval { get }
|
||||||
|
|
@ -23,6 +26,7 @@ protocol NativePlaybackBackend: AnyObject {
|
||||||
func togglePlayPause()
|
func togglePlayPause()
|
||||||
func seek(to position: Float)
|
func seek(to position: Float)
|
||||||
func jump(by seconds: TimeInterval)
|
func jump(by seconds: TimeInterval)
|
||||||
|
func selectAudioTrack(id: Int32)
|
||||||
func selectSubtitleTrack(id: Int32)
|
func selectSubtitleTrack(id: Int32)
|
||||||
func adjustSubtitleDelay(by seconds: TimeInterval)
|
func adjustSubtitleDelay(by seconds: TimeInterval)
|
||||||
@discardableResult
|
@discardableResult
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ final class NativePlayerViewController: UIViewController {
|
||||||
private var progressTimer: Timer?
|
private var progressTimer: Timer?
|
||||||
private var isScrubbing = false
|
private var isScrubbing = false
|
||||||
private var attachedSubtitleURLs: Set<URL>
|
private var attachedSubtitleURLs: Set<URL>
|
||||||
|
private var audioMenuSignature: String?
|
||||||
private var captionsMenuSignature: String?
|
private var captionsMenuSignature: String?
|
||||||
var onDismiss: (() -> Void)?
|
var onDismiss: (() -> Void)?
|
||||||
|
|
||||||
|
|
@ -34,8 +35,11 @@ final class NativePlayerViewController: UIViewController {
|
||||||
private let controlsContainer: UIVisualEffectView = {
|
private let controlsContainer: UIVisualEffectView = {
|
||||||
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
|
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
|
||||||
view.translatesAutoresizingMaskIntoConstraints = false
|
view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.layer.cornerRadius = 16
|
view.layer.cornerRadius = 22
|
||||||
view.clipsToBounds = true
|
view.clipsToBounds = true
|
||||||
|
view.backgroundColor = UIColor.white.withAlphaComponent(0.08)
|
||||||
|
view.layer.borderColor = UIColor.white.withAlphaComponent(0.18).cgColor
|
||||||
|
view.layer.borderWidth = 1
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -49,6 +53,7 @@ final class NativePlayerViewController: UIViewController {
|
||||||
private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause")
|
private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause")
|
||||||
private let jumpBackButton = NativePlayerViewController.iconButton(systemName: "gobackward.15", label: "Jump Back 15 Seconds")
|
private let jumpBackButton = NativePlayerViewController.iconButton(systemName: "gobackward.15", label: "Jump Back 15 Seconds")
|
||||||
private let jumpForwardButton = NativePlayerViewController.iconButton(systemName: "goforward.15", label: "Jump Forward 15 Seconds")
|
private let jumpForwardButton = NativePlayerViewController.iconButton(systemName: "goforward.15", label: "Jump Forward 15 Seconds")
|
||||||
|
private let audioButton = NativePlayerViewController.iconButton(systemName: "waveform.circle", label: "Audio Tracks")
|
||||||
private let captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Captions")
|
private let captionsButton = NativePlayerViewController.iconButton(systemName: "captions.bubble", label: "Captions")
|
||||||
|
|
||||||
private let elapsedLabel: UILabel = {
|
private let elapsedLabel: UILabel = {
|
||||||
|
|
@ -229,6 +234,11 @@ final class NativePlayerViewController: UIViewController {
|
||||||
self?.refreshControls()
|
self?.refreshControls()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
backend.onAudioTracksChange = { [weak self] in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.refreshControls()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startStartupTimer() {
|
private func startStartupTimer() {
|
||||||
|
|
@ -250,8 +260,9 @@ final class NativePlayerViewController: UIViewController {
|
||||||
playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
|
playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
|
||||||
jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)
|
jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)
|
||||||
jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)
|
jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)
|
||||||
|
audioButton.showsMenuAsPrimaryAction = true
|
||||||
captionsButton.showsMenuAsPrimaryAction = true
|
captionsButton.showsMenuAsPrimaryAction = true
|
||||||
playPauseButton.layer.cornerRadius = 21
|
playPauseButton.layer.cornerRadius = 24
|
||||||
scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown)
|
scrubber.addTarget(self, action: #selector(scrubbingStarted), for: .touchDown)
|
||||||
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
|
scrubber.addTarget(self, action: #selector(scrubberChanged), for: .valueChanged)
|
||||||
scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel])
|
scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel])
|
||||||
|
|
@ -266,12 +277,19 @@ final class NativePlayerViewController: UIViewController {
|
||||||
timeAndScrubRow.alignment = .center
|
timeAndScrubRow.alignment = .center
|
||||||
timeAndScrubRow.spacing = 8
|
timeAndScrubRow.spacing = 8
|
||||||
|
|
||||||
let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton])
|
let playbackCluster = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton])
|
||||||
|
playbackCluster.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
playbackCluster.axis = .horizontal
|
||||||
|
playbackCluster.alignment = .center
|
||||||
|
playbackCluster.distribution = .equalCentering
|
||||||
|
playbackCluster.spacing = 14
|
||||||
|
|
||||||
|
let controlRow = UIStackView(arrangedSubviews: [audioButton, playbackCluster, captionsButton])
|
||||||
controlRow.translatesAutoresizingMaskIntoConstraints = false
|
controlRow.translatesAutoresizingMaskIntoConstraints = false
|
||||||
controlRow.axis = .horizontal
|
controlRow.axis = .horizontal
|
||||||
controlRow.alignment = .center
|
controlRow.alignment = .center
|
||||||
controlRow.distribution = .equalSpacing
|
controlRow.distribution = .equalCentering
|
||||||
controlRow.spacing = 14
|
controlRow.spacing = 18
|
||||||
|
|
||||||
let stack = UIStackView(arrangedSubviews: [timeAndScrubRow, controlRow])
|
let stack = UIStackView(arrangedSubviews: [timeAndScrubRow, controlRow])
|
||||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
@ -322,6 +340,9 @@ final class NativePlayerViewController: UIViewController {
|
||||||
playPauseButton.heightAnchor.constraint(equalToConstant: 42),
|
playPauseButton.heightAnchor.constraint(equalToConstant: 42),
|
||||||
jumpForwardButton.widthAnchor.constraint(equalToConstant: 36),
|
jumpForwardButton.widthAnchor.constraint(equalToConstant: 36),
|
||||||
jumpForwardButton.heightAnchor.constraint(equalToConstant: 36),
|
jumpForwardButton.heightAnchor.constraint(equalToConstant: 36),
|
||||||
|
audioButton.widthAnchor.constraint(equalToConstant: 36),
|
||||||
|
audioButton.heightAnchor.constraint(equalToConstant: 36),
|
||||||
|
playbackCluster.centerXAnchor.constraint(equalTo: controlRow.centerXAnchor),
|
||||||
captionsButton.widthAnchor.constraint(equalToConstant: 36),
|
captionsButton.widthAnchor.constraint(equalToConstant: 36),
|
||||||
captionsButton.heightAnchor.constraint(equalToConstant: 36)
|
captionsButton.heightAnchor.constraint(equalToConstant: 36)
|
||||||
])
|
])
|
||||||
|
|
@ -430,6 +451,36 @@ final class NativePlayerViewController: UIViewController {
|
||||||
return UIMenu(title: "Captions", children: trackActions + [delayActions])
|
return UIMenu(title: "Captions", children: trackActions + [delayActions])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func audioMenu() -> UIMenu {
|
||||||
|
let selectedTrackID = backend.selectedAudioTrackID
|
||||||
|
let tracks = backend.audioTracks
|
||||||
|
let options = AudioOptionMapper.options(from: tracks)
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioAudio] build-menu tracks=\(SubtitleDebugFormatter.trackSummary(tracks)) selected=\(selectedTrackID)")
|
||||||
|
#endif
|
||||||
|
let trackActions = options.map { track in
|
||||||
|
UIAction(
|
||||||
|
title: track.name,
|
||||||
|
state: track.id == selectedTrackID ? .on : .off
|
||||||
|
) { [weak self] _ in
|
||||||
|
guard let self else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioAudio] select-request id=\(track.id) name=\(track.name) before=\(self.backend.selectedAudioTrackID)")
|
||||||
|
#endif
|
||||||
|
self.backend.selectAudioTrack(id: track.id)
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioAudio] select-result id=\(track.id) after=\(self.backend.selectedAudioTrackID) tracks=\(SubtitleDebugFormatter.trackSummary(self.backend.audioTracks))")
|
||||||
|
#endif
|
||||||
|
self.audioMenuSignature = nil
|
||||||
|
self.refreshControls()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return UIMenu(title: "Audio", children: trackActions)
|
||||||
|
}
|
||||||
|
|
||||||
private func startProgressUpdates() {
|
private func startProgressUpdates() {
|
||||||
progressTimer?.invalidate()
|
progressTimer?.invalidate()
|
||||||
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||||
|
|
@ -438,11 +489,13 @@ final class NativePlayerViewController: UIViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func refreshControls() {
|
private func refreshControls() {
|
||||||
|
let audioTracks = backend.audioTracks
|
||||||
let subtitleTracks = backend.subtitleTracks
|
let subtitleTracks = backend.subtitleTracks
|
||||||
playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
|
playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
|
||||||
scrubber.isEnabled = backend.isSeekable
|
scrubber.isEnabled = backend.isSeekable
|
||||||
jumpBackButton.isEnabled = backend.isSeekable
|
jumpBackButton.isEnabled = backend.isSeekable
|
||||||
jumpForwardButton.isEnabled = backend.isSeekable
|
jumpForwardButton.isEnabled = backend.isSeekable
|
||||||
|
updateAudioMenuIfNeeded(audioTracks: audioTracks)
|
||||||
updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)
|
updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)
|
||||||
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
|
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
|
||||||
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
|
remainingLabel.text = "-\(PlaybackTimeFormatter.label(for: backend.remainingTime))"
|
||||||
|
|
@ -452,6 +505,26 @@ final class NativePlayerViewController: UIViewController {
|
||||||
[scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = backend.isSeekable ? 1 : 0.45 }
|
[scrubber, jumpBackButton, jumpForwardButton].forEach { $0.alpha = backend.isSeekable ? 1 : 0.45 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateAudioMenuIfNeeded(audioTracks: [AudioTrack]) {
|
||||||
|
let selectedTrackID = backend.selectedAudioTrackID
|
||||||
|
let signature = trackMenuSignatureValue(
|
||||||
|
tracks: audioTracks,
|
||||||
|
selectedTrackID: selectedTrackID
|
||||||
|
)
|
||||||
|
let hasSelectableTrack = AudioOptionMapper.options(from: audioTracks).count > 1
|
||||||
|
audioButton.isEnabled = hasSelectableTrack
|
||||||
|
audioButton.alpha = hasSelectableTrack ? 1 : 0.45
|
||||||
|
guard signature != audioMenuSignature else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
audioMenuSignature = signature
|
||||||
|
audioButton.menu = audioMenu()
|
||||||
|
#if DEBUG
|
||||||
|
print("[DreamioAudio] refresh-menu enabled=\(audioButton.isEnabled) tracks=\(SubtitleDebugFormatter.trackSummary(audioTracks)) selected=\(selectedTrackID)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
private func updateCaptionsMenuIfNeeded(subtitleTracks: [SubtitleTrack]) {
|
private func updateCaptionsMenuIfNeeded(subtitleTracks: [SubtitleTrack]) {
|
||||||
let selectedTrackID = backend.selectedSubtitleTrackID
|
let selectedTrackID = backend.selectedSubtitleTrackID
|
||||||
let signature = captionsMenuSignatureValue(
|
let signature = captionsMenuSignatureValue(
|
||||||
|
|
@ -476,11 +549,19 @@ final class NativePlayerViewController: UIViewController {
|
||||||
tracks: [SubtitleTrack],
|
tracks: [SubtitleTrack],
|
||||||
selectedTrackID: Int32,
|
selectedTrackID: Int32,
|
||||||
delay: TimeInterval
|
delay: TimeInterval
|
||||||
|
) -> String {
|
||||||
|
let trackSignature = trackMenuSignatureValue(tracks: tracks, selectedTrackID: selectedTrackID)
|
||||||
|
return "\(trackSignature)#delay=\(String(format: "%.1f", delay))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func trackMenuSignatureValue(
|
||||||
|
tracks: [SubtitleTrack],
|
||||||
|
selectedTrackID: Int32
|
||||||
) -> String {
|
) -> String {
|
||||||
let trackSignature = tracks
|
let trackSignature = tracks
|
||||||
.map { "\($0.id):\($0.name)" }
|
.map { "\($0.id):\($0.name)" }
|
||||||
.joined(separator: "|")
|
.joined(separator: "|")
|
||||||
return "\(trackSignature)#selected=\(selectedTrackID)#delay=\(String(format: "%.1f", delay))"
|
return "\(trackSignature)#selected=\(selectedTrackID)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func revealControls() {
|
private func revealControls() {
|
||||||
|
|
@ -517,8 +598,10 @@ final class NativePlayerViewController: UIViewController {
|
||||||
button.translatesAutoresizingMaskIntoConstraints = false
|
button.translatesAutoresizingMaskIntoConstraints = false
|
||||||
button.setImage(UIImage(systemName: systemName), for: .normal)
|
button.setImage(UIImage(systemName: systemName), for: .normal)
|
||||||
button.tintColor = .white
|
button.tintColor = .white
|
||||||
button.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
button.backgroundColor = UIColor.white.withAlphaComponent(0.12)
|
||||||
button.layer.cornerRadius = 18
|
button.layer.cornerRadius = 18
|
||||||
|
button.layer.borderColor = UIColor.white.withAlphaComponent(0.16).cgColor
|
||||||
|
button.layer.borderWidth = 1
|
||||||
button.accessibilityLabel = label
|
button.accessibilityLabel = label
|
||||||
return button
|
return button
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,8 @@ enum SubtitleDisplayName {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typealias AudioTrack = SubtitleTrack
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
enum SubtitleDebugFormatter {
|
enum SubtitleDebugFormatter {
|
||||||
static func candidateSummary(_ candidates: [SubtitleCandidate]) -> String {
|
static func candidateSummary(_ candidates: [SubtitleCandidate]) -> String {
|
||||||
|
|
@ -195,6 +197,12 @@ enum SubtitleOptionMapper {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AudioOptionMapper {
|
||||||
|
static func options(from tracks: [AudioTrack]) -> [AudioTrack] {
|
||||||
|
tracks.filter { $0.id >= 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct StreamClassification {
|
struct StreamClassification {
|
||||||
let sourceKind: StreamSourceKind
|
let sourceKind: StreamSourceKind
|
||||||
let containerGuess: StreamContainerGuess
|
let containerGuess: StreamContainerGuess
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
var onFailure: ((Error) -> Void)?
|
var onFailure: ((Error) -> Void)?
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onSubtitleTracksChange: (() -> Void)?
|
var onSubtitleTracksChange: (() -> Void)?
|
||||||
|
var onAudioTracksChange: (() -> Void)?
|
||||||
|
|
||||||
#if canImport(MobileVLCKit)
|
#if canImport(MobileVLCKit)
|
||||||
private let mediaPlayer = VLCMediaPlayer()
|
private let mediaPlayer = VLCMediaPlayer()
|
||||||
|
|
@ -112,6 +113,19 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func selectAudioTrack(id: Int32) {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
#if DEBUG
|
||||||
|
logAudioTracks(reason: "before-select-\(id)")
|
||||||
|
#endif
|
||||||
|
mediaPlayer.currentAudioTrackIndex = id
|
||||||
|
#if DEBUG
|
||||||
|
logAudioTracks(reason: "after-select-\(id)")
|
||||||
|
#endif
|
||||||
|
onAudioTracksChange?()
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
func selectSubtitleTrack(id: Int32) {
|
func selectSubtitleTrack(id: Int32) {
|
||||||
#if canImport(MobileVLCKit)
|
#if canImport(MobileVLCKit)
|
||||||
didUserSelectSubtitleTrack = true
|
didUserSelectSubtitleTrack = true
|
||||||
|
|
@ -201,6 +215,26 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var audioTracks: [AudioTrack] {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
let names = mediaPlayer.audioTrackNames as? [String] ?? []
|
||||||
|
let indexes = mediaPlayer.audioTrackIndexes as? [NSNumber] ?? []
|
||||||
|
return zip(indexes, names).map { index, name in
|
||||||
|
AudioTrack(id: index.int32Value, name: name)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
[]
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedAudioTrackID: Int32 {
|
||||||
|
#if canImport(MobileVLCKit)
|
||||||
|
mediaPlayer.currentAudioTrackIndex
|
||||||
|
#else
|
||||||
|
-1
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
var subtitleTracks: [SubtitleTrack] {
|
var subtitleTracks: [SubtitleTrack] {
|
||||||
#if canImport(MobileVLCKit)
|
#if canImport(MobileVLCKit)
|
||||||
reconcileExternalSubtitleDisplayNames()
|
reconcileExternalSubtitleDisplayNames()
|
||||||
|
|
@ -305,6 +339,12 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
private func logAudioTracks(reason: String) {
|
||||||
|
let names = mediaPlayer.audioTrackNames as? [String] ?? []
|
||||||
|
let indexes = mediaPlayer.audioTrackIndexes as? [NSNumber] ?? []
|
||||||
|
print("[DreamioVLC] audio tracks reason=\(reason) names=\(names) indexes=\(indexes.map { $0.int32Value }) selected=\(mediaPlayer.currentAudioTrackIndex)")
|
||||||
|
}
|
||||||
|
|
||||||
private func logSubtitleTracks(reason: String) {
|
private func logSubtitleTracks(reason: String) {
|
||||||
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
|
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
|
||||||
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? []
|
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? []
|
||||||
|
|
@ -396,6 +436,7 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
|
||||||
reapplyAutoSelectedSubtitleTrackIfNeeded(reason: stateName(mediaPlayer.state))
|
reapplyAutoSelectedSubtitleTrackIfNeeded(reason: stateName(mediaPlayer.state))
|
||||||
onReady?()
|
onReady?()
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
|
onAudioTracksChange?()
|
||||||
case .error:
|
case .error:
|
||||||
onFailure?(NativePlaybackError.playbackFailed)
|
onFailure?(NativePlaybackError.playbackFailed)
|
||||||
case .paused, .stopped, .ended:
|
case .paused, .stopped, .ended:
|
||||||
|
|
@ -403,8 +444,10 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
|
||||||
case .esAdded:
|
case .esAdded:
|
||||||
selectPreferredSubtitleTrackIfNeeded(reason: "esAdded")
|
selectPreferredSubtitleTrackIfNeeded(reason: "esAdded")
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
logAudioTracks(reason: "esAdded")
|
||||||
logSubtitleTracks(reason: "esAdded")
|
logSubtitleTracks(reason: "esAdded")
|
||||||
#endif
|
#endif
|
||||||
|
onAudioTracksChange?()
|
||||||
onSubtitleTracksChange?()
|
onSubtitleTracksChange?()
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
|
|
|
||||||
421
docs/turns/2026-05-25-native-player-audio-tracks.html
Normal file
421
docs/turns/2026-05-25-native-player-audio-tracks.html
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue