Greasy Fork

Greasy Fork is available in English.

滚动音量Dx版

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

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

您需要先安装一款用户脚本管理器扩展,例如 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.3
// @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 getDomainKey = (key) => `${key}_${getDomainId()}`;

    const initStorage = () => {
        const domainKey = getDomainKey('storageInitialized');
        if (GM_getValue(domainKey) !== true) {
            GM_setValue(getDomainKey('stepTime'), 5);
            GM_setValue(getDomainKey('stepTimeLong'), 30);
            GM_setValue(getDomainKey('stepVolume'), 10);
            // 默认功能:1.长步进
            GM_setValue(getDomainKey('key7Function'), 1);
            GM_setValue(getDomainKey('key9Function'), 1);
            GM_setValue(domainKey, true);
        }
    };
    initStorage();

    // 根据域名获取配置
    const CONFIG = {
        stepTime: GM_getValue(getDomainKey('stepTime'), 5),
        stepTimeLong: GM_getValue(getDomainKey('stepTimeLong'), 30),
        stepVolume: GM_getValue(getDomainKey('stepVolume'), 10),
        key7Function: GM_getValue(getDomainKey('key7Function'), 1),
        key9Function: GM_getValue(getDomainKey('key9Function'), 1)
    };

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

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

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

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

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

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

    let cachedVideo = null;
    let lastVideoCheck = 0;
    let videoElements = [];
    let currentVideoIndex = 0;
    let activeVideoId = null;  // 新增:追踪当前控制的视频ID

    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 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);  // 更新激活视频ID
        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);  // 更新激活视频ID
        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: () => {
                // 优先选择video.player元素
                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];

        // 优先处理自定义按键功能
        if (e.code === 'Numpad7') {
            switch (CONFIG.key7Function) {
                case 1: // 长步进
                    video && (video.currentTime -= CONFIG.stepTimeLong);
                    break;
                case 2: // 浏览器返回
                    history.back();
                    break;
                case 3: { // 上/下一个影片
                    const prevVideo = switchToPrevVideo();
                    if (prevVideo) prevVideo.play().catch(() => {});
                    break;
                }
            }
            e.preventDefault();
            return;
        }

        if (e.code === 'Numpad9') {
            switch (CONFIG.key9Function) {
                case 1: // 长步进
                    video && (video.currentTime += CONFIG.stepTimeLong);
                    break;
                case 2: // 浏览器前进
                    history.forward();
                    break;
                case 3: { // 上/下一个影片
                    const nextVideo = switchToNextVideo();
                    if (nextVideo) nextVideo.play().catch(() => {});
                    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);
})();