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.

目前为 2024-01-22 提交的版本。查看 最新版本

// ==UserScript==
// @name         YouTube arrow keys FIX
// @version      1.3.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
// @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();


})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址