// ==UserScript==
// @name 滾動音量Dx版 Scroll Volume Dx Edition
// @name:zh-CN 滚动音量Dx版
// @name:en Scroll Volume Dx Edition
// @namespace http://tampermonkey.net/
// @version 9.6.1
// @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 Added custom modifier key for fine volume adjustment. 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 LANG = /^zh-(cn|tw|hk|mo|sg)/i.test(navigator.language) ? 'zh' : 'en';
const i18n = {
zh: {
menuStep: '⚙️ 設定步進',
menuLongStep: '⏱️ 設定長步進',
menuVolumeStep: '🔊 設定音量步進',
menuModifier: '🎚️ 設定修飾鍵微調',
menuKeyFunc: '🎛️ 設定按鍵7/9功能',
promptStep: '設定快進/快退 (秒)',
promptLongStep: '設定長跳轉 (秒)',
promptVolume: '設定音量幅度 (%)',
modifierOptions: {
1: '1. Alt 鍵',
2: '2. Ctrl 鍵',
3: '3. Shift 鍵',
4: '4. Meta 鍵 (⌘)',
5: '5. 關閉此功能'
},
keyFuncOptions: {
1: '1. 長步進',
2: '2. 上一頁/下一頁',
3: '3. 上/下一個影片',
4: '4. 平台原生功能'
},
saveAlert: '設定已保存,需重新整理頁面後生效'
},
en: {
menuStep: '⚙️ Set Step',
menuLongStep: '⏱️ Set Long Step',
menuVolumeStep: '🔊 Set Volume Step',
menuModifier: '🎚️ Set Modifier Key',
menuKeyFunc: '🎛️ Set Key 7/9 Function',
promptStep: 'Set step time (seconds)',
promptLongStep: 'Set long jump time (seconds)',
promptVolume: 'Set volume step (%)',
modifierOptions: {
1: '1. Alt key',
2: '2. Ctrl key',
3: '3. Shift key',
4: '4. Meta key (⌘)',
5: '5. Disable feature'
},
keyFuncOptions: {
1: '1. Long step',
2: '2. Browser navigation',
3: '3. Previous/Next video',
4: '4. Platform native'
},
saveAlert: 'Settings saved. Refresh page to apply'
}
};
// 配置菜单本地化
const registerMenuCommands = () => {
const t = i18n[LANG];
GM_registerMenuCommand(t.menuStep, () => handleConfigPrompt(t.promptStep, 'stepTime'));
GM_registerMenuCommand(t.menuLongStep, () => handleConfigPrompt(t.promptLongStep, 'stepTimeLong'));
GM_registerMenuCommand(t.menuVolumeStep, () => handleConfigPrompt(t.promptVolume, 'stepVolume'));
GM_registerMenuCommand(t.menuModifier, handleModifierSetting);
GM_registerMenuCommand(t.menuKeyFunc, handleKeyFunctionSetting);
};
const handleConfigPrompt = (promptText, configKey) => {
const newVal = prompt(promptText, CONFIG[configKey]);
if (newVal && !isNaN(newVal)) {
CONFIG[configKey] = parseFloat(newVal);
saveConfig(CONFIG);
}
};
const handleModifierSetting = () => {
const t = i18n[LANG];
const options = t.modifierOptions;
const choice = prompt(
`${LANG === 'zh' ? '選擇音量微調修飾鍵:' : 'Select modifier key:'}\n${Object.values(options).join('\n')}`,
CONFIG.modifierKey
);
if (choice && options[choice]) {
CONFIG.modifierKey = parseInt(choice);
saveConfig(CONFIG);
alert(t.saveAlert);
}
};
const handleKeyFunctionSetting = () => {
const t = i18n[LANG];
const baseOptions = {...t.keyFuncOptions};
if (!['YOUTUBE', 'BILIBILI'].includes(PLATFORM)) delete baseOptions[4];
const getChoice = (msgKey, currentVal) => {
const message = `${LANG === 'zh' ? '選擇按鍵功能:' : 'Select key function:'}\n${Object.values(baseOptions).join('\n')}`;
return prompt(message, currentVal);
};
const choice7 = getChoice('key7', CONFIG.key7Function);
if (choice7 && baseOptions[choice7]) CONFIG.key7Function = parseInt(choice7);
const choice9 = getChoice('key9', CONFIG.key9Function);
if (choice9 && baseOptions[choice9]) CONFIG.key9Function = parseInt(choice9);
saveConfig(CONFIG);
};
// 获取标准化的域名标识 (简化为二级域名)
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,
modifierKey: 5, // 新增:1=Alt 2=Ctrl 3=Shift 4=Meta 5=None
fineVolumeStep: 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;
})();
registerMenuCommands();
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) {
const isFineAdjust = Math.abs(delta) === CONFIG.fineVolumeStep;
const actualDelta = isFineAdjust ? delta : (delta > 0 ? CONFIG.stepVolume : -CONFIG.stepVolume); // 修正符號
const newVolume = clampVolume((video.volume * 100) + actualDelta);
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) {
const currentVol = ytPlayer.getVolume();
const newVol = clampVolume(currentVol + delta); // 直接應用delta
ytPlayer.setVolume(newVol);
video.volume = newVol / 100;
showVolume(newVol);
} else {
commonAdjustVolume(video, delta); // 後備通用邏輯
}
},
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?.();
}
},
handleWheel: function(e) {
if (isInputElement(e.target)) return;
const video = this.getVideo();
if (!video) return;
const rect = video.getBoundingClientRect();
const inVideoArea =
e.clientX >= rect.left - 50 && e.clientX <= rect.right + 50 &&
e.clientY >= rect.top - 30 && e.clientY <= rect.bottom + 30;
if (inVideoArea) {
e.preventDefault();
e.stopPropagation();
const delta = -Math.sign(e.deltaY);
this.adjustVolume(video, delta * CONFIG.stepVolume);
showVolume(video.volume * 100);
}
}
},
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;
const normalizedDelta = -Math.sign(e.deltaY); // 滾輪向上=+1,向下=-1
PLATFORM_HANDLERS[PLATFORM].adjustVolume(video, normalizedDelta * CONFIG.stepVolume);
}
function handleTwitchWheel(e) {
if (isInputElement(e.target)) return;
const video = getVideoElement();
if (!video) return;
const rect = video.getBoundingClientRect();
const inVideoArea =
e.clientX >= rect.left - 50 && e.clientX <= rect.right + 50 &&
e.clientY >= rect.top - 30 && e.clientY <= rect.bottom + 30;
if (inVideoArea) {
e.preventDefault();
e.stopPropagation();
const delta = -Math.sign(e.deltaY);
const volumeChange = delta * CONFIG.stepVolume;
PLATFORM_HANDLERS.TWITCH.adjustVolume(video, volumeChange);
showVolume(video.volume * 100);
}
}
function handleKeyEvent(e) {
if (isInputElement(e.target)) return;
const video = getVideoElement();
const handler = PLATFORM_HANDLERS[PLATFORM];
// 強化修飾鍵檢測邏輯
const isCustomModifier = (() => {
if (CONFIG.modifierKey === 5) return false;
const requiredModifier = {
1: 'altKey',
2: 'ctrlKey',
3: 'shiftKey',
4: 'metaKey'
}[CONFIG.modifierKey];
// 嚴格檢測:僅允許單一修飾鍵且無其他按鍵組合
const otherModifiers = ['altKey','ctrlKey','shiftKey','metaKey']
.filter(k => k !== requiredModifier)
.some(k => e[k]);
return e[requiredModifier] && !otherModifiers;
})();
// ==== 修正點2:非自定義修飾鍵穿透處理 ====
const hasOtherModifiers = e.altKey || e.ctrlKey || e.shiftKey || e.metaKey;
if (!isCustomModifier && hasOtherModifiers) {
return; // 允許瀏覽器處理其他修飾鍵組合
}
// ==== 修正點3:微調音量步進值應用 ====
if (isCustomModifier) {
const volumeActions = {
'Numpad8': () => handler.adjustVolume(video, CONFIG.fineVolumeStep),
'Numpad2': () => handler.adjustVolume(video, -CONFIG.fineVolumeStep)
};
if (volumeActions[e.code]) {
volumeActions[e.code]();
e.preventDefault();
return;
}
return;
}
// 处理按键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' || PLATFORM === 'STEAM') 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 === 'STEAM') {
document.addEventListener('wheel',
PLATFORM_HANDLERS.STEAM.handleWheel.bind(PLATFORM_HANDLERS.STEAM),
{ capture: true, passive: false }
);
}
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);
})();