Youtube Player Speed Slider

Add Speed Slider to Youtube Player Settings

  1. // ==UserScript==
  2. // @name Youtube Player Speed Slider
  3. // @namespace lukaszmical.pl
  4. // @version 1.0.1
  5. // @description Add Speed Slider to Youtube Player Settings
  6. // @author Łukasz Micał
  7. // @match https://*.youtube.com/*
  8. // @match https://youtube.com/*
  9. // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com
  10. // @grant none
  11. // ==/UserScript==
  12.  
  13. // libs/share/src/ui/Dom.ts
  14. var Dom = class _Dom {
  15. static appendChildren(element, children) {
  16. if (typeof children === 'string') {
  17. element.innerHTML = children;
  18. } else if (children) {
  19. element.append(
  20. ..._Dom.array(children).map((item) => {
  21. if (item instanceof HTMLElement || item instanceof SVGElement) {
  22. return item;
  23. }
  24. if (item instanceof Component) {
  25. return item.getElement();
  26. }
  27. if (_Dom.isSvgItem(item)) {
  28. return _Dom.createSvg(item);
  29. }
  30. return _Dom.create(item);
  31. })
  32. );
  33. }
  34. }
  35.  
  36. static create(data) {
  37. const element = document.createElement(data.tag);
  38. _Dom.appendChildren(element, data.children);
  39. _Dom.applyClass(element, data.classes);
  40. _Dom.applyAttrs(element, data.attrs);
  41. _Dom.applyEvents(element, data.events);
  42. _Dom.applyStyles(element, data.styles);
  43. return element;
  44. }
  45.  
  46. static element(tag, classes, children) {
  47. return _Dom.create({ tag, classes, children });
  48. }
  49.  
  50. static createSvg(data) {
  51. const element = document.createElementNS(
  52. 'http://www.w3.org/2000/svg',
  53. data.tag
  54. );
  55. _Dom.appendChildren(element, data.children);
  56. _Dom.applyClass(element, data.classes);
  57. _Dom.applyAttrs(element, data.attrs);
  58. _Dom.applyEvents(element, data.events);
  59. _Dom.applyStyles(element, data.styles);
  60. return element;
  61. }
  62.  
  63. static array(element) {
  64. return Array.isArray(element) ? element : [element];
  65. }
  66.  
  67. static elementSvg(tag, classes, children) {
  68. return _Dom.createSvg({ tag, classes, children });
  69. }
  70.  
  71. static applyAttrs(element, attrs) {
  72. if (attrs) {
  73. Object.entries(attrs).forEach(([key, value]) => {
  74. if (value === void 0 || value === false) {
  75. element.removeAttribute(key);
  76. } else {
  77. element.setAttribute(key, `${value}`);
  78. }
  79. });
  80. }
  81. }
  82.  
  83. static applyStyles(element, styles) {
  84. if (styles) {
  85. Object.entries(styles).forEach(([key, value]) => {
  86. const name = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
  87. element.style.setProperty(name, value);
  88. });
  89. }
  90. }
  91.  
  92. static applyEvents(element, events) {
  93. if (events) {
  94. Object.entries(events).forEach(([name, callback]) => {
  95. element.addEventListener(name, callback);
  96. });
  97. }
  98. }
  99.  
  100. static applyClass(element, classes) {
  101. if (classes) {
  102. element.setAttribute('class', classes);
  103. }
  104. }
  105.  
  106. static isSvgItem(item) {
  107. try {
  108. const element = document.createElementNS(
  109. 'http://www.w3.org/2000/svg',
  110. item.tag
  111. );
  112. return element.namespaceURI === 'http://www.w3.org/2000/svg';
  113. } catch (error) {
  114. return false;
  115. }
  116. }
  117. };
  118.  
  119. // libs/share/src/ui/Component.ts
  120. var Component = class {
  121. constructor(tag, props = {}) {
  122. this.element = Dom.create({ tag, ...props });
  123. }
  124.  
  125. addClassName(...className) {
  126. this.element.classList.add(...className);
  127. }
  128.  
  129. event(event, callback) {
  130. this.element.addEventListener(event, callback);
  131. }
  132.  
  133. getElement() {
  134. return this.element;
  135. }
  136.  
  137. mount(parent) {
  138. parent.appendChild(this.element);
  139. }
  140. };
  141.  
  142. // apps/youtube-speed-slider/src/components/Icon.ts
  143. var iconPath =
  144. 'M10.01,8v8l6-4L10,8L10,8z M6.3,5L5.7,4.2C7.2,3,9,2.2,11,2l0.1,1C9.3,3.2,7.7,3.9,6.3,5z M5,6.3L4.2,5.7C3,7.2,2.2,9,2,11 l1,.1C3.2,9.3,3.9,7.7,5,6.3z M5,17.7c-1.1-1.4-1.8-3.1-2-4.8L2,13c0.2,2,1,3.8,2.2,5.4L5,17.7z M11.1,21c-1.8-0.2-3.4-0.9-4.8-2 l-0.6,.8C7.2,21,9,21.8,11,22L11.1,21z M22,12c0-5.2-3.9-9.4-9-10l-0.1,1c4.6,.5,8.1,4.3,8.1,9s-3.5,8.5-8.1,9l0.1,1 C18.2,21.5,22,17.2,22,12z';
  145. var Icon = class extends Component {
  146. constructor() {
  147. super('div', {
  148. classes: 'ytp-menuitem-icon',
  149. children: {
  150. tag: 'svg',
  151. attrs: {
  152. height: '24',
  153. width: '24',
  154. viewBox: '0 0 24 24',
  155. },
  156. children: {
  157. tag: 'path',
  158. attrs: {
  159. fill: 'white',
  160. d: iconPath,
  161. },
  162. },
  163. },
  164. });
  165. }
  166. };
  167.  
  168. // apps/youtube-speed-slider/src/components/Label.ts
  169. var Label = class extends Component {
  170. constructor(speed, label = 'Speed') {
  171. super('div', { classes: 'ytp-menuitem-label' });
  172. this.speed = '1.0';
  173. this.label = label;
  174. this.updateSpeed(speed);
  175. }
  176.  
  177. updateLabel(label = 'Speed') {
  178. this.label = label;
  179. this.updateText();
  180. }
  181.  
  182. updateSpeed(speed) {
  183. this.speed = speed.toFixed(1);
  184. this.updateText();
  185. }
  186.  
  187. updateText() {
  188. this.element.innerText = `${this.label}: ${this.speed}`;
  189. }
  190. };
  191.  
  192. // apps/youtube-speed-slider/src/components/Slider.ts
  193. var Slider = class _Slider extends Component {
  194. static {
  195. this.MIN_VALUE = 0.5;
  196. }
  197. static {
  198. this.MAX_VALUE = 4;
  199. }
  200.  
  201. constructor(speed) {
  202. super('input', {
  203. attrs: {
  204. type: 'range',
  205. min: _Slider.MIN_VALUE,
  206. max: _Slider.MAX_VALUE,
  207. step: 0.05,
  208. value: speed.toString(),
  209. },
  210. styles: {
  211. accentColor: '#f00',
  212. width: 'calc(100% - 30px)',
  213. margin: '0 5px',
  214. padding: '0',
  215. },
  216. });
  217. }
  218.  
  219. setSpeed(speed) {
  220. this.element.value = speed.toString();
  221. }
  222.  
  223. getSpeed() {
  224. return parseFloat(this.element.value);
  225. }
  226. };
  227.  
  228. // apps/youtube-speed-slider/src/components/Checkbox.ts
  229. var Checkbox = class extends Component {
  230. constructor(checked) {
  231. super('input', {
  232. styles: {
  233. accentColor: '#f00',
  234. width: '20px',
  235. height: '20px',
  236. margin: '0',
  237. padding: '0',
  238. },
  239. attrs: {
  240. type: 'checkbox',
  241. title: 'Remember speed',
  242. checked,
  243. },
  244. });
  245. }
  246.  
  247. getValue() {
  248. return this.element.checked;
  249. }
  250. };
  251.  
  252. // apps/youtube-speed-slider/src/components/SpeedMenuItem.ts
  253. var SpeedMenuItem = class _SpeedMenuItem extends Component {
  254. constructor() {
  255. super('div', {
  256. classes: 'ytp-menuitem',
  257. attrs: {
  258. id: _SpeedMenuItem.ID,
  259. },
  260. });
  261. this.wrapper = Dom.element('div', 'ytp-menuitem-content');
  262. }
  263.  
  264. static {
  265. this.ID = 'yts-speed-menu-item';
  266. }
  267.  
  268. addElement(icon, label, slider, checkbox) {
  269. this.element.append(icon, label, this.wrapper);
  270. this.wrapper.append(checkbox, slider);
  271. }
  272. };
  273.  
  274. // libs/share/src/utils/delay.ts
  275. async function delay(ms = 1e3) {
  276. return await new Promise((resolve) => {
  277. setTimeout(resolve, ms);
  278. });
  279. }
  280.  
  281. // apps/youtube-speed-slider/src/components/Menu.ts
  282. var Menu = class {
  283. constructor() {
  284. this.getMenu();
  285. }
  286.  
  287. getMenu() {
  288. return document.querySelector('.ytp-settings-menu .ytp-panel-menu');
  289. }
  290.  
  291. getDefaultMenuItem() {
  292. const defaultSpeedItem = [
  293. ...document.querySelectorAll('.ytp-menuitem'),
  294. ].filter((e) => {
  295. const path = e
  296. .querySelector('.ytp-menuitem-icon path')
  297. ?.getAttribute('d');
  298. return path?.startsWith('M10,8v8l6-4L10,');
  299. });
  300. if (defaultSpeedItem.length) {
  301. return defaultSpeedItem[0];
  302. }
  303. return void 0;
  304. }
  305.  
  306. getLabel() {
  307. const label = this.getDefaultMenuItem()?.querySelector(
  308. '.ytp-menuitem-label'
  309. );
  310. return label?.innerText;
  311. }
  312.  
  313. async reopenMenu() {
  314. const menuButton = document.querySelector('.ytp-settings-button');
  315. const menu = this.getMenu();
  316. if (menu && this.menuHasCustomItem(menu)) {
  317. return;
  318. }
  319. if (menuButton) {
  320. menu?.style?.setProperty('opacity', '0');
  321. menuButton.click();
  322. await delay(50);
  323. menuButton.click();
  324. menu?.style?.setProperty('opacity', '1');
  325. await delay(50);
  326. }
  327. }
  328.  
  329. menuHasCustomItem(menu) {
  330. return Boolean(menu.querySelector(`#${SpeedMenuItem.ID}`));
  331. }
  332.  
  333. addCustomSpeedItem(item) {
  334. const menu = this.getMenu();
  335. const defaultItem = this.getDefaultMenuItem();
  336. if (menu === null) {
  337. return false;
  338. }
  339. if (this.menuHasCustomItem(menu)) {
  340. defaultItem?.parentNode?.removeChild(defaultItem);
  341. return true;
  342. }
  343. if (defaultItem) {
  344. defaultItem.replaceWith(item.getElement());
  345. } else {
  346. menu.appendChild(item.getElement());
  347. }
  348. return true;
  349. }
  350. };
  351.  
  352. // apps/youtube-speed-slider/src/components/Player.ts
  353. var Player = class _Player {
  354. constructor(speed) {
  355. this.speed = speed;
  356. this.player = null;
  357. this.setSpeed(this.speed);
  358. }
  359.  
  360. static {
  361. this.READY_FLAG = 'yts-listener';
  362. }
  363.  
  364. getPlayer() {
  365. if (!this.player) {
  366. this.player = document.querySelector('.html5-main-video');
  367. if (this.player) {
  368. this.initEvent(this.player);
  369. }
  370. }
  371. return this.player;
  372. }
  373.  
  374. initEvent(player) {
  375. if (!player.getAttribute(_Player.READY_FLAG)) {
  376. player.addEventListener('ratechange', this.checkPlayerSpeed.bind(this));
  377. player.setAttribute(_Player.READY_FLAG, 'ready');
  378. }
  379. }
  380.  
  381. checkPlayerSpeed() {
  382. const player = this.getPlayer();
  383. if (player && Math.abs(player.playbackRate - this.speed) > 0.01) {
  384. player.playbackRate = this.speed;
  385. setTimeout(this.checkPlayerSpeed.bind(this), 200);
  386. }
  387. }
  388.  
  389. setSpeed(speed) {
  390. this.speed = speed;
  391. const player = this.getPlayer();
  392. if (player !== null) {
  393. player.playbackRate = speed;
  394. }
  395. }
  396. };
  397.  
  398. // libs/share/src/ui/Observer.ts
  399. var Observer = class {
  400. stop() {
  401. if (this.observer) {
  402. this.observer.disconnect();
  403. }
  404. }
  405.  
  406. start(element, callback) {
  407. this.stop();
  408. this.observer = new MutationObserver(callback);
  409. this.observer.observe(element, {
  410. childList: true,
  411. subtree: true,
  412. attributes: true,
  413. characterData: true,
  414. attributeOldValue: true,
  415. characterDataOldValue: true,
  416. });
  417. }
  418. };
  419.  
  420. // libs/share/src/store/Store.ts
  421. var Store = class {
  422. constructor(key) {
  423. this.key = key;
  424. }
  425.  
  426. encode(val) {
  427. return JSON.stringify(val);
  428. }
  429.  
  430. decode(val) {
  431. return JSON.parse(val);
  432. }
  433.  
  434. set(value) {
  435. try {
  436. localStorage.setItem(this.key, this.encode(value));
  437. } catch (e) {
  438.  
  439. }
  440. }
  441.  
  442. get(defaultValue = void 0) {
  443. try {
  444. const data = localStorage.getItem(this.key);
  445. if (data) {
  446. return this.decode(data);
  447. }
  448. return defaultValue;
  449. } catch (e) {
  450. return defaultValue;
  451. }
  452. }
  453.  
  454. remove() {
  455. localStorage.removeItem(this.key);
  456. }
  457. };
  458.  
  459. // apps/youtube-speed-slider/src/controllers/AppController.ts
  460. var AppController = class {
  461. constructor() {
  462. this.rememberSpeed = new Store('yts-remember-speed');
  463. this.speed = new Store('yts-speed');
  464. const initialSpeed = this.getSpeed();
  465. this.menu = new Menu();
  466. this.player = new Player(initialSpeed);
  467. this.speedMenuItem = new SpeedMenuItem();
  468. this.icon = new Icon();
  469. this.label = new Label(initialSpeed);
  470. this.slider = new Slider(initialSpeed);
  471. this.checkbox = new Checkbox(this.rememberSpeed.get(false));
  472. this.observer = new Observer();
  473. this.speedMenuItem.addElement(
  474. this.icon.getElement(),
  475. this.label.getElement(),
  476. this.slider.getElement(),
  477. this.checkbox.getElement()
  478. );
  479. this.initEvents();
  480. }
  481.  
  482. initEvents() {
  483. this.slider.event('change', this.sliderChangeEvent.bind(this));
  484. this.slider.event('input', this.sliderChangeEvent.bind(this));
  485. this.slider.event('wheel', this.sliderWheelEvent.bind(this));
  486. this.checkbox.event('change', this.checkboxEvent.bind(this));
  487. document.addEventListener('spfdone', this.initApp.bind(this));
  488. }
  489.  
  490. sliderChangeEvent(_) {
  491. this.updateSpeed(this.slider.getSpeed());
  492. }
  493.  
  494. checkboxEvent(_) {
  495. this.rememberSpeed.set(this.checkbox.getValue());
  496. }
  497.  
  498. sliderWheelEvent(event) {
  499. const current = this.slider.getSpeed();
  500. const diff = event.deltaY > 0 ? -0.05 : 0.05;
  501. const value = Math.max(
  502. Slider.MIN_VALUE,
  503. Math.min(current + diff, Slider.MAX_VALUE)
  504. );
  505. if (current != value) {
  506. this.slider.setSpeed(value);
  507. this.updateSpeed(value);
  508. }
  509. event.preventDefault();
  510. }
  511.  
  512. updateSpeed(speed) {
  513. this.speed.set(speed);
  514. this.player.setSpeed(speed);
  515. this.label.updateSpeed(speed);
  516. }
  517.  
  518. getSpeed() {
  519. return this.rememberSpeed.get() ? this.speed.get(1) : 1;
  520. }
  521.  
  522. mutationCallback() {
  523. this.initApp();
  524. }
  525.  
  526. async initApp() {
  527. this.player.setSpeed(this.getSpeed());
  528. await this.menu.reopenMenu();
  529. const label = this.menu.getLabel();
  530. if (label) {
  531. this.label.updateLabel(label);
  532. }
  533. const player = this.player.getPlayer();
  534. if (player) {
  535. this.observer.start(player, this.mutationCallback.bind(this));
  536. }
  537. return this.menu.addCustomSpeedItem(this.speedMenuItem);
  538. }
  539. };
  540.  
  541. // apps/youtube-speed-slider/src/main.ts
  542. var app = new AppController();
  543.  
  544. async function init() {
  545. const ok = await app.initApp();
  546. if (!ok) {
  547. window.setTimeout(init, 2e3);
  548. }
  549. }
  550.  
  551. document.addEventListener('spfdone', init);
  552. init();