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

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

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

// ==UserScript==
// @name         YouTube Quick Speed & Volume Interface(Capsule Style)
// @name:zh-TW   YouTube 快速倍速與音量控制介面(膠囊樣式)
// @name:zh-CN   YouTube 快速倍速与音量控制界面(胶囊样式)
// @namespace    https://twitter.com/CobleeH
// @version      5.52
// @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)';
    // 定義高亮和懸停時的圓角半徑,使其呈現膠囊形狀
    const HIGHLIGHT_BORDER_RADIUS = '9999px'; 

    // 定義兩種模式下的 bottom 值
    const BOTTOM_NORMAL = '85px';
    const BOTTOM_FULLSCREEN = '170px'; // 避開分享按鈕的高度
    // 水平對齊修正
    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';
        
        // 【修正點 1】預設圓角也改為膠囊形,以配合懸停效果
        option.style.borderRadius = HIGHLIGHT_BORDER_RADIUS; 
        
        option.style.transition = 'background-color 0.1s ease, color 0.1s ease, font-weight 0.1s ease';

        option.addEventListener('mouseenter', () => {
            // 只有在未被高亮選中時,才顯示 hover 效果
            if (option.style.backgroundColor !== 'rgb(255, 255, 255)' && option.style.backgroundColor !== 'rgb(255,255,255)') {
                option.style.backgroundColor = 'rgba(255, 255, 255, 0.25)';
            }
        });
        option.addEventListener('mouseleave', () => {
            // 只有在未被高亮選中時,才移除 hover 效果
            if (option.style.backgroundColor !== 'rgb(255, 255, 255)' && 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';
            // 【修正點 2】取消高亮時,圓角保持膠囊形狀
            option.style.borderRadius = HIGHLIGHT_BORDER_RADIUS;
        });
        selectedOption.style.backgroundColor = '#fff';
        selectedOption.style.color = '#000';
        selectedOption.style.fontWeight = 'bold';
        // 這裡確保選中時是膠囊形
        selectedOption.style.borderRadius = HIGHLIGHT_BORDER_RADIUS; 
    }

    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';
        // --- 膠囊樣式 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';
        // --- 膠囊樣式 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});

})();