Youtube key shortcuts FIX

Fix player controls Space, Left, Right, Up, Down to behave consistently after page load or clicking individual controls. Not focusing the mute button anymore.

目前為 2024-01-16 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name Youtube key shortcuts FIX
  3. // @namespace https://github.com/Calciferz
  4. // @homepageURL https://github.com/Calciferz/YoutubeKeysFix
  5. // @supportURL https://github.com/Calciferz/YoutubeKeysFix/issues
  6. // @version 1.2.0
  7. // @description Fix player controls Space, Left, Right, Up, Down to behave consistently after page load or clicking individual controls. Not focusing the mute button anymore.
  8. // @icon http://youtube.com/yts/img/favicon_32-vflOogEID.png
  9. // @author Calcifer
  10. // @license MIT
  11. // @copyright 2023, Calcifer (https://github.com/Calciferz)
  12. // @match *://*.youtube.com/*
  13. // @exclude *://*.youtube.com/tv*
  14. // @exclude *://*.youtube.com/live_chat*
  15. // @grant none
  16. // @require http://code.jquery.com/jquery-latest.js
  17. // ==/UserScript==
  18.  
  19. /* To test classicUI add the appropriate one of the following to Youtube url:
  20. &disable_polymer=1
  21. ?disable_polymer=1
  22. */
  23.  
  24. /* eslint-disable no-multi-spaces */
  25. /* eslint-disable no-multi-str */
  26.  
  27.  
  28. (function () {
  29. 'use strict';
  30.  
  31.  
  32. var playerContainer; // = document.getElementById('player-container') || document.getElementById('player') in embeds
  33. var playerElem; // = document.getElementById('movie_player') || playerContainer.querySelector('.html5-video-player') in embeds
  34. //var playerFocused;
  35. var isMaterialUI, isClassicUI;
  36. var lastFocusedPageArea;
  37. var areaOrder= [ null ], areaContainers= [ null ], areaFocusDefault= [ null ], areaFocusedSubelement= [ null ];
  38. //var areaContainers= {}, areaFocusedSubelement= {};
  39.  
  40. function isSubelementOf(elementWithin, ancestor) {
  41. if (! ancestor) return null;
  42. for (; elementWithin; elementWithin= elementWithin.parentElement) {
  43. if (elementWithin.id == ancestor) return true;
  44. }
  45. return false;
  46. }
  47.  
  48. function getAreaOf(elementWithin) {
  49. for (var i= 1; i<areaContainers.length; i++) if (isSubelementOf(elementWithin, areaContainers[i])) return i;
  50. return 0;
  51. //for (var area in areaContainers) if (isSubelementOf(document.activeElement, areaContainers[area])) return area;
  52. }
  53. function getFocusedArea() { return getAreaOf(document.activeElement); }
  54.  
  55. function tryFocus(newFocus) {
  56. newFocus= $(newFocus);
  57. if (! newFocus.length) return null;
  58. if (! newFocus.is(':visible()')) return false;
  59. //var oldFocus= document.activeElement;
  60. newFocus.focus();
  61. var done= (newFocus[0] === document.activeElement);
  62. if (! done) console.error('[YoutubeKeysFix]: tryFocus() failed to focus newFocus=', newFocus[0], ', activeElement=', document.activeElement);
  63. return done;
  64. }
  65.  
  66. function focusNextArea() {
  67. // Focus next area's areaFocusedSubelement (activeElement)
  68. var currentArea= getFocusedArea() || 0;
  69. var nextArea= (lastFocusedPageArea && lastFocusedPageArea !== currentArea) ? lastFocusedPageArea : currentArea + 1;
  70. // captureFocus() will store lastFocusedPageArea again if moving to a non-player area
  71. // if moving to the player then lastFocusedPageArea resets, Shift-Esc will move to search bar (area 2)
  72. lastFocusedPageArea= null;
  73. // To enter player after last area: nextArea= 1; To skip player: nextArea= 2;
  74. if (nextArea >= areaContainers.length) nextArea= 2;
  75.  
  76. var done= tryFocus( areaFocusedSubelement[nextArea] );
  77. if (! done) done= tryFocus( $(areaFocusDefault[nextArea]) );
  78. //if (! done) done= tryFocus( areaContainers[nextArea] );
  79. return done;
  80. }
  81.  
  82. function focusPlayer() {
  83. var player= $(areaFocusDefault[1]);
  84. if (! player[0]) return false;
  85. // If focus was outside player
  86. var focusSubelement= ! player[0].contains(document.activeElement) && areaFocusedSubelement[1];
  87. // And focusSubelement is inside player then focus that finally
  88. if (focusSubelement === player[0] || focusSubelement && ! player[0].contains(focusSubelement)) focusSubelement= null;
  89.  
  90. // Focus player first to scroll into view, then the subelement
  91. var done= tryFocus(player);
  92. if (! done) return false;
  93.  
  94. // Focus player's areaFocusedSubelement if focus was outside player area
  95. done= focusSubelement && tryFocus(focusSubelement);
  96. // Show that focus indicator blue frame and background if subelement got focus
  97. if (done) player.addClass('ytp-probably-keyboard-focus');
  98.  
  99. return true;
  100. }
  101.  
  102.  
  103. function redirectEvent(event, cloneEvent) {
  104. if (! playerElem) initPlayer();
  105. if (! playerElem || ! $(playerElem).is(':visible()')) return;
  106. cloneEvent= cloneEvent || new Event(event.type);
  107. //var cloneEvent= $.extend(cloneEvent, event);
  108. cloneEvent.redirectedEvent= event;
  109. // shallow copy every property
  110. for (var k in event) if (! (k in cloneEvent)) cloneEvent[k]= event[k];
  111.  
  112. event.preventDefault();
  113. event.stopPropagation();
  114. event.stopImmediatePropagation();
  115. console.log('[YoutubeKeysFix]: redirectEvent() cloneEvent=', cloneEvent);
  116. playerElem.dispatchEvent(cloneEvent);
  117. }
  118.  
  119.  
  120. function handleShiftEsc(event) {
  121. // Shift-Esc only implemented for watch page
  122. if (window.location.pathname !== "/watch") return;
  123. // Not in fullscreen
  124. if (getFullscreen()) return;
  125. // show focus outline when navigating focus
  126. document.documentElement.classList.remove('no-focus-outline');
  127. // Bring focus to next area
  128. focusNextArea();
  129. event.preventDefault();
  130. event.stopPropagation();
  131. }
  132.  
  133.  
  134. function onKeydown(event) {
  135. // Debug log of key event
  136. //if (event.key != 'Shift') console.log("[YoutubeKeysFix]: onKeydown() " + event.type + " " + event.which + " ->", event.target, event);
  137. if (event.which == 9) {
  138. // show focus outline when navigating focus
  139. document.documentElement.classList.remove('no-focus-outline');
  140. }
  141.  
  142. // event.target is the focused element (that received the keypress)
  143. // event not received when fullscreen in Opera (already handled by browser)
  144. if (event.shiftKey && event.which == 27) return handleShiftEsc(event);
  145. // Redirect Space (32) to pause video
  146. var redirectSpace= 32 == event.which;
  147. // Redirect arrow keys (37-40: Left,Up,Right,Down) to video player (position/volume)
  148. var redirectArrows= 37 <= event.which && event.which <= 40;
  149. if (redirectSpace || redirectArrows) return redirectEvent(event);
  150. }
  151.  
  152.  
  153. // Tag list from Youtube Plus: https://github.com/ParticleCore/Particle/blob/master/src/Userscript/YouTubePlus.user.js#L885
  154. var keyHandlingElements= { INPUT:1, TEXTAREA:1, IFRAME:1, OBJECT:1, EMBED:1 };
  155.  
  156. function captureKeydown(event) {
  157. // Debug log of key event
  158. //if (event.key != 'Shift') console.log("[YoutubeKeysFix]: captureKeydown() " + event.type + " " + event.which + " ->", event); //, event);
  159.  
  160. // Ignore events for the playerElem to avoid recursion
  161. //if (playerElem == document.activeElement) return;
  162. if (playerElem === event.target) return;
  163.  
  164. // Redirect Space (32) to pause video, if not in a textbox
  165. //var textbox= keyHandlingElements[event.target.tagName] || event.target.isContentEditable;// || event.target.getAttribute('role') == 'textbox';
  166. //var redirectSpace= 32 == event.which && !textbox;
  167.  
  168. // Sliders' key handling behaviour is inconsistent with the default player behaviour. To disable them
  169. // redirect arrow keys (33-40: PageUp,PageDown,End,Home,Left,Up,Right,Down) to page scroll/video player (position/volume)
  170. var redirectArrows= 33 <= event.which && event.which <= 40 && event.target.getAttribute('role') == 'slider' && isSubelementOf(event.target, 'player');
  171. if (redirectArrows) return redirectEvent(event);
  172. }
  173.  
  174.  
  175. function captureMouse(event) {
  176. // Called when mouse button is pressed/released over an element.
  177. // Debug log of mouse button event
  178. //console.log("[YoutubeKeysFix]: captureMouse() " + event.type + " ->", event.target);
  179.  
  180. // hide focus outline when clicking
  181. document.documentElement.classList.add('no-focus-outline');
  182. }
  183.  
  184.  
  185. function onMouse(event) {
  186. // Called when mouse button is pressed over an element.
  187. // Debug log of mouse button event
  188. //console.log("[YoutubeKeysFix]: onMouse() " + event.type + " ->", event.target);
  189. }
  190.  
  191. function onWheel(event) {
  192. //console.log("[YoutubeKeysFix]: onWheel() " + event.type + " " + event.deltaY + " phase " + event.eventPhase + " ->", event.currentTarget, event);
  193. if (! playerElem || ! playerElem.contains(event.target)) return;
  194.  
  195. var deltaY= null !== event.deltaY ? event.deltaY : event.wheelDeltaY;
  196. var up= deltaY <= 0; // null == 0 -> up
  197. var cloneEvent= new Event('keydown');
  198. cloneEvent.which= cloneEvent.keyCode= up ? 38 : 40;
  199. cloneEvent.key= up ? 'ArrowUp': 'ArrowDown';
  200. redirectEvent(event, cloneEvent);
  201. }
  202.  
  203. function getFullscreen() {
  204. return document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement;
  205. }
  206.  
  207. function onFullscreen(event) {
  208. var fullscreen= getFullscreen();
  209. if (fullscreen) {
  210. if ( !fullscreen.contains(document.activeElement) ) {
  211. onFullscreen.prevFocus= document.activeElement;
  212. fullscreen.focus();
  213. }
  214. } else if (onFullscreen.prevFocus) {
  215. onFullscreen.prevFocus.focus();
  216. onFullscreen.prevFocus= null;
  217. }
  218. }
  219.  
  220. function captureFocus(event) {
  221. // Called when an element gets focus (by clicking or TAB)
  222. // Debug log of focused element
  223. //console.log("[YoutubeKeysFix]: captureFocus() " + event.type + " ->", event.target);
  224.  
  225. // Window will focus the activeElement, do nothing at the moment
  226. if (event.target === window) return;
  227.  
  228. // Save focused element inside player or on page
  229. var area= getAreaOf(event.target);
  230. if (0 !== area) {
  231. areaFocusedSubelement[area]= event.target;
  232. //if (areaContainers[area]) document.getElementById(areaContainers[area]).activeElement= event.target;
  233. // store if not focusing player area
  234. if (area !== 1) lastFocusedPageArea= area;
  235. }
  236. }
  237.  
  238.  
  239.  
  240. function chainInitFunc(f1, f2) {
  241. return (function() {
  242. if (f1) f1.apply(this, arguments);
  243. if (f2) f2.apply(this, arguments);
  244. });
  245. }
  246.  
  247. // Run init on onYouTubePlayerReady ('#movie_player' created).
  248. console.log("[YoutubeKeysFix]: loading, onYouTubePlayerReady=", window.onYouTubePlayerReady);
  249. window.onYouTubePlayerReady= chainInitFunc(initPlayer, window.onYouTubePlayerReady);
  250. initEvents();
  251. initStyle();
  252. initDom();
  253.  
  254. function initEvents() {
  255. // Handlers are capture type to see all events before they are consumed
  256. document.addEventListener('mousedown', captureMouse, true);
  257. //document.addEventListener('mouseup', captureMouse, true);
  258.  
  259. // captureFocus captures focus changes before the event is handled
  260. // does not capture body.focus() in Opera, material design
  261. document.addEventListener('focus', captureFocus, true);
  262. //window.addEventListener('focusin', captureFocus);
  263.  
  264. document.addEventListener('mousedown', onMouse);
  265. // mousewheel over player area adjusts volume
  266. //document.addEventListener('wheel', onWheel, true);
  267. // captureKeydown is run before original handlers to have a chance to modify the events
  268. document.addEventListener('keydown', captureKeydown, true);
  269. // onKeydown handles keypress in the bubbling phase to handle Esc if not handled by the focused element
  270. document.addEventListener('keydown', onKeydown);
  271.  
  272. if (document.onfullscreenchange !== undefined) document.addEventListener('fullscreenchange', onFullscreen);
  273. else if (document.onwebkitfullscreenchange !== undefined) document.addEventListener('webkitfullscreenchange', onFullscreen);
  274. else if (document.onmozfullscreenchange !== undefined) document.addEventListener('mozfullscreenchange', onFullscreen);
  275. else if (document.MSFullscreenChange !== undefined) document.addEventListener('MSFullscreenChange', onFullscreen);
  276. }
  277.  
  278. function initStyle() {
  279. // Style for materialUI player, video list item, comment highlight:
  280. // #masthead-container is present on all materialUI pages: index, watch, etc.
  281. if (document.getElementById('masthead')) {
  282. $(document.head).append('\n\
  283. <style name="yt-fix-materialUI type="text/css">\n\
  284. #player-container:focus-within { box-shadow: 0 0 20px 0px rgba(0,0,0,0.8); }\n\
  285. .ytp-probably-keyboard-focus :focus { background-color: rgba(120, 180, 255, 0.6); }\n\
  286. //html:not(.no-focus-outline) ytd-video-primary-info-renderer > #container > #info:focus-within, \n\
  287. //html:not(.no-focus-outline) ytd-video-secondary-info-renderer > #container > #top-row:focus-within, \n\
  288. //html:not(.no-focus-outline) ytd-video-secondary-info-renderer > #container > .description:focus-within \n\
  289. //{ box-shadow: 0 0 10px 0px rgba(0,0,0,0.4); }\n\
  290. html:not(.no-focus-outline) ytd-compact-video-renderer #dismissable:focus-within { box-shadow: 0 0 15px 1px rgba(0,0,100,0.4); }\n\
  291. a.yt-simple-endpoint.ytd-compact-video-renderer { margin-top: 3px; }\n\
  292. </style>\n\
  293. ');
  294. }
  295.  
  296. // Style for classicUI player, video list item, comment-simplebox highlight and layout rearranging for the highlight:
  297. // #yt-masthead-container is present on all classicUI pages: index, watch, etc.
  298. if (document.getElementById('yt-masthead-container')) {
  299. $(document.head).append('\n\
  300. <style name="yt-fix-classicUI" type="text/css">\n\
  301. #player-api:focus-within { box-shadow: 0 0 20px 0px rgba(0,0,0,0.8); }\n\
  302. .ytp-probably-keyboard-focus :focus { background-color: rgba(120, 180, 255, 0.6); }\n\
  303. #masthead-search-terms.masthead-search-terms-border:focus-within { border: 1px solid #4d90fe; box-shadow: inset 0px 0px 8px 0px #4d90fe; }\n\
  304. html:not(.no-focus-outline) #watch-header:focus-within, \n\
  305. html:not(.no-focus-outline) #action-panel-details:focus-within, \n\
  306. html:not(.no-focus-outline) #watch-discussion:focus-within \n\
  307. { box-shadow: 0 0 10px 0px rgba(0,0,0,0.4); }\n\
  308. html:not(.no-focus-outline) .video-list-item:focus-within { box-shadow: 0 0 15px 1px rgba(0,0,100,0.4); }\n\
  309. html:not(.no-focus-outline) .video-list-item:focus-within .related-item-action-menu .yt-uix-button { opacity: 1; }\n\
  310. html:not(.no-focus-outline) .video-list-item:focus-within .video-actions { right: 2px; }\n\
  311. html:not(.no-focus-outline) .video-list-item:focus-within .video-time, \n\
  312. html:not(.no-focus-outline) .related-list-item:focus-within .video-time-overlay { right: -60px; }\n\
  313. #watch7-sidebar-contents { padding-right: 10px; }\n\
  314. #watch7-sidebar-contents .checkbox-on-off { margin-right: 5px; }\n\
  315. #watch7-sidebar .watch-sidebar-head { margin-bottom: 5px; margin-left: 0; }\n\
  316. #watch7-sidebar .watch-sidebar-section { padding-left: 5px; margin-bottom: 0; }\n\
  317. #watch7-sidebar .watch-sidebar-separation-line { margin: 10px 5px; }\n\
  318. .video-list-item .thumb-wrapper { margin: 0; }\n\
  319. .video-list-item { margin-left: 5px; }\n\
  320. .video-list-item .content-wrapper a { padding-top: 3px; min-height: 91px; }\n\
  321. .related-list-item .content-wrapper { margin-left: 176px; margin-right: 5px; }\n\
  322. .related-list-item .related-item-action-menu { top: 3px; right: 0; }\n\
  323. .related-item-dismissable .related-item-action-menu .yt-uix-button { margin: 0; height: 20px; width: 20px; }\n\
  324. </style>\n\
  325. ');
  326. }
  327. }
  328.  
  329. function initDom() {
  330. isMaterialUI= (null !== document.getElementById('masthead'));
  331. isClassicUI= (null !== document.getElementById('yt-masthead-container'));
  332. // MaterialUI has an extra #player.skeleton > #player-api element, remnant of the classicUI, different from the one expected here
  333. // The one with the video: ytd-watch > #top > #player > #player-container.ytd-watch (> #movie_player.html5-video-player)
  334. playerContainer= isMaterialUI ? document.getElementById('player-container') : isClassicUI ? document.getElementById('player-api') : document.getElementById('player');
  335. // isEmbeddedUI= !isMaterialUI && !isClassicUI;
  336.  
  337. // Areas' root elements
  338. areaOrder= [ null, 'player', 'masthead', 'videos', 'content' ];
  339. areaContainers= isMaterialUI ? [ null, 'player-container', 'masthead-container' /* header */, 'related', 'sections' /* comments */ ]
  340. : [ null, 'player-api', 'yt-masthead-container' /* header */, 'watch7-sidebar' /* related videos */, 'watch7-content' /* comments */ ];
  341.  
  342. // Areas' default element to focus
  343. areaFocusDefault[0]= null;
  344. areaFocusDefault[1]= isMaterialUI || isClassicUI ? '#movie_player' : '#player .html5-video-player';
  345. areaFocusDefault[2]= isMaterialUI ? '#masthead input#search' : '#masthead-search-term';
  346. areaFocusDefault[3]= isMaterialUI ? '#items a.ytd-compact-video-renderer:first()' : '#watch7-sidebar-modules a.content-link:first()';
  347. areaFocusDefault[4]= isMaterialUI ? '#info #menu #top-level-buttons button:last()' : '#watch8-action-buttons button:first()';
  348. areaFocusDefault.length= 5;
  349. }
  350.  
  351. function initPlayer() {
  352. // The movie player frame '#movie_player', might not be generated yet.
  353. playerElem= document.getElementById('movie_player') || $('#player .html5-video-player')[0];
  354. if (! playerElem) {
  355. console.error("[YoutubeKeysFix]: initPlayer() failed to find '#movie_player' element: not created yet");
  356. return false;
  357. }
  358.  
  359. console.log("[YoutubeKeysFix]: initPlayer()");
  360. // Movie player frame (element) is focused when loading the page to get movie player keyboard controls.
  361. if (window.location.pathname === "/watch") playerElem.focus();
  362.  
  363. removeTabStops();
  364. }
  365.  
  366. function removeTabStops() {
  367. //let $$= document.querySelectorAll;
  368. //console.log("[YoutubeKeysFix]: removeTabStops()");
  369.  
  370. function removeTabIndex(i, e) {
  371. e.removeAttribute('tabindex');
  372. console.log("[YoutubeKeysFix] removeTabIndex:", e);
  373. }
  374.  
  375. // progress bar
  376. $('.ytp-progress-bar[tabindex]').each( removeTabIndex );
  377. // fine seeking bar
  378. $('.ytp-fine-scrubbing-container [tabindex]').each( removeTabIndex );
  379. // volume slider
  380. $('.ytp-volume-panel[tabindex]').each( removeTabIndex );
  381. // subtitle
  382. $('.caption-window').each( removeTabIndex );
  383. /*
  384. $$('#movie_player [tabindex]').forEach( removeTabIndex );
  385. $$('.ytp-chrome-bottom [tabindex]').forEach( removeTabIndex );
  386. /**/
  387.  
  388. //console.log("[YoutubeKeysFix]: removeTabStops() DONE");
  389. }
  390.  
  391. })();
  392.