// ==UserScript==
// @name 滾動音量Dx版 Scroll Volume Dx Edition
// @name:zh-CN 滚动音量Dx版
// @name:en Scroll Volume Dx Edition
// @namespace http://tampermonkey.net/
// @version 9.4
// @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 initStorage = () => {
if (GM_getValue('storageInitialized') !== true) {
GM_setValue('stepTime', 5);
GM_setValue('stepTimeLong', 30);
GM_setValue('stepVolume', 10);
GM_setValue('storageInitialized', true);
}
};
initStorage();
const CONFIG = {
stepTime: GM_getValue('stepTime', 5),
stepTimeLong: GM_getValue('stepTimeLong', 30),
stepVolume: GM_getValue('stepVolume', 10)
};
GM_registerMenuCommand("⚙️ 設定步進", () => {
const newVal = prompt("設定快進/快退", CONFIG.stepTime);
if (newVal && !isNaN(newVal)) {
GM_setValue('stepTime', parseFloat(newVal));
CONFIG.stepTime = parseFloat(newVal);
}
});
GM_registerMenuCommand("⏱️ 設定長步進", () => {
const newVal = prompt("設定長跳轉", CONFIG.stepTimeLong);
if (newVal && !isNaN(newVal)) {
GM_setValue('stepTimeLong', parseFloat(newVal));
CONFIG.stepTimeLong = parseFloat(newVal);
}
});
GM_registerMenuCommand("🔊 設定音量步進", () => {
const newVal = prompt("設定音量幅度 (%)", CONFIG.stepVolume);
if (newVal && !isNaN(newVal)) {
GM_setValue('stepVolume', parseFloat(newVal));
CONFIG.stepVolume = parseFloat(newVal);
}
});
let cachedVideo = null;
let lastVideoCheck = 0;
const PLATFORM = (() => {
const host = location.hostname;
if (/youtube\.com|youtu\.be/.test(host)) return "YOUTUBE";
if (/www.bilibili\.com/.test(host)) return "BILIBILI"; //live.bilibili對應GENERIC
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);
}
function getVideoElement() {
if (cachedVideo && document.contains(cachedVideo)) {
return cachedVideo;
}
const handler = PLATFORM_HANDLERS[PLATFORM] || PLATFORM_HANDLERS.GENERIC;
cachedVideo = handler.getVideo();
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;
}
const PLATFORM_HANDLERS = {
YOUTUBE: {
getVideo: () => document.querySelector('video, ytd-player video') || findVideoInIframes(),
adjustVolume: (video, delta) => {
if (delta < 0 && video.muted) {
video.muted = false;
const muteButton = document.querySelector('.ytp-mute-button');
if (muteButton && muteButton.getAttribute('aria-label')?.includes('取消靜音')) {
muteButton.click();
}
}
const newVolume = Math.max(0, Math.min(100,
(video.volume * 100) +
(delta > 0 ? -CONFIG.stepVolume : CONFIG.stepVolume)
));
video.volume = newVolume / 100;
const ytPlayer = document.querySelector('#movie_player');
if (ytPlayer && ytPlayer.setVolume) {
ytPlayer.setVolume(newVolume);
}
showVolume(newVolume);
return newVolume;
},
toggleFullscreen: () => document.querySelector('.ytp-fullscreen-button')?.click(),
specialKeys: {
'Space': () => {}, //代表使用YT默認
'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': () => {}, //空值代表使用bilibili默認
'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: () => Array.from(document.querySelectorAll('video')).find(v => v.offsetParent !== null) || 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: () => {
const iframeVideo = findVideoInIframes();
if (iframeVideo) return iframeVideo;
return 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++) {
if (elementToFullscreen.parentElement) {
elementToFullscreen = elementToFullscreen.parentElement;
} else {
break;
}
}
if (elementToFullscreen.requestFullscreen) {
elementToFullscreen.requestFullscreen();
} else if (elementToFullscreen.webkitRequestFullscreen) {
elementToFullscreen.webkitRequestFullscreen();
} else if (elementToFullscreen.msRequestFullscreen) {
elementToFullscreen.msRequestFullscreen();
} else {
if (video.requestFullscreen) {
video.requestFullscreen();
} else if (video.webkitRequestFullscreen) {
video.webkitRequestFullscreen();
} else if (video.msRequestFullscreen) {
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();
e.stopImmediatePropagation();
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();
if (!video) return;
const handler = PLATFORM_HANDLERS[PLATFORM];
if (handler.specialKeys?.[e.code]) {
handler.specialKeys[e.code]();
e.preventDefault();
return;
}
const actions = {
'Space': () => video[video.paused ? 'play' : 'pause'](),
'Numpad5': () => video[video.paused ? 'play' : 'pause'](),
'NumpadEnter': () => handler.toggleFullscreen(video),
'NumpadAdd': () => video.currentTime += video.duration * 0.1,
'NumpadSubtract': () => video.currentTime -= video.duration * 0.1,
'Numpad0': () => togglePlaybackRate(video),
'Numpad1': () => adjustRate(video, -0.1),
'Numpad3': () => adjustRate(video, 0.1),
'Numpad8': () => handler.adjustVolume(video, -CONFIG.stepVolume),
'Numpad2': () => handler.adjustVolume(video, CONFIG.stepVolume),
'Numpad4': () => video.currentTime -= CONFIG.stepTime,
'Numpad6': () => video.currentTime += CONFIG.stepTime,
'Numpad7': () => video.currentTime -= CONFIG.stepTimeLong,
'Numpad9': () => video.currentTime += CONFIG.stepTimeLong
};
if (actions[e.code]) {
actions[e.code]();
e.preventDefault();
}
}
function bindVideoEvents() {
if (PLATFORM === 'TWITCH') return;
const videos = document.querySelectorAll('video');
videos.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 });
}
const observer = new MutationObserver(bindVideoEvents);
observer.observe(document.body, { childList: true, subtree: true });
}
if (document.readyState !== 'loading') init();
else document.addEventListener('DOMContentLoaded', init);
})();