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