hackertube-zh 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +169 -2
- package/background.js +140 -0
- package/bin/install.js +141 -0
- package/content/player-main.js +136 -0
- package/content/player.css +42 -0
- package/content/player.js +221 -0
- package/content/site.css +31 -0
- package/content/site.js +291 -0
- package/icons/icon-small.svg +35 -0
- package/icons/icon.svg +38 -0
- package/icons/icon128.png +0 -0
- package/icons/icon16.png +0 -0
- package/icons/icon32.png +0 -0
- package/icons/icon48.png +0 -0
- package/lib/cache.js +98 -0
- package/lib/embedlogin.js +32 -0
- package/lib/engines.js +193 -0
- package/lib/runtime.js +65 -0
- package/lib/subtitles.js +210 -0
- package/lib/textutil.js +40 -0
- package/lib/uidict.js +105 -0
- package/manifest.json +60 -0
- package/options/options.css +30 -0
- package/options/options.html +111 -0
- package/options/options.js +99 -0
- package/package.json +40 -4
- package/popup/popup.css +11 -0
- package/popup/popup.html +55 -0
- package/popup/popup.js +43 -0
- package/ui/common.css +41 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Isolated content script inside HackerTube's YouTube embed: receives the caption file from player-main.js,
|
|
2
|
+
// regroups it into sentences, translates them a few minutes ahead of playback and draws the Chinese subtitles
|
|
3
|
+
// over the video (inside the player element, so they also show in fullscreen).
|
|
4
|
+
(() => {
|
|
5
|
+
const H = globalThis.HTZ;
|
|
6
|
+
const S = H.subtitles, R = H.runtime;
|
|
7
|
+
|
|
8
|
+
const anc = location.ancestorOrigins;
|
|
9
|
+
const origins = anc ? Array.from({ length: anc.length }, (_, i) => anc[i]) : [document.referrer];
|
|
10
|
+
const fromHackerTube = origins.some((o) => { try { return /(^|\.)hackertube\.org$/.test(new URL(o).hostname); } catch { return false; } });
|
|
11
|
+
// HTZ_ALLOW_ANY_HOST is only ever set by the local test harness (dev/), which loads this file as a page script
|
|
12
|
+
if (!fromHackerTube && !globalThis.HTZ_ALLOW_ANY_HOST) return;
|
|
13
|
+
|
|
14
|
+
const LOOKAHEAD_MS = 5 * 60 * 1000; // translate up to five minutes ahead: cost follows what is actually watched
|
|
15
|
+
const MAX_INFLIGHT = 2;
|
|
16
|
+
|
|
17
|
+
let settings = { ...R.DEFAULTS };
|
|
18
|
+
let vid = null, title = "";
|
|
19
|
+
let sentences = [], zh = [], st = []; // st: 0 = not sent, 1 = in flight, 2 = done
|
|
20
|
+
let gen = 0, inflight = 0, failures = 0, doneCount = 0;
|
|
21
|
+
let retryAt = 0; // after a failed request nothing is sent before this time
|
|
22
|
+
|
|
23
|
+
const post = (type, data = {}) => window.postMessage({ source: "htz-iso", type, ...data }, "*");
|
|
24
|
+
const sendConfig = () => post("config", { enabled: settings.subMode !== "off" });
|
|
25
|
+
const player = () => document.getElementById("movie_player");
|
|
26
|
+
const video = () => document.querySelector("#movie_player video") || document.querySelector("video");
|
|
27
|
+
|
|
28
|
+
// ---------- overlay ----------
|
|
29
|
+
let overlay, zhLine, enLine, badge, badgeTimer, shown = "";
|
|
30
|
+
const ro = new ResizeObserver((entries) => {
|
|
31
|
+
for (const e of entries) e.target.style.setProperty("--htz-h", `${Math.round(e.contentRect.height)}px`);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function ensureOverlay() {
|
|
35
|
+
const p = player();
|
|
36
|
+
if (!p) return false;
|
|
37
|
+
if (overlay && overlay.isConnected && overlay.parentElement === p) return true;
|
|
38
|
+
overlay = document.createElement("div");
|
|
39
|
+
overlay.className = "htz-sub";
|
|
40
|
+
overlay.setAttribute("aria-hidden", "true");
|
|
41
|
+
zhLine = document.createElement("div"); zhLine.className = "htz-line htz-zh"; zhLine.lang = "zh-CN";
|
|
42
|
+
enLine = document.createElement("div"); enLine.className = "htz-line htz-en";
|
|
43
|
+
overlay.append(zhLine, enLine);
|
|
44
|
+
badge = document.createElement("div"); badge.className = "htz-badge";
|
|
45
|
+
p.append(overlay, badge);
|
|
46
|
+
ro.observe(p);
|
|
47
|
+
applyStyle();
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function applyStyle() {
|
|
52
|
+
const p = player();
|
|
53
|
+
if (!p) return;
|
|
54
|
+
p.style.setProperty("--htz-scale", String(settings.subScale || 1));
|
|
55
|
+
p.style.setProperty("--htz-bottom", String(settings.subBottom ?? 8));
|
|
56
|
+
p.classList.toggle("htz-style-box", settings.subStyle === "box");
|
|
57
|
+
// our lines include the English, so YouTube's own captions would only duplicate them
|
|
58
|
+
p.classList.toggle("htz-hide-native", settings.subMode !== "off" && sentences.length > 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function say(msg, { ms = 4000, error = false } = {}) {
|
|
62
|
+
if (!ensureOverlay()) return;
|
|
63
|
+
clearTimeout(badgeTimer);
|
|
64
|
+
badge.textContent = msg;
|
|
65
|
+
badge.classList.toggle("err", error);
|
|
66
|
+
badge.classList.add("on");
|
|
67
|
+
if (ms) badgeTimer = setTimeout(() => badge.classList.remove("on"), ms);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function render() {
|
|
71
|
+
if (!overlay || !overlay.isConnected) { if (!sentences.length || !ensureOverlay()) return; }
|
|
72
|
+
const v = video();
|
|
73
|
+
const i = v && sentences.length && settings.subMode !== "off" ? S.findActive(sentences, v.currentTime * 1000) : -1;
|
|
74
|
+
let z = "", e = "";
|
|
75
|
+
if (i >= 0) {
|
|
76
|
+
const en = sentences[i].text, tr = zh[i];
|
|
77
|
+
if (settings.subMode === "zh") z = tr || en;
|
|
78
|
+
else { z = tr || ""; e = en; }
|
|
79
|
+
}
|
|
80
|
+
const key = `${z}\u0001${e}`;
|
|
81
|
+
if (key === shown) return;
|
|
82
|
+
shown = key;
|
|
83
|
+
zhLine.textContent = z;
|
|
84
|
+
enLine.textContent = e;
|
|
85
|
+
overlay.classList.toggle("on", !!(z || e));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let raf = 0;
|
|
89
|
+
function loop() {
|
|
90
|
+
raf = 0;
|
|
91
|
+
render();
|
|
92
|
+
const v = video();
|
|
93
|
+
if (v && !v.paused) raf = requestAnimationFrame(loop);
|
|
94
|
+
}
|
|
95
|
+
const kick = () => { if (!raf) raf = requestAnimationFrame(loop); };
|
|
96
|
+
|
|
97
|
+
// ---------- translation scheduling ----------
|
|
98
|
+
function progress() {
|
|
99
|
+
if (!sentences.length) return;
|
|
100
|
+
const total = sentences.length;
|
|
101
|
+
if (doneCount < total && inflight) say(`中文字幕 · 翻译中 ${doneCount}/${total}`, { ms: 0 });
|
|
102
|
+
else if (badge && badge.textContent.startsWith("中文字幕 · 翻译中")) say(`中文字幕 · 已就绪 ${doneCount}/${total}`, { ms: 1500 });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function pump() {
|
|
106
|
+
if (settings.subMode === "off" || !sentences.length || Date.now() < retryAt) return;
|
|
107
|
+
const v = video();
|
|
108
|
+
const now = v ? v.currentTime * 1000 : 0;
|
|
109
|
+
while (inflight < MAX_INFLIGHT) {
|
|
110
|
+
let i = S.firstFrom(sentences, now - 2000);
|
|
111
|
+
while (i < sentences.length && st[i] !== 0) i++;
|
|
112
|
+
if (i >= sentences.length || sentences[i].start > now + LOOKAHEAD_MS) break;
|
|
113
|
+
// first request is small so the first subtitle appears within a second or two
|
|
114
|
+
const size = doneCount === 0 && inflight === 0 ? 6 : 25;
|
|
115
|
+
const idx = [];
|
|
116
|
+
for (let j = i; j < sentences.length && idx.length < size && st[j] === 0; j++) idx.push(j);
|
|
117
|
+
idx.forEach((k) => { st[k] = 1; });
|
|
118
|
+
inflight++;
|
|
119
|
+
const myGen = gen;
|
|
120
|
+
const previous = sentences.slice(Math.max(0, i - 3), i).map((s) => s.text);
|
|
121
|
+
R.translate(idx.map((k) => sentences[k].text), { kind: "subtitle", context: { title, previous } })
|
|
122
|
+
.then((res) => {
|
|
123
|
+
if (myGen !== gen) return;
|
|
124
|
+
failures = 0;
|
|
125
|
+
idx.forEach((k, n) => { zh[k] = res.translations[n]; st[k] = 2; });
|
|
126
|
+
doneCount += idx.length;
|
|
127
|
+
if (res.warning) say(res.warning, { error: true, ms: 6000 });
|
|
128
|
+
})
|
|
129
|
+
.catch((err) => {
|
|
130
|
+
if (myGen !== gen) return;
|
|
131
|
+
failures++;
|
|
132
|
+
idx.forEach((k) => { st[k] = 0; });
|
|
133
|
+
say(`字幕翻译失败:${err.message}`, { error: true, ms: 6000 });
|
|
134
|
+
// never give up for good, the network may come back mid-video: wait longer each time, up to 30 s
|
|
135
|
+
const wait = Math.min(2500 * failures, 30000);
|
|
136
|
+
retryAt = Date.now() + wait;
|
|
137
|
+
setTimeout(pump, wait + 50);
|
|
138
|
+
})
|
|
139
|
+
.finally(() => {
|
|
140
|
+
if (myGen !== gen) return;
|
|
141
|
+
inflight--;
|
|
142
|
+
shown = "";
|
|
143
|
+
render();
|
|
144
|
+
progress();
|
|
145
|
+
pump();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
progress();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function reset(newVid) {
|
|
152
|
+
gen++;
|
|
153
|
+
vid = newVid;
|
|
154
|
+
sentences = []; zh = []; st = [];
|
|
155
|
+
inflight = 0; failures = 0; doneCount = 0; shown = ""; retryAt = 0;
|
|
156
|
+
if (overlay) { zhLine.textContent = ""; enLine.textContent = ""; overlay.classList.remove("on"); }
|
|
157
|
+
applyStyle();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function onCaptions(msg) {
|
|
161
|
+
if (msg.vid && msg.vid !== vid) reset(msg.vid);
|
|
162
|
+
if (msg.title) title = msg.title;
|
|
163
|
+
const list = S.buildSentences(S.parseTimedText(msg.body));
|
|
164
|
+
if (!list.length) return;
|
|
165
|
+
// a second copy of the same track (YouTube re-requests on seek or quality change) changes nothing
|
|
166
|
+
if (sentences.length === list.length && sentences.every((s, i) => s.text === list[i].text)) return;
|
|
167
|
+
reset(vid);
|
|
168
|
+
sentences = list;
|
|
169
|
+
zh = new Array(list.length);
|
|
170
|
+
st = new Array(list.length).fill(0);
|
|
171
|
+
ensureOverlay();
|
|
172
|
+
applyStyle();
|
|
173
|
+
if (settings.subMode !== "off") say(`中文字幕 · 已获取 ${list.length} 句${msg.kind === "asr" ? "(自动生成字幕)" : ""}`, { ms: 0 });
|
|
174
|
+
pump();
|
|
175
|
+
kick();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------- wiring ----------
|
|
179
|
+
window.addEventListener("message", (e) => {
|
|
180
|
+
if (e.source !== window || !e.data || e.data.source !== "htz-main") return;
|
|
181
|
+
const m = e.data;
|
|
182
|
+
if (m.type === "hello") sendConfig();
|
|
183
|
+
else if (m.type === "video") { if (m.vid !== vid) reset(m.vid); title = m.title || title; }
|
|
184
|
+
else if (m.type === "captions" && typeof m.body === "string") onCaptions(m);
|
|
185
|
+
else if (m.type === "nocaptions" && m.vid === vid && settings.subMode !== "off") say("这个视频没有字幕,无法生成中文字幕", { ms: 6000 });
|
|
186
|
+
else if (m.type === "captionsfailed" && m.vid === vid && !sentences.length && settings.subMode !== "off") {
|
|
187
|
+
say("没拿到字幕:点一下播放器右下角的 CC 按钮即可重试", { ms: 8000 });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// media events don't bubble, but a capturing listener on the document still sees them
|
|
192
|
+
let lastPump = 0;
|
|
193
|
+
document.addEventListener("play", kick, true);
|
|
194
|
+
// a seek or a changed setting is the viewer asking again: retry now instead of waiting out the pause
|
|
195
|
+
document.addEventListener("seeked", () => { shown = ""; retryAt = 0; render(); pump(); }, true);
|
|
196
|
+
document.addEventListener("pause", render, true);
|
|
197
|
+
document.addEventListener("timeupdate", () => {
|
|
198
|
+
render();
|
|
199
|
+
if (Date.now() - lastPump > 3000) { lastPump = Date.now(); pump(); }
|
|
200
|
+
}, true);
|
|
201
|
+
|
|
202
|
+
R.loadSettings().then((s) => { settings = s; sendConfig(); applyStyle(); });
|
|
203
|
+
R.onSettings((s) => {
|
|
204
|
+
const prev = settings;
|
|
205
|
+
settings = s;
|
|
206
|
+
sendConfig();
|
|
207
|
+
applyStyle();
|
|
208
|
+
const engineChanged = ["engine", "claudeModel", "openaiBaseUrl", "openaiModel"].some((k) => s[k] !== prev[k]);
|
|
209
|
+
if (engineChanged && sentences.length) {
|
|
210
|
+
const keep = sentences;
|
|
211
|
+
reset(vid);
|
|
212
|
+
sentences = keep;
|
|
213
|
+
zh = new Array(keep.length);
|
|
214
|
+
st = new Array(keep.length).fill(0);
|
|
215
|
+
}
|
|
216
|
+
shown = "";
|
|
217
|
+
retryAt = 0;
|
|
218
|
+
render();
|
|
219
|
+
pump();
|
|
220
|
+
});
|
|
221
|
+
})();
|
package/content/site.css
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/* Bilingual: the original and its translation sit together as one pair, then the site's usual spacing. */
|
|
2
|
+
html[data-htz-mode="bilingual"] .htz-src { margin-bottom: 2px !important; }
|
|
3
|
+
html[data-htz-mode="bilingual"] .htz-tr { margin-top: 0 !important; }
|
|
4
|
+
html[data-htz-mode="bilingual"] .htz-tr.vt,
|
|
5
|
+
html[data-htz-mode="bilingual"] h1.htz-tr { opacity: .92; }
|
|
6
|
+
|
|
7
|
+
/* Chinese only: hide the original, the translated copy already carries the original's classes */
|
|
8
|
+
html[data-htz-mode="zh"] .htz-src { display: none !important; }
|
|
9
|
+
|
|
10
|
+
.htz-pair { min-width: 0; }
|
|
11
|
+
.htz-inline { margin-left: .4em; opacity: .75; }
|
|
12
|
+
|
|
13
|
+
#htz-status {
|
|
14
|
+
position: fixed; right: 16px; bottom: 16px; z-index: 2147483000;
|
|
15
|
+
max-width: min(420px, calc(100vw - 32px)); padding: 6px 12px; border-radius: 999px;
|
|
16
|
+
background: var(--chip-on, #0f0f0f); color: var(--chip-on-text, #fff);
|
|
17
|
+
font: 13px/1.4 -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
18
|
+
box-shadow: 0 2px 10px rgba(0,0,0,.2); pointer-events: none;
|
|
19
|
+
opacity: 0; transform: translateY(6px); transition: opacity .2s, transform .2s;
|
|
20
|
+
}
|
|
21
|
+
#htz-status.on { opacity: .92; transform: none; }
|
|
22
|
+
#htz-status.err { background: #c62828; color: #fff; border-radius: 12px; }
|
|
23
|
+
|
|
24
|
+
#htz-toast {
|
|
25
|
+
position: fixed; left: 50%; bottom: 24px; z-index: 2147483000; transform: translate(-50%, 8px);
|
|
26
|
+
max-width: calc(100vw - 32px); padding: 10px 16px; border-radius: 8px;
|
|
27
|
+
background: var(--chip-on, #0f0f0f); color: var(--chip-on-text, #fff);
|
|
28
|
+
font: 14px/1.4 -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
29
|
+
opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s;
|
|
30
|
+
}
|
|
31
|
+
#htz-toast.on { opacity: 1; transform: translate(-50%, 0); }
|
package/content/site.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// HackerTube pages: localize the interface instantly, translate titles / summaries / comments as they scroll
|
|
2
|
+
// into view, and render the Chinese right under the original (or instead of it). HackerTube is a single-page
|
|
3
|
+
// app that re-renders with innerHTML, so everything is driven by a MutationObserver.
|
|
4
|
+
(() => {
|
|
5
|
+
const H = globalThis.HTZ;
|
|
6
|
+
const T = H.text, UI = H.uidict, R = H.runtime;
|
|
7
|
+
|
|
8
|
+
// Content written per video goes to the translation engine. "block": the translation is a copy of the element
|
|
9
|
+
// (same tag and classes, so it looks native) inserted right after it. "inline": a short label inside a link
|
|
10
|
+
// or chip, translated in place. "dict": try the local UI dictionary first (page headings like "Settings").
|
|
11
|
+
const RULES = [
|
|
12
|
+
{ sel: ".vt", kind: "block" },
|
|
13
|
+
{ sel: ".watch h1", kind: "block" },
|
|
14
|
+
{ sel: ".snip", kind: "block" },
|
|
15
|
+
{ sel: ".desc > p", kind: "block" },
|
|
16
|
+
{ sel: ".cmt .body > p", kind: "block" },
|
|
17
|
+
{ sel: ".plnote", kind: "block" },
|
|
18
|
+
{ sel: ".plinfo h2", kind: "block" },
|
|
19
|
+
{ sel: ".plinfo .intro", kind: "block" },
|
|
20
|
+
{ sel: ".plcard p.sub", kind: "block" },
|
|
21
|
+
{ sel: ".pagehead h2", kind: "block", dict: true },
|
|
22
|
+
{ sel: ".setting b", kind: "block" },
|
|
23
|
+
{ sel: ".setting p", kind: "block" },
|
|
24
|
+
{ sel: ".about .lede", kind: "block" },
|
|
25
|
+
{ sel: ".about > h2", kind: "block" },
|
|
26
|
+
{ sel: ".about > p", kind: "block" },
|
|
27
|
+
{ sel: ".about li", kind: "block" },
|
|
28
|
+
{ sel: ".areaname", kind: "inline" },
|
|
29
|
+
{ sel: ".cats a", kind: "inline" },
|
|
30
|
+
{ sel: ".catwrap .chip", kind: "inline" },
|
|
31
|
+
{ sel: ".tag.cat", kind: "inline" },
|
|
32
|
+
];
|
|
33
|
+
const CONTENT_SEL = RULES.map((r) => r.sel).join(",");
|
|
34
|
+
const OURS = ".htz-tr, .htz-inline, .htz-pair, #htz-status, #htz-toast";
|
|
35
|
+
// never touched: our own nodes, code, the copyable agent prompt, hashtags, people's and channels' names
|
|
36
|
+
const SKIP_SEL = `${OURS}, script, style, pre, code, textarea, #agent-prompt, .tag:not(.cat), .chan, .who b, .cmt .meta b, .count, .dur`;
|
|
37
|
+
|
|
38
|
+
let settings = { ...R.DEFAULTS };
|
|
39
|
+
let mode = "off";
|
|
40
|
+
const memo = new Map(); // source text -> translation, for this page's lifetime
|
|
41
|
+
const waiting = new Map(); // source text -> elements waiting for it
|
|
42
|
+
const inflight = new Set(); // source texts currently being translated
|
|
43
|
+
let failures = 0; // requests failed in a row: sets the pause before the next retry
|
|
44
|
+
const replaced = new Map(); // Text node -> original text, so "off" can restore the page exactly
|
|
45
|
+
let handled = new WeakSet();
|
|
46
|
+
|
|
47
|
+
// ---------- status pill + toast ----------
|
|
48
|
+
const status = (() => {
|
|
49
|
+
let el, timer;
|
|
50
|
+
return (msg, { error = false, ms = 0 } = {}) => {
|
|
51
|
+
if (!el) { el = document.createElement("div"); el.id = "htz-status"; document.body.append(el); }
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
el.textContent = msg || "";
|
|
54
|
+
el.classList.toggle("on", !!msg);
|
|
55
|
+
el.classList.toggle("err", error);
|
|
56
|
+
if (msg && ms) timer = setTimeout(() => el.classList.remove("on"), ms);
|
|
57
|
+
};
|
|
58
|
+
})();
|
|
59
|
+
function toast(msg) {
|
|
60
|
+
let el = document.getElementById("htz-toast");
|
|
61
|
+
if (!el) { el = document.createElement("div"); el.id = "htz-toast"; document.body.append(el); }
|
|
62
|
+
el.textContent = msg;
|
|
63
|
+
el.classList.add("on");
|
|
64
|
+
clearTimeout(toast.t);
|
|
65
|
+
toast.t = setTimeout(() => el.classList.remove("on"), 3000);
|
|
66
|
+
}
|
|
67
|
+
function showProgress() {
|
|
68
|
+
if (inflight.size) status(`翻译中 · ${inflight.size} 条`);
|
|
69
|
+
else status("");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------- text replacement that can be undone ----------
|
|
73
|
+
function replaceText(node, zh) {
|
|
74
|
+
if (!replaced.has(node)) replaced.set(node, node.data);
|
|
75
|
+
const orig = replaced.get(node);
|
|
76
|
+
const lead = orig.match(/^\s*/)[0], trail = orig.match(/\s*$/)[0];
|
|
77
|
+
node.data = lead + zh + trail;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isChannelLine(el) {
|
|
81
|
+
// cards: <h3 class="vt">title</h3><div class="sub">channel</div><div class="sub">views • date</div>
|
|
82
|
+
if (!el.classList.contains("sub") || !el.closest(".vcard, .next")) return false;
|
|
83
|
+
const prev = el.previousElementSibling;
|
|
84
|
+
return !!prev && prev.classList.contains("vt");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Interface strings: every text node outside content elements that the dictionary knows.
|
|
88
|
+
function localizeUi(root) {
|
|
89
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
90
|
+
acceptNode(n) {
|
|
91
|
+
const p = n.parentElement;
|
|
92
|
+
if (!p || !n.data.trim()) return NodeFilter.FILTER_REJECT;
|
|
93
|
+
if (p.closest(SKIP_SEL)) return NodeFilter.FILTER_REJECT;
|
|
94
|
+
// the "▶ this video" / "link" pills inside comments are interface, though they sit in content
|
|
95
|
+
if (p.matches(".mk")) return NodeFilter.FILTER_ACCEPT;
|
|
96
|
+
if (p.closest(CONTENT_SEL) || isChannelLine(p)) return NodeFilter.FILTER_REJECT;
|
|
97
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
const nodes = [];
|
|
101
|
+
while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
102
|
+
for (const n of nodes) {
|
|
103
|
+
if (replaced.has(n)) continue;
|
|
104
|
+
const zh = UI.localize(n.data);
|
|
105
|
+
if (zh) replaceText(n, zh);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------- content translation ----------
|
|
110
|
+
const ruleFor = (el) => RULES.find((r) => el.matches(r.sel));
|
|
111
|
+
const firstText = (el) => [...el.childNodes].find((n) => n.nodeType === 3 && n.data.trim());
|
|
112
|
+
|
|
113
|
+
function register(el) {
|
|
114
|
+
if (handled.has(el)) return;
|
|
115
|
+
handled.add(el);
|
|
116
|
+
const rule = ruleFor(el);
|
|
117
|
+
if (!rule) return;
|
|
118
|
+
const node = rule.kind === "inline" ? firstText(el) : null;
|
|
119
|
+
const text = T.normalize(rule.kind === "inline" ? node && node.data : el.textContent);
|
|
120
|
+
if (!text) return;
|
|
121
|
+
if (rule.dict) {
|
|
122
|
+
const zh = UI.localize(text);
|
|
123
|
+
if (zh) { const n = firstText(el); if (n) replaceText(n, zh); return; }
|
|
124
|
+
}
|
|
125
|
+
if (!T.needsTranslation(text) || T.hasCJK(text)) return;
|
|
126
|
+
el.__htz = { rule, node, text };
|
|
127
|
+
if (memo.has(text)) render(el);
|
|
128
|
+
else io.observe(el);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Only what is on (or near) the screen gets translated: the home feed scrolls forever.
|
|
132
|
+
const io = new IntersectionObserver((entries) => {
|
|
133
|
+
for (const e of entries) {
|
|
134
|
+
if (!e.isIntersecting) continue;
|
|
135
|
+
io.unobserve(e.target);
|
|
136
|
+
enqueue(e.target);
|
|
137
|
+
}
|
|
138
|
+
}, { rootMargin: "800px 0px" });
|
|
139
|
+
|
|
140
|
+
function enqueue(el) {
|
|
141
|
+
const { text } = el.__htz;
|
|
142
|
+
if (memo.has(text)) return render(el);
|
|
143
|
+
if (!waiting.has(text)) waiting.set(text, []);
|
|
144
|
+
waiting.get(text).push(el);
|
|
145
|
+
clearTimeout(enqueue.t);
|
|
146
|
+
enqueue.t = setTimeout(flush, 60);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function flush() {
|
|
150
|
+
const texts = [...waiting.keys()].filter((t) => !inflight.has(t));
|
|
151
|
+
if (!texts.length) return;
|
|
152
|
+
for (const idx of T.chunk(texts, { maxItems: 40, maxChars: 5000 })) send(idx.map((i) => texts[i]));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function send(texts) {
|
|
156
|
+
texts.forEach((t) => inflight.add(t));
|
|
157
|
+
showProgress();
|
|
158
|
+
const myMode = mode;
|
|
159
|
+
try {
|
|
160
|
+
const res = await R.translate(texts, { kind: "page" });
|
|
161
|
+
if (mode !== myMode) return;
|
|
162
|
+
failures = 0;
|
|
163
|
+
texts.forEach((t, i) => memo.set(t, res.translations[i]));
|
|
164
|
+
for (const t of texts) for (const el of waiting.get(t) || []) if (el.isConnected) render(el);
|
|
165
|
+
if (res.warning) status(res.warning, { error: true, ms: 6000 });
|
|
166
|
+
} catch (e) {
|
|
167
|
+
status(`翻译失败:${e.message}`, { error: true, ms: 6000 });
|
|
168
|
+
// hand the elements back to the viewport observer after a pause (longer each time, up to 30 s): whatever
|
|
169
|
+
// is still near the screen then is asked for again, so the page recovers once the network or engine does
|
|
170
|
+
const els = texts.flatMap((t) => waiting.get(t) || []);
|
|
171
|
+
setTimeout(() => {
|
|
172
|
+
if (mode === myMode) for (const el of els) if (el.isConnected) io.observe(el);
|
|
173
|
+
}, Math.min(3000 * ++failures, 30000));
|
|
174
|
+
} finally {
|
|
175
|
+
texts.forEach((t) => { inflight.delete(t); waiting.delete(t); });
|
|
176
|
+
if (!document.querySelector("#htz-status.err.on")) showProgress();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function render(el) {
|
|
181
|
+
if (mode === "off" || !el.__htz) return;
|
|
182
|
+
const { rule, node, text } = el.__htz;
|
|
183
|
+
const zh = memo.get(text);
|
|
184
|
+
if (!zh || T.normalize(zh) === text) return;
|
|
185
|
+
if (rule.kind === "inline") {
|
|
186
|
+
if (!node || !node.isConnected) return;
|
|
187
|
+
if (mode === "zh") return replaceText(node, zh);
|
|
188
|
+
if (node.parentElement && node.parentElement.classList.contains("htz-pair")) return;
|
|
189
|
+
// these labels sit in flex rows (name ... count): wrapping original + translation in one span keeps them
|
|
190
|
+
// a single flex item that flows as text, instead of two squeezed columns
|
|
191
|
+
const pair = document.createElement("span");
|
|
192
|
+
pair.className = "htz-pair";
|
|
193
|
+
const s = document.createElement("span");
|
|
194
|
+
s.className = "htz-inline";
|
|
195
|
+
s.lang = "zh-CN";
|
|
196
|
+
s.textContent = zh;
|
|
197
|
+
node.before(pair);
|
|
198
|
+
pair.append(node, s);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (el.nextElementSibling && el.nextElementSibling.classList.contains("htz-tr")) return;
|
|
202
|
+
// a copy with the same tag and classes picks up the site's own styling (title size, muted colour, clamp)
|
|
203
|
+
const tr = document.createElement(el.tagName === "LI" ? "div" : el.tagName);
|
|
204
|
+
tr.className = `${el.className} htz-tr`.trim();
|
|
205
|
+
tr.lang = "zh-CN";
|
|
206
|
+
tr.textContent = zh;
|
|
207
|
+
el.classList.add("htz-src");
|
|
208
|
+
el.after(tr);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------- scanning ----------
|
|
212
|
+
function process(root) {
|
|
213
|
+
if (mode === "off" || !(root instanceof Element) || root.closest(OURS)) return;
|
|
214
|
+
const host = root.parentElement && root.parentElement.closest(CONTENT_SEL);
|
|
215
|
+
if (host) return register(host);
|
|
216
|
+
if (root.matches(CONTENT_SEL)) register(root);
|
|
217
|
+
root.querySelectorAll(CONTENT_SEL).forEach(register);
|
|
218
|
+
localizeUi(root);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const mo = new MutationObserver((muts) => {
|
|
222
|
+
if (mode === "off") return;
|
|
223
|
+
for (const m of muts) {
|
|
224
|
+
for (const n of m.addedNodes) {
|
|
225
|
+
if (n.nodeType === 1) process(n);
|
|
226
|
+
else if (n.nodeType === 3 && n.parentElement) process(n.parentElement);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (replaced.size > 3000) for (const n of replaced.keys()) if (!n.isConnected) replaced.delete(n);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
function revertAll() {
|
|
233
|
+
for (const [n, orig] of replaced) if (n.isConnected) n.data = orig;
|
|
234
|
+
replaced.clear();
|
|
235
|
+
document.querySelectorAll(".htz-pair").forEach((p) => p.replaceWith(...[...p.childNodes].filter((n) => n.nodeType === 3)));
|
|
236
|
+
document.querySelectorAll(".htz-tr, .htz-inline").forEach((n) => n.remove());
|
|
237
|
+
document.querySelectorAll(".htz-src").forEach((n) => n.classList.remove("htz-src"));
|
|
238
|
+
io.disconnect();
|
|
239
|
+
waiting.clear();
|
|
240
|
+
handled = new WeakSet();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function applyMode(next) {
|
|
244
|
+
revertAll();
|
|
245
|
+
mode = next;
|
|
246
|
+
document.documentElement.dataset.htzMode = mode;
|
|
247
|
+
const q = document.getElementById("q");
|
|
248
|
+
if (q) q.placeholder = mode === "off" ? "Search" : settings.searchTranslate ? "搜索(可直接输入中文)" : "搜索";
|
|
249
|
+
if (mode !== "off") process(document.body);
|
|
250
|
+
else status("");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---------- Chinese search: translate the query to English, then let the site search as usual ----------
|
|
254
|
+
// The site's semantic search embeds English text; a Chinese query finds little. We intercept the submit in the
|
|
255
|
+
// capture phase, swap the input's value for the English, and resubmit so the site's own handler runs.
|
|
256
|
+
let bypass = false;
|
|
257
|
+
document.addEventListener("submit", async (e) => {
|
|
258
|
+
const form = e.target;
|
|
259
|
+
if (bypass || mode === "off" || !settings.searchTranslate || !(form instanceof HTMLFormElement) || form.id !== "search") return;
|
|
260
|
+
const q = form.querySelector("#q");
|
|
261
|
+
const query = q && q.value.trim();
|
|
262
|
+
if (!query || !T.hasCJK(query)) return;
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
e.stopImmediatePropagation();
|
|
265
|
+
status("正在把搜索词翻译成英文…");
|
|
266
|
+
try {
|
|
267
|
+
const res = await R.translate([query], { kind: "query", to: "en" });
|
|
268
|
+
const en = T.normalize(res.translations[0]);
|
|
269
|
+
if (en) { q.value = en; toast(`已将「${query}」译为 “${en}” 进行搜索`); }
|
|
270
|
+
status("");
|
|
271
|
+
} catch (err) {
|
|
272
|
+
status(`搜索词翻译失败,按原文搜索:${err.message}`, { error: true, ms: 5000 });
|
|
273
|
+
}
|
|
274
|
+
bypass = true;
|
|
275
|
+
try { form.requestSubmit(); } finally { bypass = false; }
|
|
276
|
+
}, true);
|
|
277
|
+
|
|
278
|
+
// ---------- start ----------
|
|
279
|
+
R.loadSettings().then((s) => {
|
|
280
|
+
settings = s;
|
|
281
|
+
applyMode(s.pageMode);
|
|
282
|
+
mo.observe(document.body, { childList: true, subtree: true });
|
|
283
|
+
});
|
|
284
|
+
R.onSettings((s) => {
|
|
285
|
+
const prev = settings;
|
|
286
|
+
settings = s;
|
|
287
|
+
const engineChanged = ["engine", "claudeModel", "openaiBaseUrl", "openaiModel"].some((k) => s[k] !== prev[k]);
|
|
288
|
+
if (engineChanged) memo.clear();
|
|
289
|
+
if (engineChanged || s.pageMode !== prev.pageMode || s.searchTranslate !== prev.searchTranslate) applyMode(s.pageMode);
|
|
290
|
+
});
|
|
291
|
+
})();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
|
2
|
+
<!-- 16 / 32 px toolbar art: the same play button and puzzle piece as icon.svg, filling more of the square, with a
|
|
3
|
+
wider gap. The piece gets a second knob on the right: with only the top knob it reads as a padlock when tiny. -->
|
|
4
|
+
<defs>
|
|
5
|
+
<linearGradient id="play" x1="0" y1="0" x2="1" y2="1">
|
|
6
|
+
<stop offset="0" stop-color="#FF6A33"/>
|
|
7
|
+
<stop offset="1" stop-color="#FC4C1B"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
<linearGradient id="piece" x1="0" y1="0" x2="0" y2="1">
|
|
10
|
+
<stop offset="0" stop-color="#FF961F"/>
|
|
11
|
+
<stop offset="1" stop-color="#FF7E14"/>
|
|
12
|
+
</linearGradient>
|
|
13
|
+
<mask id="gap">
|
|
14
|
+
<rect width="128" height="128" fill="#fff"/>
|
|
15
|
+
<g fill="#000" stroke="#000" stroke-width="14" stroke-linejoin="round">
|
|
16
|
+
<rect x="66" y="74" width="46" height="46" rx="7"/>
|
|
17
|
+
<circle cx="89" cy="63" r="11"/>
|
|
18
|
+
<circle cx="116" cy="97" r="9.5"/>
|
|
19
|
+
</g>
|
|
20
|
+
</mask>
|
|
21
|
+
</defs>
|
|
22
|
+
|
|
23
|
+
<g mask="url(#gap)">
|
|
24
|
+
<rect x="2" y="10" width="116" height="94" rx="28" fill="url(#play)"/>
|
|
25
|
+
<path d="M34 36 L34 74 L64 55 Z" fill="#fff" stroke="#fff" stroke-width="11" stroke-linejoin="round"/>
|
|
26
|
+
</g>
|
|
27
|
+
|
|
28
|
+
<g fill="url(#piece)">
|
|
29
|
+
<rect x="66" y="74" width="46" height="46" rx="7"/>
|
|
30
|
+
<circle cx="89" cy="63" r="11"/>
|
|
31
|
+
<rect x="83" y="63" width="12" height="12"/>
|
|
32
|
+
<circle cx="116" cy="97" r="9.5"/>
|
|
33
|
+
<rect x="110" y="91" width="7" height="12"/>
|
|
34
|
+
</g>
|
|
35
|
+
</svg>
|
package/icons/icon.svg
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
|
2
|
+
<!-- HackerTube 中文插件: a video play button with a browser-extension puzzle piece, kept apart by a transparent
|
|
3
|
+
gap. Used for the 48 and 128 px icons and the README wordmark; icon-small.svg is the 16 / 32 px art. -->
|
|
4
|
+
<defs>
|
|
5
|
+
<linearGradient id="play" x1="0" y1="0" x2="1" y2="1">
|
|
6
|
+
<stop offset="0" stop-color="#FF6A33"/>
|
|
7
|
+
<stop offset="1" stop-color="#FC4C1B"/>
|
|
8
|
+
</linearGradient>
|
|
9
|
+
<linearGradient id="piece" x1="0" y1="0" x2="0" y2="1">
|
|
10
|
+
<stop offset="0" stop-color="#FF961F"/>
|
|
11
|
+
<stop offset="1" stop-color="#FF7E14"/>
|
|
12
|
+
</linearGradient>
|
|
13
|
+
<!-- the puzzle piece's footprint, grown by the gap, is cut out of the play button -->
|
|
14
|
+
<mask id="gap">
|
|
15
|
+
<rect width="128" height="128" fill="#fff"/>
|
|
16
|
+
<g fill="#000" stroke="#000" stroke-width="9" stroke-linejoin="round">
|
|
17
|
+
<rect x="75.5" y="71" width="44.5" height="40" rx="5"/>
|
|
18
|
+
<circle cx="99" cy="63.5" r="9.6"/>
|
|
19
|
+
</g>
|
|
20
|
+
</mask>
|
|
21
|
+
<!-- the socket on the piece's left side -->
|
|
22
|
+
<mask id="socket">
|
|
23
|
+
<rect width="128" height="128" fill="#fff"/>
|
|
24
|
+
<circle cx="76.5" cy="91" r="6.4" fill="#000"/>
|
|
25
|
+
</mask>
|
|
26
|
+
</defs>
|
|
27
|
+
|
|
28
|
+
<g mask="url(#gap)">
|
|
29
|
+
<rect x="8" y="17" width="106" height="83" rx="26" fill="url(#play)"/>
|
|
30
|
+
<path d="M49.5 43 L49.5 72 L76.5 57.5 Z" fill="#fff" stroke="#fff" stroke-width="9" stroke-linejoin="round"/>
|
|
31
|
+
</g>
|
|
32
|
+
|
|
33
|
+
<g fill="url(#piece)" mask="url(#socket)">
|
|
34
|
+
<rect x="75.5" y="71" width="44.5" height="40" rx="5"/>
|
|
35
|
+
<circle cx="99" cy="63.5" r="9.6"/>
|
|
36
|
+
<rect x="94" y="64" width="10" height="9"/>
|
|
37
|
+
</g>
|
|
38
|
+
</svg>
|
|
Binary file
|
package/icons/icon16.png
ADDED
|
Binary file
|
package/icons/icon32.png
ADDED
|
Binary file
|
package/icons/icon48.png
ADDED
|
Binary file
|