1- import { memoize } from "@fxts/core"
21import { ofetch } from "ofetch"
2+ import { getSelectedCaption } from "@/lib/yt/getSelectedCaption"
33import { getVideoInfo } from "@/lib/yt/getVideoInfo"
44
55type CaptionTrackLike = {
@@ -10,6 +10,8 @@ type CaptionTrackLike = {
1010}
1111
1212const isManual = ( t : CaptionTrackLike ) => t . kind !== "asr"
13+ const isAsr = ( t : CaptionTrackLike ) =>
14+ t . kind === "asr" || Boolean ( t . vssId ?. startsWith ( "a." ) )
1315
1416// YouTube vssId convention:
1517// ".{lang}" = original manually authored caption (e.g. ".en")
@@ -19,44 +21,77 @@ const isOriginalManual = (t: CaptionTrackLike) =>
1921const isOriginalAsr = ( t : CaptionTrackLike ) =>
2022 Boolean ( t . vssId ?. startsWith ( "a." ) )
2123
22- export const getVideoSubtitle = memoize ( async ( videoId : string ) => {
23- const { r, pot } = await getVideoInfo ( videoId )
24+ // Not memoized: the user may switch caption tracks between invocations, so
25+ // the returned SRT must reflect the current selection.
26+ export const getVideoSubtitle = async ( videoId : string ) => {
27+ const [ { r, pot } , selected ] = await Promise . all ( [
28+ getVideoInfo ( videoId ) ,
29+ getSelectedCaption ( ) ,
30+ ] )
2431
2532 const tracklist = r . captions ?. playerCaptionsTracklistRenderer
2633 const captionTracks : CaptionTrackLike [ ] = tracklist ?. captionTracks ?? [ ]
2734
28- if ( captionTracks . length === 0 ) {
29- return null
30- }
31-
32- // Pick the caption track that matches the main audio language.
33- // Priority:
34- // 1. Manual caption in default audio's language (kind !== "asr")
35- // 2. Auto-generated caption in default audio's lang (kind === "asr")
35+ // Pick the caption track to fetch. Priority:
36+ // 0. The track the user currently has selected in the player.
37+ // 1. Manual caption in the default audio's language (kind !== "asr")
38+ // 2. Auto-generated caption in the default audio's lang (kind === "asr")
3639 // 3. vssId-based fallback: original manual, then original asr
3740 // 4. captionTracks[0] as a last resort
38- //
39- // `captionTracks[0]` alone is unreliable: it's just whatever YouTube lists
40- // first (often a translated track), so it can mismatch the voice.
4141 let captionTrack : CaptionTrackLike | undefined
4242
43- // Strategy 1: audioTracks -> captionTrackIndices (most reliable on modern API)
44- const audioTracks = tracklist ?. audioTracks
45- const defaultAudioTrackIndex = tracklist ?. defaultAudioTrackIndex
46- const defaultAudioTrack =
47- typeof defaultAudioTrackIndex === "number"
48- ? audioTracks ?. [ defaultAudioTrackIndex ]
49- : undefined
50-
51- const audioCaptionIndices : number [ ] =
52- defaultAudioTrack ?. captionTrackIndices ?? [ ]
53- const audioCaptionCandidates = audioCaptionIndices
54- . map ( ( i : number ) => captionTracks [ i ] )
55- . filter ( ( t ) : t is CaptionTrackLike => Boolean ( t ) )
56-
57- if ( audioCaptionCandidates . length > 0 ) {
58- captionTrack =
59- audioCaptionCandidates . find ( isManual ) ?? audioCaptionCandidates [ 0 ]
43+ // Strategy 0: honor the user's explicit selection in the YouTube player.
44+ // If the user is actively watching with, say, Korean captions selected, we
45+ // should copy Korean — even when Chinese (a creator-uploaded translation)
46+ // happens to be listed first in `captionTracks`.
47+ if ( selected ) {
48+ if ( selected . vssId ) {
49+ captionTrack = captionTracks . find ( ( t ) => t . vssId === selected . vssId )
50+ }
51+ if ( ! captionTrack && selected . languageCode ) {
52+ captionTrack = captionTracks . find (
53+ ( t ) =>
54+ t . languageCode === selected . languageCode &&
55+ ( t . kind ?? "" ) === ( selected . kind ?? "" ) ,
56+ )
57+ }
58+ // If the player is showing an auto-translated track (which won't appear in
59+ // `captionTracks`), use the selected baseUrl directly.
60+ if ( ! captionTrack && selected . baseUrl ) {
61+ captionTrack = selected
62+ }
63+ }
64+
65+ // Strategy 1: audioTracks -> captionTrackIndices (modern API).
66+ if ( ! captionTrack ) {
67+ const audioTracks = tracklist ?. audioTracks
68+ const defaultAudioTrackIndex = tracklist ?. defaultAudioTrackIndex
69+ const defaultAudioTrack =
70+ typeof defaultAudioTrackIndex === "number"
71+ ? audioTracks ?. [ defaultAudioTrackIndex ]
72+ : undefined
73+
74+ const audioCaptionIndices : number [ ] =
75+ defaultAudioTrack ?. captionTrackIndices ?? [ ]
76+ const audioCaptionCandidates = audioCaptionIndices
77+ . map ( ( i : number ) => captionTracks [ i ] )
78+ . filter ( ( t ) : t is CaptionTrackLike => Boolean ( t ) )
79+
80+ if ( audioCaptionCandidates . length > 0 ) {
81+ // The ASR caption is always in the audio's language. Use it to pin down
82+ // the audio language and filter out creator-uploaded translations that
83+ // YouTube may have lumped under the same audio.
84+ const asrInAudio = audioCaptionCandidates . find ( isAsr )
85+ const audioLang = asrInAudio ?. languageCode
86+ const sameLangCandidates = audioLang
87+ ? audioCaptionCandidates . filter ( ( t ) => t . languageCode === audioLang )
88+ : audioCaptionCandidates
89+
90+ captionTrack =
91+ sameLangCandidates . find ( isManual ) ??
92+ sameLangCandidates [ 0 ] ??
93+ audioCaptionCandidates [ 0 ]
94+ }
6095 }
6196
6297 // Strategy 2: vssId-based fallback when audioTracks data is unavailable.
@@ -84,4 +119,4 @@ export const getVideoSubtitle = memoize(async (videoId: string) => {
84119 } )
85120
86121 return srt
87- } )
122+ }
0 commit comments