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

@@ -7886,6 +7886,39 @@
}
}
},
"player.track.default" : {
"comment" : "Badge for the container's default embedded track",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "DEFAULT"
}
}
}
},
"player.track.forced" : {
"comment" : "Badge for forced embedded subtitle tracks",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "FORCED"
}
}
}
},
"player.track.number %lld" : {
"comment" : "Fallback name for an embedded track without title or language",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Track %lld"
}
}
}
},
"player.tvos.volumeDown" : {
"extractionState" : "stale",
"localizations" : {

View File

@@ -0,0 +1,150 @@
//
// MPVTrack.swift
// Yattee
//
// Model representing one entry of mpv's `track-list` property.
//
import Foundation
/// A single track reported by mpv's `track-list` property (embedded or external).
struct MPVTrack: Equatable, Sendable, Identifiable, Decodable {
enum TrackType: String, Decodable, Sendable {
case video
case audio
case sub
}
/// mpv track id unique only within a track type.
let trackID: Int
let type: TrackType
let title: String?
let lang: String?
let isDefault: Bool
let isForced: Bool
/// True for tracks loaded via `sub-add`/`audio-add` (external files).
let isExternal: Bool
/// Whether mpv currently plays this track.
let isSelected: Bool
/// Cover-art pseudo video tracks.
let isAlbumArt: Bool
let codec: String?
let channelCount: Int?
let sampleRate: Int?
/// Identifiable across types mpv ids collide between audio/sub/video.
var id: String { "\(type.rawValue):\(trackID)" }
private enum CodingKeys: String, CodingKey {
case trackID = "id"
case type
case title
case lang
case isDefault = "default"
case isForced = "forced"
case isExternal = "external"
case isSelected = "selected"
case isAlbumArt = "albumart"
case codec
case channelCount = "demux-channel-count"
case sampleRate = "demux-samplerate"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
trackID = try container.decode(Int.self, forKey: .trackID)
type = try container.decode(TrackType.self, forKey: .type)
title = try container.decodeIfPresent(String.self, forKey: .title)
lang = try container.decodeIfPresent(String.self, forKey: .lang)
isDefault = try container.decodeIfPresent(Bool.self, forKey: .isDefault) ?? false
isForced = try container.decodeIfPresent(Bool.self, forKey: .isForced) ?? false
isExternal = try container.decodeIfPresent(Bool.self, forKey: .isExternal) ?? false
isSelected = try container.decodeIfPresent(Bool.self, forKey: .isSelected) ?? false
isAlbumArt = try container.decodeIfPresent(Bool.self, forKey: .isAlbumArt) ?? false
codec = try container.decodeIfPresent(String.self, forKey: .codec)
channelCount = try container.decodeIfPresent(Int.self, forKey: .channelCount)
sampleRate = try container.decodeIfPresent(Int.self, forKey: .sampleRate)
}
init(
trackID: Int,
type: TrackType,
title: String? = nil,
lang: String? = nil,
isDefault: Bool = false,
isForced: Bool = false,
isExternal: Bool = false,
isSelected: Bool = false,
isAlbumArt: Bool = false,
codec: String? = nil,
channelCount: Int? = nil,
sampleRate: Int? = nil
) {
self.trackID = trackID
self.type = type
self.title = title
self.lang = lang
self.isDefault = isDefault
self.isForced = isForced
self.isExternal = isExternal
self.isSelected = isSelected
self.isAlbumArt = isAlbumArt
self.codec = codec
self.channelCount = channelCount
self.sampleRate = sampleRate
}
/// Base language code normalized to the 2-letter form when possible, so
/// Matroska 3-letter codes ("eng") match preference values ("en").
var baseLanguageCode: String? {
guard let lang, !lang.isEmpty, lang != "und" else { return nil }
var code = lang.lowercased()
if let hyphenIndex = code.firstIndex(of: "-") {
code = String(code[..<hyphenIndex])
}
guard code.count == 3 else { return code }
// ISO 639-2 -> 639-1 where a 2-letter code exists ("eng" -> "en").
return Locale.LanguageCode(code).identifier(.alpha2) ?? code
}
/// "Director's Commentary", "English", or a numbered fallback.
var displayName: String {
if let title, !title.isEmpty {
return title
}
if let baseLanguageCode,
let localized = Locale.current.localizedString(forLanguageCode: baseLanguageCode) {
return localized
}
return String(localized: "player.track.number \(trackID)")
}
/// Whether this track's language matches a user preference code like "en".
func matchesLanguage(_ preferredCode: String?) -> Bool {
guard let preferredCode, !preferredCode.isEmpty,
let baseLanguageCode else { return false }
var preferred = preferredCode.lowercased()
if let hyphenIndex = preferred.firstIndex(of: "-") {
preferred = String(preferred[..<hyphenIndex])
}
if preferred.count == 3 {
preferred = Locale.LanguageCode(preferred).identifier(.alpha2) ?? preferred
}
return preferred == baseLanguageCode
}
/// Secondary detail line for advanced mode, e.g. "eac3 · 6ch · 48 kHz".
var detailText: String? {
var parts: [String] = []
if let codec, !codec.isEmpty {
parts.append(codec)
}
if let channelCount {
parts.append("\(channelCount)ch")
}
if let sampleRate {
parts.append("\(sampleRate / 1000) kHz")
}
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
}

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

View File

@@ -922,7 +922,8 @@ struct ControlsSectionRenderer: View {
@ViewBuilder
private var captionsButton: some View {
if actions.hasCaptions, actions.onCaptionSelected != nil {
controlButton(systemImage: actions.currentCaption != nil ? "captions.bubble.fill" : "captions.bubble") {
let subtitlesActive = actions.currentCaption != nil || actions.currentEmbeddedSubtitleTrackID != nil
controlButton(systemImage: subtitlesActive ? "captions.bubble.fill" : "captions.bubble") {
actions.onShowCaptionsSelector?()
}
}

View File

@@ -862,6 +862,16 @@ extension ExpandedPlayerSheet {
onStreamSelected: { [self] stream, audioStream in
switchToStream(stream, audioStream: audioStream)
},
embeddedAudioTracks: playerService.embeddedAudioTracks,
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
onEmbeddedAudioTrackSelected: { trackID in
playerService.selectEmbeddedAudioTrack(trackID)
},
onEmbeddedSubtitleTrackSelected: { trackID in
playerService.selectEmbeddedSubtitleTrack(trackID)
},
panscanValue: navigationCoordinator?.pinchPanscan ?? 0.0,
isPanscanAllowed: !isPortraitPanelVisible,
onTogglePanscan: { [weak navigationCoordinator] in
@@ -1132,6 +1142,16 @@ extension ExpandedPlayerSheet {
onStreamSelected: { [self] stream, audioStream in
switchToStream(stream, audioStream: audioStream)
},
embeddedAudioTracks: playerService.embeddedAudioTracks,
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
onEmbeddedAudioTrackSelected: { trackID in
playerService.selectEmbeddedAudioTrack(trackID)
},
onEmbeddedSubtitleTrackSelected: { trackID in
playerService.selectEmbeddedSubtitleTrack(trackID)
},
panscanValue: navigationCoordinator?.pinchPanscan ?? 0.0,
isPanscanAllowed: !isPortraitPanelVisible,
onTogglePanscan: { [weak navigationCoordinator] in
@@ -1509,6 +1529,16 @@ extension ExpandedPlayerSheet {
onStreamSelected: { [self] stream, audioStream in
switchToStream(stream, audioStream: audioStream)
},
embeddedAudioTracks: playerService.embeddedAudioTracks,
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
onEmbeddedAudioTrackSelected: { trackID in
playerService.selectEmbeddedAudioTrack(trackID)
},
onEmbeddedSubtitleTrackSelected: { trackID in
playerService.selectEmbeddedSubtitleTrack(trackID)
},
panscanValue: navigationCoordinator?.pinchPanscan ?? 0.0,
isPanscanAllowed: !(isPanelPinned && isPanelVisible),
onTogglePanscan: { [weak navigationCoordinator] in

View File

@@ -697,6 +697,10 @@ private struct PlayerSheetsModifier: ViewModifier {
currentRate: playerState?.rate ?? .x1,
isControlsLocked: playerState?.isControlsLocked ?? false,
isAudioMode: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false,
embeddedAudioTracks: playerService.embeddedAudioTracks,
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
onStreamSelected: { stream, audioStream in
onStreamSelected(stream, audioStream)
},
@@ -713,6 +717,12 @@ private struct PlayerSheetsModifier: ViewModifier {
await playerService.switchToOnlineStream(stream, audioStream: audioStream)
}
},
onEmbeddedAudioTrackSelected: { trackID in
playerService.selectEmbeddedAudioTrack(trackID)
},
onEmbeddedSubtitleTrackSelected: { trackID in
playerService.selectEmbeddedSubtitleTrack(trackID)
},
onRateChanged: { rate in
playerState?.rate = rate
playerService.currentBackend?.rate = Float(rate.rawValue)

View File

@@ -261,6 +261,16 @@ struct MPVVideoView: View {
Task {
await playerService.selectStreamManually(stream, audioStream: audioStream)
}
},
embeddedAudioTracks: playerService.embeddedAudioTracks,
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
onEmbeddedAudioTrackSelected: { trackID in
playerService.selectEmbeddedAudioTrack(trackID)
},
onEmbeddedSubtitleTrackSelected: { trackID in
playerService.selectEmbeddedSubtitleTrack(trackID)
}
)
#elseif os(macOS)

View File

@@ -76,6 +76,12 @@ struct PlayerControlsActions {
/// Current audio stream
let currentAudioStream: Stream?
/// Embedded (in-container) tracks reported by mpv for the loaded file
var embeddedAudioTracks: [MPVTrack] = []
var embeddedSubtitleTracks: [MPVTrack] = []
var currentEmbeddedAudioTrackID: Int? = nil
var currentEmbeddedSubtitleTrackID: Int? = nil
#if os(iOS)
/// Current panscan value (0.0 = fit, 1.0 = fill)
let panscanValue: Double
@@ -217,9 +223,9 @@ struct PlayerControlsActions {
return !video.isFromLocalFolder
}
/// Whether captions are available
/// Whether captions are available (external files or embedded tracks)
var hasCaptions: Bool {
!availableCaptions.isEmpty
!availableCaptions.isEmpty || !embeddedSubtitleTracks.isEmpty
}
/// Whether chapters are available

View File

@@ -91,6 +91,15 @@ struct PlayerControlsView: View {
var onCaptionSelected: ((Caption?) -> Void)? = nil
/// Callback when stream is selected (for quality selector integration)
var onStreamSelected: ((Stream, Stream?) -> Void)? = nil
/// Embedded (in-container) tracks reported by mpv for the loaded file
var embeddedAudioTracks: [MPVTrack] = []
var embeddedSubtitleTracks: [MPVTrack] = []
var currentEmbeddedAudioTrackID: Int? = nil
var currentEmbeddedSubtitleTrackID: Int? = nil
/// Callback when an embedded audio track is selected
var onEmbeddedAudioTrackSelected: ((Int) -> Void)? = nil
/// Callback when an embedded subtitle track is selected (nil = off)
var onEmbeddedSubtitleTrackSelected: ((Int?) -> Void)? = nil
/// Current panscan value (0.0 = fit, 1.0 = fill)
var panscanValue: Double = 0.0
@@ -285,9 +294,14 @@ struct PlayerControlsView: View {
currentCaption: currentCaption,
initialTab: .subtitles,
showTabPicker: false,
embeddedSubtitleTracks: embeddedSubtitleTracks,
currentEmbeddedSubtitleTrackID: currentEmbeddedSubtitleTrackID,
onStreamSelected: { _, _ in },
onCaptionSelected: { caption in
onCaptionSelected?(caption)
},
onEmbeddedSubtitleTrackSelected: { trackID in
onEmbeddedSubtitleTrackSelected?(trackID)
}
)
}
@@ -317,10 +331,15 @@ struct PlayerControlsView: View {
currentAudioStream: currentAudioStream,
initialTab: .audio,
showTabPicker: false,
embeddedAudioTracks: embeddedAudioTracks,
currentEmbeddedAudioTrackID: currentEmbeddedAudioTrackID,
onStreamSelected: { stream, audioStream in
onStreamSelected?(stream, audioStream)
},
onCaptionSelected: { _ in }
onCaptionSelected: { _ in },
onEmbeddedAudioTrackSelected: { trackID in
onEmbeddedAudioTrackSelected?(trackID)
}
)
}
@@ -379,6 +398,10 @@ struct PlayerControlsView: View {
availableStreams: availableStreams,
currentStream: currentStream,
currentAudioStream: currentAudioStream,
embeddedAudioTracks: embeddedAudioTracks,
embeddedSubtitleTracks: embeddedSubtitleTracks,
currentEmbeddedAudioTrackID: currentEmbeddedAudioTrackID,
currentEmbeddedSubtitleTrackID: currentEmbeddedSubtitleTrackID,
panscanValue: panscanValue,
isPanscanAllowed: isPanscanAllowed,
isAutoPlayNextEnabled: appEnvironment?.settingsManager.queueAutoPlayNext ?? true,

View File

@@ -414,6 +414,83 @@ struct CaptionRowView: View {
}
}
// MARK: - Embedded Track Row
/// Row view for embedded (in-container) audio/subtitle tracks reported by mpv.
struct EmbeddedTrackRowView: View {
let track: MPVTrack
let isSelected: Bool
let isPreferred: Bool
let showAdvancedDetails: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading, spacing: 2) {
titleRow
if showAdvancedDetails, let detail = track.detailText {
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if isSelected {
Image(systemName: "checkmark")
.foregroundStyle(.tint)
}
}
.frame(minHeight: showAdvancedDetails ? nil : 36)
#if os(tvOS)
.padding(.vertical, tvRowVerticalPadding)
.padding(.horizontal, tvRowHorizontalPadding)
.frame(maxWidth: .infinity, alignment: .leading)
#endif
.contentShape(Rectangle())
}
#if os(tvOS)
.buttonStyle(TVSettingsRowButtonStyle())
#else
.buttonStyle(.plain)
#endif
}
@ViewBuilder
private var titleRow: some View {
HStack(spacing: 6) {
if isPreferred {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.orange)
}
Text(track.displayName)
.font(.headline)
if track.isDefault {
badge(String(localized: "player.track.default"))
}
if track.isForced {
badge(String(localized: "player.track.forced"))
}
}
}
private func badge(_ text: String) -> some View {
Text(text)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.gray.opacity(0.2))
.foregroundStyle(.secondary)
.clipShape(Capsule())
}
}
// MARK: - Previews
#Preview("Adaptive Stream Row") {

View File

@@ -188,6 +188,11 @@ extension QualitySelectorView {
}
private var currentAudioDisplayValue: String {
if embeddedAudioTracks.count > 1,
let trackID = currentEmbeddedAudioTrackID,
let track = embeddedAudioTracks.first(where: { $0.trackID == trackID }) {
return track.displayName
}
if isCurrentStreamMuxed {
return String(localized: "player.quality.audioFromVideo.short")
}
@@ -198,7 +203,11 @@ extension QualitySelectorView {
}
private var currentSubtitlesDisplayValue: String {
currentCaption?.displayName ?? String(localized: "stream.subtitles.off")
if let trackID = currentEmbeddedSubtitleTrackID,
let track = embeddedSubtitleTracks.first(where: { $0.trackID == trackID }) {
return track.displayName
}
return currentCaption?.displayName ?? String(localized: "stream.subtitles.off")
}
// MARK: - Detail Content Views
@@ -741,7 +750,9 @@ extension QualitySelectorView {
@ViewBuilder
var audioSectionContent: some View {
if isCurrentStreamMuxed {
if embeddedAudioTracks.count > 1 {
embeddedAudioTracksContent
} else if isCurrentStreamMuxed {
VStack(spacing: 0) {
HStack {
Image(systemName: "info.circle")
@@ -778,6 +789,44 @@ extension QualitySelectorView {
}
}
/// Embedded (in-container) audio tracks, switched live via mpv `aid`.
@ViewBuilder
private var embeddedAudioTracksContent: some View {
#if os(tvOS)
VStack(spacing: 8) {
ForEach(embeddedAudioTracks) { track in
embeddedAudioTrackRow(track)
}
}
#else
VStack(spacing: 0) {
ForEach(Array(embeddedAudioTracks.enumerated()), id: \.element.id) { index, track in
if index > 0 {
Divider()
}
embeddedAudioTrackRow(track)
.padding(.vertical, 8)
.padding(.horizontal, 12)
}
}
.cardBackground()
#endif
}
@ViewBuilder
private func embeddedAudioTrackRow(_ track: MPVTrack) -> some View {
EmbeddedTrackRowView(
track: track,
isSelected: track.trackID == currentEmbeddedAudioTrackID,
isPreferred: track.matchesLanguage(preferredAudioLanguage),
showAdvancedDetails: showAdvancedStreamDetails,
onTap: {
onEmbeddedAudioTrackSelected(track.trackID)
performDismiss()
}
)
}
@ViewBuilder
private func audioStreamRow(_ stream: Stream) -> some View {
let isSelected: Bool = stream.url == selectedAudioStream?.url
@@ -816,13 +865,17 @@ extension QualitySelectorView {
VStack(spacing: 8) {
CaptionRowView(
caption: nil,
isSelected: currentCaption == nil,
isSelected: currentCaption == nil && currentEmbeddedSubtitleTrackID == nil,
isPreferred: false,
onTap: {
handleCaptionTap(nil)
}
)
ForEach(embeddedSubtitleTracks) { track in
embeddedSubtitleTrackRow(track)
}
ForEach(sortedCaptions) { caption in
CaptionRowView(
caption: caption,
@@ -838,7 +891,7 @@ extension QualitySelectorView {
VStack(spacing: 0) {
CaptionRowView(
caption: nil,
isSelected: currentCaption == nil,
isSelected: currentCaption == nil && currentEmbeddedSubtitleTrackID == nil,
isPreferred: false,
onTap: {
handleCaptionTap(nil)
@@ -847,6 +900,14 @@ extension QualitySelectorView {
.padding(.vertical, 8)
.padding(.horizontal, 12)
ForEach(embeddedSubtitleTracks) { track in
Divider()
embeddedSubtitleTrackRow(track)
.padding(.vertical, 8)
.padding(.horizontal, 12)
}
ForEach(sortedCaptions) { caption in
Divider()
@@ -866,12 +927,34 @@ extension QualitySelectorView {
#endif
}
@ViewBuilder
private func embeddedSubtitleTrackRow(_ track: MPVTrack) -> some View {
EmbeddedTrackRowView(
track: track,
isSelected: track.trackID == currentEmbeddedSubtitleTrackID,
isPreferred: track.matchesLanguage(preferredSubtitlesLanguage),
showAdvancedDetails: showAdvancedStreamDetails,
onTap: {
// Re-tapping the selected track turns subtitles off (parity
// with external caption rows)
if track.trackID == currentEmbeddedSubtitleTrackID {
onEmbeddedSubtitleTrackSelected(nil)
} else {
onEmbeddedSubtitleTrackSelected(track.trackID)
}
performDismiss()
}
)
}
private func isCaptionPreferred(_ caption: Caption) -> Bool {
guard let preferred = preferredSubtitlesLanguage else { return false }
return caption.baseLanguageCode == preferred || caption.languageCode.hasPrefix(preferred)
}
private func handleCaptionTap(_ caption: Caption?) {
// Note: the Off row needs no embedded-track handling caption "off"
// sets sid=no and clears the embedded intent in PlayerService.
if caption?.id == currentCaption?.id && caption != nil {
onCaptionSelected(nil)
} else {

View File

@@ -29,6 +29,15 @@ struct QualitySelectorView: View {
let onLoadOnlineStreams: () -> Void
let onSwitchToOnlineStream: (Stream, Stream?) -> Void
/// Embedded (in-container) tracks reported by mpv for the loaded file.
let embeddedAudioTracks: [MPVTrack]
let embeddedSubtitleTracks: [MPVTrack]
let currentEmbeddedAudioTrackID: Int?
let currentEmbeddedSubtitleTrackID: Int?
let onEmbeddedAudioTrackSelected: (Int) -> Void
/// nil = subtitles off
let onEmbeddedSubtitleTrackSelected: (Int?) -> Void
/// Current playback rate
var currentRate: PlaybackRate = .x1
/// Callback when playback rate changes
@@ -100,10 +109,10 @@ struct QualitySelectorView: View {
/// Available tabs based on streams
var availableTabs: [QualitySelectorTab] {
var tabs: [QualitySelectorTab] = [.video]
if hasVideoOnlyStreams && !audioStreams.isEmpty {
if (hasVideoOnlyStreams && !audioStreams.isEmpty) || embeddedAudioTracks.count > 1 {
tabs.append(.audio)
}
if !captions.isEmpty {
if !captions.isEmpty || !embeddedSubtitleTracks.isEmpty {
tabs.append(.subtitles)
}
return tabs
@@ -133,7 +142,7 @@ struct QualitySelectorView: View {
/// Whether streams are empty (not loading, but no streams available)
var hasNoStreams: Bool {
if !showTabPicker && initialTab == .subtitles {
return !isLoading && captions.isEmpty && !isPlayingDownloadedContent
return !isLoading && captions.isEmpty && embeddedSubtitleTracks.isEmpty && !isPlayingDownloadedContent
}
return !isLoading && streams.isEmpty && !isPlayingDownloadedContent
}
@@ -168,10 +177,16 @@ struct QualitySelectorView: View {
isAudioMode: Bool = false,
initialTab: QualitySelectorTab = .video,
showTabPicker: Bool = true,
embeddedAudioTracks: [MPVTrack] = [],
embeddedSubtitleTracks: [MPVTrack] = [],
currentEmbeddedAudioTrackID: Int? = nil,
currentEmbeddedSubtitleTrackID: Int? = nil,
onStreamSelected: @escaping (Stream, Stream?) -> Void,
onCaptionSelected: @escaping (Caption?) -> Void = { _ in },
onLoadOnlineStreams: @escaping () -> Void = {},
onSwitchToOnlineStream: @escaping (Stream, Stream?) -> Void = { _, _ in },
onEmbeddedAudioTrackSelected: @escaping (Int) -> Void = { _ in },
onEmbeddedSubtitleTrackSelected: @escaping (Int?) -> Void = { _ in },
onRateChanged: ((PlaybackRate) -> Void)? = nil,
onLockToggled: ((Bool) -> Void)? = nil,
onAudioModeToggled: ((Bool) -> Void)? = nil,
@@ -191,10 +206,16 @@ struct QualitySelectorView: View {
self.currentRate = currentRate
self.isControlsLocked = isControlsLocked
self.isAudioMode = isAudioMode
self.embeddedAudioTracks = embeddedAudioTracks
self.embeddedSubtitleTracks = embeddedSubtitleTracks
self.currentEmbeddedAudioTrackID = currentEmbeddedAudioTrackID
self.currentEmbeddedSubtitleTrackID = currentEmbeddedSubtitleTrackID
self.onStreamSelected = onStreamSelected
self.onCaptionSelected = onCaptionSelected
self.onLoadOnlineStreams = onLoadOnlineStreams
self.onSwitchToOnlineStream = onSwitchToOnlineStream
self.onEmbeddedAudioTrackSelected = onEmbeddedAudioTrackSelected
self.onEmbeddedSubtitleTrackSelected = onEmbeddedSubtitleTrackSelected
self.onRateChanged = onRateChanged
self.onLockToggled = onLockToggled
self.onAudioModeToggled = onAudioModeToggled

View File

@@ -167,6 +167,10 @@ struct TVPlayerView: View {
},
currentRate: playerState?.rate ?? .x1,
isAudioMode: appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false,
embeddedAudioTracks: playerService.embeddedAudioTracks,
embeddedSubtitleTracks: playerService.embeddedSubtitleTracks,
currentEmbeddedAudioTrackID: playerService.selectedEmbeddedAudioTrackID,
currentEmbeddedSubtitleTrackID: playerService.selectedEmbeddedSubtitleTrackID,
onStreamSelected: { stream, audioStream in
switchToStream(stream, audioStream: audioStream)
},
@@ -183,6 +187,12 @@ struct TVPlayerView: View {
await playerService.switchToOnlineStream(stream, audioStream: audioStream)
}
},
onEmbeddedAudioTrackSelected: { trackID in
playerService.selectEmbeddedAudioTrack(trackID)
},
onEmbeddedSubtitleTrackSelected: { trackID in
playerService.selectEmbeddedSubtitleTrack(trackID)
},
onRateChanged: { rate in
playerState?.rate = rate
playerService.currentBackend?.rate = Float(rate.rawValue)