在视频区域使用滚轮调节音量。支持:YouTube、B站、Steam、Twitch
当前为
// ==UserScript==
// @name DX_视频区滚轮调音
// @namespace http://tampermonkey.net/
// @version 3.1.2
// @description 在视频区域使用滚轮调节音量。支持:YouTube、B站、Steam、Twitch
// @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
// @noframes
// ==/UserScript==
(function() {
'use strict';
// ================================
// 配置和常量
// ================================
const CONFIG = {
stepVolume: GM_getValue('VideoWheelConfig_stepVolume', 10)
};
const saveConfig = () => {
GM_setValue('VideoWheelConfig_stepVolume', CONFIG.stepVolume);
};
const CONSTANTS = {
CACHE_TIMEOUT: 300,
WHEEL_THROTTLE: 50,
VOLUME_DISPLAY_TIMEOUT: 1000,
OBSERVER_DEBOUNCE: 100,
VIDEO_AREA_PADDING_X: 50,
VIDEO_AREA_PADDING_Y: 30
};
// ================================
// 平台检测
// ================================
const PLATFORM = (() => {
const host = location.hostname;
if (/youtube\.com|youtu\.be/.test(host)) return "YOUTUBE";
if (/bilibili\.com/.test(host)) return "BILIBILI";
if (/twitch\.tv/.test(host)) return "TWITCH";
if (/store\.steampowered\.com/.test(host)) return "STEAM";
return "GENERIC";
})();
// ================================
// 工具函数
// ================================
const utils = {
isInteractiveElement(element) {
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(element.tagName) ||
element.isContentEditable;
},
isElementVisible(element) {
return element &&
document.contains(element) &&
element.readyState > 0 &&
element.offsetParent !== null;
},
safeQueryIframeContent(iframe, callback) {
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
return iframeDoc ? callback(iframeDoc) : null;
} catch (e) {
return null;
}
},
clampVolume(vol) {
return Math.max(0, Math.min(100, Math.round(vol * 100) / 100));
},
// 修复:安全的视频查找函数
findVideoBySelectors(selectors) {
for (const selector of selectors) {
const element = document.querySelector(selector);
if (element && this.isElementVisible(element)) {
return element;
}
}
return null;
}
};
// ================================
// 视频检测器(修复版)
// ================================
const videoDetector = {
cachedVideo: null,
lastVideoCheck: 0,
findVideoInIframes() {
const iframes = document.querySelectorAll('iframe');
for (const iframe of iframes) {
const video = utils.safeQueryIframeContent(iframe, (iframeDoc) => {
const videos = iframeDoc.querySelectorAll('video');
return Array.from(videos).find(v => utils.isElementVisible(v));
});
if (video) return video;
}
return null;
},
// 修复:统一的视频检测逻辑
getVideoElement() {
const now = Date.now();
// 使用缓存优化性能
if (this.cachedVideo &&
utils.isElementVisible(this.cachedVideo) &&
(now - this.lastVideoCheck < CONSTANTS.CACHE_TIMEOUT)) {
return this.cachedVideo;
}
let video = null;
// 平台专用选择器
const platformSelectors = {
'YOUTUBE': ['video', 'ytd-player video'],
'BILIBILI': ['.bpx-player-video-wrap video', '.bilibili-player video'],
'TWITCH': ['.video-ref video', '.twitch-video video'],
'STEAM': ['video'],
'GENERIC': ['video.player', 'video', '.video-player video', '.video-js video', '.player-container video']
};
const selectors = platformSelectors[PLATFORM] || platformSelectors.GENERIC;
video = utils.findVideoBySelectors(selectors);
// 如果没找到,尝试在iframe中查找
if (!video) {
video = this.findVideoInIframes();
}
// 修复:Steam平台特殊逻辑 - 优先选择正在播放的视频
if (PLATFORM === 'STEAM' && video) {
const allVideos = Array.from(document.querySelectorAll('video'));
const playingVideo = allVideos.find(v => utils.isElementVisible(v) && !v.paused);
if (playingVideo) {
video = playingVideo;
}
}
this.cachedVideo = video;
this.lastVideoCheck = now;
return video;
},
clearCache() {
this.cachedVideo = null;
this.lastVideoCheck = 0;
}
};
// ================================
// 音量显示(修复版)
// ================================
const volumeDisplay = {
element: null,
timeoutId: null,
show(volume) {
if (!this.element) {
this.create();
}
this.updatePosition();
this.element.textContent = `${Math.round(volume)}%`;
this.element.style.opacity = '1';
this.scheduleHide();
},
create() {
this.element = document.createElement('div');
this.element.id = 'video-wheel-volume-display';
Object.assign(this.element.style, {
position: 'fixed',
zIndex: 2147483647,
minWidth: '90px',
height: '50px',
lineHeight: '50px',
textAlign: 'center',
borderRadius: '4px',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
color: '#fff',
fontSize: '24px',
fontFamily: 'Arial, sans-serif',
opacity: '0',
transition: 'opacity 0.3s',
pointerEvents: 'none'
});
document.body.appendChild(this.element);
},
// 修复:fixed定位不需要scroll偏移
updatePosition() {
const video = videoDetector.getVideoElement();
if (video && this.element) {
const rect = video.getBoundingClientRect();
// fixed定位直接使用视口坐标,不需要加scroll偏移
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
this.element.style.left = centerX + 'px';
this.element.style.top = centerY + 'px';
this.element.style.transform = 'translate(-50%, -50%)';
} else {
// 备用位置:屏幕中央
this.element.style.left = '50%';
this.element.style.top = '50%';
this.element.style.transform = 'translate(-50%, -50%)';
}
},
scheduleHide() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
this.timeoutId = setTimeout(() => {
if (this.element) {
this.element.style.opacity = '0';
}
}, CONSTANTS.VOLUME_DISPLAY_TIMEOUT);
},
cleanup() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
}
};
// ================================
// 音量控制
// ================================
const volumeControl = {
commonAdjustVolume(video, delta) {
const newVolume = utils.clampVolume((video.volume * 100) + delta);
video.volume = newVolume / 100;
volumeDisplay.show(newVolume);
return newVolume;
},
youtubeAdjustVolume(video, delta) {
try {
// 修复:更精确的YouTube播放器检测
const ytPlayer = document.querySelector('#movie_player') ||
document.querySelector('.html5-video-player');
if (ytPlayer && typeof ytPlayer.getVolume === 'function' &&
typeof ytPlayer.setVolume === 'function') {
const currentVol = ytPlayer.getVolume();
const newVol = utils.clampVolume(currentVol + delta);
ytPlayer.setVolume(newVol);
video.volume = newVol / 100;
volumeDisplay.show(newVol);
return newVol;
}
} catch (error) {
console.warn('YouTube播放器API调用失败,使用通用音量控制:', error);
}
return this.commonAdjustVolume(video, delta);
},
adjustVolume(video, delta) {
if (!video) return;
if (PLATFORM === 'YOUTUBE') {
return this.youtubeAdjustVolume(video, delta);
} else {
return this.commonAdjustVolume(video, delta);
}
}
};
// ================================
// 事件处理(修复版)
// ================================
const eventHandler = {
lastWheelTime: 0,
isProcessing: false,
isInVideoArea(e) {
const video = videoDetector.getVideoElement();
if (!video) return false;
const rect = video.getBoundingClientRect();
return e.clientX >= rect.left - CONSTANTS.VIDEO_AREA_PADDING_X &&
e.clientX <= rect.right + CONSTANTS.VIDEO_AREA_PADDING_X &&
e.clientY >= rect.top - CONSTANTS.VIDEO_AREA_PADDING_Y &&
e.clientY <= rect.bottom + CONSTANTS.VIDEO_AREA_PADDING_Y;
},
handleWheelEvent(e) {
// 防止重复处理
if (this.isProcessing) return;
if (utils.isInteractiveElement(e.target)) {
return;
}
const now = Date.now();
// 修复:节流期间不阻止事件,让页面正常滚动
if (now - this.lastWheelTime < CONSTANTS.WHEEL_THROTTLE) {
return;
}
if (!this.isInVideoArea(e)) {
return;
}
const video = videoDetector.getVideoElement();
if (!video) return;
this.isProcessing = true;
try {
// 彻底阻止事件传播
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
this.lastWheelTime = now;
const delta = -Math.sign(e.deltaY) * CONFIG.stepVolume;
volumeControl.adjustVolume(video, delta);
} finally {
this.isProcessing = false;
}
},
cleanup() {
this.lastWheelTime = 0;
this.isProcessing = false;
}
};
// ================================
// 菜单命令
// ================================
const registerMenuCommands = () => {
GM_registerMenuCommand('🔊 设置音量步进', () => {
const newVal = prompt('设置音量步进 (1-100)', CONFIG.stepVolume);
if (newVal !== null) {
const num = parseFloat(newVal);
if (!isNaN(num) && num >= 1 && num <= 100) {
CONFIG.stepVolume = num;
saveConfig();
alert('设置已保存,需刷新页面后生效');
} else {
alert('请输入1-100之间的数字');
}
}
});
};
// ================================
// 初始化和清理
// ================================
let observer = null;
let observerTimeout = null;
let isInitialized = false;
const init = () => {
if (isInitialized) return;
isInitialized = true;
registerMenuCommands();
// 事件绑定
const eventOptions = { capture: true, passive: false };
document.addEventListener('wheel', eventHandler.handleWheelEvent.bind(eventHandler), eventOptions);
// Steam平台额外绑定
if (PLATFORM === 'STEAM') {
window.addEventListener('wheel', eventHandler.handleWheelEvent.bind(eventHandler), eventOptions);
}
// 设置DOM观察器
observer = new MutationObserver(() => {
clearTimeout(observerTimeout);
observerTimeout = setTimeout(() => {
videoDetector.clearCache();
}, CONSTANTS.OBSERVER_DEBOUNCE);
});
observer.observe(document.body, {
childList: true,
subtree: true
});
};
const cleanup = () => {
if (observerTimeout) {
clearTimeout(observerTimeout);
observerTimeout = null;
}
if (observer) {
observer.disconnect();
observer = null;
}
volumeDisplay.cleanup();
eventHandler.cleanup();
isInitialized = false;
};
// 页面隐藏时清理缓存
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
videoDetector.clearCache();
}
});
window.addEventListener('beforeunload', cleanup);
// ================================
// 启动
// ================================
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();