// ==UserScript== // @name YouTube Dual Subtitles for French, German, Russian, Ukrainian // @namespace http://tampermonkey.net/ // @version 1.1 // @license Unlicense // @description Add dual subtitles to YouTube videos // @author Jim Chen // @homepage https://jimchen.me // @match https://www.youtube.com/* // @match https://m.youtube.com/* // @match https://cdn.jimchen.me/* // @run-at document-idle // @downloadURL none // ==/UserScript== (function () { "use strict"; console.log("[Dual Subs] Script initialized"); let processingSubtitles = false; async function handleVideoNavigation() { console.log("[Dual Subs] Navigation detected"); let videoID = extractYouTubeVideoID(); if (videoID == null) return; console.log(`[Dual Subs] videoID ${videoID}`); if (processingSubtitles) { console.log(`[Dual Subs] Processed Subtitles, Returning`); return; } processingSubtitles = true; removeSubs(); await processSubtitles(); processingSubtitles = false; } 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; } async function processSubtitles() { console.log("[Dual Subs] Starting subtitle processing"); const playerData = await new Promise((resolve) => { const checkForPlayer = () => { console.log("[Dual Subs] Trying to get Caption Data"); 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]; let videoID = extractYouTubeVideoID(); console.log(`[Dual Subs] fetchedVideoID ${fetchedVideoID}`); if (fetchedVideoID !== videoID) { console.log(`[Dual Subs] fetchedVideoID !== videoID`); setTimeout(checkForPlayer, 1000); } else { console.log("[Dual Subs] Successfully retrieved caption data"); resolve(captionData); } } else { console.log("[Dual Subs] Caption data not found, retrying"); setTimeout(checkForPlayer, 1000); } }; checkForPlayer(); }); if (!playerData) { console.log("[Dual Subs] No player data available"); return; } await addSubtitles(playerData); } async function addSubtitles(playerData) { console.log("[Dual Subs] Finding auto-generated track"); 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) { console.log("[Dual Subs] I am not learning the language of the video"); return; } await addOneSubtitle(`${otherTrack.baseUrl}&fmt=vtt&tlang=en`); await addOneSubtitle(`${otherTrack.baseUrl}&fmt=vtt`); } else { const otherTrack = playerData.find((track) => ["a.en", "en"].some((code) => track.vssId.includes(code))); await addOneSubtitle(`${otherTrack.baseUrl}&fmt=vtt`); await addOneSubtitle(`${otherTrack.baseUrl}&fmt=vtt&tlang=ru`); } } async function addOneSubtitle(url, maxRetries = 5, delay = 1000) { const video = document.querySelector("video"); try { const response = await fetch(url); const subtitleData = (await response.text()).replaceAll("align:start position:0%", ""); const track = document.createElement("track"); track.src = "data:text/vtt," + encodeURIComponent(subtitleData); await new Promise((resolve) => setTimeout(resolve, delay)); video.appendChild(track); track.track.mode = "showing"; console.log(`[Dual Subs] Successfully added one subtitle`); } catch (error) { if (maxRetries > 0) { console.log(`[Dual Subs] Retrying... (${maxRetries} attempts remaining)`); await new Promise((resolve) => setTimeout(resolve, delay)); return addOneSubtitle(url, maxRetries - 1, delay); } } } function removeSubs() { console.log("[Dual Subs] Attempting to remove subtitles"); const video = document.getElementsByTagName("video")[0]; if (!video) return; const tracks = video.getElementsByTagName("track"); Array.from(tracks).forEach(function (ele) { ele.track.mode = "hidden"; ele.parentNode.removeChild(ele); }); console.log(`[Dual Subs] Successfully removed ${tracks.length} subtitle track(s)`); } 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(); })();