Skip to content

Commit f2d3002

Browse files
committed
fix(yt): respect user's selected caption track when copying and bump to 1.3.14
When the user has explicitly enabled a specific caption track in the YouTube player (e.g. Korean on a Korean-audio video where the creator also uploaded a Chinese manual caption that sits at the top of the list), copy that track instead of the audio-language default. - Read the currently displayed track via movie_player.getOption('captions', 'track') from the main-world script and match it back to captionTracks by vssId or languageCode+kind; for auto-translated tracks (not in captionTracks), use the player's baseUrl directly. - Tighten the audio-language fallback: filter candidates to the ASR caption's languageCode so creator-uploaded translations lumped into captionTrackIndices no longer win. - Drop memoization on getVideoSubtitle and convertSrtToText so the output reflects the current player selection between invocations.
1 parent 91274b6 commit f2d3002

5 files changed

Lines changed: 159 additions & 41 deletions

File tree

entrypoints/youtube-main-world.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,47 @@ export default defineUnlistedScript(() => {
7171
"*",
7272
)
7373
}
74+
75+
if (event.data.type === "GET_YT_CAPTIONS_SELECTION") {
76+
// Read the currently displayed caption track from the YouTube player.
77+
// Returns `{}` when captions are off; we normalize that to `null`.
78+
let selected: {
79+
vssId?: string
80+
languageCode?: string
81+
kind?: string
82+
baseUrl?: string
83+
} | null = null
84+
85+
try {
86+
const player = document.getElementById("movie_player") as any
87+
if (player && typeof player.getOption === "function") {
88+
const track = player.getOption("captions", "track")
89+
if (
90+
track &&
91+
typeof track === "object" &&
92+
Object.keys(track).length > 0
93+
) {
94+
selected = {
95+
vssId: track.vssId,
96+
languageCode: track.languageCode,
97+
kind: track.kind,
98+
baseUrl: track.baseUrl,
99+
}
100+
}
101+
}
102+
} catch (error) {
103+
console.error("Error reading selected caption track:", error)
104+
}
105+
106+
window.postMessage(
107+
{
108+
type: "YT_CAPTIONS_SELECTION",
109+
data: selected,
110+
requestId: event.data.requestId,
111+
},
112+
"*",
113+
)
114+
}
74115
})
75116

76117
console.log("YouTube main world script loaded")

lib/yt/convertSrtToText.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import { memoize } from "@fxts/core"
21
import Parser from "srt-parser-2"
32

4-
export const convertSrtToText = memoize(
5-
async (_videoId: string, srt: string) => {
6-
const r = new Parser().fromSrt(srt)
3+
// Not memoized: the same videoId can produce different SRT content when the
4+
// user switches caption tracks between invocations.
5+
export const convertSrtToText = async (_videoId: string, srt: string) => {
6+
const r = new Parser().fromSrt(srt)
77

8-
return r.map((item) => item.text).join("\n")
9-
},
10-
(videoId) => videoId,
11-
)
8+
return r.map((item) => item.text).join("\n")
9+
}

lib/yt/getSelectedCaption.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
export type SelectedCaption = {
2+
vssId?: string
3+
languageCode?: string
4+
kind?: string
5+
baseUrl?: string
6+
}
7+
8+
// Reads the currently displayed caption track from the YouTube player via
9+
// the main-world script. Returns `null` when no captions are shown.
10+
//
11+
// Not memoized: the user may switch tracks between invocations.
12+
export const getSelectedCaption = (): Promise<SelectedCaption | null> => {
13+
return new Promise((resolve) => {
14+
const requestId = Math.random().toString(36).substring(7)
15+
16+
const handleMessage = (event: MessageEvent) => {
17+
if (event.source !== window) return
18+
19+
if (
20+
event.data.type === "YT_CAPTIONS_SELECTION" &&
21+
event.data.requestId === requestId
22+
) {
23+
window.removeEventListener("message", handleMessage)
24+
resolve(event.data.data ?? null)
25+
}
26+
}
27+
28+
window.addEventListener("message", handleMessage)
29+
30+
window.postMessage(
31+
{
32+
type: "GET_YT_CAPTIONS_SELECTION",
33+
requestId,
34+
},
35+
"*",
36+
)
37+
38+
// Timeout to avoid hanging if the main-world script is unavailable.
39+
setTimeout(() => {
40+
window.removeEventListener("message", handleMessage)
41+
resolve(null)
42+
}, 1000)
43+
})
44+
}

lib/yt/getVideoSubtitle.ts

Lines changed: 67 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { memoize } from "@fxts/core"
21
import { ofetch } from "ofetch"
2+
import { getSelectedCaption } from "@/lib/yt/getSelectedCaption"
33
import { getVideoInfo } from "@/lib/yt/getVideoInfo"
44

55
type CaptionTrackLike = {
@@ -10,6 +10,8 @@ type CaptionTrackLike = {
1010
}
1111

1212
const 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) =>
1921
const 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+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "cpdown",
33
"description": "Copy any webpage as clean markdown",
4-
"version": "1.3.13",
4+
"version": "1.3.14",
55
"type": "module",
66
"scripts": {
77
"dev": "wxt",

0 commit comments

Comments
 (0)