Greasy Fork 还支持 简体中文。

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.