YouTube arrow keys FIX

Fix player controls Space, Left, Right, Up, Down to behave consistently after page load or clicking individual controls. Not focusing the mute button anymore.

Mint 2024.01.22.. Lásd a legutóbbi verzió

// ==UserScript==
// @name         YouTube arrow keys FIX
// @version      1.3.0
// @description  Fix player controls Space, Left, Right, Up, Down to behave consistently after page load or clicking individual controls. Not focusing the mute button anymore.
// @author       Calcifer
// @license      MIT
// @namespace    https://github.com/Calciferz
// @homepageURL  https://github.com/Calciferz/YoutubeKeysFix
// @supportURL   https://github.com/Calciferz/YoutubeKeysFix/issues
// @icon         http://youtube.com/yts/img/favicon_32-vflOogEID.png
// @include      https://*.youtube.com/*
// @include      https://youtube.googleapis.com/embed*
// @grant        none
// @require      http://code.jquery.com/jquery-latest.js
// ==/UserScript==

/* eslint-disable  no-multi-spaces */
/* eslint-disable  no-multi-str */


(function () {
    'use strict';

    var playerContainer;  // = document.getElementById('player-container') || document.getElementById('player') in embeds
    var playerElem;  // = document.getElementById('movie_player')
    var isEmbeddedUI;
    var subtitleObserver;
    var subtitleContainer;

    var lastFocusedPageArea;
    var areaOrder= [ null ],
        areaContainers= [ null ],
        areaFocusDefault= [ null ],
        areaFocusedSubelement= [ null ];



    function formatElemIdOrClass(elem) {
      return   elem.id ?  '#' + elem.id
      : elem.className ?  '.' + elem.className.replace(' ', '.')
      : elem.tagName;
    }

    function formatElemIdOrTag(elem) {
      return   elem.id ?  '#' + elem.id
      : elem.tagName;
    }

    function isElementWithin(elementWithin, ancestor) {
        if (! ancestor)  return null;
        for (; elementWithin; elementWithin= elementWithin.parentElement) {
            if (elementWithin === ancestor)  return true;
        }
        return false;
    }

    function getAreaOf(elementWithin) {
        for (var i= 1; i<areaContainers.length; i++) {
          if (isElementWithin(elementWithin, areaContainers[i]))  return i;
        }
        return 0;
    }
    function getFocusedArea() { return getAreaOf(document.activeElement); }

    function tryFocus(newFocus) {
        newFocus= $(newFocus);
        if (! newFocus.length)  return null;
        if (! newFocus.is(':visible()'))  return false;
        //var oldFocus= document.activeElement;
        newFocus.focus();
        var done= (newFocus[0] === document.activeElement);
        if (! done)  console.error("[YoutubeKeysFix]  tryFocus():  Failed to focus newFocus=", [newFocus[0]], "activeElement=", [document.activeElement]);
        return done;
    }

    function focusNextArea() {
        // Focus next area's areaFocusedSubelement (activeElement)
        var currentArea= getFocusedArea() || 0;
        var nextArea= (lastFocusedPageArea && lastFocusedPageArea !== currentArea) ? lastFocusedPageArea : currentArea + 1;
        // captureFocus() will store lastFocusedPageArea again if moving to a non-player area
        // if moving to the player then lastFocusedPageArea resets, Shift-Esc will move to search bar (area 2)
        lastFocusedPageArea= null;
        // To enter player after last area: nextArea= 1;  To skip player: nextArea= 2;
        if (nextArea >= areaContainers.length)  nextArea= 2;

        let done = false;
        do {
          done= tryFocus( areaFocusedSubelement[nextArea] );
          if (! done)  done= tryFocus( $(areaFocusDefault[nextArea]) );
          //if (! done)  done= tryFocus( areaContainers[nextArea] );
          if (! done)  nextArea++;
        } while (!done && nextArea < areaContainers.length);
        return done;
    }


    function redirectEventTo(target, event, cloneEvent) {
        if (! target || ! $(target).is(':visible()'))  return;
        cloneEvent= cloneEvent || new Event(event.type);
        //var cloneEvent= $.extend(cloneEvent, event);
        // shallow copy every property
        for (var k in event)  if (! (k in cloneEvent))  cloneEvent[k]= event[k];
        cloneEvent.originalEvent= event;

        event.preventDefault();
        event.stopPropagation();
        event.stopImmediatePropagation();

        try { console.log("[YoutubeKeysFix]  redirectEventTo():  type=" + cloneEvent.type, "key='" + cloneEvent.key + "' to=" + formatElemIdOrTag(target), "from=", [event.target, event, cloneEvent]); }
        catch (err)  { console.error("[YoutubeKeysFix]  redirectEventTo():  Error while logging=", err); }

        target.dispatchEvent(cloneEvent);
    }


    function handleShiftEsc(event) {
        // Shift-Esc only implemented for watch page
        if (window.location.pathname !== "/watch")  return;
        // Not in fullscreen
        if (getFullscreen())  return;
        // Bring focus to next area
        focusNextArea();
        event.preventDefault();
        event.stopPropagation();
    }


    // Tag list from YouTube Plus: https://github.com/ParticleCore/Particle/blob/master/src/Userscript/YouTubePlus.user.js#L885
    var keyHandlingElements= { INPUT:1, TEXTAREA:1, IFRAME:1, OBJECT:1, EMBED:1 };

    function onKeydown(event) {
        // Debug log of key event
        //if (event.key != 'Shift')  console.log("[YoutubeKeysFix]  onKeydown():  type=" + event.type, "key='" + event.key + "' target=", [event.target, event]);

        // Space -> pause video except when writing a comment - Youtube takes care of this
    }


    function captureKeydown(event) {
        // Debug log of key event
        //if (event.key != 'Shift')  console.log("[YoutubeKeysFix]  captureKeydown():  type=" + event.type, "key='" + event.key + "' target=", [event.target, event]);

        let keyCode = event.which;

        // Shift-Esc -> cycle through search box, videos, comments
        // Event is not received when fullscreen in Opera (already handled by browser)
        if (keyCode == 27 && event.shiftKey)
          return handleShiftEsc(event);

        // Only capture events within player
        if (!isElementWithin(event.target, playerElem))  return;

        // Sliders' key handling behaviour is inconsistent with the default player behaviour
        // Redirect arrow keys (33-40: PageUp,PageDown,End,Home,Left,Up,Right,Down) to page scroll/video player (position/volume)
        if (33 <= keyCode && keyCode <= 40 && event.target !== playerElem && event.target.getAttribute('role') == 'slider')
          return redirectEventTo(playerElem, event);
    }


    function captureMouse(event) {
        // Called when mouse button is pressed/released over an element.
        // Debug log of mouse button event
        //console.log("[YoutubeKeysFix]  captureMouse():  type=" + event.type, "button=" + event.button, "target=", [event.target, event]);
    }


    function onMouse(event) {
        // Called when mouse button is pressed over an element.
        // Debug log of mouse button event
        //console.log("[YoutubeKeysFix]  onMouse():  type=" + event.type, "button=" + event.button, "target=", [event.target, event]);
    }

    function onWheel(event) {
        //console.log("[YoutubeKeysFix]  onWheel():  deltaY=" + Math.round(event.deltaY), "phase=" + event.eventPhase, "target=", [event.currentTarget, event]);
        if (! playerElem || ! playerElem.contains(event.target))  return;

        var deltaY= null !== event.deltaY ? event.deltaY : event.wheelDeltaY;
        var up= deltaY <= 0;    // null == 0 -> up
        var cloneEvent= new Event('keydown');
        cloneEvent.which= cloneEvent.keyCode= up ? 38 : 40;
        cloneEvent.key= up ? 'ArrowUp': 'ArrowDown';
        redirectEventTo(playerElem, event, cloneEvent);
    }

    function getFullscreen() {
        return document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement;
    }

    function onFullscreen(event) {
        var fullscreen= getFullscreen();
        if (fullscreen) {
            if ( !fullscreen.contains(document.activeElement) ) {
                onFullscreen.prevFocus= document.activeElement;
                fullscreen.focus();
            }
        } else if (onFullscreen.prevFocus) {
            onFullscreen.prevFocus.focus();
            onFullscreen.prevFocus= null;
        }
    }

    function captureFocus(event) {
        // Called when an element gets focus (by clicking or TAB)
        // Debug log of focused element
        //console.log("[YoutubeKeysFix]  captureFocus():  target=", [event.target, event]);

        // Window will focus the activeElement, do nothing at the moment
        if (event.target === window)  return;

        // Save focused element inside player or on page
        var area= getAreaOf(event.target);
        if (0 !== area) {
            areaFocusedSubelement[area]= event.target;
            //if (areaContainers[area])  areaContainers[area].activeElement= event.target;
            // store if not focusing player area
            if (area !== 1)  lastFocusedPageArea= area;
        }
    }



    function initEvents() {
        // Handlers are capture type to see all events before they are consumed
        document.addEventListener('mousedown', captureMouse, true);
        //document.addEventListener('mouseup', captureMouse, true);

        // captureFocus captures focus changes before the event is handled
        // does not capture body.focus() in Opera, material design
        document.addEventListener('focus', captureFocus, true);
        //window.addEventListener('focusin', captureFocus);

        document.addEventListener('mousedown', onMouse);
        // mousewheel over player area adjusts volume
        // Passive event handler can call preventDefault() on wheel events to prevent scrolling the page
        //document.addEventListener('wheel', onWheel, { passive: false, capture: true });

        // captureKeydown is run before original handlers to capture key presses before the player does
        document.addEventListener('keydown', captureKeydown, true);
        // onKeydown handles Tab in the bubbling phase after other elements (textbox, button, link) got a chance.
        document.addEventListener('keydown', onKeydown);

        if (document.onfullscreenchange !== undefined)  document.addEventListener('fullscreenchange', onFullscreen);
        else if (document.onwebkitfullscreenchange !== undefined)  document.addEventListener('webkitfullscreenchange', onFullscreen);
        else if (document.onmozfullscreenchange !== undefined)  document.addEventListener('mozfullscreenchange', onFullscreen);
        else if (document.MSFullscreenChange !== undefined)  document.addEventListener('MSFullscreenChange', onFullscreen);
    }


    function initStyle() {
        $(document.head).append(`
<style name="yt-fix-materialUI" type="text/css">

#player-container:focus-within { box-shadow: 0 0 20px 0px rgba(0,0,0,0.8); }

/* Seekbar (when visible) gradient shadow is only as high as the seekbar instead of darkening the bottom 1/3 of the video */
/* Copied values from class .ytp-chrome-bottom in www-player.css */
.ytp-chrome-bottom {
  padding-top: 10px;
  left: 0 !important;
  width: 100% !important;
  background-image: linear-gradient(to top, rgb(0 0 0 / 70%), rgb(0 0 0 / 0%));
}
.ytp-chrome-bottom > * {
  margin-inline: 12px;
}
.ytp-gradient-bottom {
  display: none;
}

/* Highlight focused button in player */
.ytp-probably-keyboard-focus :focus {
  background-color: rgba(120, 180, 255, 0.6);
}

/* Hide the obstructive video suggestions in the embedded player when paused */
.ytp-pause-overlay-container {
  display: none;
}

</style>
        `);
    }


    function initDom() {

        // Area names
        areaOrder= [
            null,
            'player',
            'header',
            'comments',
            'videos',
        ];

        // Areas' root elements
        areaContainers= [
            null,
            document.getElementById('player-container'),    // player
            document.getElementById('masthead-container'),  // header
            document.getElementById('sections'),  // comments
            document.getElementById('related'),   // videos
        ];

        // Areas' default element to focus
        areaFocusDefault= [
            null,
            '#movie_player',           // player
            '#masthead input#search',  // header
            '#info #menu #top-level-buttons button:last()',  // comments
            '#items a.ytd-compact-video-renderer:first()',   // videos
        ];
    }


    function initPlayer() {
        // Path (on page load):  body  >  ytd-app  >  div#content  >  ytd-page-manager#page-manager
        // Path (created 1st step):  >  ytd-watch-flexy.ytd-page-manager  >  div#full-bleed-container  >  div#player-full-bleed-container
        // Path (created 2nd step):  >  div#player-container  >  ytd-player#ytd-player  >  div#container  >  div#movie_player.html5-video-player  >  html5-video-container
        // Path (created 3rd step):  >  video.html5-main-video

        // The movie player frame #movie_player is not part of the initial page load.
        playerElem= document.getElementById('movie_player');
        if (! playerElem) {
            console.error("[YoutubeKeysFix]  initPlayer():  Failed to find #movie_player element: not created yet");
            return false;
        }

        if (previousPlayerReadyCallback) {
            try { previousPlayerReadyCallback.call(arguments); }
            catch (err) { console.error("[YoutubeKeysFix]  initPlayer():  Original onYouTubePlayerReady():", onYouTubePlayerReady, "threw error:", err); }
            previousPlayerReadyCallback = null;
        }

        isEmbeddedUI= playerElem.classList.contains('ytp-embed');

        playerContainer= document.getElementById('player-container')  // full-bleed-container > player-full-bleed-container > player-container > ytd-player > container > movie_player
          || isEmbeddedUI && document.getElementById('player');  // body > player > movie_player.ytp-embed

        console.log("[YoutubeKeysFix]  initPlayer():  player=", [playerElem]);

        // Movie player frame (element) is focused when loading the page to get movie player keyboard controls.
        if (window.location.pathname === "/watch")  playerElem.focus();

        removeTabStops();
    }

    // Disable focusing certain player controls: volume slider, progress bar, fine seeking bar, subtitle.
    // It was possible to focus these using TAB, but the controls (space, arrow keys)
    // change in a confusing manner, creating a miserable UX.
    // Maybe this is done for accessibility reasons? The irony...
    // Youtube should have rethought this design for a decade now.
    function removeTabStops() {
        //let $$= document.querySelectorAll;
        //console.log("[YoutubeKeysFix]  removeTabStops()");

        function removeTabIndexWithSelector(rootElement, selector) {
            for (let elem of rootElement.querySelectorAll(selector)) {
                console.log("[YoutubeKeysFix]  removeTabIndexWithSelector():", "tabindex=", elem.getAttribute('tabindex'), [elem]);
                elem.removeAttribute('tabindex');
            }
        }

        // Remove tab stops from progress bar
        //removeTabIndexWithSelector(playerElem, '.ytp-progress-bar[tabindex]');
        removeTabIndexWithSelector(playerElem, '.ytp-progress-bar');

        // Remove tab stops from fine seeking bar
        //removeTabIndexWithSelector(playerElem, '.ytp-fine-scrubbing-container [tabindex]');
        //removeTabIndexWithSelector(playerElem, '.ytp-fine-scrubbing-thumbnails[tabindex]');
        removeTabIndexWithSelector(playerElem, '.ytp-fine-scrubbing-thumbnails');

        // Remove tab stops from volume slider
        //removeTabIndexWithSelector(playerElem, '.ytp-volume-panel[tabindex]');
        removeTabIndexWithSelector(playerElem, '.ytp-volume-panel');

        // Remove tab stops of non-buttons and links (inclusive selector)
        //removeTabIndexWithSelector(playerElem, '[tabindex]:not(button):not(a):not(div.ytp-ce-element)');

        // Make unfocusable all buttons in the player
        //removeTabIndexWithSelector(playerElem, '[tabindex]');

        // Make unfocusable all buttons in the player controls (bottom bar)
        //removeTabIndexWithSelector(playerElem, '.ytp-chrome-bottom [tabindex]');
        //removeTabIndexWithSelector(playerElem.querySelector('.ytp-chrome-bottom'), '[tabindex]');

        // Remove tab stops from subtitle element when created
        function mutationHandler(mutations, observer) {
            for (let mut of mutations) {
                //console.log("[YoutubeKeysFix]  mutationHandler():\n", mut);  // spammy
                //removeTabIndexWithSelector(mut.target, '.caption-window[tabindex]');
                removeTabIndexWithSelector(mut.target, '.caption-window');

                if (subtitleContainer)  continue;
                subtitleContainer = playerElem.querySelector('#ytp-caption-window-container');
                // If subtitle container is created
                if (subtitleContainer) {
                    console.log("[YoutubeKeysFix]  mutationHandler():  Subtitle container created, stopped observing #movie_player", [subtitleContainer]);
                    // Observe subtitle container instead of movie_player
                    observer.disconnect();
                    observer.observe(subtitleContainer, { childList: true });
                }
            }
        }

        // Subtitle container observer setup
        // #movie_player > #ytp-caption-window-container > .caption-window
        subtitleContainer = playerElem.querySelector('#ytp-caption-window-container');
        if (!subtitleObserver && window.MutationObserver) {
            subtitleObserver = new window.MutationObserver( mutationHandler );
            // Observe movie_player because subtitle container is not created yet
            subtitleObserver.observe(subtitleContainer || playerElem, { childList: true, subtree: !subtitleContainer });
        }
    }


    console.log("[YoutubeKeysFix]  loading:  onYouTubePlayerReady=", window.onYouTubePlayerReady);
    // Run initPlayer() on onYouTubePlayerReady (#movie_player created)
    let previousPlayerReadyCallback = window.onYouTubePlayerReady;
    window.onYouTubePlayerReady = initPlayer;
    //let playerReadyPromise = new Promise( function(resolve, reject) { window.onYouTubePlayerReady = resolve; } );
    //playerReadyPromise.then( previousPlayerReadyCallback ).then( initPlayer );

    //initPlayer();
    initDom();
    initEvents();
    initStyle();


})();