Youtube记忆恢复双语字幕和播放速度-下载字幕

记忆播放器设置菜单(含自动翻译菜单)选择的字幕语言和播放速度。默认中文(简体)字幕/默认字幕(双语);找不到匹配的语言时,匹配前缀,例如中文(简体)->中文

/* eslint-disable no-use-before-define */
// ==UserScript==
// @name          Youtube记忆恢复双语字幕和播放速度-下载字幕
// @name:en    Youtube store/restore bilingual subtitles and playback speed - download subtitles
// @description  记忆播放器设置菜单(含自动翻译菜单)选择的字幕语言和播放速度。默认中文(简体)字幕/默认字幕(双语);找不到匹配的语言时,匹配前缀,例如中文(简体)->中文
// @description:en  The selected subtitle language and playback speed are stored and auto restored
// @license MIT
// @match       https://*.youtube.com/*
// @run-at       document-start
// @author      szdailei@gmail.com
// @source      https://github.com/szdailei/GM-scripts
// @namespace  https://greasyfork.org
// @version         3.1.3
// ==/UserScript==

/**
require:  @run-at document-start
ensure:  run handleYtNavigateFinish() when yt-navigate-finish event triggered
*/
(() => {
  const PLAY_SPEED_LOCAL_STORAGE_KEY = 'greasyfork-org-youtube-config-play-speed';
  const SUBTITLE_LOCAL_STORAGE_KEY = 'greasyfork-org-youtube-config-subtitle';
  const NOT_SUPPORT_LANGUAGE =
    'Only English/Chinese/Russian are supported. \n\nFor users who have signed in youtube, please change the account language to a supported language. \n\nFor users who have not signed in youtube, please change the browser language to a supported language.';
  const DEFAULT_SUBTITLES = 'chinese';
  const TIMER_OF_MENU_LOAD_AFTER_USER_CLICK = 20;
  const TIMER_OF_ELEMENT_LOAD = 100;
  const numbers = '0123456789';
  const specialCharacterAndNumbers = '`~!@#$%^&*()_+<>?:"{},./;\'[]0123456789-=()';

  class I18n {
    constructor(langCode, resource) {
      this.langCode = langCode;
      switch (langCode) {
        case 'zh':
        case 'zh-CN':
        case 'zh-SG':
        case 'zh-Hans-CN':
        case 'cmn-Hans-CN':
        case 'cmn-Hans-SG':
          this.resource = resource.cmnHans;
          break;
        case 'zh-TW':
        case 'zh-Hant-TW':
        case 'cmn-Hant-TW':
          this.resource = resource.cmnHant;
          break;
        case 'zh-HK':
        case 'zh-MO':
        case 'zh-Hant-HK':
        case 'zh-Hant-MO':
        case 'yue-Hant-HK':
        case 'yue-Hant-MO':
          this.resource = resource.cmnHantHK;
          break;
        case 'en':
        case 'en-AU':
        case 'en-BZ':
        case 'en-CA':
        case 'en-CB':
        case 'en-GB':
        case 'en-IE':
        case 'en-IN':
        case 'en-JM':
        case 'en-NZ':
        case 'en-PH':
        case 'en-TT':
        case 'en-US':
        case 'en-ZA':
        case 'en-ZW':
          this.resource = resource.en;
          break;
        case 'ru':
        case 'ru-RU':
          this.resource = resource.ru;
          break;
        default:
          this.resource = resource.en;
          break;
      }
    }

    t(key) {
      return this.resource[key];
    }
  }

  let lastHref = null;
  const hostLanguage = document.getElementsByTagName('html')[0].getAttribute('lang');
  if (hostLanguage === null) {
    return;
  }

  const i18n = new I18n(hostLanguage, getResource());
  if (getStorage(i18n.t('subtitles')) === null) {
    setStorage(i18n.t('subtitles'), i18n.t(DEFAULT_SUBTITLES));
  }

  window.addEventListener('yt-navigate-finish', handleYtNavigateFinish);

  function getResource() {
    const resource = {
      en: {
        playSpeed: 'Playback speed',
        subtitles: 'Subtitles',
        autoTranlate: 'Auto-translate',
        chinese: 'Chinese (Simplified)',
        downloadTranscript: 'Download transcript',
      },
      cmnHans: {
        playSpeed: '播放速度',
        subtitles: '字幕',
        autoTranlate: '自动翻译',
        chinese: '中文(简体)',
        downloadTranscript: '下载字幕',
      },
      cmnHant: {
        playSpeed: '播放速度',
        subtitles: '字幕',
        autoTranlate: '自動翻譯',
        chinese: '中文(簡體)',
        downloadTranscript: '下載字幕',
      },
      cmnHantHK: {
        playSpeed: '播放速度',
        subtitles: '字幕',
        autoTranlate: '自動翻譯',
        chinese: '中文(簡體字)',
        downloadTranscript: '下載字幕',
      },
      ru: {
        playSpeed: 'Скорость воспроизведения',
        subtitles: 'Субтитры',
        autoTranlate: 'Перевести',
        chinese: 'Русский',
        downloadTranscript: 'Скачать транскрибцию',
      },
    };
    return resource;
  }

  function handleYtNavigateFinish() {
    if (lastHref === window.location.href || window.location.href.indexOf('/watch') === -1) {
      return;
    }

    lastHref = window.location.href;
    // run once on https://www.youtube.com/watch*.
    youtubeConfig();
  }

  /**
require:  yt-navigate-finish event on https://www.youtube.com/watch*
ensure: 
    1. If there isn't subtitle enable button, exit.
    2. store/resotre play speed and subtitle. If can't restore subtitle, but there is auto-translate radio, translate to stored subtitle.
    3. If there is transcript, trun on transcript.
*/
  async function youtubeConfig() {
    const player = await waitUntil(document.getElementById('movie_player'));
    const rightControls = await waitUntil(player.getElementsByClassName('ytp-right-controls'));
    const rightControl = rightControls[0];
    if (isSubtitleEabled(rightControl) === false) {
      return;
    }

    const settingsButtons = await waitUntil(rightControl.getElementsByClassName('ytp-settings-button'));
    const settingsButton = settingsButtons[0];
    settingsButton.addEventListener('click', handleRadioClick);

    settingsButton.click();
    const settingsMenu = await waitUntil(getPanelMenuByTitle(player, ''));
    await restoreSettingOfTitle(player, settingsMenu, i18n.t('playSpeed'));

    const isSubtitlRestored = await restoreSettingOfTitle(player, settingsMenu, i18n.t('subtitles'));
    if (isSubtitlRestored === false) {
      const labels = settingsMenu.getElementsByClassName('ytp-menuitem-label');
      const subtitlesRadio = getElementByShortTextContent(labels, i18n.t('subtitles'));
      subtitlesRadio.click();
      const subtitleMenu = await waitUntil(getPanelMenuByTitle(player, i18n.t('subtitles')));
      const isAutoTransSubtitleRestored = await restoreSettingOfTitle(player, subtitleMenu, i18n.t('autoTranlate'));
      if (isAutoTransSubtitleRestored === false) {
        settingsButton.click(); // close settings menu
      }
    } else {
      settingsButton.click(); // close settings menu
    }

    await turnOnTranscript();
  }

  function isSubtitleEabled(rightControl) {
    const subtitlesEnableButtons = rightControl.getElementsByClassName('ytp-subtitles-button');
    if (
      subtitlesEnableButtons === null ||
      subtitlesEnableButtons[0] === null ||
      subtitlesEnableButtons[0].style.display === 'none'
    ) {
      return false;
    }

    if (!subtitlesEnableButtons[0].getAttribute('aria-pressed')) {
      return false;
    }

    if (subtitlesEnableButtons[0].getAttribute('aria-pressed') === 'false') {
      subtitlesEnableButtons[0].click();
    }
    return true;
  }

  async function restoreSettingOfTitle(player, openedMenu, subMenuTitle) {
    const value = getStorage(subMenuTitle);
    if (value === null) {
      return true;
    }

    const labels = openedMenu.getElementsByClassName('ytp-menuitem-label');
    const radio = getElementByShortTextContent(labels, subMenuTitle);
    if (radio === null) {
      return false;
    }
    radio.click();
    const subMenu = await waitUntil(getPanelMenuByTitle(player, subMenuTitle));
    return restoreSettingByValue(subMenu, value);
  }

  function getPanelMenuByTitle(player, title) {
    if (title === null || title === '') {
      // settings menu
      const panelMenus = player.getElementsByClassName('ytp-panel-menu');
      if (panelMenus === null || panelMenus.length === 0 || panelMenus[0].previousElementSibling !== null) {
        // no panelMenus or panelMenu has previousElementSibling (panelHeader)
        return null;
      }
      return panelMenus[0];
    }

    // other menu, not settings menu
    const panelHeaders = player.getElementsByClassName('ytp-panel-header');
    if (panelHeaders !== null) {
      for (let i = 0; i < panelHeaders.length; i += 1) {
        const panelHeaderTitle = getPanelHeaderTitle(panelHeaders[i]);
        if (getShortText(panelHeaderTitle.textContent) === title) {
          return panelHeaders[i].nextElementSibling;
        }
      }
    }
    return null;
  }

  function getPanelHeaderTitle(panelHeader) {
    const panelTitles = panelHeader.getElementsByClassName('ytp-panel-title');
    return panelTitles[0];
  }

  function restoreSettingByValue(openedMenu, value) {
    const panelheader = openedMenu.previousElementSibling;
    const panelTitle = getPanelHeaderTitle(panelheader);
    const labels = openedMenu.getElementsByClassName('ytp-menuitem-label');
    let storedRadio = getElementByTextContent(labels, value);
    if (storedRadio === null) {
      // if can't match '中文(简体)',try '中文'
      storedRadio = getElementByShortTextContent(labels, getShortText(value));
      if (storedRadio === null) {
        panelTitle.click();
        return false;
      }
    }

    if (storedRadio.parentElement.getAttribute('aria-checked') === 'true') {
      panelTitle.click();
      return true;
    }
    storedRadio.click();
    return true;
  }

  function handleRadioClick() {
    const player = document.getElementById('movie_player');

    if (this.textContent === '') {
      // clicked on settingsButton which will open settingsMenu
      handleRadioToPanelMenuClick(player, '', handleRadioClick);
      return;
    }

    // clicked on radio which will open subMenu
    const label = this.getElementsByClassName('ytp-menuitem-label')[0];
    const shortText = getShortText(label.textContent);
    if (
      shortText === i18n.t('playSpeed') ||
      shortText === i18n.t('subtitles') ||
      shortText === i18n.t('autoTranlate')
    ) {
      handleRadioToPanelMenuClick(player, shortText, handleRadioClick);
      return;
    }

    // in 'autoTranlate' menu, only one radio which seleted by default has parentNode, others are orphan nodes and can't get parentNode by 'this'
    const panelHeaders = player.getElementsByClassName('ytp-panel-header');
    const title = getShortText(getPanelHeaderTitle(panelHeaders[0]).textContent);
    setStorage(title, label.textContent);
  }

  async function handleRadioToPanelMenuClick(player, title, eventListener) {
    const panelMenu = await waitUntil(getPanelMenuByTitle(player, title), TIMER_OF_MENU_LOAD_AFTER_USER_CLICK);
    addEventListenerOnPanelMenu(panelMenu, eventListener);
  }

  function addEventListenerOnPanelMenu(panelMenu, eventListener) {
    const radios = panelMenu.getElementsByClassName('ytp-menuitem-label');
    Array.prototype.forEach.call(radios, (radio) => {
      radio.parentElement.addEventListener('click', eventListener);
    });
  }

  async function turnOnTranscript() {
    const infoContents = await waitUntil(document.getElementById('info-contents'));
    const moreActionsMenuButtons = await waitUntil(infoContents.getElementsByClassName('dropdown-trigger'));
    const moreActionsMenuButton = moreActionsMenuButtons[0];

    moreActionsMenuButton.click();
    const menuPopupRenderers = await waitUntil(document.getElementsByTagName('ytd-menu-popup-renderer'));

    const items = menuPopupRenderers[0].querySelector('#items');

    // The first item should be invisible, the second item be "Report", the third be "Show transcript"
    // "Show transcript" MUST be there
    if (items.length < 3) {
      moreActionsMenuButton.click(); // close moreActionsMenu
      return;
    }

    const showTranscriptRadio = items.childNodes[2];

    showTranscriptRadio.click();

    const engagementPanel = await getEngagementPanel();

    const titleContainer = engagementPanel.querySelector('div[id=title-container]');
    const transcriptTitle = titleContainer.querySelector('yt-formatted-string[id=title-text]');

    insertPaperButton(transcriptTitle, i18n.t('downloadTranscript'), onTranscriptDownloadButtonClicked);
  }

  async function getEngagementPanel() {
    const panels = await waitUntil(document.getElementById('panels'));
    const engagementPanel = panels.querySelector(
      'ytd-engagement-panel-section-list-renderer[visibility=ENGAGEMENT_PANEL_VISIBILITY_EXPANDED]'
    );
    return engagementPanel;
  }

  function insertPaperButton(transcriptTitle, textContent, clickCallback) {
    transcriptTitle.textContent = textContent;
    transcriptTitle.style.background = 'red';
    transcriptTitle.style.cursor = 'pointer';

    transcriptTitle.addEventListener('click', clickCallback);
  }

  async function onTranscriptDownloadButtonClicked() {
    const infoContents = document.getElementById('info-contents');
    const title = infoContents.querySelector('h1');
    const filename = `${title.textContent}.vtt`;

    const engagementPanel = await getEngagementPanel();

    const segmentsContainer = engagementPanel.querySelector('div[id=segments-container]');

    const cueGroups = segmentsContainer.childNodes;
    if (cueGroups === null) {
      return;
    }

    const ytpTimeDuration = await getYtpTimeDuration();
    const content = getFormattedSRT(cueGroups, ytpTimeDuration);
    saveTextAsFile(filename, content);
  }

  function convertTimeFormat(time) {
    const fields = time.split(':');

    if (fields.length === 2) {
      fields.unshift('00');
    }

    const convertedArray = []
    for (let i = 0; i < 2; i += 1) {
      const fieldInt = parseInt(fields[i],10)
      let str
      if (fieldInt < 10) {
        str = `0${fieldInt.toString()}`;
      } else {
        str = fieldInt.toString();
      }
      convertedArray.push(str)
    }

    return `${convertedArray[0]}:${convertedArray[1]}:${fields[2]}`;
  }

  function getFormattedSRT(cueGroups, ytpTimeDuration) {
    let content = 'WEBVTT\n\n';
    for (let i = 0; i < cueGroups.length; i += 1) {
      const currentSubtitleStartOffsets = cueGroups[i].getElementsByClassName('segment-timestamp');
      const startTime = convertTimeFormat(currentSubtitleStartOffsets[0].textContent.trim());
      let endTime;
      if (i === cueGroups.length - 1) {
        endTime = convertTimeFormat(ytpTimeDuration);
      } else {
        const nextSubtitleStartOffsets = cueGroups[i + 1].getElementsByClassName('segment-timestamp');
        endTime = convertTimeFormat(nextSubtitleStartOffsets[0].textContent.split('\n').join('').trim());
      }

      const timeLine = `${startTime}.000  -->  ${endTime}.000`;
      const cues = cueGroups[i].getElementsByClassName('segment-text');
      const contentLine = cues[0].textContent.split('\n').join('').trim();
      content += `${timeLine}\n${contentLine}\n\n`;
    }
    return content;
  }

  async function getYtpTimeDuration() {
    const player = await waitUntil(document.getElementById('movie_player'));
    const leftControls = await waitUntil(player.getElementsByClassName('ytp-left-controls'));
    const ytpTimeDurations = leftControls[0].getElementsByClassName('ytp-time-duration');
    return ytpTimeDurations[0].textContent;
  }

  function saveTextAsFile(filename, text) {
    const a = document.createElement('a');
    a.href = `data:text/txt;charset=utf-8,${encodeURIComponent(text)}`;
    a.download = filename;
    a.click();
  }

  function getElementByTextContent(elements, textContent) {
    for (let i = 0; i < elements.length; i += 1) {
      if (elements[i].textContent === textContent) {
        return elements[i];
      }
    }
    return null;
  }

  function getElementByShortTextContent(elements, textContent) {
    for (let i = 0; i < elements.length; i += 1) {
      if (getShortText(elements[i].textContent) === textContent) {
        return elements[i];
      }
    }
    return null;
  }

  function getShortText(text) {
    if (text === null) {
      return null;
    }
    if (text === '' || numbers.indexOf(text[0]) !== -1 || text === i18n.t('autoTranlate')) {
      return text.trim();
    }

    // return input text before specialCharacterAndNumbers
    let shortText = '';
    for (let i = 0; i < text.length; i += 1) {
      if (specialCharacterAndNumbers.indexOf(text[i]) !== -1) {
        break;
      }
      shortText += text[i];
    }
    return shortText.trim();
  }

  function getStorage(title) {
    let storedValue = null;
    switch (title) {
      case i18n.t('playSpeed'):
        storedValue = localStorage.getItem(PLAY_SPEED_LOCAL_STORAGE_KEY);
        break;
      case i18n.t('subtitles'):
      case i18n.t('autoTranlate'):
        storedValue = localStorage.getItem(SUBTITLE_LOCAL_STORAGE_KEY);
        break;
      default:
        break;
    }
    return storedValue;
  }

  function setStorage(title, value) {
    switch (title) {
      case i18n.t('playSpeed'):
        localStorage.setItem(PLAY_SPEED_LOCAL_STORAGE_KEY, value);
        break;
      case i18n.t('subtitles'):
      case i18n.t('autoTranlate'):
        localStorage.setItem(SUBTITLE_LOCAL_STORAGE_KEY, value);
        break;
      default:
        break;
    }
  }

  async function waitUntil(condition, timer) {
    let timeout = TIMER_OF_ELEMENT_LOAD;
    if (timer) {
      timeout = timer;
    }
    return new Promise((resolve) => {
      const interval = setInterval(() => {
        const result = condition;
        if (result) {
          clearInterval(interval);
          resolve(result);
        }
      }, timeout);
    });
  }
})();