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

在YouTube的右下部区域添加一个快速速度和音量界面,而不干扰现有控件。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         YouTube Quick Speed & Volume Interface(Capsule Style)
// @name:zh-TW   YouTube 快速倍速與音量控制介面(膠囊樣式)
// @name:zh-CN   YouTube 快速倍速与音量控制界面(胶囊样式)
// @namespace    https://twitter.com/CobleeH
// @version      5.51
// @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'; // 避開分享按鈕的高度
    // 水平對齊修正:將 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, font-weight 0.1s ease'; // 添加 font-weight 過渡

        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';
            option.style.borderRadius = '4px'; // 取消高亮時恢復初始小圓角
        });
        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});

})();