Skip to content

Instantly share code, notes, and snippets.

@schuhwerk
Last active July 4, 2024 14:07
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 2024-07-04
// @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
// ==/UserScript==
const videoPlaybackControl = (() => {
const transformSpeedKeys = {
"," : ( speed ) => speed - 0.25,
"." : ( speed ) => speed + 0.25,
}
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 || vE?.playbackRate == s ) {
console.log( "No video elm found" )
return
}
//console.log( "Apply speed of ", s , "to", videoElm )
easyToast.success( s.toFixed(2) )
GM_setValue(speedStorageKey, s );
videoElm.playbackRate = s
},
get: () => GM_getValue(speedStorageKey, 1 )
}
const findRoots = (ele) => {
return [
ele,
...ele.querySelectorAll('*')
].filter(e => !!e.shadowRoot)
.flatMap(e => [e.shadowRoot, ...findRoots(e.shadowRoot)])
}
const getFirstVideElm = () => {
console.log("finding vide Elements on page")
let video = document.querySelector( 'video' )
if ( video ) return video;
let shadowElems = document.body ? findRoots( document.body ) : []
console.log( shadowElems )
for( const elm of shadowElems ){
video = elm.querySelector( 'video' )
// console.log( "video for shadow", elm, video)
if ( video ){
return video // return early on the first video found.
}
}
console.log( "no video found on page" )
}
const registerShortcutKeys = ( videoElmPrm = null ) => {
videoElm = videoElmPrm ?? getFirstVideElm()
console.log("add keydown event listener", videoElm)
document.addEventListener("keydown", handlePressedKey);
speed.setApply( speed.get() )
}
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 : () => {
setTimeout(() => registerShortcutKeys(), 3000) // react apps, which load content later (in shadow dom).
document.addEventListener("playing", (e) => registerShortcutKeys( e.target ), { capture: true, once: true });
document.addEventListener("playing", (e) => speed.setApply( speed.get(), e.target ) , { capture: true });
document.addEventListener("play", (e) => videoElm = e.target, true);
document.addEventListener("DOMContentLoaded", () => setTimeout(() => registerShortcutKeys(), 3000 ));
}
}
})()
videoPlaybackControl.init()
/* 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: 9990; 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") {
console.log("cancel!", this.timeoutID);
clearTimeout(this.timeoutID);
this.timeoutID = null; // reset the timeoutID after clearing it
}
},
removeAndClear() {
document.querySelectorAll('.'+this.toastClass).forEach(e => e.remove());
this.clear()
}
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment