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.

  1. /* eslint-disable userscripts/use-download-and-update-url */
  2. /* -eslint-disable userscripts/better-use-match -- Is this a thing? */
  3. // ==UserScript==
  4. // @name YouTube arrow keys FIX
  5. // @version 2.0.0
  6. // @description Fix YouTube keyboard controls (arrow keys) to be more consistent (Left,Right - jump, Up,Down - volume) after page load or clicking individual controls.
  7. // @author Calcifer
  8. // @license MIT
  9. // @namespace https://github.com/Calciferz
  10. // @homepageURL https://github.com/Calciferz/YoutubeKeysFix
  11. // @supportURL https://github.com/Calciferz/YoutubeKeysFix/issues
  12. // @icon http://youtube.com/yts/img/favicon_32-vflOogEID.png
  13. // @match https://*.youtube.com/*
  14. // @match https://youtube.googleapis.com/embed*
  15. // @grant none
  16. // ==/UserScript==
  17.  
  18. /* eslint-disable no-multi-spaces */
  19. /* eslint-disable no-multi-str */
  20.  
  21.  
  22. (function () {
  23. 'use strict';
  24.  
  25. var playerContainer; // = document.getElementById('player-container') || document.getElementById('player') in embeds
  26. var playerElem; // = document.getElementById('movie_player')
  27. var playerObserver;
  28. var isEmbeddedUI;
  29. var subtitleObserver;
  30. var subtitleContainer;
  31.  
  32. var lastFocusedPageArea;
  33. var areaOrder= [ null ],
  34. areaContainers= [ null ],
  35. areaFocusDefault= [ null ],
  36. areaFocusedSubelement= [ null ];
  37.  
  38.  
  39.  
  40. function formatElemIdOrClass(elem) {
  41. return elem.id ? '#' + elem.id
  42. : elem.className ? '.' + elem.className.replace(' ', '.')
  43. : elem.tagName;
  44. }
  45.  
  46. function formatElemIdOrTag(elem) {
  47. return elem.id ? '#' + elem.id
  48. : elem.tagName;
  49. }
  50.  
  51. function isElementWithin(elementWithin, ancestor) {
  52. if (! ancestor) return null;
  53. for (; elementWithin; elementWithin= elementWithin.parentElement) {
  54. if (elementWithin === ancestor) return true;
  55. }
  56. return false;
  57. }
  58.  
  59. function getAreaOf(elementWithin) {
  60. for (var i= 1; i<areaContainers.length; i++) {
  61. if (isElementWithin(elementWithin, areaContainers[i])) return i;
  62. }
  63. return 0;
  64. }
  65. function getFocusedArea() { return getAreaOf(document.activeElement); }
  66.  
  67. // Source: jquery
  68. function isVisible(elem) {
  69. return !elem ? null : !!( elem.offsetWidth || elem.offsetHeight || elem.getClientRects().length );
  70. }
  71.  
  72. function tryFocus(newFocus) {
  73. if (!newFocus) return null;
  74. if (!isVisible(newFocus)) return false;
  75. //var oldFocus= document.activeElement;
  76. newFocus.focus();
  77. var done= (newFocus === document.activeElement);
  78. if (! done) console.error("[YoutubeKeysFix] tryFocus(): Failed to focus newFocus=", [newFocus], "activeElement=", [document.activeElement]);
  79. return done;
  80. }
  81.  
  82. function focusNextArea() {
  83. // Focus next area's areaFocusedSubelement (activeElement)
  84. var currentArea= getFocusedArea() || 0;
  85. var nextArea= (lastFocusedPageArea && lastFocusedPageArea !== currentArea) ? lastFocusedPageArea : currentArea + 1;
  86. // captureFocus() will store lastFocusedPageArea again if moving to a non-player area
  87. // if moving to the player then lastFocusedPageArea resets, Shift-Esc will move to search bar (area 2)
  88. lastFocusedPageArea= null;
  89. // To enter player after last area: nextArea= 1; To skip player: nextArea= 2;
  90. if (nextArea >= areaContainers.length) nextArea= 2;
  91.  
  92. let done = false;
  93. do {
  94. done= tryFocus( areaFocusedSubelement[nextArea] );
  95. if (! done) done= tryFocus( document.querySelector( areaFocusDefault[nextArea] ) );
  96. //if (! done) done= tryFocus( areaContainers[nextArea] );
  97. if (! done) nextArea++;
  98. } while (!done && nextArea < areaContainers.length);
  99. return done;
  100. }
  101.  
  102.  
  103. function redirectEventTo(target, event, cloneEvent) {
  104. if (!isVisible(target)) return;
  105. cloneEvent= cloneEvent || new Event(event.type);
  106. // shallow copy every property
  107. for (var k in event) if (! (k in cloneEvent)) cloneEvent[k]= event[k];
  108. cloneEvent.originalEvent= event;
  109.  
  110. event.preventDefault();
  111. event.stopPropagation();
  112. event.stopImmediatePropagation();
  113.  
  114. try { console.log("[YoutubeKeysFix] redirectEventTo(): type=" + cloneEvent.type, "key='" + cloneEvent.key + "' to=" + formatElemIdOrTag(target), "from=", [event.target, event, cloneEvent]); }
  115. catch (err) { console.error("[YoutubeKeysFix] redirectEventTo(): Error while logging=", err); }
  116.  
  117. target.dispatchEvent(cloneEvent);
  118. }
  119.  
  120.  
  121. function handleShiftEsc(event) {
  122. // Shift-Esc only implemented for watch page
  123. if (window.location.pathname !== "/watch") return;
  124. // Not in fullscreen
  125. if (getFullscreen()) return;
  126. // Bring focus to next area
  127. focusNextArea();
  128. event.preventDefault();
  129. event.stopPropagation();
  130. }
  131.  
  132.  
  133. // Tag list from YouTube Plus: https://github.com/ParticleCore/Particle/blob/master/src/Userscript/YouTubePlus.user.js#L885
  134. var keyHandlingElements= { INPUT:1, TEXTAREA:1, IFRAME:1, OBJECT:1, EMBED:1 };
  135.  
  136. function onKeydown(event) {
  137. // Debug log of key event
  138. //if (event.key != 'Shift') console.log("[YoutubeKeysFix] onKeydown(): type=" + event.type, "key='" + event.key + "' target=", [event.target, event]);
  139.  
  140. let keyCode = event.which;
  141.  
  142. // Ignore redirected events to avoid recursion
  143. if (event.originalEvent) return;
  144.  
  145. let redirect = false;
  146. let inTextbox= keyHandlingElements[event.target.tagName] || event.target.isContentEditable; //|| event.target.getAttribute('role') == 'textbox';
  147. // event.target is the focused element that received the keypress
  148.  
  149. // Space -> pause video except when writing a comment - Youtube takes care of this
  150. //if (keyCode == 32) redirect = !inTextbox;
  151. //if (keyCode == 32) return redirectEventTo(document.body, event);
  152.  
  153. // Left,Right -> jump 5sec - Youtube takes care of this
  154. //if (keyCode == 37 || keyCode == 39) redirect = !inTextbox;
  155.  
  156. // End,Home,Up,Down -> control the player if page is scrolled to the top, otherwise scroll the page
  157. if (keyCode == 35 || keyCode == 36 || keyCode == 38 || keyCode == 40) {
  158. redirect = !inTextbox && 0 == document.documentElement.scrollTop;
  159. }
  160.  
  161. // Debug log of redirect
  162. //if (redirect) console.log("[YoutubeKeysFix] onKeydown(): redirect, type=" + event.type, "key='" + event.key + "' target=", [event.target, event]);
  163. if (redirect) {
  164. return redirectEventTo(playerElem, event);
  165. }
  166. }
  167.  
  168.  
  169. function captureKeydown(event) {
  170. // Debug log of key event
  171. //if (event.key != 'Shift') console.log("[YoutubeKeysFix] captureKeydown(): type=" + event.type, "key='" + event.key + "' target=", [event.target, event]);
  172.  
  173. let keyCode = event.which;
  174.  
  175. // Shift-Esc -> cycle through search box, videos, comments
  176. // Event is not received when fullscreen in Opera (already handled by browser)
  177. if (keyCode == 27 && event.shiftKey) {
  178. return handleShiftEsc(event);
  179. }
  180.  
  181. // Ignore redirected events to avoid recursion
  182. if (event.originalEvent) {
  183. return;
  184. }
  185.  
  186. // Only capture events within player
  187. if (!isElementWithin(event.target, playerElem)) return;
  188.  
  189. // End,Home,Up,Down -> scroll the page if not scrolled to the top
  190. if (keyCode == 35 || keyCode == 36 || keyCode == 38 || keyCode == 40) {
  191. if (0 < document.documentElement.scrollTop) return redirectEventTo(document.body, event);
  192. }
  193.  
  194. // Sliders' key handling behaviour is inconsistent with the default player behaviour
  195. // Redirect arrow keys (33-40: PageUp,PageDown,End,Home,Left,Up,Right,Down) to page scroll/video player (position/volume)
  196. if (33 <= keyCode && keyCode <= 40 && event.target !== playerElem && event.target.getAttribute('role') == 'slider') {
  197. return redirectEventTo(playerElem, event);
  198. }
  199. }
  200.  
  201.  
  202. function captureMouse(event) {
  203. // Called when mouse button is pressed/released over an element.
  204. // Debug log of mouse button event
  205. //console.log("[YoutubeKeysFix] captureMouse(): type=" + event.type, "button=" + event.button, "target=", [event.target, event]);
  206. }
  207.  
  208.  
  209. function onMouse(event) {
  210. // Called when mouse button is pressed over an element.
  211. // Debug log of mouse button event
  212. //console.log("[YoutubeKeysFix] onMouse(): type=" + event.type, "button=" + event.button, "target=", [event.target, event]);
  213. }
  214.  
  215. function onWheel(event) {
  216. //console.log("[YoutubeKeysFix] onWheel(): deltaY=" + Math.round(event.deltaY), "phase=" + event.eventPhase, "target=", [event.currentTarget, event]);
  217. if (! playerElem || ! playerElem.contains(event.target)) return;
  218.  
  219. var deltaY= null !== event.deltaY ? event.deltaY : event.wheelDeltaY;
  220. var up= deltaY <= 0; // null == 0 -> up
  221. var cloneEvent= new Event('keydown');
  222. cloneEvent.which= cloneEvent.keyCode= up ? 38 : 40;
  223. cloneEvent.key= up ? 'ArrowUp': 'ArrowDown';
  224. redirectEventTo(playerElem, event, cloneEvent);
  225. }
  226.  
  227. function getFullscreen() {
  228. return document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement;
  229. }
  230.  
  231. function onFullscreen(event) {
  232. var fullscreen= getFullscreen();
  233. if (fullscreen) {
  234. if ( !fullscreen.contains(document.activeElement) ) {
  235. onFullscreen.prevFocus= document.activeElement;
  236. fullscreen.focus();
  237. }
  238. } else if (onFullscreen.prevFocus) {
  239. onFullscreen.prevFocus.focus();
  240. onFullscreen.prevFocus= null;
  241. }
  242. }
  243.  
  244. function captureFocus(event) {
  245. // Called when an element gets focus (by clicking or TAB)
  246. // Debug log of focused element
  247. //console.log("[YoutubeKeysFix] captureFocus(): target=", [event.target, event]);
  248.  
  249. // Window will focus the activeElement, do nothing at the moment
  250. if (event.target === window) return;
  251.  
  252. // Save focused element inside player or on page
  253. var area= getAreaOf(event.target);
  254. if (0 !== area) {
  255. areaFocusedSubelement[area]= event.target;
  256. //if (areaContainers[area]) areaContainers[area].activeElement= event.target;
  257. // store if not focusing player area
  258. if (area !== 1) lastFocusedPageArea= area;
  259. }
  260. }
  261.  
  262.  
  263.  
  264. function initEvents() {
  265. // Handlers are capture type to see all events before they are consumed
  266. document.addEventListener('mousedown', captureMouse, true);
  267. //document.addEventListener('mouseup', captureMouse, true);
  268.  
  269. // captureFocus captures focus changes before the event is handled
  270. // does not capture body.focus() in Opera, material design
  271. document.addEventListener('focus', captureFocus, true);
  272. //window.addEventListener('focusin', captureFocus);
  273.  
  274. document.addEventListener('mousedown', onMouse);
  275. // mousewheel over player area adjusts volume
  276. // Passive event handler can call preventDefault() on wheel events to prevent scrolling the page
  277. //document.addEventListener('wheel', onWheel, { passive: false, capture: true });
  278.  
  279. // captureKeydown is run before original handlers to capture key presses before the player does
  280. document.addEventListener('keydown', captureKeydown, true);
  281. // onKeydown handles Tab,End,Home,Up,Down in the bubbling phase after other elements (textbox, button, link) got a chance.
  282. document.addEventListener('keydown', onKeydown);
  283.  
  284. if (document.onfullscreenchange !== undefined) document.addEventListener('fullscreenchange', onFullscreen);
  285. else if (document.onwebkitfullscreenchange !== undefined) document.addEventListener('webkitfullscreenchange', onFullscreen);
  286. else if (document.onmozfullscreenchange !== undefined) document.addEventListener('mozfullscreenchange', onFullscreen);
  287. else if (document.MSFullscreenChange !== undefined) document.addEventListener('MSFullscreenChange', onFullscreen);
  288. }
  289.  
  290.  
  291. function initStyle() {
  292. let s= document.createElement('style');
  293. s.name= 'YoutubeKeysFix-styles';
  294. s.type= 'text/css';
  295. s.textContent= `
  296.  
  297. /* Highlight focused button in player */
  298. .ytp-probably-keyboard-focus :focus {
  299. background-color: rgba(120, 180, 255, 0.6);
  300. }
  301.  
  302. /* Hide the obstructive video suggestions in the embedded player when paused */
  303. .ytp-pause-overlay-container {
  304. display: none;
  305. }
  306.  
  307. `;
  308. document.head.appendChild(s);
  309. }
  310.  
  311.  
  312. function initDom() {
  313.  
  314. // Area names
  315. areaOrder= [
  316. null,
  317. //'player',
  318. 'header',
  319. 'comments',
  320. 'videos',
  321. ];
  322.  
  323. // Areas' root elements
  324. areaContainers= [
  325. null,
  326. //document.getElementById('player-container'), // player
  327. document.getElementById('masthead-container'), // header
  328. document.getElementById('sections'), // comments
  329. document.getElementById('related'), // videos
  330. ];
  331.  
  332. // Areas' default element to focus
  333. areaFocusDefault= [
  334. null,
  335. //'#movie_player', // player
  336. '#masthead input#search', // header
  337. '#info #menu #top-level-buttons button:last()', // comments
  338. '#items a.ytd-compact-video-renderer:first()', // videos
  339. ];
  340. }
  341.  
  342.  
  343. function observePlayer() {
  344. // The movie player frame #movie_player is not part of the initial page load.
  345. playerElem= document.getElementById('movie_player');
  346. if (playerElem) return initPlayer();
  347.  
  348. // Player elem observer setup
  349. playerObserver = new MutationObserver( mutationHandler );
  350. playerObserver.observe(document.body, { childList: true, subtree: true });
  351.  
  352. function mutationHandler(mutations, observer) {
  353. playerElem= document.getElementById('movie_player');
  354. if (!playerElem) return;
  355.  
  356. console.log("[YoutubeKeysFix] mutationHandler(): #movie_player created, stopped observing body", [playerElem]);
  357. // Stop playerObserver
  358. observer.disconnect();
  359.  
  360. initPlayer();
  361. }
  362. }
  363.  
  364. function initPlayer() {
  365. // Path (on page load): body > ytd-app
  366. // Path (DOMContentLoaded): > div#content > ytd-page-manager#page-manager
  367. // Path (created 1st step): > ytd-watch-flexy.ytd-page-manager > div#full-bleed-container > div#player-full-bleed-container
  368. // Path (created 2nd step): > div#player-container > ytd-player#ytd-player > div#container > div#movie_player.html5-video-player > html5-video-container
  369. // Path (created 3rd step): > video.html5-main-video
  370.  
  371. isEmbeddedUI= playerElem.classList.contains('ytp-embed');
  372.  
  373. playerContainer= document.getElementById('player-container') // full-bleed-container > player-full-bleed-container > player-container > ytd-player > container > movie_player
  374. || isEmbeddedUI && document.getElementById('player'); // body > player > movie_player.ytp-embed
  375.  
  376. console.log("[YoutubeKeysFix] initPlayer(): player=", [playerElem]);
  377.  
  378. removeTabStops();
  379. }
  380.  
  381. // Disable focusing certain player controls: volume slider, progress bar, fine seeking bar, subtitle.
  382. // It was possible to focus these using TAB, but the controls (space, arrow keys)
  383. // change in a confusing manner, creating a miserable UX.
  384. // Maybe this is done for accessibility reasons? The irony...
  385. // Youtube should have rethought this design for a decade now.
  386. function removeTabStops() {
  387. //console.log("[YoutubeKeysFix] removeTabStops()");
  388.  
  389. function removeTabIndexWithSelector(rootElement, selector) {
  390. for (let elem of rootElement.querySelectorAll(selector)) {
  391. console.log("[YoutubeKeysFix] removeTabIndexWithSelector():", "tabindex=", elem.getAttribute('tabindex'), [elem]);
  392. elem.removeAttribute('tabindex');
  393. }
  394. }
  395.  
  396. // Remove tab stops from video player
  397. //playerElem.removeAttribute('tabindex');
  398. //removeTabIndexWithSelector(document, '#' + playerElem.id + '[tabindex]');
  399. //removeTabIndexWithSelector(document, '#' + playerElem.id);
  400. removeTabIndexWithSelector(document, '.html5-video-player');
  401. //removeTabIndexWithSelector(playerElem, '.html5-video-container [tabindex]');
  402. //removeTabIndexWithSelector(playerElem, '.html5-main-video[tabindex]');
  403. removeTabIndexWithSelector(playerElem, '.html5-main-video');
  404.  
  405. // Remove tab stops from progress bar
  406. //removeTabIndexWithSelector(playerElem, '.ytp-progress-bar[tabindex]');
  407. removeTabIndexWithSelector(playerElem, '.ytp-progress-bar');
  408.  
  409. // Remove tab stops from fine seeking bar
  410. //removeTabIndexWithSelector(playerElem, '.ytp-fine-scrubbing-container [tabindex]');
  411. //removeTabIndexWithSelector(playerElem, '.ytp-fine-scrubbing-thumbnails[tabindex]');
  412. removeTabIndexWithSelector(playerElem, '.ytp-fine-scrubbing-thumbnails');
  413.  
  414. // Remove tab stops from volume slider
  415. //removeTabIndexWithSelector(playerElem, '.ytp-volume-panel[tabindex]');
  416. removeTabIndexWithSelector(playerElem, '.ytp-volume-panel');
  417.  
  418. // Remove tab stops of non-buttons and links (inclusive selector)
  419. //removeTabIndexWithSelector(playerElem, '[tabindex]:not(button):not(a):not(div.ytp-ce-element)');
  420.  
  421. // Make unfocusable all buttons in the player
  422. //removeTabIndexWithSelector(playerElem, '[tabindex]');
  423.  
  424. // Make unfocusable all buttons in the player controls (bottom bar)
  425. //removeTabIndexWithSelector(playerElem, '.ytp-chrome-bottom [tabindex]');
  426. //removeTabIndexWithSelector(playerElem.querySelector('.ytp-chrome-bottom'), '[tabindex]');
  427.  
  428. // Remove tab stops from subtitle element when created
  429. function mutationHandler(mutations, observer) {
  430. for (let mut of mutations) {
  431. //console.log("[YoutubeKeysFix] mutationHandler():\n", mut); // spammy
  432. //removeTabIndexWithSelector(mut.target, '.caption-window[tabindex]');
  433. removeTabIndexWithSelector(mut.target, '.caption-window');
  434.  
  435. if (subtitleContainer) continue;
  436. subtitleContainer = playerElem.querySelector('#ytp-caption-window-container');
  437. // If subtitle container is created
  438. if (subtitleContainer) {
  439. console.log("[YoutubeKeysFix] mutationHandler(): Subtitle container created, stopped observing #movie_player", [subtitleContainer]);
  440. // Observe subtitle container instead of movie_player
  441. observer.disconnect();
  442. observer.observe(subtitleContainer, { childList: true });
  443. }
  444. }
  445. }
  446.  
  447. // Subtitle container observer setup
  448. // #movie_player > #ytp-caption-window-container > .caption-window
  449. subtitleContainer = playerElem.querySelector('#ytp-caption-window-container');
  450. if (!subtitleObserver) {
  451. subtitleObserver = new MutationObserver( mutationHandler );
  452. // Observe movie_player because subtitle container is not created yet
  453. subtitleObserver.observe(subtitleContainer || playerElem, { childList: true, subtree: !subtitleContainer });
  454. }
  455. }
  456.  
  457.  
  458. console.log("[YoutubeKeysFix] loading: version=" + GM_info.script.version, "sandboxMode=" + GM_info.sandboxMode, "onYouTubePlayerReady=", window.onYouTubePlayerReady);
  459.  
  460. initDom();
  461. initEvents();
  462. initStyle();
  463. // Run initPlayer() when #movie_player is created
  464. observePlayer();
  465.  
  466.  
  467. })();
  468.