From 2461a33febf61abe77fb06cb474092b94a0db22d Mon Sep 17 00:00:00 2001 From: ned Date: Sun, 1 Jun 2025 21:10:46 +0200 Subject: [PATCH] feat: default lang and mpv audio track switching --- Model/Applications/InvidiousAPI.swift | 65 ++++++++++++++++---- Model/Player/Backends/MPVBackend.swift | 17 +++++ Model/Player/PlayerModel.swift | 16 +++++ Model/Stream.swift | 25 +++++++- Shared/LanguageCodes.swift | 3 + Shared/Player/Controls/ControlsOverlay.swift | 50 +++++++++++++++ Shared/Player/PlaybackSettings.swift | 52 ++++++++++++++++ 7 files changed, 216 insertions(+), 12 deletions(-) diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index c9096d51..a299b6f9 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -685,50 +685,93 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } } + func extractXTags(from urlString: String) -> [String: String] { + guard let urlComponents = URLComponents(string: urlString), + let queryItems = urlComponents.queryItems, + let xtagsValue = queryItems.first(where: { $0.name == "xtags" })?.value else { + return [:] + } + + guard let decoded = xtagsValue.removingPercentEncoding else { return [:] } + + // Parse key-value pairs (format: key1=value1:key2=value2) + // Example: "acont=dubbed-auto:lang=en-US" + let pairs = decoded.split(separator: ":") + var result: [String: String] = [:] + for pair in pairs { + let parts = pair.split(separator: "=", maxSplits: 1) + if parts.count == 2 { + result[String(parts[0])] = String(parts[1]) + } + } + return result + } + private func extractAdaptiveFormats(from streams: [JSON], videoId: String?) -> [Stream] { - let audioStreams = streams + let audioTracks = streams .filter { $0["type"].stringValue.starts(with: "audio/mp4") } .sorted { $0.dictionaryValue["bitrate"]?.int ?? 0 > $1.dictionaryValue["bitrate"]?.int ?? 0 } - guard let audioStream = audioStreams.first else { + .compactMap { audioStream -> Stream.AudioTrack? in + guard let url = audioStream["url"].url, + let audioItag = audioStream["itag"].string + else { return nil } + + let finalURL: URL + if let videoId, account.instance.invidiousCompanion { + let audioCompanionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(audioItag)" + finalURL = URL(string: audioCompanionURLString) ?? url + } else { + finalURL = url + } + + let xTags = extractXTags(from: url.absoluteString) + + return Stream.AudioTrack( + url: finalURL, + content: xTags["acont"], + language: xTags["lang"] + ) + } + .sorted { + /// Always prefer original audio streams over dubbed ones + !$0.isDubbed && $1.isDubbed + } + + guard !audioTracks.isEmpty else { return .init() } let videoStreams = streams.filter { $0["type"].stringValue.starts(with: "video/") } return videoStreams.compactMap { videoStream in - guard let audioAssetURL = audioStream["url"].url, - let videoAssetURL = videoStream["url"].url, - let audioItag = audioStream["itag"].string, + guard let videoAssetURL = videoStream["url"].url, let videoItag = videoStream["itag"].string else { return nil } - let finalAudioURL: URL let finalVideoURL: URL if let videoId, account.instance.invidiousCompanion { - let audioCompanionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(audioItag)" let videoCompanionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(videoItag)" - finalAudioURL = URL(string: audioCompanionURLString) ?? audioAssetURL finalVideoURL = URL(string: videoCompanionURLString) ?? videoAssetURL } else { - finalAudioURL = audioAssetURL finalVideoURL = videoAssetURL } return Stream( instance: account.instance, - audioAsset: AVURLAsset(url: finalAudioURL), + audioAsset: AVURLAsset(url: audioTracks[0].url), videoAsset: AVURLAsset(url: finalVideoURL), resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue), kind: .adaptive, encoding: videoStream["encoding"].string, videoFormat: videoStream["type"].string, bitrate: videoStream["bitrate"].int, - requestRange: videoStream["init"].string ?? videoStream["index"].string + requestRange: videoStream["init"].string ?? videoStream["index"].string, + audioTracks: audioTracks ) } } diff --git a/Model/Player/Backends/MPVBackend.swift b/Model/Player/Backends/MPVBackend.swift index 69b5167a..70220e5f 100644 --- a/Model/Player/Backends/MPVBackend.swift +++ b/Model/Player/Backends/MPVBackend.swift @@ -185,6 +185,10 @@ final class MPVBackend: PlayerBackend { var audioSampleRate: String { client?.audioSampleRate ?? "unknown" } + + var availableAudioTracks: [Stream.AudioTrack] { + stream?.audioTracks ?? [] + } init() { clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in @@ -243,6 +247,9 @@ final class MPVBackend: PlayerBackend { let updateCurrentStream = { DispatchQueue.main.async { [weak self] in + if self?.video?.id != video.id { + self?.model.selectedAudioTrackIndex = 0 + } self?.stream = stream self?.video = video self?.model.stream = stream @@ -319,6 +326,7 @@ final class MPVBackend: PlayerBackend { startPlaying() } + stream.audioAsset = AVURLAsset(url: stream.audioTracks[stream.selectedAudioTrackIndex].url) let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url @@ -728,4 +736,13 @@ final class MPVBackend: PlayerBackend { logger.info("MPV backend received unhandled property: \(name)") } } + + func switchAudioTrack(to index: Int) { + guard let stream, let video else { return } + + stream.selectedAudioTrackIndex = index + model.saveTime { [weak self] in + self?.playStream(stream, of: video, preservingTime: true, upgrading: false) + } + } } diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index 356a4bc2..eb8532fb 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -210,6 +210,14 @@ final class PlayerModel: ObservableObject { var keyPressMonitor: Any? #endif + @Published var selectedAudioTrackIndex = 0 { + didSet { + if oldValue != selectedAudioTrackIndex { + handleAudioTrackChange() + } + } + } + init() { #if os(iOS) isOrientationLocked = Defaults[.isOrientationLocked] @@ -1467,4 +1475,12 @@ final class PlayerModel: ObservableObject { } } #endif + + private func handleAudioTrackChange() { + (backend as? MPVBackend)?.switchAudioTrack(to: selectedAudioTrackIndex) + } + + var availableAudioTracks: [Stream.AudioTrack] { + (backend as? MPVBackend)?.availableAudioTracks ?? [] + } } diff --git a/Model/Stream.swift b/Model/Stream.swift index fca53df2..e52646e9 100644 --- a/Model/Stream.swift +++ b/Model/Stream.swift @@ -191,6 +191,25 @@ class Stream: Equatable, Hashable, Identifiable { return .unknown } } + + struct AudioTrack: Hashable, Identifiable { + let id = UUID().uuidString + let url: URL + let content: String? + let language: String? + + var displayLanguage: String { + LanguageCodes(rawValue: language ?? "")?.description.capitalized ?? language ?? "Unknown" + } + + var description: String { + "\(displayLanguage) (\(content ?? "Unknown"))" + } + + var isDubbed: Bool { + content?.lowercased().starts(with: "dubbed") ?? false + } + } let id = UUID() @@ -208,6 +227,8 @@ class Stream: Equatable, Hashable, Identifiable { var videoFormat: String? var bitrate: Int? var requestRange: String? + var audioTracks: [AudioTrack] = [] + var selectedAudioTrackIndex = 0 init( instance: Instance? = nil, @@ -220,7 +241,8 @@ class Stream: Equatable, Hashable, Identifiable { encoding: String? = nil, videoFormat: String? = nil, bitrate: Int? = nil, - requestRange: String? = nil + requestRange: String? = nil, + audioTracks: [AudioTrack] = [] ) { self.instance = instance self.audioAsset = audioAsset @@ -233,6 +255,7 @@ class Stream: Equatable, Hashable, Identifiable { format = .from(videoFormat ?? "") self.bitrate = bitrate self.requestRange = requestRange + self.audioTracks = audioTracks } var isLocal: Bool { diff --git a/Shared/LanguageCodes.swift b/Shared/LanguageCodes.swift index ef3235ce..b044f1a1 100644 --- a/Shared/LanguageCodes.swift +++ b/Shared/LanguageCodes.swift @@ -11,6 +11,7 @@ enum LanguageCodes: String, CaseIterable { case Greek = "el" case English = "en" case English_GB = "en-GB" + case English_US = "en-US" case Spanish = "es" case Persian = "fa" case Finnish = "fi" @@ -76,6 +77,8 @@ enum LanguageCodes: String, CaseIterable { return "English" case .English_GB: return "English (United Kingdom)" + case .English_US: + return "English (United States)" case .Spanish: return "Spanish" case .Persian: diff --git a/Shared/Player/Controls/ControlsOverlay.swift b/Shared/Player/Controls/ControlsOverlay.swift index 5ba0f816..cbd818d0 100644 --- a/Shared/Player/Controls/ControlsOverlay.swift +++ b/Shared/Player/Controls/ControlsOverlay.swift @@ -19,6 +19,7 @@ struct ControlsOverlay: View { case increaseRate case decreaseRate case captions + case audioTrack } @FocusState private var focusedField: Field? @@ -60,6 +61,15 @@ struct ControlsOverlay: View { #endif } + if !player.availableAudioTracks.isEmpty { + Section(header: controlsHeader("Audio Track".localized())) { + audioTrackButton + #if os(tvOS) + .focused($focusedField, equals: .audioTrack) + #endif + } + } + Section(header: controlsHeader("Stream & Player".localized())) { qualityButton #if os(tvOS) @@ -438,6 +448,46 @@ struct ControlsOverlay: View { } ) } + + @ViewBuilder private var audioTrackButton: some View { + #if os(macOS) + audioTrackPicker + .labelsHidden() + .frame(maxWidth: 300) + #elseif os(iOS) + Menu { + audioTrackPicker + } label: { + Text(player.availableAudioTracks[player.selectedAudioTrackIndex].displayLanguage) + .frame(maxWidth: 240, alignment: .trailing) + } + .transaction { t in t.animation = .none } + .buttonStyle(.plain) + .foregroundColor(.accentColor) + .frame(maxWidth: 240, alignment: .trailing) + .frame(height: 40) + #else + ControlsOverlayButton(focusedField: $focusedField, field: .audioTrack) { + Text(player.availableAudioTracks[player.selectedAudioTrackIndex].displayLanguage) + .frame(maxWidth: 320) + } + .contextMenu { + ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in + Button(track.description) { player.selectedAudioTrackIndex = index } + } + Button("Cancel", role: .cancel) {} + } + #endif + } + + private var audioTrackPicker: some View { + Picker("", selection: $player.selectedAudioTrackIndex) { + ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in + Text(track.description).tag(index) + } + } + .transaction { t in t.animation = .none } + } } struct ControlsOverlay_Previews: PreviewProvider { diff --git a/Shared/Player/PlaybackSettings.swift b/Shared/Player/PlaybackSettings.swift index c9861235..f9b3419c 100644 --- a/Shared/Player/PlaybackSettings.swift +++ b/Shared/Player/PlaybackSettings.swift @@ -22,6 +22,7 @@ struct PlaybackSettings: View { case increaseRate case decreaseRate case captions + case audioTrack } @FocusState private var focusedField: Field? @@ -112,6 +113,17 @@ struct PlaybackSettings: View { #endif } + if !player.availableAudioTracks.isEmpty { + HStack { + controlsHeader("Audio Track".localized()) + Spacer() + audioTrackButton + #if os(tvOS) + .focused($focusedField, equals: .audioTrack) + #endif + } + } + HStack(spacing: 8) { controlsHeader("Backend".localized()) Spacer() @@ -453,6 +465,46 @@ struct PlaybackSettings: View { } .disabled(captions.isEmpty) } + + @ViewBuilder private var audioTrackButton: some View { + #if os(macOS) + audioTrackPicker + .labelsHidden() + .frame(maxWidth: 300) + #elseif os(iOS) + Menu { + audioTrackPicker + } label: { + Text(player.availableAudioTracks[player.selectedAudioTrackIndex].displayLanguage) + .frame(maxWidth: 240, alignment: .trailing) + } + .transaction { t in t.animation = .none } + .buttonStyle(.plain) + .foregroundColor(.accentColor) + .frame(maxWidth: 240, alignment: .trailing) + .frame(height: 40) + #else + ControlsOverlayButton(focusedField: $focusedField, field: .audioTrack) { + Text(player.availableAudioTracks[player.selectedAudioTrackIndex].displayLanguage) + .frame(maxWidth: 320) + } + .contextMenu { + ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in + Button(track.description) { player.selectedAudioTrackIndex = index } + } + Button("Cancel", role: .cancel) {} + } + #endif + } + + private var audioTrackPicker: some View { + Picker("", selection: $player.selectedAudioTrackIndex) { + ForEach(Array(player.availableAudioTracks.enumerated()), id: \.offset) { index, track in + Text(track.description).tag(index) + } + } + .transaction { t in t.animation = .none } + } } struct PlaybackSettings_Previews: PreviewProvider {