Last active
March 30, 2025 11:16
-
-
Save schuhwerk/67fb4da50652681b857002e1ba2bf071 to your computer and use it in GitHub Desktop.
UserScript. Control Video Playback Speed with Keyboard.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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