Skip to content

Instantly share code, notes, and snippets.

@paponius
Last active January 20, 2026 17:42
Show Gist options
  • Select an option

  • Save paponius/c509d734f0a57a00d2e8b1aeb1346621 to your computer and use it in GitHub Desktop.

Select an option

Save paponius/c509d734f0a57a00d2e8b1aeb1346621 to your computer and use it in GitHub Desktop.
Userscript to manage video speed using simple keyboard shortcuts. Video speed is displayed in the page title automatically

Keyboard shortcuts for HTLM5 video speed and FW/RW skips (UserScript)

A mod from: https://gist.github.com/Anmol-Sharma/bdfb563f8a65ee0c84958cb3fee74187/

This is a lightweight script, for maximum compatibility. Only to have speed control and 5/10 sec. FF/RW skips. (I have another version, see on bottom)
It's only active during the video playback, to minimize issues with a page.
The video element does not need to be in focus - does not need to be clicked.
Keys defeined in this script will override existing shortcut keys.
For example on Tiktok, by default arrows do skip back/forward but only with video in focus. With this they will work also without focus.

Do not install this script by clicking on RAW, that will "disable" further updates.

This script has limits.

  • It will not find <video> in a shadow DOM. Some sites today use it for <video> e.g. bbc.com, NYT.
  • There is no play/pause key. This script will disable itself on pause and would not be able to perform "play". (Some sites e.g. Twitter do have "k" as play/pause already defined)
  • Mute/Unmute does not always correlate with a site. i.e. When mute icon is pressed after keyboard mute key was used, the next time the state from site is ignored.

I also have more comprehensive version, where some limitations from above work. It's a part of larger project, Normal video player, here:
https://github.com/paponius/Normal-video-player

