Greasy Fork

YouTube Dual Subtitles for French, German, Russian, Ukrainian

Add dual subtitles to YouTube videos

当前为 2025-03-04 提交的版本,查看 最新版本

// ==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
// ==/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();
})();