Video Speed Buttons

Add speed buttons to any HTML5 <video> element. Comes with a loader for YouTube and Vimeo

  1. // ==UserScript==
  2. // @name Video Speed Buttons
  3. // @description Add speed buttons to any HTML5 <video> element. Comes with a loader for YouTube and Vimeo
  4. // @namespace bradenscode
  5. // @version 1.0.10
  6. // @copyright 2017, Braden Best
  7. // @run-at document-end
  8. // @author Braden Best
  9. // @grant none
  10. //
  11. // @match *://*.youtube.com/*
  12. // @match *://youtube.com/*
  13. // @match *://*.vimeo.com/*
  14. // @match *://vimeo.com/*
  15. // ==/UserScript==
  16.  
  17. // To add a new site: add a @match above, and modify loader_data.container_candidates near the bottom
  18.  
  19. const CONTROLLER_VSB = 0; // normal controller (video speed buttons). Uses lots of loops to enforce speed
  20. const CONTROLLER_VSC = 1; // alternative controller (video speed controller). Keyboard-only, minimalistic, no loops
  21.  
  22. const controller_type = CONTROLLER_VSB;
  23. // change this to use the experimental CONTROLLER_VSC, which is keyboard-only
  24. // and extremely minimalistic, but is also fast, lightweight on memory usage,
  25. // and doesn't use any loops. Try it out, see if it works better for you.
  26. // The controls are the same as VSB. + to increase the speed, - to decrease,
  27. // * to reset to 1.
  28.  
  29. function video_speed_buttons(anchor, video_el){
  30. if(!anchor || !video_el)
  31. return null;
  32.  
  33. const COLOR_SELECTED = "#FF5500",
  34. COLOR_NORMAL = "grey",
  35. BUTTON_SIZE = "120%",
  36. DEFAULT_SPEED = 1.0,
  37. LABEL_TEXT = "Video Speed: ",
  38. ALLOW_EXTERNAL_ACCESS = false;
  39.  
  40. const BUTTON_TEMPLATES = [
  41. ["25%", 0.25],
  42. ["50%", 0.5],
  43. ["Normal", 1],
  44. ["1.5x", 1.5],
  45. ["2x", 2],
  46. ["3x", 3],
  47. ["4x", 4],
  48. ["8x", 8],
  49. ["16x", 16]
  50. ];
  51.  
  52. const buttons = {
  53. head: null,
  54. selected: null,
  55. last: null
  56. };
  57.  
  58. const keyboard_controls = [
  59. ["-", "Speed Down", function(ev){
  60. if(is_comment_box(ev.target))
  61. return false;
  62.  
  63. (buttons.selected || buttons.head)
  64. .getprev()
  65. .el
  66. .dispatchEvent(new MouseEvent("click"));
  67. }],
  68. ["+", "Speed Up", function(ev){
  69. if(is_comment_box(ev.target))
  70. return false;
  71.  
  72. (buttons.selected || buttons.head)
  73. .getnext()
  74. .el
  75. .dispatchEvent(new MouseEvent("click"));
  76. }],
  77. ["*", "Reset Speed", function(ev){
  78. let selbtn = buttons.head;
  79. let result = null;
  80.  
  81. if(is_comment_box(ev.target))
  82. return false;
  83.  
  84. while(selbtn !== null && result === null)
  85. if(selbtn.speed === DEFAULT_SPEED)
  86. result = selbtn;
  87. else
  88. selbtn = selbtn.next;
  89.  
  90. if(result === null)
  91. result = buttons.head;
  92.  
  93. result.el.dispatchEvent(new MouseEvent("click"));
  94. }],
  95. ["?", "Show Help", function(ev){
  96. let infobox;
  97.  
  98. if(is_comment_box(ev.target))
  99. return false;
  100.  
  101. (infobox = Infobox(container))
  102. .log("Keyboard Controls (click to close)<br>");
  103.  
  104. keyboard_controls.forEach(function([key, description]){
  105. infobox.log(` [${key}] ${description}<br>`);
  106. });
  107. }]
  108. ];
  109.  
  110. const container = (function(){
  111. let div = document.createElement("div");
  112. let prev_node = null;
  113.  
  114. div.className = "vsb-container";
  115. div.style.borderBottom = "1px solid #ccc";
  116. div.style.marginBottom = "10px";
  117. div.style.paddingBottom = "10px";
  118. div.appendChild(SpeedButtonLabel(LABEL_TEXT));
  119.  
  120. BUTTON_TEMPLATES.forEach(function(button){
  121. let speedButton = SpeedButton(...button, div);
  122.  
  123. if(buttons.head === null)
  124. buttons.head = speedButton;
  125.  
  126. if(prev_node !== null){
  127. speedButton.prev = prev_node;
  128. prev_node.next = speedButton;
  129. }
  130.  
  131. prev_node = speedButton;
  132.  
  133. if(speedButton.speed == DEFAULT_SPEED)
  134. speedButton.select();
  135. });
  136.  
  137. return div;
  138. })();
  139.  
  140. function is_comment_box(el){
  141. const candidate = [
  142. ".comment-simplebox-text",
  143. "textarea"
  144. ].map(c => document.querySelector(c))
  145. .find(el => el !== null);
  146.  
  147. if(candidate === null){
  148. logvsb("video_speed_buttons::is_comment_box", "no candidate for comment box. Assuming false.");
  149. return 0;
  150. }
  151.  
  152. return el === candidate;
  153. }
  154.  
  155. function Infobox(parent){
  156. let el = document.createElement("pre");
  157.  
  158. el.style.font = "1em monospace";
  159. el.style.borderTop = "1px solid #ccc";
  160. el.style.marginTop = "10px";
  161. el.style.paddingTop = "10px";
  162.  
  163. el.addEventListener("click", function(){
  164. parent.removeChild(el);
  165. });
  166.  
  167. parent.appendChild(el);
  168.  
  169. function log(msg){
  170. el.innerHTML += msg;
  171. }
  172.  
  173. return {
  174. el,
  175. log
  176. };
  177. }
  178.  
  179. let playbackRate_data = {
  180. rate: 1,
  181. video: null,
  182. };
  183.  
  184. function setPlaybackRate(el, rate){
  185. if(el) {
  186. el.playbackRate = rate;
  187. playbackRate_data.rate = rate;
  188. playbackRate_data.video = el;
  189. }
  190. else
  191. logvsb("video_speed_buttons::setPlaybackRate", "video element is null or undefined", 1);
  192. }
  193.  
  194. function SpeedButtonLabel(text){
  195. let el = document.createElement("span");
  196.  
  197. el.innerHTML = text;
  198. el.style.marginRight = "10px";
  199. el.style.fontWeight = "bold";
  200. el.style.fontSize = BUTTON_SIZE;
  201. el.style.color = COLOR_NORMAL;
  202.  
  203. return el;
  204. }
  205.  
  206. function SpeedButton(text, speed, parent){
  207. let el = SpeedButtonLabel(text);
  208. let self;
  209.  
  210. el.style.cursor = "pointer";
  211.  
  212. el.addEventListener("click", function(){
  213. setPlaybackRate(video_el, speed);
  214. self.select();
  215. });
  216.  
  217. parent.appendChild(el);
  218.  
  219. function select(){
  220. if(buttons.last !== null)
  221. buttons.last.el.style.color = COLOR_NORMAL;
  222.  
  223. buttons.last = self;
  224. buttons.selected = self;
  225. el.style.color = COLOR_SELECTED;
  226. }
  227.  
  228. function getprev(){
  229. if(self.prev === null)
  230. return self;
  231.  
  232. return buttons.selected = self.prev;
  233. }
  234.  
  235. function getnext(){
  236. if(self.next === null)
  237. return self;
  238.  
  239. return buttons.selected = self.next;
  240. }
  241.  
  242. return self = {
  243. el,
  244. text,
  245. speed,
  246. prev: null,
  247. next: null,
  248. select,
  249. getprev,
  250. getnext
  251. };
  252. }
  253.  
  254. function kill(){
  255. anchor.removeChild(container);
  256. document.body.removeEventListener("keydown", ev_keyboard);
  257. }
  258.  
  259. function set_video_el(new_video_el){
  260. video_el = new_video_el;
  261. }
  262.  
  263. function ev_keyboard(ev){
  264. let match = keyboard_controls.find(([key, unused, callback]) => key === ev.key);
  265. let callback = (match || {2: ()=>null})[2];
  266.  
  267. callback(ev);
  268. }
  269.  
  270. setPlaybackRate(video_el, DEFAULT_SPEED);
  271. anchor.insertBefore(container, anchor.firstChild);
  272. document.body.addEventListener("keydown", ev_keyboard);
  273.  
  274. return {
  275. controls: keyboard_controls,
  276. buttons,
  277. kill,
  278. SpeedButton,
  279. Infobox,
  280. setPlaybackRate,
  281. is_comment_box,
  282. set_video_el,
  283. playbackRate_data,
  284. ALLOW_EXTERNAL_ACCESS,
  285. };
  286. }
  287.  
  288. video_speed_buttons.from_query = function(anchor_q, video_q){
  289. return video_speed_buttons(
  290. document.querySelector(anchor_q),
  291. document.querySelector(video_q));
  292. }
  293.  
  294. // Multi-purpose Loader (defaults to floating on top right)
  295. const loader_data = {
  296. container_candidates: [
  297. // YouTube
  298. "div#above-the-fold",
  299. "div#title.style-scope.ytd-watch-metadata",
  300. "div#container.ytd-video-primary-info-renderer",
  301. "div#watch-header",
  302. "div#watch7-headline",
  303. "div#watch-headline-title",
  304. // Vimeo
  305. ".clip_info-wrapper",
  306. ],
  307.  
  308. css_div: [
  309. "position: fixed",
  310. "top: 0",
  311. "right: 0",
  312. "zIndex: 100000",
  313. "background: rgba(0, 0, 0, 0.8)",
  314. "color: #eeeeee",
  315. "padding: 10px"
  316. ].map(rule => rule.split(/: */)),
  317.  
  318. css_vsb_container: [
  319. "borderBottom: none",
  320. "marginBottom: 0",
  321. "paddingBottom: 0",
  322. ].map(rule => rule.split(/: */))
  323. };
  324.  
  325. function logvsb(where, msg, lvl = 0){
  326. let logf = (["info", "error"])[lvl];
  327.  
  328. console[logf](`[vsb::${where}] ${msg}`);
  329. }
  330.  
  331. function loader_loop(){
  332. let vsbc = () => document.querySelector(".vsb-container");
  333. let candidate;
  334. let default_candidate;
  335. let vsb_handle;
  336.  
  337. if(vsbc() !== null)
  338. return;
  339.  
  340. candidate = loader_data
  341. .container_candidates
  342. .map(candidate => document.querySelector(candidate))
  343. .find(candidate => candidate !== null);
  344.  
  345. default_candidate = (function(){
  346. let el = document.createElement("div");
  347.  
  348. loader_data.css_div.forEach(function([name, value]){
  349. el.style[name] = value; });
  350.  
  351. return el;
  352. }());
  353.  
  354. vsb_handle = video_speed_buttons(candidate || default_candidate, document.querySelector("video"));
  355.  
  356. if(candidate === null){
  357. logvsb("loader_loop", "no candidates for title section. Defaulting to top of page.");
  358. document.body.appendChild(default_candidate);
  359.  
  360. loader_data.css_vsb_container.forEach(function([name, value]){
  361. vsbc().style[name] = value;
  362. });
  363. }
  364.  
  365. // ugly hack to address vimeo automatically resetting the speed
  366. vsb_handle.enforcer_loop_iid = setInterval(function(){
  367. let prdata = vsb_handle.playbackRate_data;
  368.  
  369. if (prdata.video !== null)
  370. prdata.video.playbackRate = prdata.rate;
  371. }, 500);
  372.  
  373. if(vsb_handle.ALLOW_EXTERNAL_ACCESS)
  374. window.vsb = vsb_handle;
  375. }
  376.  
  377. const vsc = {
  378. name: "Video Speed Controller",
  379. getvideo: _ => document.querySelector("video"), // Yep, it's really that simple.
  380. rates: [0.25, 0.5, 1, 1.5, 2, 3, 4, 8, 16],
  381. selrate: 2,
  382. ev_keydown: function(ev) {
  383. let change = 0;
  384.  
  385. if (vsc.getvideo() === null)
  386. return true;
  387.  
  388. if (ev.key === "+")
  389. change = +1;
  390.  
  391. if (ev.key === "-")
  392. change = -1;
  393.  
  394. if (ev.key === "*")
  395. change = -(vsc.selrate - 2);
  396.  
  397. vsc.selrate = (vsc.rates.length + vsc.selrate + change) % vsc.rates.length;
  398. vsc.getvideo().playbackRate = vsc.rates[vsc.selrate];
  399. console.log(`[${vsc.name}] Speed set to ${vsc.rates[vsc.selrate]}`);
  400. }
  401. };
  402.  
  403. if (controller_type === CONTROLLER_VSB) {
  404. setInterval(function(){
  405. if(document.readyState === "complete")
  406. setTimeout(loader_loop, 1000);
  407. }, 1000); // Blame YouTube for this
  408. }
  409. else if (controller_type === CONTROLLER_VSC) {
  410. document.body.addEventListener("keydown", vsc.ev_keydown);
  411. console.clear();
  412. console.log(`[${vsc.name}] loaded`);
  413. }