// ==UserScript== // @name 视频控制器 // @namespace video-controller // @description 全站视频倍速/快进/音量增强控制,支持快捷键、自动倍速、自动音量、提示设置。适配 B站/YouTube/百度云盘/本地视频等。 // @version 1.0.1 // @license MIT // @author Qiu Zongman // @homepageURL https://gitee.com/qiuzongman/text/blob/main/TamperMonkey/video-controller/ // @icon https://gitee.com/qiuzongman/text/raw/main/TamperMonkey/video-controller/icon.png // @match *://*/* // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @run-at document-start // ==/UserScript== // // v1.0.1 新增: // - 设置面板/Toast 全屏时可见 // (function () { 'use strict'; // 仅顶层页面运行,避免 iframe 内重复执行 if (window.top !== window.self) return; // ======================= 默认设置 ======================= const DEFAULT_SETTINGS = { // 快捷键 togglePlay: ' ', speedUp: 'w', speedDown: 's', forward: 'ArrowRight', backward: 'ArrowLeft', frameForward: '', frameBackward: '', volumeUp: 'ArrowUp', volumeDown: 'ArrowDown', brightnessUp: '+', brightnessDown: '-', // 步长 speedStep: 0.5, volumeStep: 0.1, brightnessStep: 0.1, // 范围 minSpeed: 0.25, maxSpeed: 16, maxVolume: 5.0, // 快进 skipSeconds: 5, // 快捷倍速 quickSpeed1Key: '1', quickSpeed1Val: 1.0, quickSpeed2Key: '2', quickSpeed2Val: 2.0, quickSpeed3Key: '3', quickSpeed3Val: 3.0, quickSpeed4Key: '4', quickSpeed4Val: 4.0, // 自动 autoSpeedEnabled: false, autoSpeed: 1.0, autoVolumeEnabled: false, autoVolume: 1.0, autoBrightnessEnabled: false, autoBrightness: 1.0, // 提示(0=关闭) toastDuration: 3000, // 毫秒 }; const STORAGE_KEY = 'vc_settings'; // ======================= 设置存取 ======================= let settings = {}; function loadSettings() { try { const raw = typeof GM_getValue === 'function' ? GM_getValue(STORAGE_KEY, null) : null; settings = raw ? { ...DEFAULT_SETTINGS, ...JSON.parse(raw) } : { ...DEFAULT_SETTINGS }; } catch (e) { settings = { ...DEFAULT_SETTINGS }; } } function saveSettings() { try { if (typeof GM_setValue === 'function') { GM_setValue(STORAGE_KEY, JSON.stringify(settings)); } } catch (e) {} } // ======================= Shadow DOM 劫持(百度云盘等) ======================= function hackAttachShadow() { if (window._vcHasHackAttachShadow_) return; try { window._vcShadowDomList_ = window._vcShadowDomList_ || []; const origAttach = window.Element.prototype.attachShadow; window.Element.prototype.attachShadow = function (init) { if (init && init.mode) { init.mode = 'open'; // 强制 open 模式 } const shadowRoot = origAttach.call(this, init); window._vcShadowDomList_.push(shadowRoot); shadowRoot._shadowHost = this; document.dispatchEvent(new CustomEvent('vcAddShadowRoot', { detail: { shadowRoot } })); return shadowRoot; }; window._vcHasHackAttachShadow_ = true; } catch (e) { console.warn('[视频控制器] hackAttachShadow 失败:', e); } } // ======================= Toast 提示 ======================= let _toastEl = null; let _toastTimer = null; function Toast(msg) { if (settings.toastDuration === 0) return; if (!_toastEl) { _toastEl = document.createElement('div'); _toastEl.style.cssText = [ 'font-family: Arial, "Microsoft YaHei", sans-serif;', 'max-width: 60%; min-width: 150px; padding: 0 14px;', 'height: 40px; color: #fff; line-height: 40px;', 'text-align: center; border-radius: 8px;', 'position: fixed; top: 50%; left: 50%;', 'transform: translate(-50%, -50%);', 'z-index: 2147483647;', 'background: rgba(0,0,0,0.78);', 'pointer-events: none;', 'transition: opacity 0.3s ease;' ].join(''); document.body.appendChild(_toastEl); } // 全屏时把 toast 移到全屏元素内 var host = document.fullscreenElement || document.body; if (_toastEl.parentNode !== host) host.appendChild(_toastEl); _toastEl.textContent = msg; _toastEl.style.opacity = '1'; _toastEl.style.display = ''; // 清除之前的定时器 if (_toastTimer) clearTimeout(_toastTimer); _toastTimer = setTimeout(() => { _toastEl.style.opacity = '0'; _toastTimer = setTimeout(() => { _toastEl.style.display = 'none'; }, 300); }, settings.toastDuration); } // ======================= AudioContext 音量增益 ======================= const audioCtxMap = new WeakMap(); // video -> { ctx, source, gain } function getAudioBoost(video) { let record = audioCtxMap.get(video); if (!record) { try { const AudioContext = window.AudioContext || window.webkitAudioContext; if (!AudioContext) return null; const ctx = new AudioContext(); const source = ctx.createMediaElementSource(video); const gain = ctx.createGain(); source.connect(gain); gain.connect(ctx.destination); record = { ctx, source, gain }; audioCtxMap.set(video, record); } catch (e) { // 已连接过或跨域限制 return null; } } return record; } function setVideoVolume(video, vol) { // vol: 期望音量,范围 0 ~ maxVolume if (vol <= 1.0) { // 标准音量范围,直接用 video.volume video.volume = Math.max(0, Math.min(1, vol)); // 如果之前有增益连接,把 gain 调到 1 const record = audioCtxMap.get(video); if (record) { record.gain.gain.value = 1.0; } } else { // >1 需要 AudioContext 增益 const record = getAudioBoost(video); if (record) { video.volume = 1.0; record.gain.gain.value = vol; } else { // 无法创建增益,退回到最大 100% video.volume = 1.0; } } } function getVideoVolume(video) { const record = audioCtxMap.get(video); if (record && record.gain.gain.value > 1.0) { return record.gain.gain.value; } return video.volume; } // ======================= 视频发现 ======================= const VIDEO_SEL = 'video, bwp-video'; function findAllVideos() { const videos = []; // 1. 主文档 document.querySelectorAll(VIDEO_SEL).forEach(function(v) { videos.push(v); }); // 2. open shadow DOM try { document.querySelectorAll('*').forEach(function(el) { if (el.shadowRoot) { el.shadowRoot.querySelectorAll(VIDEO_SEL).forEach(function(v) { videos.push(v); }); } }); } catch (e) {} // 3. hackAttachShadow 记录的 shadow root if (window._vcShadowDomList_) { window._vcShadowDomList_.forEach(function(sr) { try { if (sr && sr.querySelectorAll) { sr.querySelectorAll(VIDEO_SEL).forEach(function(v) { if (!videos.includes(v)) videos.push(v); }); } } catch (e) {} }); } return videos; } function getActiveVideo() { const videos = findAllVideos(); if (videos.length === 0) return null; // 优先返回正在播放的视频 for (let i = 0; i < videos.length; i++) { if (!videos[i].paused) return videos[i]; } // 其次返回可见区域最大的视频 let best = null; let bestArea = 0; for (let i = 0; i < videos.length; i++) { const r = videos[i].getBoundingClientRect(); if (r.width > 0 && r.height > 0) { const area = r.width * r.height; if (area > bestArea) { bestArea = area; best = videos[i]; } } } if (best) return best; // 最后返回第一个(可能被隐藏) return videos[0]; } // ======================= 视频操作 ======================= function changeSpeed(video, delta) { if (!video) return; let newRate = video.playbackRate + delta; newRate = Math.round(newRate / settings.speedStep) * settings.speedStep; newRate = Math.max(settings.minSpeed, Math.min(settings.maxSpeed, newRate)); video.playbackRate = newRate; Toast('倍速 ' + newRate.toFixed(2) + 'x'); } function skipTime(video, seconds) { if (!video || isNaN(video.duration)) return; video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + seconds)); } function changeVolume(video, delta) { if (!video) return; const curVol = getVideoVolume(video); let newVol = curVol + delta; newVol = Math.round(newVol / settings.volumeStep) * settings.volumeStep; newVol = Math.max(0, Math.min(settings.maxVolume, newVol)); setVideoVolume(video, newVol); Toast('音量 ' + Math.round(newVol * 100) + '%'); } function changeBrightness(video, delta) { if (!video) return; let val = (video._vcBrightness || 1.0) + delta; val = Math.round(val / settings.brightnessStep) * settings.brightnessStep; val = Math.max(0, Math.min(3, val)); video._vcBrightness = val; video.style.filter = 'brightness(' + val + ')'; Toast('亮度 ' + Math.round(val * 100) + '%'); } function setBrightness(video, val) { if (!video) return; video._vcBrightness = val; video.style.filter = val === 1 ? '' : 'brightness(' + val + ')'; } // ======================= 自动倍速 / 音量 / 亮度 ======================= function onVideoPlay(e) { const video = e.target; if (!video || video.tagName !== 'VIDEO') return; if (settings.autoSpeedEnabled) { const rate = parseFloat(settings.autoSpeed); if (!isNaN(rate) && rate >= settings.minSpeed && rate <= settings.maxSpeed) { video.playbackRate = rate; } } if (settings.autoVolumeEnabled) { const vol = parseFloat(settings.autoVolume); if (!isNaN(vol) && vol >= 0 && vol <= settings.maxVolume) { setVideoVolume(video, vol); } } if (settings.autoBrightnessEnabled) { const br = parseFloat(settings.autoBrightness); if (!isNaN(br) && br >= 0 && br <= 3) { setBrightness(video, br); } } } function bindVideoEvents(video) { if (video._vcEventsBound) return; video._vcEventsBound = true; video.addEventListener('play', onVideoPlay); } // 为所有视频绑定事件 function bindAllVideos() { const videos = findAllVideos(); for (let i = 0; i < videos.length; i++) { bindVideoEvents(videos[i]); } } // ======================= 键盘事件 ======================= function onKeyDown(e) { // 在输入框中不处理 const tag = (e.target && e.target.tagName) || ''; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || (e.target && e.target.isContentEditable)) { return; } // 避免修饰键干扰 if (e.ctrlKey || e.altKey || e.metaKey) return; // 按键重复过滤(按住不放不会频繁触发) if (onKeyDown._lk === e.key && Date.now() - onKeyDown._lt < 150) return; onKeyDown._lk = e.key; onKeyDown._lt = Date.now(); const video = getActiveVideo(); if (!video) return; let handled = false; if (e.key === settings.togglePlay) { video.paused ? video.play() : video.pause(); Toast(video.paused ? '已暂停' : '已播放'); handled = true; } if (e.key === settings.speedUp) { changeSpeed(video, settings.speedStep); handled = true; } if (e.key === settings.speedDown) { changeSpeed(video, -settings.speedStep); handled = true; } if (e.key === settings.forward) { skipTime(video, settings.skipSeconds); Toast('快进 ' + settings.skipSeconds + 's'); handled = true; } if (e.key === settings.backward) { skipTime(video, -settings.skipSeconds); Toast('快退 ' + settings.skipSeconds + 's'); handled = true; } if (settings.frameForward && e.key === settings.frameForward) { skipTime(video, 1 / 30); Toast('逐帧+'); handled = true; } if (settings.frameBackward && e.key === settings.frameBackward) { skipTime(video, -1 / 30); Toast('逐帧-'); handled = true; } if (e.key === settings.volumeUp) { changeVolume(video, settings.volumeStep); handled = true; } if (e.key === settings.volumeDown) { changeVolume(video, -settings.volumeStep); handled = true; } if (e.key === settings.brightnessUp) { changeBrightness(video, settings.brightnessStep); handled = true; } if (e.key === settings.brightnessDown) { changeBrightness(video, -settings.brightnessStep); handled = true; } for (let i = 1; i <= 4; i++) { if (e.key === settings['quickSpeed' + i + 'Key']) { video.playbackRate = settings['quickSpeed' + i + 'Val']; Toast('倍速 ' + settings['quickSpeed' + i + 'Val'].toFixed(1) + 'x'); handled = true; break; } } if (handled) { e.preventDefault(); e.stopPropagation(); } } // ======================= 设置面板 ======================= function openSettings() { // 移除已有面板 const existing = document.getElementById('vc-settings-panel'); if (existing) existing.remove(); const panel = document.createElement('div'); panel.id = 'vc-settings-panel'; panel.style.cssText = [ 'position: fixed; top: 50%; left: 50%;', 'transform: translate(-50%, -50%);', 'background: #fff; padding: 20px;', 'border: 2px solid #555; border-radius: 8px;', 'z-index: 2147483646; width: 560px;', 'max-height: 85vh; overflow-y: auto;', 'pointer-events: auto;' ].join(''); panel.innerHTML = buildSettingsHTML(); (document.fullscreenElement || document.body).appendChild(panel); // 事件绑定 bindSettingsEvents(panel); } function buildSettingsHTML() { const s = settings; const esc = (v) => String(v).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); const dk = (v) => v === ' ' ? 'Space' : v; return `
加速
快进
音量+
逐帧+
亮度+
暂停
倍速
倍速
倍速步长
音量步长
亮度步长
跳转秒数
减速
快退
音量-
逐帧-
亮度-
倍速
倍速
自动倍速
自动音量
自动亮度
提示时长
倍速范围 0.25x ~ 16x
音量范围 0x ~ 5x(>100% 需 AudioContext 增益)
亮度范围 0x ~ 3x
`; } function bindSettingsEvents(panel) { // 快捷键捕获:点击输入框后按下键盘自动记录 const keyInputs = panel.querySelectorAll('.vc-key-input'); keyInputs.forEach(function (input) { input.addEventListener('focus', function () { input.placeholder = '正在监听按键...'; input.value = ''; input._capturing = true; }); input.addEventListener('blur', function () { input.placeholder = '点击后按键'; // 如果未捕获到按键,恢复原值 if (input._capturing && !input.value) { const id = input.id; const keyMap = { 'vc-togglePlay': 'togglePlay', 'vc-speedUp': 'speedUp', 'vc-speedDown': 'speedDown', 'vc-forward': 'forward', 'vc-backward': 'backward', 'vc-frameForward': 'frameForward', 'vc-frameBackward': 'frameBackward', 'vc-volumeUp': 'volumeUp', 'vc-volumeDown': 'volumeDown', 'vc-brightnessUp': 'brightnessUp', 'vc-brightnessDown': 'brightnessDown', 'vc-qk1': 'quickSpeed1Key', 'vc-qk2': 'quickSpeed2Key', 'vc-qk3': 'quickSpeed3Key', 'vc-qk4': 'quickSpeed4Key' }; var kv = settings[keyMap[id]] || DEFAULT_SETTINGS[keyMap[id]] || ''; input.value = kv === ' ' ? 'Space' : kv; } input._capturing = false; }); }); // 在面板上捕获按键(用于快捷键输入) panel.addEventListener('keydown', function (e) { const activeEl = document.activeElement; if (!activeEl || !activeEl.classList.contains('vc-key-input') || !activeEl._capturing) return; e.preventDefault(); e.stopPropagation(); activeEl.value = e.key === ' ' ? 'Space' : e.key; activeEl._capturing = false; activeEl.blur(); }, true); // 保存 panel.querySelector('#vc-save').addEventListener('click', function () { applySettingsFromPanel(panel); saveSettings(); panel.remove(); Toast('设置已保存,立即生效'); // 重新绑定所有视频事件 bindAllVideos(); }); // 取消 panel.querySelector('#vc-cancel').addEventListener('click', function () { panel.remove(); }); // 恢复默认 panel.querySelector('#vc-reset').addEventListener('click', function () { settings = { ...DEFAULT_SETTINGS }; saveSettings(); panel.remove(); Toast('已恢复默认设置'); bindAllVideos(); }); // ESC 关闭(仅在未聚焦快捷键输入框时) function onEsc(e) { if (e.key === 'Escape') { const activeEl = document.activeElement; if (activeEl && activeEl.classList.contains('vc-key-input')) return; panel.remove(); document.removeEventListener('keydown', onEsc); } } document.addEventListener('keydown', onEsc); } function applySettingsFromPanel(panel) { const getVal = (id) => panel.querySelector('#' + id).value.trim(); const getNum = (id) => parseFloat(panel.querySelector('#' + id).value); const getKey = (id) => { var v = getVal(id); return v === 'Space' ? ' ' : v; }; settings.togglePlay = getKey('vc-togglePlay') || DEFAULT_SETTINGS.togglePlay; settings.speedUp = getKey('vc-speedUp') || DEFAULT_SETTINGS.speedUp; settings.speedDown = getKey('vc-speedDown') || DEFAULT_SETTINGS.speedDown; settings.forward = getKey('vc-forward') || DEFAULT_SETTINGS.forward; settings.backward = getKey('vc-backward') || DEFAULT_SETTINGS.backward; settings.frameForward = getKey('vc-frameForward'); settings.frameBackward = getKey('vc-frameBackward'); settings.volumeUp = getKey('vc-volumeUp') || DEFAULT_SETTINGS.volumeUp; settings.volumeDown = getKey('vc-volumeDown') || DEFAULT_SETTINGS.volumeDown; settings.brightnessUp = getKey('vc-brightnessUp') || DEFAULT_SETTINGS.brightnessUp; settings.brightnessDown = getKey('vc-brightnessDown') || DEFAULT_SETTINGS.brightnessDown; settings.speedStep = Math.max(0.05, getNum('vc-speedStep') || DEFAULT_SETTINGS.speedStep); settings.skipSeconds = Math.max(0, getNum('vc-skipSeconds') || DEFAULT_SETTINGS.skipSeconds); settings.volumeStep = Math.max(0.01, getNum('vc-volumeStep') || DEFAULT_SETTINGS.volumeStep); settings.brightnessStep = Math.max(0.01, getNum('vc-brightnessStep') || DEFAULT_SETTINGS.brightnessStep); settings.autoSpeedEnabled = panel.querySelector('#vc-autoSpeedEnabled').value === '1'; settings.autoSpeed = Math.max(0.25, Math.min(16, getNum('vc-autoSpeed') || DEFAULT_SETTINGS.autoSpeed)); settings.autoVolumeEnabled = panel.querySelector('#vc-autoVolumeEnabled').value === '1'; settings.autoVolume = Math.max(0, Math.min(5, getNum('vc-autoVolume') || DEFAULT_SETTINGS.autoVolume)); settings.autoBrightnessEnabled = panel.querySelector('#vc-autoBrightnessEnabled').value === '1'; settings.autoBrightness = Math.max(0, Math.min(3, getNum('vc-autoBrightness') || DEFAULT_SETTINGS.autoBrightness)); for (let i = 1; i <= 4; i++) { settings['quickSpeed' + i + 'Key'] = getKey('vc-qk' + i) || DEFAULT_SETTINGS['quickSpeed' + i + 'Key']; settings['quickSpeed' + i + 'Val'] = Math.max(0.25, Math.min(16, getNum('vc-qk' + i + 'Val') || DEFAULT_SETTINGS['quickSpeed' + i + 'Val'])); } settings.toastDuration = getNum('vc-toastDuration'); if (isNaN(settings.toastDuration)) settings.toastDuration = DEFAULT_SETTINGS.toastDuration; } // ======================= 初始化 ======================= function init() { loadSettings(); // 劫持 attachShadow(必须在页面创建 shadow root 前运行) hackAttachShadow(); // 全屏状态变化时搬移 toast 和面板 document.addEventListener('fullscreenchange', function () { var host = document.fullscreenElement || document.body; if (_toastEl && _toastEl.parentNode !== host) host.appendChild(_toastEl); var pnl = document.getElementById('vc-settings-panel'); if (pnl && pnl.parentNode !== host) host.appendChild(pnl); }); // 键盘事件 document.addEventListener('keydown', onKeyDown, true); // 为已有视频绑定事件 bindAllVideos(); // 监听 shadow DOM 新增 document.addEventListener('vcAddShadowRoot', function (e) { if (e.detail && e.detail.shadowRoot) { try { e.detail.shadowRoot.querySelectorAll(VIDEO_SEL).forEach(bindVideoEvents); } catch (_) {} } }); // 监听 DOM 动态新增视频元素 const observer = new MutationObserver(function (mutations) { for (let i = 0; i < mutations.length; i++) { const addedNodes = mutations[i].addedNodes; for (let j = 0; j < addedNodes.length; j++) { const node = addedNodes[j]; if (node.nodeType === 1) { if (node.matches && node.matches(VIDEO_SEL)) { bindVideoEvents(node); } else if (node.querySelectorAll) { node.querySelectorAll(VIDEO_SEL).forEach(bindVideoEvents); } } } } }); observer.observe(document.documentElement, { childList: true, subtree: true }); } // 注册菜单命令 if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('视频控制器 设置', openSettings); } // 启动 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();