// ==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 // @downloadURL none // ==/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}); })();