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-23 提交的版本,查看 最新版本

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