// ==UserScript==
// @name 滾動音量Dx版 Scroll Volume Dx Edition
// @name:zh-CN 滚动音量Dx版
// @name:en Scroll Volume Dx Edition
// @namespace http://tampermonkey.net/
// @version 7.7
// @description 滾輪、013速度28音量5(空白鍵)播放暫停、enter全螢幕切換、小鍵盤+-增減10%進度。完整支援:YouTube、B站、Steam。B站直播(局部)
// @description:zh-CN 滚轮、013速度28音量5(空白键)播放暂停、enter全萤幕切换、小键盘+-增减10%进度。完整支援:YouTube、B站、Steam。B站直播(局部)
// @description:en wheel scroll for volume. NumpadKey:013 for speed, 28 for volume, 5(space) for play/pause, enter for fullscreen. 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/*
// @exclude *://www.facebook.com/*
// @grant GM_setValue
// @grant GM_getValue
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const VOLUME_STORAGE_PREFIX = 'volume_';
const PLATFORMS = {
YOUTUBE: isYouTubeMain(),
BILIBILI: isBilibili(),
TWITCH: isTwitch(),
STEAM: isSteam(),
FULLSCREEN_API_SUPPORT: !!document.fullscreenEnabled
};
function isBilibili() {
return /bilibili\.com/.test(location.hostname);
}
function isYouTubeMain() {
return /youtube\.com|youtu\.be/.test(location.hostname);
}
function isTwitch() {
return /twitch\.tv/.test(location.hostname);
}
function isSteam() {
return /steam(community|powered)\.com/.test(location.hostname);
}
function getYTPlayer() {
return document.querySelector('ytd-player')?.getPlayer?.();
}
let cachedVideoElement = null;
function getVideoElement() {
if (!cachedVideoElement) {
const handler = getCurrentPlatformHandler();
cachedVideoElement = handler.getVideo();
}
return cachedVideoElement;
}
function getDomainKey() {
return VOLUME_STORAGE_PREFIX + window.location.hostname;
}
const adjustVolumeHandler = (video, delta) => {
const volumeValue = PLATFORMS.YOUTUBE ? getYTPlayer()?.getVolume() : video.volume * 100;
if (volumeValue === undefined) return;
const newVolume = Math.max(0, Math.min(100, volumeValue + (delta > 0 ? -10 : 10)));
PLATFORMS.YOUTUBE ? getYTPlayer()?.setVolume(newVolume) : (video.volume = newVolume / 100);
showVolume(newVolume);
GM_setValue(getDomainKey(), newVolume);
return newVolume;
};
function initVolumeMemory(video, retryCount = 0) {
if (!video || retryCount > 3) return;
const savedVolume = GM_getValue(getDomainKey());
if (savedVolume === undefined) return;
const applyVolume = () => {
try {
if (PLATFORMS.YOUTUBE) {
const player = getYTPlayer();
if (player?.setVolume) {
player.setVolume(savedVolume);
player.unMute();
} else {
video.volume = savedVolume / 100;
}
} else {
video.volume = savedVolume / 100;
video.muted = false;
}
} catch (e) {
console.warn('Volume restore failed:', e);
}
};
if (video.readyState < 2) {
setTimeout(() => initVolumeMemory(video, retryCount + 1), 500);
} else {
applyVolume();
}
}
const PLATFORM_HANDLERS = {
YOUTUBE: {
getVideo: () => {
const iframeVideo = findVideoInIframes();
return iframeVideo || document.querySelector('window.ytplayer,html5-video-player.ytp-player,ytd-player video');
},
adjustVolume: adjustVolumeHandler,
toggleFullscreen: () => {
const video = getVideoElement();
// 統一使用YouTube原生控制
if (document.querySelector('.ytp-fullscreen-button')) {
simulateKeyPress('f', 70);
} else {
toggleNativeFullscreen(video);
}
},
specialKeys: {
'7': () => {
const player = getYTPlayer();
if (player?.getPlayerState() === 1) {
document.querySelector('.ytp-prev-button')?.click();
}
},
'9': () => {
const player = getYTPlayer();
if (player?.getPlayerState() === 1) {
document.querySelector('.ytp-next-button')?.click();
}
},
'NumpadEnter': () => simulateKeyPress('f', 70) // 將YT的numpadenter功能移到specialKeys
}
},
BILIBILI: {
getVideo: () => {
const iframeVideo = findVideoInIframes();
return iframeVideo || document.querySelector('.bpx-player-video-wrap video');
},
adjustVolume: adjustVolumeHandler,
toggleFullscreen: (video) => {
const fullscreenBtn = document.querySelector('.bpx-player-ctrl-full');
if (fullscreenBtn) {
fullscreenBtn.click();
} else {
toggleNativeFullscreen(video);
}
},
specialKeys: {
'7': () => {
const prevBtn = document.querySelector('.bpx-player-ctrl-eplist-prev');
if (prevBtn) prevBtn.click();
},
'9': () => {
const nextBtn = document.querySelector('.bpx-player-ctrl-eplist-next');
if (nextBtn) nextBtn.click();
}
}
},
TWITCH: {
getVideo: () => {
const iframeVideo = findVideoInIframes();
return iframeVideo || document.querySelector('.video-ref video');
},
adjustVolume: adjustVolumeHandler,
toggleFullscreen: (video) => {
const fullscreenBtn = document.querySelector('[data-a-target="player-fullscreen-button"]');
if (fullscreenBtn) {
fullscreenBtn.click();
} else {
toggleNativeFullscreen(video);
}
},
specialKeys: {
'7': () => simulateKeyPress('ArrowLeft', 37),
'9': () => simulateKeyPress('ArrowRight', 39)
}
},
STEAM: {
getVideo: () => {
const iframeVideo = findVideoInIframes();
return iframeVideo || document.querySelector('video');
},
adjustVolume: adjustVolumeHandler,
toggleFullscreen: (video) => {
document.fullscreenElement
? document.exitFullscreen()
: video.requestFullscreen?.();
},
specialKeys: {
'7': () => {
const video = getVideoElement();
if (video) video.currentTime -= 30;
},
'9': () => {
const video = getVideoElement();
if (video) video.currentTime += 30;
}
}
},
GENERIC: {
getVideo: () => {
const iframeVideo = findVideoInIframes();
if (iframeVideo) return iframeVideo;
const selectors = [
'window.ytplayer',
'html5-video-player.ytp-player',
'div.html5-video-player',
'video',
'.video-player video',
'.video-js video',
'.html5-video-container video',
'.player-container video',
'.media-container video',
'div.ytp-cued-thumbnail-overlay',
'.video-container video'
];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el?.tagName === 'VIDEO') return el;
}
return null;
},
adjustVolume: adjustVolumeHandler,
toggleFullscreen: (video) => toggleNativeFullscreen(video),
specialKeys: {
'7': () => {
const video = getVideoElement();
if (video) video.currentTime -= 10;
},
'9': () => {
const video = getVideoElement();
if (video) video.currentTime += 10;
},
'NumpadEnter': (video) => toggleNativeFullscreen(video) // 通用numpadenter功能
}
}
};
function findVideoInIframes() {
const iframes = document.querySelectorAll('iframe');
for (const iframe of iframes) {
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
const video = iframeDoc?.querySelector('video');
if (video) return video;
} catch (e) {
console.log('Cannot access iframe content:', e);
}
}
return null;
}
function setupMutationObserver() {
const handleIframeLoad = (iframe) => {
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
const iframeVideo = iframeDoc?.querySelector('video');
if (iframeVideo) {
cachedVideoElement = iframeVideo;
initVideoHandlers(iframeVideo);
}
} catch (e) {
console.log('Cannot access iframe content:', e);
}
};
const observer = new MutationObserver((mutations) => {
const processedIframes = new Set();
for (const mutation of mutations) {
if (mutation.type !== 'childList') continue;
for (const node of mutation.addedNodes) {
if (node.nodeName === 'VIDEO') {
cachedVideoElement = node;
initVideoHandlers(node);
continue;
}
if (node.querySelector) {
const video = node.querySelector('video');
if (video) {
cachedVideoElement = video;
initVideoHandlers(video);
}
const iframes = node.querySelectorAll('iframe:not([data-volume-observed])');
iframes.forEach(iframe => {
if (processedIframes.has(iframe)) return;
iframe.dataset.volumeObserved = 'true';
processedIframes.add(iframe);
if (iframe.contentDocument) {
handleIframeLoad(iframe);
}
iframe.addEventListener('load', () => handleIframeLoad(iframe));
});
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
function initVideoHandlers(video) {
if (!video) return;
initVolumeMemory(video);
video.addEventListener('play', () => {
state.hasPlayed = true;
});
}
function toggleNativeFullscreen(video) {
if (!video) return;
try {
if (document.fullscreenElement) {
document.exitFullscreen();
} 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);
}
}
const state = {
volumeAccumulator: 0,
lastCustomRate: 1.0,
hasPlayed: false,
matchedContainer: null
};
function simulateKeyPress(key, keyCode) {
document.dispatchEvent(new KeyboardEvent('keydown', {key, keyCode, bubbles: true}));
}
function isInputElement(target) {
return /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) ||
target.isContentEditable;
}
function getCurrentPlatformHandler() {
const platform = Object.keys(PLATFORMS).find(k => PLATFORMS[k]);
return PLATFORM_HANDLERS[platform] || PLATFORM_HANDLERS.GENERIC;
}
function adjustRate(video, changeValue) {
const newRate = Math.max(0.1, Math.min(16,
video.playbackRate + changeValue
));
video.playbackRate = parseFloat(newRate.toFixed(1));
state.lastCustomRate = newRate;
showVolume(newRate * 100);
}
function togglePlaybackRate(video) {
const currentRate = video.playbackRate;
const newRate = currentRate === 1 ? state.lastCustomRate : 1;
video.playbackRate = newRate;
if (newRate !== 1) {
state.lastCustomRate = currentRate;
}
showVolume(newRate * 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 handleWheel(e) {
const video = getVideoElement();
if (!video || !isMouseOverVideo(e)) return;
e.preventDefault();
const handler = getCurrentPlatformHandler();
handler.adjustVolume(video, e.deltaY); // 使用平台專用的adjustVolume處理
}
function isMouseOverVideo(e) {
const video = getVideoElement();
if (!video) return false;
const rect = video.getBoundingClientRect();
return e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom;
}
function handleKeyEvent(e) {
const video = getVideoElement();
if (!video || isInputElement(e.target)) return;
const handler = getCurrentPlatformHandler();
if (handler.specialKeys && handler.specialKeys[e.key]) {
handler.specialKeys[e.key](video);
e.preventDefault();
return;
}
const actions = {
'Space': () => video[video.paused ? 'play' : 'pause'](),
'Numpad5': () => video[video.paused ? 'play' : 'pause'](),
'NumpadEnter': () => { // 保留numpadenter的預設功能
const handler = getCurrentPlatformHandler();
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': () => adjustVolumeHandler(video, -10),
'Numpad2': () => adjustVolumeHandler(video, 10),
'Numpad4': () => { video.currentTime -= 10; },
'Numpad6': () => { video.currentTime += 10; }
};
const action = actions[e.code] || (['+','-'].includes(e.key) && actions[`Numpad${e.key}`]);
if (action) {
action();
e.preventDefault();
}
}
function init() {
document.addEventListener('wheel', handleWheel, { passive: false });
document.addEventListener('keydown', handleKeyEvent, true);
const video = getVideoElement();
if (video) {
initVideoHandlers(video);
}
setupMutationObserver();
document.querySelectorAll('iframe').forEach(iframe => {
if (iframe.dataset.volumeObserved) return;
iframe.dataset.volumeObserved = 'true';
iframe.addEventListener('load', () => {
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
const iframeVideo = iframeDoc.querySelector('video');
if (iframeVideo) {
cachedVideoElement = iframeVideo;
initVideoHandlers(iframeVideo);
}
} catch (e) {
console.log('Cannot access iframe content:', e);
}
});
if (iframe.contentDocument) {
const iframeVideo = iframe.contentDocument.querySelector('video');
if (iframeVideo) {
cachedVideoElement = iframeVideo;
initVideoHandlers(iframeVideo);
}
}
});
}
if (document.readyState !== 'loading') {
init();
} else {
document.addEventListener('DOMContentLoaded', init);
}
})();