YouTube Playback Speed Control++

Adds an immersive playback speed control interface to YouTube's bottom controls to extend the speed options, while also hiding the default one. Includes keyboard shortcuts.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         YouTube Playback Speed Control++
// @namespace    https://naeembolchhi.github.io/
// @version      0.6
// @description  Adds an immersive playback speed control interface to YouTube's bottom controls to extend the speed options, while also hiding the default one. Includes keyboard shortcuts.
// @author       NaeemBolchhi
// @license      GPL-3.0-or-later
// @match        https://www.youtube.com/*
// @icon         data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 29 29"><path d="M14.48 24.5s9.08 0 11.34-.6a3.632 3.632 0 0 0 2.56-2.53c.62-2.22.62-6.89.62-6.89s0-4.64-.62-6.84a3.546 3.546 0 0 0-2.56-2.53c-2.25-.61-11.34-.61-11.34-.61s-9.06 0-11.31.61A3.658 3.658 0 0 0 .59 7.64c-.6 2.2-.6 6.84-.6 6.84s0 4.67.6 6.89a3.731 3.731 0 0 0 2.58 2.53c2.24.6 11.31.6 11.31.6Z" fill="%23f03"/><path d="m19 14.5-7.5-4.25v8.5L19 14.5Z" fill="%23fff"/></svg>
// @run-at       document-body
// @grant        GM_addStyle
// ==/UserScript==

