YouTube 快速倍速與音量控制介面(膠囊樣式)

在YouTube的右下部區域添加一個快速速度和音量界面,而不干擾現有控件。

目前為 2025-10-15 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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      5.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
// @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 = '180px';
    const CAPSULE_BACKGROUND_COLOR = 'rgba(40, 40, 40, 0.4)';

    // 定義兩種模式下的 bottom 值
    const BOTTOM_NORMAL = '85px'; 
    const BOTTOM_FULLSCREEN = '170px'; // 避開分享按鈕的高度
    // 水平對齊修正:將 right 值從 15px 調整到 25px,以更好地貼齊浮動 UI 的右邊緣
    const RIGHT_OFFSET = '25px';

    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 = RIGHT_OFFSET; 
        container.style.bottom = BOTTOM_NORMAL; 
        container.style.zIndex = '9999';
        container.style.opacity = '0';
        container.style.pointerEvents = 'none';
        container.style.transition = 'opacity 0.2s ease-out, bottom 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 = '5px';

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

        // --- 膠囊樣式 ---
        volumeContainer.style.background = CAPSULE_BACKGROUND_COLOR;
        volumeContainer.style.borderRadius = '9999px';
        volumeContainer.style.padding = '4px 10px';
        // 🚀 修正:移除 boxShadow
        // 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';
        // 🚀 修正:移除 boxShadow
        // 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;
                // 考慮到 YouTube 影片的 mute 狀態,當影片靜音時,音量 slider 會在 0 處
                const effectiveVolume = video.muted ? 0 : newVolume; 
                
                if (Math.abs(volumeValue - effectiveVolume) < 0.001) {
                    highlightOption(option, '.ytp-volume-options-custom .ytp-option-custom');
                }
            });
        };

        // 1. 執行初始高亮 (新影片載入時執行)
        syncSpeed();
        syncVolume();

        // 2. 監聽原生播放器狀態變化
        video.addEventListener('ratechange', syncSpeed);
        video.addEventListener('volumechange', syncVolume);
    }

    // 動態調整膠囊位置
    function updatePosition(player, controlContainer) {
        if (!controlContainer) return;

        const isFullscreen = player.classList.contains('ytp-fullscreen');
        // 在劇院模式下,播放器容器寬度與視窗寬度一致,且原生浮動 UI 也會出現
        const isTheaterMode = document.querySelector('ytd-watch-flexy[theater]') || player.classList.contains('ytp-autohide');

        // 在全螢幕或劇院模式下使用更高的 BOTTOM 值
        if (isFullscreen || isTheaterMode) {
            controlContainer.style.bottom = BOTTOM_FULLSCREEN;
        } else {
            controlContainer.style.bottom = BOTTOM_NORMAL;
        }
    }

    function setupAutoHideSync(player, controlContainer) {
        // 確保控制項的顯示/隱藏與原生控制條同步
        const showControls = () => {
            controlContainer.style.opacity = '1';
            controlContainer.style.pointerEvents = 'auto';
        };
        const hideControls = () => {
            controlContainer.style.opacity = '0';
            controlContainer.style.pointerEvents = 'none';
        };

        // 觀察器:監聽播放器 class 變化,並同時更新位置
        const observer = new MutationObserver(() => {
            updatePosition(player, controlContainer); // 每次類別改變時都更新位置

            if (player.classList.contains('ytp-autohide')) {
                hideControls();
            } else {
                showControls();
            }
        });
        observer.observe(player, { attributes: true, attributeFilter: ['class'] });

        // 初始執行一次
        updatePosition(player, controlContainer); 
        if (player.classList.contains('ytp-autohide')) {
            hideControls();
        } else {
            showControls();
        }
    }

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

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

        const controlContainer = createControlContainer();
        player.appendChild(controlContainer);
        setupInitialStateAndSync(); // 呼叫同步函數
        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});

})();