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.

目前为 2023-03-21 提交的版本。查看 最新版本

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