Greasy Fork

来自缓存

Greasy Fork is available in English.

DX_喇叭按钮滚轮调音_MAX

在喇叭/音量按钮上使用滚轮调节音量

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

// ==UserScript==
// @name         DX_喇叭按钮滚轮调音_MAX
// @namespace    http://tampermonkey.net/
// @version      2.3.1
// @description  在喇叭/音量按钮上使用滚轮调节音量
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @license      MIT
// @noframes
// ==/UserScript==

(() => {
    'use strict';
    
    if (window.top !== window.self || window.__SPEAKER_WHEEL_INITIALIZED__) return;
    window.__SPEAKER_WHEEL_INITIALIZED__ = true;

    // 模式定义
    const MODES = {
        1: { id: 1, name: "保守模式", allVideos: false, universal: false, desc: "🎬关 + 🌐关 - 只处理已知平台的已知按钮" },
        2: { id: 2, name: "主流平台增强", allVideos: false, universal: true, desc: "🎬关 + 🌐开 - 只处理主流平台,但按钮识别更强" },
        3: { id: 3, name: "精准视频模式", allVideos: true, universal: false, desc: "🎬开 + 🌐关 - 只处理视频,但需要已知的音量按钮" },
        4: { id: 4, name: "全能模式", allVideos: true, universal: true, desc: "🎬开 + 🌐开 - 最大兼容性,任何网站都能用" }
    };

    // 配置管理函数(合并获取和保存逻辑)
    const ConfigManager = {
        getSiteConfig() {
            const siteSettings = GM_getValue('SpeakerWheelSiteSettings', {});
            const hostname = location.hostname;
            const siteConfig = siteSettings[hostname] || {};
            const currentMode = siteConfig.mode || 1;
            const modeConfig = MODES[currentMode];
            
            return {
                step: GM_getValue('SpeakerWheelStepVolume', 5),
                mode: currentMode,
                allVideos: modeConfig.allVideos,
                universal: modeConfig.universal,
                showDisplay: siteConfig.showVolumeDisplay ?? true,
                enabled: siteConfig.enabled ?? true
            };
        },
        
        saveSiteConfig(config) {
            const siteSettings = GM_getValue('SpeakerWheelSiteSettings', {});
            const hostname = location.hostname;
            
            siteSettings[hostname] = {
                mode: config.mode,
                showVolumeDisplay: config.showDisplay,
                enabled: config.enabled
            };
            
            GM_setValue('SpeakerWheelSiteSettings', siteSettings);
        }
    };

    // 获取当前配置
    const CONFIG = ConfigManager.getSiteConfig();

    // 切换模式函数
    const switchMode = () => {
        const nextMode = CONFIG.mode % 4 + 1;
        const modeConfig = MODES[nextMode];
        
        CONFIG.mode = nextMode;
        CONFIG.allVideos = modeConfig.allVideos;
        CONFIG.universal = modeConfig.universal;
        
        ConfigManager.saveSiteConfig(CONFIG);
        
        alert(`🎬 视频检测加强 / 🌐 音频检测追加\n\n已切换到: ${modeConfig.name}\n${modeConfig.desc}\n\n页面将刷新以应用更改`);
        location.reload();
    };

    // 统一初始化函数
    const initScript = () => {
        // 注册菜单命令
        const registerMenuCommands = () => {
            // 网站开关放在最上面 - 始终显示
            GM_registerMenuCommand(
                `🚀 本站脚本开关${CONFIG.enabled ? ' ✅' : ' ❌'}`,
                () => {
                    CONFIG.enabled = !CONFIG.enabled;
                    ConfigManager.saveSiteConfig(CONFIG);
                    alert(`脚本已${CONFIG.enabled ? '启用' : '禁用'}\n页面将刷新`);
                    location.reload();
                }
            );

            // 如果脚本被禁用,只显示网站开关,不显示其他菜单
            if (!CONFIG.enabled) return;

            const currentMode = MODES[CONFIG.mode];
            const nextModeId = CONFIG.mode % 4 + 1;
            const nextMode = MODES[nextModeId];
            
            // 主模式切换菜单
            GM_registerMenuCommand(
                `🔄 ${currentMode.name} → ${nextMode.name}`,
                switchMode
            );
            
            // 音量步进设置
            GM_registerMenuCommand('🔊 设置音量步进', () => {
                const newVal = prompt('设置音量调整步进 (%)', CONFIG.step);
                if (newVal && !isNaN(newVal)) {
                    const step = parseFloat(newVal);
                    GM_setValue('SpeakerWheelStepVolume', step);
                    alert('设置已保存');
                    location.reload();
                }
            });

            // 音量显示切换
            GM_registerMenuCommand(
                `👁️ 音量滑块(第三方)${CONFIG.showDisplay ? ' ✅' : ' ❌'}`,
                () => {
                    CONFIG.showDisplay = !CONFIG.showDisplay;
                    ConfigManager.saveSiteConfig(CONFIG);
                    if (!CONFIG.showDisplay) hideDisplay();
                    location.reload();
                }
            );
        };

        // 初始化菜单
        registerMenuCommands();

        // 如果脚本被禁用,不初始化任何功能
        if (!CONFIG.enabled) return;

        // 以下是脚本启用时的功能初始化

        // 平台标志
        const IS_YOUTUBE = location.hostname.includes('youtube');

        // 选择器配置(合并重复选择器)
        const getSelectors = () => {
            const platformSelectors = [
                // B站
                '.bpx-player-ctrl-volume', '.bpx-player-volume', '.bpx-player-ctrl-mute',
                '.bui-volume', '.bpx-player-vol',
                
                // YouTube
                '.ytp-mute-button', '.ytp-volume-panel', '.ytp-volume-slider', '.ytp-volume-slider-handle',
                
                // Twitch
                '[data-a-target="player-volume-button"]', '.player-controls__volume-control', '.volume-slider__slider',
                
                // Netflix
                '[data-uia="volume-slider"]', '[data-uia="audio-toggle"]',
                
                // STEAM 
                '.video_volume_control', '.volume_control'
            ];
            
            if (!CONFIG.universal) {
                return [...new Set(platformSelectors)]; // 去重
            }
            
            // 通用模式下的宽松选择器
            const universalSelectors = [
                // 类名匹配
                '[class*="volume"]', '[class*="sound"]', '[class*="mute"]',
                '.volume-icon', '.mute-btn', '.volume-btn', '.mute-icon',
                '.s-volume', '.volume__button', '.mute__button',
                // 属性匹配
                'button[aria-label*="volume"]', 'button[title*="volume"]',
                'button[aria-label*="mute"]', 'button[title*="mute"]',
                '.mute-button', '.sound-button'
            ];
            
            return [...new Set([...platformSelectors, ...universalSelectors])]; // 合并并去重
        };

        // 状态管理
        const state = {
            display: null, target: null, volume: 50,
            overSpeaker: false, overDisplay: false, dragging: false,
            position: null, timeout: null, locked: false
        };

        // 工具函数
        const clamp = vol => Math.max(0, Math.min(100, Math.round(vol)));
        const clampPos = (val, size) => {
            const margin = 20 + size / 2;
            return Math.max(margin, Math.min(window.innerWidth - margin, val));
        };

        // 平台检测和视频处理(合并相关逻辑)
        const PlatformUtils = {
            isVideoSite(host) {
                const videoPlatforms = [
                    'bilibili', 'youtube', 'twitch', 'netflix',
                    'nicovideo', 'vimeo', 'youku', 'iqiyi', 
                    'qq.com', 'acfun'
                ];
                return videoPlatforms.some(platform => host.includes(platform));
            },
            
            getVideo() {
                // 平台特定视频选择器配置
                const platformVideoSelectors = {
                    'bilibili': '.bpx-player-video-wrap video, .bilibili-player video',
                    'youtube': '#movie_player video, .html5-video-player video',
                    'twitch': '.video-ref video, .twitch-video video',
                    'netflix': 'video'
                };
                
                // 优先使用平台特定选择器
                for (const [platform, selector] of Object.entries(platformVideoSelectors)) {
                    if (location.hostname.includes(platform)) {
                        const video = document.querySelector(selector);
                        if (video && video.readyState > 0) {
                            return video;
                        }
                    }
                }
                
                // 回退到通用检测
                const videos = [...document.querySelectorAll('video')].filter(v => 
                    v.offsetParent !== null && v.readyState > 0
                );
                
                return (CONFIG.allVideos || this.isVideoSite(location.hostname)) ? 
                    videos.find(v => !v.paused) || videos[0] : null;
            },
            
			getVolume(video) {
				if (!video) return state.volume;
				
				// YouTube特殊处理
				if (IS_YOUTUBE) {
					const player = video.closest('#movie_player') || document.querySelector('.html5-video-player');
					
					// 先检查YouTube是否静音
					const isMuted = player?.isMuted?.();
					if (isMuted) {
						return 0;
					}
					
					const platformVolume = player?.getVolume?.();
					if (platformVolume !== undefined) {
						return platformVolume;
					}
				}
				
				// 通用静音检测
				if (video.muted) {
					return 0;
				}
				
				return Math.round(video.volume * 100);
			},
            
			setVolume(video, volume) {
				state.volume = clamp(volume);
				
				if (video) {
					// 记录变化前的状态
					const changed = video.volume !== state.volume / 100 || video.muted !== (state.volume === 0);
					
					video.volume = state.volume / 100;
					
					// 统一静音处理
					if (state.volume === 0) {
						video.muted = true;
						// YouTube特殊静音处理
						if (IS_YOUTUBE) {
							const player = video.closest('#movie_player') || document.querySelector('.html5-video-player');
							player?.mute?.();
						}
					} else {
						video.muted = false;
						// YouTube取消静音
						if (IS_YOUTUBE) {
							const player = video.closest('#movie_player') || document.querySelector('.html5-video-player');
							player?.unMute?.();
						}
					}
					
					// YouTube:使用原生API
					if (IS_YOUTUBE) {
						const player = video.closest('#movie_player') || document.querySelector('.html5-video-player');
						if (state.volume > 0) {
							player?.setVolume?.(state.volume);
						}
					} 
					// 其他平台:只有实际发生变化时才触发
					else if (changed) {
						video.dispatchEvent(new Event('volumechange', { bubbles: true }));
					}
				}
				return state.volume;
			}
        };

        // 位置计算函数(合并相关逻辑)
        const PositionUtils = {
            getPosition(target) {
                try {
                    const rect = target?.getBoundingClientRect();
                    if (!rect?.width || rect.top < -50) return this.getDefaultPos();
                    
                    const x = clampPos(rect.left + rect.width / 2, 60);
                    const y = clampPos(rect.top - 100, 180);
                    return isNaN(x) || isNaN(y) ? this.getDefaultPos() : { x, y };
                } catch {
                    return this.getDefaultPos();
                }
            },
            
            getDefaultPos() {
                return {
                    x: clampPos(window.innerWidth / 2, 60),
                    y: clampPos(window.innerHeight / 3, 180)
                };
            },
            
            applyPosition(display, pos) {
                display.style.left = `${pos.x}px`;
                display.style.top = `${pos.y}px`;
                display.style.transform = 'translate(-50%, -50%)';
            }
        };

        // 音量控制函数
        const adjustVolume = (delta, target = null) => {
            const video = PlatformUtils.getVideo();
            if (!video) return;
            
            // 如果视频是静音状态但音量不为0,取消静音
            if (video.muted && delta > 0) {
                video.muted = false;
            }
            
            let currentVol = PlatformUtils.getVolume(video);
            const newVolume = clamp(currentVol + delta);
            PlatformUtils.setVolume(video, newVolume);
            showVolume(newVolume, target || state.target, true);
        };

        // 显示/隐藏控制(合并相关逻辑)
        const DisplayManager = {
            clearHide() {
                clearTimeout(state.timeout);
                state.timeout = null;
            },
            
            scheduleHide() {
                this.clearHide();
                if (!state.overSpeaker && !state.overDisplay && !state.dragging) {
                    state.timeout = setTimeout(this.hideDisplay, 1500);
                }
            },
            
            resetUIState() {
                state.target = null;
                state.overSpeaker = false;
                state.overDisplay = false;
                state.locked = false;
                state.dragging = false;
                state.position = null;
                this.clearHide();
            },
            
            hideDisplay() {
                if (state.display) {
                    state.display.style.opacity = '0';
                    setTimeout(() => {
                        if (state.display?.style.opacity === '0') {
                            state.display.style.display = 'none';
                            this.resetUIState();
                        }
                    }, 300);
                }
            }
        };

        // UI 更新函数
        const updateUI = (volume, bar, text, handle) => {
            const percent = Math.round(volume);
            
            if (text) {
                text.textContent = `${percent}%`;
                text.style.color = '#fff';
            }
            if (bar) {
                bar.style.height = `${percent}%`;
                bar.style.background = '#fff';
            }
            if (handle) {
                handle.style.top = `${100 - percent}%`;
                handle.style.background = '#ff4444';
            }
        };

        // 显示元素创建(优化样式设置)
        const createDisplay = () => {
            if (state.display) return state.display;

            const display = document.createElement('div');
            display.id = 'speaker-wheel-volume-display';
            
            const text = document.createElement('div');
            const bar = document.createElement('div');
            const slider = document.createElement('div');
            const handle = document.createElement('div');
            const container = document.createElement('div');

            // 统一样式设置
            const styles = {
                display: {
                    position: 'fixed', zIndex: '2147483647', padding: '15px 5px',
                    background: 'rgba(40, 40, 40, 0.95)', borderRadius: '5px',
                    opacity: '0', transition: 'opacity 0.3s ease', pointerEvents: 'auto',
                    boxShadow: '0 4px 20px rgba(0,0,0,0.5)', border: '2px solid rgba(255,255,255,0.2)',
                    backdropFilter: 'blur(10px)', 
                    display: 'none',
                    flexDirection: 'column',
                    alignItems: 'center', gap: '12px', userSelect: 'none'
                },
                text: {
                    color: '#fff', fontSize: '12px', fontFamily: 'Arial, sans-serif',
                    fontWeight: 'bold', textAlign: 'center', minWidth: '40px', pointerEvents: 'none'
                },
                container: {
                    width: '8px', height: '120px', background: 'rgba(80, 80, 80, 0.8)',
                    borderRadius: '5px', border: '1px solid rgba(255,255,255,0.1)',
                    position: 'relative', overflow: 'hidden', pointerEvents: 'none'
                },
                bar: {
                    position: 'absolute', 
                    bottom: '0', 
                    left: '0', 
                    width: '100%', 
                    height: '0%',
                    background: '#fff', 
                    borderRadius: '2px', 
                    transition: 'height 0.1s ease',
                    pointerEvents: 'none',
                    minHeight: '6px'
                },
                slider: {
                    position: 'absolute', left: '0', width: '100%', height: '100%',
                    cursor: 'pointer', zIndex: '3', pointerEvents: 'auto'
                },
                handle: {
                    position: 'absolute', 
                    left: '0', 
                    width: '100%', 
                    height: '4px',
                    background: '#ff4444', 
                    borderRadius: '2px', 
                    transition: 'top 0.05s ease',
                    pointerEvents: 'none',
                    minHeight: '4px'
                }
            };

            Object.assign(display.style, styles.display);
            Object.assign(text.style, styles.text);
            Object.assign(container.style, styles.container);
            Object.assign(bar.style, styles.bar);
            Object.assign(slider.style, styles.slider);
            Object.assign(handle.style, styles.handle);

            // 组装
            slider.appendChild(handle);
            container.append(bar, slider);
            display.append(text, container);
            document.body.appendChild(display);

            // 事件处理(合并相关事件)
            const setupDisplayEvents = () => {
                display.addEventListener('mouseenter', () => {
                    state.overDisplay = state.locked = true;
                    DisplayManager.clearHide();
                });

                display.addEventListener('mouseleave', () => {
                    state.overDisplay = false;
                    DisplayManager.scheduleHide();
                });

                // 滑块拖动处理
                let dragging = false;
                const updateFromMouse = (y) => {
                    const rect = slider.getBoundingClientRect();
                    const percent = Math.max(0, Math.min(1, (rect.bottom - y) / rect.height));
                    const volume = Math.round(percent * 100);
                    
                    PlatformUtils.setVolume(PlatformUtils.getVideo(), volume);
                    updateUI(volume, bar, text, handle);
                };

                slider.addEventListener('mousedown', (e) => {
                    if (e.button !== 0) return;
                    dragging = state.dragging = state.locked = true;
                    updateFromMouse(e.clientY);
                    
                    const move = (e) => dragging && updateFromMouse(e.clientY);
                    const up = () => {
                        dragging = state.dragging = false;
                        document.removeEventListener('mousemove', move);
                        document.removeEventListener('mouseup', up);
                        DisplayManager.scheduleHide();
                    };
                    
                    document.addEventListener('mousemove', move);
                    document.addEventListener('mouseup', up);
                });

                // 滚轮事件
                display.addEventListener('wheel', wheelHandler, { capture: true, passive: false });
            };

            setupDisplayEvents();

            state.display = display;
            display.elements = { text, bar, handle };
            return state.display;
        };

        const showVolume = (vol, target, isWheel = false) => {
            if (!CONFIG.showDisplay) return;
            
            if (!state.overSpeaker && !isWheel && !state.dragging) {
                return;
            }
            
            const display = createDisplay();
            const { bar, text, handle } = display.elements;
            
            updateUI(vol, bar, text, handle);
            display.style.display = 'flex';

            if (isWheel && state.position) {
                PositionUtils.applyPosition(display, state.position);
            } else if (target && !state.locked) {
                state.target = target;
                state.position = PositionUtils.getPosition(target);
                state.locked = true;
                PositionUtils.applyPosition(display, state.position);
            } else if (state.position) {
                PositionUtils.applyPosition(display, state.position);
            } else {
                state.position = PositionUtils.getDefaultPos();
                state.locked = true;
                PositionUtils.applyPosition(display, state.position);
            }

            display.style.opacity = '1';
            DisplayManager.clearHide();
        };

        // 事件处理函数
        const wheelHandler = (e) => {
            const onVolume = state.overSpeaker || state.overDisplay;
            const video = PlatformUtils.getVideo();
            if (onVolume && video) {
                e.preventDefault();
                e.stopPropagation();
                adjustVolume(-Math.sign(e.deltaY) * CONFIG.step, e.currentTarget);
            }
        };

        // 元素事件绑定(优化去重逻辑)
        const bindElementEvents = (el) => {
            if (el.dataset.bound) return;
            el.dataset.bound = 'true';

            el.addEventListener('mouseenter', (e) => {
                state.overSpeaker = true;
                DisplayManager.clearHide();
                if (CONFIG.showDisplay && PlatformUtils.getVideo()) {
                    showVolume(PlatformUtils.getVolume(PlatformUtils.getVideo()), e.currentTarget, false);
                }
            });

            el.addEventListener('mouseleave', () => {
                state.overSpeaker = false;
                DisplayManager.scheduleHide();
            });

            el.addEventListener('wheel', wheelHandler, { capture: true, passive: false });

            if (!el.style.cursor) {
                el.style.cursor = 'ns-resize';
                el.title = '使用鼠标滚轮调节音量';
            }
        };

        // 事件设置(合并初始化逻辑)
        const setupEvents = () => {
            // 全局事件
            document.addEventListener('mousedown', (e) => {
                if (!e.target.closest('#speaker-wheel-volume-display') && !state.overSpeaker) {
                    DisplayManager.hideDisplay();
                }
            });

            // 绑定选择器(使用优化后的去重选择器)
            const uniqueSelectors = getSelectors();
            uniqueSelectors.forEach(selector => {
                try {
                    const elements = document.querySelectorAll(selector);
                    elements.forEach(bindElementEvents);
                } catch {}
            });

            // 视频监听
            const video = PlatformUtils.getVideo();
            if (video && !video.dataset.listener) {
                const handleVolumeChange = () => {
                    if (!state.dragging) {
                        const volume = PlatformUtils.getVolume(video);
                        showVolume(volume, state.target, false);
                    }
                };
                
                video.addEventListener('volumechange', handleVolumeChange);
                video.dataset.listener = 'true';
            }
        };

        // 延迟执行初始设置
        setTimeout(() => {
            setupEvents();
        }, 300);

        // DOM变化监听(优化防抖逻辑)
        let observerTimeout;
        const observer = new MutationObserver(() => {
            clearTimeout(observerTimeout);
            observerTimeout = setTimeout(() => {
                setupEvents();
            }, 1);
        });
        observer.observe(document.body, { childList: true, subtree: true });
    };

    // 执行初始化
    initScript();
})();