-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontent.js
More file actions
1240 lines (1176 loc) · 59.6 KB
/
Copy pathcontent.js
File metadata and controls
1240 lines (1176 loc) · 59.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// immerse — click a caption word, get an AI explanation of it in context; A/S/D to move by
// sentence; Z for a Chinese line. Isolated world, no build step.
// hook.js (MAIN world) supplies the player's signed timedtext URL; bg.js makes the Claude call.
const SEG = ".ytp-caption-segment";
const BOX = "#ytp-caption-window-container";
const WORD = /[A-Za-z][A-Za-z'’-]*/;
// A sentence ends only when the punctuation is followed by space/end, so "llama.cpp" stays whole.
// ponytail: "3.5" mid-number can still false-close one. Rare enough to eat.
const SENTENCE = /^([\s\S]*?[.!?]+)(\s+|$)/;
// Clause-level cut for navigation: ASR "sentences" run long, and replaying one to hear a single
// word means sitting through all of it. Commas need trailing whitespace so "1,000" stays whole.
const CLAUSE = /^([\s\S]*?(?:[.!?]+|[,;:]))(\s+|$)/;
// Closed word classes: the full membership is short and fixed, so a lookup table is exact and
// free. Only verbs and nouns are open-ended enough to need the model.
const set = (s) => new Set(s.split(" "));
const PREP = set(`about above across after against along among around as at before behind below
beneath beside between beyond by down during except for from in inside into near of off on onto
out outside over past since through throughout to toward towards under until up upon with within
without`.split(/\s+/).filter(Boolean).join(" "));
const AUX = set("am is are was were be been being have has had do does did will would shall should can could may might must");
const DET = set("a an the this that these those my your his her its our their some any each every no");
const PRON = set("i you he she it we they me him us them who whom whose which what");
const CONJ = set("and but or nor so yet because although though while if unless whereas than");
// `learned` is the model's word→verb/noun map; undefined means "leave it neutral".
function posOf(word, learned) {
const w = word.toLowerCase();
if (AUX.has(w)) return "aux";
if (PREP.has(w)) return "prep";
if (DET.has(w)) return "det";
if (PRON.has(w)) return "pron";
if (CONJ.has(w)) return "conj";
if (!learned) return undefined;
if (learned[w]) return learned[w];
// The model still answers with base forms sometimes, so try the obvious endings before giving
// up. ponytail: a naive stemmer, not a lemmatiser — irregulars like "grew"→"grow" stay unmatched.
// A real one means npm and a bundler, which this project does not have.
// Each ending is its own candidate: an alternation would let /(es|s)$/ eat "sees" down to "se".
// A wrong stem is harmless because only an exact hit in the map is accepted.
for (const stem of [
w.replace(/ies$/, "y"), // stories → story
w.replace(/s$/, ""), // sees → see
w.replace(/es$/, ""), // watches → watch
w.replace(/ed$/, ""), // opened → open
w.replace(/ing$/, ""), // talking → talk
w.replace(/ing$/, "e"), // making → make
w.replace(/([bdgklmnprt])\1(ing|ed)$/, "$1"), // running → run, stopped → stop
]) {
if (stem !== w && learned[stem]) return learned[stem];
}
return undefined;
}
// The model answers in a line format rather than prose so the popup can lay it out: the
// in-context meaning first, then a few general senses each with an example and its translation.
// Malformed lines are dropped rather than rendered, so a bad reply degrades to less content
// instead of a broken panel.
function parseReply(text) {
const senses = [];
let context = "";
let contextZh = "";
let zh = "";
for (const line of String(text ?? "").split("\n")) {
const l = line.trim();
// CONTEXT_ZH before CONTEXT: the longer prefix has to win, or it is swallowed by the shorter.
if (l.startsWith("CONTEXT_ZH:")) contextZh = l.slice(11).trim();
else if (l.startsWith("CONTEXT:")) context = l.slice(8).trim();
else if (l.startsWith("ZH:")) zh = l.slice(3).trim();
else if (l.startsWith("SENSE:")) {
// Not named `zh`: that one is the whole sentence's translation, this is the example's.
const [pos, gloss, example, egzh] = l.slice(6).split("|").map((p) => p.trim());
if (pos && gloss) senses.push({ pos, gloss, example, zh: egzh });
}
}
// A reply that ignored the format entirely is still worth showing as plain text.
return {
context: context || (senses.length ? "" : String(text ?? "").trim()),
contextZh,
zh,
senses,
};
}
// zeroStudy's own guidance: no more than ten marks per hour of immersion. Past that the reviews
// pile up faster than they can be cleared and the deck stops being reviewable at all.
const MARKS_PER_HOUR = 10;
// A plain wall-clock hour. This originally ran on the immersion clock ("an hour of watching"),
// which meant marks from yesterday still filled the window after a 10-hour break — technically
// consistent, practically absurd for an advisory cap on a personal tool. Only 學習中 counts:
// the cap exists to protect the review queue, and a word marked 已掌握 never enters it.
function markRate(words, now, windowMs = 3600_000) {
return words.filter(
(w) => !w.suspended && w.addedAt != null && w.addedAt > now - windowMs,
).length;
}
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const bounded = (word, flags) =>
new RegExp(`(?<![A-Za-z'’-])${escapeRe(word)}(?![A-Za-z'’-])`, flags);
// Case-insensitive, whole-word search: "grew into" must not match inside "grown into".
function indexOfWord(text, phrase) {
const m = text.match(bounded(phrase, "i"));
return m ? m.index : -1;
}
// Cut `text` into runs, marking the stretches that are known multi-word expressions so they can
// be rendered as one clickable unit. Idioms and phrasal verbs are exactly what an advanced
// learner misses, and boxing each word separately hides them.
function splitPhrases(text, phrases = []) {
const out = [];
let rest = text;
while (rest) {
let best = null;
for (const p of phrases) {
const at = indexOfWord(rest, p);
// Earliest match wins; on a tie the longer phrase does, so "look forward to" beats
// "look forward".
if (at >= 0 && (!best || at < best.at || (at === best.at && p.length > best.p.length))) {
best = { at, p };
}
}
if (!best) {
out.push({ text: rest });
break;
}
out.push({ text: rest.slice(0, best.at) });
out.push({ text: rest.slice(best.at, best.at + best.p.length), phrase: true });
rest = rest.slice(best.at + best.p.length);
}
return out.filter((run) => run.text);
}
// Stitch the timed cues into sentences. Cues break mid-sentence ("...like Qwen, Kimmy, and the" /
// "GLM family are..."), so the text is concatenated and re-split on punctuation. Each segment
// carries the time its first word is spoken — cue start when it opens a cue, interpolated by
// character position when it starts mid-cue. Called twice per track: with SENTENCE for the
// card/explain context, with CLAUSE for A/S/D navigation and card timestamps.
function toSentences(cues, re = SENTENCE) {
const out = [];
let buf = "";
let start = null;
for (const c of cues) {
if (start === null) start = c.start;
buf = buf ? `${buf} ${c.text}` : c.text;
let m;
while ((m = buf.match(re))) {
out.push({ text: m[1].trim(), start });
buf = buf.slice(m[0].length);
if (!buf.trim()) {
// Nothing left over: the next sentence starts in whichever cue comes next.
start = null;
} else {
// The next sentence starts mid-cue. A cue-granular start sits up to a whole cue early —
// inside the tail of the sentence just pushed — so the previous sentence's last words
// were attributed to the next one, and S (replay) kept landing on its own last word.
// Interpolate by character position; without a finite cue end, fall back to cue start.
const consumed = Math.max(0, c.text.length - buf.length);
const dur = Number.isFinite(c.end) ? c.end - c.start : 0;
start = c.start + (c.text.length && dur > 0 ? (dur * consumed) / c.text.length : 0);
}
}
}
if (buf.trim()) out.push({ text: buf.trim(), start });
// A sentence runs until the next one starts, which makes "which sentence is playing" a lookup.
out.forEach((s, k) => (s.end = out[k + 1]?.start ?? Infinity));
return out;
}
// Which zh cues translate sentence `s`. The zh track is segmented differently from the English
// one (a cue often straddles two sentences), so cues are assigned to the sentence their MIDPOINT
// falls in — the old "starts inside the window" test returned nothing for a short sentence whose
// translation cue began a beat early, and the line silently vanished. If no midpoint lands in the
// window (very short sentence), fall back to whichever cue is playing at the sentence's centre.
function zhFor(zhCues, s) {
const mid = (c) => (c.end === Infinity ? c.start : (c.start + (c.end ?? c.start)) / 2);
let cues = zhCues.filter((c) => mid(c) >= s.start && mid(c) < s.end);
if (!cues.length) {
const horizon = s.end === Infinity ? s.start + 15 : s.end;
const centre = (s.start + horizon) / 2;
cues = zhCues.filter((c) => c.start <= centre && centre < (c.end ?? Infinity));
}
return cues.map((c) => c.text).join("");
}
// Which word of a sentence is being spoken at time t, by character position.
//
// ponytail: interpolated, not measured. YouTube does ship per-word offsets in its json3 captions,
// but they would have to survive toSentences, which concatenates cues and re-splits them on
// punctuation — the function every other feature here depends on. The same character-proportional
// estimate already runs inside it to place a sentence that starts mid-cue, and that is the fix
// that stopped S replaying from the wrong word. Error is zero at both ends of a sentence and
// largest in the middle of a long one. If the highlight visibly drags behind the voice, carry
// tOffsetMs through toSentences and store real word times in the transcript instead.
//
// `tokens` are the reader's own tokens, each carrying `at` — its offset into `s.text`.
function spokenIdx(tokens, s, t) {
const dur = s.end - s.start;
// An open-ended sentence cannot be interpolated: every position would come out as zero and the
// first word would sit lit for the rest of the video. Nothing lit is the honest answer.
if (!Number.isFinite(dur) || dur <= 0 || t < s.start || t >= s.end) return -1;
const pos = ((t - s.start) / dur) * s.text.length;
let hit = -1;
for (const tk of tokens) {
if (tk.t === "w" && tk.at <= pos) hit = tk.idx;
}
return hit;
}
// The model's numbered translation lines back into an array aligned with the sentences. A line
// it skipped, or one it numbered wrongly, leaves a hole rather than shifting every later sentence
// onto the wrong translation — the failure this whole feature exists to prevent.
function parseZh(raw, n) {
const out = Array.from({ length: n }, () => "");
for (const line of String(raw ?? "").split("\n")) {
const m = line.match(/^\s*(\d+)\s*\t\s*(.*\S)\s*$/);
if (m && +m[1] < n && !out[+m[1]]) out[+m[1]] = m[2];
}
return out;
}
// Which sentence S should replay. Not simply "the one the clock is in": sentence ends and
// starts touch, so by the time a hand reacts to a sentence landing and presses S, the clock has
// usually crossed into the next one — replaying THAT replays a sentence barely begun. So less
// than a beat into a sentence, the one the ear wants back is the one before. Unless S itself
// just put the clock here: that press means "loop this sentence", not "walk one further back",
// which is what `last` — the index S replayed most recently — distinguishes.
// replay() seeks REPLAY_LEAD before a sentence's start, so an interpolated start sitting on the
// first word does not clip it. That landing spot falls inside the PREVIOUS sentence's range, so a
// second S read it as "still in the previous sentence" and stepped one sentence back on every
// press. Sentences are contiguous (each end === the next start), so shifting every boundary
// earlier by the lead makes the landing spot belong to the sentence it precedes.
const REPLAY_LEAD = 0.3;
function replayTarget(sentences, t, last) {
let k = sentences.findIndex((x) => t >= x.start - REPLAY_LEAD && t < x.end - REPLAY_LEAD);
if (k < 0) k = t < (sentences[0]?.start ?? 0) ? 0 : sentences.length - 1;
// Pressed a beat INTO a sentence (a positive delta under ~0.9s) means the reaction lagged the
// boundary and the ear wanted the previous one. A NEGATIVE delta is the pre-roll landing spot,
// which belongs to this sentence on purpose — never step back from it, or a second S walks back.
const delta = t - sentences[k].start;
if (delta >= 0 && delta < 0.9 && last !== k) k = Math.max(0, k - 1);
return k;
}
// Build the timedtext URL for a fetch. The stashed URL may already carry tlang=… — when the
// player itself is displaying an auto-translated track — and must be stripped, or the "English"
// pipeline (sentences, phrases, POS) silently runs on the translated text. YouTube renders one
// track at a time; both languages at once is exactly what the Z line exists for.
function cueUrl(url, tlang) {
const u = new URL(url, "https://www.youtube.com");
u.searchParams.set("fmt", "json3");
u.searchParams.delete("tlang");
if (tlang) u.searchParams.set("tlang", tlang);
return u;
}
function start_() {
const state = { captures: [], open: null, anchor: null, marks: {}, zhOn: false, blurOn: false,
trackUrl: null, cues: [], sentences: [], clauses: [], zhCues: [], zh: [], phrases: [], pos: {},
replayed: -1,
explains: new Map() }; // word|sentence → in-flight or settled explanation, so a re-click never re-bills
window.__im = state;
// Immersion clock: seconds of video actually playing in a visible tab. Flushed every 15s rather
// than every tick — a storage write per second would be absurd for a counter nobody watches.
function tickImmersion() {
const v = video();
if (v && !v.paused && !v.ended && !document.hidden) state.imm += 1;
if (state.imm - state.immSaved >= 15) {
const delta = state.imm - state.immSaved;
state.immSaved = state.imm;
// Two shapes on purpose: `immersion` is a monotonic clock the mark-rate budget measures
// against, `immLog` is per-day and is what the daily goal reads. Both are added up by the
// worker rather than here: several tabs can be watching at once, and a read-modify-write
// per tab loses one tab's seconds every flush.
const day = new Date();
const key = `${day.getFullYear()}-${day.getMonth() + 1}-${day.getDate()}`;
const vid = new URLSearchParams(location.search).get("v");
// askOnce, not ask: ask retries a closed channel, and retrying an "add this much" message
// can apply it twice. At-most-once is the right trade for a counter — a dropped flush costs
// a few seconds, while an inflated one lies to the daily goal and the mark-rate budget.
askOnce({ type: "imm", delta, day: key, videoId: vid });
}
}
// Reloading the extension orphans this script: chrome.* is still there but every call throws
// "Extension context invalidated". chrome.runtime.id going undefined is the signal — but the
// invalidation is not atomic, so a storage call can throw while the id still reads fine. Every
// chrome.* call therefore goes through safe(), which swallows both the synchronous throw from
// the call itself and the rejection of the promise it returns.
const alive = () => !!chrome.runtime?.id;
const safe = (fn, fallback) => {
try {
return Promise.resolve(fn()).catch(() => fallback);
} catch {
return Promise.resolve(fallback);
}
};
// Named getStore/setStore, not get/set: `set` is already the word-class Set builder above.
const getStore = (keys, fallback = {}) => safe(() => chrome.storage.local.get(keys), fallback);
const setStore = (obj) => safe(() => chrome.storage.local.set(obj), undefined);
getStore(["marks", "zhOn", "blurOn", "immersion"]).then((r) => {
state.marks = r.marks ?? {};
state.zhOn = !!r.zhOn;
state.imm = r.immersion ?? 0;
state.immSaved = state.imm;
setBlur(!!r.blurOn);
repaint();
});
// Other pages rewrite marks too (library status buttons, debt cleanup). Without this, the next
// mark here would write this tab's stale copy back over their change — the same lost-update bug
// the review page had, in the other direction. Caption colours follow along for free.
chrome.storage?.onChanged?.addListener((ch, area) => {
if (area !== "local" || !alive() || !ch.marks) return;
state.marks = ch.marks.newValue ?? {};
repaint();
});
// 進階聽力: blur the English words so the ear has to do the work — reading captions is the
// path of least resistance, and the brain will take it every time it is available. Hovering
// peeks at one word (and the hover-freeze pauses the video, so a peek also stops the clock);
// the Chinese line stays sharp, which combined with Z gives translation-only listening.
// CSS does all of it: the toggle is just a class on <html>.
function setBlur(on) {
state.blurOn = on;
document.documentElement.classList.toggle("im-blur", on);
}
function toggleBlur() {
setBlur(!state.blurOn);
setStore({ blurOn: state.blurOn });
}
// sendMessage reports a dead service worker through lastError, not through the reply — without
// this the failure arrives as a bare `undefined` and looks like an empty answer.
const askOnce = (payload) =>
new Promise((ok) => {
if (!alive()) return ok({ error: "擴充剛重新載入,請重新整理這個分頁" });
chrome.runtime.sendMessage(payload, (r) =>
ok(chrome.runtime.lastError ? { error: chrome.runtime.lastError.message } : r),
);
});
// "message channel closed" = the worker died mid-call (idle kill, update). The failure is the
// worker's, not the request's — a new message wakes a fresh worker, so one retry usually lands.
const ask = (payload) =>
askOnce(payload).then((r) =>
String(r?.error ?? "").includes("message channel closed") ? askOnce(payload) : r,
);
const video = () => document.querySelector("video");
// --- transcript -----------------------------------------------------------------------------
// The same signed URL with tlang= returns YouTube's own translation, so the Chinese line costs
// no API call. Same origin as the page, so no CORS problem.
async function fetchCues(url, tlang) {
const r = await fetch(cueUrl(url, tlang));
if (!r.ok) return [];
const json = await r.json().catch(() => null);
const list = (json?.events ?? [])
.filter((e) => e.segs)
.map((e) => ({
start: e.tStartMs / 1000,
text: e.segs.map((s) => s.utf8).join("").replace(/\s+/g, " ").trim(),
}))
.filter((c) => c.text);
// Each cue runs until the next one starts — zhFor needs midpoints, which need ends.
list.forEach((c, i) => (c.end = list[i + 1]?.start ?? Infinity));
return list;
}
async function loadTrack() {
const url = document.documentElement.dataset.imTimedtext;
if (!url || url === state.trackUrl) return; // also re-fires on SPA navigation to a new video
// No video id means a homepage/preview player fired this request. There is nothing to study
// and no cache key to file under — and hover-previews must not burn phrase/POS calls.
// trackUrl is deliberately left unset so the real watch page re-evaluates from scratch.
const pageId = new URLSearchParams(location.search).get("v");
if (!pageId) return;
// A timedtext URL names the video whose captions it carries. When an ad with captions plays,
// the player requests the AD's track — on a watch page whose address still says the real
// video — and one polluted transcript went through the whole pipeline and into the cloud
// that way. The mismatch is the tell; the real track arrives after the ad and passes.
const trackId = new URL(url, location.origin).searchParams.get("v");
if (trackId && trackId !== pageId) return;
state.trackUrl = url;
// Snapshot the id and title now. The pipeline below awaits several times; if the user
// navigates A→B during those awaits, B's loadTrack overwrites the shared state and the URL,
// and a save that re-read location would file A's content under B's id and title. `stale()`
// checks the one flag B is guaranteed to have changed — state.trackUrl — after every await,
// so the superseded load bails instead of writing.
const videoId = pageId;
const stale = () => state.trackUrl !== url;
// Remember whether the on-screen captions are a translated track, to hint the user below.
state.translatedTrack = url.includes("tlang=");
state.zhCues = [];
state.zh = [];
state.phrases = [];
state.pos = {};
state.replayed = -1;
state.cues = await fetchCues(url);
if (stale()) return; // navigated to another video mid-fetch; that load now owns the state
state.sentences = toSentences(state.cues);
state.clauses = toSentences(state.cues, CLAUSE);
if (state.zhOn) state.zhCues = await fetchCues(url, "zh-Hant");
if (stale()) return;
console.log("[immerse]", state.sentences.length, "sentences from", state.cues.length, "cues");
// Homepage preview players fire timedtext requests too, and those often yield no usable
// cues — an empty transcript must never reach the model (the API rejects empty content,
// and there is nothing to ask about anyway).
if (!state.sentences.length) return;
// Save only after both land: the phrases and POS tags cost real money here, and shipping them
// with the transcript is what lets the phone colour and box the same text for free.
await Promise.all([loadPhrases(), loadPos(), loadZh()]);
if (stale()) return; // B took over during the model calls — never save A's content as B
saveTranscript(videoId);
}
// Hand the whole transcript to the worker, which files it in the repo for the phone to read.
// The phone cannot obtain this itself — YouTube refuses to play, and therefore to fetch its own
// captions, inside a mobile WebView — so the desktop, which is legitimately watching anyway,
// is the only place it can come from.
// videoId is snapshotted at loadTrack entry (the URL is reliable immediately). The TITLE is
// read here, not there: on SPA navigation the timedtext request that triggers loadTrack often
// fires while document.title is still the transient "YouTube", before the real title loads, so
// an entry-time read mislabelled two transcripts in three. By now it has settled — provided we
// are still on this video, which the caller's stale() check plus this live URL re-check confirm
// (the re-check also closes the gap where the URL has already changed but the new load's
// timedtext has not yet fired, so state.trackUrl still points here).
async function saveTranscript(videoId) {
if (!videoId || !state.sentences.length) return;
if (new URLSearchParams(location.search).get("v") !== videoId) return;
let title = document.title.replace(/ - YouTube$/, "");
if (!title || title === "YouTube") title = videoId; // never store the placeholder as a name
// Fetch the Chinese track even when the Z line was never switched on. It is YouTube's own
// translation of the same URL, so it costs nothing, and it is the last chance to get it: the
// phone has no signed URL of its own.
let zhCues = state.zhCues;
if (!zhCues.length && state.trackUrl) zhCues = await fetchCues(state.trackUrl, "zh-Hant");
// The last segment's end is Infinity, which JSON turns into null. Give it a real horizon so
// the reader on the phone can highlight the final sentence like any other.
const last = state.cues[state.cues.length - 1];
const horizon = Number.isFinite(last?.end) ? last.end : (last?.start ?? 0) + 15;
const trim = (list) =>
list.map((x) => ({ text: x.text, start: x.start, end: Number.isFinite(x.end) ? x.end : horizon }));
askOnce({
type: "tx-save",
tx: {
// Bumped when the shape OR the translation changes, so a stale transcript is rewritten
// next time the video is opened. v6 re-translates with a prompt that stops the model
// swapping adjacent short fragments; v5 was the first per-sentence translation; v4 shipped
// YouTube's cues raw; v1-v3 split them per sentence — see ZH_SYSTEM in prompts.js.
v: state.zh.length ? 6 : 4,
videoId,
title,
at: Date.now(),
sentences: trim(state.sentences),
clauses: trim(state.clauses),
// Our own per-sentence translation, index-aligned with `sentences`. YouTube's cues are
// kept only as the desktop's stopgap while ours loads; the phone reads `zh`. If the
// translation failed this stays v4 so the next desktop visit tries again.
zh: state.zh.length ? state.zh : undefined,
zhCues: trim(zhCues),
phrases: state.phrases,
pos: state.pos,
},
}).then((r) => {
// The upload is the moment the phone gains this video, so say it on screen — the console
// only ever told people it worked after they already doubted it. Failure gets a toast for
// the same reason, louder. "Already current" stays quiet: it is the outcome of most opens,
// and a toast that fires every time is one that stops being read.
if (r?.error) {
console.warn("[immerse] 逐字稿未存檔:", r.error);
toast(`逐字稿上傳失敗:${r.error}`, 0);
} else if (r?.skipped) {
// Not silent any more: replaying a video and seeing NOTHING read as "the upload isn't
// working". A brief auto-fading line (not the sticky bubble) confirms the desktop checked
// and the phone already has this one, without nagging on every open.
console.log("[immerse] 逐字稿已是最新", videoId, `v${r.v ?? "?"}`);
toast("逐字稿已是最新版,手機已有 ✓");
} else {
console.log("[immerse] 逐字稿已存入雲端", videoId, `v${state.zh.length ? 6 : 4}`);
toast("逐字稿已上傳,手機可以看了 ✓", 0);
}
});
}
// Same batch-once-per-video shape as loadPhrases. Untagged words simply render neutral, so a
// partial or missing answer degrades quietly instead of breaking the captions.
async function loadPos() {
const key = `pos3_${new URLSearchParams(location.search).get("v")}`;
const full = state.sentences.map((s) => s.text).join(" ");
const head = full.slice(0, 80);
const hit = (await getStore(key))[key];
const cached = hit?.head === head ? hit.raw : undefined;
const res = cached !== undefined ? { text: cached } : await ask({ type: "pos", text: full });
if (!res || res.error) return console.warn("[immerse] pos failed:", res?.error ?? "no reply");
const raw = res.text ?? "";
state.pos = Object.fromEntries(
raw
.split("\n")
.map((l) => l.trim().split(/\s+/))
.filter(([w, tag]) => w && ["verb", "noun", "adj"].includes(tag))
.map(([w, tag]) => [w.toLowerCase(), tag]),
);
if (!cached && Object.keys(state.pos).length) setStore({ [key]: { raw, head } });
console.log("[immerse]", Object.keys(state.pos).length, "words tagged");
repaint();
}
// Our own translation of every sentence, numbered so each comes back to the line it belongs
// to. Batched: a 20-minute talk is ~300 sentences and the whole thing in one reply would brush
// the output ceiling; and a batch that fails leaves holes in 80 lines, not in all of them.
// Cached per video like the others — about five cents of Haiku per talk, paid once.
async function loadZh() {
const key = `zh2_${new URLSearchParams(location.search).get("v")}`;
const full = state.sentences.map((s) => s.text).join(" ");
const head = full.slice(0, 80);
const hit = (await getStore(key))[key];
let raw = hit?.head === head ? hit.raw : undefined;
if (raw === undefined) {
const BATCH = 80;
const parts = [];
for (let i = 0; i < state.sentences.length; i += BATCH) {
const text = state.sentences
.slice(i, i + BATCH)
.map((s, k) => `${i + k}\t${s.text}`)
.join("\n");
const res = await ask({ type: "zh", text });
if (!res || res.error) return console.warn("[immerse] zh failed:", res?.error ?? "no reply");
parts.push(res.text ?? "");
}
raw = parts.join("\n");
}
state.zh = parseZh(raw, state.sentences.length);
const got = state.zh.filter(Boolean).length;
if (hit?.raw !== raw && got) setStore({ [key]: { raw, head } });
console.log("[immerse]", got, "/", state.sentences.length, "sentences translated");
repaint();
}
// One call per video, not per sentence — the transcript goes over whole and comes back as a
// list. Cached per video so a page reload doesn't pay for it again.
async function loadPhrases() {
const videoId = new URLSearchParams(location.search).get("v");
// The prefix is the prompt version: bump it and every cached answer is re-asked, since a
// stored list from an older prompt is exactly as wrong as a stale one.
const key = `ph3_${videoId}`;
const full = state.sentences.map((s) => s.text).join(" ");
// A cache entry is only trusted if it was written for this exact transcript. SPA navigation
// leaves the previous video's timedtext URL on <html> for a tick, so a videoId alone is not
// proof the stored answer belongs to the text we are about to match against.
const head = full.slice(0, 80);
const hit = (await getStore(key))[key];
const cached = hit?.head === head ? hit.raw : undefined;
const res = cached !== undefined ? { text: cached } : await ask({ type: "phrases", text: full });
// Don't let an API failure turn into "0 phrases" — that looks identical to a working
// extension that simply found nothing, which is why this state is reported in the popup.
if (!res || res.error) {
state.phraseNote = `phrases failed: ${res?.error ?? "no reply from the worker"}`;
return console.warn("[immerse]", state.phraseNote);
}
const raw = res.text ?? "";
// Keep only expressions that really occur in this transcript. A hallucinated phrase simply
// won't match, which makes this line the entire verification step.
state.phrases = raw
.split("\n")
// Strip list markers the model adds despite being told not to; "- grew into" would never
// match the transcript and would be silently dropped.
.map((p) => p.trim().replace(/^[-*•–]+\s*|^\d+[.)]\s*/, "").trim())
.filter((p) => p.includes(" ") && indexOfWord(full, p) >= 0);
if (!cached && state.phrases.length) setStore({ [key]: { raw, head } });
state.phraseNote = state.phrases.length
? `${state.phrases.length} phrases: ${state.phrases.slice(0, 4).join(" / ")}`
: // Nothing matched: show both sides so a wrong-transcript case is obvious at a glance.
`0 matched | sent "${head.slice(0, 50)}…" | got ${raw.replace(/\n/g, " / ").slice(0, 90) || "(empty)"}`;
console.log("[immerse]", state.phraseNote, state.phrases);
repaint();
}
const idxAt = (list) => {
const t = video()?.currentTime ?? 0;
return list.findIndex((s) => t >= s.start && t < s.end);
};
const playing = () => idxAt(state.sentences);
// The caption on screen lags the clock by up to a cue, so trust the word over the timestamp.
function segFor(list, word) {
const k = idxAt(list);
if (k < 0) return null;
const re = bounded(word);
for (const j of [k, k - 1, k + 1]) {
if (list[j] && re.test(list[j].text)) return list[j];
}
return list[k];
}
const sentenceFor = (word) => segFor(state.sentences, word);
// A/S/D moves by CLAUSE, not sentence: an ASR sentence can run twenty seconds, and replaying
// all of it to hear one word again is dead time. Comma-level hops keep the loop tight.
function seek(delta) {
const v = video();
const list = state.clauses?.length ? state.clauses : state.sentences;
if (!v || !list.length) return;
const k = Math.max(0, idxAt(list));
const target = list[Math.min(list.length - 1, Math.max(0, k + delta))];
if (target) v.currentTime = target.start;
}
// S: the whole sentence again, from its head. A and D walk by clause; replaying by clause
// restarted at the nearest comma — the middle of the thought, which read as being stuck on a
// random word. Landing a hair before the head, because an interpolated start can sit ON the
// first word and clip it.
function replay() {
const v = video();
if (!v || !state.sentences.length) return;
const k = replayTarget(state.sentences, v.currentTime, state.replayed);
state.replayed = k;
v.currentTime = Math.max(0, state.sentences[k].start - REPLAY_LEAD);
}
// --- clickable caption words ----------------------------------------------------------------
function wrap(seg) {
const text = seg.textContent;
if (seg.dataset.imText === text) return;
seg.textContent = "";
seg.appendChild(tokenSpans(text));
seg.dataset.imText = seg.textContent;
}
// Colour each word by part of speech. A phrase chip stays a single click target but shows its
// verb and particle separately — seeing "grew" and "into" in different colours inside one box
// is the whole point of grouping it.
function posSpans(text) {
return text
.split(/(\s+)/)
.filter(Boolean)
.map((tok) => {
const w = tok.match(WORD);
if (!w) return document.createTextNode(tok);
const s = document.createElement("span");
const p = posOf(w[0], state.pos);
if (p) s.className = `im-${p}`;
s.textContent = tok;
return s;
});
}
function chip(display, word, isPhrase) {
const el = document.createElement("span");
const how = state.marks[word.toLowerCase()]; // marks are case-insensitive
// im-anchor re-applied on rebuild, same as the mark colours — the spans are ephemeral.
el.className = ["im-w", isPhrase && "im-phrase", how && `im-${how}`,
word === state.anchor && "im-anchor"].filter(Boolean).join(" ");
el.append(...posSpans(display)); // keeps the comma; dataset holds the clean word
el.dataset.imWord = word;
return el;
}
function tokenSpans(text) {
const frag = document.createDocumentFragment();
// Phrases the user circled and marked join the model-detected ones, so a learned expression
// keeps rendering as one boxed unit in every later video.
const phrases = state.phrases.concat(Object.keys(state.marks).filter((k) => k.includes(" ")));
// ponytail: a caption line can cut a phrase in half; that one just renders as separate
// words rather than being tracked across segments.
for (const run of splitPhrases(text, phrases)) {
if (run.phrase) {
frag.appendChild(chip(run.text, run.text, true));
continue;
}
for (const tok of run.text.split(/(\s+)/)) {
if (!tok) continue;
const w = tok.match(WORD);
if (!w) frag.appendChild(document.createTextNode(tok));
else frag.appendChild(chip(tok, w[0]));
}
}
return frag;
}
// wrap() skips a segment whose text it already handled, so a colour change needs the memo cleared.
function repaint() {
document.querySelectorAll(SEG).forEach((s) => {
delete s.dataset.imText;
wrap(s);
});
}
function tick() {
document.querySelectorAll(SEG).forEach(wrap);
paintZh();
}
function capture(el, phrase) {
const word = phrase ?? el.dataset.imWord;
const videoId = new URLSearchParams(location.search).get("v");
const s = sentenceFor(word);
// The card's timestamp anchors to the start of the CLAUSE holding the word, not the click
// moment: a click lands anywhere in a long ASR sentence, so 回到影片那一刻 used to drop you
// mid-sentence and the word only turned up near its end — or had already passed.
const c = segFor(state.clauses ?? [], word);
const t = +(c?.start ?? video()?.currentTime ?? 0).toFixed(2);
const item = { id: `${videoId}:${t}:${word}`, word, videoId, t, sentence: s?.text ?? null,
context: "", senses: [], done: false };
state.captures.push(item);
state.open = { item, el };
freeze(); // keyboard or programmatic clicks never went through the hover path
if (!s) {
Object.assign(item, { context: "(no transcript yet — turn captions on and reload)", done: true });
render();
return;
}
render();
// One request per (word, sentence), shared and cached: re-opening the card, or clicking the
// same word twice quickly, must not bill twice. Errors are evicted so a retry really retries.
const cacheKey = `${word}|${s.text}`;
const hit = state.explains.get(cacheKey);
const job =
hit ??
ask({ type: "explain", word, sentence: s.text }).then((r) => {
if (r?.text) return parseReply(r.text);
state.explains.delete(cacheKey);
return parseReply(r?.error ?? "(no reply)");
});
if (!hit) state.explains.set(cacheKey, job);
job.then((parsed) => {
Object.assign(item, parsed, { done: true });
// Marked before the reply landed: the stored row was saved empty, so write it back now
// that there is something worth reviewing.
const how = state.marks[item.word.toLowerCase()];
if (how) deck(item, how);
render();
});
}
// The deck is only ever changed by pressing 學習中 / 已掌握. Clicking a word is curiosity, not
// a commitment to memorise it — auto-saving every click filled the review queue with noise.
// Keyed on the lowercased word, so meeting the same word in a second video updates one entry
// rather than creating a duplicate card.
// Writes are chained one at a time: deck is a read-modify-write, and two in flight at once —
// a fresh mark racing the explain-reply backfill — silently lose the earlier one.
// Resolves either way: a rejected link would poison the chain and silently stop every later
// mark — the same failure mode that killed the immersion clock.
let deckChain = Promise.resolve();
const deck = (item, how) =>
(deckChain = deckChain.then(() =>
deckWrite(item, how).catch((e) => {
console.warn("[immerse] deck write failed", e);
return []; // callers read a word list; an empty one just means "no budget warning"
}),
));
async function deckWrite(item, how) {
const { words = [], deleted = {} } = await getStore(["words", "deleted"]);
const id = item.word.toLowerCase();
const at = words.findIndex((w) => w.id === id);
if (!how) {
// A tombstone, not just a splice: another device still holding the row would otherwise hand
// it straight back at the next merge. Re-marking the word later out-dates the stone.
if (at >= 0) words.splice(at, 1);
deleted[id] = Date.now();
} else {
const row = {
addedAt: Date.now(), // only set on first add; the spread below keeps an existing stamp
...(at >= 0 ? words[at] : {}), // keep whatever scheduling the card already has
id,
word: item.word,
context: item.context,
contextZh: item.contextZh,
senses: item.senses,
sentence: item.sentence,
zh: item.zh,
videoId: item.videoId,
title: document.title.replace(/ - YouTube$/, ""),
t: item.t,
suspended: how === "known", // 已掌握 stays in the library but off the review queue
knownAt: how === "known" ? Date.now() : undefined, // for the "mastered this week" count
updatedAt: Date.now(), // per-row stamp, so a future second writer (the app) can merge
};
if (at >= 0) words[at] = row;
else words.push(row);
}
await setStore({ words, deleted });
return words;
}
async function mark(item, how) {
if (!alive()) {
state.markNote = "擴充剛重新載入,請重新整理這個分頁後再標記";
return render();
}
const k = item.word.toLowerCase();
const clearing = state.marks[k] === how; // pressing the same button again removes it entirely
if (clearing) delete state.marks[k];
else state.marks[k] = how;
setStore({ marks: state.marks });
repaint();
render();
const words = await deck(item, clearing ? null : how);
const n = markRate(words, Date.now());
// A warning, not a block: it is your call, but an unclearable backlog is the failure mode.
state.markNote =
n > MARKS_PER_HOUR
? `近一小時已標記 ${n} 個學習中,建議 ≤ ${MARKS_PER_HOUR},標太多會複習不完`
: "";
render();
}
// --- transcript export ------------------------------------------------------------------------
// E copies the whole transcript to the clipboard — the same cues the extension already holds.
// With the Z line on, each sentence is paired with YouTube's own zh-Hant translation, so the
// export costs no API call either way. Plain text, no timestamps: it is for reading and for
// pasting into notes, not for re-subtitling.
// ms = 0 makes it a bubble that stays until its ✕ is clicked — for the messages someone must
// not miss just because they looked away for half a minute. Everything else fades on its own.
function toast(msg, ms = 2200) {
let el = document.getElementById("im-toast");
if (!el) {
el = document.createElement("div");
el.id = "im-toast";
}
// In fullscreen only the fullscreened element's subtree is painted, and the upload lands
// ~30s into a video — right when someone has gone fullscreen — so a body-mounted toast plays
// to an empty room. Mount into the fullscreen element then, body otherwise. NOT #movie_player:
// it is overflow:hidden, and a position:fixed toast lands at the viewport bottom, outside the
// player box, where the clip eats it — which is exactly why the bubble stopped showing.
// document.fullscreenElement is whatever YouTube fullscreened (usually the player), and fixed
// positioning resolves to the viewport inside it. Re-appending each call follows SPA nav.
(document.fullscreenElement ?? document.body).appendChild(el);
el.replaceChildren(document.createTextNode(msg));
clearTimeout(state.toastT);
// The sticky class carries pointer-events — the fading kind must stay click-through, and a
// faded-out sticky bubble must not keep blocking the video invisibly.
el.classList.toggle("im-stick", !ms);
if (ms) {
state.toastT = setTimeout(() => (el.style.opacity = "0"), ms);
} else {
const x = document.createElement("button");
x.textContent = "✕";
x.addEventListener("click", () => {
el.style.opacity = "0";
el.classList.remove("im-stick");
});
el.appendChild(x);
}
el.style.opacity = "1";
}
async function copyTranscript() {
if (!state.sentences.length) return toast("還沒有字幕——請先開啟 CC 字幕再按 E");
let lines = state.sentences.map((s) => s.text);
if (state.zhOn) {
if (!state.zhCues.length && state.trackUrl) {
state.zhCues = await fetchCues(state.trackUrl, "zh-Hant");
}
lines = state.sentences.map((s, i) => `${s.text}\n${state.zh[i] || zhFor(state.zhCues, s)}`);
}
const title = document.title.replace(/ - YouTube$/, "");
const vid = new URLSearchParams(location.search).get("v");
const text = `${title}\nhttps://youtu.be/${vid}\n\n${lines.join(state.zhOn ? "\n\n" : "\n")}`;
try {
await navigator.clipboard.writeText(text);
toast(`已複製完整字幕(${state.sentences.length} 句${state.zhOn ? ",中英對照" : ""})`);
} catch {
toast("複製失敗——請點一下頁面再按 E");
}
}
// --- Chinese line ---------------------------------------------------------------------------
async function toggleZh() {
state.zhOn = !state.zhOn;
setStore({ zhOn: state.zhOn });
if (state.zhOn && !state.zhCues.length && state.trackUrl) {
state.zhCues = await fetchCues(state.trackUrl, "zh-Hant");
}
paintZh();
}
// The line lives INSIDE YouTube's caption window, not at fixed viewport coordinates. The old
// approach measured the segments' rect and pinned a fixed div under it — but the segments are
// rebuilt several times a second, a mid-rebuild rect reads 0,0, and the line jumped to the
// top-left of the screen. As a child of the window it rides along with dragging, fullscreen
// and reflow for free, with no coordinate maths to go stale.
function paintZh() {
let el = document.getElementById("im-zh");
if (!el) {
el = document.createElement("div");
el.id = "im-zh";
document.body.appendChild(el); // reparented under the caption window as soon as one exists
}
const segs = document.querySelectorAll(SEG);
const s = state.sentences[playing()];
if (!segs.length) {
el.style.display = "none";
return;
}
let text;
if (state.translatedTrack) {
// The captions on screen are Chinese already — clicking them queries garbage, and a zh
// line under a zh track helps nobody. Say what to do instead of failing quietly.
text = "YouTube 字幕是自動翻譯軌——請切回英文原文,中文對照改按 Z 顯示";
} else if (state.zhOn && s) {
// Ours when it has arrived; YouTube's cue meanwhile, which is roughly right and better
// than a blank line for the seconds the translation takes.
text = state.zh[playing()] || zhFor(state.zhCues, s);
} else {
el.style.display = "none";
return;
}
// Both writes are guarded: our own MutationObserver watches this subtree, and an
// unconditional append/textContent every tick would be a mutation loop.
const win = segs[segs.length - 1].closest(".caption-window");
if (win && el.parentElement !== win) win.appendChild(el);
if (el.textContent !== text) el.textContent = text;
// Track the caption font so fullscreen scales the translation with the English above it.
el.style.fontSize = `${parseFloat(getComputedStyle(segs[0]).fontSize) * 0.72 || 18}px`;
el.style.display = text ? "block" : "none";
}
// --- popup ----------------------------------------------------------------------------------
function line(text, cls) {
const el = document.createElement("div");
el.className = cls;
el.textContent = text; // never innerHTML — this is API text
return el;
}
// Web Speech API: no key, no network, no cost — the teardown found zeroStudy uses the same.
const say = (text) =>
speechSynthesis.speak(Object.assign(new SpeechSynthesisUtterance(text), { lang: "en-US" }));
function head(word) {
const el = document.createElement("div");
el.className = "im-head";
el.append(word, " ");
const b = document.createElement("button");
b.textContent = "🔊";
b.title = "pronounce";
b.addEventListener("click", () => say(word));
el.appendChild(b);
return el;
}
function sense(s) {
const el = document.createElement("div");
el.className = "im-sense";
el.append(line(s.pos, "im-pos"), line(s.gloss, "im-gloss"));
if (s.example) el.appendChild(line(s.example, "im-eg"));
if (s.zh) el.appendChild(line(s.zh, "im-egzh"));
return el;
}
function buttons(item) {
const row = document.createElement("div");
row.className = "im-btns";
for (const [how, label] of [["learning", "學習中"], ["known", "已掌握"]]) {
const b = document.createElement("button");
b.textContent = label;
if (state.marks[item.word.toLowerCase()] === how) b.className = "on";
b.addEventListener("click", () => mark(item, how));
row.appendChild(b);
}
return row;
}
function render() {
let box = document.getElementById("im-pop");
if (!box) {
box = document.createElement("div");
box.id = "im-pop";
box.addEventListener("click", (e) => e.stopPropagation());
document.body.appendChild(box);
}
if (!state.open) {
box.style.display = "none";
return;
}
const { item, el } = state.open;
const sent = document.createElement("div");
sent.className = "im-sent";
sent.appendChild(tokenSpans(item.sentence ?? ""));
box.replaceChildren(
head(item.word),
// Chinese first — it is the line that unblocks you. English stays underneath as input.
line(item.done ? item.contextZh || item.context : "…", "im-ai"),
...(item.contextZh && item.context ? [line(item.context, "im-ai-en")] : []),
...item.senses.map(sense),
// The sentence renders as clickable word chips, exactly like the captions — reading the
// card IS the moment you notice the phrase, so circling has to work right here, not only
// down in the captions (which may even have moved on).
sent,
buttons(item),
...(state.markNote ? [line(state.markNote, "im-warn")] : []),
...(item.word.includes(" ") ? [] : [line("在下面例句按住滑鼠掃過幾個字:圈成片語一起學(字幕上用 ⇧+點兩端)", "im-note")]),
line(state.phraseNote ?? "…finding phrases", "im-note"),
);
box.style.display = "block";