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

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