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