mirror of
				https://github.com/yattee/yattee.git
				synced 2025-10-30 20:22:06 +00:00 
			
		
		
		
	Merge pull request #874 from n3d1117/feat/mpv-audio-track-switching
feat: MPV audio track switching and fix default audio language
This commit is contained in:
		| @@ -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 | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
|   | ||||
| @@ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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 ?? [] | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Arkadiusz Fal
					Arkadiusz Fal