diff --git a/Sources/Kaset/Models/YouTube/YouTubeFeed.swift b/Sources/Kaset/Models/YouTube/YouTubeFeed.swift index 7294bb0a7..f908ba1bd 100644 --- a/Sources/Kaset/Models/YouTube/YouTubeFeed.swift +++ b/Sources/Kaset/Models/YouTube/YouTubeFeed.swift @@ -444,6 +444,17 @@ struct YouTubeCaptionTrack: Identifiable, Hashable { } } +// MARK: - YouTubeAudioTrack + +/// An alternate audio track (dubbed language) offered by the watch page player. +/// `id` is the track's index in the player's live audio-track array, which is +/// all `setAudioTrack` needs — the opaque player track objects never cross the +/// JS bridge. +struct YouTubeAudioTrack: Identifiable, Hashable { + let id: String + let displayName: String +} + // MARK: - YouTubeQuality /// Display helpers for YouTube's quality-level identifiers. diff --git a/Sources/Kaset/Services/Player/YouTubePlayerService.swift b/Sources/Kaset/Services/Player/YouTubePlayerService.swift index dee4b87b1..c7989e918 100644 --- a/Sources/Kaset/Services/Player/YouTubePlayerService.swift +++ b/Sources/Kaset/Services/Player/YouTubePlayerService.swift @@ -34,6 +34,9 @@ protocol YouTubeWatchPlaybackControlling: AnyObject { func availableCaptionTracks() async -> [YouTubeCaptionTrack] func currentCaptionLanguageCode() async -> String? func setCaptionTrack(languageCode: String?) + func availableAudioTracks() async -> [YouTubeAudioTrack] + func currentAudioTrackId() async -> String? + func setAudioTrack(id: String) func availableQualityLevels() async -> [String] func currentQualityLevel() async -> String? func setQualityLevel(_ level: String) @@ -453,6 +456,13 @@ final class YouTubePlayerService { /// Language code of the active caption track (nil = captions off). private(set) var activeCaptionLanguageCode: String? + /// Alternate audio tracks (dubbed languages) on the current watch page. + /// Empty when the video has a single audio track. + private(set) var audioTracks: [YouTubeAudioTrack] = [] + + /// Id of the active audio track (nil until resolved). + private(set) var activeAudioTrackId: String? + /// Quality levels available on the current watch page. private(set) var qualityLevels: [String] = [] @@ -1171,6 +1181,8 @@ final class YouTubePlayerService { self.heatmap = [] self.captionTracks = [] self.activeCaptionLanguageCode = nil + self.audioTracks = [] + self.activeAudioTrackId = nil self.qualityLevels = [] self.currentQuality = nil self.userPinnedQuality = nil @@ -1247,6 +1259,8 @@ final class YouTubePlayerService { : levels + ["auto"] self.currentQuality = await self.playbackController.currentQualityLevel() self.activeCaptionLanguageCode = await self.playbackController.currentCaptionLanguageCode() + self.audioTracks = await self.playbackController.availableAudioTracks() + self.activeAudioTrackId = await self.playbackController.currentAudioTrackId() if !tracks.isEmpty || attempt == 2 { return @@ -1308,6 +1322,13 @@ final class YouTubePlayerService { HapticService.toggle() } + /// Selects an alternate audio track (dubbed language) by id. + func selectAudioTrack(id: String) { + self.activeAudioTrackId = id + self.playbackController.setAudioTrack(id: id) + HapticService.toggle() + } + /// Selects a playback quality level. func selectQuality(_ level: String) { self.currentQuality = level diff --git a/Sources/Kaset/Views/YouTube/YouTubePlayerBar.swift b/Sources/Kaset/Views/YouTube/YouTubePlayerBar.swift index b9e5cd649..78bd78de6 100644 --- a/Sources/Kaset/Views/YouTube/YouTubePlayerBar.swift +++ b/Sources/Kaset/Views/YouTube/YouTubePlayerBar.swift @@ -579,6 +579,7 @@ struct YouTubePlayerBar: View { self.downloadButton } self.compactCaptionsMenu + self.compactAudioMenu self.compactQualityMenu case .reduced: self.overflowMenu @@ -719,6 +720,14 @@ struct YouTubePlayerBar: View { Label(String(localized: "Captions"), systemImage: "captions.bubble") } + if self.hasAlternateAudio { + Menu { + self.audioMenuItems + } label: { + Label(String(localized: "Audio"), systemImage: "waveform.circle") + } + } + Menu { self.qualityMenuItems } label: { @@ -777,6 +786,43 @@ struct YouTubePlayerBar: View { .disabled(self.youtubePlayer.currentVideo == nil) } + /// True only for multi-audio videos; single-track videos hide the control. + private var hasAlternateAudio: Bool { + self.youtubePlayer.audioTracks.count > 1 + } + + @ViewBuilder private var audioMenuItems: some View { + ForEach(self.youtubePlayer.audioTracks) { track in + Button { + self.youtubePlayer.selectAudioTrack(id: track.id) + } label: { + if self.youtubePlayer.activeAudioTrackId == track.id { + Label(track.displayName, systemImage: "checkmark") + } else { + Text(track.displayName) + } + } + } + } + + @ViewBuilder private var compactAudioMenu: some View { + if self.hasAlternateAudio { + Menu { + self.audioMenuItems + } label: { + Image(systemName: "waveform.circle") + .font(.system(size: 16, weight: .regular)) + .frame(width: 16, height: 16) + .foregroundStyle(.primary) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .buttonStyle(.plain) + .frame(width: 28, height: 28) + .disabled(self.youtubePlayer.currentVideo == nil) + } + } + @ViewBuilder private var qualityMenuItems: some View { Text("Speed").font(.caption.weight(.semibold)).foregroundStyle(.secondary) ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { speed in diff --git a/Sources/Kaset/Views/YouTube/YouTubeWatchWebView+Scripts.swift b/Sources/Kaset/Views/YouTube/YouTubeWatchWebView+Scripts.swift index 1c831e010..97a93c502 100644 --- a/Sources/Kaset/Views/YouTube/YouTubeWatchWebView+Scripts.swift +++ b/Sources/Kaset/Views/YouTube/YouTubeWatchWebView+Scripts.swift @@ -1301,6 +1301,124 @@ extension YouTubeWatchWebView { """ } + /// Fetches the alternate audio tracks (dubbed languages) the player offers. + /// Switching is by index into the player's live track array, so the opaque + /// player track objects never have to cross the JS bridge. + func availableAudioTracks() async -> [YouTubeAudioTrack] { + guard let json = await self.evaluateForString(Self.availableAudioTracksScript), + let data = json.data(using: .utf8), + let entries = try? JSONSerialization.jsonObject(with: data) as? [[String: String]] + else { + return [] + } + return entries.compactMap { entry in + guard let id = entry["id"], !id.isEmpty, + let name = entry["name"], !name.isEmpty + else { + return nil + } + return YouTubeAudioTrack(id: id, displayName: name) + } + } + + /// The index (as a string) of the player's active audio track, or nil. + func currentAudioTrackId() async -> String? { + let script = """ + (function() { + try { + const p = document.getElementById('movie_player'); + if (!p || typeof p.getAvailableAudioTracks !== 'function') { return ''; } + const tracks = p.getAvailableAudioTracks() || []; + let cur = null; + try { cur = p.getAudioTrack(); } catch (e) {} + for (let i = 0; i < tracks.length; i++) { + if (tracks[i] === cur) { return String(i); } + } + return ''; + } catch (e) { return ''; } + })(); + """ + let id = await self.evaluateForString(script) + return (id?.isEmpty == false) ? id : nil + } + + /// Activates the audio track at the given index in the player's live array. + func setAudioTrack(id: String) { + self.webView?.evaluateJavaScript(Self.setAudioTrackScript(id: id), completionHandler: nil) + } + + static var availableAudioTracksScript: String { + // ponytail: display names are heuristic — first from the opaque track + // object, else the player-response `audioTrack.displayName` by order, + // else a generic "Audio N". Switching (by index) is exact regardless. + // If names come out wrong on a multi-audio video, dump + // JSON.stringify(getAvailableAudioTracks()) and map the real name key. + """ + (function() { + try { + const p = document.getElementById('movie_player'); + if (!p || typeof p.getAvailableAudioTracks !== 'function') { return '[]'; } + let tracks = []; + try { tracks = p.getAvailableAudioTracks() || []; } catch (e) { return '[]'; } + if (tracks.length < 2) { return '[]'; } + + function playerResponse() { + if (typeof p.getPlayerResponse === 'function') { + try { const r = p.getPlayerResponse(); if (r) { return r; } } catch (e) {} + } + return window.ytInitialPlayerResponse || null; + } + const responseNames = []; + const seenName = {}; + const resp = playerResponse(); + const fmts = (resp && resp.streamingData && resp.streamingData.adaptiveFormats) || []; + fmts.forEach(function(f) { + const dn = f && f.audioTrack && f.audioTrack.displayName; + if (dn && !seenName[dn]) { seenName[dn] = true; responseNames.push(dn); } + }); + + function nameFrom(track, i) { + try { + for (const k in track) { + const v = track[k]; + if (v && typeof v === 'object') { + if (typeof v.name === 'string' && v.name) { return v.name; } + if (v.name && typeof v.name.simpleText === 'string' && v.name.simpleText) { + return v.name.simpleText; + } + } + } + } catch (e) {} + if (responseNames[i]) { return responseNames[i]; } + return 'Audio ' + (i + 1); + } + + return JSON.stringify(tracks.map(function(track, i) { + return { id: String(i), name: nameFrom(track, i) }; + })); + } catch (e) { return '[]'; } + })(); + """ + } + + static func setAudioTrackScript(id: String) -> String { + let idLiteral = Self.jsStringLiteral(id) + return """ + (function() { + try { + const p = document.getElementById('movie_player'); + if (!p || typeof p.getAvailableAudioTracks !== 'function' + || typeof p.setAudioTrack !== 'function') { return; } + const tracks = p.getAvailableAudioTracks() || []; + const idx = parseInt(\(idLiteral), 10); + if (idx >= 0 && idx < tracks.length) { + try { p.setAudioTrack(tracks[idx]); } catch (e) {} + } + } catch (e) {} + })(); + """ + } + /// The storyboard spec string for the current video, read from the player /// response. Drives the ambient backdrop's fine-grained live color. ///