DX_视频区滚轮调音

在视频区域使用滚轮调节音量。支持:YouTube、B站、Steam、Twitch。Steam页面支持多视频独立控制

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         DX_视频区滚轮调音
// @namespace    http://tampermonkey.net/
// @version      3.2.0
// @description  在视频区域使用滚轮调节音量。支持:YouTube、B站、Steam、Twitch。Steam页面支持多视频独立控制
// @match        *://www.youtube.com/*
// @match        *://www.bilibili.com/*
// @match        *://live.bilibili.com/*
// @match        *://www.twitch.tv/*
// @match        *://store.steampowered.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @license      MIT
// @noframes
// ==/UserScript==

(function() {
    'use strict';
    
    const CONFIG = {
        stepVolume: GM_getValue('VideoWheelConfig_stepVolume', 10)
    };

    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 (/store\.steampowered\.com/.test(host)) return "STEAM";
        return "GENERIC";
    })();

    // 工具函数
    const utils = {
        isInteractiveElement(element) {
            return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(element.tagName) || element.isContentEditable;
        },

        isElementVisible(element) {
            return element && document.contains(element) && element.offsetWidth > 50 && element.offsetHeight > 50;
        },

        clampVolume(vol) {
            return Math.max(0, Math.min(100, Math.round(vol)));
        },

        isMouseInVideoArea(x, y, video) {
            const rect = video.getBoundingClientRect();
            // 严格在视频区域内,不留边距
            return x >= rect.left && x <= rect.right && 
                   y >= rect.top && y <= rect.bottom;
        }
    };

    // 视频检测器
    const videoDetector = {
        cachedVideos: new Map(),
        videoIdCounter: 0,
        
        getVideoElement(x, y) {
            // Steam平台特殊处理 - 多视频支持
            if (PLATFORM === 'STEAM') {
                return this.findVideoAtPosition(x, y);
            }
            
            // 其他平台使用原有逻辑
            let video = this.getCachedVideo();
            if (video && utils.isElementVisible(video)) {
                return video;
            }
            
            const selectors = {
                'YOUTUBE': ['video', 'ytd-player video'],
                'BILIBILI': ['.bpx-player-video-wrap video', '.bilibili-player video'],
                'TWITCH': ['.video-ref video', '.twitch-video video'],
                'GENERIC': ['video.player', 'video']
            }[PLATFORM] || ['video'];
            
            for (const selector of selectors) {
                video = document.querySelector(selector);
                if (video && utils.isElementVisible(video)) {
                    this.cacheVideo(video);
                    return video;
                }
            }
            
            return null;
        },
        
        // Steam专用:根据坐标查找视频
        findVideoAtPosition(x, y) {
            const videos = Array.from(document.querySelectorAll('video')).filter(v => utils.isElementVisible(v));
            
            for (const video of videos) {
                // Steam平台也使用严格的视频区域检测
                if (utils.isMouseInVideoArea(x, y, video)) {
                    return video;
                }
            }
            
            return null;
        },
        
        getCachedVideo() {
            // 清理过期的缓存
            this.cleanupCache();
            
            // 返回第一个有效的缓存视频(非Steam平台)
            for (const [id, data] of this.cachedVideos) {
                if (utils.isElementVisible(data.video)) {
                    return data.video;
                }
            }
            return null;
        },
        
        cacheVideo(video) {
            // 为视频生成唯一ID,基于计数器
            if (!video.__videoWheelId) {
                video.__videoWheelId = `video_${++this.videoIdCounter}`;
            }
            const id = video.__videoWheelId;
            this.cachedVideos.set(id, {
                video: video,
                timestamp: Date.now()
            });
        },
        
        cleanupCache() {
            const now = Date.now();
            const maxAge = 30000; // 30秒
            
            for (const [id, data] of this.cachedVideos) {
                if (now - data.timestamp > maxAge || !document.contains(data.video)) {
                    this.cachedVideos.delete(id);
                }
            }
        }
    };

    // 音量显示
    const volumeDisplay = {
        element: null,
        timeoutId: null,
        scrollHandler: null,
        currentVideo: null,
        
        show(volume, video) {
            if (!this.element) this.create();
            
            this.currentVideo = video;
            this.updatePosition();
            this.element.textContent = `${Math.round(volume)}%`;
            this.element.style.opacity = '1';
            
            // 添加滚动监听
            this.addScrollListener();
            this.scheduleHide();
        },
        
        create() {
            this.element = document.createElement('div');
            this.element.id = 'video-wheel-volume-display';
            Object.assign(this.element.style, {
                position: 'fixed', zIndex: 2147483647, minWidth: '90px', height: '50px',
                lineHeight: '50px', textAlign: 'center', borderRadius: '4px',
                backgroundColor: 'rgba(0, 0, 0, 0.7)', color: '#fff', fontSize: '24px',
                fontFamily: 'Arial, sans-serif', opacity: '0', transition: 'opacity 0.3s',
                pointerEvents: 'none'
            });
            document.body.appendChild(this.element);
        },
        
        updatePosition() {
            if (!this.currentVideo || !utils.isElementVisible(this.currentVideo)) {
                this.hide();
                return;
            }
            
            const rect = this.currentVideo.getBoundingClientRect();
            const centerX = rect.left + rect.width / 2;
            const centerY = rect.top + rect.height / 2;
            
            this.element.style.left = centerX + 'px';
            this.element.style.top = centerY + 'px';
            this.element.style.transform = 'translate(-50%, -50%)';
        },
        
        addScrollListener() {
            this.removeScrollListener();
            
            this.scrollHandler = () => {
                this.updatePosition();
            };
            
            // 使用 capture 和 passive 提高性能
            window.addEventListener('scroll', this.scrollHandler, { 
                capture: true, 
                passive: true 
            });
        },
        
        removeScrollListener() {
            if (this.scrollHandler) {
                window.removeEventListener('scroll', this.scrollHandler, { 
                    capture: true 
                });
                this.scrollHandler = null;
            }
        },
        
        scheduleHide() {
            clearTimeout(this.timeoutId);
            this.timeoutId = setTimeout(() => {
                this.hide();
            }, 1000);
        },
        
        hide() {
            this.removeScrollListener();
            this.currentVideo = null;
            if (this.element) {
                this.element.style.opacity = '0';
            }
        }
    };

    // 音量控制
    const volumeControl = {
        adjustVolume(video, delta) {
            if (!video) return;
            
            let newVolume;
            if (PLATFORM === 'YOUTUBE') {
                // YouTube特殊处理
                try {
                    const ytPlayer = document.querySelector('#movie_player') || document.querySelector('.html5-video-player');
                    if (ytPlayer?.getVolume && ytPlayer?.setVolume) {
                        const currentVol = ytPlayer.getVolume();
                        newVolume = utils.clampVolume(currentVol + delta);
                        ytPlayer.setVolume(newVolume);
                    } else {
                        throw new Error('YouTube API not available');
                    }
                } catch {
                    newVolume = utils.clampVolume((video.volume * 100) + delta);
                    video.volume = newVolume / 100;
                }
            } else if (PLATFORM === 'STEAM') {
                // Steam平台特殊处理
                const currentVolume = video.volume * 100;
                
                if (delta > 0) {
                    // 向上滚动 - 如果当前是静音状态则取消静音
                    if (video.muted) {
                        video.muted = false;
                        // 设置一个默认音量,比如步进值
                        newVolume = CONFIG.stepVolume;
                    } else {
                        newVolume = utils.clampVolume(currentVolume + delta);
                    }
                } else {
                    // 向下滚动 - 减少音量但不进入静音
                    newVolume = utils.clampVolume(currentVolume + delta);
                    if (newVolume === 0) {
                        // 音量归0但不静音
                        video.muted = false;
                    }
                }
                
                video.volume = newVolume / 100;
            } else {
                // 通用处理
                newVolume = utils.clampVolume((video.volume * 100) + delta);
                video.volume = newVolume / 100;
            }
            
            volumeDisplay.show(newVolume, video);
            return newVolume;
        }
    };

    // 事件处理
    const eventHandler = {
        isProcessing: false,
        
        handleWheelEvent(e) {
            if (this.isProcessing || utils.isInteractiveElement(e.target)) return;
            
            const video = videoDetector.getVideoElement(e.clientX, e.clientY);
            if (!video) return;
            
            // 严格检查鼠标是否在视频区域内
            if (!utils.isMouseInVideoArea(e.clientX, e.clientY, video)) {
                return;
            }
            
            this.isProcessing = true;
            
            try {
                e.preventDefault();
                e.stopPropagation();
                
                const delta = -Math.sign(e.deltaY) * CONFIG.stepVolume;
                volumeControl.adjustVolume(video, delta);
            } finally {
                this.isProcessing = false;
            }
        }
    };

    // 初始化
    let isInitialized = false;

    const init = () => {
        if (isInitialized) return;
        isInitialized = true;
        
        // 菜单命令
        GM_registerMenuCommand('🔊 设置音量步进', () => {
            const newVal = prompt('设置音量步进 (1-100)', CONFIG.stepVolume);
            if (newVal !== null) {
                const num = parseFloat(newVal);
                if (!isNaN(num) && num >= 1 && num <= 100) {
                    CONFIG.stepVolume = num;
                    GM_setValue('VideoWheelConfig_stepVolume', num);
                    alert('设置已保存');
                } else {
                    alert('请输入1-100之间的数字');
                }
            }
        });
        
        // 事件绑定
        const eventOptions = { capture: true, passive: false };
        const handler = eventHandler.handleWheelEvent.bind(eventHandler);
        document.addEventListener('wheel', handler, eventOptions);
        
        if (PLATFORM === 'STEAM') {
            window.addEventListener('wheel', handler, eventOptions);
        }
        
        // 定期清理视频缓存
        setInterval(() => {
            videoDetector.cleanupCache();
        }, 15000);
    };

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