// ==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 `