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

@@ -98,6 +98,7 @@ protocol MPVClientDelegate: AnyObject {
func mpvClient(_ client: MPVClient, didUpdateProperty property: String, value: Any?)
func mpvClient(_ client: MPVClient, didReceiveEvent event: mpv_event_id)
func mpvClient(_ client: MPVClient, didUpdateCacheState cacheState: MPVCacheState)
func mpvClient(_ client: MPVClient, didUpdateTrackList tracks: [MPVTrack])
func mpvClientDidEndFile(_ client: MPVClient, reason: MPVEndFileReason, errorCode: Int32, errorString: String?)
}
@@ -166,6 +167,11 @@ final class MPVClient: @unchecked Sendable {
/// The loop itself exits via `isDestroyed` + `mpv_wakeup` + `eventLoopExitSemaphore`.
private var eventLoopRunning = false
/// Whether a coalesced track-list refresh is pending (mpvQueue-guarded).
/// Load and sub-add fire bursts of track-list/aid/sid notifications;
/// they collapse into a single fetch.
private var trackListRefreshScheduled = false
/// Semaphore signaled when event loop exits
private let eventLoopExitSemaphore = DispatchSemaphore(value: 0)
@@ -648,6 +654,11 @@ final class MPVClient: @unchecked Sendable {
observeProperty("hwdec-interop", format: MPV_FORMAT_STRING)
// Color transfer for tvOS dynamic-range matching (SDR / HDR10 / HLG)
observeProperty("video-params/gamma", format: MPV_FORMAT_STRING)
// Track roster and selection: change signals only the values are
// ignored and a coalesced track-list fetch runs instead.
observeProperty("track-list", format: MPV_FORMAT_NONE)
observeProperty("aid", format: MPV_FORMAT_STRING)
observeProperty("sid", format: MPV_FORMAT_STRING)
}
// MARK: - Options
@@ -840,6 +851,66 @@ final class MPVClient: @unchecked Sendable {
commandAsync(["sub-remove"])
}
// MARK: - Embedded Tracks
/// Select an audio track by mpv track id (live, no reload). nil disables audio.
func setAudioTrack(_ trackID: Int?) {
setProperty("aid", trackID.map(String.init) ?? "no")
}
/// Select a subtitle track by mpv track id (embedded or external). nil disables subtitles.
func setSubtitleTrack(_ trackID: Int?) {
setProperty("sid", trackID.map(String.init) ?? "no")
}
/// Schedule a coalesced fetch of `track-list`, delivered via
/// `mpvClient(_:didUpdateTrackList:)`. Safe to call from any thread.
private func scheduleTrackListRefresh() {
mpvQueue.async { [weak self] in
guard let self, !self.trackListRefreshScheduled, !self.isDestroyed else { return }
self.trackListRefreshScheduled = true
self.mpvQueue.asyncAfter(deadline: .now() + 0.08) { [weak self] in
guard let self else { return }
self.trackListRefreshScheduled = false
self.refreshTrackListUnsafe()
}
}
}
/// Read and deliver the current track list (must be called on mpvQueue).
private func refreshTrackListUnsafe() {
guard let mpv, !isDestroyed else { return }
guard let cString = mpv_get_property_string(mpv, "track-list") else { return }
let json = String(cString: cString)
mpv_free(cString)
let tracks = Self.parseTrackList(json: json)
if tracks.isEmpty, json.count > 2 {
logWarning("Failed to parse track-list", details: String(json.prefix(500)))
}
Task { @MainActor [weak self] in
guard let self else { return }
self.delegate?.mpvClient(self, didUpdateTrackList: tracks)
}
}
/// Decode mpv's JSON representation of `track-list`. Lenient: a single
/// undecodable entry is dropped instead of blanking the whole list.
static func parseTrackList(json: String) -> [MPVTrack] {
struct FailableTrack: Decodable {
let track: MPVTrack?
init(from decoder: Decoder) {
track = try? MPVTrack(from: decoder)
}
}
guard let data = json.data(using: .utf8),
let entries = try? JSONDecoder().decode([FailableTrack].self, from: data) else {
return []
}
return entries.compactMap(\.track)
}
// MARK: - Properties
/// **THREADING WARNING FOR SYNC GETTERS**: The synchronous property getters below
@@ -1663,6 +1734,14 @@ final class MPVClient: @unchecked Sendable {
private func handlePropertyChange(_ property: mpv_event_property) {
let name = String(cString: property.name)
// Any change to the track roster or selection funnels into one
// coalesced track-list refresh; the raw property values are unused.
if name == "track-list" || name == "aid" || name == "sid" {
scheduleTrackListRefresh()
return
}
var value: Any?
switch property.format {

View File

@@ -99,6 +99,9 @@ final class MPVBackend: PlayerBackend {
private var currentCaption: Caption?
private var pendingAutoplay: Bool = false
/// Tracks reported by mpv for the loaded file (embedded and external).
private(set) var currentTracks: [MPVTrack] = []
// Retry mechanism: 4 attempts with increasing timeouts and delays
// Attempt 1: 3s timeout, 1s delay | Attempt 2: 3s timeout, 3s delay
// Attempt 3: 10s timeout, 5s delay | Attempt 4: 10s timeout, fail
@@ -481,6 +484,10 @@ final class MPVBackend: PlayerBackend {
currentCaption = nil
mpvClient?.removeAllSubtitlesAsync()
// Clear stale track info; the observer repopulates it once the new file loads
currentTracks = []
delegate?.backend(self, didUpdateTracks: [])
// Reset first-frame tracking for new content
renderView?.resetFirstFrameTracking()
applyStreamFrameRateHint(stream)
@@ -910,6 +917,22 @@ final class MPVBackend: PlayerBackend {
currentCaption
}
// MARK: - Embedded Tracks
/// Select an embedded audio track by mpv track id (live, no reload).
func selectEmbeddedAudioTrack(_ trackID: Int) {
mpvClient?.setAudioTrack(trackID)
}
/// Select an embedded subtitle track by mpv track id (nil = off).
/// Deliberately no `sub-remove`: external captions stay loaded so the user
/// can switch back without a re-download. Selection is no longer
/// represented by a Caption, so `currentCaption` is cleared.
func selectEmbeddedSubtitleTrack(_ trackID: Int?) {
currentCaption = nil
mpvClient?.setSubtitleTrack(trackID)
}
/// Update subtitle appearance settings on the active MPV instance.
/// Call this after changing subtitle settings to apply them immediately without restarting playback.
func updateSubtitleSettings() {
@@ -1573,6 +1596,14 @@ extension MPVBackend: MPVClientDelegate {
// Cache state is used for buffer display on seek bar - no action needed here
}
nonisolated func mpvClient(_ client: MPVClient, didUpdateTrackList tracks: [MPVTrack]) {
Task { @MainActor [weak self] in
guard let self else { return }
self.currentTracks = tracks
self.delegate?.backend(self, didUpdateTracks: tracks)
}
}
nonisolated func mpvClientDidEndFile(_ client: MPVClient, reason: MPVEndFileReason, errorCode: Int32, errorString: String?) {
Task { @MainActor [weak self] in
self?.handleEndFile(reason: reason, errorCode: errorCode, errorString: errorString)

View File

@@ -152,6 +152,7 @@ protocol PlayerBackendDelegate: AnyObject {
func backend(_ backend: any PlayerBackend, didUpdateVideoSize width: Int, height: Int)
func backend(_ backend: any PlayerBackend, didUpdateRetryState currentRetry: Int, maxRetries: Int, isRetrying: Bool, exhausted: Bool)
func backend(_ backend: any PlayerBackend, didRequestStreamRefresh atTime: TimeInterval?)
func backend(_ backend: any PlayerBackend, didUpdateTracks tracks: [MPVTrack])
func backendDidBecomeReady(_ backend: any PlayerBackend)
func backendDidFinishPlaying(_ backend: any PlayerBackend)
}

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)")