Better-Native-Video

Add keyboard support to native HTML5 video player.

  1. // ==UserScript==
  2. // @name Better-Native-Video
  3. // @namespace https://tribbe.dev
  4. // @version 1.0.1
  5. // @description Add keyboard support to native HTML5 video player.
  6. // @author Tribbe
  7. // @include http://*/*
  8. // @include https://*/*
  9. // ==/UserScript==
  10.  
  11. "use strict";
  12.  
  13. const videoAttribute = "betterHtml5VideoType",
  14. timeoutAttribute = "betterHtml5VideoClickTimeout";
  15.  
  16. let toggleChecked, toggleEnabled, observer, dirVideo, settings = {
  17. firstClick: "focus",
  18. dblFullScreen: true,
  19. clickDelay: 0.3,
  20. skipNormal: 5,
  21. skipShift: 10,
  22. skipCtrl: 1,
  23. allowWOControls: false,
  24. };
  25.  
  26. const shortcutFuncs = {
  27. toggleCaptions: function(v){
  28. const validTracks = [];
  29. for(let i = 0; i < v.textTracks.length; ++i){
  30. const tt = v.textTracks[i];
  31. if(tt.mode === "showing"){
  32. tt.mode = "disabled";
  33. if(v.textTracks.addEventListener){
  34. // If text track event listeners are supported
  35. // (they are on the most recent Chrome), add
  36. // a marker to remember the old track. Use a
  37. // listener to delete it if a different track
  38. // is selected.
  39. v.cbhtml5vsLastCaptionTrack = tt.label;
  40. function cleanup(e){
  41. for(let i = 0; i < v.textTracks.length; ++i){
  42. const ott = v.textTracks[i];
  43. if(ott.mode === "showing"){
  44. delete v.cbhtml5vsLastCaptionTrack;
  45. v.textTracks.removeEventListener("change", cleanup);
  46. return;
  47. }
  48. }
  49. }
  50. v.textTracks.addEventListener("change", cleanup);
  51. }
  52. return;
  53. }else if(tt.mode !== "hidden"){
  54. validTracks.push(tt);
  55. }
  56. }
  57. // If we got here, none of the tracks were selected.
  58. if(validTracks.length === 0){
  59. return true; // Do not prevent default if no UI activated
  60. }
  61. // Find the best one and select it.
  62. validTracks.sort(function(a, b){
  63.  
  64. if(v.cbhtml5vsLastCaptionTrack){
  65. const lastLabel = v.cbhtml5vsLastCaptionTrack;
  66.  
  67. if(a.label === lastLabel && b.label !== lastLabel){
  68. return -1;
  69. }else if(b.label === lastLabel && a.label !== lastLabel){
  70. return 1;
  71. }
  72. }
  73.  
  74. const aLang = a.language.toLowerCase(),
  75. bLang = b.language.toLowerCase(),
  76. navLang = navigator.language.toLowerCase();
  77.  
  78. if(aLang === navLang && bLang !== navLang){
  79. return -1;
  80. }else if(bLang === navLang && aLang !== navLang){
  81. return 1;
  82. }
  83.  
  84. const aPre = aLang.split("-")[0],
  85. bPre = bLang.split("-")[0],
  86. navPre = navLang.split("-")[0];
  87.  
  88. if(aPre === navPre && bPre !== navPre){
  89. return -1;
  90. }else if(bPre === navPre && aPre !== navPre){
  91. return 1;
  92. }
  93.  
  94. return 0;
  95. })[0].mode = "showing";
  96. },
  97.  
  98. togglePlay: function(v){
  99. if (v.paused) {
  100. v.play();
  101. } else {
  102. v.pause();
  103. }
  104. },
  105.  
  106. toStart: function(v){
  107. v.currentTime = 0;
  108. },
  109.  
  110. toEnd: function(v){
  111. v.currentTime = v.duration;
  112. },
  113.  
  114. skipLeft: function(v,key,shift,ctrl){
  115. if (shift) {
  116. v.currentTime -= settings.skipShift;
  117. } else if(ctrl) {
  118. v.currentTime -= settings.skipCtrl;
  119. } else {
  120. v.currentTime -= settings.skipNormal;
  121. }
  122. },
  123.  
  124. skipRight: function(v,key,shift,ctrl){
  125. if (shift) {
  126. v.currentTime += settings.skipShift;
  127. } else if (ctrl) {
  128. v.currentTime += settings.skipCtrl;
  129. } else {
  130. v.currentTime += settings.skipNormal;
  131. }
  132. },
  133.  
  134. increaseVol: function(v){
  135. if (v.volume <= 0.9) v.volume += 0.1;
  136. else v.volume = 1;
  137. },
  138.  
  139. decreaseVol: function(v){
  140. if (v.volume >= 0.1) v.volume -= 0.1;
  141. else v.volume = 0;
  142. },
  143.  
  144. toggleMute: function(v){
  145. v.muted = !v.muted;
  146. },
  147.  
  148. toggleFS: function(v){
  149. if (document.webkitFullscreenElement) {
  150. document.webkitExitFullscreen();
  151. } else {
  152. v.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT);
  153. }
  154. },
  155.  
  156. reloadVideo: function(v){
  157. const currTime = v.currentTime;
  158. v.load();
  159. v.currentTime = currTime;
  160. },
  161.  
  162. slowOrPrevFrame: function(v,key,shift){
  163. if (shift) { // Less-Than
  164. v.playbackRate -= 0.25;
  165. } else { // Comma
  166. v.currentTime -= 1/60;
  167. }
  168. },
  169.  
  170. fastOrNextFrame: function(v,key,shift){
  171. if (shift) { // Greater-Than
  172. v.playbackRate += 0.25;
  173. } else { // Period
  174. v.currentTime += 1/60;
  175. }
  176. },
  177.  
  178. normalSpeed: function(v,key,shift){
  179. if(shift) { // ?
  180. v.playbackRate = v.defaultPlaybackRate;
  181. }
  182. },
  183.  
  184. toPercentage: function(v,key){
  185. v.currentTime = v.duration * (key - 48) / 10.0;
  186. },
  187. };
  188.  
  189. const keyFuncs = {
  190. 32 : shortcutFuncs.togglePlay,// Space
  191. 75 : shortcutFuncs.togglePlay, // K
  192. 35 : shortcutFuncs.toEnd,// End
  193. 48 : shortcutFuncs.toStart,// 0
  194. 36 : shortcutFuncs.toStart,// Home
  195. 37 : shortcutFuncs.skipLeft,// Left arrow
  196. 74 : shortcutFuncs.skipLeft,// J
  197. 39 : shortcutFuncs.skipRight,// Right arrow
  198. 76 : shortcutFuncs.skipRight,// L
  199. 38 : shortcutFuncs.increaseVol,// Up arrow
  200. 40 : shortcutFuncs.decreaseVol,// Down arrow
  201. 77 : shortcutFuncs.toggleMute,// M
  202. 70 : shortcutFuncs.toggleFS,// F
  203. 67 : shortcutFuncs.toggleCaptions,// C
  204. 82 : shortcutFuncs.reloadVideo,// R
  205. 188: shortcutFuncs.slowOrPrevFrame,// Comma or Less-Than
  206. 190: shortcutFuncs.fastOrNextFrame,// Period or Greater-Than
  207. 191: shortcutFuncs.normalSpeed,// Forward slash or ?
  208. 49 : shortcutFuncs.toPercentage,// 1
  209. 50 : shortcutFuncs.toPercentage,// 2
  210. 51 : shortcutFuncs.toPercentage,// 3
  211. 52 : shortcutFuncs.toPercentage,// 4
  212. 53 : shortcutFuncs.toPercentage,// 5
  213. 54 : shortcutFuncs.toPercentage,// 6
  214. 55 : shortcutFuncs.toPercentage,// 7
  215. 56 : shortcutFuncs.toPercentage,// 8
  216. 57 : shortcutFuncs.toPercentage,// 9
  217. };
  218.  
  219. function registerDirectVideo(v, force){
  220. ignoreAllIndirectVideos();
  221. if(dirVideo){
  222. ignoreDirectVideo();
  223. }
  224. if(force !== undefined ? force : v.hasAttribute("controls")){
  225. dirVideo = v;
  226. v.dataset[videoAttribute] = "direct";
  227. }else{
  228. v.dataset[videoAttribute] = "";
  229. }
  230. }
  231.  
  232. function ignoreDirectVideo(reregister){
  233. if(reregister && document.body.contains(dirVideo)){
  234. registerVideo(dirVideo);
  235. dirVideo.focus();
  236. }else{
  237. dirVideo.dataset[videoAttribute] = "";
  238. }
  239. dirVideo = undefined;
  240. }
  241.  
  242. function registerVideo(v, force){
  243. v.dataset[videoAttribute] =
  244. (force !== undefined ? force : v.hasAttribute("controls")) ?
  245. "normal" : "";
  246. }
  247.  
  248. function ignoreVideo(v){
  249. v.dataset[videoAttribute] = "";
  250. }
  251.  
  252. function registerAllNewVideos(vs){
  253. for(let i = vs.length - 1; i >= 0; --i){
  254. if(vs[i].dataset[videoAttribute] === undefined){
  255. registerVideo(vs[i]);
  256. }
  257. }
  258. }
  259.  
  260. function ignoreAllIndirectVideos(){
  261. const rv = document.getElementsByTagName("video");
  262. for(let i = rv.length - 1; i >= 0; --i){
  263. if(rv[i] !== dirVideo) ignoreVideo(rv[i]);
  264. }
  265. }
  266.  
  267. function isValidTarget(el){
  268. return (
  269. (dirVideo && (el === dirVideo
  270. || el === document.body
  271. || el === document.documentElement))
  272. || (el.dataset && el.dataset[videoAttribute])
  273. );
  274. }
  275.  
  276. function handleClick(e){
  277. if(!isValidTarget(e.target)){
  278. return true; // Do not prevent default
  279. }
  280. const v = dirVideo || e.target;
  281. if(settings.firstClick === "play" || dirVideo || document.activeElement === v){
  282. if(v.dataset[timeoutAttribute]){
  283. clearTimeout(v.dataset[timeoutAttribute]|0);
  284. delete v.dataset[timeoutAttribute];
  285. }
  286. if(settings.dblFullScreen && settings.clickDelay > 0){
  287. v.dataset[timeoutAttribute] = setTimeout(function(){
  288. shortcutFuncs.togglePlay(v);
  289. delete v.dataset[timeoutAttribute];
  290. }, settings.clickDelay * 1000);
  291. }else{
  292. shortcutFuncs.togglePlay(v);
  293. }
  294. }
  295. v.focus();
  296. e.preventDefault();
  297. e.stopPropagation();
  298. return false
  299. }
  300.  
  301. function handleDblClick(e){
  302. if(!(settings.dblFullScreen && isValidTarget(e.target))){
  303. return true; // Do not prevent default
  304. }
  305. const v = dirVideo || e.target;
  306. if(v.dataset[timeoutAttribute]){
  307. clearTimeout(v.dataset[timeoutAttribute]|0);
  308. delete v.dataset[timeoutAttribute];
  309. }
  310. shortcutFuncs.toggleFS(v);
  311. e.preventDefault();
  312. e.stopPropagation();
  313. return false
  314. }
  315.  
  316. function handleKeyDown(e){
  317. if(!isValidTarget(e.target) || e.altKey || e.metaKey){
  318. return true; // Do not activate
  319. }
  320. const func = keyFuncs[e.keyCode];
  321. if(func){
  322. if((func.length < 3 && e.shiftKey) ||
  323. (func.length < 4 && e.ctrlKey)){
  324. return true; // Do not activate
  325. }
  326. func(dirVideo || e.target, e.keyCode, e.shiftKey, e.ctrlKey);
  327. e.preventDefault();
  328. e.stopPropagation();
  329. return false;
  330. }
  331. return true; // Do not prevent default if no UI activated
  332. }
  333.  
  334. function handleKeyOther(e){
  335. if(!isValidTarget(e.target) || e.altKey || e.metaKey){
  336. return true; // Do not prevent default
  337. }
  338. const func = keyFuncs[e.keyCode];
  339. if(func){
  340. if((func.length < 3 && e.shiftKey) ||
  341. (func.length < 4 && e.ctrlKey)){
  342. return true; // Do not prevent default
  343. }
  344. e.preventDefault();
  345. e.stopPropagation();
  346. return false;
  347. }
  348. return true; // Do not prevent default if no UI activated
  349. }
  350.  
  351. function handleFullscreen(){
  352. if(document.webkitFullscreenElement
  353. && document.webkitFullscreenElement.dataset[videoAttribute]){
  354. document.webkitFullscreenElement.focus();
  355. }
  356. }
  357.  
  358. function handleMutationRecords(mrs){
  359. for(let i = mrs.length - 1; i >= 0; --i){
  360. if(mrs[i].attributeName === "controls"){
  361. const t = mrs[i].target;
  362. if(!t.hasAttribute("controls")){
  363. switch(t.dataset[videoAttribute]){
  364. case "direct":
  365. ignoreDirectVideo(false);
  366. break;
  367. case "normal":
  368. ignoreVideo(t);
  369. break;
  370. }
  371. }else if(t.tagName.toLowerCase() === "video"){
  372. if(document.body.children.length === 1
  373. && document.body.firstElementChild === t){
  374. registerDirectVideo(t);
  375. }else{
  376. registerVideo(t);
  377. t.focus();
  378. }
  379. }
  380. }else if(mrs[i].type === "childList"){
  381. if(dirVideo && (document.body.children.length !== 1
  382. || document.body.firstElementChild !== dirVideo)){
  383. ignoreDirectVideo(true);
  384. }
  385. if(mrs[i].removedNodes){
  386. for(let j = mrs[i].removedNodes.length - 1; j >= 0; --j){
  387. if(mrs[i].removedNodes[j] === dirVideo){
  388. ignoreDirectVideo();
  389. }
  390. // No need to ignore other videos currently,
  391. // as it's just setting an attribute.
  392. }
  393. }
  394. if(document.body.children.length === 1
  395. && document.body.firstElementChild !== dirVideo
  396. && document.body.firstElementChild.tagName.toLowerCase() === "video"
  397. && document.body.firstElementChild.dataset[videoAttribute] !== ""){
  398. registerDirectVideo(document.body.firstElementChild);
  399. }else if(mrs[i].addedNodes){
  400. for(let j = mrs[i].addedNodes.length - 1; j >= 0; --j){
  401. const an = mrs[i].addedNodes[j];
  402. if(an.tagName && an.tagName.toLowerCase() === "video"){
  403. if(an.dataset[videoAttribute] === undefined){
  404. registerVideo(an);
  405. }
  406. }else if(an.getElementsByTagName){
  407. registerAllNewVideos(an.getElementsByTagName("video"));
  408. }
  409. }
  410. }
  411. }
  412. }
  413. }
  414.  
  415. function enableExtension(){
  416. // useCapture: Handler fired while event is bubbling down instead of up
  417. document.addEventListener("webkitfullscreenchange", handleFullscreen, true);
  418.  
  419. document.addEventListener("click", handleClick, true);
  420. document.addEventListener("dblclick", handleDblClick, true);
  421. document.addEventListener("keydown", handleKeyDown, true);
  422. document.addEventListener("keypress", handleKeyOther, true);
  423. document.addEventListener("keyup", handleKeyOther, true);
  424.  
  425. observer = observer || new MutationObserver(handleMutationRecords);
  426. observer.observe(document.body, {
  427. childList: true,
  428. attributes: true,
  429. attributeFilter: ["controls"],
  430. subtree: true
  431. });
  432.  
  433. if(document.body.children.length === 1
  434. && document.body.firstElementChild.tagName.toLowerCase() === "video"
  435. && document.body.firstElementChild.dataset[videoAttribute] !== ""){
  436. registerDirectVideo(document.body.firstElementChild);
  437. }else{
  438. registerAllNewVideos(document.getElementsByTagName("video"));
  439. }
  440. }
  441.  
  442. enableExtension();