Add playback speed controls to web players with keyboard shortcuts
// ==UserScript==
// @name Playback Speed Control
// @namespace https://github.com/ZigZagT
// @version 2.2.0
// @description Add playback speed controls to web players with keyboard shortcuts
// @author ZigZagT
// @include /^https?://[^/]*plex[^/]*/
// @include /^https?://[^/]*:32400/
// @include *://app.plex.tv/**
// @include *://plex.tv/**
// @include *://*.youtube.com/**
// @match *://*/*
// @run-at document-start
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const console_log = (...args) => console.log('PlaybackSpeed:', ...args);
// ─── Site Detection ───
const isPlex = /plex/i.test(window.location.hostname) || window.location.port === '32400';
const isYouTube = window.location.hostname.includes('youtube.com');
const isKnownSite = isPlex || isYouTube;
function getNormalizedOrigin() {
let hostname = window.location.hostname;
if (hostname.startsWith('www.')) {
hostname = hostname.substring(4);
}
const port = window.location.port ? ':' + window.location.port : '';
return hostname + port;
}
const normalizedOrigin = getNormalizedOrigin();
// ─── Runtime Detection ───
const isUserscript = (
typeof GM_registerMenuCommand !== 'undefined' &&
typeof GM_unregisterMenuCommand !== 'undefined' &&
typeof GM_getValue !== 'undefined' &&
typeof GM_setValue !== 'undefined'
);
// ─── Multi-Instance Claiming ───
// Shared state lives on <html> dataset so both the userscript sandbox
// and the page's regular JS context can see the same slots.
const slots = document.documentElement.dataset;
if (isUserscript) {
if (slots.playbackSpeedControlUserscript) {
console_log('userscript instance already running, bailing');
return;
}
slots.playbackSpeedControlUserscript = 'active';
} else {
if (slots.playbackSpeedControlUserscript) {
console_log('userscript instance present, bailing');
return;
}
if (slots.playbackSpeedControl) {
console_log('non-userscript instance already running, bailing');
return;
}
slots.playbackSpeedControl = 'active';
}
// ─── Settings ───
function getSetting(key, defaultValue) {
if (!isUserscript) return defaultValue;
return GM_getValue(key, defaultValue);
}
function setSetting(key, value) {
if (!isUserscript) return;
GM_setValue(key, value);
}
let settings = {
enablePlex: getSetting('enablePlex', true),
enableYouTube: getSetting('enableYouTube', true),
plexSkipAutoPlayCountdown: getSetting('plexSkipAutoPlayCountdown', true),
plexNaturalVolume: getSetting('plexNaturalVolume', true),
youtubeNaturalVolume: getSetting('youtubeNaturalVolume', true),
naturalVolume: getSetting(`naturalVolume:${normalizedOrigin}`, false),
};
// Non-userscript: only Plex features
if (!isUserscript && !isPlex) {
console_log('non-userscript mode only supports Plex, bailing');
return;
}
if (isPlex && !settings.enablePlex) {
console_log('Plex disabled, bailing');
return;
}
if (isYouTube && !settings.enableYouTube) {
console_log('YouTube disabled, bailing');
return;
}
// ─── Menu Commands (userscript only, scoped to current site) ───
const menuToggles = [];
if (isPlex) {
menuToggles.push(
{ key: 'enablePlex', labelOn: 'Plex: Enabled \u2713', labelOff: 'Plex: Disabled \u2717' },
{ key: 'plexSkipAutoPlayCountdown', labelOn: 'Skip Auto Play Countdown: Enabled \u2713', labelOff: 'Skip Auto Play Countdown: Disabled \u2717' },
{ key: 'plexNaturalVolume', labelOn: 'Natural Volume Control: Enabled \u2713', labelOff: 'Natural Volume Control: Disabled \u2717' },
);
}
if (isYouTube) {
menuToggles.push(
{ key: 'enableYouTube', labelOn: 'YouTube: Enabled \u2713', labelOff: 'YouTube: Disabled \u2717' },
{ key: 'youtubeNaturalVolume', labelOn: 'Natural Volume Control: Enabled \u2713', labelOff: 'Natural Volume Control: Disabled \u2717' },
);
}
if (!isKnownSite) {
menuToggles.push(
{ key: 'naturalVolume', storageKey: `naturalVolume:${normalizedOrigin}`,
labelOn: `Natural Volume (${normalizedOrigin}): Enabled \u2713`,
labelOff: `Natural Volume (${normalizedOrigin}): Disabled \u2717` },
);
}
function registerMenuCommands() {
if (!isUserscript) return;
for (const toggle of menuToggles) {
if (toggle.cmdId !== undefined) {
GM_unregisterMenuCommand(toggle.cmdId);
}
const label = settings[toggle.key] ? toggle.labelOn : toggle.labelOff;
toggle.cmdId = GM_registerMenuCommand(label, () => {
settings[toggle.key] = !settings[toggle.key];
setSetting(toggle.storageKey || toggle.key, settings[toggle.key]);
registerMenuCommands();
if (!isKnownSite && toggle.key === 'naturalVolume' && settings[toggle.key]) {
alert('Natural Volume applies a generic audio fix to this site. '
+ 'It has not been tested here and may not work correctly or could cause audio issues. '
+ 'If you experience problems, disable this setting from the Tampermonkey menu.');
}
const state = settings[toggle.key] ? 'ENABLED' : 'DISABLED';
if (confirm(`${toggle.key} is now ${state}. Reload page to apply changes?`)) {
window.location.reload();
}
});
}
}
registerMenuCommands();
// ─── Common: Playback Speed Control ───
const cycleSpeeds = [
0.5, 0.8, 1, 1.2, 1.4, 1.6, 1.8, 2, 2.5, 3, 3, 5, 4, 5, 6, 7, 8, 9, 10, 15, 20
];
const quickSetSpeeds = {
1: 1,
2: 1.5,
3: 2,
4: 3,
5: 4,
6: 5,
7: 7,
8: 8,
9: 10,
};
let currentSpeed = 1;
function prompt(txt) {
const existingPrompt = document.querySelector("#playback-speed-prompt");
if (existingPrompt) {
document.body.removeChild(existingPrompt);
}
const prompt = document.createElement("div");
prompt.id = "playback-speed-prompt";
prompt.innerText = txt;
document.body.appendChild(prompt);
prompt.style = `
position: fixed;
top: 0;
left: 0;
width: 8em;
height: 2em;
background-color: rgba(0, 0, 0, 0.5);
color: white;
font-size: 2em;
text-align: center;
z-index: 99999;
pointer-events: none;
`;
setTimeout(() => {
try {
document.body.removeChild(prompt);
} catch (e) {}
}, 2000);
}
function setVideoSpeed(speed) {
currentSpeed = speed;
}
function syncVideoSpeed() {
const videoElem = document.querySelector("video");
if (videoElem == null) {
return;
}
if (videoElem.playbackRate != currentSpeed) {
console_log(`setting playbackRate to ${currentSpeed} for`, videoElem);
videoElem.playbackRate = currentSpeed;
}
}
function getNextCycleSpeed(direction, currentSpeed) {
let newSpeed = currentSpeed;
for (const speed of cycleSpeeds) {
if (direction === 'slowdown') {
if (speed < currentSpeed) {
newSpeed = speed;
} else {
break;
}
} else if (direction === 'speedup') {
if (speed > currentSpeed) {
newSpeed = speed;
break;
}
} else {
console.error(`invalid change speed direction ${direction}`)
break;
}
}
return newSpeed;
}
function keyboardUpdateSpeed(e) {
const target = e.target;
if (target.matches('input, textarea, [contenteditable]')) {
return;
}
let newSpeed = currentSpeed;
let isEventHandled = false;
console_log({currentSpeed, key: e.key});
if (e.key in quickSetSpeeds) {
newSpeed = quickSetSpeeds[e.key];
isEventHandled = true;
} else if (["<", ","].includes(e.key)) {
newSpeed = getNextCycleSpeed('slowdown', currentSpeed);
isEventHandled = true;
} else if ([">", "."].includes(e.key)) {
newSpeed = getNextCycleSpeed('speedup', currentSpeed);
isEventHandled = true;
}
if (isEventHandled) {
e.preventDefault();
e.stopImmediatePropagation();
console_log('change speed to', newSpeed);
setVideoSpeed(newSpeed);
prompt(`Speed: ${newSpeed}x`);
}
}
function btnSpeedUpFn() {
let newSpeed = getNextCycleSpeed('speedup', currentSpeed);
console_log('change speed to', newSpeed);
setVideoSpeed(newSpeed);
prompt(`Speed: ${newSpeed}x`);
}
function btnSlowdownFn() {
let newSpeed = getNextCycleSpeed('slowdown', currentSpeed);
console_log('change speed to', newSpeed);
setVideoSpeed(newSpeed);
prompt(`Speed: ${newSpeed}x`);
}
// ─── Common: Natural Volume Control ───
// Web apps set HTMLMediaElement.volume linearly, but human hearing is
// logarithmic. Override the volume property with a dB-linear curve so
// site sliders produce perceptually uniform loudness steps.
// Conversion functions from Discord's perceptual library (MIT):
// https://github.com/discord/perceptual
const VOLUME_DYNAMIC_RANGE_DB = 45;
function perceptualToAmplitude(perceptual, normMax = 1) {
if (perceptual <= 0) return 0;
if (perceptual >= normMax) return normMax;
const db = (perceptual / normMax) * VOLUME_DYNAMIC_RANGE_DB - VOLUME_DYNAMIC_RANGE_DB;
return Math.min(normMax, Math.pow(10, db / 20) * normMax);
}
function amplitudeToPerceptual(amplitude, normMax = 1) {
if (amplitude <= 0) return 0;
if (amplitude >= normMax) return normMax;
const db = 20 * Math.log10(amplitude / normMax);
return Math.min(normMax, Math.max(0, (VOLUME_DYNAMIC_RANGE_DB + db) / VOLUME_DYNAMIC_RANGE_DB) * normMax);
}
let nativeVolumeDescriptor = null;
let volumeOverrideActive = false;
const volumeLockValue = isUserscript ? 'userscript' : 'static';
function removeNaturalVolumeOverride() {
if (!volumeOverrideActive) return;
if (!nativeVolumeDescriptor) return;
if (slots.playbackSpeedControlNaturalVolumeControl !== volumeLockValue) {
console.error('playbackSpeedControlNaturalVolumeControl is gone');
return;
}
Object.defineProperty(HTMLMediaElement.prototype, 'volume', nativeVolumeDescriptor);
volumeOverrideActive = false;
nativeVolumeDescriptor = null;
delete slots.playbackSpeedControlNaturalVolumeControl;
console_log('natural volume control removed');
}
// YouTube applies loudness normalization by capping video.volume below 1.0.
// For videos inside a YouTube player, we read the normalization factor so
// our curve anchors at the endpoints: 0→0, normMax→normMax.
function getNormMaxYoutube(videoElem) {
const player = videoElem.closest('#movie_player');
if (!player || !player.getPlayerResponse) return 1;
const loudnessDb = player.getPlayerResponse()?.playerConfig?.audioConfig?.loudnessDb;
if (loudnessDb == null || loudnessDb <= 0) return 1;
return Math.pow(10, -loudnessDb / 20);
}
function syncNaturalVolume() {
const shouldActivate = (isPlex && settings.plexNaturalVolume)
|| (isYouTube && settings.youtubeNaturalVolume)
|| (!isKnownSite && settings.naturalVolume);
if (!shouldActivate) {
removeNaturalVolumeOverride();
return;
}
// already active, either by us or by other instances
if (slots.playbackSpeedControlNaturalVolumeControl || volumeOverrideActive) {
return;
}
// start activate
// set slots.playbackSpeedControlNaturalVolumeControl first so no other instance can active, should we fail in activate process
slots.playbackSpeedControlNaturalVolumeControl = volumeLockValue;
nativeVolumeDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'volume');
Object.defineProperty(HTMLMediaElement.prototype, 'volume', {
get() {
const amplitude = nativeVolumeDescriptor.get.call(this);
const normMax = getNormMaxYoutube(this);
const perceptual = amplitudeToPerceptual(amplitude, normMax);
console_log(`volume get: amplitude=${amplitude.toFixed(4)} → perceptual=${perceptual.toFixed(4)} (normMax=${normMax.toFixed(4)})`);
return perceptual;
},
set(perceptual) {
const normMax = getNormMaxYoutube(this);
const amplitude = perceptualToAmplitude(perceptual, normMax);
console_log(`volume set: perceptual=${perceptual.toFixed(4)} → amplitude=${amplitude.toFixed(4)} (normMax=${normMax.toFixed(4)})`);
nativeVolumeDescriptor.set.call(this, amplitude);
},
configurable: true,
enumerable: true,
});
// set volumeOverrideActive last so we don't attempt cleanup, should we fail in activate process
volumeOverrideActive = true;
console_log('natural volume control applied');
// Read the value the site set (native, pre-override) and re-set it
// through the override so the perceptual curve takes effect immediately
const videoElem = document.querySelector("video");
if (videoElem) {
const siteVolume = nativeVolumeDescriptor.get.call(videoElem);
videoElem.volume = siteVolume;
}
}
// ─── Plex Module ───
const instanceId = crypto.randomUUID();
function addPlaybackButtonControls() {
const btnStyle = `
align-items: center;
border-radius: 15px;
display: flex;
font-size: 18px;
height: 30px;
justify-content: center;
margin-left: 5px;
text-align: center;
width: 30px;
`;
const containers = document.querySelectorAll('[class*="PlayerControls-buttonGroupRight"]');
containers.forEach(container => {
const existing = container.querySelector('#playback-speed-btn-slowdown');
if (existing) {
if (existing.dataset.playbackSpeedOwner === instanceId) {
return;
}
console_log('removing speed controls owned by', existing.dataset.playbackSpeedOwner);
existing.remove();
const existingSpeedUp = container.querySelector('#playback-speed-btn-speedup');
if (existingSpeedUp) {
existingSpeedUp.remove();
}
}
const btnSlowDown = document.createElement('button');
btnSlowDown.id = 'playback-speed-btn-slowdown';
btnSlowDown.dataset.playbackSpeedOwner = instanceId;
btnSlowDown.style = btnStyle;
btnSlowDown.innerHTML = '🐢';
btnSlowDown.addEventListener('click', btnSlowdownFn);
const btnSpeedUp = document.createElement('button');
btnSpeedUp.id = 'playback-speed-btn-speedup';
btnSpeedUp.dataset.playbackSpeedOwner = instanceId;
btnSpeedUp.style = btnStyle;
btnSpeedUp.innerHTML = '🐇';
btnSpeedUp.addEventListener('click', btnSpeedUpFn);
console_log('adding speed controls to', container);
container.prepend(btnSlowDown, btnSpeedUp);
})
}
let lastAutoPlayedBtn = null;
function autoPlayNext() {
const checkbox = document.querySelector('input#autoPlayCheck');
if (!checkbox || !checkbox.checked) return;
const playNextBtn = document.querySelector('button[aria-label="Play Next"]');
if (!playNextBtn || playNextBtn === lastAutoPlayedBtn) return;
console_log('auto-clicking Play Next');
lastAutoPlayedBtn = playNextBtn;
// Plex UI listens on pointer/mouse events and ignores .click() alone
playNextBtn.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true}));
playNextBtn.dispatchEvent(new MouseEvent('mousedown', {bubbles: true}));
playNextBtn.dispatchEvent(new PointerEvent('pointerup', {bubbles: true}));
playNextBtn.dispatchEvent(new MouseEvent('mouseup', {bubbles: true}));
playNextBtn.click();
}
function plexLoopTick() {
syncNaturalVolume();
syncVideoSpeed();
addPlaybackButtonControls();
if (settings.plexSkipAutoPlayCountdown) {
autoPlayNext();
}
}
// ─── YouTube Module ───
function youtubeLoopTick() {
syncNaturalVolume();
syncVideoSpeed();
}
// ─── Generic Site Module ───
function genericLoopTick() {
syncNaturalVolume();
}
// ─── Main Loop ───
// AbortController lets the non-userscript instance remove its keyboard
// listener cleanly when a userscript instance takes over.
const abortController = new AbortController();
function scheduleLoopFrame() {
setTimeout(() => {
requestAnimationFrame(() => {
// Non-userscript self-teardown: if a userscript appeared, stop.
// Restore prototype before releasing the lock so the
// userscript captures the true native descriptor.
if (!isUserscript && slots.playbackSpeedControlUserscript) {
console_log('userscript instance detected, tearing down');
removeNaturalVolumeOverride();
abortController.abort();
return;
}
if (isPlex) {
plexLoopTick();
} else if (isYouTube) {
youtubeLoopTick();
} else {
genericLoopTick();
}
scheduleLoopFrame();
});
}, 500);
}
// ─── Registration ───
console_log(`registering (${isUserscript ? 'as userscript' : 'static script'}, site: ${isPlex ? 'plex' : isYouTube ? 'youtube' : normalizedOrigin})`);
// Capture phase so our handler intercepts events before other handlers
// https://www.quirksmode.org/js/events_order.html#link4
if (isKnownSite) {
window.addEventListener("keydown", keyboardUpdateSpeed, { capture: true, signal: abortController.signal });
}
scheduleLoopFrame();
})();