YouTube 快速倍速与音量控制界面(胶囊样式)

在YouTube的中下部区域添加一个快速速度和音量界面,而不干扰现有控件。(胶囊样式)

当前为 2025-10-15 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         YouTube Quick Speed & Volume Interface
// @name:zh-TW   YouTube 快速倍速與音量控制介面(膠囊樣式)
// @name:zh-CN   YouTube 快速倍速与音量控制界面(胶囊样式)
// @namespace    https://twitter.com/CobleeH
// @version      2.0
// @description  Add a quick speed and volume interface to YouTube's middle-bottom area without interfering with existing controls.
// @description:zh-TW  在YouTube的中下部區域添加一個快速速度和音量界面,而不干擾現有控件。(膠囊樣式)
// @description:zh-CN  在YouTube的中下部区域添加一个快速速度和音量界面,而不干扰现有控件。(胶囊样式)
// @author       CobleeH / Gemini Revision
// @match        https://www.youtube.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const speeds = [0.5, 1, 1.5, 2, 3];
    const volumes = [0, 0.15, 0.35, 0.65, 1];

    const UNIFIED_MIN_WIDTH = '240px';
    const CAPSULE_BACKGROUND_COLOR = 'rgba(0, 0, 0, 0.7)';

    function createControlContainer() {
        const container = document.createElement('div');
        container.classList.add('ytp-control-container-custom');
        container.style.display = 'flex';
        container.style.flexDirection = 'column';
        container.style.alignItems = 'stretch';
        container.style.position = 'absolute';
        container.style.right = '15px';
        container.style.bottom = '85px';
        container.style.zIndex = '9999';
        container.style.opacity = '0';
        container.style.pointerEvents = 'none';
        container.style.transition = 'opacity 0.2s ease-out';

        const volumeOptions = createVolumeOptions();
        const speedOptions = createSpeedOptions();
        container.appendChild(volumeOptions);
        container.appendChild(speedOptions);
        return container;
    }

    function createOptionElement(text) {
        const option = document.createElement('div');
        option.classList.add('ytp-option-custom');
        option.innerText = text;
        option.style.cursor = 'pointer';
        option.style.margin = '0 3px';
        option.style.flexGrow = '1';
        option.style.textAlign = 'center';
        option.style.padding = '3px 6px';
        option.style.borderRadius = '4px';
        option.style.transition = 'background-color 0.1s ease, color 0.1s ease';

        option.addEventListener('mouseenter', () => {
            if (option.style.backgroundColor !== 'rgb(255, 255, 255)') {
                option.style.backgroundColor = 'rgba(255, 255, 255, 0.25)';
            }
        });
        option.addEventListener('mouseleave', () => {
            if (option.style.backgroundColor !== 'rgb(255, 255, 255)') {
                option.style.backgroundColor = 'transparent';
            }
        });
        return option;
    }

    // 此函數同時負責高亮選中的按鈕和取消高亮其他按鈕
    function highlightOption(selectedOption, selector) {
        const options = document.querySelectorAll(selector);
        options.forEach(option => {
            option.style.color = '#fff';
            option.style.fontWeight = 'normal';
            option.style.backgroundColor = 'transparent';
        });
        selectedOption.style.backgroundColor = '#fff';
        selectedOption.style.color = '#000';
        selectedOption.style.fontWeight = 'bold';
    }

    function createVolumeOptions() {
        const volumeContainer = document.createElement('div');
        volumeContainer.classList.add('ytp-volume-options-custom');
        volumeContainer.style.display = 'flex';
        volumeContainer.style.alignItems = 'center';
        volumeContainer.style.marginBottom = '3px';

        // 套用統一寬度
        volumeContainer.style.minWidth = UNIFIED_MIN_WIDTH;

        // --- 膠囊樣式 ---
        volumeContainer.style.background = CAPSULE_BACKGROUND_COLOR;
        volumeContainer.style.borderRadius = '9999px';
        volumeContainer.style.padding = '4px 10px';
        volumeContainer.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.5)';
        // --- 膠囊樣式 END ---

        const label = document.createElement('span');
        label.innerText = 'Vol';
        label.style.marginRight = '8px';
        label.style.fontWeight = 'bold';
        volumeContainer.appendChild(label);
        volumes.forEach(volume => {
            const option = createOptionElement((volume * 100) + '%');
            option.addEventListener('click', () => {
                const video = document.querySelector('video');
                if (video) {
                    video.volume = volume;
                    highlightOption(option, '.ytp-volume-options-custom .ytp-option-custom');
                }
            });
            volumeContainer.appendChild(option);
        });
        return volumeContainer;
    }

    function createSpeedOptions() {
        const speedContainer = document.createElement('div');
        speedContainer.classList.add('ytp-speed-options-custom');
        speedContainer.style.display = 'flex';
        speedContainer.style.alignItems = 'center';

        // 套用統一寬度
        speedContainer.style.minWidth = UNIFIED_MIN_WIDTH;

        // --- 膠囊樣式 ---
        speedContainer.style.background = CAPSULE_BACKGROUND_COLOR;
        speedContainer.style.borderRadius = '9999px';
        speedContainer.style.padding = '4px 10px';
        speedContainer.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.5)';
        // --- 膠囊樣式 END ---

        const label = document.createElement('span');
        label.innerText = 'Spd';
        label.style.marginRight = '8px';
        label.style.fontWeight = 'bold';
        speedContainer.appendChild(label);
        speeds.forEach(speed => {
            const option = createOptionElement(speed + 'x');
            option.addEventListener('click', () => {
                const video = document.querySelector('video');
                if (video) {
                    video.playbackRate = speed;
                    highlightOption(option, '.ytp-speed-options-custom .ytp-option-custom');
                }
            });
            speedContainer.appendChild(option);
        });
        return speedContainer;
    }

    // 新增/修改:設定初始高亮並添加事件監聽器,以修復切換影片不同步的問題
    function setupInitialStateAndSync() {
        const video = document.querySelector('video');
        if (!video) return;

        // --- 核心同步邏輯 ---

        // 速度同步函數
        const syncSpeed = () => {
            const newSpeed = video.playbackRate;
            const speedOptions = document.querySelectorAll('.ytp-speed-options-custom .ytp-option-custom');
            speedOptions.forEach(option => {
                const speedValue = parseFloat(option.innerText.replace('x', ''));
                if (Math.abs(speedValue - newSpeed) < 0.001) {
                    highlightOption(option, '.ytp-speed-options-custom .ytp-option-custom');
                }
            });
        };

        // 音量同步函數
        const syncVolume = () => {
            const newVolume = video.volume;
            const volumeOptions = document.querySelectorAll('.ytp-volume-options-custom .ytp-option-custom');
            volumeOptions.forEach(option => {
                const volumeValue = parseFloat(option.innerText.replace('%', '')) / 100;
                if (Math.abs(volumeValue - newVolume) < 0.001) {
                    highlightOption(option, '.ytp-volume-options-custom .ytp-option-custom');
                }
            });
        };

        // 1. 執行初始高亮
        syncSpeed();
        syncVolume();

        // 2. 監聽原生播放器狀態變化
        video.addEventListener('ratechange', syncSpeed);
        video.loca// For volume change events (including mute)
        video.addEventListener('volumechange', syncVolume);
    }

    function setupAutoHideSync(player, controlContainer) {
        const showControls = () => {
            controlContainer.style.opacity = '1';
            controlContainer.style.pointerEvents = 'auto';
        };
        const hideControls = () => {
            controlContainer.style.opacity = '0';
            controlContainer.style.pointerEvents = 'none';
        };
        const observer = new MutationObserver(() => {
            if (player.classList.contains('ytp-autohide')) {
                hideControls();
            } else {
                showControls();
            }
        });
        observer.observe(player, { attributes: true, attributeFilter: ['class'] });
        if (player.classList.contains('ytp-autohide')) {
            hideControls();
        } else {
            showControls();
        }
    }

    function insertControls() {
        const player = document.querySelector('.html5-video-player');
        if (!player) return;

        // 🚀 修正 1: 清理舊的控制容器,確保每次切換影片都會重新插入
        let existingContainer = document.querySelector('.ytp-control-container-custom');
        if (existingContainer) {
            existingContainer.remove();
        }

        const controlContainer = createControlContainer();
        player.appendChild(controlContainer);
        setupInitialStateAndSync(); // 🚀 修正 2: 呼叫新的同步函數
        setupAutoHideSync(player, controlContainer);
    }

    // 觀察器與載入事件
    const mainObserver = new MutationObserver(() => {
        if (document.querySelector('.html5-video-player') && !document.querySelector('.ytp-control-container-custom')) {
             insertControls();
        }
    });
    mainObserver.observe(document.body, { childList: true, subtree: true });
    window.addEventListener('load', insertControls);
    let lastUrl = location.href;
    new MutationObserver(() => {
        const url = location.href;
        if (url !== lastUrl) {
            lastUrl = url;
            // 延遲 500ms 確保 YouTube 播放器已載入新影片的屬性
            setTimeout(insertControls, 500);
        }
    }).observe(document, {subtree: true, childList: true});

})();