// ==UserScript== // @name VKvideo 实时双语字幕 // @version 1.0.3 // @description 使用google翻译API,支持俄网VK video 实时双语字幕播放,下载,字幕视频定位 // @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 // @run-at document-start // ==/UserScript== (function () { "use strict"; // ── Logging ────────────────────────────────────────────────────────── const log = (...args) => console.log("[VK Subs]", ...args); const warn = (...args) => console.warn("[VK Subs]", ...args); // ── Translation config ─────────────────────────────────────────────── var TRANSLATE_TO = "zh"; // target language: zh/en/ja/ko/de/fr/es... var TRANSLATE_ENABLED = true; // set to false to disable translation // ── Encoding detection ──────────────────────────────────────────────── // // WHY: VK's auto-generated subtitle VTT files from okcdn.ru are sometimes // Windows‑1251 encoded (Russian), NOT UTF‑8. A naive .text() produces // mojibake. We download raw bytes (responseType:"arraybuffer") and try // multiple encodings. See docs/decisions.md §3. // // Heuristic: U+FFFD replacement‑character density >3% = wrong encoding. // When Windows‑1251 Russian is decoded as UTF‑8, almost every byte in the // 0xC0‑0xFF range becomes an invalid UTF‑8 sequence → massive U+FFFD output. const ENCODINGS = ["utf-8", "windows-1251", "koi8-r", "iso-8859-5"]; const VTT_HEADER_RE = /^WEBVTT/mu; const VTT_TIMING_ARROW = "-->"; function decodeBuffer(buffer, encoding) { try { return new TextDecoder(encoding, { fatal: false }).decode(buffer); } catch { return null; } } /** * Detect whether a decoded string is likely mojibake (wrong encoding). * * When Windows-1251 Russian text is decoded as UTF-8, almost every byte * in the 0xC0-0xFF range (which covers all Cyrillic codepoints in cp1251) * becomes an invalid UTF-8 sequence, producing U+FFFD replacement chars. * * A correct decoding should have near-zero replacement characters. */ function isLikelyMojibake(text) { var replacementCount = 0; for (var i = 0; i < text.length; i++) { if (text.charCodeAt(i) === 0xfffd) replacementCount++; } // If >3% of characters are replacement chars, the encoding is likely wrong. return replacementCount > text.length * 0.03; } function looksLikeVttContent(text) { return VTT_HEADER_RE.test(text) || text.indexOf("-->") !== -1; } function decodeTextFromBuffer(buffer) { let bestFallback = ""; for (const encoding of ENCODINGS) { const decoded = decodeBuffer(buffer, encoding); if (!decoded) continue; if (looksLikeVttContent(decoded) && !isLikelyMojibake(decoded)) { log("decoded VTT with encoding:", encoding, `(${decoded.length} chars)`); return decoded; } // Keep the first candidate in case no encoding passes the heuristic. // We still prefer a mojibake‑free result later in the list over an // earlier mojibake result. if (!bestFallback && looksLikeVttContent(decoded)) { bestFallback = decoded; } } if (bestFallback) { warn("all encodings produced mojibake; using best-effort fallback"); return bestFallback; } // Last resort: first encoding without any heuristic return decodeBuffer(buffer, ENCODINGS[0]) || ""; } // ── VTT Parser ──────────────────────────────────────────────────────── // VOT‑style VTT parser. Handles the common VTT dialect served by VK. const VTT_TIMING_RE = /^(?(?:\d{2}:)?\d{2}:\d{2}\.\d{3})\s+-->\s+(?(?:\d{2}:)?\d{2}:\d{2}\.\d{3})/u; function parseClockTime(value) { const parts = value.trim().split(":"); if (parts.length === 3) { const h = parseInt(parts[0], 10); const m = parseInt(parts[1], 10); const s = parseFloat(parts[2]); return (h * 3600 + m * 60 + s) * 1000; } if (parts.length === 2) { const m = parseInt(parts[0], 10); const s = parseFloat(parts[1]); return (m * 60 + s) * 1000; } return NaN; } function parseVtt(text) { const normalized = text.replace(/\r/g, "").replace(/^/, ""); const lines = normalized.split("\n"); if (!lines[0] || !lines[0].startsWith("WEBVTT")) { return []; } const cues = []; let i = 1; while (i < lines.length) { // skip empty lines and non-timing block headers (NOTE, STYLE, REGION) 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; } // optional cue id if (!VTT_TIMING_RE.test(lines[i])) { i++; continue; } const timingMatch = VTT_TIMING_RE.exec(lines[i]); if (!timingMatch) { i++; continue; } const startMs = parseClockTime(timingMatch.groups.start); const endMs = parseClockTime(timingMatch.groups.end); i++; const textLines = []; while (i < lines.length && lines[i].trim() !== "") { let line = lines[i]; // strip HTML-like tags and entities line = line .replace(/<[^>]+>/g, "") .replace(/ /gi, " ") .replace(/&/gi, "&") .replace(/</gi, "<") .replace(/>/gi, ">") .replace(/"/gi, '"') .replace(/'/g, "'"); textLines.push(line); i++; } const cueText = textLines.join("\n").trim(); if (!isNaN(startMs) && !isNaN(endMs) && cueText) { cues.push({ start: startMs / 1000, end: endMs / 1000, text: cueText }); } } // sort by start time cues.sort((a, b) => a.start - b.start); return cues; } // ── Overlay UI ──────────────────────────────────────────────────────── // // WHY: VK's video lives inside 's Shadow DOM. A // fixed‑position overlay in the light DOM gets occluded by the player's // stacking context — the user cannot see it. We mount INTO the shadow root // (same technique as VOT's resolveOverlayMountTargets). // See docs/decisions.md §7. var OVERLAY_ID = "vot-vk-sub-overlay"; var overlay = null; var overlayMountRoot = null; // shadow root or document.body /** * Find the best mount point for the overlay. Prefers the shadow root * of because that's where the video lives. */ function resolveOverlayMount() { // Try VK's player shadow root first var player = document.querySelector("vk-video-player"); if (player && player.shadowRoot) { return player.shadowRoot; } // Fallback: any shadow root that contains a video var allEls = document.querySelectorAll("*"); for (var i = 0; i < allEls.length; i++) { var sr = allEls[i].shadowRoot; if (sr && sr.querySelector("video")) { return sr; } } // Last resort: document body return document.body; } // ── Overlay vertical-drag state ──────────────────────────────────── var _dragY = null; // { startY, startBottom } var _userBottom = 64; // persisted px from bottom edge (default 64) /** Get the container height (px) for drag clamping. */ 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(mountRoot) { var el = document.createElement("div"); el.id = OVERLAY_ID; // Subtitle box itself 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);"; // Original subtitle text span (selectable) 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); // Translation text span (hidden until translation arrives) 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 on TOP of the box ──────────────────────────────── // WHY: We tried a side handle (flex column) but it overlapped with // long text. A top‑mounted bar completely separates the drag zone // from the text area. See docs/decisions.md §8. 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); // ── Drag handlers — only on the 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 newBottom = _dragY.startBottom + dy; var parentH = getContainerHeight(el); var maxBottom = Math.max(8, parentH - 56); _userBottom = Math.max(8, Math.min(maxBottom, newBottom)); 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); // Double‑click handle to reset position handle.addEventListener("dblclick", function () { _userBottom = 64; el.style.bottom = "64px"; }); if (mountRoot) { mountRoot.appendChild(el); } return el; } function ensureOverlayInDOM() { var mount = resolveOverlayMount(); if (!mount) return; // If mount point changed, recreate overlay in new location if (mount !== overlayMountRoot) { if (overlay && overlay.parentNode) { overlay.parentNode.removeChild(overlay); } overlay = null; overlayMountRoot = null; } if (!overlay) { overlay = document.getElementById(OVERLAY_ID); // The existing overlay might be in a different root if (!overlay || !overlay.parentNode) { overlay = createOverlay(mount); } overlayMountRoot = mount; } // Safety: if overlay is orphaned, re-append to current mount if (!overlay.parentNode && mount) { overlay = createOverlay(mount); overlayMountRoot = mount; } } // // WHY: We skip re-rendering when the text is unchanged to prevent the // translation module from re-firing for the same sentence on every // timeupdate tick (60×/sec). Without this guard the translation would // appear to "flicker" as it gets re-applied each frame. // function showOverlay(text) { ensureOverlayInDOM(); if (!overlay) return; if (!text || !text.trim()) { overlay.style.display = "none"; return; } var displayText = text.replace(/[\r\n]+/g, " ").replace(/\s{2,}/g, " ").trim(); if (overlay.firstChild.textContent === displayText && overlay.style.display === "block") { return; } overlay.firstChild.textContent = displayText; overlay.style.display = "block"; // Notify translation module if (TRANSLATE_ENABLED) { try { overlay.dispatchEvent(new CustomEvent("vot-subtitle-change", { detail: { text: displayText }, bubbles: true, composed: true })); } catch (e) {} } } 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); } // ═══════════════════════════════════════════════════════════════════════ // ── Translation module ────────────────────────────────────────────── // ═══════════════════════════════════════════════════════════════════════ // // WHY: We pre‑translate all cues in the background (starting from the // current playback position) so that when showOverlay fires, the // translation is already cached → zero‑latency display. // // Architecture: buildTranslationQueue → pumpTranslationQueue (250ms // interval to avoid rate‑limiting) → showTranslation on cache hit. // Live cache‑miss fallback via onSubtitleChange → translateViaApi. // To disable: TRANSLATE_ENABLED = false at top of script. // To change API: edit translateViaApi(). // See docs/decisions.md §4 and §5. var _transCache = {}; // { "source text": "translated text" } var _transCacheSize = 0; var _transCacheMax = 2000; var _transQueue = []; // texts still to translate (ordered by priority) var _transTimer = null; // setTimeout id for next batch var _transDone = 0; var _transTotal = 0; var _transRunning = false; var _liveTransPending = null; var _lastVideoTime = 0; // for seek detection /** Call the translation API (same as before, replace body to switch). */ function translateViaApi(text, targetLang) { return new Promise(function (resolve, reject) { var url = "https://translate.googleapis.com/translate_a/single" + "?client=gtx&sl=auto&tl=" + encodeURIComponent(targetLang) + "&dt=t&q=" + encodeURIComponent(text); GM.xmlHttpRequest({ method: "GET", url: url, timeout: 5000, onload: function (resp) { try { var data = JSON.parse(resp.responseText); var translated = (data[0] || []).map(function (seg) { return (seg || [])[0] || ""; }).join(""); if (translated) resolve(translated); else reject(new Error("Empty translation")); } catch (e) { reject(e); } }, onerror: function () { reject(new Error("API error")); }, ontimeout: function () { reject(new Error("Timeout")); }, }); }); } /** Process next item in the translation queue. */ function pumpTranslationQueue() { if (!TRANSLATE_ENABLED || !_transRunning) return; if (_transQueue.length === 0) { _transRunning = false; _transTimer = null; updateTransProgress(); return; } var text = _transQueue.shift(); translateViaApi(text, TRANSLATE_TO).then(function (result) { _transCache[text] = result; _transCacheSize++; if (_transCacheSize > _transCacheMax) { var firstKey = Object.keys(_transCache)[0]; if (firstKey) { delete _transCache[firstKey]; _transCacheSize--; } } _transDone++; updateTransProgress(); // Continue after a short delay to avoid rate-limiting _transTimer = setTimeout(pumpTranslationQueue, 250); }).catch(function (err) { warn("Translation worker error:", err && err.message); _transQueue.push(text); _transTimer = setTimeout(pumpTranslationQueue, 1000); }); } /** * Build a priority‑sorted translation queue. * WHY: Sorting from the current playback position means the sentences * the user will see next are translated FIRST. Pre‑translation covers * the most time‑critical content before the background worker finishes * the rest. See docs/decisions.md §5. */ function buildTranslationQueue() { if (!TRANSLATE_ENABLED) return; var cues = _dedupedCues; if (!cues.length) return; // Collect unique texts to translate var seen = {}; var items = []; for (var i = 0; i < cues.length; i++) { var text = cues[i].text; if (!text || !text.trim() || seen[text] || _transCache[text] !== undefined) continue; seen[text] = true; items.push({ text: text, index: i }); } if (!items.length) { _transTotal = 0; _transDone = 0; updateTransProgress(); return; } // Find current playback index var curTime = videoElement ? videoElement.currentTime : 0; var curIdx = 0; for (var j = 0; j < cues.length; j++) { if (curTime >= cues[j].start && curTime < cues[j].end) { curIdx = j; break; } } // Sort: items closest to current position first items.sort(function (a, b) { var da = Math.abs(a.index - curIdx); var db = Math.abs(b.index - curIdx); return da - db; }); _transQueue = []; for (var k = 0; k < items.length; k++) _transQueue.push(items[k].text); _transTotal = _transQueue.length; _transDone = 0; updateTransProgress(); } /** Start (or restart) the translation worker. */ function startTranslationWorker() { if (!TRANSLATE_ENABLED) return; buildTranslationQueue(); if (_transRunning) return; _transRunning = true; pumpTranslationQueue(); } /** Update the panel header with translation progress. */ function updateTransProgress() { var titleEl = document.querySelector("#vot-vk-sub-panel [data-trans-title]"); if (!titleEl) return; if (_transTotal === 0) { titleEl.textContent = "Subtitles"; return; } if (_transDone >= _transTotal) { titleEl.textContent = "Subtitles ✓"; return; } titleEl.textContent = "Subtitles (" + _transDone + "/" + _transTotal + ")"; } /** Show translation in overlay with full opacity. */ function showTranslation(translatedText) { if (!overlay || !overlay.parentNode) return; var transEl = overlay.querySelector(".vot-trans"); if (!transEl) return; transEl.textContent = translatedText || ""; } /** Called when overlay text changes (only fires on actual change). */ function onSubtitleChange(e) { if (!TRANSLATE_ENABLED) return; var text = (e.detail && e.detail.text) || ""; if (!text || !text.trim()) return; // Cache hit → instant (pre-translated by worker) var cached = _transCache[text]; if (cached !== undefined) { showTranslation(cached); return; } // Cache miss → live translate (rare after pre-translation) if (_liveTransPending === text) return; // already fetching _liveTransPending = text; translateViaApi(text, TRANSLATE_TO).then(function (result) { _transCache[text] = result; _transCacheSize++; if (overlay && overlay.firstChild.textContent === text) { showTranslation(result); } _liveTransPending = null; }).catch(function () { _liveTransPending = null; }); } function initTranslationModule() { document.addEventListener("vot-subtitle-change", onSubtitleChange); } // ── Subtitle download ────────────────────────────────────────────────── /** * Convert activeCues back to a valid VTT file for download. * Used when the original VTT text isn't available (textTracks / page state). */ function serializeCuesToVtt(cues) { var lines = ["WEBVTT", ""]; for (var i = 0; i < cues.length; i++) { var c = cues[i]; var startSec = c.start; var endSec = c.end; var sh = Math.floor(startSec / 3600); var sm = Math.floor((startSec % 3600) / 60); var ss = Math.floor(startSec % 60); var sms = Math.floor((startSec % 1) * 1000); var eh = Math.floor(endSec / 3600); var em = Math.floor((endSec % 3600) / 60); var es = Math.floor(endSec % 60); var ems = Math.floor((endSec % 1) * 1000); var startStr = (sh > 0 ? (sh < 10 ? "0" : "") + sh + ":" : "") + (sm < 10 ? "0" : "") + sm + ":" + (ss < 10 ? "0" : "") + ss + "." + (sms < 100 ? (sms < 10 ? "00" : "0") : "") + sms; var endStr = (eh > 0 ? (eh < 10 ? "0" : "") + eh + ":" : "") + (em < 10 ? "0" : "") + em + ":" + (es < 10 ? "0" : "") + es + "." + (ems < 100 ? (ems < 10 ? "00" : "0") : "") + ems; lines.push(startStr + " --> " + endStr); lines.push(c.text); lines.push(""); } return lines.join("\n"); } function downloadSubtitles() { var vttData = _rawVttText || serializeCuesToVtt(activeCues); if (!vttData) { warn("No subtitle data to download"); return; } try { var blob = new Blob([vttData], { type: "text/vtt;charset=utf-8" }); var url = URL.createObjectURL(blob); var a = document.createElement("a"); a.href = url; a.download = "vk-subtitles-" + (_loadedVideoId || "video") + ".vtt"; a.style.display = "none"; document.body.appendChild(a); a.click(); setTimeout(function () { document.body.removeChild(a); URL.revokeObjectURL(url); }, 100); log("Download triggered: " + a.download); } catch (e) { warn("Download failed:", e); } } // ── Subtitle list panel ─────────────────────────────────────────────── var _panelEl = null; var _panelListEl = null; var _panelBaseX = null; // initial left px var _panelBaseY = 80; // initial top px var _panelOffX = 0; // drag offset X var _panelOffY = 0; // drag offset Y var _panelCollapsed = false; var _panelScrollLock = false; var _panelScrollTimer = null; var _highlightInterval = null; var _cueRows = []; var _dedupedCues = []; // cues after dedup, matching _cueRows order var _highlightedIdx = -1; function formatTime(seconds) { var m = Math.floor(seconds / 60); var s = Math.floor(seconds % 60); return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s; } function createSubtitlePanel() { if (_panelEl) return; if (_panelBaseX == null) _panelBaseX = window.innerWidth - 360; var panel = document.createElement("div"); panel.id = "vot-vk-sub-panel"; // WHY: Panel uses position:fixed in light DOM (not shadow root) so it // can be freely dragged anywhere on screen. will-change:transform hints // the browser to GPU‑accelerate the drag. backdrop-filter removed // because it caused jank during drag (forced repaint on every frame). 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 bar ────────────────────────────────────────────────── 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);"; // Title (also shows translation progress) 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); // Download button var dlBtn = document.createElement("button"); dlBtn.textContent = "⬇"; dlBtn.title = "Download VTT"; dlBtn.style.cssText = "background:rgba(255,255,255,0.1);border:none;color:#fff;" + "cursor:pointer;border-radius:4px;width:28px;height:22px;" + "font-size:13px;line-height:1;padding:0;flex-shrink:0;"; // WHY: stopPropagation on pointerdown prevents the header's drag handler // from capturing the pointer before the button's click event fires. // See docs/decisions.md §11. dlBtn.addEventListener("pointerdown", function (e) { e.stopPropagation(); }); dlBtn.addEventListener("click", function (e) { e.stopPropagation(); downloadSubtitles(); }); dlBtn.addEventListener("mouseenter", function () { dlBtn.style.background = "rgba(255,255,255,0.25)"; }); dlBtn.addEventListener("mouseleave", function () { dlBtn.style.background = "rgba(255,255,255,0.1)"; }); header.appendChild(dlBtn); // Scroll-to-current button var scBtn = document.createElement("button"); scBtn.textContent = "⊙"; scBtn.title = "Scroll to current"; scBtn.style.cssText = "background:rgba(255,255,255,0.1);border:none;color:#fff;" + "cursor:pointer;border-radius:4px;width:22px;height:22px;" + "font-size:12px;line-height:1;padding:0;flex-shrink:0;"; scBtn.addEventListener("pointerdown", function (e) { e.stopPropagation(); }); scBtn.addEventListener("click", function (e) { e.stopPropagation(); _panelScrollLock = false; updatePanelHighlight(); }); scBtn.addEventListener("mouseenter", function () { scBtn.style.background = "rgba(255,255,255,0.25)"; }); scBtn.addEventListener("mouseleave", function () { scBtn.style.background = "rgba(255,255,255,0.1)"; }); header.appendChild(scBtn); // Collapse button var colBtn = document.createElement("button"); colBtn.textContent = "−"; colBtn.title = "Collapse"; colBtn.style.cssText = "background:rgba(255,255,255,0.1);border:none;color:#fff;" + "cursor:pointer;border-radius:4px;width:22px;height:22px;" + "font-size:14px;line-height:1;padding:0;flex-shrink:0;"; colBtn.addEventListener("pointerdown", function (e) { e.stopPropagation(); }); colBtn.addEventListener("click", function (e) { e.stopPropagation(); _panelCollapsed = !_panelCollapsed; colBtn.textContent = _panelCollapsed ? "+" : "−"; listContainer.style.display = _panelCollapsed ? "none" : ""; }); colBtn.addEventListener("mouseenter", function () { colBtn.style.background = "rgba(255,255,255,0.25)"; }); colBtn.addEventListener("mouseleave", function () { colBtn.style.background = "rgba(255,255,255,0.1)"; }); header.appendChild(colBtn); panel.appendChild(header); // ── Drag: GPU-accelerated via transform ───────────────────────── var _panelDrag = null; header.addEventListener("pointerdown", function (e) { if (e.button !== undefined && e.button !== 0) return; e.preventDefault(); header.setPointerCapture(e.pointerId); _panelDrag = { startX: e.clientX, startY: e.clientY, offX: _panelOffX, offY: _panelOffY }; }); header.addEventListener("pointermove", function (e) { if (!_panelDrag) return; _panelOffX = _panelDrag.offX + (e.clientX - _panelDrag.startX); _panelOffY = _panelDrag.offY + (e.clientY - _panelDrag.startY); applyPanelTransform(); }); var endPanelDrag = function () { _panelDrag = null; try { header.releasePointerCapture(1); } catch (e) {} }; header.addEventListener("pointerup", endPanelDrag); header.addEventListener("pointercancel", endPanelDrag); // ── Scrollable 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(); startHighlightTimer(); log("Subtitle panel created"); } function applyPanelTransform() { if (!_panelEl) return; _panelEl.style.transform = "translate(" + _panelOffX + "px, " + _panelOffY + "px)"; } /** * Deduplicate cues that share the same start time or have substantial * overlap. VK's auto-generated VTT often emits a short partial phrase * followed immediately by the full sentence at the same timestamp. * We keep only the best (longest text, latest start) version. */ // // WHY: VK's auto‑generated VTT contains short→long corrections at the // same timestamp (differing by ~0.01s). Without dedup the panel shows // duplicate entries. Tolerance 0.05s catches these. Overlap >50% of // the shorter cue keeps the later (corrected) version. // See docs/decisions.md §6. // function dedupCues(cues) { if (!cues.length) return []; var result = [cues[0]]; for (var i = 1; i < cues.length; i++) { var prev = result[result.length - 1]; var cur = cues[i]; if (Math.abs(cur.start - prev.start) < 0.05) { if (cur.text.length >= prev.text.length) { result[result.length - 1] = cur; } continue; } // Overlap > 50% of the shorter cue → keep the later one (correction) var overlap = Math.min(prev.end, cur.end) - Math.max(prev.start, cur.start); var shorter = Math.min(prev.end - prev.start, cur.end - cur.start); if (overlap > 0 && shorter > 0 && overlap / shorter > 0.5) { result[result.length - 1] = cur; continue; } result.push(cur); } return result; } function renderCueList() { if (!_panelListEl) return; _panelListEl.innerHTML = ""; _cueRows = []; if (!activeCues.length) { var emptyMsg = document.createElement("div"); emptyMsg.textContent = "No subtitles loaded"; emptyMsg.style.cssText = "padding:16px;color:#666;text-align:center;"; _panelListEl.appendChild(emptyMsg); return; } var cues = dedupCues(activeCues); _dedupedCues = cues; // Kick off background translation from current playback position startTranslationWorker(); for (var i = 0; i < cues.length; i++) { var cue = cues[i]; 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(i)); // Time column 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); // Text column — allow wrapping for long sentences var textSpan = document.createElement("span"); textSpan.textContent = cue.text; textSpan.style.cssText = "flex:1;font-size:12px;line-height:1.4;" + "white-space:pre-wrap;word-break:break-word;min-width:0;"; row.appendChild(textSpan); // Click to seek row.addEventListener("click", function (cue) { return function () { if (videoElement && typeof videoElement.currentTime === "number") { videoElement.currentTime = cue.start; } }; }(cue)); _panelListEl.appendChild(row); _cueRows.push(row); } } function updatePanelHighlight() { if (!videoElement || !_cueRows.length) return; var time = videoElement.currentTime; var idx = -1; var cues = _dedupedCues; if (cues.length) { var lo = 0, hi = cues.length - 1; while (lo <= hi) { var mid = (lo + hi) >>> 1; var cue = cues[mid]; if (time >= cue.start && time < cue.end) { idx = mid; break; } if (time < cue.start) hi = mid - 1; else lo = mid + 1; } } if (idx === _highlightedIdx) return; // no change _highlightedIdx = idx; // Update row styles 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 startHighlightTimer() { if (_highlightInterval) return; _highlightInterval = setInterval(updatePanelHighlight, 500); } // Keep panel on-screen on window resize window.addEventListener("resize", function () { if (!_panelEl) return; var curX = _panelBaseX + _panelOffX; var curY = _panelBaseY + _panelOffY; if (curX + 340 > window.innerWidth) _panelOffX = window.innerWidth - 350 - _panelBaseX; if (curY > window.innerHeight - 100) _panelOffY = window.innerHeight - 200 - _panelBaseY; applyPanelTransform(); }); // ── VK player API ───────────────────────────────────────────────────── /** * With @grant in Tampermonkey, page globals like Videoview are only * accessible via unsafeWindow. */ function getPageWindow() { return (typeof unsafeWindow !== "undefined" ? unsafeWindow : globalThis); } function getSubtitleUrlsFromPlayer() { try { var pageWin = getPageWindow(); // VK's global video player API (same one VOT uses via VKHelper) if (typeof pageWin.Videoview === "undefined") return null; var player = pageWin.Videoview.getPlayerObject && pageWin.Videoview.getPlayerObject(); if (!player || !player.vars || !player.vars.subs) return null; var subs = player.vars.subs; var urls = []; var subsArray = Array.isArray(subs) ? subs : [subs]; for (var i = 0; i < subsArray.length; i++) { var sub = subsArray[i]; if (sub.url) { urls.push({ url: sub.url, lang: sub.lang || "unknown", isAuto: !!sub.is_auto, }); } } return urls.length > 0 ? urls : null; } catch (e) { warn("Failed to get subtitle URLs from VK player:", e); return null; } } // ── Subtitle fetching via GM.xmlHttpRequest ─────────────────────────── function fetchSubtitlesViaGM(url) { return new Promise((resolve, reject) => { log("Fetching subtitles via GM:", url.substring(0, 80) + "..."); const req = GM.xmlHttpRequest({ method: "GET", url: url, responseType: "arraybuffer", timeout: 10000, onload: function (resp) { if (resp.status < 200 || resp.status >= 400) { reject(new Error(`HTTP ${resp.status}`)); return; } if (!resp.response || resp.response.byteLength === 0) { reject(new Error("Empty response")); return; } // resp.response is an ArrayBuffer when responseType is "arraybuffer" const buffer = resp.response; const text = decodeTextFromBuffer(buffer); if (!text) { reject(new Error("Failed to decode subtitle text")); return; } resolve(text); }, onerror: function (err) { var msg = (err && err.error) || (err && err.statusText) || "GM XHR error"; reject(new Error(msg)); }, ontimeout: function () { reject(new Error("Timeout")); }, }); // Store for potential abort return req; }); } // ── Core state ──────────────────────────────────────────────────────── let activeCues = []; let videoElement = null; let timeUpdateHandler = null; let loadedDataHandler = null; var _timeUpdateCount = 0; var _cueHitCount = 0; var _loadingSubtitles = false; // prevents concurrent VTT downloads var _rawVttText = null; // decoded VTT text for download var _loadedVideoId = null; // for download filename // // WHY: VK's VTT often has multiple overlapping cues at the same timestamp // (short partial phrase → long full sentence correction). A naive binary // search may return different cues on consecutive timeupdate calls, causing // subtitle/translation flicker. We scan ±0.05s neighbors and return the // one with the longest text. See docs/decisions.md §6. // function findCurrentCue(time) { if (!activeCues.length) return null; var lo = 0, hi = activeCues.length - 1; var best = null; while (lo <= hi) { var mid = (lo + hi) >>> 1; var cue = activeCues[mid]; if (time >= cue.start && time < cue.end) { best = cue; // Check neighbors: VK emits multiple cues at the same timestamp // (short → long correction). Prefer the one with the longest text. var left = mid - 1; while (left >= 0 && Math.abs(activeCues[left].start - cue.start) < 0.05) { if (time >= activeCues[left].start && time < activeCues[left].end && activeCues[left].text.length > best.text.length) { best = activeCues[left]; } left--; } var right = mid + 1; while (right < activeCues.length && Math.abs(activeCues[right].start - cue.start) < 0.05) { if (time >= activeCues[right].start && time < activeCues[right].end && activeCues[right].text.length > best.text.length) { best = activeCues[right]; } right++; } return best; } if (time < cue.start) hi = mid - 1; else lo = mid + 1; } return null; } var _lastLoadAttempt = 0; function onTimeUpdate() { if (!videoElement) return; _timeUpdateCount++; // WHY: After a seek, the pre‑translation worker's priority queue is // ordered for the old position. A >2s jump triggers a queue rebuild // so upcoming sentences are translated first again. var curTime = videoElement.currentTime; if (Math.abs(curTime - _lastVideoTime) > 2 && _dedupedCues.length) { log("Seek detected, rebuilding translation queue from " + curTime.toFixed(1)); startTranslationWorker(); } _lastVideoTime = curTime; // If we don't have subtitles yet, try loading every 2 seconds. var now = Date.now(); if (!activeCues.length && (now - _lastLoadAttempt) > 2000) { _lastLoadAttempt = now; loadSubtitlesForCurrentVideo(); } // First event + every 60th: log status for debugging if (_timeUpdateCount === 1 || _timeUpdateCount % 60 === 0) { log("timeupdate #" + _timeUpdateCount + " time=" + curTime.toFixed(1) + " cues=" + activeCues.length + " hits=" + _cueHitCount); } const time = curTime; const cue = findCurrentCue(time); if (cue) { _cueHitCount++; showOverlay(cue.text); } else { showOverlay(""); } } function detachVideo() { if (!videoElement) return; if (timeUpdateHandler) { videoElement.removeEventListener("timeupdate", timeUpdateHandler); timeUpdateHandler = null; } if (loadedDataHandler) { videoElement.removeEventListener("loadeddata", loadedDataHandler); loadedDataHandler = null; } videoElement = null; activeCues = []; showOverlay(""); log("Detached from video"); } async function attachToVideo(video) { if (!video) return; if (videoElement === video) { log("Already attached to this video"); return; } detachVideo(); videoElement = video; activeCues = []; showOverlay(""); log("Attached to video element:", video); // Try to get subtitles from VK player API await loadSubtitlesForCurrentVideo(); // Setup event listeners timeUpdateHandler = () => { if (videoElement) onTimeUpdate(); }; loadedDataHandler = async () => { log("loadeddata event, re-checking subtitles"); await loadSubtitlesForCurrentVideo(); }; videoElement.addEventListener("timeupdate", timeUpdateHandler); videoElement.addEventListener("loadeddata", loadedDataHandler); log("Event listeners bound"); } async function loadSubtitlesForCurrentVideo() { if (!videoElement) return; if (_loadingSubtitles) return; _loadingSubtitles = true; try { // First try VK player API const urls = getSubtitleUrlsFromPlayer(); if (urls && urls.length > 0) { const autoSub = urls.find((s) => s.isAuto); const target = autoSub || urls[0]; log("Found subtitle URL from player API:", target.lang, "auto:", target.isAuto); try { var vttText2 = await fetchSubtitlesViaGM(target.url); var cues2 = parseVtt(vttText2); if (cues2.length > 0) { activeCues = cues2; _rawVttText = vttText2; _loadedVideoId = target.url.match(/id=(\d+)/); _loadedVideoId = _loadedVideoId ? _loadedVideoId[1] : "unknown"; renderCueList(); log("Loaded " + cues2.length + " cues from VK player API"); showStatus("Subtitles: " + cues2.length + " cues (" + target.lang + ")"); return; } } catch (e) { warn("Failed to load subtitles from player API URL:", e.message); } } else { log("No subtitles in player.vars, trying track elements..."); } // Fallback: try elements const tracks = videoElement.querySelectorAll("track"); for (var ti = 0; ti < tracks.length; ti++) { var track = tracks[ti]; if (!track.src) continue; log("Found track element:", track.src.substring(0, 80), track.srclang); try { var vttText = await fetchSubtitlesViaGM(track.src); var cues = parseVtt(vttText); if (cues.length > 0) { activeCues = cues; _rawVttText = vttText; // save for download _loadedVideoId = track.src.match(/id=(\d+)/); _loadedVideoId = _loadedVideoId ? _loadedVideoId[1] : "unknown"; renderCueList(); // rebuild panel list log("Loaded " + cues.length + " cues from track element"); showStatus("Subtitles: " + cues.length + " cues (via track)"); return; } } catch (e) { warn("Failed to load track:", e.message); } } // Last resort: textTracks API var textTracks = Array.from(videoElement.textTracks || []); for (var tti = 0; tti < textTracks.length; tti++) { var tt = textTracks[tti]; if (!["subtitles", "captions", ""].includes(tt.kind)) continue; tt.mode = "hidden"; if (tt.cues && tt.cues.length > 0) { activeCues = Array.from(tt.cues).map(function (cue) { return { start: cue.startTime, end: cue.endTime, text: cue.text.replace(/<[^>]+>/g, "").trim(), }; }); activeCues.sort(function (a, b) { return a.start - b.start; }); _rawVttText = serializeCuesToVtt(activeCues); // generate VTT for download renderCueList(); log("Loaded " + activeCues.length + " cues from textTracks API"); showStatus("Subtitles: " + activeCues.length + " cues (textTracks)"); return; } } if (scanPageStateForSubtitles()) { _rawVttText = serializeCuesToVtt(activeCues); renderCueList(); showStatus("Subtitles: " + activeCues.length + " cues (page state)"); return; } log("No subtitles found for current video"); } finally { _loadingSubtitles = false; } } // ── Page state scanning (fallback) ─────────────────────────────────── function normalizeCue(item) { if (!item || typeof item !== "object") return null; var text = item.text || item.subtitle || item.caption || item.title || null; if (!text || !text.trim()) return null; var start = parseFloat(item.start != null ? item.start : (item.begin != null ? item.begin : (item.from != null ? item.from : item.ts))); var end = parseFloat(item.end != null ? item.end : (item.to != null ? item.to : (item.finish != null ? item.finish : item.stop))); if (isNaN(start) || isNaN(end) || end <= start) return null; return { start: start, end: end, text: text.trim() }; } function extractCues(obj, results, depth) { if (depth > 6 || !obj || typeof obj !== "object") return; if (Array.isArray(obj)) { const possible = obj.filter((i) => normalizeCue(i)); if (possible.length === obj.length && obj.length > 0) { for (const i of obj) { const cue = normalizeCue(i); if (cue) results.push(cue); } return; } for (const i of obj) extractCues(i, results, depth + 1); return; } for (const key of Object.keys(obj)) { if (/subtitles?|captions?|tracks?|events?|segments?/i.test(key)) { extractCues(obj[key], results, depth + 1); } } const cue = normalizeCue(obj); if (cue) { results.push(cue); return; } for (const key of Object.keys(obj)) extractCues(obj[key], results, depth + 1); } function scanPageStateForSubtitles() { // In sandboxed context, page globals are on unsafeWindow var pageWin = getPageWindow(); var knownKeys = [ "__INITIAL_STATE__", "__PRELOADED_STATE__", "__NEXT_DATA__", "__PAGE_DATA__", "initialState", "pageData", "appState", "windowData", "data", "state", ]; for (var k = 0; k < knownKeys.length; k++) { try { var value = pageWin[knownKeys[k]]; if (!value) continue; var results = []; extractCues(value, results, 0); if (results.length > 0) { results.sort(function (a, b) { return a.start - b.start; }); activeCues = results; log("Found " + results.length + " cues in page state: " + knownKeys[k]); return true; } } catch (e) {} } return false; } // ── Video discovery ─────────────────────────────────────────────────── // // vkvideo.ru uses , a custom element with Shadow DOM. // document.querySelectorAll("video") cannot see videos inside shadow roots, // so we must search recursively and hook attachShadow to catch late‑created // shadow trees (same technique used by VOT's VideoObserver). /** * Collect all