Greasy Fork

Greasy Fork is available in English.

滚动音量Dx版

滚轮、013速度、28音量、46+-5sec、5(空白键)播放暂停、enter全萤幕切换、小键盘+-增减10%进度。完整支援:YouTube、B站、Steam。B站直播(局部)

当前为 2025-06-25 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         滾動音量Dx版 Scroll Volume Dx Edition
// @name:zh-CN   滚动音量Dx版
// @name:en      Scroll Volume Dx Edition
// @namespace    http://tampermonkey.net/
// @version      9.5.6
// @description  滾輪、013速度、28音量、46+-5sec、5(空白鍵)播放暫停、enter全螢幕切換、小鍵盤+-增減10%進度。完整支援:YouTube、B站、Steam。B站直播(局部)
// @description:zh-CN 滚轮、013速度、28音量、46+-5sec、5(空白键)播放暂停、enter全萤幕切换、小键盘+-增减10%进度。完整支援:YouTube、B站、Steam。B站直播(局部)
// @description:en  wheel scroll for volume. NumpadKey:013 for speed, 28 for volume, 46 for 5sec、5(space) for play/pause, enter for fullscreen, numpad+- for 5sec. Fully supports: YouTube, Bilibili, Steam. Bilibili live (partial)
// @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
// ==/UserScript==

