mirror of
https://github.com/yattee/yattee.git
synced 2026-08-05 23:01:28 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user