// ==UserScript== // @name VKvideo 实时双语字幕 // @version 1.1.1 // @description 俄网VK Video实时中俄双语字幕 — Yandex优先,支持上下文翻译 // @author zzzdawei // @match *://vk.com/* // @match *://*.vk.com/* // @match *://vk.ru/* // @match *://*.vk.ru/* // @match *://vkvideo.ru/* // @match *://*.vkvideo.ru/* // @grant GM.xmlHttpRequest // @grant GM_addStyle // @grant unsafeWindow // @connect * // @connect okcdn.ru // @connect vkuser.net // @connect vk-cdn.net // @connect vot.toil.cc // @run-at document-start // ==/UserScript== (function () { "use strict"; // ═══════════════════════════════════════════════════════════════════════ // 1. CONFIG // ═══════════════════════════════════════════════════════════════════════ var TRANSLATE_TO = "zh"; var TRANSLATE_ENABLED = true; // ═══════════════════════════════════════════════════════════════════════ // 2. UTILS // ═══════════════════════════════════════════════════════════════════════ var log = function () { console.log .apply(console, ["[VK Subs]"].concat(Array.prototype.slice.call(arguments))); }; var warn = function () { console.warn .apply(console, ["[VK Subs]"].concat(Array.prototype.slice.call(arguments))); }; /** MM:SS panel display */ function formatTime(sec) { var m = Math.floor(sec / 60), s = Math.floor(sec % 60); return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s; } /** VTT timestamp: HH:MM:SS.mmm or MM:SS.mmm */ function formatVttTime(sec) { var h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = Math.floor(sec % 60), ms = Math.floor((sec % 1) * 1000); var ts = (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s + "." + (ms < 100 ? (ms < 10 ? "00" : "0") : "") + ms; return h > 0 ? ((h < 10 ? "0" : "") + h + ":" + ts) : ts; } /** SRT timestamp: HH:MM:SS,mmm */ function formatSrtTime(sec) { var h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = Math.floor(sec % 60), ms = Math.floor((sec % 1) * 1000); return (h < 10 ? "0" : "") + h + ":" + (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s + "," + (ms < 100 ? (ms < 10 ? "00" : "0") : "") + ms; } function getPageWindow() { return typeof unsafeWindow !== "undefined" ? unsafeWindow : globalThis; } // ═══════════════════════════════════════════════════════════════════════ // 3. ENCODING — multi-charset VTT decoder // ═══════════════════════════════════════════════════════════════════════ var ENCODINGS = ["utf-8", "windows-1251", "koi8-r", "iso-8859-5"]; var VTT_HEADER_RE = /^WEBVTT/mu; function decodeBuffer(buffer, encoding) { try { return new TextDecoder(encoding, { fatal: false }).decode(buffer); } catch (_) { return null; } } function isLikelyMojibake(text) { var c = 0; for (var i = 0; i < text.length; i++) { if (text.charCodeAt(i) === 0xfffd) c++; } return c > text.length * 0.03; } function looksLikeVttContent(text) { return VTT_HEADER_RE.test(text) || text.indexOf("-->") !== -1; } /** Decode ArrayBuffer → VTT string, trying all known encodings. */ function decodeTextFromBuffer(buffer) { var best = ""; for (var e = 0; e < ENCODINGS.length; e++) { var decoded = decodeBuffer(buffer, ENCODINGS[e]); if (!decoded) continue; if (looksLikeVttContent(decoded) && !isLikelyMojibake(decoded)) { log("VTT encoding:", ENCODINGS[e], "(" + decoded.length + " chars)"); return decoded; } if (!best && looksLikeVttContent(decoded)) best = decoded; } if (best) { warn("all encodings produced mojibake; using fallback"); return best; } return decodeBuffer(buffer, ENCODINGS[0]) || ""; } // ═══════════════════════════════════════════════════════════════════════ // 4. VTT PARSER // ═══════════════════════════════════════════════════════════════════════ var VTT_TIMING_RE = /^(?(?:\d{2}:)?\d{2}:\d{2}\.\d{3})\s+-->\s+(?(?:\d{2}:)?\d{2}:\d{2}\.\d{3})/u; function parseClockTime(v) { var p = v.trim().split(":"); if (p.length === 3) return (parseInt(p[0],10)*3600 + parseInt(p[1],10)*60 + parseFloat(p[2])) * 1000; if (p.length === 2) return (parseInt(p[0],10)*60 + parseFloat(p[1])) * 1000; return NaN; } /** Parse WEBVTT text → [{start, end, text}, ...] sorted by start. */ function parseVtt(text) { var normalized = text.replace(/\r/g, "").replace(/^/, ""); var lines = normalized.split("\n"); if (!lines[0] || !lines[0].startsWith("WEBVTT")) return []; var cues = [], i = 1; while (i < lines.length) { while (i < lines.length && lines[i].trim() === "") i++; if (i >= lines.length) break; if (/^(NOTE|STYLE|REGION)\b/i.test(lines[i])) { i++; while (i < lines.length && lines[i].trim() !== "") i++; continue; } if (!VTT_TIMING_RE.test(lines[i])) { i++; continue; } var m = VTT_TIMING_RE.exec(lines[i]); if (!m) { i++; continue; } var startMs = parseClockTime(m.groups.start), endMs = parseClockTime(m.groups.end); i++; var textLines = []; while (i < lines.length && lines[i].trim() !== "") { var line = lines[i].replace(/<[^>]+>/g, "").replace(/ /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, '"').replace(/'/g, "'"); textLines.push(line); i++; } var cueText = textLines.join("\n").trim(); if (!isNaN(startMs) && !isNaN(endMs) && cueText) cues.push({ start: startMs / 1000, end: endMs / 1000, text: cueText }); } cues.sort(function (a, b) { return a.start - b.start; }); return cues; } /** * Clean VK's auto-generated VTT cues: 5-pass dedup + merge. * * VK auto-VTT has interleaved formats: * A — short→long corrections at same timestamp * B — daisy-chain pairs: two texts share start time, second repeats as next pair's first * * P1: group by start time (0.05s tolerance) * P2: within-group: drop exact dupes, drop short texts contained in longer siblings * P3: cross-group text dedup (daisy-chain repeats within 5s) * P4: rolling-window containment merge * P5: daisy-chain dedup across \n line boundaries */ function cleanCues(cues) { if (!cues.length) return []; // P1: group by start time cues.sort(function (a, b) { return a.start - b.start; }); var groups = [], curGroup = [cues[0]]; for (var i = 1; i < cues.length; i++) { if (Math.abs(cues[i].start - curGroup[0].start) < 0.05) curGroup.push(cues[i]); else { groups.push(curGroup); curGroup = [cues[i]]; } } groups.push(curGroup); // P2: within-group resolution var resolved = []; for (var g = 0; g < groups.length; g++) { var group = groups[g]; var unique = [], seen = {}; for (var j = 0; j < group.length; j++) { if (!seen[group[j].text]) { seen[group[j].text] = true; unique.push(group[j]); } } for (var k = 0; k < unique.length; k++) { var contained = false; for (var m = 0; m < unique.length; m++) { if (k === m) continue; if (unique[m].text.length > unique[k].text.length && unique[m].text.indexOf(unique[k].text) !== -1) { contained = true; break; } } if (!contained) resolved.push(unique[k]); } } if (resolved.length <= 1) return resolved; // P3: cross-group text dedup var result3 = [], seen3 = {}; for (var r = 0; r < resolved.length; r++) { var cue = resolved[r], prev = seen3[cue.text]; if (prev !== undefined) { if (cue.start - prev.start < 5) { prev.end = Math.max(prev.end, cue.end); continue; } } seen3[cue.text] = cue; result3.push(cue); } if (result3.length <= 1) return result3; // P4: rolling-window containment merge var final4 = [result3[0]]; for (var f = 1; f < result3.length; f++) { var p4 = final4[final4.length - 1], c4 = result3[f]; if (c4.start - p4.start < 5) { if (p4.text.length > 3 && c4.text.indexOf(p4.text) !== -1 && c4.text.length > p4.text.length) { final4[final4.length - 1] = { start: p4.start, end: c4.end, text: c4.text }; continue; } if (c4.text.length > 3 && p4.text.indexOf(c4.text) !== -1 && p4.text.length > c4.text.length) { p4.end = Math.max(p4.end, c4.end); continue; } } final4.push(c4); } if (final4.length <= 1) return final4; // P5: daisy-chain across \n boundaries var final5 = [final4[0]]; for (var p = 1; p < final4.length; p++) { var p5 = final5[final5.length - 1], c5 = final4[p]; var pLines = p5.text.split("\n"), cLines = c5.text.split("\n"); var pLast = pLines[pLines.length - 1].trim(), cFirst = cLines[0].trim(); if (pLast.length > 3 && pLast === cFirst) { if (cLines.length === 1) { p5.end = Math.max(p5.end, c5.end); continue; } var stripped = cLines.slice(1).join("\n").trim(); if (stripped) final5.push({ start: c5.start, end: c5.end, text: stripped }); else p5.end = Math.max(p5.end, c5.end); continue; } final5.push(c5); } return final5; } /** Binary-search the current cue. Prefers longest text among same-timestamp neighbors. */ function findCurrentCue(cues, time) { if (!cues || !cues.length) return null; var lo = 0, hi = cues.length - 1, best = null; while (lo <= hi) { var mid = (lo + hi) >>> 1, cue = cues[mid]; if (time >= cue.start && time < cue.end) { best = cue; for (var L = mid - 1; L >= 0 && Math.abs(cues[L].start - cue.start) < 0.05; L--) { if (time >= cues[L].start && time < cues[L].end && cues[L].text.length > best.text.length) best = cues[L]; } for (var R = mid + 1; R < cues.length && Math.abs(cues[R].start - cue.start) < 0.05; R++) { if (time >= cues[R].start && time < cues[R].end && cues[R].text.length > best.text.length) best = cues[R]; } return best; } if (time < cue.start) hi = mid - 1; else lo = mid + 1; } return null; } // ═══════════════════════════════════════════════════════════════════════ // 4.5. SENTENCE MERGE — build translation units from VK's tiny fragments // ═══════════════════════════════════════════════════════════════════════ // // WHY: VK auto-VTT breaks text every ~2s with no punctuation. Translating // individual fragments produces gibberish. We merge consecutive cues into // sentence-level "translation units" capped at 6s / 150 chars so the API // gets coherent input. Display cues stay individual for precise timing. var _transUnits = []; function mergeToTranslationUnits(cues) { if (!cues.length) return []; var MAX_DUR = 6; // hard cap: never merge beyond 6 seconds var MAX_CHARS = 150; // hard cap: never merge beyond 150 characters var GAP_S = 0.3; // gap threshold to force split (natural pause) var units = []; var cur = { start: cues[0].start, end: cues[0].end, text: cues[0].text }; for (var i = 1; i < cues.length; i++) { var c = cues[i]; var dur = c.end - cur.start; var totalChars = cur.text.length + c.text.length + 1; var gap = c.start - cur.end; var isEnd = /[.!?]$/.test(cur.text); var tooLong = dur > MAX_DUR || totalChars > MAX_CHARS; var hasPause = gap > GAP_S; if (isEnd || tooLong || hasPause) { units.push(cur); cur = { start: c.start, end: c.end, text: c.text }; } else { cur.end = c.end; cur.text += " " + c.text; } } units.push(cur); log("Translation units:", cues.length, "display cues →", units.length, "units"); return units; } /** * After a translation unit is translated, copy its translation to all * display cues whose time range falls within the unit. */ function syncUnitToDisplay(unitText, translation, displayCues) { var unit = null; for (var u = 0; u < _transUnits.length; u++) { if (_transUnits[u].text === unitText) { unit = _transUnits[u]; break; } } if (!unit) { cacheSet(unitText, translation); return; } cacheSet(unitText, translation); for (var i = 0; i < displayCues.length; i++) { var dc = displayCues[i]; if (dc.start >= unit.start - 0.05 && dc.end <= unit.end + 0.05) { cacheSet(dc.text, translation); } } } /** Sync ALL translated units to display cues. */ function syncAllUnitsToDisplay() { var dc = _dedupedCues; if (!dc.length || !_transUnits.length) return; for (var u = 0; u < _transUnits.length; u++) { var unit = _transUnits[u]; var trans = cacheGet(unit.text); if (!trans) continue; for (var i = 0; i < dc.length; i++) { if (dc[i].start >= unit.start - 0.05 && dc[i].end <= unit.end + 0.05) { cacheSet(dc[i].text, trans); } } } } // ═══════════════════════════════════════════════════════════════════════ // 5. TRANSLATION ENGINE (fully decoupled) // ═══════════════════════════════════════════════════════════════════════ // // Layers: Cache → Context → Provider → Queue → Orchestrator // // Cache: get/set/evict translation entries // Context: wrap sentence with prev/next for coherent translation // Provider: call a translation API (Google, VOT/Yandex, etc.) // Queue: batch + priority-sort + rate-limit the translation work // Orchestrator: wire cache + context + provider + queue together // ── 5a. Cache ──────────────────────────────────────────────────────── var _cache = Object.create(null); var _cacheSize = 0; var _cacheMax = 3000; function cacheGet(key) { return _cache[key]; } function cacheSet(k, v) { if (!_cache[k]) _cacheSize++; _cache[k] = v; } function cacheHas(key) { return _cache[key] !== undefined; } function cacheEvict(n) { n = n || 100; var keys = Object.keys(_cache); for (var i = 0; i < Math.min(n, keys.length); i++) { delete _cache[keys[i]]; _cacheSize--; } } // ── 5b. Context strategy ───────────────────────────────────────────── // // WHY: isolated sentences lose pronoun references and discourse flow. // Wrapping with neighbors gives the API enough context. var CTX_SEP = "◈CTX◈"; // ◈CTX◈ — won't appear in natural text /** Wrap cue at index i with prev/next neighbors. */ function contextWrap(cues, i) { var parts = []; if (i > 0) parts.push(cues[i - 1].text); parts.push(cues[i].text); if (i < cues.length - 1) parts.push(cues[i + 1].text); return parts.join("\n" + CTX_SEP + "\n"); } /** Extract the target (middle) segment from a context-aware translation result. */ function contextExtract(translated) { var parts = translated.split(CTX_SEP); var idx = Math.min(1, Math.floor((parts.length - 1) / 2)); return (parts[idx] || parts[0] || "").trim(); } // ── 5c. Provider: Google Translate ─────────────────────────────────── function providerGoogle(text, targetLang) { return new Promise(function (resolve, reject) { var url = "https://translate.googleapis.com/translate_a/single" + "?client=gtx&sl=ru&tl=" + encodeURIComponent(targetLang) + "&dt=t&q=" + encodeURIComponent(text); GM.xmlHttpRequest({ method: "GET", url: url, timeout: 5000, onload: function (r) { try { var data = JSON.parse(r.responseText); var out = (data[0] || []).map(function (s) { return (s || [])[0] || ""; }).join(""); out ? resolve(out) : reject(new Error("Empty")); } catch (e) { reject(e); } }, onerror: function () { reject(new Error("API error")); }, ontimeout: function () { reject(new Error("Timeout")); } }); }); } // ── 5c. Provider: VOT Backend (Yandex) ─────────────────────────────── // // WHY: VOT backend proxies to Yandex Translate, producing much better // Russian→Chinese than Google's free endpoint. We fetch BOTH original // and translated subtitle tracks — Yandex preserves cue timing so they // are 1:1 aligned. var VOT_BACKEND = "https://vot.toil.cc/v1"; var _votOriginal = null; // VOT original-track cues var _votTranslated = null; // VOT translated-track cues (1:1 aligned) var _votApplied = false; function extractVkVideoId() { if (_loadedVideoId && _loadedVideoId !== "unknown") return _loadedVideoId; var m = location.href.match(/video(-?\d+_\d+)/); if (m) return m[1]; m = location.href.match(/video(\d{6,})/); if (m) return m[1]; if (videoElement && videoElement.src) { m = videoElement.src.match(/(\d{6,})/); if (m) return m[1]; } return "unknown"; } /** Fetch and parse a single Yandex JSON subtitle file → cue array. */ function fetchYandexJson(url) { return new Promise(function (resolve) { GM.xmlHttpRequest({ method: "GET", url: url, timeout: 10000, onload: function (r) { try { var data = JSON.parse(r.responseText); var items = Array.isArray(data) ? data : (data.subtitles || data.events || []); if (!items.length) { resolve(null); return; } var cues = []; for (var i = 0; i < items.length; i++) { var it = items[i]; var text = it.text || it.caption || ""; var startMs = it.startMs || it.start || it.tStartMs || 0; var dur = it.durationMs || it.duration || (it.endMs ? it.endMs - startMs : 2000); if (!text.trim()) continue; cues.push({ start: startMs / 1000, end: (startMs + dur) / 1000, text: text.trim() }); } resolve(cues.length > 0 ? cues : null); } catch (_) { resolve(null); } }, onerror: function () { resolve(null); }, ontimeout: function () { resolve(null); } }); }); } /** Fetch BOTH original + translated VOT tracks. Returns {original:[], translated:[]} or null. */ function providerVotFetchTracks(videoId, targetLang) { return new Promise(function (resolve) { GM.xmlHttpRequest({ method: "POST", url: VOT_BACKEND + "/video-subtitles/get-subtitles", headers: { "Content-Type": "application/json", "User-Agent": "vot.js/2.4.17" }, data: JSON.stringify({ service: "vk", video_id: String(videoId), provider: "yandex" }), timeout: 10000, onload: function (r) { try { var data = JSON.parse(r.responseText); if (!Array.isArray(data) || !data.length) { resolve(null); return; } var orig = null, trans = null; for (var i = 0; i < data.length; i++) { if (!data[i].lang_from) orig = data[i]; if (data[i].lang === targetLang && data[i].lang_from) trans = data[i]; } if (!orig || !trans || !orig.subtitle_url || !trans.subtitle_url) { resolve(null); return; } log("VOT: dual tracks —", orig.lang, "→", trans.lang); Promise.all([fetchYandexJson(orig.subtitle_url), fetchYandexJson(trans.subtitle_url)]).then(function (r2) { var o = r2[0], t = r2[1]; if (!o || !t) { resolve(null); return; } var len = Math.min(o.length, t.length); resolve({ original: o.slice(0, len), translated: t.slice(0, len) }); }); } catch (_) { resolve(null); } }, onerror: function () { resolve(null); }, ontimeout: function () { resolve(null); } }); }); } // ── 5d. Queue: batching + priority + rate limiting ────────────────── var _queue = []; var _queueTimer = null; var _queueTotal = 0; var _queueDone = 0; var BATCH_SIZE = 8; var BATCH_DELAY = 300; // ms between batches var BATCH_RETRY_DELAY = 1500; /** * Build a priority queue of translation batches. * Each uncached cue is wrapped with context. Batches nearest the * current playback position are processed first. */ function queueBuild(cues, curTime) { if (!cues || !cues.length) return; var batches = []; for (var i = 0; i < cues.length; i += BATCH_SIZE) { var end = Math.min(i + BATCH_SIZE, cues.length); var contexts = [], targets = [], allCached = true; for (var j = i; j < end; j++) { targets.push(cues[j].text); if (!cacheHas(cues[j].text)) { allCached = false; contexts.push(contextWrap(cues, j)); } else contexts.push(null); } if (!allCached) batches.push({ contexts: contexts, targets: targets, firstIdx: i }); } if (!batches.length) { _queueTotal = 0; _queueDone = 0; return; } var curIdx = 0; for (var k = 0; k < cues.length; k++) { if (curTime >= cues[k].start && curTime < cues[k].end) { curIdx = k; break; } } batches.sort(function (a, b) { return Math.abs(a.firstIdx - curIdx) - Math.abs(b.firstIdx - curIdx); }); _queue = batches; _queueTotal = batches.length; _queueDone = 0; } /** * Translate a batch of context-wrapped sentences via Google. * Splits results back by batch separator, extracts target segment from * each context block, and caches the individual translations. */ function queuePumpOne(batch, onDone) { var BSEP = "\n\n---VOTSEP---\n\n"; var uncached = [], targets = []; for (var i = 0; i < batch.contexts.length; i++) { if (batch.contexts[i] !== null) { uncached.push(batch.contexts[i]); targets.push(batch.targets[i]); } } if (!uncached.length) { onDone(true); return; } providerGoogle(uncached.join(BSEP), TRANSLATE_TO).then(function (result) { var blocks = result.split(BSEP); for (var i = 0; i < Math.min(uncached.length, blocks.length); i++) { var extracted = contextExtract(blocks[i]); if (extracted) cacheSet(targets[i], extracted); } if (blocks.length !== uncached.length) { warn("batch split mismatch:", blocks.length, "vs", uncached.length); for (var j = 0; j < targets.length; j++) { if (!cacheHas(targets[j])) { (function (t) { providerGoogle(t, TRANSLATE_TO).then(function (r) { cacheSet(t, r); }); })(targets[j]); } } } if (_cacheSize > _cacheMax) cacheEvict(100); onDone(true); }).catch(function (err) { warn("batch error:", err && err.message); _queue.unshift(batch); onDone(false); }); } /** Pump queue at controlled intervals. */ function queuePump() { if (_queue.length === 0) { _queueTimer = null; return; } var batch = _queue.shift(); queuePumpOne(batch, function (ok) { _queueDone++; syncAllUnitsToDisplay(); updatePanelTitle(); refreshPanelTranslations(); _queueTimer = setTimeout(queuePump, ok ? BATCH_DELAY : BATCH_RETRY_DELAY); }); } // ── 5e. Orchestrator — wires cache + context + provider + queue ───── var _running = false; var _livePending = null; /** Start/restart background translation for a cue list. */ function translationStart(cues, curTime) { if (!TRANSLATE_ENABLED) return; queueBuild(cues || _transUnits, curTime || 0); if (_running) return; _running = true; queuePump(); } /** Stop background translation. */ function translationStop() { _running = false; _queue = []; if (_queueTimer) { clearTimeout(_queueTimer); _queueTimer = null; } _queueTotal = 0; _queueDone = 0; } /** * Live-translate on cache miss during playback. * Finds the translation unit containing this display cue, translates the * WHOLE unit for coherence, then syncs the result to all display cues * within that unit so subsequent fragments are instant. */ function translationLive(text, displayCues) { if (!TRANSLATE_ENABLED || !text || _livePending === text) return; if (cacheHas(text)) return; _livePending = text; // Find which translation unit covers this display cue var unit = null; if (_transUnits.length && displayCues) { // Locate the display cue first var dcStart = 0; for (var d = 0; d < displayCues.length; d++) { if (displayCues[d].text === text) { dcStart = displayCues[d].start; break; } } // Find containing translation unit for (var u = 0; u < _transUnits.length; u++) { if (dcStart >= _transUnits[u].start - 0.05 && dcStart <= _transUnits[u].end + 0.05) { unit = _transUnits[u]; break; } } } var payload = unit ? contextWrap(_transUnits, _transUnits.indexOf(unit)) : text; providerGoogle(payload, TRANSLATE_TO).then(function (result) { var extracted = unit ? contextExtract(result) : result; if (unit) { cacheSet(unit.text, extracted); // Sync to all display cues in this unit for (var i = 0; i < displayCues.length; i++) { if (displayCues[i].start >= unit.start - 0.05 && displayCues[i].end <= unit.end + 0.05) { cacheSet(displayCues[i].text, extracted); } } } else { cacheSet(text, extracted); } refreshPanelTranslations(); if (overlay && overlay.firstChild && overlay.firstChild.textContent === text) showTranslation(extracted); _livePending = null; }).catch(function () { _livePending = null; }); } /** Progress string for panel header. */ function translationProgress() { if (_queueTotal === 0) return "Subtitles"; if (_queueDone >= _queueTotal) return "Subtitles ✓"; return "Subtitles (" + _queueDone + "/" + _queueTotal + ")"; } /** Update panel header title element. */ function updatePanelTitle() { var el = document.querySelector("#vot-vk-sub-panel [data-trans-title]"); if (el) el.textContent = translationProgress(); } // ── 5f. VOT backend trigger (fire-and-forget) ──────────────────────── // // Tries VOT backend. On success, replaces VK cues with VOT's original // cues (better text quality) and fills the cache with 1:1 aligned Yandex // translations. Cancels the Google queue since everything is covered. function tryVotBackend(videoId) { if (!TRANSLATE_ENABLED || _votApplied) return; if (!videoId || videoId === "unknown") return; _votApplied = true; log("VOT: requesting dual-track for", videoId, "→", TRANSLATE_TO); providerVotFetchTracks(videoId, TRANSLATE_TO).then(function (tracks) { if (!tracks) { log("VOT: nothing available, Google fallback active"); return; } _votOriginal = tracks.original; _votTranslated = tracks.translated; log("VOT: received", tracks.original.length, "cues (1:1 aligned)"); _dedupedCues = tracks.original; _transUnits = tracks.original; // VOT cues are already sentence-level for (var i = 0; i < tracks.original.length; i++) cacheSet(tracks.original[i].text, tracks.translated[i].text); translationStop(); updatePanelTitle(); rebuildPanel(); if (overlay && overlay.firstChild && overlay.firstChild.textContent) { var c = cacheGet(overlay.firstChild.textContent); if (c) showTranslation(c); } }); } // ═══════════════════════════════════════════════════════════════════════ // 6. SUBTITLE DOWNLOAD — VTT / SRT bilingual serializers // ═══════════════════════════════════════════════════════════════════════ /** Serialize cue pairs to bilingual VTT. */ function serializeBilingual(orig, trans, fmt) { var fmtTime = fmt === "srt" ? formatSrtTime : formatVttTime; var lines = fmt === "srt" ? [] : ["WEBVTT", ""]; for (var i = 0; i < orig.length; i++) { var c = orig[i]; if (fmt === "srt") lines.push(String(i + 1)); lines.push(fmtTime(c.start) + " --> " + fmtTime(c.end)); lines.push(c.text); var t = trans ? (trans[i] ? trans[i].text : "") : (cacheGet(c.text) || ""); if (t) lines.push(t); lines.push(""); } return lines.join("\n"); } function triggerDownload(data, filename, mime) { try { var blob = new Blob([data], { type: mime + ";charset=utf-8" }); var url = URL.createObjectURL(blob); var a = document.createElement("a"); a.href = url; a.download = filename; a.style.display = "none"; document.body.appendChild(a); a.click(); setTimeout(function () { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100); log("Download:", filename); } catch (e) { warn("Download failed:", e); } } function downloadBilingualVtt() { var orig = _votOriginal || _dedupedCues || activeCues; if (!orig.length) { warn("No subtitles"); return; } triggerDownload(serializeBilingual(orig, _votTranslated, "vtt"), "vk-subs-" + (_loadedVideoId || "video") + "-bilingual.vtt", "text/vtt"); } function downloadBilingualSrt() { var orig = _votOriginal || _dedupedCues || activeCues; if (!orig.length) { warn("No subtitles"); return; } triggerDownload(serializeBilingual(orig, _votTranslated, "srt"), "vk-subs-" + (_loadedVideoId || "video") + "-bilingual.srt", "text/srt"); } /** Internal: mono VTT (no translation), used for textTracks fallback. */ function serializeMonoVtt(cues) { var lines = ["WEBVTT", ""]; for (var i = 0; i < cues.length; i++) { lines.push(formatVttTime(cues[i].start) + " --> " + formatVttTime(cues[i].end)); lines.push(cues[i].text); lines.push(""); } return lines.join("\n"); } // ═══════════════════════════════════════════════════════════════════════ // 7. OVERLAY UI — floating subtitle box on video (Shadow DOM) // ═══════════════════════════════════════════════════════════════════════ var OVERLAY_ID = "vot-vk-sub-overlay"; var overlay = null; var overlayMount = null; var _dragY = null; var _userBottom = 64; function resolveOverlayMount() { var p = document.querySelector("vk-video-player"); if (p && p.shadowRoot) return p.shadowRoot; var all = document.querySelectorAll("*"); for (var i = 0; i < all.length; i++) { var sr = all[i].shadowRoot; if (sr && sr.querySelector("video")) return sr; } return document.body; } function getContainerHeight(el) { var p = el.parentNode; if (!p) return 720; if (p.host && p.host.getBoundingClientRect) return p.host.getBoundingClientRect().height || 720; if (p.getBoundingClientRect instanceof Function) return p.getBoundingClientRect().height || 720; return 720; } function createOverlay(mount) { var el = document.createElement("div"); el.id = OVERLAY_ID; el.style.cssText = "position:absolute;bottom:" + _userBottom + "px;left:50%;transform:translateX(-50%);z-index:999999;max-width:88%;display:none;padding:8px 16px;background:rgba(0,0,0,0.75);color:#fff;font-size:18px;line-height:1.3;text-align:center;border-radius:8px;backdrop-filter:blur(8px);font-family:Arial,Helvetica,sans-serif;box-shadow:0 2px 16px rgba(0,0,0,0.4);"; var textSpan = document.createElement("span"); textSpan.style.cssText = "display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:text;user-select:text;font-size:18px;line-height:1.3;"; el.appendChild(textSpan); var transSpan = document.createElement("span"); transSpan.className = "vot-trans"; transSpan.style.cssText = "display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#aaa;font-size:13px;font-style:italic;line-height:1.2;margin-top:2px;"; el.appendChild(transSpan); // Drag handle (top edge) var handle = document.createElement("div"); handle.style.cssText = "position:absolute;top:-14px;left:50%;transform:translateX(-50%);width:60px;height:14px;cursor:ns-resize;border-radius:7px 7px 0 0;background:rgba(60,60,60,0.9);border:1px solid rgba(255,255,255,0.1);border-bottom:none;box-shadow:0 -2px 10px rgba(0,0,0,0.4);transition:width 0.15s,background 0.15s;"; handle.innerHTML = ''; handle.addEventListener("mouseenter", function () { handle.style.width = "84px"; handle.style.background = "rgba(255,255,255,0.25)"; }); handle.addEventListener("mouseleave", function () { handle.style.width = "60px"; handle.style.background = "rgba(60,60,60,0.9)"; }); el.appendChild(handle); handle.addEventListener("pointerdown", function (e) { if (e.button !== undefined && e.button !== 0) return; e.preventDefault(); e.stopPropagation(); handle.setPointerCapture(e.pointerId); handle.style.background = "rgba(255,255,255,0.30)"; _dragY = { startY: e.clientY, startBottom: _userBottom }; }); handle.addEventListener("pointermove", function (e) { if (!_dragY) return; var dy = _dragY.startY - e.clientY; var nb = _dragY.startBottom + dy; var maxB = Math.max(8, getContainerHeight(el) - 56); _userBottom = Math.max(8, Math.min(maxB, nb)); el.style.bottom = _userBottom + "px"; }); var endDrag = function (e) { if (!_dragY) return; handle.style.background = "rgba(255,255,255,0.10)"; handle.releasePointerCapture(e.pointerId); _dragY = null; }; handle.addEventListener("pointerup", endDrag); handle.addEventListener("pointercancel", endDrag); handle.addEventListener("dblclick", function () { _userBottom = 64; el.style.bottom = "64px"; }); if (mount) mount.appendChild(el); return el; } function ensureOverlayInDOM() { var mount = resolveOverlayMount(); if (!mount) return; if (mount !== overlayMount) { if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); overlay = null; overlayMount = null; } if (!overlay) { overlay = document.getElementById(OVERLAY_ID); if (!overlay || !overlay.parentNode) overlay = createOverlay(mount); overlayMount = mount; } if (!overlay.parentNode && mount) { overlay = createOverlay(mount); overlayMount = mount; } } /** * Show a subtitle line on the overlay. Skips re-render if unchanged * (prevents translation flicker at 60 fps timeupdate). */ function showOverlay(text) { ensureOverlayInDOM(); if (!overlay) return; if (!text || !text.trim()) { overlay.style.display = "none"; return; } var display = text.replace(/[\r\n]+/g, " ").replace(/\s{2,}/g, " ").trim(); if (overlay.firstChild.textContent === display && overlay.style.display === "block") return; overlay.firstChild.textContent = display; overlay.style.display = "block"; if (TRANSLATE_ENABLED) { try { overlay.dispatchEvent(new CustomEvent("vot-subtitle-change", { detail: { text: display }, bubbles: true, composed: true })); } catch (_) {} } } function showTranslation(text) { if (!overlay || !overlay.parentNode) return; var t = overlay.querySelector(".vot-trans"); if (t) t.textContent = text || ""; } function showStatus(msg) { var el = document.getElementById("vot-vk-sub-status"); if (!el) { el = document.createElement("div"); el.id = "vot-vk-sub-status"; el.style.cssText = "position:fixed;top:8px;right:8px;z-index:2147483647;padding:4px 10px;background:rgba(0,0,0,0.6);color:#aaa;font-size:11px;border-radius:4px;pointer-events:none;font-family:monospace;"; if (document.body) document.body.appendChild(el); } if (!el.parentNode && document.body) document.body.appendChild(el); el.textContent = msg; el.style.display = "block"; setTimeout(function () { el.style.display = "none"; }, 5000); } // ═══════════════════════════════════════════════════════════════════════ // 8. PANEL UI — subtitle list panel (Light DOM) // ═══════════════════════════════════════════════════════════════════════ var _panelEl = null; var _panelListEl = null; var _panelBaseX = null; var _panelBaseY = 80; var _panelOffX = 0; var _panelOffY = 0; var _panelCollapsed = false; var _panelScrollLock = false; var _panelScrollTimer = null; var _highlightTimer = null; var _cueRows = []; var _dedupedCues = []; var _highlightedIdx = -1; function applyPanelTransform() { if (_panelEl) _panelEl.style.transform = "translate(" + _panelOffX + "px, " + _panelOffY + "px)"; } /** Create a single cue row. */ function createCueRow(cue, idx) { var row = document.createElement("div"); row.style.cssText = "display:flex;gap:8px;padding:4px 10px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,0.03);transition:background 0.1s;"; row.setAttribute("data-cue-idx", String(idx)); var timeSpan = document.createElement("span"); timeSpan.textContent = formatTime(cue.start); timeSpan.style.cssText = "flex-shrink:0;width:42px;color:#888;font-size:11px;font-family:monospace;text-align:right;padding-top:2px;"; row.appendChild(timeSpan); var container = document.createElement("div"); container.style.cssText = "flex:1;min-width:0;"; var textSpan = document.createElement("span"); textSpan.textContent = cue.text; textSpan.style.cssText = "display:block;font-size:12px;line-height:1.4;white-space:pre-wrap;word-break:break-word;"; container.appendChild(textSpan); var transSpan = document.createElement("span"); transSpan.className = "vot-panel-trans"; transSpan.style.cssText = "display:block;font-size:11px;line-height:1.3;color:#aaa;font-style:italic;white-space:pre-wrap;word-break:break-word;margin-top:1px;"; container.appendChild(transSpan); row.appendChild(container); row.addEventListener("click", (function (c) { return function () { if (videoElement && typeof videoElement.currentTime === "number") videoElement.currentTime = c.start; }; })(cue)); return row; } /** Full panel rebuild from _dedupedCues. */ function rebuildPanel() { if (!_panelListEl) return; _panelListEl.innerHTML = ""; _cueRows = []; _highlightedIdx = -1; var cues = _dedupedCues; if (!cues.length) { var empty = document.createElement("div"); empty.textContent = "No subtitles loaded"; empty.style.cssText = "padding:16px;color:#666;text-align:center;"; _panelListEl.appendChild(empty); return; } for (var i = 0; i < cues.length; i++) { var row = createCueRow(cues[i], i); _panelListEl.appendChild(row); _cueRows.push(row); } refreshPanelTranslations(); } /** First render: clean raw cues, build dedup list, kick off translation. */ function renderCueList() { if (!_panelListEl) return; _panelListEl.innerHTML = ""; _cueRows = []; if (!activeCues.length) { var empty = document.createElement("div"); empty.textContent = "No subtitles loaded"; empty.style.cssText = "padding:16px;color:#666;text-align:center;"; _panelListEl.appendChild(empty); return; } var cues = cleanCues(activeCues); _dedupedCues = cues; _transUnits = mergeToTranslationUnits(cues); translationStart(_transUnits, videoElement ? videoElement.currentTime : 0); for (var i = 0; i < cues.length; i++) { var row = createCueRow(cues[i], i); _panelListEl.appendChild(row); _cueRows.push(row); } } function updatePanelHighlight() { if (!videoElement || !_cueRows.length) return; var time = videoElement.currentTime, cues = _dedupedCues || activeCues, idx = -1; if (cues.length) { var lo = 0, hi = cues.length - 1; while (lo <= hi) { var mid = (lo + hi) >>> 1; if (time >= cues[mid].start && time < cues[mid].end) { idx = mid; break; } if (time < cues[mid].start) hi = mid - 1; else lo = mid + 1; } } if (idx === _highlightedIdx) return; _highlightedIdx = idx; for (var i = 0; i < _cueRows.length; i++) { var spans = _cueRows[i].querySelectorAll("span"); if (i === idx) { _cueRows[i].style.background = "rgba(255,255,255,0.15)"; for (var s = 0; s < spans.length; s++) spans[s].style.color = "#fff"; if (!_panelScrollLock && _panelListEl) _cueRows[i].scrollIntoView({ block: "center", behavior: "smooth" }); } else { _cueRows[i].style.background = ""; if (spans.length) spans[0].style.color = "#888"; if (spans.length > 1) spans[1].style.color = ""; } } } function refreshPanelTranslations() { if (!_cueRows.length) return; var cues = _dedupedCues; for (var i = 0; i < _cueRows.length; i++) { var ts = _cueRows[i].querySelector(".vot-panel-trans"); if (!ts || ts.textContent) continue; var cue = cues[i]; if (!cue) continue; var t = cacheGet(cue.text); if (t) ts.textContent = t; } } function refreshPanelTranslationsClear() { for (var i = 0; i < _cueRows.length; i++) { var ts = _cueRows[i].querySelector(".vot-panel-trans"); if (ts) ts.textContent = ""; } } /** Create a header button with shared styling. */ function makeHeaderBtn(text, title, width, fontSize, isActive) { var btn = document.createElement("button"); btn.textContent = text; btn.title = title; btn.style.cssText = "background:" + (isActive ? "rgba(255,255,255,0.25)" : "rgba(255,255,255,0.1)") + ";border:none;color:" + (isActive ? "#fff" : "#ccc") + ";cursor:pointer;border-radius:4px;width:" + (width || 22) + "px;height:22px;font-size:" + (fontSize || 11) + "px;line-height:1;padding:0;flex-shrink:0;"; btn.addEventListener("pointerdown", function (e) { e.stopPropagation(); }); btn.addEventListener("mouseenter", function () { btn.style.background = "rgba(255,255,255,0.25)"; }); btn.addEventListener("mouseleave", function () { btn.style.background = isActive ? "rgba(255,255,255,0.25)" : "rgba(255,255,255,0.1)"; }); return btn; } function createSubtitlePanel() { if (_panelEl) return; if (_panelBaseX == null) _panelBaseX = window.innerWidth - 360; var panel = document.createElement("div"); panel.id = "vot-vk-sub-panel"; panel.style.cssText = "position:fixed;top:" + _panelBaseY + "px;left:" + _panelBaseX + "px;z-index:2147483646;width:340px;background:rgba(0,0,0,0.88);color:#fff;border-radius:8px;font-family:Arial,Helvetica,sans-serif;font-size:13px;box-shadow:0 2px 20px rgba(0,0,0,0.5);will-change:transform;display:flex;flex-direction:column;overflow:hidden;"; applyPanelTransform(); // ── Header ── var header = document.createElement("div"); header.style.cssText = "display:flex;align-items:center;gap:6px;padding:4px 8px;cursor:move;user-select:none;flex-shrink:0;border-bottom:1px solid rgba(255,255,255,0.08);background:rgba(255,255,255,0.06);"; var title = document.createElement("span"); title.setAttribute("data-trans-title", "1"); title.textContent = "Subtitles"; title.style.cssText = "flex:1;font-size:12px;color:#aaa;"; header.appendChild(title); // T — translation toggle var trBtn = makeHeaderBtn("T", "翻译开关 — 开启/关闭双语翻译", 22, 11, TRANSLATE_ENABLED); trBtn.style.fontWeight = "bold"; trBtn.addEventListener("click", function (e) { e.stopPropagation(); TRANSLATE_ENABLED = !TRANSLATE_ENABLED; if (TRANSLATE_ENABLED) { trBtn.style.background = "rgba(255,255,255,0.25)"; trBtn.style.color = "#fff"; translationStart(_transUnits, videoElement ? videoElement.currentTime : 0); updatePanelTitle(); } else { trBtn.style.background = "rgba(255,255,255,0.1)"; trBtn.style.color = "#666"; translationStop(); updatePanelTitle(); showTranslation(""); refreshPanelTranslationsClear(); } }); header.appendChild(trBtn); // ⬇ VTT download var vttBtn = makeHeaderBtn("⬇", "下载双语字幕 VTT — 原文 + 中文翻译", 28, 13, false); vttBtn.addEventListener("click", function (e) { e.stopPropagation(); downloadBilingualVtt(); }); header.appendChild(vttBtn); // SRT download var srtBtn = makeHeaderBtn("SRT", "下载双语字幕 SRT — 通用格式,原文 + 中文翻译", 30, 9, false); srtBtn.style.fontWeight = "bold"; srtBtn.addEventListener("click", function (e) { e.stopPropagation(); downloadBilingualSrt(); }); header.appendChild(srtBtn); // ⊙ scroll-to-current var scBtn = makeHeaderBtn("⊙", "定位到当前播放的字幕行", 22, 12, false); scBtn.addEventListener("click", function (e) { e.stopPropagation(); _panelScrollLock = false; updatePanelHighlight(); }); header.appendChild(scBtn); // −/+ collapse var colBtn = makeHeaderBtn("−", "折叠 / 展开字幕列表面板", 22, 14, false); colBtn.addEventListener("click", function (e) { e.stopPropagation(); _panelCollapsed = !_panelCollapsed; colBtn.textContent = _panelCollapsed ? "+" : "−"; colBtn.title = _panelCollapsed ? "展开字幕列表面板" : "折叠字幕列表面板"; listContainer.style.display = _panelCollapsed ? "none" : ""; }); header.appendChild(colBtn); panel.appendChild(header); // Panel drag var _pDrag = null; header.addEventListener("pointerdown", function (e) { if (e.button !== undefined && e.button !== 0) return; e.preventDefault(); header.setPointerCapture(e.pointerId); _pDrag = { x: e.clientX, y: e.clientY, ox: _panelOffX, oy: _panelOffY }; }); header.addEventListener("pointermove", function (e) { if (!_pDrag) return; _panelOffX = _pDrag.ox + (e.clientX - _pDrag.x); _panelOffY = _pDrag.oy + (e.clientY - _pDrag.y); applyPanelTransform(); }); header.addEventListener("pointerup", function () { _pDrag = null; try { header.releasePointerCapture(1); } catch (_) {} }); header.addEventListener("pointercancel", function () { _pDrag = null; try { header.releasePointerCapture(1); } catch (_) {} }); // Cue list var listContainer = document.createElement("div"); listContainer.style.cssText = "overflow-y:auto;overflow-x:hidden;max-height:60vh;scroll-behavior:smooth;display:block;"; listContainer.addEventListener("wheel", function () { _panelScrollLock = true; clearTimeout(_panelScrollTimer); _panelScrollTimer = setTimeout(function () { _panelScrollLock = false; }, 3000); }); panel.appendChild(listContainer); _panelListEl = listContainer; document.body.appendChild(panel); _panelEl = panel; renderCueList(); if (!_highlightTimer) _highlightTimer = setInterval(updatePanelHighlight, 500); log("Panel created"); } window.addEventListener("resize", function () { if (!_panelEl) return; var cx = _panelBaseX + _panelOffX, cy = _panelBaseY + _panelOffY; if (cx + 340 > window.innerWidth) _panelOffX = window.innerWidth - 350 - _panelBaseX; if (cy > window.innerHeight - 100) _panelOffY = window.innerHeight - 200 - _panelBaseY; applyPanelTransform(); }); // ═══════════════════════════════════════════════════════════════════════ // 9. VIDEO DISCOVERY — Shadow DOM penetration + prototype hooks // ═══════════════════════════════════════════════════════════════════════ var knownShadowRoots = []; function collectShadowVideos(host, results) { var sr = host.shadowRoot; if (sr && sr.querySelectorAll) { var v = sr.querySelectorAll("video"); for (var i = 0; i < v.length; i++) results.push(v[i]); return; } for (var s = 0; s < knownShadowRoots.length; s++) { var csr = knownShadowRoots[s]; if (!csr.querySelectorAll) continue; if (csr.host === host || !csr.host) { var v2 = csr.querySelectorAll("video"); for (var j = 0; j < v2.length; j++) results.push(v2[j]); } } } function queryAllVideosOnPage() { var results = []; var light = document.querySelectorAll("video"); for (var i = 0; i < light.length; i++) results.push(light[i]); var vkPlayer = document.querySelector("vk-video-player"); if (vkPlayer) collectShadowVideos(vkPlayer, results); var all = document.querySelectorAll("*"); for (var j = 0; j < all.length; j++) { if (all[j] !== vkPlayer && all[j].shadowRoot) collectShadowVideos(all[j], results); } for (var s = 0; s < knownShadowRoots.length; s++) { var csr = knownShadowRoots[s]; if (!csr.querySelectorAll) continue; var cv = csr.querySelectorAll("video"); for (var c = 0; c < cv.length; c++) { if (results.indexOf(cv[c]) === -1) results.push(cv[c]); } } return results; } function findBestVideo() { var all = queryAllVideosOnPage(); if (!all.length) { var iframes = document.querySelectorAll("iframe"); for (var f = 0; f < iframes.length; f++) { try { var doc = iframes[f].contentDocument || (iframes[f].contentWindow && iframes[f].contentWindow.document); if (doc) { var iv = doc.querySelectorAll("video"); if (iv.length) return iv[0]; } } catch (_) {} } return null; } for (var i = 0; i < all.length; i++) { if (all[i].classList && all[i].classList.contains("player-media")) return all[i]; } for (var j = 0; j < all.length; j++) { if (all[j].closest && all[j].closest("vk-video-player, .videoplayer_media, [data-video]")) return all[j]; } return all[0]; } function waitForVideo(ms) { ms = ms || 15000; return new Promise(function (resolve) { var start = Date.now(); var check = function () { var v = findBestVideo(); if (v) resolve(v); else if (Date.now() - start > ms) resolve(null); else setTimeout(check, 300); }; check(); }); } function hookAttachShadow() { if (Element.prototype.attachShadow.__votHooked) return; var orig = Element.prototype.attachShadow; var wrapper = function (init) { var sr = orig.call(this, init); var mode = (init && init.mode) || "open"; log("Shadow root created (" + mode + ")", this.tagName || "unknown"); knownShadowRoots.push(sr); var timer = null; var obs = new MutationObserver(function () { if (timer) return; timer = setTimeout(function () { timer = null; checkVideoChange("shadow mutation"); }, 250); }); obs.observe(sr, { childList: true, subtree: true }); setTimeout(function () { checkVideoChange("new shadow root"); }, 50); return sr; }; wrapper.__votHooked = true; Element.prototype.attachShadow = wrapper; } function hookVideoPlay() { if (HTMLVideoElement.prototype.play.__votPlayHooked) return; var orig = HTMLVideoElement.prototype.play; var debounce = null; var wrapper = function () { var v = this; if (v !== currentVideo && v.nodeType === 1 && v.classList && v.classList.contains("player-media")) { if (debounce) return; debounce = setTimeout(function () { debounce = null; }, 500); log("Video.play() intercepted:", v); currentVideo = v; attachToVideo(v); } return orig.apply(this, arguments); }; wrapper.__votPlayHooked = true; HTMLVideoElement.prototype.play = wrapper; log("HTMLVideoElement.prototype.play hooked"); } // ═══════════════════════════════════════════════════════════════════════ // 10. SUBTITLE LOADING — fetch + parse + activate // ═══════════════════════════════════════════════════════════════════════ var activeCues = []; var videoElement = null; var currentVideo = null; var timeUpdateHandler = null; var loadedDataHandler = null; var _timeUpdateCount = 0; var _cueHitCount = 0; var _loadingSubtitles = false; var _rawVttText = null; var _loadedVideoId = null; var _lastVideoTime = 0; var _lastLoadAttempt = 0; function fetchSubtitlesViaGM(url) { return new Promise(function (resolve, reject) { log("Fetching:", url.substring(0, 80) + "..."); GM.xmlHttpRequest({ method: "GET", url: url, responseType: "arraybuffer", timeout: 10000, onload: function (r) { if (r.status < 200 || r.status >= 400) { reject(new Error("HTTP " + r.status)); return; } if (!r.response || r.response.byteLength === 0) { reject(new Error("Empty")); return; } var text = decodeTextFromBuffer(r.response); text ? resolve(text) : reject(new Error("Decode failed")); }, onerror: function (e) { reject(new Error((e && e.error) || "GM XHR error")); }, ontimeout: function () { reject(new Error("Timeout")); } }); }); } function getSubtitleUrlsFromPlayer() { try { var pw = getPageWindow(); if (typeof pw.Videoview === "undefined") return null; var player = pw.Videoview.getPlayerObject && pw.Videoview.getPlayerObject(); if (!player || !player.vars || !player.vars.subs) return null; var subs = Array.isArray(player.vars.subs) ? player.vars.subs : [player.vars.subs]; var urls = []; for (var i = 0; i < subs.length; i++) { if (subs[i].url) urls.push({ url: subs[i].url, lang: subs[i].lang || "unknown", isAuto: !!subs[i].is_auto }); } return urls.length ? urls : null; } catch (e) { warn("Videoview error:", e); return null; } } // ── Page state scanning (last-resort fallback) ── function normalizeCue(item) { if (!item || typeof item !== "object") return null; var t = item.text || item.subtitle || item.caption || item.title || null; if (!t || !t.trim()) return null; var s = parseFloat(item.start != null ? item.start : (item.begin != null ? item.begin : (item.from != null ? item.from : item.ts))); var e = parseFloat(item.end != null ? item.end : (item.to != null ? item.to : (item.finish != null ? item.finish : item.stop))); if (isNaN(s) || isNaN(e) || e <= s) return null; return { start: s, end: e, text: t.trim() }; } function extractCues(obj, results, depth) { if (depth > 6 || !obj || typeof obj !== "object") return; if (Array.isArray(obj)) { var possible = obj.filter(function (i) { return normalizeCue(i); }); if (possible.length === obj.length && obj.length > 0) { for (var p = 0; p < obj.length; p++) { var cue = normalizeCue(obj[p]); if (cue) results.push(cue); } return; } for (var a = 0; a < obj.length; a++) extractCues(obj[a], results, depth + 1); return; } var keys = Object.keys(obj); for (var k = 0; k < keys.length; k++) { if (/subtitles?|captions?|tracks?|events?|segments?/i.test(keys[k])) extractCues(obj[keys[k]], results, depth + 1); } var c = normalizeCue(obj); if (c) { results.push(c); return; } for (var k2 = 0; k2 < keys.length; k2++) extractCues(obj[keys[k2]], results, depth + 1); } function scanPageStateForSubtitles() { var pw = getPageWindow(); var keys = ["__INITIAL_STATE__", "__PRELOADED_STATE__", "__NEXT_DATA__", "__PAGE_DATA__", "initialState", "pageData", "appState", "windowData", "data", "state"]; for (var k = 0; k < keys.length; k++) { try { var val = pw[keys[k]]; if (!val) continue; var results = []; extractCues(val, results, 0); if (results.length > 0) { results.sort(function (a, b) { return a.start - b.start; }); activeCues = cleanCues(results); log("Found", activeCues.length, "cues in page state:", keys[k]); return true; } } catch (_) {} } return false; } /** Unified subtitle loading: VK player API → track elements → textTracks → page state. */ async function loadSubtitlesForCurrentVideo() { if (!videoElement || _loadingSubtitles) return; _loadingSubtitles = true; try { // 1. VK player API var urls = getSubtitleUrlsFromPlayer(); if (urls && urls.length) { var auto = urls.find(function (s) { return s.isAuto; }), target = auto || urls[0]; log("Player API:", target.lang, "auto:", target.isAuto); try { var vttText = await fetchSubtitlesViaGM(target.url); var cues = parseVtt(vttText); if (cues.length) { activeCues = cleanCues(cues); _rawVttText = vttText; _loadedVideoId = (target.url.match(/id=(\d+)/) || [])[1] || "unknown"; renderCueList(); _votApplied = false; tryVotBackend(_loadedVideoId); showStatus("字幕: " + activeCues.length + " (" + target.lang + ")"); log("Loaded", activeCues.length, "cues (player API)"); return; } } catch (e) { warn("Player API URL failed:", e.message); } } // 2. elements var tracks = videoElement.querySelectorAll("track"); for (var ti = 0; ti < tracks.length; ti++) { var track = tracks[ti]; if (!track.src) continue; log("Track element:", track.src.substring(0, 80), track.srclang); try { var tv = await fetchSubtitlesViaGM(track.src); var tc = parseVtt(tv); if (tc.length) { activeCues = cleanCues(tc); _rawVttText = tv; _loadedVideoId = (track.src.match(/id=(\d+)/) || [])[1] || "unknown"; renderCueList(); _votApplied = false; tryVotBackend(_loadedVideoId); showStatus("字幕: " + activeCues.length + " (track)"); log("Loaded", activeCues.length, "cues (track)"); return; } } catch (e) { warn("Track failed:", e.message); } } // 3. textTracks API var ttracks = Array.from(videoElement.textTracks || []); for (var tti = 0; tti < ttracks.length; tti++) { var tt = ttracks[tti]; if (!["subtitles", "captions", ""].includes(tt.kind)) continue; tt.mode = "hidden"; if (tt.cues && tt.cues.length) { activeCues = Array.from(tt.cues).map(function (c) { return { start: c.startTime, end: c.endTime, text: c.text.replace(/<[^>]+>/g, "").trim() }; }); activeCues.sort(function (a, b) { return a.start - b.start; }); activeCues = cleanCues(activeCues); _rawVttText = serializeMonoVtt(activeCues); _loadedVideoId = extractVkVideoId(); renderCueList(); _votApplied = false; tryVotBackend(_loadedVideoId); showStatus("字幕: " + activeCues.length + " (textTracks)"); log("Loaded", activeCues.length, "cues (textTracks)"); return; } } // 4. Page state if (scanPageStateForSubtitles()) { _rawVttText = serializeMonoVtt(activeCues); _loadedVideoId = extractVkVideoId(); renderCueList(); _votApplied = false; tryVotBackend(_loadedVideoId); showStatus("字幕: " + activeCues.length + " (page state)"); return; } log("No subtitles found"); } finally { _loadingSubtitles = false; } } // ═══════════════════════════════════════════════════════════════════════ // 11. CORE — video attach/detach, timeupdate loop // ═══════════════════════════════════════════════════════════════════════ function detachVideo() { if (!videoElement) return; if (timeUpdateHandler) { videoElement.removeEventListener("timeupdate", timeUpdateHandler); timeUpdateHandler = null; } if (loadedDataHandler) { videoElement.removeEventListener("loadeddata", loadedDataHandler); loadedDataHandler = null; } videoElement = null; activeCues = []; _dedupedCues = []; _transUnits = []; _votApplied = false; _votOriginal = null; _votTranslated = null; showOverlay(""); log("Detached"); } async function attachToVideo(video) { if (!video || videoElement === video) return; detachVideo(); videoElement = video; activeCues = []; showOverlay(""); log("Attached:", video); await loadSubtitlesForCurrentVideo(); timeUpdateHandler = function () { if (videoElement) onTimeUpdate(); }; loadedDataHandler = async function () { log("loadeddata"); await loadSubtitlesForCurrentVideo(); }; videoElement.addEventListener("timeupdate", timeUpdateHandler); videoElement.addEventListener("loadeddata", loadedDataHandler); } function onTimeUpdate() { if (!videoElement) return; _timeUpdateCount++; var curTime = videoElement.currentTime; // Seek detection: rebuild translation queue if (Math.abs(curTime - _lastVideoTime) > 2 && _dedupedCues.length) { translationStart(_transUnits, curTime); } _lastVideoTime = curTime; // Retry subtitle loading var now = Date.now(); if (!activeCues.length && (now - _lastLoadAttempt) > 2000) { _lastLoadAttempt = now; loadSubtitlesForCurrentVideo(); } if (_timeUpdateCount === 1 || _timeUpdateCount % 60 === 0) { log("tick #" + _timeUpdateCount, "t=" + curTime.toFixed(1), "cues=" + activeCues.length, "hits=" + _cueHitCount); } var cue = findCurrentCue(_dedupedCues.length ? _dedupedCues : activeCues, curTime); if (cue) { _cueHitCount++; showOverlay(cue.text); } else showOverlay(""); } // ── Live translation handler (event from showOverlay) ──────────────── function onSubtitleChange(e) { if (!TRANSLATE_ENABLED) return; var text = (e.detail && e.detail.text) || ""; if (!text || !text.trim()) return; var cached = cacheGet(text); if (cached !== undefined) { showTranslation(cached); return; } translationLive(text, _dedupedCues.length ? _dedupedCues : activeCues); } // ── Video change observer ───────────────────────────────────────────── function checkVideoChange(source) { var v = findBestVideo(); if (v && v !== currentVideo) { currentVideo = v; log("Video changed (" + source + ")"); attachToVideo(v); } } // ═══════════════════════════════════════════════════════════════════════ // 12. BOOTSTRAP // ═══════════════════════════════════════════════════════════════════════ function main() { var isReboot = !!document.documentElement.getAttribute("data-vot-reboot"); if (!isReboot) log("Starting VK Subtitle Extractor [document-start]"); document.documentElement.setAttribute("data-vot-reboot", "1"); hookAttachShadow(); hookVideoPlay(); document.addEventListener("vot-subtitle-change", onSubtitleChange); function onBodyReady() { var domTimer = null; var domObs = new MutationObserver(function () { if (domTimer) return; domTimer = setTimeout(function () { domTimer = null; checkVideoChange("DOM mutation"); }, 250); }); domObs.observe(document.documentElement, { childList: true, subtree: true }); log("DOM observer active"); createSubtitlePanel(); waitForVideo().then(function (v) { if (v) { currentVideo = v; attachToVideo(v); } else log("No video found, waiting..."); }).catch(function (e) { warn("waitForVideo:", e); }); } if (document.body) onBodyReady(); else { var bodyObs = new MutationObserver(function () { if (document.body) { bodyObs.disconnect(); onBodyReady(); } }); bodyObs.observe(document.documentElement, { childList: true, subtree: true }); } } try { main(); } catch (e) { warn("init error:", e); } })();