Playback Speed Control

Add playback speed controls to web players with keyboard shortcuts

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==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();
})();