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

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