YouTube Auto HD and FPS

Auto select the highest quality on YouTube

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name               YouTube Auto HD and FPS
// @namespace          https://github.com/jlhg/youtube-auto-hd
// @license            GPL-3.0
// @version            0.1.0
// @description        Auto select the highest quality on YouTube
// @description:zh-TW  YouTube 自動選最高畫質
// @author             jlhg
// @homepage           https://github.com/jlhg/youtube-auto-hd
// @supportURL         https://github.com/jlhg/youtube-auto-hd/issues
// @match              https://www.youtube.com/watch*
// @grant              none
// ==/UserScript==

(function() {
  'use strict';

  const SELECTORS = {
    buttonSettings: '.ytp-settings-button',
    video: 'video',
    player: '.html5-video-player:not(#inline-preview-player)',
    menuOption: '.ytp-settings-menu[data-layer] .ytp-menuitem',
    menuOptionContent: ".ytp-menuitem-content",
    optionQuality: ".ytp-settings-menu[data-layer] .ytp-menuitem:last-child",
    panelHeaderBack: ".ytp-panel-header button",
    labelPremium: '.ytp-premium-label'
  };

  const OBSERVER_OPTIONS = {
    childList: true,
    subtree: true
  };

  const SUFFIX_EBR = 'ebr';

  const fpsSupported = [60, 50, 30];
  const qualities = [4320, 2160, 1440, 1080, 720, 480, 360, 240, 144];

  function isElementVisible(element) {
    return element?.offsetWidth > 0 && element?.offsetHeight > 0;
  }

  async function getCurrentQualityElements() {
    return waitElement(SELECTORS.player).then((el) => {
      const elMenuOptions = [...el.querySelectorAll(SELECTORS.menuOption)];
      return elMenuOptions.filter(getIsQualityElement);
    });
  }

  function convertQualityToNumber(elQuality) {
    const isPremiumQuality = Boolean(elQuality.querySelector(SELECTORS.labelPremium));
    const qualityNumber = parseInt(elQuality.textContent);
    if (isPremiumQuality) {
      return (qualityNumber + SUFFIX_EBR);
    }

    return qualityNumber;
  }

  async function getAvailableQualities() {
    const elQualities = await getCurrentQualityElements();
    return elQualities.map(convertQualityToNumber);
  }

  function getPlayerDiv(elVideo) {
    return elVideo.closest(SELECTORS.player);
  }

  function getVideoFPS() {
    const elQualities = getCurrentQualityElements();
    const labelQuality = elQualities[0]?.textContent;
    if (!labelQuality) {
      return 30;
    }
    const fpsMatch = labelQuality.match(/[ps](\d+)/);
    return fpsMatch ? Number(fpsMatch[1]) : 30;
  }

  function getFpsFromRange(qualities, fpsToCheck) {
    const fpsList = Object.keys(qualities)
      .map(fps => parseInt(fps))
      .sort((a, b) => b - a);
    return fpsList.find(fps => fps <= fpsToCheck) || fpsList.at(-1);
  }

  function getIsQualityElement(element) {
    const isQuality = Boolean(element.textContent.match(/\d/));
    const isHasChildren = element.children.length > 1;
    return isQuality && !isHasChildren;
  }

  async function getIsSettingsMenuOpen() {
    waitElement(SELECTORS.buttonSettings).then((el) => {
      const elButtonSettings = el;
      return elButtonSettings?.ariaExpanded === "true";
    });
  }

  function getIsLastOptionQuality(elVideo) {
    const elOptionInSettings = getPlayerDiv(elVideo).querySelector(SELECTORS.optionQuality);

    if (!elOptionInSettings) {
      return false;
    }

    const elQualityName = elOptionInSettings.querySelector(SELECTORS.menuOptionContent);

    // If the video is a channel trailer, the last option is initially the speed one,
    // and the speed setting can only be a single digit
    const matchNumber = elQualityName?.textContent?.match(/\d+/);
    if (!matchNumber) {
      return false;
    }

    const numberString = matchNumber[0];
    const minQualityCharLength = 3; // e.g. 3 characters in 720p

    return numberString.length >= minQualityCharLength;
  }

  async function changeQualityAndClose(elVideo, elPlayer) {
    await changeQualityWhenPossible(elVideo);
    await closeMenu(elPlayer);
  }

  function openQualityMenu(elVideo) {
    const elSettingQuality = getPlayerDiv(elVideo).querySelector(SELECTORS.optionQuality);
    elSettingQuality.click();
  }

  async function changeQuality() {
    const elQualities = await getCurrentQualityElements();
    const qualitiesAvailable = await getAvailableQualities();
    const applyQuality = (iQuality) => {
      elQualities[iQuality]?.click();
    };

    const isQualityPreferredEBR = qualitiesAvailable[0].toString().endsWith(SUFFIX_EBR);
    if (isQualityPreferredEBR) {
      applyQuality(0);
      return;
    }

    const iQualityFallback = qualitiesAvailable.findIndex(quality => !quality.toString().endsWith(SUFFIX_EBR));
    applyQuality(iQualityFallback);
  }

  async function changeQualityWhenPossible(elVideo) {
    if (!getIsLastOptionQuality(elVideo)) {
      elVideo.addEventListener("canplay", () => changeQualityWhenPossible(elVideo), { once: true });
      return;
    }

    openQualityMenu(elVideo);
    await changeQuality();
  }

  async function closeMenu(elPlayer) {
    const clickPanelBackIfPossible = () => {
      const elPanelHeaderBack = elPlayer.querySelector(SELECTORS.panelHeaderBack);
      if (elPanelHeaderBack) {
        elPanelHeaderBack.click();
        return true;
      }
      return false;
    };

    if (clickPanelBackIfPossible()) {
      return;
    }

    new MutationObserver((_, observer) => {
      if (clickPanelBackIfPossible()) {
        observer.disconnect();
      }
    }).observe(elPlayer, OBSERVER_OPTIONS);
  }

  function waitElement(selector) {
    return new Promise(resolve => {
      let element = [...document.querySelectorAll(selector)]
        .find(isElementVisible);

      if (element) {
        return resolve(element);
      }

      const observer = new MutationObserver(mutations => {
        let element = [...document.querySelectorAll(selector)]
          .find(isElementVisible);

        if (element) {
          observer.disconnect();
          resolve(element);
        }
      });

      observer.observe(document.body, OBSERVER_OPTIONS);
    });
  }

  waitElement(SELECTORS.video).then(async (elVideo) => {
    const elPlayer = getPlayerDiv(elVideo);
    const elSettings = elPlayer.querySelector(SELECTORS.buttonSettings);
    if (!elSettings) {
      return;
    }

    const isSettingsMenuOpen = await getIsSettingsMenuOpen();
    if (!isSettingsMenuOpen) {
      elSettings.click();
    }
    elSettings.click();

    await changeQualityAndClose(elVideo, elPlayer);
    elPlayer.querySelector(SELECTORS.buttonSettings).blur();
  });
})();