(function() {
    'use strict';

    // 获取标准化的域名标识 (简化为二级域名)
    const getDomainId = () => {
        const hostParts = location.hostname.split('.');
        return hostParts.length > 2 ? hostParts.slice(-2).join('_') : hostParts.join('_');
    };

    // 平台检测
    const PLATFORM = (() => {
        const host = location.hostname;
        if (/youtube\.com|youtu\.be/.test(host)) return "YOUTUBE";
        if (/www.bilibili\.com/.test(host)) return "BILIBILI";
        if (/twitch\.tv/.test(host)) return "TWITCH";
        if (/steam(community|powered)\.com/.test(host)) return "STEAM";
        return "GENERIC";
    })();

    // 存储配置结构优化
    const CONFIG_STORAGE_KEY = 'ScrollVolumeDxConfig';
    const DEFAULT_CONFIG = {
        stepTime: 5,
        stepTimeLong: 30,
        stepVolume: 10,
        key7Function: ['YOUTUBE', 'BILIBILI'].includes(PLATFORM) ? 4 : 1,
        key9Function: ['YOUTUBE', 'BILIBILI'].includes(PLATFORM) ? 4 : 1
    };

    // 获取配置(修复自定义参数保存问题)
    const getConfig = () => {
        const savedConfig = GM_getValue(CONFIG_STORAGE_KEY, {});
        const domainId = getDomainId();

        // 返回深拷贝的配置对象
        return {
            ...DEFAULT_CONFIG,
            ...(savedConfig[domainId] || {})
        };
    };

    // 保存配置(修复自定义参数保存问题)
    const saveConfig = (config) => {
        const savedConfig = GM_getValue(CONFIG_STORAGE_KEY, {});
        const domainId = getDomainId();

        // 创建当前域名的配置副本
        const currentConfig = { ...config };

        // 检查是否所有值都是默认值
        const isDefault = Object.keys(DEFAULT_CONFIG).every(key =>
            currentConfig[key] === DEFAULT_CONFIG[key]
        );

        if (isDefault) {
            // 删除默认配置
            if (savedConfig[domainId]) {
                delete savedConfig[domainId];
                GM_setValue(CONFIG_STORAGE_KEY, savedConfig);
            }
            return;
        }

        // 只存储与默认值不同的配置项
        const diffConfig = {};
        Object.keys(currentConfig).forEach(key => {
            if (currentConfig[key] !== DEFAULT_CONFIG[key]) {
                diffConfig[key] = currentConfig[key];
            }
        });

        savedConfig[domainId] = diffConfig;
        GM_setValue(CONFIG_STORAGE_KEY, savedConfig);
    };

    // 初始化配置(确保深拷贝)
    const CONFIG = (() => {
        const config = getConfig();
        // 首次运行时保存默认配置
        saveConfig(config);
        return config;
    })();

    // 配置菜单
    GM_registerMenuCommand("⚙️ 設定步進", () => {
        const newVal = prompt("設定快進/快退", CONFIG.stepTime);
        if (newVal && !isNaN(newVal)) {
            CONFIG.stepTime = parseFloat(newVal);
            saveConfig(CONFIG);
        }
    });

    GM_registerMenuCommand("⏱️ 設定長步進", () => {
        const newVal = prompt("設定長跳轉", CONFIG.stepTimeLong);
        if (newVal && !isNaN(newVal)) {
            CONFIG.stepTimeLong = parseFloat(newVal);
            saveConfig(CONFIG);
        }
    });

    GM_registerMenuCommand("🔊 設定音量步進", () => {
        const newVal = prompt("設定音量幅度 (%)", CONFIG.stepVolume);
        if (newVal && !isNaN(newVal)) {
            CONFIG.stepVolume = parseFloat(newVal);
            saveConfig(CONFIG);
        }
    });

    GM_registerMenuCommand("🎛️ 設定按鍵7/9功能", () => {
        const options = {
            1: '1. 長步進',
            2: '2. 上一頁/下一頁',
            3: '3. 上/下一個影片'
        };

        // 平台原生功能选项(仅限YouTube/Bilibili)
        if (['YOUTUBE', 'BILIBILI'].includes(PLATFORM)) {
            options[4] = '4. 平台原生功能';
        }

        const message = `選擇按鍵7功能:\n${Object.values(options).join('\n')}`;
        const choice7 = prompt(message, CONFIG.key7Function);
        if (choice7 && options[choice7]) {
            CONFIG.key7Function = parseInt(choice7);
            saveConfig(CONFIG);
        }

        const choice9 = prompt(`選擇按鍵9功能:\n${Object.values(options).join('\n')}`, CONFIG.key9Function);
        if (choice9 && options[choice9]) {
            CONFIG.key9Function = parseInt(choice9);
            saveConfig(CONFIG);
        }
    });

    let cachedVideo = null;
    let lastVideoCheck = 0;
    let videoElements = [];
    let currentVideoIndex = 0;
    let activeVideoId = null;

    const videoStateMap = new WeakMap();

    function getVideoState(video) {
        if (!videoStateMap.has(video)) {
            videoStateMap.set(video, {
                lastCustomRate: 1.0,
                isDefaultRate: true
            });
        }
        return videoStateMap.get(video);
    }

    // 生成视频唯一ID
    const generateVideoId = (video) =>
        `${video.src}_${video.clientWidth}x${video.clientHeight}`;

    function getVideoElement() {
        // 优先使用当前激活的视频
        if (activeVideoId) {
            const activeVideo = videoElements.find(v => generateVideoId(v) === activeVideoId);
            if (activeVideo && document.contains(activeVideo)) {
                cachedVideo = activeVideo;
                return cachedVideo;
            }
        }

        // 常规检测逻辑
        if (cachedVideo && document.contains(cachedVideo) && (Date.now() - lastVideoCheck < 300)) {
            return cachedVideo;
        }

        const handler = PLATFORM_HANDLERS[PLATFORM] || PLATFORM_HANDLERS.GENERIC;
        cachedVideo = handler.getVideo();
        lastVideoCheck = Date.now();

        // 更新视频元素列表和当前索引
        updateVideoElements();
        if (cachedVideo && videoElements.length > 0) {
            currentVideoIndex = videoElements.indexOf(cachedVideo);
            if (currentVideoIndex === -1) currentVideoIndex = 0;
            activeVideoId = generateVideoId(cachedVideo);
        }

        return cachedVideo;
    }

    function updateVideoElements() {
        videoElements = Array.from(document.querySelectorAll('video'))
            .filter(v => v.offsetParent !== null && v.readyState > 0);
    }

    function switchToNextVideo() {
        if (videoElements.length < 2) return null;

        currentVideoIndex = (currentVideoIndex + 1) % videoElements.length;
        cachedVideo = videoElements[currentVideoIndex];
        activeVideoId = generateVideoId(cachedVideo);
        lastVideoCheck = Date.now();

        return cachedVideo;
    }

    function switchToPrevVideo() {
        if (videoElements.length < 2) return null;

        currentVideoIndex = (currentVideoIndex - 1 + videoElements.length) % videoElements.length;
        cachedVideo = videoElements[currentVideoIndex];
        activeVideoId = generateVideoId(cachedVideo);
        lastVideoCheck = Date.now();

        return cachedVideo;
    }

    function commonAdjustVolume(video, delta) {
        if (delta < 0 && video.muted) {
            video.muted = false;
        }
        const newVolume = Math.max(0, Math.min(100,
             (video.volume * 100) +
             (delta > 0 ? -CONFIG.stepVolume : CONFIG.stepVolume)
        ));
        video.volume = newVolume / 100;
        showVolume(newVolume);
        return newVolume;
    }

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

    const PLATFORM_HANDLERS = {
        YOUTUBE: {
            getVideo: () => document.querySelector('video, ytd-player video') || findVideoInIframes(),
            adjustVolume: (video, delta) => {
                const ytPlayer = document.querySelector('#movie_player');
                if (!ytPlayer?.getVolume) return commonAdjustVolume(video, delta);

                const currentVol = ytPlayer.getVolume();
                const newVol = clampVolume(currentVol + (delta > 0 ? -CONFIG.stepVolume : CONFIG.stepVolume));
                ytPlayer.setVolume(newVol);
                video.volume = newVol / 100;
                showVolume(newVol);
            },
            toggleFullscreen: () => document.querySelector('.ytp-fullscreen-button')?.click(),
            specialKeys: {
                'Space': () => {},
                'Numpad7': () => document.querySelector('.ytp-prev-button')?.click(),
                'Numpad9': () => document.querySelector('.ytp-next-button')?.click()
            }
        },
        BILIBILI: {
            getVideo: () => document.querySelector('.bpx-player-video-wrap video') || findVideoInIframes(),
            adjustVolume: commonAdjustVolume,
            toggleFullscreen: () => document.querySelector('.bpx-player-ctrl-full')?.click(),
            specialKeys: {
                'Space': () => {},
                'Numpad2': () => {},
                'Numpad8': () => {},
                'Numpad4': () => {},
                'Numpad6': () => {},
                'Numpad7': () => document.querySelector('.bpx-player-ctrl-prev')?.click(),
                'Numpad9': () => document.querySelector('.bpx-player-ctrl-next')?.click()
            }
        },
        TWITCH: {
            getVideo: () => document.querySelector('.video-ref video') || findVideoInIframes(),
            adjustVolume: commonAdjustVolume,
            toggleFullscreen: () => document.querySelector('[data-a-target="player-fullscreen-button"]')?.click(),
            specialKeys: {
                'Numpad7': () => simulateKeyPress('ArrowLeft'),
                'Numpad9': () => simulateKeyPress('ArrowRight')
            }
        },
        STEAM: {
            getVideo: () => {
                const videos = Array.from(document.querySelectorAll('video'));
                const playingVideo = videos.find(v => v.offsetParent !== null && !v.paused);
                if (playingVideo) return playingVideo;
                const visibleVideo = videos.find(v => v.offsetParent !== null);
                if (visibleVideo) return visibleVideo;
                return findVideoInIframes();
            },
            adjustVolume: commonAdjustVolume,
            toggleFullscreen: (video) => {
                if (!video) return;
                const container = video.closest('.game_hover_activated') || video.parentElement;
                if (container && !document.fullscreenElement) {
                    container.requestFullscreen?.().catch(() => video.requestFullscreen?.());
                } else {
                    document.exitFullscreen?.();
                }
            }
        },
        GENERIC: {
            getVideo: () => {
                return document.querySelector('video.player') ||
                       findVideoInIframes() ||
                       document.querySelector('video, .video-player video, .video-js video, .player-container video');
            },
            adjustVolume: commonAdjustVolume,
            toggleFullscreen: (video) => toggleNativeFullscreen(video),
        }
    };

    function findVideoInIframes() {
        const iframes = document.querySelectorAll('iframe');
        for (const iframe of iframes) {
            try {
                const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
                return iframeDoc?.querySelector('video');
            } catch {}
        }
        return null;
    }

    function toggleNativeFullscreen(video) {
        if (!video) return;
        try {
            if (document.fullscreenElement) {
                document.exitFullscreen();
            } else {
                let elementToFullscreen = video;
                for (let i = 0; i < 2; i++) {
                    elementToFullscreen = elementToFullscreen.parentElement || elementToFullscreen;
                }
                elementToFullscreen.requestFullscreen?.() ||
                elementToFullscreen.webkitRequestFullscreen?.() ||
                elementToFullscreen.msRequestFullscreen?.() ||
                video.requestFullscreen?.() ||
                video.webkitRequestFullscreen?.() ||
                video.msRequestFullscreen?.();
            }
        } catch (e) {
            console.error('Fullscreen error:', e);
        }
    }

    function simulateKeyPress(key) {
        document.dispatchEvent(new KeyboardEvent('keydown', {key, bubbles: true}));
    }

    function isInputElement(target) {
        return /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable;
    }

    function adjustRate(video, changeValue) {
        const state = getVideoState(video);
        const newRate = Math.max(0.1, Math.min(16, video.playbackRate + changeValue));
        video.playbackRate = parseFloat(newRate.toFixed(1));
        state.lastCustomRate = video.playbackRate;
        state.isDefaultRate = (video.playbackRate === 1.0);
        showVolume(video.playbackRate * 100);
    }

    function togglePlaybackRate(video) {
        const state = getVideoState(video);
        if (state.isDefaultRate) {
            video.playbackRate = state.lastCustomRate;
            state.isDefaultRate = false;
        } else {
            state.lastCustomRate = video.playbackRate;
            video.playbackRate = 1.0;
            state.isDefaultRate = true;
        }
        showVolume(video.playbackRate * 100);
    }

    function showVolume(vol) {
        const display = document.getElementById('dynamic-volume-display') || createVolumeDisplay();
        display.textContent = `${Math.round(vol)}%`;
        display.style.opacity = '1';
        setTimeout(() => display.style.opacity = '0', 1000);
    }

    function createVolumeDisplay() {
        const display = document.createElement('div');
        display.id = 'dynamic-volume-display';
        Object.assign(display.style, {
            position: 'fixed',
            zIndex: 2147483647,
            top: '50%',
            left: '50%',
            transform: 'translate(-50%, -50%)',
            padding: '10px 20px',
            borderRadius: '8px',
            backgroundColor: 'rgba(0, 0, 0, 0.7)',
            color: '#fff',
            fontSize: '24px',
            fontFamily: 'Arial, sans-serif',
            opacity: '0',
            transition: 'opacity 1s',
            pointerEvents: 'none'
        });
        document.body.appendChild(display);
        return display;
    }

    function handleVideoWheel(e) {
        e.preventDefault();
        e.stopPropagation();
        const video = e.target;
        PLATFORM_HANDLERS[PLATFORM].adjustVolume(video, e.deltaY);
    }

    function handleTwitchWheel(e) {
        if (isInputElement(e.target)) return;
        const video = getVideoElement();
        if (!video) return;
        const rect = video.getBoundingClientRect();
        const inVideoArea = e.clientX >= rect.left && e.clientX <= rect.right &&
                            e.clientY >= rect.top && e.clientY <= rect.bottom;
        if (inVideoArea) {
            e.preventDefault();
            e.stopPropagation();
            PLATFORM_HANDLERS.TWITCH.adjustVolume(video, e.deltaY);
        }
    }

    function handleKeyEvent(e) {
        if (isInputElement(e.target)) return;
        const video = getVideoElement();
        const handler = PLATFORM_HANDLERS[PLATFORM];

        // 处理按键7
        if (e.code === 'Numpad7') {
            switch (CONFIG.key7Function) {
                case 1: // 长步进
                    video && (video.currentTime -= CONFIG.stepTimeLong);
                    break;
                case 2: // 浏览器返回
                    history.back();
                    break;
                case 3: // 上一个影片
                    switchToPrevVideo()?.play().catch(() => {});
                    break;
                case 4: // 平台原生功能
                    if (handler.specialKeys?.Numpad7) {
                        handler.specialKeys.Numpad7();
                    }
                    break;
            }
            e.preventDefault();
            return;
        }

        // 处理按键9
        if (e.code === 'Numpad9') {
            switch (CONFIG.key9Function) {
                case 1: // 长步进
                    video && (video.currentTime += CONFIG.stepTimeLong);
                    break;
                case 2: // 浏览器前进
                    history.forward();
                    break;
                case 3: // 下一个影片
                    switchToNextVideo()?.play().catch(() => {});
                    break;
                case 4: // 平台原生功能
                    if (handler.specialKeys?.Numpad9) {
                        handler.specialKeys.Numpad9();
                    }
                    break;
            }
            e.preventDefault();
            return;
        }

        // 处理平台特殊按键
        if (handler.specialKeys?.[e.code]) {
            handler.specialKeys[e.code]();
            e.preventDefault();
            return;
        }

        // 处理其他通用按键
        const actions = {
            'Space': () => video && video[video.paused ? 'play' : 'pause'](),
            'Numpad5': () => video && video[video.paused ? 'play' : 'pause'](),
            'NumpadEnter': () => handler.toggleFullscreen(video),
            'NumpadAdd': () => video && (video.currentTime += video.duration * 0.1),
            'NumpadSubtract': () => video && (video.currentTime -= video.duration * 0.1),
            'Numpad0': () => video && togglePlaybackRate(video),
            'Numpad1': () => video && adjustRate(video, -0.1),
            'Numpad3': () => video && adjustRate(video, 0.1),
            'Numpad8': () => video && handler.adjustVolume(video, -CONFIG.stepVolume),
            'Numpad2': () => video && handler.adjustVolume(video, CONFIG.stepVolume),
            'Numpad4': () => video && (video.currentTime -= CONFIG.stepTime),
            'Numpad6': () => video && (video.currentTime += CONFIG.stepTime)
        };

        if (actions[e.code]) {
            actions[e.code]();
            e.preventDefault();
        }
    }

    function bindVideoEvents() {
        if (PLATFORM === 'TWITCH') return;

        document.querySelectorAll('video').forEach(video => {
            if (!video.dataset.volumeBound) {
                video.addEventListener('wheel', handleVideoWheel, { passive: false });
                video.dataset.volumeBound = 'true';
            }
        });
    }

    function init() {
        bindVideoEvents();
        document.addEventListener('keydown', handleKeyEvent, true);

        if (PLATFORM === 'TWITCH') {
            document.addEventListener('wheel', handleTwitchWheel, { capture: true, passive: false });
        }

        // 初始化视频元素列表
        updateVideoElements();

        // 监听DOM变化更新视频列表
        new MutationObserver(() => {
            bindVideoEvents();
            updateVideoElements();
            if (activeVideoId && !videoElements.some(v => generateVideoId(v) === activeVideoId)) {
                activeVideoId = null;
            }
        }).observe(document.body, { childList: true, subtree: true });
    }

    if (document.readyState !== 'loading') init();
    else document.addEventListener('DOMContentLoaded', init);
})();