Add Speed Slider to Youtube Player Settings
// ==UserScript== // @name Youtube Player Speed Slider // @namespace lukaszmical.pl // @version 1.0.1 // @description Add Speed Slider to Youtube Player Settings // @author Łukasz Micał // @match https://*.youtube.com/* // @match https://youtube.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com // @grant none // ==/UserScript== // libs/share/src/ui/Dom.ts var Dom = class _Dom { static appendChildren(element, children) { if (typeof children === 'string') { element.innerHTML = children; } else if (children) { element.append( ..._Dom.array(children).map((item) => { if (item instanceof HTMLElement || item instanceof SVGElement) { return item; } if (item instanceof Component) { return item.getElement(); } if (_Dom.isSvgItem(item)) { return _Dom.createSvg(item); } return _Dom.create(item); }) ); } } static create(data) { const element = document.createElement(data.tag); _Dom.appendChildren(element, data.children); _Dom.applyClass(element, data.classes); _Dom.applyAttrs(element, data.attrs); _Dom.applyEvents(element, data.events); _Dom.applyStyles(element, data.styles); return element; } static element(tag, classes, children) { return _Dom.create({ tag, classes, children }); } static createSvg(data) { const element = document.createElementNS( 'http://www.w3.org/2000/svg', data.tag ); _Dom.appendChildren(element, data.children); _Dom.applyClass(element, data.classes); _Dom.applyAttrs(element, data.attrs); _Dom.applyEvents(element, data.events); _Dom.applyStyles(element, data.styles); return element; } static array(element) { return Array.isArray(element) ? element : [element]; } static elementSvg(tag, classes, children) { return _Dom.createSvg({ tag, classes, children }); } static applyAttrs(element, attrs) { if (attrs) { Object.entries(attrs).forEach(([key, value]) => { if (value === void 0 || value === false) { element.removeAttribute(key); } else { element.setAttribute(key, `${value}`); } }); } } static applyStyles(element, styles) { if (styles) { Object.entries(styles).forEach(([key, value]) => { const name = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); element.style.setProperty(name, value); }); } } static applyEvents(element, events) { if (events) { Object.entries(events).forEach(([name, callback]) => { element.addEventListener(name, callback); }); } } static applyClass(element, classes) { if (classes) { element.setAttribute('class', classes); } } static isSvgItem(item) { try { const element = document.createElementNS( 'http://www.w3.org/2000/svg', item.tag ); return element.namespaceURI === 'http://www.w3.org/2000/svg'; } catch (error) { return false; } } }; // libs/share/src/ui/Component.ts var Component = class { constructor(tag, props = {}) { this.element = Dom.create({ tag, ...props }); } addClassName(...className) { this.element.classList.add(...className); } event(event, callback) { this.element.addEventListener(event, callback); } getElement() { return this.element; } mount(parent) { parent.appendChild(this.element); } }; // apps/youtube-speed-slider/src/components/Icon.ts var iconPath = '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'; var Icon = class extends Component { constructor() { super('div', { classes: 'ytp-menuitem-icon', children: { tag: 'svg', attrs: { height: '24', width: '24', viewBox: '0 0 24 24', }, children: { tag: 'path', attrs: { fill: 'white', d: iconPath, }, }, }, }); } }; // apps/youtube-speed-slider/src/components/Label.ts var Label = class extends Component { constructor(speed, label = 'Speed') { super('div', { classes: 'ytp-menuitem-label' }); this.speed = '1.0'; this.label = label; this.updateSpeed(speed); } updateLabel(label = 'Speed') { this.label = label; this.updateText(); } updateSpeed(speed) { this.speed = speed.toFixed(1); this.updateText(); } updateText() { this.element.innerText = `${this.label}: ${this.speed}`; } }; // apps/youtube-speed-slider/src/components/Slider.ts var Slider = class _Slider extends Component { static { this.MIN_VALUE = 0.5; } static { this.MAX_VALUE = 4; } constructor(speed) { super('input', { attrs: { type: 'range', min: _Slider.MIN_VALUE, max: _Slider.MAX_VALUE, step: 0.05, value: speed.toString(), }, styles: { accentColor: '#f00', width: 'calc(100% - 30px)', margin: '0 5px', padding: '0', }, }); } setSpeed(speed) { this.element.value = speed.toString(); } getSpeed() { return parseFloat(this.element.value); } }; // apps/youtube-speed-slider/src/components/Checkbox.ts var Checkbox = class extends Component { constructor(checked) { super('input', { styles: { accentColor: '#f00', width: '20px', height: '20px', margin: '0', padding: '0', }, attrs: { type: 'checkbox', title: 'Remember speed', checked, }, }); } getValue() { return this.element.checked; } }; // apps/youtube-speed-slider/src/components/SpeedMenuItem.ts var SpeedMenuItem = class _SpeedMenuItem extends Component { constructor() { super('div', { classes: 'ytp-menuitem', attrs: { id: _SpeedMenuItem.ID, }, }); this.wrapper = Dom.element('div', 'ytp-menuitem-content'); } static { this.ID = 'yts-speed-menu-item'; } addElement(icon, label, slider, checkbox) { this.element.append(icon, label, this.wrapper); this.wrapper.append(checkbox, slider); } }; // libs/share/src/utils/delay.ts async function delay(ms = 1e3) { return await new Promise((resolve) => { setTimeout(resolve, ms); }); } // apps/youtube-speed-slider/src/components/Menu.ts var Menu = class { constructor() { this.getMenu(); } getMenu() { return document.querySelector('.ytp-settings-menu .ytp-panel-menu'); } getDefaultMenuItem() { const defaultSpeedItem = [ ...document.querySelectorAll('.ytp-menuitem'), ].filter((e) => { const path = e .querySelector('.ytp-menuitem-icon path') ?.getAttribute('d'); return path?.startsWith('M10,8v8l6-4L10,'); }); if (defaultSpeedItem.length) { return defaultSpeedItem[0]; } return void 0; } getLabel() { const label = this.getDefaultMenuItem()?.querySelector( '.ytp-menuitem-label' ); return label?.innerText; } async reopenMenu() { const menuButton = document.querySelector('.ytp-settings-button'); const menu = this.getMenu(); if (menu && this.menuHasCustomItem(menu)) { return; } if (menuButton) { menu?.style?.setProperty('opacity', '0'); menuButton.click(); await delay(50); menuButton.click(); menu?.style?.setProperty('opacity', '1'); await delay(50); } } menuHasCustomItem(menu) { return Boolean(menu.querySelector(`#${SpeedMenuItem.ID}`)); } addCustomSpeedItem(item) { const menu = this.getMenu(); const defaultItem = this.getDefaultMenuItem(); if (menu === null) { return false; } if (this.menuHasCustomItem(menu)) { defaultItem?.parentNode?.removeChild(defaultItem); return true; } if (defaultItem) { defaultItem.replaceWith(item.getElement()); } else { menu.appendChild(item.getElement()); } return true; } }; // apps/youtube-speed-slider/src/components/Player.ts var Player = class _Player { constructor(speed) { this.speed = speed; this.player = null; this.setSpeed(this.speed); } static { this.READY_FLAG = 'yts-listener'; } getPlayer() { if (!this.player) { this.player = document.querySelector('.html5-main-video'); if (this.player) { this.initEvent(this.player); } } return this.player; } initEvent(player) { if (!player.getAttribute(_Player.READY_FLAG)) { player.addEventListener('ratechange', this.checkPlayerSpeed.bind(this)); player.setAttribute(_Player.READY_FLAG, 'ready'); } } checkPlayerSpeed() { const player = this.getPlayer(); if (player && Math.abs(player.playbackRate - this.speed) > 0.01) { player.playbackRate = this.speed; setTimeout(this.checkPlayerSpeed.bind(this), 200); } } setSpeed(speed) { this.speed = speed; const player = this.getPlayer(); if (player !== null) { player.playbackRate = speed; } } }; // libs/share/src/ui/Observer.ts var Observer = class { stop() { if (this.observer) { this.observer.disconnect(); } } start(element, callback) { this.stop(); this.observer = new MutationObserver(callback); this.observer.observe(element, { childList: true, subtree: true, attributes: true, characterData: true, attributeOldValue: true, characterDataOldValue: true, }); } }; // libs/share/src/store/Store.ts var Store = class { constructor(key) { this.key = key; } encode(val) { return JSON.stringify(val); } decode(val) { return JSON.parse(val); } set(value) { try { localStorage.setItem(this.key, this.encode(value)); } catch (e) { } } get(defaultValue = void 0) { try { const data = localStorage.getItem(this.key); if (data) { return this.decode(data); } return defaultValue; } catch (e) { return defaultValue; } } remove() { localStorage.removeItem(this.key); } }; // apps/youtube-speed-slider/src/controllers/AppController.ts var AppController = class { constructor() { this.rememberSpeed = new Store('yts-remember-speed'); this.speed = new Store('yts-speed'); const initialSpeed = this.getSpeed(); this.menu = new Menu(); this.player = new Player(initialSpeed); this.speedMenuItem = new SpeedMenuItem(); this.icon = new Icon(); this.label = new Label(initialSpeed); this.slider = new Slider(initialSpeed); this.checkbox = new Checkbox(this.rememberSpeed.get(false)); this.observer = new Observer(); this.speedMenuItem.addElement( this.icon.getElement(), this.label.getElement(), this.slider.getElement(), this.checkbox.getElement() ); this.initEvents(); } initEvents() { this.slider.event('change', this.sliderChangeEvent.bind(this)); this.slider.event('input', this.sliderChangeEvent.bind(this)); this.slider.event('wheel', this.sliderWheelEvent.bind(this)); this.checkbox.event('change', this.checkboxEvent.bind(this)); document.addEventListener('spfdone', this.initApp.bind(this)); } sliderChangeEvent(_) { this.updateSpeed(this.slider.getSpeed()); } checkboxEvent(_) { this.rememberSpeed.set(this.checkbox.getValue()); } sliderWheelEvent(event) { const current = this.slider.getSpeed(); const diff = event.deltaY > 0 ? -0.05 : 0.05; const value = Math.max( Slider.MIN_VALUE, Math.min(current + diff, Slider.MAX_VALUE) ); if (current != value) { this.slider.setSpeed(value); this.updateSpeed(value); } event.preventDefault(); } updateSpeed(speed) { this.speed.set(speed); this.player.setSpeed(speed); this.label.updateSpeed(speed); } getSpeed() { return this.rememberSpeed.get() ? this.speed.get(1) : 1; } mutationCallback() { this.initApp(); } async initApp() { this.player.setSpeed(this.getSpeed()); await this.menu.reopenMenu(); const label = this.menu.getLabel(); if (label) { this.label.updateLabel(label); } const player = this.player.getPlayer(); if (player) { this.observer.start(player, this.mutationCallback.bind(this)); } return this.menu.addCustomSpeedItem(this.speedMenuItem); } }; // apps/youtube-speed-slider/src/main.ts var app = new AppController(); async function init() { const ok = await app.initApp(); if (!ok) { window.setTimeout(init, 2e3); } } document.addEventListener('spfdone', init); init();