mirror of
https://github.com/dirtydishes/dreamio.git
synced 2026-06-06 13:37:24 +00:00
Merge pull request #8 from dirtydishes/audio-track-selection
add native audio track selection
This commit is contained in:
commit
25fe0d278f
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-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-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-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."}}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@ protocol NativePlaybackBackend: AnyObject {
|
|||
var onFailure: ((Error) -> Void)? { get set }
|
||||
var onStateChange: (() -> Void)? { get set }
|
||||
var onSubtitleTracksChange: (() -> Void)? { get set }
|
||||
var onAudioTracksChange: (() -> Void)? { get set }
|
||||
var isPlaying: Bool { get }
|
||||
var isSeekable: Bool { get }
|
||||
var duration: TimeInterval { get }
|
||||
var currentTime: TimeInterval { get }
|
||||
var remainingTime: TimeInterval { get }
|
||||
var position: Float { get }
|
||||
var audioTracks: [AudioTrack] { get }
|
||||
var selectedAudioTrackID: Int32 { get }
|
||||
var subtitleTracks: [SubtitleTrack] { get }
|
||||
var selectedSubtitleTrackID: Int32 { get }
|
||||
var subtitleDelay: TimeInterval { get }
|
||||
|
|
@ -23,6 +26,7 @@ protocol NativePlaybackBackend: AnyObject {
|
|||
func togglePlayPause()
|
||||
func seek(to position: Float)
|
||||
func jump(by seconds: TimeInterval)
|
||||
func selectAudioTrack(id: Int32)
|
||||
func selectSubtitleTrack(id: Int32)
|
||||
func adjustSubtitleDelay(by seconds: TimeInterval)
|
||||
@discardableResult
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ final class NativePlayerViewController: UIViewController {
|
|||
private var progressTimer: Timer?
|
||||
private var isScrubbing = false
|
||||
private var attachedSubtitleURLs: Set<URL>
|
||||
private var audioMenuSignature: String?
|
||||
private var captionsMenuSignature: String?
|
||||
var onDismiss: (() -> Void)?
|
||||
|
||||
|
|
@ -34,8 +35,11 @@ final class NativePlayerViewController: UIViewController {
|
|||
private let controlsContainer: UIVisualEffectView = {
|
||||
let view = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialDark))
|
||||
view.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.layer.cornerRadius = 16
|
||||
view.layer.cornerRadius = 22
|
||||
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
|
||||
}()
|
||||
|
||||
|
|
@ -49,6 +53,7 @@ final class NativePlayerViewController: UIViewController {
|
|||
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 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 elapsedLabel: UILabel = {
|
||||
|
|
@ -229,6 +234,11 @@ final class NativePlayerViewController: UIViewController {
|
|||
self?.refreshControls()
|
||||
}
|
||||
}
|
||||
backend.onAudioTracksChange = { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
self?.refreshControls()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startStartupTimer() {
|
||||
|
|
@ -250,8 +260,9 @@ final class NativePlayerViewController: UIViewController {
|
|||
playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)
|
||||
jumpBackButton.addTarget(self, action: #selector(jumpBack), for: .touchUpInside)
|
||||
jumpForwardButton.addTarget(self, action: #selector(jumpForward), for: .touchUpInside)
|
||||
audioButton.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(scrubberChanged), for: .valueChanged)
|
||||
scrubber.addTarget(self, action: #selector(scrubbingEnded), for: [.touchUpInside, .touchUpOutside, .touchCancel])
|
||||
|
|
@ -266,12 +277,19 @@ final class NativePlayerViewController: UIViewController {
|
|||
timeAndScrubRow.alignment = .center
|
||||
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.axis = .horizontal
|
||||
controlRow.alignment = .center
|
||||
controlRow.distribution = .equalSpacing
|
||||
controlRow.spacing = 14
|
||||
controlRow.distribution = .equalCentering
|
||||
controlRow.spacing = 18
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [timeAndScrubRow, controlRow])
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
|
@ -322,6 +340,9 @@ final class NativePlayerViewController: UIViewController {
|
|||
playPauseButton.heightAnchor.constraint(equalToConstant: 42),
|
||||
jumpForwardButton.widthAnchor.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.heightAnchor.constraint(equalToConstant: 36)
|
||||
])
|
||||
|
|
@ -430,6 +451,36 @@ final class NativePlayerViewController: UIViewController {
|
|||
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() {
|
||||
progressTimer?.invalidate()
|
||||
progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||
|
|
@ -438,11 +489,13 @@ final class NativePlayerViewController: UIViewController {
|
|||
}
|
||||
|
||||
private func refreshControls() {
|
||||
let audioTracks = backend.audioTracks
|
||||
let subtitleTracks = backend.subtitleTracks
|
||||
playPauseButton.setImage(UIImage(systemName: backend.isPlaying ? "pause.fill" : "play.fill"), for: .normal)
|
||||
scrubber.isEnabled = backend.isSeekable
|
||||
jumpBackButton.isEnabled = backend.isSeekable
|
||||
jumpForwardButton.isEnabled = backend.isSeekable
|
||||
updateAudioMenuIfNeeded(audioTracks: audioTracks)
|
||||
updateCaptionsMenuIfNeeded(subtitleTracks: subtitleTracks)
|
||||
elapsedLabel.text = PlaybackTimeFormatter.label(for: backend.currentTime)
|
||||
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 }
|
||||
}
|
||||
|
||||
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]) {
|
||||
let selectedTrackID = backend.selectedSubtitleTrackID
|
||||
let signature = captionsMenuSignatureValue(
|
||||
|
|
@ -476,11 +549,19 @@ final class NativePlayerViewController: UIViewController {
|
|||
tracks: [SubtitleTrack],
|
||||
selectedTrackID: Int32,
|
||||
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 {
|
||||
let trackSignature = tracks
|
||||
.map { "\($0.id):\($0.name)" }
|
||||
.joined(separator: "|")
|
||||
return "\(trackSignature)#selected=\(selectedTrackID)#delay=\(String(format: "%.1f", delay))"
|
||||
return "\(trackSignature)#selected=\(selectedTrackID)"
|
||||
}
|
||||
|
||||
private func revealControls() {
|
||||
|
|
@ -517,8 +598,10 @@ final class NativePlayerViewController: UIViewController {
|
|||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.setImage(UIImage(systemName: systemName), for: .normal)
|
||||
button.tintColor = .white
|
||||
button.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||
button.backgroundColor = UIColor.white.withAlphaComponent(0.12)
|
||||
button.layer.cornerRadius = 18
|
||||
button.layer.borderColor = UIColor.white.withAlphaComponent(0.16).cgColor
|
||||
button.layer.borderWidth = 1
|
||||
button.accessibilityLabel = label
|
||||
return button
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ struct SubtitleTrack: Equatable {
|
|||
let name: String
|
||||
}
|
||||
|
||||
typealias AudioTrack = SubtitleTrack
|
||||
|
||||
#if DEBUG
|
||||
enum SubtitleDebugFormatter {
|
||||
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 {
|
||||
let sourceKind: StreamSourceKind
|
||||
let containerGuess: StreamContainerGuess
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
var onFailure: ((Error) -> Void)?
|
||||
var onStateChange: (() -> Void)?
|
||||
var onSubtitleTracksChange: (() -> Void)?
|
||||
var onAudioTracksChange: (() -> Void)?
|
||||
|
||||
#if canImport(MobileVLCKit)
|
||||
private let mediaPlayer = VLCMediaPlayer()
|
||||
|
|
@ -108,6 +109,19 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
#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) {
|
||||
#if canImport(MobileVLCKit)
|
||||
didUserSelectSubtitleTrack = true
|
||||
|
|
@ -197,6 +211,26 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
#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] {
|
||||
#if canImport(MobileVLCKit)
|
||||
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
|
||||
|
|
@ -269,6 +303,12 @@ final class VLCNativePlaybackBackend: NSObject, NativePlaybackBackend {
|
|||
}
|
||||
|
||||
#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) {
|
||||
let names = mediaPlayer.videoSubTitlesNames as? [String] ?? []
|
||||
let indexes = mediaPlayer.videoSubTitlesIndexes as? [NSNumber] ?? []
|
||||
|
|
@ -360,6 +400,7 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
|
|||
reapplyAutoSelectedSubtitleTrackIfNeeded(reason: stateName(mediaPlayer.state))
|
||||
onReady?()
|
||||
onStateChange?()
|
||||
onAudioTracksChange?()
|
||||
case .error:
|
||||
onFailure?(NativePlaybackError.playbackFailed)
|
||||
case .paused, .stopped, .ended:
|
||||
|
|
@ -367,8 +408,10 @@ extension VLCNativePlaybackBackend: VLCMediaPlayerDelegate {
|
|||
case .esAdded:
|
||||
selectPreferredSubtitleTrackIfNeeded(reason: "esAdded")
|
||||
#if DEBUG
|
||||
logAudioTracks(reason: "esAdded")
|
||||
logSubtitleTracks(reason: "esAdded")
|
||||
#endif
|
||||
onAudioTracksChange?()
|
||||
onSubtitleTracksChange?()
|
||||
default:
|
||||
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