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

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

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴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      6.1
// @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 = '110px';
    const BOTTOM_FULLSCREEN = '170px'; 
    // 預設的右側偏移量
    const RIGHT_OFFSET_DEFAULT = '25px';
    // 設定選單開啟時,將控制項推到畫面外的偏移量(確保完全隱藏)
    const RIGHT_OFFSET_HIDDEN = '-300px'; 
    const CONTROL_TRANSITION = 'opacity 0.2s ease-out, bottom 0.2s ease-out, right 0.2s ease-out';


    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_DEFAULT; 
        container.style.bottom = BOTTOM_NORMAL;
        container.style.zIndex = '9999';
        container.style.opacity = '0';
        container.style.pointerEvents = 'none';
        container.style.transition = CONTROL_TRANSITION; 

        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 = HIGHLIGHT_BORDER_RADIUS; 
        
        option.style.transition = 'background-color 0.1s ease, color 0.1s ease, font-weight 0.1s ease';

        option.addEventListener('mouseenter', () => {
            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', () => {
            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 = 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';

        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';

        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;
                const effectiveVolume = video.muted ? 0 : newVolume;

                if (Math.abs(volumeValue - effectiveVolume) < 0.001) {
                    highlightOption(option, '.ytp-volume-options-custom .ytp-option-custom');
                }
            });
        };

        syncSpeed();
        syncVolume();

        video.addEventListener('ratechange', syncSpeed);
        video.addEventListener('volumechange', syncVolume);
    }

    // 【核心修正】判斷設定選單是否開啟的函數
    function isSettingsMenuOpen() {
        // 原生設定選單的容器是 .ytp-popup.ytp-settings-menu
        const settingsMenu = document.querySelector('.ytp-settings-menu');
        
        // 檢查三個條件:元素存在、不是被隱藏 (display: none)、且是可見的 (opacity > 0)
        if (settingsMenu) {
             // getComputedStyle 可以獲取元素實時計算後的樣式
            const style = window.getComputedStyle(settingsMenu);
            // 判斷方式:檢查 style.display 是否為 block/flex/grid 等非 none 值
            // 同時檢查它的 visibility 是否為 visible
            return style.display !== 'none' && style.visibility === 'visible';
        }
        return false;
    }


    // 合併所有位置和可見性更新邏輯
    function updateVisibilityAndPosition(player, controlContainer) {
        if (!controlContainer) return;

        const isFullscreen = player.classList.contains('ytp-fullscreen');
        const isTheaterMode = document.querySelector('ytd-watch-flexy[theater]') || player.classList.contains('ytp-autohide');
        
        // 【核心修正】呼叫精準的判斷函數
        const settingsOpen = isSettingsMenuOpen();


        // 1. 調整垂直位置 (bottom)
        if (isFullscreen || isTheaterMode) {
            controlContainer.style.bottom = BOTTOM_FULLSCREEN;
        } else {
            controlContainer.style.bottom = BOTTOM_NORMAL;
        }

        // 2. 調整水平位置 (right) 以避開設定選單
        if (settingsOpen) {
            controlContainer.style.right = RIGHT_OFFSET_HIDDEN; // 推到右邊隱藏
        } else {
            controlContainer.style.right = RIGHT_OFFSET_DEFAULT; // 恢復預設位置
        }
        
        // 3. 調整透明度 (opacity) 與原生控制條同步
        if (player.classList.contains('ytp-autohide')) {
            // 如果原生控制條隱藏,則自訂控制項也隱藏
            controlContainer.style.opacity = '0';
            controlContainer.style.pointerEvents = 'none';
        } else {
            // 如果原生控制條顯示,則自訂控制項也顯示
            controlContainer.style.opacity = '1';
            controlContainer.style.pointerEvents = 'auto';
        }
    }


    // 【修改】專門監聽設定選單容器的樣式變化
    function setupSettingsMenuObserver(player, controlContainer) {
        // 我們要監聽的目標是設定選單的彈出容器
        const settingsPopup = document.querySelector('.ytp-popup.ytp-settings-menu');
        
        if (!settingsPopup) {
             // 如果元素還沒載入,則監聽播放器容器
             const playerObserver = new MutationObserver(() => {
                 if (document.querySelector('.ytp-popup.ytp-settings-menu')) {
                     playerObserver.disconnect();
                     // 找到元素後重新設置監聽
                     setupSettingsMenuObserver(player, controlContainer); 
                 }
             });
             // 監聽播放器子節點,直到找到設定選單
             playerObserver.observe(player, { childList: true, subtree: true });
             return;
        }
        
        // 觀察器:監聽設定選單的 class 和 style 屬性
        const settingsObserver = new MutationObserver(() => {
            updateVisibilityAndPosition(player, controlContainer);
        });
        
        // 【重點】監聽 class 變動 (例如 ytp-panel-menu/ytp-settings-menu 開啟時的 class)
        // 以及 style 變動 (display: block/none, opacity, visibility)
        settingsObserver.observe(settingsPopup, { attributes: true, attributeFilter: ['class', 'style'] });

        // 確保初始狀態正確
        updateVisibilityAndPosition(player, controlContainer);
    }
    
    // 整合控制條隱藏與設定選單監聽
    function setupAutoHideSync(player, controlContainer) {
        
        // 1. 監聽播放器 Class 變化 (處理全螢幕/自動隱藏/垂直位置)
        const playerClassObserver = new MutationObserver(() => {
            updateVisibilityAndPosition(player, controlContainer); 
        });
        playerClassObserver.observe(player, { attributes: true, attributeFilter: ['class'] });

        // 2. 監聽設定選單的出現與隱藏 (處理重疊問題)
        setupSettingsMenuObserver(player, controlContainer);

        // 初始執行一次
        updateVisibilityAndPosition(player, controlContainer);
    }

    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;
            setTimeout(insertControls, 500);
        }
    }).observe(document, {subtree: true, childList: true});

})();