Greasy Fork

来自缓存

Greasy Fork is available in English.

DX_喇叭按钮滚轮调音_MAX

在喇叭/音量按钮上使用滚轮调节音量 - 修复Steam失效问题 - 简化优化版

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

// ==UserScript==
// @name         DX_喇叭按钮滚轮调音_MAX
// @namespace    http://tampermonkey.net/
// @version      2.4.5
// @description  在喇叭/音量按钮上使用滚轮调节音量 - 修复Steam失效问题 - 简化优化版
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @license      MIT
// @noframes
// ==/UserScript==

(function() {
    'use strict';

    if (window.top !== window.self || window.__SPEAKER_WHEEL_INITIALIZED__) return;
    window.__SPEAKER_WHEEL_INITIALIZED__ = true;

    // 简化的缓存管理
    const CacheManager = {
        videoCache: null,
        
        getVideoCache() {
            return this.videoCache && document.contains(this.videoCache) ? this.videoCache : null;
        },
        
        setVideoCache(video) {
            this.videoCache = video;
        },
        
        clearVideoCache() {
            this.videoCache = null;
        }
    };

    // 模式定义
    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 PLATFORM = (() => {
        const host = location.hostname;
        if (/youtube\.com|youtu\.be/.test(host)) return "YOUTUBE";
        if (/bilibili\.com/.test(host)) return "BILIBILI";
        if (/twitch\.tv/.test(host)) return "TWITCH";
        if (/steam(community|powered)\.com/.test(host)) return "STEAM";
        if (/netflix\.com/.test(host)) return "NETFLIX";
        return "GENERIC";
    })();

    const IS_STEAM = PLATFORM === 'STEAM';
    const IS_YOUTUBE = PLATFORM === 'YOUTUBE';

    // 统一选择器配置
    const PLATFORM_SELECTORS = {
        STEAM: ['svg._1CpOAgPPD7f_fGI4HaYX6C'],
        YOUTUBE: ['.ytp-mute-button', '.ytp-volume-panel', '.ytp-volume-slider', '.ytp-volume-slider-handle'],
        BILIBILI: ['.bpx-player-ctrl-volume', '.bpx-player-volume', '.bpx-player-ctrl-mute', '.bui-volume', '.bpx-player-vol'],
        TWITCH: ['[data-a-target="player-volume-button"]', '.player-controls__volume-control', '.volume-slider__slider'],
        NETFLIX: ['[data-uia="volume-slider"]', '[data-uia="audio-toggle"]'],
        GENERIC: [
            '[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'
        ]
    };

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

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

    let steamInitialized = false;

    // 视频元素检测函数
    const getVideoElement = () => {
        // 先尝试从缓存获取
        const cachedVideo = CacheManager.getVideoCache();
        if (cachedVideo) {
            return cachedVideo;
        }

        let video = null;
        if (IS_STEAM) {
            video = findActiveSteamVideo();
        } else {
            video = findActiveVideo();
        }

        // 更新缓存
        if (video) {
            CacheManager.setVideoCache(video);
        }
        
        return video;
    };

    // 查找激活的视频
    const findActiveVideo = () => {
        const allVideos = Array.from(document.querySelectorAll('video'));
        if (allVideos.length === 0) return null;

        // 1. 优先查找正在播放的视频
        const playingVideo = allVideos.find(v => v.offsetParent !== null && !v.paused && v.readyState > 0);
        if (playingVideo) return playingVideo;

        // 2. 查找可见的视频
        const visibleVideo = allVideos.find(v => v.offsetParent !== null && v.offsetWidth > 100 && v.offsetHeight > 50);
        if (visibleVideo) return visibleVideo;

        // 3. 如果启用全视频模式,返回第一个有效视频
        if (CONFIG.allVideos) {
            return allVideos.find(v => v.offsetParent !== null) || allVideos[0];
        }

        // 4. 检查是否是视频平台
        const videoPlatforms = [
            'bilibili', 'youtube', 'twitch', 'netflix',
            'nicovideo', 'vimeo', 'youku', 'iqiyi',
            'qq.com', 'acfun'
        ];

        const isVideoSite = videoPlatforms.some(platform => location.hostname.includes(platform));
        return isVideoSite ? allVideos.find(v => v.offsetParent !== null) || allVideos[0] : null;
    };

    // Steam平台专用视频检测
    const findActiveSteamVideo = () => {
        const allVideos = Array.from(document.querySelectorAll('video'));
        if (allVideos.length === 0) return null;

        const playingVideo = allVideos.find(v => v.offsetParent !== null && !v.paused);
        if (playingVideo) return playingVideo;

        const visibleVideo = allVideos.find(v => v.offsetParent !== null && v.offsetWidth > 300 && v.offsetHeight > 150);
        return visibleVideo || allVideos.find(v => v.offsetParent !== null) || allVideos[0];
    };

    // 音量获取和设置函数
    const getVolume = (video) => {
        if (!video) return state.volume;

        // YouTube特殊处理
        if (IS_YOUTUBE) {
            const player = video.closest('#movie_player') || document.querySelector('.html5-video-player');
            const isMuted = player?.isMuted?.();
            if (isMuted) return 0;

            const platformVolume = player?.getVolume?.();
            if (platformVolume !== undefined) return platformVolume;
        }

        // Steam特殊处理
        if (IS_STEAM) {
            if (video.muted) return 0;
            return Math.round(video.volume * 100);
        }

        // 通用静音检测
        if (video.muted) return 0;

        return Math.round(video.volume * 100);
    };

    const setVolume = (video, volume) => {
        const clampedVolume = clampVolume(volume);
        state.volume = clampedVolume;

        if (video) {
            // Steam特殊处理
            if (IS_STEAM) {
                video.volume = clampedVolume / 100;
                if (clampedVolume === 0) {
                    video.muted = true;
                } else {
                    video.muted = false;
                }
                return clampedVolume;
            }

            // 其他平台逻辑
            if (clampedVolume === 0) {
                video.muted = true;
                if (IS_YOUTUBE) {
                    const player = video.closest('#movie_player') || document.querySelector('.html5-video-player');
                    player?.mute?.();
                }
            } else {
                video.muted = false;
                video.volume = clampedVolume / 100;
                if (IS_YOUTUBE) {
                    const player = video.closest('#movie_player') || document.querySelector('.html5-video-player');
                    player?.unMute?.();
                    player?.setVolume?.(clampedVolume);
                }
            }

            video.dispatchEvent(new Event('volumechange', { bubbles: true }));
        }
        return clampedVolume;
    };

    // 位置计算函数
    const getDisplayPosition = (target) => {
        try {
            const rect = target?.getBoundingClientRect();
            if (!rect?.width || rect.top < -50) return getDefaultPosition();

            const x = clampPos(rect.left + rect.width / 2, 60);
            const y = clampPos(rect.top - 100, 180);
            return isNaN(x) || isNaN(y) ? getDefaultPosition() : { x, y };
        } catch {
            return getDefaultPosition();
        }
    };

    const getDefaultPosition = () => {
        return {
            x: clampPos(window.innerWidth / 2, 60),
            y: clampPos(window.innerHeight / 3, 180)
        };
    };

    const applyPosition = (display, pos) => {
        display.style.left = `${pos.x}px`;
        display.style.top = `${pos.y}px`;
        display.style.transform = 'translate(-50%, -50%)';
    };

    // 显示/隐藏控制
    const clearHide = () => {
        clearTimeout(state.timeout);
        state.timeout = null;
    };

    const scheduleHide = () => {
        clearHide();
        if (!state.overSpeaker && !state.overDisplay && !state.dragging) {
            state.timeout = setTimeout(() => {
                hideDisplay();
            }, 1500);
        }
    };

    const resetUIState = () => {
        state.target = null;
        state.overSpeaker = false;
        state.overDisplay = false;
        state.locked = false;
        state.dragging = false;
        state.position = null;
        clearHide();
    };

    const hideDisplay = () => {
        if (state.display) {
            state.display.style.opacity = '0';
            setTimeout(() => {
                if (state.display?.style.opacity === '0') {
                    state.display.style.display = 'none';
                    resetUIState();
                }
            }, 300);
        }
    };

    const handleScroll = () => {
        if (state.display && state.display.style.display !== 'none') {
            hideDisplay();
        }
    };

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

        // 统一样式设置
        Object.assign(display.style, {
            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'
        });

        Object.assign(text.style, {
            color: '#fff', fontSize: '12px', fontFamily: 'Arial, sans-serif',
            fontWeight: 'bold', textAlign: 'center', minWidth: '40px', pointerEvents: 'none'
        });

        Object.assign(container.style, {
            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'
        });

        Object.assign(bar.style, {
            position: 'absolute',
            bottom: '0',
            left: '0',
            width: '100%',
            height: '0%',
            background: '#fff',
            borderRadius: '2px',
            transition: 'height 0.1s ease',
            pointerEvents: 'none',
            minHeight: '6px'
        });

        Object.assign(slider.style, {
            position: 'absolute', left: '0', width: '100%', height: '100%',
            cursor: 'pointer', zIndex: '3', pointerEvents: 'auto'
        });

        Object.assign(handle.style, {
            position: 'absolute',
            left: '0',
            width: '100%',
            height: '4px',
            background: '#ff4444',
            borderRadius: '2px',
            transition: 'top 0.05s ease',
            pointerEvents: 'none',
            minHeight: '4px'
        });

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

        // 事件处理
        display.addEventListener('mouseenter', () => {
            state.overDisplay = state.locked = true;
            clearHide();
        });

        display.addEventListener('mouseleave', () => {
            state.overDisplay = false;
            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);

            const video = getVideoElement();
            setVolume(video, 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);
                scheduleHide();
            };

            document.addEventListener('mousemove', move);
            document.addEventListener('mouseup', up);
        });

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

        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) {
            applyPosition(display, state.position);
        } else if (target && !state.locked) {
            state.target = target;
            state.position = getDisplayPosition(target);
            state.locked = true;
            applyPosition(display, state.position);
        } else if (state.position) {
            applyPosition(display, state.position);
        } else {
            state.position = getDefaultPosition();
            state.locked = true;
            applyPosition(display, state.position);
        }

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

    // 音量调整功能
    const adjustVolume = (delta, target = null) => {
        const video = getVideoElement();
        if (!video) return;
        
        let currentVol = getVolume(video);
        
        // Steam平台特殊处理
        if (IS_STEAM && video.muted && delta > 0) {
            video.muted = false;
            const newVolume = setVolume(video, CONFIG.step);
            showVolume(newVolume, target || state.target, true);
            return;
        }

        // 如果视频是静音状态但音量不为0,取消静音
        if (video.muted && delta > 0) {
            video.muted = false;
        }
        
        const newVolume = setVolume(video, currentVol + delta);
        showVolume(newVolume, target || state.target, true);
    };

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

    // 选择器配置 - 使用统一的选择器配置
    const getSelectors = () => {
        const platformSelectors = [
            ...PLATFORM_SELECTORS.BILIBILI,
            ...PLATFORM_SELECTORS.YOUTUBE,
            ...PLATFORM_SELECTORS.TWITCH,
            ...PLATFORM_SELECTORS.NETFLIX,
            ...PLATFORM_SELECTORS.STEAM
        ];

        if (!CONFIG.universal) {
            return [...new Set(platformSelectors)];
        } else {
            return [...new Set([...platformSelectors, ...PLATFORM_SELECTORS.GENERIC])];
        }
    };

    // 元素事件绑定
    const bindElementEvents = (el) => {
        if (el.dataset.bound) return;
        el.dataset.bound = 'true';

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

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

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

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

    // Steam平台处理
    const initSteamVolume = () => {
        if (steamInitialized) return;

        const setupGlobalClickHide = () => {
            document.addEventListener('mousedown', (e) => {
                if (!e.target.closest('#speaker-wheel-volume-display') && !state.overSpeaker) {
                    hideDisplay();
                }
            });
        };

        const bindSteamElements = () => {
            // 使用统一的选择器配置
            PLATFORM_SELECTORS.STEAM.forEach(selector => {
                try {
                    const elements = document.querySelectorAll(selector);
                    elements.forEach(el => {
                        if (!el.dataset.bound) {
                            el.dataset.bound = 'true';

                            el.addEventListener('mouseenter', (e) => {
                                state.overSpeaker = true;
                                clearHide();
                                if (CONFIG.showDisplay && getVideoElement()) {
                                    const video = getVideoElement();
                                    const volume = video.muted ? 0 : getVolume(video);
                                    showVolume(volume, e.currentTarget, false);
                                }
                            });

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

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

                            el.addEventListener('click', () => {
                                setTimeout(() => {
                                    const video = getVideoElement();
                                    if (video && video.muted && state.display && state.display.style.display !== 'none') {
                                        showVolume(0, state.target, false);
                                    }
                                }, 50);
                            });

                            if (!el.style.cursor) {
                                el.style.cursor = 'ns-resize';
                                el.title = '使用鼠标滚轮调节音量';
                            }
                        }
                    });
                } catch (e) {
                    console.log('Steam元素绑定失败:', e);
                }
            });
        };

        // 初始绑定
        setupGlobalClickHide();
        window.addEventListener('scroll', handleScroll, { passive: true });
        document.addEventListener('scroll', handleScroll, { passive: true });
        bindSteamElements();
        
        // 监听DOM变化
        const observer = new MutationObserver(() => {
            bindSteamElements();
            CacheManager.clearVideoCache();
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });

        steamInitialized = true;
    };

    // 标准平台处理
    const setupEvents = () => {
        // 全局事件
        document.addEventListener('mousedown', (e) => {
            if (!e.target.closest('#speaker-wheel-volume-display') && !state.overSpeaker) {
                hideDisplay();
            }
        });

        window.addEventListener('scroll', handleScroll, { passive: true });
        document.addEventListener('scroll', handleScroll, { passive: true });

        // 绑定选择器
        const uniqueSelectors = getSelectors();
        uniqueSelectors.forEach(selector => {
            try {
                const elements = document.querySelectorAll(selector);
                elements.forEach(bindElementEvents);
            } catch {}
        });

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

    // 菜单命令注册
    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);
                location.reload();
            }
        );
    };

    // 初始化函数
    const initSteamPlatform = () => {
        const tryInit = () => {
            const video = getVideoElement();
            if (video) {
                initSteamVolume();
            } else {
                setTimeout(tryInit, 500);
            }
        };
        tryInit();
    };

    const initStandardPlatform = () => {
        setupEvents();

        const observer = new MutationObserver(() => {
            setupEvents();
            CacheManager.clearVideoCache();
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
    };

    const init = () => {
        registerMenuCommands();

        if (!CONFIG.enabled) return;

        if (IS_STEAM) {
            initSteamPlatform();
        } else {
            setTimeout(() => {
                initStandardPlatform();
            }, 300);
        }
    };

    // 页面卸载时清理缓存
    window.addEventListener('beforeunload', () => {
        CacheManager.clearVideoCache();
    });

    // 启动初始化
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        setTimeout(init, 1000);
    }
})();