YouTube hotkeys

quick hotkeys for quality, speed, subtitles and replay (for RU, UA, EN, DE, FR, SP)

// ==UserScript==
// @name         YouTube hotkeys
// @namespace    http://tampermonkey.net/
// @version      1.3.1
// @description  quick hotkeys for quality, speed, subtitles and replay (for RU, UA, EN, DE, FR, SP)
// @author       Roman Sergeev aka naxombl4 aka Brutal Doomer
// @license      MIT
// @match        https://www.youtube.com/*
// @grant        GM_addStyle
// @icon         https://www.youtube.com/s/desktop/78bc1359/img/logos/favicon_144x144.png
// ==/UserScript==

/**
 * Happy 20 years of YouTube!
 * v1.3.1 organized Player into a class
 *        prevented popup duplication
 *        added UA
 * v1.3   changed script from keypress emulation to direct player access (as it should have always been)
 *        added popup to indicate speed/quality change
 *        added mutation observer to track that correct player settings are loaded
 *        added some common code structure
 * v1.2   fixed wrong behavior after YT interface updates
 *        moved surveyLoadProgress to another script
 *        fixed subtitles regexp
 *        added comments
 * v1.1   x hotkey for subtitles, alt+q for 480p quality, plus some readability alignments.
 *
 * Hotkeys are:
 * numpad + and - for +-0.25x speed (shift = +-0.5, alt = +-1);
 * shift+q for 1080p quality, ctrl+q for 720p quality, alt+q for 480p quality;
 * q, s and x for quality, speed and subtitle menus (just open, no choosing);
 * r for replay when video is over (video container should be active, unreliable);
 * speed change is rounded to the nearest multiplier of 0.25 (1.4 -> "+" -> 1.5; 1.4 -> "-" -> 1.25)
 *
 * If you have feedback, post it as a comment on my channel (The Brutal Doomer) for the quickest response.
 */

