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.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

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.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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