// ==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();
})();