(function() {
    'use strict';

    // speed/quality popup copycat (at the middle top of the player)
    const POPUP_ID = "customPopup";
    GM_addStyle(`
div#`+POPUP_ID+` {
  position: absolute;
  top: 10%;
  left: 50%;
  transform: translatex(-50%);
  z-index: 10;
  padding: 10px 12px;
  border-radius: 3px;
  font-size: 18px;
  color: white;
  background-color: #000a;
  pointer-events: none;
  opacity: 0;
}
    `);

    const PLAYER_ID = "movie_player";
    const REGEXP_R = /Повтор|Повторити|Replay|Nochmal|Revoir|Ver de nuevo/i;
    const DISPATCH = {
        "81": /Качество|Якість|Quality|Qualität|Qualité|Calidad/i, // q: quality regexp
        "83": /Скорость|Швидкість|Speed|Wiedergabegeschwindigkeit|Vitesse de lecture|Velocidad de reproducción/i, // s: speed regexp
        "88": /Субтитры|Субтитри|Subtitles|Untertitel|Sous-titres|Subtítulos/i // x: subtitles regexp
    }

    const CONSECUTIVE_CLICK_EMULATION_DELAY = 100; // milliseconds delay for YouTube elements to pop up (some hotkeys call the player menus)
    const SPEEDMIN = 0.25;
    const SPEEDMAX = 2;
    const SPEEDALT = 0.25;

    var player;

    function Player(div) {
        const QUALITY = {
            large: "480p",
            medium: "360p",
            small: "240p",
            tiny: "144p",
            auto: "auto"
        }
        const POPUP_STATIC_SHOWN_FOR = 300;

        var levels = div.getAvailableQualityLevels(); // array of quality strings inside YT player: see QUALITY keys
        var qualityTexts = levels.map(x => { // corresponding array of displayed "selected quality" strings
            var m = x.match(/\d+/);
            return m ? (m[0] + "p") : (QUALITY[x] || "null");
        });

        // copycat of YT default speed/volume top player popup. Feedbacks change of speed/quality.
        // search for it to prevent duplication
        var popup = false;
        for (const child of div.children)
            if (child.id === POPUP_ID) {
                popup = child;
                break;
            }
        if (!popup) {
            popup = document.createElement("div");
            popup.id = POPUP_ID;
            div.appendChild(popup);
        }

        var list = cn("ytp-panel-menu").children; // list of main settings items
        var sb = cn("ytp-settings-button"); // settings button - main to click on
        var rebtn = cn("ytp-play-button"); // replay button

        function cn(className) { var cns = document.getElementsByClassName(className); return cns[cns.length - 1]; } // (get by) classname
        function sopen() { return cn("ytp-settings-menu").style.display != "none"; } // whether settings window is open
        function dc(element) { element.dispatchEvent(new Event("click")); } // dispatch click
        function dcList(regexp) {
            for (var i = 0; i < list.length; i++)
                if (regexp.test(list[i].textContent))
                    return dc(list[i]);
        }

        this.showPopup = function(text) {
            popup.textContent = text;
            popup.style.transition = "none"; // a trick to show it immediately, but hide with fade-out
            popup.style.opacity = 1;
            setTimeout(() => {
                popup.style.transition = "opacity 0.3s ease-out";
                popup.style.opacity = 0;
            }, POPUP_STATIC_SHOWN_FOR);
        }

        this.setQualityByText = function(text) {
            var index = levels.indexOf(text);
            if (index == -1) index = 0; // choose the best if selected isn't present
            div.setPlaybackQualityRange(levels[index]);
            this.showPopup(qualityTexts[index]);
        }

        this.isActive = function() { return document.activeElement === div; }
        this.getSpeed = function() { return div.getPlaybackRate(); }
        this.setSpeed = function(speed) { div.setPlaybackRate(speed); }
        this.dispatchReplayButtonClick = function() { if (REGEXP_R.test(rebtn.title)) dc(rebtn); }
        this.dispatchSettingsButtonClick = function() { dc(sb); }
        this.dispatchSettingsItemClick = function(regexp) { dcList(regexp); }
        this.settingsAreOpen = function() { return sopen(); }
        this.closeSettings = function() { if (this.settingsAreOpen()) this.dispatchSettingsButtonClick(); }

        return this;
    }

    // floor1 and ceil1 are internal for round1 - round playback speed to nearest multiplier of SPEEDALT
    function floor1(x, step) { return Math.floor(x / step) * step; }
    function ceil1(x, step) { return Math.ceil (x / step) * step; }
    function round1(x, delta) { return (delta > 0) ? Math.min(SPEEDMAX, floor1(x + delta, SPEEDALT)) : Math.max(SPEEDMIN, ceil1(x + delta, SPEEDALT)); }

    // ChatGPT helped with this: observer waits for a real player (on /watch page) to load, then loads its available quality levels
    function awaitPlayerLoad(callback) {
        function isRealPlayer(div) {
            return div && typeof div.getAvailableQualityLevels === 'function' && div.getAvailableQualityLevels().length > 0;
        }

        const tryInitialize = () => {
            const div = document.getElementById(PLAYER_ID);
            if (isRealPlayer(div)) {
                callback(div);
                return true;
            }
            return false;
        };

        if (tryInitialize()) return;

        // Set up observer to watch for the player appearing
        const observer = new MutationObserver((mutations, obs) => {
            if (tryInitialize()) obs.disconnect();
        });

        observer.observe(document.body, { childList: true, subtree: true });
    }

    function playerLoadedCallback(div) { player = new Player(div); }

    function clickMenu(regexp) {
        player.dispatchSettingsButtonClick();
        if (player.settingsAreOpen()) setTimeout(() => player.dispatchSettingsItemClick(regexp), CONSECUTIVE_CLICK_EMULATION_DELAY);
    }

    // quality selection function: alt+click = 480p, ctrl+click = 720p, shift+click = 1080p. Highest resolution available up to desirable is selected.
    function changeQuality(acs) {
        var desiredQuality = (acs&4) ? "hd1080" : (acs&2) ? "hd720" : (acs&1) ? "large" : -1;
        player.setQualityByText(desiredQuality);
    }

    // v1.3 simplified to using player API
    function changeSpeed(shift) {
        var oldSpeed = player.getSpeed();
        var newSpeed = round1(oldSpeed, shift);
        player.setSpeed(newSpeed);
        player.showPopup(newSpeed + "x");
    }

    // add global hotkeys
    window.addEventListener("keydown", function (event) {
        if (document.activeElement.id == "contenteditable-root" ||
            document.activeElement.id == "search") return; // whether writing a comment or searching

        var acs = event.altKey + (event.ctrlKey << 1) + (event.shiftKey << 2);
        var key = event.keyCode;

        switch (key) {
            case 81: // q for quality
                if (acs) changeQuality(acs);
            case 83: // s for speed
            case 88: // x for subtitles
                return !acs && clickMenu(DISPATCH[key]);
            case 107:
            case 109: // + and - for speed. Default +(=) and - are untouched, because in YT they change captions size
                if (acs&2) return; // do not conflict with default zoom (ctrl plus/minus)
                var shift = (key == 109 ? -SPEEDALT : SPEEDALT) * Math.max(1, event.shiftKey * 2 + event.altKey * 4); // any formula you want
                changeSpeed(shift);
                player.closeSettings();
                break;
            //case 68: if(!acs) cn("html5-video-info-panel").classList.toggle("displayNone"); break; // doesn't work (stats for nerds)
            case 82: if(!acs && player.isActive()) player.dispatchReplayButtonClick(); // replay (sometimes works)
        }
    });

    window.addEventListener('yt-navigate-finish', () => {
        awaitPlayerLoad(playerLoadedCallback);
    });

    awaitPlayerLoad(playerLoadedCallback);
})();