Playback Speed Control

Add playback speed controls to web players with keyboard shortcuts

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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