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

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