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

在YouTube的中下部區域添加一個快速速度和音量界面,而不干擾現有控件。(膠囊樣式)

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

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

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

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 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      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});

})();