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