From 67ed8c322ba3a0ebd3978b2b5e8e500338c3591a Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 29 Jul 2026 22:14:46 +0200 Subject: [PATCH] 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. --- Yattee/Localizable.xcstrings | 33 ++++ Yattee/Models/MPVTrack.swift | 150 ++++++++++++++++++ Yattee/Services/Player/MPV/MPVClient.swift | 79 +++++++++ Yattee/Services/Player/MPVBackend.swift | 31 ++++ Yattee/Services/Player/PlayerBackend.swift | 1 + Yattee/Services/Player/PlayerService.swift | 115 ++++++++++++++ .../Player/ControlsSectionRenderer.swift | 3 +- .../Player/ExpandedPlayerSheet+Layouts.swift | 30 ++++ Yattee/Views/Player/ExpandedPlayerSheet.swift | 10 ++ Yattee/Views/Player/MPVVideoView.swift | 10 ++ .../Views/Player/PlayerControlsActions.swift | 10 +- Yattee/Views/Player/PlayerControlsView.swift | 25 ++- .../QualitySelectorRowViews.swift | 77 +++++++++ .../Player/QualitySelectorView+Sections.swift | 91 ++++++++++- Yattee/Views/Player/QualitySelectorView.swift | 27 +++- Yattee/Views/Player/tvOS/TVPlayerView.swift | 10 ++ YatteeTests/MPVTrackTests.swift | 150 ++++++++++++++++++ 17 files changed, 841 insertions(+), 11 deletions(-) create mode 100644 Yattee/Models/MPVTrack.swift create mode 100644 YatteeTests/MPVTrackTests.swift diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index b3de81f0..4674cc8f 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { diff --git a/Yattee/Models/MPVTrack.swift b/Yattee/Models/MPVTrack.swift new file mode 100644 index 00000000..747d51ee --- /dev/null +++ b/Yattee/Models/MPVTrack.swift @@ -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[.. 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[.. 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 { diff --git a/Yattee/Services/Player/MPVBackend.swift b/Yattee/Services/Player/MPVBackend.swift index 77a9341b..1134a2c6 100644 --- a/Yattee/Services/Player/MPVBackend.swift +++ b/Yattee/Services/Player/MPVBackend.swift @@ -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) diff --git a/Yattee/Services/Player/PlayerBackend.swift b/Yattee/Services/Player/PlayerBackend.swift index ac27e201..d83e1180 100644 --- a/Yattee/Services/Player/PlayerBackend.swift +++ b/Yattee/Services/Player/PlayerBackend.swift @@ -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) } diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index 3f2b47bc..644debbd 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -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)") diff --git a/Yattee/Views/Player/ControlsSectionRenderer.swift b/Yattee/Views/Player/ControlsSectionRenderer.swift index 95c3c0b2..af563c83 100644 --- a/Yattee/Views/Player/ControlsSectionRenderer.swift +++ b/Yattee/Views/Player/ControlsSectionRenderer.swift @@ -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?() } } diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift index 62d19026..e5c0f298 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift @@ -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 diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index d8b1aa4d..44127a53 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -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) diff --git a/Yattee/Views/Player/MPVVideoView.swift b/Yattee/Views/Player/MPVVideoView.swift index 6bea515a..b3e9f1c6 100644 --- a/Yattee/Views/Player/MPVVideoView.swift +++ b/Yattee/Views/Player/MPVVideoView.swift @@ -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) diff --git a/Yattee/Views/Player/PlayerControlsActions.swift b/Yattee/Views/Player/PlayerControlsActions.swift index 6e7aa2ae..0075af13 100644 --- a/Yattee/Views/Player/PlayerControlsActions.swift +++ b/Yattee/Views/Player/PlayerControlsActions.swift @@ -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 diff --git a/Yattee/Views/Player/PlayerControlsView.swift b/Yattee/Views/Player/PlayerControlsView.swift index ff9103dc..b1c3645b 100644 --- a/Yattee/Views/Player/PlayerControlsView.swift +++ b/Yattee/Views/Player/PlayerControlsView.swift @@ -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, diff --git a/Yattee/Views/Player/QualitySelector/QualitySelectorRowViews.swift b/Yattee/Views/Player/QualitySelector/QualitySelectorRowViews.swift index b8f0ad89..f3cf433b 100644 --- a/Yattee/Views/Player/QualitySelector/QualitySelectorRowViews.swift +++ b/Yattee/Views/Player/QualitySelector/QualitySelectorRowViews.swift @@ -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") { diff --git a/Yattee/Views/Player/QualitySelectorView+Sections.swift b/Yattee/Views/Player/QualitySelectorView+Sections.swift index 61399a66..45a3bae9 100644 --- a/Yattee/Views/Player/QualitySelectorView+Sections.swift +++ b/Yattee/Views/Player/QualitySelectorView+Sections.swift @@ -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 { diff --git a/Yattee/Views/Player/QualitySelectorView.swift b/Yattee/Views/Player/QualitySelectorView.swift index eb65f1d1..0218f793 100644 --- a/Yattee/Views/Player/QualitySelectorView.swift +++ b/Yattee/Views/Player/QualitySelectorView.swift @@ -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 diff --git a/Yattee/Views/Player/tvOS/TVPlayerView.swift b/Yattee/Views/Player/tvOS/TVPlayerView.swift index 50ae6f3c..e9057f6b 100644 --- a/Yattee/Views/Player/tvOS/TVPlayerView.swift +++ b/Yattee/Views/Player/tvOS/TVPlayerView.swift @@ -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) diff --git a/YatteeTests/MPVTrackTests.swift b/YatteeTests/MPVTrackTests.swift new file mode 100644 index 00000000..471ecb4e --- /dev/null +++ b/YatteeTests/MPVTrackTests.swift @@ -0,0 +1,150 @@ +// +// MPVTrackTests.swift +// YatteeTests +// +// Tests for decoding mpv's track-list property into MPVTrack. +// + +import Testing +import Foundation +@testable import Yattee + +@Suite("MPVTrack Decoding Tests") +struct MPVTrackDecodingTests { + /// Realistic mpv track-list JSON for an MKV with two audio tracks, + /// one embedded subtitle, and one external (sub-add) subtitle. + static let fixture = """ + [ + {"id":1,"type":"video","src-id":0,"albumart":false,"default":true,"forced":false, + "dependent":false,"visual-impaired":false,"hearing-impaired":false,"external":false, + "selected":true,"main-selection":0,"ff-index":0,"decoder-desc":"hevc","codec":"hevc", + "demux-w":3840,"demux-h":1600,"demux-fps":23.976}, + {"id":1,"type":"audio","src-id":1,"title":"Surround 7.1","lang":"eng","audio-channels":8, + "albumart":false,"default":true,"forced":false,"external":false,"selected":true, + "codec":"truehd","demux-channel-count":8,"demux-samplerate":48000}, + {"id":2,"type":"audio","src-id":2,"lang":"pol","albumart":false,"default":false, + "forced":false,"external":false,"selected":false,"codec":"eac3", + "demux-channel-count":6,"demux-samplerate":48000}, + {"id":1,"type":"sub","src-id":3,"lang":"eng","albumart":false,"default":false, + "forced":true,"external":false,"selected":false,"codec":"subrip"}, + {"id":2,"type":"sub","title":"english.srt","lang":"en","default":false,"forced":false, + "external":true,"external-filename":"/tmp/english.srt","selected":false,"codec":"subrip"} + ] + """ + + @Test("Decodes full track-list fixture") + func decodesFixture() { + let tracks = MPVClient.parseTrackList(json: Self.fixture) + #expect(tracks.count == 5) + + let video = tracks[0] + #expect(video.type == .video) + #expect(video.trackID == 1) + #expect(video.isDefault) + #expect(video.isSelected) + + let mainAudio = tracks[1] + #expect(mainAudio.type == .audio) + #expect(mainAudio.title == "Surround 7.1") + #expect(mainAudio.lang == "eng") + #expect(mainAudio.channelCount == 8) + #expect(mainAudio.sampleRate == 48000) + #expect(mainAudio.isSelected) + #expect(!mainAudio.isExternal) + + let secondAudio = tracks[2] + #expect(secondAudio.trackID == 2) + #expect(secondAudio.lang == "pol") + #expect(!secondAudio.isSelected) + + let forcedSub = tracks[3] + #expect(forcedSub.type == .sub) + #expect(forcedSub.isForced) + #expect(!forcedSub.isExternal) + + let externalSub = tracks[4] + #expect(externalSub.isExternal) + } + + @Test("IDs are unique across types even when mpv ids collide") + func uniqueIDs() { + let tracks = MPVClient.parseTrackList(json: Self.fixture) + let ids = Set(tracks.map(\.id)) + #expect(ids.count == tracks.count) + } + + @Test("Minimal entry decodes with defaults") + func minimalEntry() { + let tracks = MPVClient.parseTrackList(json: #"[{"id":3,"type":"audio"}]"#) + #expect(tracks.count == 1) + let track = tracks[0] + #expect(track.trackID == 3) + #expect(!track.isDefault) + #expect(!track.isForced) + #expect(!track.isExternal) + #expect(!track.isSelected) + #expect(track.lang == nil) + #expect(track.codec == nil) + } + + @Test("One bad entry is dropped, the rest survive") + func lenientDecoding() { + let json = #"[{"id":1,"type":"audio"},{"id":"broken","type":"audio"},{"id":2,"type":"sub"}]"# + let tracks = MPVClient.parseTrackList(json: json) + #expect(tracks.count == 2) + #expect(tracks[0].trackID == 1) + #expect(tracks[1].type == .sub) + } + + @Test("Unknown track type drops only that entry") + func unknownType() { + let json = #"[{"id":1,"type":"attachment"},{"id":1,"type":"audio"}]"# + let tracks = MPVClient.parseTrackList(json: json) + #expect(tracks.count == 1) + #expect(tracks[0].type == .audio) + } + + @Test("Garbage input yields empty list") + func garbageInput() { + #expect(MPVClient.parseTrackList(json: "not json").isEmpty) + #expect(MPVClient.parseTrackList(json: "").isEmpty) + #expect(MPVClient.parseTrackList(json: "{}").isEmpty) + } +} + +@Suite("MPVTrack Language Matching Tests") +struct MPVTrackLanguageTests { + private func track(lang: String?, title: String? = nil) -> MPVTrack { + MPVTrack(trackID: 1, type: .audio, title: title, lang: lang) + } + + @Test("Normalizes ISO 639-2 codes to two-letter form") + func normalization() { + #expect(track(lang: "eng").baseLanguageCode == "en") + #expect(track(lang: "pol").baseLanguageCode == "pl") + #expect(track(lang: "en").baseLanguageCode == "en") + #expect(track(lang: "en-US").baseLanguageCode == "en") + #expect(track(lang: "und").baseLanguageCode == nil) + #expect(track(lang: nil).baseLanguageCode == nil) + } + + @Test("Matches user preference codes against Matroska codes") + func preferenceMatching() { + #expect(track(lang: "eng").matchesLanguage("en")) + #expect(track(lang: "en").matchesLanguage("eng")) + #expect(track(lang: "pol").matchesLanguage("pl")) + #expect(!track(lang: "eng").matchesLanguage("pl")) + #expect(!track(lang: nil).matchesLanguage("en")) + #expect(!track(lang: "eng").matchesLanguage(nil)) + #expect(!track(lang: "eng").matchesLanguage("")) + } + + @Test("Display name prefers title, falls back to language") + func displayName() { + #expect(track(lang: "eng", title: "Commentary").displayName == "Commentary") + // Localized language name for "en" under the current test locale + let name = track(lang: "eng").displayName + #expect(!name.isEmpty) + #expect(name != "eng") + } +}