(function() {
    'use strict';

    // Available speed options
    const speeds = [
        [0.25, 0.5, 0.75, 1],
        [1.25, 1.5, 1.75, 2],
        [2.25, 2.5, 2.75, 3],
        [3.25, 3.5, 3.75, 4]
    ];

    // Define styles
    const speedCSS = `
        .ytp-speed-option, .ytp-speed-increase, .ytp-speed-decrease, .ytp-speed-insert {
            -webkit-user-select: none;
            -ms-user-select: none;
            user-select: none;
            -webkit-tap-highlight-color: transparent;
        }
        .ytp-tooltip.ytp-bottom {
            top: unset !important;
            bottom: 70px;
        }
        .ytp-internal-speed, .ytp-speed-container.hidden {
            display: none !important;
        }
        .ytp-speed-container.invisible {
            opacity: 0;
        }
        .ytp-speed-container, .ytp-speed-row, .ytp-speed-option, .ytp-speed-display, .ytp-speed-increase, .ytp-speed-decrease {
            display: flex;
            align-items: center;
            justify-content: center;
        }
        .ytp-speed-container {
            opacity: 1;
            flex-direction: column;
            position: absolute;
            right: 0;
            bottom: 62px;
            z-index: 9999;
            background: var(--yt-sys-color-baseline--overlay-background-medium,rgba(0,0,0,.6));
            backdrop-filter: var(--yt-frosted-glass-backdrop-filter-override,blur(16px));
            color: rgb(255, 255, 255);
            border-radius: 12px;
            padding: 24px 16px 16px;
            font-size: 14px;
            gap: 8px;
            transition: opacity .1s cubic-bezier(0,0,.2,1);
        }
        .ytp-speed-row {
            gap: 8px;
        }
        .ytp-speed-option {
            cursor: pointer;
            height: 32px;
            width: 53px;
            border-radius: 16px;
            background: rgba(255,255,255,.1);
        }
        .ytp-speed-display {
            font-size: 18px;
            font-weight: 900;
            margin-bottom: 24px;
            width: 80%;
            text-align: center;
        }
        .ytp-speed-insert {
            flex: 1 0 auto;
        }
        .ytp-speed-insert::after,
        .ytp-speed-option::after {
            content: 'x';
        }
        .ytp-speed-increase, .ytp-speed-decrease {
            all: unset;
            cursor: pointer;
            font-size: 24px;
            height: 32px;
            width: 32px;
            border-radius: 100%;
            background: rgba(255,255,255,.1);
        }
        .ytp-speed-option:hover,.ytp-speed-increase:hover, .ytp-speed-decrease:hover {
            background: rgba(255,255,255,.2);
        }
        .ytp-speed-button {
            overflow: visible !important;
            position: relative !important;
            display: flex !important;
            justify-content: center;
        }
        .ytp-speed-button svg {
            transition: transform .1s cubic-bezier(.4,0,1,1);
        }
        .ytp-speed-button.opened svg {
            transform: rotate(30deg);
        }
        .ytp-tooltip-text {
            font-size: 13px;
            width: 92px;
            text-align: center;
        }
        .ytp-speed-tooltip {
            position: absolute;
            bottom: 62px !important;
            display: none;
        }
        .ytp-speed-tooltip.hover {
            display: unset;
        }
        .ytp-right-controls-left:has(>button[aria-expanded="true"]) .ytp-speed-tooltip,
        .ytp-speed-button.opened .ytp-speed-tooltip {
            display: none !important;
        }
        .ytp-chrome-bottom:has(.ytp-speed-button.opened) {
            opacity: 1 !important;
        }
        div:has(>.ytp-bezel-text-wrapper)[class*="visible-"] {
            display: unset !important;
        }
        div:has(>.ytp-bezel-text-wrapper)[class*="visible-"] .ytp-bezel-text-wrapper {
            opacity: 1 !important;
        }
        div:has(>.ytp-bezel-text-wrapper)[class*="visible-"] .ytp-bezel-text-wrapper+* {
            display: none !important;
        }
        #movie_player:not(:has(.ytp-speed-container.invisible)) .ytp-fullscreen-quick-actions {
            display: none !important;
        }
    `;

    // Create speed control
    function createSpeedOptions() {
        // Click hold variable for '+' and '-'
        let holdTimeout, holdInterval;
        const stopHolds = () => {
            clearInterval(holdInterval);
            clearTimeout(holdTimeout);
        };

        const speedContainer = document.createElement('div');
        speedContainer.classList.add('ytp-speed-container','hidden','invisible');

        // Speed display and buttons
        const speedDisplay = document.createElement('div');
        speedDisplay.classList.add('ytp-speed-display');

        const speedDisplaySpan = document.createElement('span');
        speedDisplaySpan.classList.add('ytp-speed-insert');

        const speedDisplayDecrease = document.createElement('button');
        speedDisplayDecrease.classList.add('ytp-speed-decrease');
        speedDisplayDecrease.title = 'Decrease playback speed\nCtrl + , (Comma)';
        speedDisplayDecrease.addEventListener('mousedown', () => {
            changeSpeed('-');
            holdTimeout = setTimeout(() => {
                holdInterval = setInterval(() => {changeSpeed('-')}, 70);
            }, 500);
        });
        speedDisplayDecrease.addEventListener('mouseup', stopHolds);
        speedDisplayDecrease.addEventListener('mouseleave', stopHolds);

        const speedDisplayIncrease = document.createElement('button');
        speedDisplayIncrease.classList.add('ytp-speed-increase');
        speedDisplayIncrease.title = 'Increase playback speed\nCtrl + . (Period)';
        speedDisplayIncrease.addEventListener('mousedown', () => {
            changeSpeed('+');
            holdTimeout = setTimeout(() => {
                holdInterval = setInterval(() => {changeSpeed('+')}, 70);
            }, 500);
        });
        speedDisplayIncrease.addEventListener('mouseup', stopHolds);
        speedDisplayIncrease.addEventListener('mouseleave', stopHolds);

        // Keyboard shortcuts
        document.addEventListener('keydown', (e) => {
            if (e.ctrlKey && e.key === ',') {
                e.preventDefault();
                changeSpeed('-');

            } else if (e.ctrlKey && e.key === '.') {
                changeSpeed('+');

            }
        });

        const speedDisplayDecreaseSpan = document.createElement('span');
        speedDisplayDecreaseSpan.textContent = '–';

        const speedDisplayIncreaseSpan = document.createElement('span');
        speedDisplayIncreaseSpan.textContent = '+';

        speedDisplayDecrease.appendChild(speedDisplayDecreaseSpan);
        speedDisplayIncrease.appendChild(speedDisplayIncreaseSpan);
        speedDisplay.appendChild(speedDisplayDecrease);
        speedDisplay.appendChild(speedDisplaySpan);
        speedDisplay.appendChild(speedDisplayIncrease);

        speedContainer.appendChild(speedDisplay);

        // Add speed options
        for (let x = 0; x < speeds.length; x++) {
            const speedRow = document.createElement('div');
            speedRow.classList.add('ytp-speed-row');

            for (let y = 0; y < speeds[x].length; y++) {
                const speedOption = document.createElement('span');
                speedOption.classList.add('ytp-speed-option');
                speedOption.textContent = speeds[x][y];
                speedOption.addEventListener('click', () => {
                    const video = document.querySelector('video');
                    if (video) {
                        // video.playbackRate = speeds[x][y];
                        setSpeedValue(speedOption.textContent);
                    }
                });
                speedRow.appendChild(speedOption);
            }

            speedContainer.appendChild(speedRow);
        }

        return speedContainer;
    }

    // Create speed button in chromeBottom
    function createSpeedButton() {
        const settingsButton = document.querySelector('.ytp-chrome-bottom .ytp-settings-button');
        const speedContainer = document.querySelector('.ytp-speed-container');

        const speedButton = document.createElement('button');
        speedButton.classList.add('ytp-button', 'ytp-speed-button');

        speedButton.appendChild(settingsButton.children[0].cloneNode(true));
        speedButton.querySelector('path').setAttribute('d', "M12 1c1.44 0 2.87.28 4.21.83a11 11 0 0 1 3.45 2.27l-1.81 1.05c-3.78-3.23-9.46-2.79-12.69.99A8.986 8.986 0 0 0 3 12a9 9 0 0 0 18 0v-.44c-.03-.4-.08-.8-.15-1.2l1.81-1.05c1.49 5.89-2.08 11.87-7.98 13.36-1.36.34-2.78.42-4.17.23-6.02-.82-10.24-6.36-9.42-12.38C1.83 5.06 6.49.99 12 1Zm7.08 6.25-7.96 3.25a1.75 1.75 0 0 0-.92 2.28c.38.88 1.4 1.29 2.28.92.13-.06.25-.13.36-.21l6.8-5.26c.25-.19.29-.55.1-.8a.579.579 0 0 0-.66-.18h-.01Z");

        const speedButtonTooltip = document.createElement('div');
        speedButtonTooltip.classList.add('ytp-tooltip','ytp-bottom','ytp-speed-tooltip');
        const speedButtonTooltip2 = document.createElement('div');
        speedButtonTooltip2.classList.add('ytp-tooltip-text-wrapper');
        const speedButtonTooltip3 = document.createElement('div');
        speedButtonTooltip3.classList.add('ytp-tooltip-bottom-text');
        const speedButtonTooltip4 = document.createElement('span');
        speedButtonTooltip4.classList.add('ytp-tooltip-text');
        speedButtonTooltip4.textContent = 'Playback speed';

        speedButtonTooltip3.appendChild(speedButtonTooltip4);
        speedButtonTooltip2.appendChild(speedButtonTooltip3);
        speedButtonTooltip.appendChild(speedButtonTooltip2);

        speedButton.appendChild(speedButtonTooltip);

        speedButton.addEventListener('click', () => {
            if (speedContainer.classList.contains('hidden')) {
                speedContainer.classList.remove('hidden');

                requestAnimationFrame(() => {
                    requestAnimationFrame(() => {
                        speedContainer.classList.remove('invisible');

                        setTimeout(() => {
                            speedButton.classList.add('opened');
                        }, 100);
                    });
                });
            } else {
                speedContainer.classList.add('invisible');
                speedButton.classList.remove('opened');

                setTimeout(() => {
                    speedContainer.classList.add('hidden');
                }, 100);
            }
        });

        document.body.addEventListener('click', (e) => {
            if (e.target.closest('.ytp-speed-button.ytp-button') || e.target.closest('.ytp-speed-container') || speedContainer.classList.contains('hidden')) {return;}

            speedContainer.classList.add('invisible');
            speedButton.classList.remove('opened');

            setTimeout(() => {
                speedContainer.classList.add('hidden');
            }, 100);
        });

        function hoverState() {
            speedButtonTooltip.classList.add('hover');
        }
        function hoverStateGone() {
            speedButtonTooltip.classList.remove('hover');
        }

        speedButton.addEventListener('mouseenter', () => {
            hoverState();
        });
        speedButton.addEventListener('mouseleave', () => {
            hoverStateGone();
        });
        speedButton.addEventListener('focus', () => {
            hoverState();
        });
        speedButton.addEventListener('blur', () => {
            hoverStateGone();
        });

        settingsButton.parentNode.insertBefore(speedButton, settingsButton);
    }

    // Deal with it when an option is clicked
    function setSpeedValue(speedValue) {
        const displayTextSpan = document.querySelector('.ytp-speed-insert');
        displayTextSpan.textContent = parseFloat(speedValue).toFixed(2);

        const video = document.querySelector('video');
        video.playbackRate = parseFloat(speedValue);

        bezelSpeedValue(speedValue);
    }

    // Display speed value in bezel
    function bezelSpeedValue(speedValue) {
        const bezel = document.querySelector('.ytp-bezel-text-wrapper');
        const videoSpeed = parseFloat(speedValue).toFixed(2);
        const dateNow = parseInt(Date.now()).toString();

        bezel.children[0].textContent = videoSpeed + 'x';
        bezel.parentNode.classList.add('visible-' + dateNow);

        setTimeout(() => {
            bezel.parentNode.classList.remove('visible-' + dateNow)
        }, 1000);
    }

    // Insert speed options into the player
    function insertSpeedOptions() {
        const chromeBottom = document.querySelector('.ytp-chrome-bottom');
        if (!chromeBottom || document.querySelector('.ytp-speed-container')) {return;}

        // Safely add styles
        GM_addStyle(speedCSS);

        // Add speed menu
        chromeBottom.appendChild(createSpeedOptions());

        // Add speed button
        createSpeedButton();

        // Set initial highlight
        setInitialSpeed();
    }

    // Initialize highlight speed options
    function setInitialSpeed() {
        const currentSpeed = document.querySelector('video')?.playbackRate || 1;
        setSpeedValue(currentSpeed);
    }

    // Hide internal speed settings
    function hideInternalSpeed() {
        const chromeBottom = document.querySelector('.ytp-chrome-bottom');
        if (!chromeBottom || document.querySelector('.ytp-internal-speed')) {return;}

        const labels = document.querySelectorAll('#ytp-id-5 .ytp-menuitem-label');

        for (let x = 0; x < labels.length; x++) {
            if (labels[x].textContent.match(/playback speed/i)) {
                labels[x].closest('.ytp-menuitem').classList.add('ytp-internal-speed');
            }
        }
    }

    // Change video speed
    function changeSpeed(key) {
        const changeRate = 0.05;
        const currentSpeed = parseFloat(document.querySelector('video').playbackRate);
        let newSpeed;

        if (key === '-') {
            newSpeed = currentSpeed - changeRate;
        } else if (key === '+') {
            newSpeed = currentSpeed + changeRate;
        }

        if (newSpeed < 0.05) {
            newSpeed = 0.05;
        }

        setSpeedValue(newSpeed);
    }

    // Use MutationObserver to listen for player changes
    const observer = new MutationObserver(() => {
        insertSpeedOptions();
        try {hideInternalSpeed()} catch {}
    });
    observer.observe(document.body, { childList: true, subtree: true });

    // Execute after page loads
    window.addEventListener('load', insertSpeedOptions);
})();