Youtube Remember Speed

Remembers the speed that you last used. Now hijacks YouTube's custom speed slider and gives you up to 8x speed.

بۇ قوليازمىنى قاچىلاش؟
ئاپتورنىڭ تەۋسىيەلىگەن قوليازمىسى

سىز بەلكىم YouTube HD Premium نى ياقتۇرۇشىڭىز مۇمكىن.

بۇ قوليازمىنى قاچىلاش
// ==UserScript==
// @name                Youtube Remember Speed
// @name:zh-TW          YouTube 播放速度記憶
// @name:zh-CN          YouTube 播放速度记忆
// @name:ja             YouTube 再生速度メモリー
// @icon                https://www.google.com/s2/favicons?domain=youtube.com
// @author              ElectroKnight22
// @namespace           electroknight22_youtube_remember_playback_rate_namespace
// @version             2.2.0
// @match               *://www.youtube.com/*
// @match               *://www.youtube-nocookie.com/*
// @exclude             *://music.youtube.com/*
// @grant               GM.getValue
// @grant               GM.setValue
// @grant               GM.deleteValue
// @grant               GM.listValues
// @license             MIT
// @description         Remembers the speed that you last used. Now hijacks YouTube's custom speed slider and gives you up to 8x speed.
// @description:zh-TW   記住上次使用的播放速度,並改造YouTube的速度調整滑桿,最高支援8倍速。
// @description:zh-CN   记住上次使用的播放速度,并改造YouTube的速度调整滑杆,最高支持8倍速。
// @description:ja      最後に使った再生速度を覚えておき、YouTubeの速度スライダーを改造して最大8倍速まで対応させます。
// @homepageURL         https://greasyfork.org/scripts/503771-youtube-remember-speed
// ==/UserScript==

/*jshint esversion: 11 */

