Greasy Fork is available in English.
Toggle translation for YouTube and VK videos with a fixed translation box
// ==UserScript== // @name YouTube VK Toggle Translation for French, German, Russian, Ukrainian // @namespace http://tampermonkey.net/ // @version 2.0 // @license Unlicense // @description Toggle translation for YouTube and VK videos with a fixed translation box // @author Jim Chen // @homepage https://jimchen.me // @match https://www.youtube.com/* // @match https://m.youtube.com/* // @match https://vkvideo.ru/* // @run-at document-idle // ==/UserScript== (function () { "use strict"; let lastUrl = location.href; const observer = new MutationObserver(() => { if (location.href !== lastUrl) { lastUrl = location.href; handleVideoNavigation(); } }); observer.observe(document.body, { childList: true, subtree: true }); handleVideoNavigation(); // Function to remove existing subtitle and translation elements function cleanupSubtitles() { const captionWindows = document.querySelectorAll(".caption-window"); captionWindows.forEach((window) => window.remove()); } async function handleVideoNavigation() { // Clean up existing subtitles before adding new ones cleanupSubtitles(); let subtitleURL; if (window.location.href.includes("youtube.com")) { subtitleURL = await extractSubtitleUrlYouTube(); } else { subtitleURL = await extractSubtitleUrlVK(); } if (!subtitleURL) return; if (window.location.href.includes("youtube.com")) { await addOn###btitleYouTube(subtitleURL); } else { await addOn###btitleVK(subtitleURL, 5, 1000); } } async function extractSubtitleUrlYouTube() { function extractYouTubeVideoID() { const url = window.location.href; const patterns = { standard: /(?:https?:\/\/)?(?:www\.)?youtube\.com\/watch\?v=([^&]+)/, embed: /(?:https?:\/\/)?(?:www\.)?youtube\.com\/embed\/([^?]+)/, mobile: /(?:https?:\/\/)?(?:www\.)?youtu\.be\/([^?]+)/, }; let videoID = null; if (patterns.standard.test(url)) { videoID = url.match(patterns.standard)[1]; } else if (patterns.embed.test(url)) { videoID = url.match(patterns.embed)[1]; } else if (patterns.mobile.test(url)) { videoID = url.match(patterns.mobile)[1]; } return videoID; } let videoID = extractYouTubeVideoID(); if (videoID == null) return; const playerData = await new Promise((resolve) => { const checkForPlayer = () => { let ytAppData = document.querySelector("#movie_player"); let captionData = ytAppData?.getPlayerResponse()?.captions?.playerCaptionsTracklistRenderer?.captionTracks; if (captionData) { const fetchedBaseUrl = captionData[0].baseUrl; const fetchedVideoID = fetchedBaseUrl.match(/[?&]v=([^&]+)/)?.[1]; if (fetchedVideoID !== videoID) setTimeout(checkForPlayer, 1000); else resolve(captionData); } else setTimeout(checkForPlayer, 1000); }; checkForPlayer(); }); if (!playerData) return; const hasForeignTrack = playerData.some(({ vssId }) => /(ru|uk|de|fr)/.test(vssId)); if (hasForeignTrack) { const autoGeneratedTrack = playerData.find((track) => ["a.ru", "a.uk", "a.de", "a.fr"].includes(track.vssId)); const manualTrack = playerData.find((track) => ["ru", "uk", "de", "fr"].some((code) => track.vssId.includes(code))); const otherTrack = autoGeneratedTrack || manualTrack; if (!otherTrack) return; return `${otherTrack.baseUrl}&fmt=vtt`; } } async function extractSubtitleUrlVK() { const url = window.location.href; const vkPattern = /(?:https?:\/\/)?(?:www\.)?vkvideo\.ru\/video-?\d+_(\d+)/; const vkMatch = url.match(vkPattern); if (!vkMatch) return null; const subtitleElement = document.querySelector('[id^="vk_external_ru_"]'); if (subtitleElement) { const subtitleUrl = subtitleElement.getAttribute("src"); if (subtitleUrl) return subtitleUrl; } return await new Promise((resolve) => { const checkForSubtitle = () => { const subtitleElement = document.querySelector('[id^="vk_external_ru_"]'); if (subtitleElement) { const subtitleUrl = subtitleElement.getAttribute("src"); if (subtitleUrl) resolve(subtitleUrl); else resolve(null); } else { setTimeout(checkForSubtitle, 1000); } }; checkForSubtitle(); }); } async function addOn###btitleYouTube(url, maxRetries = 5, delay = 1000) { let currentVideo = document.querySelector("video"); if (!currentVideo) return; try { console.log(`[Dual Subs] Starting Step 1, Subtitle URL ${url}`); const response = await fetch(url); const subtitleData = await response.text(); function parseVTTTime(timeStr) { const parts = timeStr.split(/[:.]/); return parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2]) + parseInt(parts[3]) / 1000; } function parseVTT(subtitleData) { const subtitleQueue = []; const lines = subtitleData.trim().split("\n"); let i = 0; while (i < lines.length && !lines[i].includes("-->")) i++; while (i < lines.length) { const line = lines[i].trim(); if (!line) { i++; continue; } const timeMatch = line.match(/(\d+:\d+:\d+\.\d+)\s+-->\s+(\d+:\d+:\d+\.\d+)/); if (timeMatch) { const start = parseVTTTime(timeMatch[1]); const end = parseVTTTime(timeMatch[2]); const textLines = []; i++; while (i < lines.length && lines[i].trim()) { textLines.push(lines[i].trim()); i++; } if (textLines.length > 0) { subtitleQueue.push({ start, end, textLines }); } } else { i++; } } return subtitleQueue; } const subtitleQueue = parseVTT(subtitleData); console.log(`[Dual Subs] Starting Step 2, Trying to Insert Subtitle Element`); function createCaptionWindow() { const videoPlayer = document.querySelector(".html5-video-player"); if (!videoPlayer) { console.error("HTML5 video player not found"); return null; } const captionWindow = document.createElement("div"); captionWindow.className = "caption-window ytp-caption-window-bottom"; captionWindow.style.cssText = ` touch-action: none; text-align: center; position: absolute; left: 50%; transform: translateX(-50%); bottom: 10%; width: 90%; max-width: 800px; `; const captionsText = document.createElement("span"); captionsText.className = "captions-text"; captionsText.style.cssText = "overflow-wrap: normal; display: block;"; const captionVisualLine = document.createElement("span"); captionVisualLine.className = "caption-visual-line"; captionVisualLine.style.cssText = "display: block;"; const ytpCaptionSegment = document.createElement("span"); ytpCaptionSegment.className = "ytp-caption-segment"; ytpCaptionSegment.style.cssText = ` display: inline-block; white-space: pre-wrap; background: rgba(8, 8, 8, 0.75); font-size: 2.5vw; color: rgb(255, 255, 255); fill: rgb(255, 255, 255); `; const translationBox = document.createElement("div"); translationBox.className = "translation-box"; translationBox.style.cssText = ` position: absolute; background: rgba(0, 0, 0, 0.9); color: white; padding: 8px; border-radius: 4px; font-size: 24px; top: -40px; left: 50%; transform: translateX(-50%); z-index: 9999; display: none; transition: opacity 0.1s ease; opacity: 1; `; captionWindow.appendChild(translationBox); captionVisualLine.appendChild(ytpCaptionSegment); captionsText.appendChild(captionVisualLine); captionWindow.appendChild(captionsText); videoPlayer.appendChild(captionWindow); return { ytpCaptionSegment, translationBox }; } const { ytpCaptionSegment, translationBox } = createCaptionWindow() || {}; if (!ytpCaptionSegment || !translationBox) return; currentVideo.addEventListener("timeupdate", () => { const currentTime = currentVideo.currentTime; const currentSubtitle = subtitleQueue.find((sub) => currentTime >= sub.start && currentTime <= sub.end); updat###btitle(currentSubtitle); }); const translationCache = new Map(); async function translateText(text, targetLang = "en") { if (translationCache.has(text)) return translationCache.get(text); try { const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${targetLang}&dt=t&q=${encodeURIComponent(text)}`; const response = await fetch(url); const data = await response.json(); const translatedText = data[0][0][0]; translationCache.set(text, translatedText); return translatedText; } catch (error) { console.error("[Dual Subs] Translation error:", error); return text; } } function debounce(func, wait) { let timeout; return function (...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; } let currentTranslation = { word: null, translation: null, visible: false, }; function updat###btitle(currentSubtitle) { while (ytpCaptionSegment.firstChild) ytpCaptionSegment.removeChild(ytpCaptionSegment.firstChild); if (!currentTranslation.visible) { translationBox.style.display = "none"; } else { translationBox.textContent = currentTranslation.translation; translationBox.style.display = "block"; } if (!currentSubtitle) { ytpCaptionSegment.style.display = "none"; return; } ytpCaptionSegment.style.display = "inline-block"; currentSubtitle.textLines.forEach((line) => { const lineSpan = document.createElement("span"); lineSpan.style.display = "block"; if (line.includes("<c>")) { const currentTime = currentVideo.currentTime; const timeTagRegex = /<(\d{2}:\d{2}:\d{2}\.\d{3})><c>(.*?)<\/c>/g; let lastIndex = 0; let wordArray = []; let timeArray = []; let match; while ((match = timeTagRegex.exec(line)) !== null) { const timeStr = match[1]; const text = match[2]; const time = parseVTTTime(timeStr); if (match.index > lastIndex) { const untaggedText = line.slice(lastIndex, match.index).trim(); if (untaggedText) { wordArray.push(untaggedText); timeArray.push(currentSubtitle.start); } } wordArray.push(text); timeArray.push(time); lastIndex = timeTagRegex.lastIndex; } if (lastIndex < line.length) { const untaggedText = line.slice(lastIndex).trim(); if (untaggedText) { wordArray.push(untaggedText); timeArray.push(currentSubtitle.start); } } let currentWordIndex = 0; for (let i = 0; i < wordArray.length; i++) { if (currentTime >= timeArray[i]) currentWordIndex = i; else break; } wordArray.forEach((word, index) => { const wordSpan = document.createElement("span"); wordSpan.textContent = word + " "; wordSpan.className = "subtitle-word"; wordSpan.style.cursor = "pointer"; if (index < currentWordIndex) { wordSpan.style.color = "#ffffff"; } else if (index === currentWordIndex && currentTime >= timeArray[index]) { const startTime = timeArray[index]; const endTime = index + 1 < timeArray.length ? timeArray[index + 1] : currentSubtitle.end; const progress = (currentTime - startTime) / (endTime - startTime); wordSpan.style.cssText = ` background: linear-gradient(to right, #ffffff 50%, #888888 50%); background-size: 200% 100%; background-position: ${100 - progress * 100}%; color: transparent; background-clip: text; -webkit-background-clip: text; transition: background-position 0.1s linear; `; } else { wordSpan.style.color = "#888888"; } const showTranslation = debounce(async () => { const translation = await translateText(word); translationBox.textContent = translation; translationBox.style.display = "block"; }, 200); wordSpan.addEventListener("mouseenter", async () => { currentTranslation.word = word; currentTranslation.visible = true; if (!translationCache.has(word)) { const translation = await translateText(word); translationBox.textContent = translation; currentTranslation.translation = translation; } else { translationBox.textContent = translationCache.get(word); currentTranslation.translation = translationCache.get(word); } translationBox.style.display = "block"; }); wordSpan.addEventListener("mouseleave", () => { currentTranslation.visible = false; translationBox.style.display = "none"; }); lineSpan.appendChild(wordSpan); }); } else { const words = line.split(" "); words.forEach((word) => { const wordSpan = document.createElement("span"); wordSpan.textContent = word + " "; wordSpan.className = "subtitle-word"; wordSpan.style.color = "#ffffff"; wordSpan.style.cursor = "pointer"; wordSpan.addEventListener("mouseenter", async () => { currentTranslation.word = word; currentTranslation.visible = true; if (!translationCache.has(word)) { const translation = await translateText(word); translationBox.textContent = translation; currentTranslation.translation = translation; } else { translationBox.textContent = translationCache.get(word); currentTranslation.translation = translationCache.get(word); } translationBox.style.display = "block"; }); wordSpan.addEventListener("mouseleave", () => { currentTranslation.visible = false; translationBox.style.display = "none"; }); lineSpan.appendChild(wordSpan); }); } ytpCaptionSegment.appendChild(lineSpan); }); } const styleSheet = document.createElement("style"); styleSheet.textContent = ` @keyframes slideColor { 0% { background-position: 100%; } 100% { background-position: 0%; } } .subtitle-word:hover { text-decoration: underline; } @media (max-width: 768px) { .ytp-caption-segment { font-size: 20px; } .translation-box { font-size: 24px; padding: 6px; } } `; document.head.appendChild(styleSheet); console.log(`[Dual Subs] Subtitle and Translation Setup Complete`); } catch (error) { console.error("[Dual Subs] Error:", error); if (maxRetries > 0) { await new Promise((resolve) => setTimeout(resolve, delay)); return addOn###btitleYouTube(url, maxRetries - 1, delay); } } } async function addOn###btitleVK(url, maxRetries = 5, delay = 1000) { let currentVideo = document.querySelector("video"); if (!currentVideo) return; try { console.log(`[Dual Subs VK] Starting Step 1, Subtitle URL ${url}`); const response = await fetch(url); const subtitleData = await response.text(); function parseVTTTime(timeStr) { const parts = timeStr.split(/[:.]/); return parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2]) + parseInt(parts[3]) / 1000; } function parseVTT(subtitleData) { const subtitleQueue = []; const lines = subtitleData.trim().split("\n"); let i = 0; while (i < lines.length && !lines[i].includes("-->")) i++; while (i < lines.length) { const line = lines[i].trim(); if (!line) { i++; continue; } const timeMatch = line.match(/(\d+:\d+:\d+\.\d+)\s+-->\s+(\d+:\d+:\d+\.\d+)/); if (timeMatch) { const start = parseVTTTime(timeMatch[1]); const end = parseVTTTime(timeMatch[2]); const textLines = []; i++; while (i < lines.length && lines[i].trim()) { textLines.push(lines[i].trim()); i++; } if (textLines.length > 0) { subtitleQueue.push({ start, end, textLines }); } } else { i++; } } return subtitleQueue; } const subtitleQueue = parseVTT(subtitleData); console.log(`[Dual Subs VK] Starting Step 2, Trying to Insert Subtitle Element for VK`); function createCaptionWindow() { const videoPlayer = document.querySelector(".videoplayer_media"); if (!videoPlayer) { console.error("VK video player container not found"); return null; } const captionWindow = document.createElement("div"); captionWindow.className = "caption-window vk-caption-window-bottom"; captionWindow.style.cssText = ` touch-action: none; text-align: center; position: absolute; left: 50%; transform: translateX(-50%); bottom: 10%; width: 90%; max-width: 800px; `; const captionsText = document.createElement("span"); captionsText.className = "captions-text"; captionsText.style.cssText = "overflow-wrap: normal; display: block;"; const captionVisualLine = document.createElement("span"); captionVisualLine.className = "caption-visual-line"; captionVisualLine.style.cssText = "display: block;"; const vkCaptionSegment = document.createElement("span"); vkCaptionSegment.className = "vk-caption-segment"; vkCaptionSegment.style.cssText = ` display: inline-block; white-space: pre-wrap; background: rgba(8, 8, 8, 0.75); font-size: 2.5vw; color: rgb(255, 255, 255); fill: rgb(255, 255, 255); `; const translationBox = document.createElement("div"); translationBox.className = "translation-box"; translationBox.style.cssText = ` position: absolute; background: rgba(0, 0, 0, 0.9); color: white; padding: 8px; border-radius: 4px; font-size: 24px; top: -40px; left: 50%; transform: translateX(-50%); z-index: 9999; display: none; transition: opacity 0.1s ease; opacity: 1; `; captionWindow.appendChild(translationBox); captionVisualLine.appendChild(vkCaptionSegment); captionsText.appendChild(captionVisualLine); captionWindow.appendChild(captionsText); videoPlayer.appendChild(captionWindow); return { vkCaptionSegment, translationBox }; } const { vkCaptionSegment, translationBox } = createCaptionWindow() || {}; if (!vkCaptionSegment || !translationBox) return; currentVideo.addEventListener("timeupdate", () => { const currentTime = currentVideo.currentTime; const currentSubtitle = subtitleQueue.find((sub) => currentTime >= sub.start && currentTime <= sub.end); updat###btitle(currentSubtitle); }); const translationCache = new Map(); async function translateText(text, targetLang = "en") { if (translationCache.has(text)) return translationCache.get(text); try { const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${targetLang}&dt=t&q=${encodeURIComponent(text)}`; const response = await fetch(url); const data = await response.json(); const translatedText = data[0][0][0]; translationCache.set(text, translatedText); return translatedText; } catch (error) { console.error("[Dual Subs VK] Translation error:", error); return text; } } function debounce(func, wait) { let timeout; return function (...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; } let currentTranslation = { word: null, translation: null, visible: false, }; function updat###btitle(currentSubtitle) { while (vkCaptionSegment.firstChild) vkCaptionSegment.removeChild(vkCaptionSegment.firstChild); if (!currentTranslation.visible) { translationBox.style.display = "none"; } else { translationBox.textContent = currentTranslation.translation; translationBox.style.display = "block"; } if (!currentSubtitle) { vkCaptionSegment.style.display = "none"; return; } vkCaptionSegment.style.display = "inline-block"; currentSubtitle.textLines.forEach((line) => { const lineSpan = document.createElement("span"); lineSpan.style.display = "block"; if (line.includes("<c>")) { const currentTime = currentVideo.currentTime; const timeTagRegex = /<(\d{2}:\d{2}:\d{2}\.\d{3})><c>(.*?)<\/c>/g; let lastIndex = 0; let wordArray = []; let timeArray = []; let match; while ((match = timeTagRegex.exec(line)) !== null) { const timeStr = match[1]; const text = match[2]; const time = parseVTTTime(timeStr); if (match.index > lastIndex) { const untaggedText = line.slice(lastIndex, match.index).trim(); if (untaggedText) { wordArray.push(untaggedText); timeArray.push(currentSubtitle.start); } } wordArray.push(text); timeArray.push(time); lastIndex = timeTagRegex.lastIndex; } if (lastIndex < line.length) { const untaggedText = line.slice(lastIndex).trim(); if (untaggedText) { wordArray.push(untaggedText); timeArray.push(currentSubtitle.start); } } let currentWordIndex = 0; for (let i = 0; i < wordArray.length; i++) { if (currentTime >= timeArray[i]) currentWordIndex = i; else break; } wordArray.forEach((word, index) => { const wordSpan = document.createElement("span"); wordSpan.textContent = word + " "; wordSpan.className = "subtitle-word"; wordSpan.style.cursor = "pointer"; if (index < currentWordIndex) { wordSpan.style.color = "#ffffff"; } else if (index === currentWordIndex && currentTime >= timeArray[index]) { const startTime = timeArray[index]; const endTime = index + 1 < timeArray.length ? timeArray[index + 1] : currentSubtitle.end; const progress = (currentTime - startTime) / (endTime - startTime); wordSpan.style.cssText = ` background: linear-gradient(to right, #ffffff 50%, #888888 50%); background-size: 200% 100%; background-position: ${100 - progress * 100}%; color: transparent; background-clip: text; -webkit-background-clip: text; transition: background-position 0.1s linear; `; } else { wordSpan.style.color = "#888888"; } wordSpan.addEventListener("mouseenter", async () => { currentTranslation.word = word; currentTranslation.visible = true; if (!translationCache.has(word)) { const translation = await translateText(word); translationBox.textContent = translation; currentTranslation.translation = translation; } else { translationBox.textContent = translationCache.get(word); currentTranslation.translation = translationCache.get(word); } translationBox.style.display = "block"; }); wordSpan.addEventListener("mouseleave", () => { currentTranslation.visible = false; translationBox.style.display = "none"; }); lineSpan.appendChild(wordSpan); }); } else { const words = line.split(" "); words.forEach((word) => { const wordSpan = document.createElement("span"); wordSpan.textContent = word + " "; wordSpan.className = "subtitle-word"; wordSpan.style.color = "#ffffff"; wordSpan.style.cursor = "pointer"; wordSpan.addEventListener("mouseenter", async () => { currentTranslation.word = word; currentTranslation.visible = true; if (!translationCache.has(word)) { const translation = await translateText(word); translationBox.textContent = translation; currentTranslation.translation = translation; } else { translationBox.textContent = translationCache.get(word); currentTranslation.translation = translationCache.get(word); } translationBox.style.display = "block"; }); wordSpan.addEventListener("mouseleave", () => { currentTranslation.visible = false; translationBox.style.display = "none"; }); lineSpan.appendChild(wordSpan); }); } vkCaptionSegment.appendChild(lineSpan); }); } const styleSheet = document.createElement("style"); styleSheet.textContent = ` .vk-caption-window-bottom { z-index: 999999 !important; pointer-events: auto !important; } .vk-caption-segment { z-index: 999999 !important; pointer-events: auto !important; } .subtitle-word { z-index: 999999 !important; pointer-events: auto !important; } @keyframes slideColor { 0% { background-position: 100%; } 100% { background-position: 0%; } } .subtitle-word:hover { text-decoration: underline; } @media (max-width: 768px) { .vk-caption-segment { font-size: 20px; } .translation-box { font-size: 24px; padding: 6px; } } `; document.head.appendChild(styleSheet); console.log(`[Dual Subs VK] Subtitle and Translation Setup Complete`); } catch (error) { console.error("[Dual Subs VK] Error:", error); if (maxRetries > 0) { await new Promise((resolve) => setTimeout(resolve, delay)); return addOn###btitleVK(url, maxRetries - 1, delay); } } } })();