YouTube arrow keys FIX

Fix YouTube keyboard controls (arrow keys) to be more consistent (Left,Right - jump, Up,Down - volume) after page load or clicking individual controls.

/* eslint-disable  userscripts/use-download-and-update-url */
/* -eslint-disable  userscripts/better-use-match  --  Is this a thing? */
// ==UserScript==
// @name         YouTube arrow keys FIX
// @version      2.0.0
// @description  Fix YouTube keyboard controls (arrow keys) to be more consistent (Left,Right - jump, Up,Down - volume) after page load or clicking individual controls.
// @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
// @match        https://*.youtube.com/*
// @match        https://youtube.googleapis.com/embed*
// @grant        none
// ==/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 playerObserver;
    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); }

    // Source: jquery
    function isVisible(elem) {
        return !elem ? null : !!( elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length );
    }

    function tryFocus(newFocus) {
        if (!newFocus)  return null;
        if (!isVisible(newFocus))  return false;
        //var oldFocus= document.activeElement;
        newFocus.focus();
        var done= (newFocus === document.activeElement);
        if (! done)  console.error("[YoutubeKeysFix]  tryFocus():  Failed to focus newFocus=", [newFocus], "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( document.querySelector( areaFocusDefault[nextArea] ) );
          //if (! done)  done= tryFocus( areaContainers[nextArea] );
          if (! done)  nextArea++;
        } while (!done && nextArea < areaContainers.length);
        return done;
    }


    function redirectEventTo(target, event, cloneEvent) {
        if (!isVisible(target))  return;
        cloneEvent= cloneEvent || new Event(event.type);
        // 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]);

        let keyCode = event.which;

        // Ignore redirected events to avoid recursion
        if (event.originalEvent)  return;

        let redirect = false;
        let inTextbox= keyHandlingElements[event.target.tagName]  ||  event.target.isContentEditable;  //||  event.target.getAttribute('role') == 'textbox';
        // event.target is the focused element that received the keypress

        // Space -> pause video except when writing a comment - Youtube takes care of this
        //if (keyCode == 32)  redirect = !inTextbox;
        //if (keyCode == 32)  return redirectEventTo(document.body, event);

        // Left,Right -> jump 5sec - Youtube takes care of this
        //if (keyCode == 37 || keyCode == 39)  redirect = !inTextbox;

        // End,Home,Up,Down -> control the player if page is scrolled to the top, otherwise scroll the page
        if (keyCode == 35 || keyCode == 36 || keyCode == 38 || keyCode == 40) {
          redirect = !inTextbox && 0 == document.documentElement.scrollTop;
        }

        // Debug log of redirect
        //if (redirect)  console.log("[YoutubeKeysFix]  onKeydown():  redirect, type=" + event.type, "key='" + event.key + "' target=", [event.target, event]);
        if (redirect) {
          return redirectEventTo(playerElem, event);
        }
    }


    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);
        }

        // Ignore redirected events to avoid recursion
        if (event.originalEvent) {
          return;
        }

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

        // End,Home,Up,Down -> scroll the page if not scrolled to the top
        if (keyCode == 35 || keyCode == 36 || keyCode == 38 || keyCode == 40) {
          if (0 < document.documentElement.scrollTop)  return redirectEventTo(document.body, event);
        }

        // 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,End,Home,Up,Down 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() {
        let s= document.createElement('style');
        s.name= 'YoutubeKeysFix-styles';
        s.type= 'text/css';
        s.textContent= `

/* 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;
}

        `;
        document.head.appendChild(s);
    }


    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 observePlayer() {
        // The movie player frame #movie_player is not part of the initial page load.
        playerElem= document.getElementById('movie_player');
        if (playerElem)  return initPlayer();

        // Player elem observer setup
        playerObserver = new MutationObserver( mutationHandler );
        playerObserver.observe(document.body, { childList: true, subtree: true });

        function mutationHandler(mutations, observer) {
            playerElem= document.getElementById('movie_player');
            if (!playerElem)  return;

            console.log("[YoutubeKeysFix]  mutationHandler():  #movie_player created, stopped observing body", [playerElem]);
            // Stop playerObserver
            observer.disconnect();

            initPlayer();
        }
    }

    function initPlayer() {
        // Path (on page load):  body  >  ytd-app
        // Path (DOMContentLoaded):  >  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

        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]);

        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() {
        //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 video player
        //playerElem.removeAttribute('tabindex');
        //removeTabIndexWithSelector(document, '#' + playerElem.id + '[tabindex]');
        //removeTabIndexWithSelector(document, '#' + playerElem.id);
        removeTabIndexWithSelector(document, '.html5-video-player');
        //removeTabIndexWithSelector(playerElem, '.html5-video-container [tabindex]');
        //removeTabIndexWithSelector(playerElem, '.html5-main-video[tabindex]');
        removeTabIndexWithSelector(playerElem, '.html5-main-video');

        // 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) {
            subtitleObserver = new MutationObserver( mutationHandler );
            // Observe movie_player because subtitle container is not created yet
            subtitleObserver.observe(subtitleContainer || playerElem, { childList: true, subtree: !subtitleContainer });
        }
    }


    console.log("[YoutubeKeysFix]  loading:  version=" + GM_info.script.version,  "sandboxMode=" + GM_info.sandboxMode, "onYouTubePlayerReady=", window.onYouTubePlayerReady);

    initDom();
    initEvents();
    initStyle();
    // Run initPlayer() when #movie_player is created
    observePlayer();


})();