// ==UserScript==
// @name Video Speed Control
// @description Adjust HTML5 video playback speed by pressing shortcut keys.
// @version 1.1.5
// @match https://*/*
//// YouTube has it's own speed control
// @exclude https://www.youtube.com/*
//// or disable "https://*/*" above and keep just some
// @match https://twitter.com/*
// @match https://www.reddit.com/*
// @match https://x.com/*
// @run-at document-end
// @license MIT
// @author Anmol, paponius
// ==/UserScript==
// mod from: https://gist.github.com/Anmol-Sharma/bdfb563f8a65ee0c84958cb3fee74187
// Keymap:
const SLOWER = ","; // -0.1x
const FASTER = "."; // +0.1x
const NORMAL = "/"; // 1.0
const SHOW = ";"; // show speed
const FF5 = 'ArrowRight'; // Forward 5 secs
const RW5 = 'ArrowLeft'; // Reverse 5 secs
const FF10 = 'l'; // Forward 10 secs
const RW10 = 'j'; // Reverse 10 secs
const MUTE = 'm'; // Mute/Unmute
/**
* elVideo [HTMLElement] currently playing MediaElement
* speed [Number] to save resources, a call to elVideo.playbackRate. (not huge save)
* elToast [HTMLElement] to reuse toast element
* timToast [timer "handle"] to cancel toast message's timer
* currentlyPressedKeys [Array] keeps record of currently pressed keys
* LOG [Any] LOG can be assigned in another UserScript to something true
*/
var elVideo, speed, elToast, timToast, currentlyPressedKeys = [];
var LOG = unsafeWindow?.LOG || null;
// For other scripts to detect if this is running. To not duplicate keyboard shortcuts.; can't do: unsafeWindow?.videoSpeedControl
if (typeof unsafeWindow !== 'undefined') { unsafeWindow.videoSpeedControl = true; }
// capture: true: playing/pause do not bubble. Need capture to be able to capture the event on any ancestor element.
document.addEventListener("playing", assignVideo, { capture: true });
document.addEventListener("pause", divestVideo, { capture: true });
function assignVideo(event) {
if (LOG) { console.log('[video_controls] assignVideo', event.target, event.target === elVideo ? " | ignoring: it's an old one" : " | registering: new video"); }
// e.g. tiktok is changing mute state based on its internal state
if (elVideo) { elVideo.muted = (elVideo.dataset.muted === 'true'); }
if (event.target === elVideo) { return; } // elVideo could also be undefined/null here
if (!elVideo) {
// capture: true: to be able to cancel the event soon as possible. To avoid possible site's listeners.
document.addEventListener("keydown", handlePressedKey, { capture: true });
document.addEventListener("keypress", handlePressedKey, { capture: true });
document.addEventListener("keyup", handlePressedKey, { capture: true });
// document.addEventListener("keypress", stopProp, { capture: true });
// document.addEventListener("keyup", stopProp, { capture: true });
}
elVideo = event.target;
speed = Math.round(elVideo.playbackRate * 10) / 10;
}
function divestVideo(event) {
if (LOG) { console.log('[video_controls] divestVideo', event.target, event.target === elVideo ? " | removing: it's the one playing" : " | ignoring: some older"); }
// second assignVideo() could have been called, before the first one is paused and elVideo divested (assumption)
if (event.target !== elVideo) { return; }
document.removeEventListener("keydown", handlePressedKey);
document.removeEventListener("keypress", handlePressedKey);
document.removeEventListener("keyup", handlePressedKey);
// document.removeEventListener("keypress", stopProp);
// document.removeEventListener("keyup", stopProp);
elVideo = null;
}
function showNotification(message, delay = 1400) {
if (!elToast) {
elToast = document.createElement('DIV');
elToast.style.position = 'fixed';
elToast.style.top = '50%';
elToast.style.left = '50%';
elToast.style.transform = 'translate(-50%, -50%)';
elToast.style.backgroundColor = '#1c1b1b';
elToast.style.color = '#fff';
elToast.style.padding = '15px 25px';
elToast.style.borderRadius = '5px';
elToast.style.zIndex = '1000'; // Ensure it's on top
elToast.style.fontSize = '15px'; // Adjust as needed
} else { clearTimeout(timToast); }
elToast.textContent = message;
document.body.appendChild(elToast);
// need to cancel when new arrives, as the el is reused
timToast = setTimeout(() => {
elToast.remove();
}, delay); // disappear after delay
}
// todo maybe remove. was used before listening to all three events code was added to handlePressedKey()
function stopProp(event) {
if ([SLOWER, FASTER, NORMAL, SHOW, FF5, RW5, FF10, RW10, MUTE].includes(event.key)) {
if (LOG) { console.debug('%c[video_controls] event: stopProp(event)','color: lightpink;', event.key, event.target, elVideo); }
event.stopImmediatePropagation();
}
}
function handlePressedKey(event) {
// If the pressed key is coming from any input field, do nothing.
const target = event.target;
if (target.localName === "input" || target.localName === "textarea" || target.isContentEditable) return;
if (LOG) { console.debug(`%c[video_controls] event: handlePressedKey(event) | key: ${event.key} | type: ${event.type}` ,'color: cyan;', event.target, elVideo); }
// Watching all three events, also the deprecated keypress. On e.g. TikTok, keydown is blocked for all keys, keypress does not work for arrows, keyup is not optimal, but it's at least something.
// Act either on down/push or up, not both. Each pressed button is remembered to disable acting on it on the way up.
if (event.type === 'keydown' || event.type === 'keypress') {
if (currentlyPressedKeys.includes(event.key)) {
// This `if` condition (not its body) can be removed to disallow repeating on a key hold. (Maybe allow just some)
if (event.type === 'keypress') {
if (LOG) { console.debug('[video_controls] event: handlePressedKey(event) | ignored'); }
// need to stop other listeners for each type separately
stopProp(event);
return;
}
} else { currentlyPressedKeys.push(event.key); }
} else { // 'keyup'
const idx = currentlyPressedKeys.indexOf(event.key);
if (idx !== -1) {
currentlyPressedKeys.splice(idx, 1);
if (LOG) { console.debug('[video_controls] event: handlePressedKey(event) | ignored'); }
stopProp(event);
return;
}
}
// This line should be after saving of key event. Key could be pressed before elVideo is found and needs to be remembered anyway.
if (!elVideo) { return; }
if (LOG) { console.debug('[video_controls] event: handlePressedKey(event) | acting'); }
switch (event.key) {
case SLOWER:
// Math.round(): in JS `num = 7.9; num -= 0.1;` is sometimes 7.800000000000001
if (speed === 0) { break; }
speed = Math.round((speed - 0.1) * 10) / 10;
elVideo.playbackRate = speed;
showNotification(speed.toFixed(2).toString() + "x", 250);
event.stopImmediatePropagation();
break;
case FASTER:
speed = Math.round((speed + 0.1) * 10) / 10;
elVideo.playbackRate = speed;
showNotification(speed.toFixed(2).toString() + "x", 250);
event.stopImmediatePropagation();
break;
case NORMAL:
speed = elVideo.playbackRate = 1;
showNotification(speed.toFixed(2).toString() + "x", 250);
event.stopImmediatePropagation();
break;
case SHOW:
showNotification(speed.toFixed(2).toString() + "x");
event.stopImmediatePropagation();
break;
case FF5:
showNotification('+ 5 sec');
elVideo.currentTime = Math.min(elVideo.duration, elVideo.currentTime + 5);
event.stopImmediatePropagation();
break;
case RW5:
showNotification('- 5 sec');
elVideo.currentTime = Math.max(0, elVideo.currentTime - 5);
event.stopImmediatePropagation();
break;
case FF10:
showNotification('+ 10 sec');
elVideo.currentTime = Math.min(elVideo.duration, elVideo.currentTime + 10);
event.stopImmediatePropagation();
break;
case RW10:
showNotification('- 10 sec');
elVideo.currentTime = Math.max(0, elVideo.currentTime - 10);
event.stopImmediatePropagation();
break;
case MUTE:
// Player on the page sometimes remember its mute state and force it on video when its play button is pushed. (TikTok)
// This does not always correlate with a site. i.e. When mute icon is pressed after keyboard mute key was used, the next time the state from site is ignored. It can be made to follow it on e.g. Twitter, but not on TikTok.
if (elVideo.dataset.muted === undefined) { elVideo.dataset.muted = elVideo.muted; }
if (elVideo.dataset.muted === 'true') { // it's a String, not Boolean
elVideo.dataset.muted = elVideo.muted = false;
showNotification('Unmute');
} else {
elVideo.dataset.muted = elVideo.muted =true;
showNotification('Mute');
}
event.stopImmediatePropagation();
break;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment