Skip to content

Instantly share code, notes, and snippets.

@schuhwerk
Last active March 30, 2025 11:16
Show Gist options
  • Save schuhwerk/67fb4da50652681b857002e1ba2bf071 to your computer and use it in GitHub Desktop.
Save schuhwerk/67fb4da50652681b857002e1ba2bf071 to your computer and use it in GitHub Desktop.
UserScript. Control Video Playback Speed with Keyboard.
// ==UserScript==
// @name Video Speed Control with Keyboard
// @description Decrease and increase HTML video playback speed with "," and ".". Remembers and applies speeds across page-loads.
// @version 2025-03-30
// @author Vitus Schuhwerk
// @license MIT
// @homepageURL https://gist.githubusercontent.com/schuhwerk/67fb4da50652681b857002e1ba2bf071
// @updateURL https://gist.githubusercontent.com/schuhwerk/67fb4da50652681b857002e1ba2bf071/raw
// @downloadURL https://gist.githubusercontent.com/schuhwerk/67fb4da50652681b857002e1ba2bf071/raw
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addStyle
// @grant GM_addElement
// @grant GM_registerMenuCommand
// ==/UserScript==
/* show message to user, then hide. */
const easyToast = {
timeoutID: null,
toastClass: 'ntfy-toast',
addStyle() {
GM_addStyle(`.${this.toastClass} {
position: fixed; color: white; background: linear-gradient(to right, rgb(7 128 113), rgb(111 155 35));
z-index: 99999; padding: 10px 20px; margin: 3vw; border-radius: 5px; font-size: 18px; right: 0; top: 0;
}`)
},
success(message) {
this.addStyle()
this.addStyle = () => {} // overwrite, only add on first use.
this.removeAndClear();
GM_addElement(document.body, 'div', { class: this.toastClass, textContent: message });
this.timeoutID = setTimeout(() => this.removeAndClear(), 2500);
},
clear(){
if (typeof this.timeoutID === "number") {
clearTimeout(this.timeoutID);
this.timeoutID = null; // reset the timeoutID after clearing it
}
},
removeAndClear() {
document.querySelectorAll('.'+this.toastClass).forEach(e => e.remove());
this.clear()
}
};
const videoPlaybackControl = (() => {
const log = ( ...l ) => console.info( "%cVPC", "color:grey", ...l )
const transformSpeedKeys = {
"," : ( speed ) => speed - 0.25,
"." : ( speed ) => speed + 0.25,
"-" : ( speed ) => 1,
}
const isForbiddenTarget = ( target ) => [ "input", "textarea" ].includes( target?.localName ) || target?.isContentEditable
let videoElm; // Stores currently playing video element
const speedStorageKey = "playbackSpeed"
const speed = {
setApply: ( s = GM_getValue(speedStorageKey, 1 ), vElm = null ) => {
let vE = vElm ?? videoElm ?? getFirstVideElm()
s = Math.max( 0.25, Math.min( s, 10 ))
if (vE.playbackRate === s) {
// log("Playback rate already set to", s)
return
}
if (!vE) {
log("No video element found on page")
return
}
easyToast.success( s.toFixed(2) )
GM_setValue(speedStorageKey, s )
vE.playbackRate = s
},
get: () => GM_getValue(speedStorageKey, 1 ),
reset: () => {
GM_setValue(speedStorageKey, 1);
speed.setApply(1);
}
}
const findRoots = (ele) => {
return [
ele,
...ele.querySelectorAll('*')
].filter(e => !!e.shadowRoot)
.flatMap(e => [e.shadowRoot, ...findRoots(e.shadowRoot)])
}
const getFirstVideElm = () => {
let video = document.querySelector( 'video' )
if ( video ) return video;
let shadowElems = document.body ? findRoots( document.body ) : []
for( const elm of shadowElems ){
video = elm.querySelector( 'video' )
if ( video ){
return video // return early on the first video found.
}
}
return null;
}
const registerShortcutKeys = () => {
document.removeEventListener("keydown", handlePressedKey);
document.addEventListener("keydown", handlePressedKey);
}
const handlePressedKey = (e) => {
if ( isForbiddenTarget( e.target )) return; // If the pressed key is coming from any input field, do nothing.
if ( ! Object.keys( transformSpeedKeys ).includes(e.key ) ) return // Not an interesting key.
speed.setApply( transformSpeedKeys[e.key]( speed.get() ) )
}
return {
init : () => {
registerShortcutKeys();
const initialVideo = getFirstVideElm();
if (initialVideo) {
videoElm = initialVideo;
speed.setApply(speed.get(), initialVideo);
log("Initial video found");
}
setTimeout(() => registerShortcutKeys(), 3000); // react apps, which load content later (in shadow dom).
document.addEventListener("playing", (e) => registerShortcutKeys(), { capture: true, once: true });
document.addEventListener("playing", (e) => speed.setApply(speed.get(), e.target), { capture: true });
document.addEventListener("play", (e) => {
videoElm = e.target;
speed.setApply(speed.get(), e.target);
}, true);
document.addEventListener("DOMContentLoaded", () => setTimeout(() => registerShortcutKeys(), 3000));
GM_registerMenuCommand("Slower (hotkey: ,)", () => speed.setApply(transformSpeedKeys[","](speed.get())));
GM_registerMenuCommand("Faster (hotkey: .)", () => speed.setApply(transformSpeedKeys["."](speed.get())));
GM_registerMenuCommand("Reset (hotkey: -)", speed.reset);
}
}
})()
videoPlaybackControl.init()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment