Add embedded audio/subtitle track selection for multi-track files

Files with multiple embedded tracks (e.g. MKV from media sources or
downloads) previously exposed none of them: mpv's track-list was never
read, aid was never set, and sid was only ever no/auto. The quality
selector showed no Audio or Subtitles tab for such files.

MPVClient now observes track-list/aid/sid and delivers a coalesced,
leniently decoded [MPVTrack] snapshot; selection state is derived from
the reported "selected" flags. Embedded tracks are switched live via
aid/sid (no reload), surfaced in the existing Audio/Subtitles sections
alongside external captions on iOS, tvOS and macOS. External sub-add/
audio-add tracks are filtered out to avoid double-listing. User picks
are sticky across same-video reloads (quality switch, audio mode,
retries) and preferred audio/subtitle languages auto-select a matching
embedded track once per load, with external captions taking precedence.
This commit is contained in:
Arkadiusz Fal
2026-07-29 22:14:46 +02:00
parent 150ebf42a4
commit 67ed8c322b
17 changed files with 841 additions and 11 deletions

View File

@@ -62,6 +62,37 @@ final class PlayerService {
/// Currently loaded caption.
private(set) var currentCaption: Caption?
/// Tracks reported by mpv for the currently loaded file (embedded and external).
private(set) var embeddedTracks: [MPVTrack] = []
/// Embedded (in-container) audio tracks of the current file.
var embeddedAudioTracks: [MPVTrack] {
embeddedTracks.filter { $0.type == .audio && !$0.isExternal }
}
/// Embedded (in-container) subtitle tracks of the current file.
var embeddedSubtitleTracks: [MPVTrack] {
embeddedTracks.filter { $0.type == .sub && !$0.isExternal }
}
/// The embedded audio track mpv currently plays, if any.
var selectedEmbeddedAudioTrackID: Int? {
embeddedAudioTracks.first(where: \.isSelected)?.trackID
}
/// The embedded subtitle track mpv currently shows, if any.
var selectedEmbeddedSubtitleTrackID: Int? {
embeddedSubtitleTracks.first(where: \.isSelected)?.trackID
}
/// Embedded-track picks that should survive same-video reloads
/// (quality switch, audio-mode toggle, buffer-stall retry).
private var desiredEmbeddedAudioTrackID: Int?
private var desiredEmbeddedSubtitleTrackID: Int?
/// Whether preferred-language auto-selection already ran for this load.
private var didAutoSelectEmbeddedTracks = false
/// The current download being played, if any.
private(set) var currentDownload: Download?
@@ -234,6 +265,15 @@ final class PlayerService {
availableStreams = []
}
// Embedded-track state: the mpv-reported list always refreshes on load,
// but sticky user picks only reset when the video actually changes.
embeddedTracks = []
didAutoSelectEmbeddedTracks = false
if isNewVideo {
desiredEmbeddedAudioTrackID = nil
desiredEmbeddedSubtitleTrackID = nil
}
// Clear sponsor block state from previous video
state.sponsorSegments = []
state.currentSegment = nil
@@ -619,6 +659,10 @@ final class PlayerService {
preDownloadedSubtitleFolders.removeAll()
cleanupAllTempSubtitles()
currentCaption = nil
embeddedTracks = []
desiredEmbeddedAudioTrackID = nil
desiredEmbeddedSubtitleTrackID = nil
didAutoSelectEmbeddedTracks = false
lastSkippedSegmentID = nil
nowPlayingService.clearNowPlaying()
}
@@ -1303,6 +1347,8 @@ final class PlayerService {
mpvBackend.loadCaption(caption)
currentCaption = caption
// An explicit external pick (or Off) overrides any embedded-subtitle intent
desiredEmbeddedSubtitleTrackID = nil
if let caption {
LoggingService.shared.logPlayer("Loaded caption: \(caption.displayName)")
@@ -1311,6 +1357,70 @@ final class PlayerService {
}
}
/// Selects an embedded audio track by mpv track id (live, no reload).
/// Only works with MPV backend.
func selectEmbeddedAudioTrack(_ trackID: Int) {
guard let mpvBackend = currentBackend as? MPVBackend else { return }
desiredEmbeddedAudioTrackID = trackID
mpvBackend.selectEmbeddedAudioTrack(trackID)
LoggingService.shared.logPlayer("Selected embedded audio track \(trackID)")
}
/// Selects an embedded subtitle track by mpv track id (nil = off).
/// Only works with MPV backend.
func selectEmbeddedSubtitleTrack(_ trackID: Int?) {
guard let mpvBackend = currentBackend as? MPVBackend else { return }
desiredEmbeddedSubtitleTrackID = trackID
currentCaption = nil
mpvBackend.selectEmbeddedSubtitleTrack(trackID)
LoggingService.shared.logPlayer("Selected embedded subtitle track \(trackID.map(String.init) ?? "off")")
}
/// Re-applies a sticky embedded-track pick after a reload, or auto-selects
/// preferred languages once per load. Called whenever mpv's track list changes.
private func reapplyOrAutoSelectEmbeddedTracks() {
guard let mpvBackend = currentBackend as? MPVBackend else { return }
// Re-apply sticky user picks. Loop-safe: selecting fires one more
// track-list update in which the track is already selected.
if let desired = desiredEmbeddedAudioTrackID,
let track = embeddedAudioTracks.first(where: { $0.trackID == desired }),
!track.isSelected {
mpvBackend.selectEmbeddedAudioTrack(desired)
}
if let desired = desiredEmbeddedSubtitleTrackID,
let track = embeddedSubtitleTracks.first(where: { $0.trackID == desired }),
!track.isSelected {
mpvBackend.selectEmbeddedSubtitleTrack(desired)
}
// Auto-select preferred languages once per load. Skipped while the
// list is empty so the reset delivered at load start doesn't consume
// the one-shot flag.
guard !didAutoSelectEmbeddedTracks, !embeddedTracks.isEmpty else { return }
didAutoSelectEmbeddedTracks = true
if desiredEmbeddedAudioTrackID == nil,
embeddedAudioTracks.count > 1,
let match = embeddedAudioTracks.first(where: { $0.matchesLanguage(settingsManager?.preferredAudioLanguage) }),
!match.isSelected {
mpvBackend.selectEmbeddedAudioTrack(match.trackID)
LoggingService.shared.logPlayer("Auto-selected embedded audio track \(match.trackID) (\(match.displayName))")
}
// External captions win: the preferred-caption flow sets currentCaption
// before mpv reports any tracks.
if desiredEmbeddedSubtitleTrackID == nil,
currentCaption == nil,
let match = embeddedSubtitleTracks.first(where: { !$0.isForced && $0.matchesLanguage(settingsManager?.preferredSubtitlesLanguage) }),
!match.isSelected {
mpvBackend.selectEmbeddedSubtitleTrack(match.trackID)
LoggingService.shared.logPlayer("Auto-selected embedded subtitle track \(match.trackID) (\(match.displayName))")
}
}
/// Loads a local subtitle file from disk.
/// Only works with MPV backend.
/// - Parameter url: The local file URL of the caption file
@@ -2902,6 +3012,11 @@ extension PlayerService: PlayerBackendDelegate {
state.setRetryState(retryState)
}
func backend(_ backend: any PlayerBackend, didUpdateTracks tracks: [MPVTrack]) {
embeddedTracks = tracks
reapplyOrAutoSelectEmbeddedTracks()
}
func backend(_ backend: any PlayerBackend, didRequestStreamRefresh atTime: TimeInterval?) {
LoggingService.shared.logPlayer("Stream refresh requested at time: \(atTime ?? -1)")