Repository navigation
Add an audio-track selector for multi-audio videos #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() | ||
|
Comment on lines
+1262
to
+1263
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: These awaits can suspend while the user navigates to another video. The only video-id guard runs before the quality, caption-state, and audio queries, so an old refresh can resume afterward and overwrite the new video's Severity Level: Critical 🚨- ⚠️ New video can display previous audio tracks.
- ⚠️ Audio checkmark can identify the wrong active track.
- ⚠️ Quality and caption state can also be overwritten by the same race.(Use Cmd/Ctrl + Click for best experience) Prompt for AI Agent 🤖This is a comment left during a code review.
**Path:** Sources/Kaset/Services/Player/YouTubePlayerService.swift
**Line:** 1262:1263
**Comment:**
*Stale Reference: These awaits can suspend while the user navigates to another video. The only video-id guard runs before the quality, caption-state, and audio queries, so an old refresh can resume afterward and overwrite the new video's `audioTracks` and `activeAudioTrackId` state. Recheck `currentVideo?.videoId == videoId` immediately before applying these results, or associate the refresh with a generation token.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix |
||
|
|
||
| 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() | ||
|
Comment on lines
+1325
to
+1329
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: The service marks the requested track active before the JavaScript switch succeeds, while Severity Level: Major
|
||
| } | ||
|
|
||
| /// Selects a playback quality level. | ||
| func selectQuality(_ level: String) { | ||
| self.currentQuality = level | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); } | ||
| }); | ||
|
Comment on lines
+1373
to
+1378
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Severity Level: Major
|
||
|
|
||
| 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. | ||
| /// | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: The test target's
MockYouTubeWatchPlaybackControllerconforms directly to this protocol but does not implement these three newly required methods, so adding the requirements causes the test target to fail to compile until the mock is updated or default protocol implementations are provided. [api mismatch]Severity Level: Critical 🚨
(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