mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
add native audio track selection
This commit is contained in:
parent
87686d16e9
commit
ea5132c4d3
7 changed files with 568 additions and 7 deletions
|
|
@ -23,3 +23,4 @@
|
||||||
{"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."}}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
{"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"dreamio-8cz","title":"fix stremio external subtitle loading regression","description":"After adding late subtitle forwarding for native playback, Stremio external subtitle loading is failing. Investigate the injected bridge and native subtitle forwarding path, then adjust behavior so Stremio can still load external subtitles while native playback receives late candidates.","status":"closed","priority":0,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T11:05:42Z","created_by":"dirtydishes","updated_at":"2026-05-25T11:07:35Z","started_at":"2026-05-25T11:05:55Z","closed_at":"2026-05-25T11:07:35Z","close_reason":"Hardened subtitle bridge network observers so non-text Stremio subtitle loads are not touched, and made parser traversal deterministic for metadata preservation.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
{"_type":"issue","id":"dreamio-bao","title":"add native player audio track selection","description":"Add audio track discovery and selection to the native VLC-backed player so multi-language files can be filtered from the player controls.","status":"closed","priority":1,"issue_type":"feature","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:57:14Z","created_by":"dirtydishes","updated_at":"2026-05-25T15:01:36Z","closed_at":"2026-05-25T15:01:36Z","close_reason":"Implemented native audio track discovery and selection with a far-left audio menu in the VLC-backed player.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"dreamio-3xi","title":"Capture browser text tracks for OpenSubtitles V3","description":"OpenSubtitles V3 subtitles can be attached to the Stremio web player as HTML track/textTrack entries rather than appearing in the initial stream candidate. Extend the web bridge to inspect track elements and textTracks so external subtitles can be forwarded to native playback.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:49:50Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:51:45Z","started_at":"2026-05-25T14:49:52Z","closed_at":"2026-05-25T14:51:45Z","close_reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"dreamio-3xi","title":"Capture browser text tracks for OpenSubtitles V3","description":"OpenSubtitles V3 subtitles can be attached to the Stremio web player as HTML track/textTrack entries rather than appearing in the initial stream candidate. Extend the web bridge to inspect track elements and textTracks so external subtitles can be forwarded to native playback.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:49:50Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:51:45Z","started_at":"2026-05-25T14:49:52Z","closed_at":"2026-05-25T14:51:45Z","close_reason":"Captured OpenSubtitles V3 subtitle URLs from browser track elements and textTracks so they can be forwarded to native playback.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"dreamio-ppj","title":"Reapply VLC embedded subtitle selection after track discovery","description":"Device logs show VLC eventually exposes and selects the embedded English SDH subtitle track, but subtitles still do not render. Investigate and harden the VLC selection timing so embedded tracks are selected after discovery is stable.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:44:08Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:45:38Z","started_at":"2026-05-25T14:44:18Z","closed_at":"2026-05-25T14:45:38Z","close_reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"dreamio-ppj","title":"Reapply VLC embedded subtitle selection after track discovery","description":"Device logs show VLC eventually exposes and selects the embedded English SDH subtitle track, but subtitles still do not render. Investigate and harden the VLC selection timing so embedded tracks are selected after discovery is stable.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:44:08Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:45:38Z","started_at":"2026-05-25T14:44:18Z","closed_at":"2026-05-25T14:45:38Z","close_reason":"Re-applied the auto-selected VLC subtitle track after stream discovery and playback state changes to harden rendering timing.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
{"_type":"issue","id":"dreamio-djc","title":"Auto-select embedded VLC subtitle tracks","description":"VLC discovers embedded MKV subtitle tracks after playback starts, but Dreamio leaves subtitles disabled when no external candidates were provided. Add automatic selection for the first selectable VLC subtitle track while preserving manual caption choices.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:36:11Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:38:22Z","started_at":"2026-05-25T14:36:17Z","closed_at":"2026-05-25T14:38:22Z","close_reason":"Auto-select the first discovered VLC subtitle track when playback is still disabled, while preserving manual caption choices.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
{"_type":"issue","id":"dreamio-djc","title":"Auto-select embedded VLC subtitle tracks","description":"VLC discovers embedded MKV subtitle tracks after playback starts, but Dreamio leaves subtitles disabled when no external candidates were provided. Add automatic selection for the first selectable VLC subtitle track while preserving manual caption choices.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-25T14:36:11Z","created_by":"dirtydishes","updated_at":"2026-05-25T14:38:22Z","started_at":"2026-05-25T14:36:17Z","closed_at":"2026-05-25T14:38:22Z","close_reason":"Auto-select the first discovered VLC subtitle track when playback is still disabled, while preserving manual caption choices.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ struct SubtitleTrack: Equatable {
|
||||||
let name: String
|
let name: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typealias AudioTrack = SubtitleTrack
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
enum SubtitleDebugFormatter {
|
enum SubtitleDebugFormatter {
|
||||||
static func candidateSummary(_ candidates: [SubtitleCandidate]) -> String {
|
static func candidateSummary(_ candidates: [SubtitleCandidate]) -> String {
|
||||||
|
|
@ -93,6 +95,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()
|
||||||
|
|
@ -104,6 +105,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
|
||||||
|
|
@ -193,6 +207,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)
|
||||||
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
|
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
|
||||||
|
|
@ -257,6 +291,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] ?? []
|
||||||
|
|
@ -315,6 +355,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:
|
||||||
|
|
@ -322,8 +363,10 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
|
||||||
case .esAdded:
|
case .esAdded:
|
||||||
selectInitialSubtitleTrackIfNeeded(reason: "esAdded")
|
selectInitialSubtitleTrackIfNeeded(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