YouTube 播放速度记忆

记住上次使用的播放速度,并改造YouTube的速度调整滑杆,最高支持8倍速。

安装此脚本
作者推荐脚本

您可能也喜欢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.1.1
// @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倍速まで対応させます。
// ==/UserScript==

/*jshint esversion: 11 */

(function () {
    'use strict';

    class SpeedOverrideManager {
        constructor(maxSpeed, getSettings, setSpeed, updateSavedSpeed) {
            // Dependencies are injected for better encapsulation
            this.maxSpeed = maxSpeed;
            this.getSettings = getSettings;
            this.setSpeed = setSpeed;
            this.updateSavedSpeed = updateSavedSpeed;
            this.DOM = {
                speedTextElement: null,
                speedLabel: null,
                parentMenu: null,
            };
            this.OBSERVERS = {
                speedTextObserver: null,
                sliderObserver: null,
            };
            this.initialized = false;
        }

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

        overrideSpeedLabel(sliderElement) {
            try {
                if (!this.DOM.speedLabel || !this.DOM.speedLabel.isConnected) {
                    this.DOM.speedLabel = sliderElement.closest('.ytp-menuitem-with-footer').querySelector('.ytp-menuitem-label');
                }
                if (this.DOM.speedLabel?.textContent && !this.DOM.speedLabel.textContent.includes(`(${sliderElement.value})`)) {
                    this.DOM.speedLabel.textContent = this.DOM.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}%;`;
            document.querySelector('.ytp-speedslider-text').textContent = sliderElement.value + 'x';
        }

        overrideCustomSpeedItem(sliderElement) {
            const speedMenuItems = sliderElement.closest('.ytp-panel-menu');
            const customSpeedItem = speedMenuItems.children[0];
            const selectCustomSpeedItem = () => {
                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');
                }
            };
            if (!customSpeedItem.dataset.listenerAttached) {
                customSpeedItem.addEventListener('click', () => {
                    userSettings.targetSpeed = parseFloat(sliderElement.value);
                    this.setSpeedText(userSettings.targetSpeed);
                    this.setSpeed(userSettings.targetSpeed);
                    this.updateSavedSpeed(userSettings.targetSpeed);
                });
                customSpeedItem.dataset.listenerAttached = 'true';
            }
            selectCustomSpeedItem();
        }

        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.setSpeedText(sliderElement.value);
                    event.stopImmediatePropagation();
                },
                true,
            );
        }

        overrideSlider() {
            const sliderElement = this.DOM.parentMenu.querySelector('input.ytp-input-slider.ytp-speedslider');
            if (!sliderElement) throw new Error('Slider element not found.');
            this.overrideSpeedLabel(sliderElement);
            if (sliderElement.initialized) return;
            const targetSpeed = this.getSettings().targetSpeed;
            sliderElement.max = this.maxSpeed.toString();
            sliderElement.setAttribute('value', targetSpeed.toString());
            this.setSpeed(targetSpeed);
            this.overrideSliderStyle(sliderElement);
            this.overrideCustomSpeedItem(sliderElement);
            this.overrideSliderFunction(sliderElement);
            if (this.OBSERVERS.sliderObserver) this.OBSERVERS.sliderObserver.disconnect();
            sliderElement.initialized = true;
        }

        async findSpeedTextElement() {
            const magicSpeed = 1.05;
            const pollForElement = () => {
                const settingItems = document.querySelectorAll('.ytp-menuitem');
                return Array.from(settingItems).find((item) => item.textContent.includes(magicSpeed.toString()));
            };
            const targetSpeed = this.getSettings().targetSpeed;
            const youtubeApi = document.querySelector('#movie_player');
            // YouTube's API **MUST** be called here or things break for some unknown reason.
            youtubeApi.setPlaybackRate(magicSpeed);
            const pollingLimit = 500;
            let attempts = 0;
            while (attempts < pollingLimit) {
                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.setSpeedText(targetSpeed);
        }

        async initializeSpeedTextElement() {
            try {
                if (this.DOM.speedTextElement) {
                    if (this.DOM.speedTextElement.initialized) return;
                    this.DOM.speedTextElement.initialized = true;
                    const youtubeApi = document.querySelector('#movie_player');
                    youtubeApi?.addEventListener('onPlaybackRateChange', this.updateSavedSpeed, true);
                    this.setSpeedText(this.getSettings().targetSpeed);
                    this.OBSERVERS.speedTextObserver?.disconnect(); // Disconnect after finding
                } else {
                    await this.findSpeedTextElement();
                }
            } catch (error) {
                console.error('Failed to initialize speed text element.', error);
            }
        }

        init() {
            try {
                this.DOM.parentMenu = document.querySelector('.ytp-popup.ytp-settings-menu');
                if (!this.DOM.parentMenu) throw new Error('The parent menu was not found.');
                if (!this.OBSERVERS.sliderObserver || this.initialized === false) {
                    this.OBSERVERS.sliderObserver?.disconnect();
                    this.OBSERVERS.sliderObserver = new MutationObserver(this.overrideSlider.bind(this));
                    this.OBSERVERS.sliderObserver.observe(this.DOM.parentMenu, { childList: true, subtree: true });
                }
                if (!this.OBSERVERS.speedTextObserver || this.initialized === false) {
                    this.OBSERVERS.speedTextObserver?.disconnect();
                    this.OBSERVERS.speedTextObserver = new MutationObserver(this.initializeSpeedTextElement.bind(this));
                    this.OBSERVERS.speedTextObserver.observe(this.DOM.parentMenu, { childList: true, subtree: true, attributes: true });
                }
                this.initialized = true;
            } 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;
            }
        } catch (error) {
            console.error('Failed to set playback speed.', error);
        }
    }

    function updateSavedSpeed(speed) {
        userSettings.targetSpeed = speed;
        GM.setValue('targetSpeed', userSettings.targetSpeed);
    }

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

    function handleNewVideoLoad() {
        if (!manager) {
            // Pass helper functions and settings to the class instance
            manager = new SpeedOverrideManager(
                maxSpeed,
                () => userSettings, // Pass a function to get the latest settings
                setSpeed,
                updateSavedSpeed,
            );
        }
        setSpeed(userSettings.targetSpeed);
        manager.init();
    }

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

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