YouTube Auto HD and FPS

Auto select the highest quality on YouTube

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