滾動音量Dx版

可辨識的撥放器,滾輪、013速度28音量5播放暫停enter全螢幕切換可能有。整合YouTube、B站(0123456789enter)、B站直播(局部:012358/無滾輪enter)

目前為 2025-05-18 提交的版本,檢視 最新版本

// ==UserScript==
// @name          滾動音量Dx版
// @namespace     http://tampermonkey.net/
// @version       4.3
// @description  可辨識的撥放器,滾輪、013速度28音量5播放暫停enter全螢幕切換可能有。整合YouTube、B站(0123456789enter)、B站直播(局部:012358/無滾輪enter)
// @match        *://*/*
// @exclude      *://www.facebook.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';
    const PLATFORM = {
        BILIBILI: /bilibili\.com/.test(location.hostname),
        YOUTUBE: /youtube\.com/.test(location.hostname),
        GENERIC: true
    };
    const DEBOUNCE_INTERVAL = 100;
    const FLYWHEEL_THRESHOLD = 10;
    const TRADITIONAL_THRESHOLD = 40;
    const VOLUME_STEP = 10;
    const VOLUME_DISPLAY = {
        'zh-TW': vol => `${vol}%`,
        'zh-CN': vol => `${vol}%`,
        'en': vol => `${vol}%`,
        'default': vol => `${vol}%`
    };
    const KEYCODE_MAP = {
        SPACE: 32,
        BRACKET_LEFT: 219,
        BRACKET_RIGHT: 221,
        KEY_F: 70,
        DIGIT_0: 48,
        DIGIT_1: 49,
        DIGIT_2: 50,
        DIGIT_3: 51,
        DIGIT_4: 52,
        DIGIT_5: 53,
        DIGIT_6: 54,
        DIGIT_7: 55,
        DIGIT_8: 56,
        DIGIT_9: 57,
        NUMPAD_0: 96,
        NUMPAD_1: 97,
        NUMPAD_2: 98,
        NUMPAD_3: 99,
        NUMPAD_4: 100,
        NUMPAD_5: 101,
        NUMPAD_6: 102,
        NUMPAD_7: 103,
        NUMPAD_8: 104,
        NUMPAD_9: 105,
        NUMPAD_ENTER: 13
    };

    const state = {
        volumeAccumulator: 0,
        lastVolumeChangeTime: 0,
        volumeTimeoutId: null,
        isMouseOverVideo: false,
        lastCustomRate: 1.0,
        cachedElements: {
            video: null,
            playerWrap: null,
            lastCheckTime: 0
        },
        isGlobalMatchEnabled: GM_getValue('isGlobalMatchEnabled', true),
        customMatches: JSON.parse(GM_getValue('customMatches', '[]')),
        customExcludes: JSON.parse(GM_getValue('customExcludes', '[]'))
    };

    function getVideoElement() {
        const now = Date.now();
        if (!state.cachedElements.video || now - state.cachedElements.lastCheckTime > 30000) {
            if (PLATFORM.BILIBILI) {
                state.cachedElements.video = document.querySelector('.bpx-player-video-wrap video');
            }
            if (PLATFORM.YOUTUBE) {
                state.cachedElements.video = document.querySelector('ytd-player video');
            }
            if (!state.cachedElements.video) {
                state.cachedElements.video = document.querySelector('video');
            }
            state.cachedElements.lastCheckTime = now;
        }
        return state.cachedElements.video;
    }

    function getYTPlayer() {
        return document.querySelector('ytd-player')?.getPlayer();
    }

    function createVolumeDisplay() {
        const existingDisplay = document.getElementById("dynamic-volume-display");
        if (existingDisplay) return existingDisplay;
        const display = document.createElement("div");
        display.id = "dynamic-volume-display";
        Object.assign(display.style, {
            position: 'fixed',
            zIndex: '99999',
            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 showVolume(vol) {
        clearTimeout(state.volumeTimeoutId);
        const display = createVolumeDisplay();
        const langKey = VOLUME_DISPLAY[navigator.language] ? navigator.language : 'default';
        display.textContent = VOLUME_DISPLAY[langKey](Math.round(vol));
        display.style.opacity = '1';
        state.volumeTimeoutId = setTimeout(() => {
            display.style.opacity = '0';
        }, 1000);
    }

    function adjustVolume(deltaY, isWheelEvent = true) {
        const now = Date.now();
        if (isWheelEvent && now - state.lastVolumeChangeTime < DEBOUNCE_INTERVAL) return;
        const absDelta = Math.abs(deltaY);
        let shouldTrigger = false;
        if (absDelta < TRADITIONAL_THRESHOLD) {
            state.volumeAccumulator += absDelta;
            shouldTrigger = state.volumeAccumulator >= FLYWHEEL_THRESHOLD;
        } else {
            shouldTrigger = true;
        }
        if (shouldTrigger) {
            if (PLATFORM.YOUTUBE) {
                const player = getYTPlayer();
                if (!player) return;
                let volume = player.getVolume();
                volume += (deltaY > 0 ? -VOLUME_STEP : VOLUME_STEP);
                volume = Math.max(0, Math.min(100, volume));
                player.setVolume(volume);
                showVolume(volume);
            } else {
                const video = getVideoElement();
                if (!video) return;
                let volume = video.volume * 100;
                volume += (deltaY > 0 ? -VOLUME_STEP : VOLUME_STEP);
                volume = Math.max(0, Math.min(100, volume));
                video.volume = volume / 100;
                showVolume(volume);
            }
            state.volumeAccumulator = 0;
            state.lastVolumeChangeTime = now;
        }
    }

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

    function togglePlaybackRate() {
        const video = getVideoElement();
        if (!video) return;
        if (video.playbackRate === 1) {
            video.playbackRate = state.lastCustomRate > 1 ? state.lastCustomRate : 1.3;
        } else {
            video.playbackRate = 1;
        }
        showVolume(video.playbackRate * 100);
    }

    function handleYTNavigation(key) {
        const player = getYTPlayer();
        if (!player) return;
        const video = getVideoElement();
        if (!video) return;
        const actions = {
            '4': () => video.currentTime -= 10,
            '6': () => video.currentTime += 10,
            '7': () => document.querySelector('.ytp-prev-button')?.click(),
            '9': () => document.querySelector('.ytp-next-button')?.click()
        };
        if (actions[key]) {
            actions[key]();
            return true;
        }
        return false;
    }

    function handleWheelEvent(e) {
        if (PLATFORM.YOUTUBE) {
            const playerWrap = document.querySelector('ytd-player');
            if (playerWrap && playerWrap.contains(e.target)) {
                adjustVolume(e.deltaY);
                e.preventDefault();
            }
            return;
        }
        if (PLATFORM.BILIBILI) {
            const playerWrap = document.querySelector('.bpx-player-video-wrap');
            if (playerWrap && playerWrap.contains(e.target)) {
                adjustVolume(e.deltaY);
                e.preventDefault();
            }
            return;
        }
        if (state.isMouseOverVideo && checkDomainMatch()) {
            adjustVolume(e.deltaY);
            e.preventDefault();
            e.stopPropagation();
        }
    }

    function handleMouseEnter() {
        if (!checkDomainMatch()) return;
        state.isMouseOverVideo = true;
    }

    function handleMouseLeave() {
        state.isMouseOverVideo = false;
    }

    function handleMainKeyEvent(e) {
        if (PLATFORM.YOUTUBE && ['4','6','7','9'].includes(e.key) && handleYTNavigation(e.key)) {
            e.preventDefault();
            return;
        }
        const video = getVideoElement();
        if (!video || isInputElement(e.target)) return;
        const keyActions = {
            'Space': () => video[video.paused ? 'play' : 'pause'](),
            'Numpad5': () => video[video.paused ? 'play' : 'pause'](),
            'NumpadEnter': () => simulateKeyEvent(KEYCODE_MAP.KEY_F, 'f'),
            'Digit0': () => PLATFORM.BILIBILI && (video.currentTime = 0),
            'Digit1': () => PLATFORM.BILIBILI && (video.currentTime = video.duration * 0.1),
            'Digit2': () => PLATFORM.BILIBILI && (video.currentTime = video.duration * 0.2),
            'Digit3': () => PLATFORM.BILIBILI && (video.currentTime = video.duration * 0.3),
            'Digit4': () => PLATFORM.BILIBILI && (video.currentTime = video.duration * 0.4),
            'Digit5': () => PLATFORM.BILIBILI && (video.currentTime = video.duration * 0.5),
            'Digit6': () => PLATFORM.BILIBILI && (video.currentTime = video.duration * 0.6),
            'Digit7': () => PLATFORM.BILIBILI && (video.currentTime = video.duration * 0.7),
            'Digit8': () => PLATFORM.BILIBILI && (video.currentTime = video.duration * 0.8),
            'Digit9': () => PLATFORM.BILIBILI && (video.currentTime = video.duration * 0.9),
            'Numpad7': () => PLATFORM.BILIBILI && simulateKeyEvent(KEYCODE_MAP.BRACKET_LEFT, '['),
            'Numpad9': () => PLATFORM.BILIBILI && simulateKeyEvent(KEYCODE_MAP.BRACKET_RIGHT, ']'),
            'Numpad0': () => togglePlaybackRate(),
            'Numpad1': () => adjustRate(-0.1),
            'Numpad3': () => adjustRate(0.1),
            'Numpad8': () => adjustVolume(-VOLUME_STEP, false),
            'Numpad2': () => adjustVolume(VOLUME_STEP, false)
        };
        const action = keyActions[e.code];
        if (action) {
            action();
            e.preventDefault();
            e.stopPropagation();
        }
    }

    function simulateKeyEvent(keyCode, key = '') {
        const event = new KeyboardEvent('keydown', {
            key,
            code: key,
            keyCode,
            bubbles: true,
            cancelable: true
        });
        document.dispatchEvent(event);
    }

    function isInputElement(target) {
        return /^(input|textarea|\[contenteditable\])$/i.test(target.tagName) || target.isContentEditable;
    }

    function parseDomainPattern(pattern) {
        try {
            const url = new URL(pattern.replace(/\*/g, 'wildcard'));
            return pattern.replace(/\./g, '\\.').replace(/wildcard/g, '.*');
        } catch {
            return pattern.replace(/\./g, '\\.').replace(/\*/g, '.*');
        }
    }

    function checkDomainMatch() {
        if (!state.isGlobalMatchEnabled) {
            return state.customMatches.some(match => new RegExp(parseDomainPattern(match)).test(location.href));
        }
        return !state.customExcludes.some(exclude => new RegExp(parseDomainPattern(exclude)).test(location.href));
    }

    function cleanBilibiliURL() {
        try {
            const url = new URL(location.href);
            if (/^\/video\/BV\w+/.test(url.pathname)) {
                const cleanPath = url.pathname.split('/').slice(0,3).join('/');
                history.replaceState({}, '', `${url.origin}${cleanPath}`);
            }
        } catch(e) {}
    }

    function initEventListeners() {
        document.addEventListener('wheel', handleWheelEvent, { passive: false });
        document.addEventListener('keydown', handleMainKeyEvent, true);
        const video = getVideoElement();
        if (video) {
            video.addEventListener('mouseenter', handleMouseEnter);
            video.addEventListener('mouseleave', handleMouseLeave);
        }
        if (PLATFORM.BILIBILI) {
            window.addEventListener('load', () => setTimeout(cleanBilibiliURL, 4000));
        }
        if (PLATFORM.YOUTUBE) {
            const observer = new MutationObserver(() => {
                if (document.querySelector('ytd-player')) {
                    const video = getVideoElement();
                    if (video) {
                        video.addEventListener('mouseenter', handleMouseEnter);
                        video.addEventListener('mouseleave', handleMouseLeave);
                    }
                }
            });
            observer.observe(document.body, { childList: true, subtree: true });
        }
    }

    function registerMenuCommands() {
        GM_registerMenuCommand(`切換全域匹配 (${state.isGlobalMatchEnabled ? "開啟" : "關閉"})`, () => {
            state.isGlobalMatchEnabled = !state.isGlobalMatchEnabled;
            GM_setValue('isGlobalMatchEnabled', state.isGlobalMatchEnabled);
            location.reload();
        });
        GM_registerMenuCommand("添加/移除當前網域於自訂匹配", () => {
            const pattern = `${location.protocol}//${location.hostname}/*`;
            const index = state.customMatches.indexOf(pattern);
            if (index === -1) {
                state.customMatches.push(pattern);
                GM_setValue('customMatches', JSON.stringify(state.customMatches));
            } else {
                state.customMatches.splice(index, 1);
                GM_setValue('customMatches', JSON.stringify(state.customMatches));
            }
            location.reload();
        });
        GM_registerMenuCommand("添加/移除當前網域於自訂排除", () => {
            const pattern = `${location.protocol}//${location.hostname}/*`;
            const index = state.customExcludes.indexOf(pattern);
            if (index === -1) {
                state.customExcludes.push(pattern);
                GM_setValue('customExcludes', JSON.stringify(state.customExcludes));
            } else {
                state.customExcludes.splice(index, 1);
                GM_setValue('customExcludes', JSON.stringify(state.customExcludes));
            }
            location.reload();
        });
    }

    if (document.readyState === 'complete' || document.readyState === 'interactive') {
        initEventListeners();
    } else {
        document.addEventListener('DOMContentLoaded', initEventListeners);
    }
    registerMenuCommands();
})();