(function () {
    'use strict';

    class SpeedOverrideManager {
        constructor(maxSpeed, getSettings, setSpeed, updateSavedSpeed) {
            this.maxSpeed = maxSpeed;
            this.getSettings = getSettings;
            this.setSpeed = setSpeed;
            this.updateSavedSpeed = updateSavedSpeed;
            this.DOM = {
                speedTextElement: null,
                speedLabel: null,
                parentMenu: null,
            };
            this.mainObserver = null;
            this.observerOptions = { childList: true, subtree: true, characterData: true };
        }

        disconnect() {
            if (this.mainObserver) {
                this.mainObserver.disconnect();
            }
            this.mainObserver = null;
        }

        overrideSpeedText(targetString) {
            try {
                if (!this.DOM.speedTextElement || !this.DOM.speedTextElement.isConnected) return;
                const text = this.DOM.speedTextElement.textContent;
                this.DOM.speedTextElement.textContent = /\(.*?\)/.test(text) ? text.replace(/\(.*?\)/, `(${targetString})`) : targetString;
            } catch (error) {
                console.error('Failed to set speed text.', error);
            }
        }

        overrideSpeedLabel(sliderElement) {
            try {
                const speedLabel = this.DOM.speedLabel;
                if (speedLabel?.isConnected && speedLabel.textContent && !speedLabel.textContent.includes(`(${sliderElement.value})`)) {
                    speedLabel.textContent = speedLabel.textContent.replace(/\(.*?\)/, `(${sliderElement.value})`);
                }
            } catch (error) {
                console.error('Failed to override speed label.', error);
            }
        }

        overrideSliderStyle(sliderElement) {
            if (!sliderElement) return;
            this.overrideSpeedLabel(sliderElement);
            sliderElement.style = `--yt-slider-shape-gradient-percent: ${(sliderElement.value / this.maxSpeed) * 100}%;`;
            const speedSliderText = document.querySelector('.ytp-speedslider-text');
            if (speedSliderText) {
                speedSliderText.textContent = sliderElement.value + 'x';
            }
        }

        overrideCustomSpeedItem(sliderElement) {
            const speedMenuItems = sliderElement.closest('.ytp-panel-menu');
            if (!speedMenuItems) return;
            const customSpeedItem = speedMenuItems.children[0];
            if (!customSpeedItem) return;
            if (!customSpeedItem.dataset.customSpeedClickListener) {
                customSpeedItem.addEventListener('click', () => {
                    const newSpeed = parseFloat(sliderElement.value);
                    this.setSpeed(newSpeed);
                    this.updateSavedSpeed(newSpeed);
                    this.overrideSpeedText(newSpeed);
                });
                customSpeedItem.dataset.customSpeedClickListener = 'true';
            }

            if (customSpeedItem.classList.contains('ytp-menuitem-with-footer') && customSpeedItem.getAttribute('aria-checked') !== 'true') {
                const currentActiveItem = speedMenuItems.querySelector('[aria-checked="true"]');
                if (currentActiveItem && currentActiveItem !== customSpeedItem) {
                    currentActiveItem.setAttribute('aria-checked', 'false');
                }
                customSpeedItem.setAttribute('aria-checked', 'true');
            }
        }

        overrideSliderFunction(sliderElement) {
            sliderElement.addEventListener('input', () => {
                try {
                    const newSpeed = parseFloat(sliderElement.value);
                    this.updateSavedSpeed(newSpeed);
                    this.setSpeed(newSpeed);
                    this.overrideSliderStyle(sliderElement);
                    this.overrideCustomSpeedItem(sliderElement);
                } catch (error) {
                    console.error('Error during slider input event.', error);
                }
            });

            sliderElement.addEventListener(
                'change',
                (event) => {
                    this.overrideSpeedText(sliderElement.value);
                    event.stopImmediatePropagation();
                },
                true,
            );
        }

        setupSliderOnce(sliderElement) {
            if (!sliderElement) return;
            if (!sliderElement.dataset.listenerAttached) {
                this.overrideSliderFunction(sliderElement);
                sliderElement.dataset.listenerAttached = 'true';
            }
            sliderElement.max = this.maxSpeed.toString();
            sliderElement.setAttribute('value', this.getSettings().targetSpeed.toString());
            this.setSpeed(this.getSettings().targetSpeed);
            this.DOM.speedLabel = sliderElement.closest('.ytp-menuitem-with-footer')?.querySelector('.ytp-menuitem-label');
        }

        async findSpeedTextElement() {
            // We use a magic number to manually create a stable selector for the speed menu item.
            const magicSpeedNumber = 1.05;
            const pollForElement = () => {
                const settingItems = document.querySelectorAll('.ytp-menuitem');
                return Array.from(settingItems).find((item) => item.textContent.includes(magicSpeedNumber.toString()));
            };
            const targetSpeed = this.getSettings().targetSpeed;
            const youtubeApi = document.querySelector('#movie_player');
            // YouTube API must be called here regardless to allow the YouTube UI to updated normally. (reason unclear)
            youtubeApi.setPlaybackRate(magicSpeedNumber);

            let attempts = 0;
            while (attempts < 50) {
                const matchingItem = pollForElement();
                if (matchingItem) {
                    this.DOM.speedTextElement = matchingItem.querySelector('.ytp-menuitem-content');
                    break;
                }
                await new Promise((resolve) => setTimeout(resolve, 10));
                attempts++;
            }
            this.setSpeed(targetSpeed);
            this.updateSavedSpeed(targetSpeed);
            this.overrideSpeedText(targetSpeed);
        }

        _monitorUI() {
            const sliderElement = document.querySelector('input.ytp-input-slider.ytp-speedslider');
            if (sliderElement) {
                if (!sliderElement.dataset.speedScriptSetup) {
                    this.setupSliderOnce(sliderElement);
                    sliderElement.dataset.speedScriptSetup = 'true';
                }

                if (!this.DOM.speedLabel || !this.DOM.speedLabel.isConnected) {
                    this.DOM.speedLabel = sliderElement.closest('.ytp-menuitem-with-footer')?.querySelector('.ytp-menuitem-label');
                }

                const speedLabel = this.DOM.speedLabel;
                const expectedLabel = `(${sliderElement.value})`;
                if (speedLabel && !speedLabel.textContent.includes(expectedLabel)) {
                    this.mainObserver.disconnect();
                    this.overrideSliderStyle(sliderElement);
                    this.overrideCustomSpeedItem(sliderElement);
                    this.mainObserver.observe(this.DOM.parentMenu, this.observerOptions);
                }
                return;
            }

            if (!this.DOM.speedTextElement || !this.DOM.speedTextElement.isConnected) {
                this.findSpeedTextElement();
            } else {
                const currentText = this.DOM.speedTextElement.textContent;
                const targetSpeed = this.getSettings().targetSpeed.toString();
                if (!currentText.includes(targetSpeed)) {
                    this.mainObserver.disconnect();
                    this.overrideSpeedText(targetSpeed);
                    this.mainObserver.observe(this.DOM.parentMenu, this.observerOptions);
                }
            }
        }

        init() {
            try {
                this.DOM.parentMenu = document.querySelector('.ytp-popup.ytp-settings-menu');
                if (!this.DOM.parentMenu) return;

                this.mainObserver = new MutationObserver(this._monitorUI.bind(this));
                this.mainObserver.observe(this.DOM.parentMenu, this.observerOptions);
            } catch (error) {
                console.error('Failed to initialize speed override manager.', error);
            }
        }
    }

    // --- Main Script Logic ---
    const DEFAULT_SETTINGS = { targetSpeed: 1 };
    let userSettings = { ...DEFAULT_SETTINGS };
    const maxSpeed = 8;
    let manager = null;

    function setSpeed(targetSpeed) {
        try {
            const video = document.querySelector('video');
            if (video && video.playbackRate !== targetSpeed) {
                video.playbackRate = targetSpeed;
            }
        } catch (error) {
            console.error('Failed to set playback speed.', error);
        }
    }

    function updateSavedSpeed(speed) {
        const newSpeed = parseFloat(speed);
        if (userSettings.targetSpeed !== newSpeed) {
            userSettings.targetSpeed = newSpeed;
            GM.setValue('targetSpeed', userSettings.targetSpeed);
        }
    }

    async function applySettings() {
        try {
            const storedSpeed = await GM.getValue('targetSpeed', DEFAULT_SETTINGS.targetSpeed);
            userSettings.targetSpeed = storedSpeed;
        } catch (error) {
            console.error('Failed to apply stored settings.', error.message);
        }
    }

    function handleNewVideoLoad() {
        if (!manager) {
            manager = new SpeedOverrideManager(maxSpeed, () => userSettings, setSpeed, updateSavedSpeed);
        }
        setSpeed(userSettings.targetSpeed);
        manager.init();

        const youtubeApi = document.querySelector('#movie_player');
        if (youtubeApi && !youtubeApi.dataset.speedScriptListenerAttached) {
            youtubeApi.addEventListener('onPlaybackRateChange', (newSpeed) => {
                updateSavedSpeed(newSpeed);
                if (manager) {
                    manager.overrideSpeedText(newSpeed);
                }
            });
            youtubeApi.dataset.speedScriptListenerAttached = 'true';
        }
    }

    function main() {
        window.addEventListener(
            'pageshow',
            () => {
                handleNewVideoLoad();
                window.addEventListener(
                    'yt-player-updated',
                    () => {
                        if (manager) {
                            manager.disconnect();
                            manager = null;
                        }
                        handleNewVideoLoad();
                    },
                    true,
                );
            },
            true,
        );
    }

    applySettings().then(main);
})();