YouTube Quick Speed & Volume Interface(Capsule Style)

Add a quick speed and volume interface to YouTube's middle-bottom area without interfering with existing controls.

// ==UserScript==
// @name         YouTube Quick Speed & Volume Interface(Capsule Style)
// @name:zh-TW   YouTube 快速倍速與音量控制介面(膠囊樣式)
// @name:zh-CN   YouTube 快速倍速与音量控制界面(胶囊样式)
// @namespace    https://twitter.com/CobleeH
// @version      6.11
// @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(20, 20, 20, 0.4)';
    const HIGHLIGHT_BORDER_RADIUS = '9999px'; 

    // 定義兩種模式下的 bottom 值
    const BOTTOM_NORMAL = '110px';
    const BOTTOM_FULLSCREEN = '165px'; 
    // 預設的右側偏移量
    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});

})();