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.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.  
  151. // Only capture events within player
  152. if (!isElementWithin(event.target, playerElem)) return;
  153.  
  154. // Sliders' key handling behaviour is inconsistent with the default player behaviour
  155. // Redirect arrow keys (33-40: PageUp,PageDown,End,Home,Left,Up,Right,Down) to page scroll/video player (position/volume)
  156. if (33 <= keyCode && keyCode <= 40 && event.target !== playerElem && event.target.getAttribute('role') == 'slider') {
  157. return redirectEventTo(playerElem, event);
  158. }
  159. }
  160.  
  161.  
  162. function captureMouse(event) {
  163. // Called when mouse button is pressed/released over an element.
  164. // Debug log of mouse button event
  165. //console.log("[YoutubeKeysFix] captureMouse(): type=" + event.type, "button=" + event.button, "target=", [event.target, event]);
  166. }
  167.  
  168.  
  169. function onMouse(event) {
  170. // Called when mouse button is pressed over an element.
  171. // Debug log of mouse button event
  172. //console.log("[YoutubeKeysFix] onMouse(): type=" + event.type, "button=" + event.button, "target=", [event.target, event]);
  173. }
  174.  
  175. function onWheel(event) {
  176. //console.log("[YoutubeKeysFix] onWheel(): deltaY=" + Math.round(event.deltaY), "phase=" + event.eventPhase, "target=", [event.currentTarget, event]);
  177. if (! playerElem || ! playerElem.contains(event.target)) return;
  178.  
  179. var deltaY= null !== event.deltaY ? event.deltaY : event.wheelDeltaY;
  180. var up= deltaY <= 0; // null == 0 -> up
  181. var cloneEvent= new Event('keydown');
  182. cloneEvent.which= cloneEvent.keyCode= up ? 38 : 40;
  183. cloneEvent.key= up ? 'ArrowUp': 'ArrowDown';
  184. redirectEventTo(playerElem, event, cloneEvent);
  185. }
  186.  
  187. function getFullscreen() {
  188. return document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement;
  189. }
  190.  
  191. function onFullscreen(event) {
  192. var fullscreen= getFullscreen();
  193. if (fullscreen) {
  194. if ( !fullscreen.contains(document.activeElement) ) {
  195. onFullscreen.prevFocus= document.activeElement;
  196. fullscreen.focus();
  197. }
  198. } else if (onFullscreen.prevFocus) {
  199. onFullscreen.prevFocus.focus();
  200. onFullscreen.prevFocus= null;
  201. }
  202. }
  203.  
  204. function captureFocus(event) {
  205. // Called when an element gets focus (by clicking or TAB)
  206. // Debug log of focused element
  207. //console.log("[YoutubeKeysFix] captureFocus(): target=", [event.target, event]);
  208.  
  209. // Window will focus the activeElement, do nothing at the moment
  210. if (event.target === window) return;
  211.  
  212. // Save focused element inside player or on page
  213. var area= getAreaOf(event.target);
  214. if (0 !== area) {
  215. areaFocusedSubelement[area]= event.target;
  216. //if (areaContainers[area]) areaContainers[area].activeElement= event.target;
  217. // store if not focusing player area
  218. if (area !== 1) lastFocusedPageArea= area;
  219. }
  220. }
  221.  
  222.  
  223.  
  224. function initEvents() {
  225. // Handlers are capture type to see all events before they are consumed
  226. document.addEventListener('mousedown', captureMouse, true);
  227. //document.addEventListener('mouseup', captureMouse, true);
  228.  
  229. // captureFocus captures focus changes before the event is handled
  230. // does not capture body.focus() in Opera, material design
  231. document.addEventListener('focus', captureFocus, true);
  232. //window.addEventListener('focusin', captureFocus);
  233.  
  234. document.addEventListener('mousedown', onMouse);
  235. // mousewheel over player area adjusts volume
  236. // Passive event handler can call preventDefault() on wheel events to prevent scrolling the page
  237. //document.addEventListener('wheel', onWheel, { passive: false, capture: true });
  238.  
  239. // captureKeydown is run before original handlers to capture key presses before the player does
  240. document.addEventListener('keydown', captureKeydown, true);
  241. // onKeydown handles Tab in the bubbling phase after other elements (textbox, button, link) got a chance.
  242. document.addEventListener('keydown', onKeydown);
  243.  
  244. if (document.onfullscreenchange !== undefined) document.addEventListener('fullscreenchange', onFullscreen);
  245. else if (document.onwebkitfullscreenchange !== undefined) document.addEventListener('webkitfullscreenchange', onFullscreen);
  246. else if (document.onmozfullscreenchange !== undefined) document.addEventListener('mozfullscreenchange', onFullscreen);
  247. else if (document.MSFullscreenChange !== undefined) document.addEventListener('MSFullscreenChange', onFullscreen);
  248. }
  249.  
  250.  
  251. function initStyle() {
  252. document.head.insertAdjacentHTML('beforeend', `
  253. <style name="yt-fix-materialUI" type="text/css">
  254.  
  255. #player-container:focus-within { box-shadow: 0 0 20px 0px rgba(0,0,0,0.8); }
  256.  
  257. /* Seekbar (when visible) gradient shadow is only as high as the seekbar instead of darkening the bottom 1/3 of the video */
  258. /* Copied values from class .ytp-chrome-bottom in www-player.css */
  259. .ytp-chrome-bottom {
  260. padding-top: 10px;
  261. left: 0 !important;
  262. width: 100% !important;
  263. background-image: linear-gradient(to top, rgb(0 0 0 / 70%), rgb(0 0 0 / 0%));
  264. }
  265. .ytp-chrome-bottom > * {
  266. margin-inline: 12px;
  267. }
  268. .ytp-gradient-bottom {
  269. display: none;
  270. }
  271.  
  272. /* Highlight focused button in player */
  273. .ytp-probably-keyboard-focus :focus {
  274. background-color: rgba(120, 180, 255, 0.6);
  275. }
  276.  
  277. /* Hide the obstructive video suggestions in the embedded player when paused */
  278. .ytp-pause-overlay-container {
  279. display: none;
  280. }
  281.  
  282. </style>
  283. `);
  284. }
  285.  
  286.  
  287. function initDom() {
  288.  
  289. // Area names
  290. areaOrder= [
  291. null,
  292. 'player',
  293. 'header',
  294. 'comments',
  295. 'videos',
  296. ];
  297.  
  298. // Areas' root elements
  299. areaContainers= [
  300. null,
  301. document.getElementById('player-container'), // player
  302. document.getElementById('masthead-container'), // header
  303. document.getElementById('sections'), // comments
  304. document.getElementById('related'), // videos
  305. ];
  306.  
  307. // Areas' default element to focus
  308. areaFocusDefault= [
  309. null,
  310. '#movie_player', // player
  311. '#masthead input#search', // header
  312. '#info #menu #top-level-buttons button:last()', // comments
  313. '#items a.ytd-compact-video-renderer:first()', // videos
  314. ];
  315. }
  316.  
  317.  
  318. function initPlayer() {
  319. // Path (on page load): body > ytd-app > div#content > ytd-page-manager#page-manager
  320. // Path (created 1st step): > ytd-watch-flexy.ytd-page-manager > div#full-bleed-container > div#player-full-bleed-container
  321. // Path (created 2nd step): > div#player-container > ytd-player#ytd-player > div#container > div#movie_player.html5-video-player > html5-video-container
  322. // Path (created 3rd step): > video.html5-main-video
  323.  
  324. // The movie player frame #movie_player is not part of the initial page load.
  325. playerElem= document.getElementById('movie_player');
  326. if (! playerElem) {
  327. console.error("[YoutubeKeysFix] initPlayer(): Failed to find #movie_player element: not created yet");
  328. return false;
  329. }
  330.  
  331. if (previousPlayerReadyCallback) {
  332. try { previousPlayerReadyCallback.call(arguments); }
  333. catch (err) { console.error("[YoutubeKeysFix] initPlayer(): Original onYouTubePlayerReady():", previousPlayerReadyCallback, "threw error:", err); }
  334. previousPlayerReadyCallback = null;
  335. }
  336.  
  337. isEmbeddedUI= playerElem.classList.contains('ytp-embed');
  338.  
  339. playerContainer= document.getElementById('player-container') // full-bleed-container > player-full-bleed-container > player-container > ytd-player > container > movie_player
  340. || isEmbeddedUI && document.getElementById('player'); // body > player > movie_player.ytp-embed
  341.  
  342. console.log("[YoutubeKeysFix] initPlayer(): player=", [playerElem]);
  343.  
  344. // Movie player frame (element) is focused when loading the page to get movie player keyboard controls.
  345. if (window.location.pathname === "/watch") playerElem.focus();
  346.  
  347. removeTabStops();
  348. }
  349.  
  350. // Disable focusing certain player controls: volume slider, progress bar, fine seeking bar, subtitle.
  351. // It was possible to focus these using TAB, but the controls (space, arrow keys)
  352. // change in a confusing manner, creating a miserable UX.
  353. // Maybe this is done for accessibility reasons? The irony...
  354. // Youtube should have rethought this design for a decade now.
  355. function removeTabStops() {
  356. //console.log("[YoutubeKeysFix] removeTabStops()");
  357.  
  358. function removeTabIndexWithSelector(rootElement, selector) {
  359. for (let elem of rootElement.querySelectorAll(selector)) {
  360. console.log("[YoutubeKeysFix] removeTabIndexWithSelector():", "tabindex=", elem.getAttribute('tabindex'), [elem]);
  361. elem.removeAttribute('tabindex');
  362. }
  363. }
  364.  
  365. // Remove tab stops from progress bar
  366. //removeTabIndexWithSelector(playerElem, '.ytp-progress-bar[tabindex]');
  367. removeTabIndexWithSelector(playerElem, '.ytp-progress-bar');
  368.  
  369. // Remove tab stops from fine seeking bar
  370. //removeTabIndexWithSelector(playerElem, '.ytp-fine-scrubbing-container [tabindex]');
  371. //removeTabIndexWithSelector(playerElem, '.ytp-fine-scrubbing-thumbnails[tabindex]');
  372. removeTabIndexWithSelector(playerElem, '.ytp-fine-scrubbing-thumbnails');
  373.  
  374. // Remove tab stops from volume slider
  375. //removeTabIndexWithSelector(playerElem, '.ytp-volume-panel[tabindex]');
  376. removeTabIndexWithSelector(playerElem, '.ytp-volume-panel');
  377.  
  378. // Remove tab stops of non-buttons and links (inclusive selector)
  379. //removeTabIndexWithSelector(playerElem, '[tabindex]:not(button):not(a):not(div.ytp-ce-element)');
  380.  
  381. // Make unfocusable all buttons in the player
  382. //removeTabIndexWithSelector(playerElem, '[tabindex]');
  383.  
  384. // Make unfocusable all buttons in the player controls (bottom bar)
  385. //removeTabIndexWithSelector(playerElem, '.ytp-chrome-bottom [tabindex]');
  386. //removeTabIndexWithSelector(playerElem.querySelector('.ytp-chrome-bottom'), '[tabindex]');
  387.  
  388. // Remove tab stops from subtitle element when created
  389. function mutationHandler(mutations, observer) {
  390. for (let mut of mutations) {
  391. //console.log("[YoutubeKeysFix] mutationHandler():\n", mut); // spammy
  392. //removeTabIndexWithSelector(mut.target, '.caption-window[tabindex]');
  393. removeTabIndexWithSelector(mut.target, '.caption-window');
  394.  
  395. if (subtitleContainer) continue;
  396. subtitleContainer = playerElem.querySelector('#ytp-caption-window-container');
  397. // If subtitle container is created
  398. if (subtitleContainer) {
  399. console.log("[YoutubeKeysFix] mutationHandler(): Subtitle container created, stopped observing #movie_player", [subtitleContainer]);
  400. // Observe subtitle container instead of movie_player
  401. observer.disconnect();
  402. observer.observe(subtitleContainer, { childList: true });
  403. }
  404. }
  405. }
  406.  
  407. // Subtitle container observer setup
  408. // #movie_player > #ytp-caption-window-container > .caption-window
  409. subtitleContainer = playerElem.querySelector('#ytp-caption-window-container');
  410. if (!subtitleObserver && window.MutationObserver) {
  411. subtitleObserver = new window.MutationObserver( mutationHandler );
  412. // Observe movie_player because subtitle container is not created yet
  413. subtitleObserver.observe(subtitleContainer || playerElem, { childList: true, subtree: !subtitleContainer });
  414. }
  415. }
  416.  
  417.  
  418. console.log("[YoutubeKeysFix] loading: onYouTubePlayerReady=", window.onYouTubePlayerReady);
  419. // Run initPlayer() on onYouTubePlayerReady (#movie_player created)
  420. let previousPlayerReadyCallback = window.onYouTubePlayerReady;
  421. window.onYouTubePlayerReady = initPlayer;
  422. //let playerReadyPromise = new Promise( function(resolve, reject) { window.onYouTubePlayerReady = resolve; } );
  423. //playerReadyPromise.then( previousPlayerReadyCallback ).then( initPlayer );
  424.  
  425. //initPlayer();
  426. initDom();
  427. initEvents();
  428. initStyle();
  429.  
  430.  
  431. })();
  432.