// ==UserScript== // @name 全网页强制倍速 // @namespace https://workbuddy.cn // @version 2.3.0 // @description 任意网页视频/音频倍速 0.25x~16x 可调 + 快捷键(S/D 减速加速, R 恢复1x, 空格 播放暂停, ←→ 快退快进5s) + 倍速记忆 + 倍速/进度弹窗 + 逐帧(,/.) + 截图(C) + 亮度对比度 + 跨标签页同步;已修复记住开关/播放中视频选择/iframe 重复面板 // @author WorkBuddy // @match *://*/* // @match file:///* // @grant GM_setValue // @grant GM_getValue // ==/UserScript== (function () { 'use strict'; // 是否顶层窗口(用于避免嵌套 iframe 重复建面板) var isTop = (window.self === window.top); // ---------- 常量 ---------- var SETTINGS_KEY = 'vsc_settings'; var CHANNEL = 'vsc_sync'; var MIN_SPEED = 0.25, MAX_SPEED = 16; var SEEK_SEC = 5; // 快退/快进秒数 var FRAME_SEC = 1 / 30; // 逐帧步长(约 30fps) var PRESETS_BASE = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 3, 4]; var PRESETS_MORE = [5, 6, 8, 10, 12, 16]; // 点「更多」在 4x 后展开 // 快捷键步进随倍速自适应:低速细调,高速粗调 function keyStep(cur) { if (cur < 2) return 0.1; if (cur < 5) return 0.25; return 0.5; } // ---------- 存储 ---------- var defaults = { speed: 1, brightness: 100, contrast: 100, remember: true, crossTab: true, autoApply: true, indicator: true }; function loadStore() { var s = null; try { if (typeof GM_getValue === 'function') { var g = GM_getValue(SETTINGS_KEY); if (g) s = JSON.parse(g); } } catch (e) {} if (!s) { try { var l = localStorage.getItem(SETTINGS_KEY); if (l) s = JSON.parse(l); } catch (e) {} } s = Object.assign({}, defaults, s || {}); if (!s.remember) s.speed = 1; // 关闭“记住倍速”时不恢复上次倍速 return s; } function saveStore() { var json = JSON.stringify(settings); try { if (typeof GM_setValue === 'function') GM_setValue(SETTINGS_KEY, json); } catch (e) {} try { localStorage.setItem(SETTINGS_KEY, json); } catch (e) {} } var settings = loadStore(); function clamp(s) { return Math.min(MAX_SPEED, Math.max(MIN_SPEED, s)); } function round2(s) { return Math.round(s * 100) / 100; } var currentSpeed = clamp(settings.speed); // ---------- 跨标签页同步 ---------- var bc = null; try { if (typeof BroadcastChannel !== 'undefined') bc = new BroadcastChannel(CHANNEL); } catch (e) {} function broadcast() { if (!settings.crossTab) return; var payload = JSON.stringify({ type: 'settings', settings: settings, speed: currentSpeed }); if (bc) { try { bc.postMessage(payload); } catch (e) {} } else { try { localStorage.setItem(CHANNEL, payload + ':' + Date.now()); } catch (e) {} } } function applyRemote(data) { if (!data || !data.settings) return; settings = Object.assign({}, settings, data.settings); if (typeof data.speed === 'number') currentSpeed = clamp(data.speed); saveStore(); syncUI(); var v = getActiveVideo(); if (v) applyToVideo(v); } if (bc) bc.onmessage = function (ev) { try { applyRemote(JSON.parse(ev.data)); } catch (e) {} }; window.addEventListener('storage', function (e) { if (e.key === CHANNEL && e.newValue) { try { applyRemote(JSON.parse(e.newValue.split(':')[0])); } catch (err) {} } }); // ---------- 视频选择 ---------- var hoverVideo = null; // 用 DOM 属性(而非 HTML 属性选择器)找出“正在播放”的视频 function firstPlaying() { var vs = document.querySelectorAll('video'); for (var i = 0; i < vs.length; i++) { if (!vs[i].paused) return vs[i]; } return null; } function getActiveVideo() { if (hoverVideo && document.contains(hoverVideo)) return hoverVideo; var ae = document.activeElement; if (ae && (ae.tagName === 'VIDEO' || ae.tagName === 'AUDIO')) return ae; var playing = firstPlaying(); if (playing) return playing; return document.querySelectorAll('video, audio')[0] || null; } function allMedia() { return Array.prototype.slice.call(document.querySelectorAll('video, audio')); } // ---------- 应用倍速/滤镜 ---------- function applyToVideo(v) { try { v.playbackRate = currentSpeed; v.defaultPlaybackRate = currentSpeed; } catch (e) {} try { var b = settings.brightness / 100, c = settings.contrast / 100; v.style.filter = (b === 1 && c === 1) ? '' : ('brightness(' + b + ') contrast(' + c + ')'); } catch (e) {} } function applyToAll() { allMedia().forEach(applyToVideo); } function setSpeed(s) { currentSpeed = clamp(s); applyToAll(); if (settings.remember) settings.speed = currentSpeed; // 仅开启记住时持久化倍速 saveStore(); broadcast(); syncUI(); showIndicator(true); toast('倍速 ' + currentSpeed.toFixed(2) + 'x'); } // ---------- 快捷键 ---------- function togglePlay(v) { try { if (v.paused) v.play(); else v.pause(); } catch (e) {} showIndicator(true); } function seek(v, d) { try { var dur = isFinite(v.duration) ? v.duration : 1e9; v.currentTime = Math.min(dur, Math.max(0, v.currentTime + d)); } catch (e) {} showIndicator(true); } function frameStep(v, dir) { try { v.pause(); var dur = isFinite(v.duration) ? v.duration : 1e9; v.currentTime = Math.min(dur, Math.max(0, v.currentTime + dir * FRAME_SEC)); } catch (e) {} showIndicator(true); toast((dir > 0 ? '逐帧▶' : '◀逐帧')); } function screenshot(v) { try { var cv = document.createElement('canvas'); cv.width = v.videoWidth || 640; cv.height = v.videoHeight || 360; var ctx = cv.getContext('2d'); if (!ctx) { toast('当前环境不支持截图'); return; } ctx.drawImage(v, 0, 0, cv.width, cv.height); var url = cv.toDataURL('image/png'); var a = document.createElement('a'); a.href = url; a.download = 'video-frame-' + Date.now() + '.png'; document.body.appendChild(a); a.click(); document.body.removeChild(a); toast('截图已保存'); } catch (e) { toast('截图失败(可能为跨域视频)'); } } function handleKey(e) { var t = e.target || {}; var tag = t.tagName || ''; if (/INPUT|TEXTAREA|SELECT/.test(tag) || t.isContentEditable) return; var v = getActiveVideo(); if (!v) return; var k = e.key; if (k === 's' || k === 'S') { setSpeed(round2(currentSpeed - keyStep(currentSpeed))); e.preventDefault(); } else if (k === 'd' || k === 'D') { setSpeed(round2(currentSpeed + keyStep(currentSpeed))); e.preventDefault(); } else if (k === 'r' || k === 'R') { setSpeed(1); e.preventDefault(); } else if (k === ' ') { togglePlay(v); e.preventDefault(); } else if (k === 'ArrowLeft') { seek(v, -SEEK_SEC); e.preventDefault(); } else if (k === 'ArrowRight') { seek(v, SEEK_SEC); e.preventDefault(); } else if (k === ',') { frameStep(v, -1); e.preventDefault(); } else if (k === '.') { frameStep(v, 1); e.preventDefault(); } else if (k === 'c' || k === 'C') { screenshot(v); e.preventDefault(); } } document.addEventListener('keydown', handleKey, true); // 鼠标悬停跟踪“活动视频” document.addEventListener('mouseover', function (e) { var el = e.target; while (el && el !== document) { if (el.tagName === 'VIDEO' || el.tagName === 'AUDIO') { hoverVideo = el; return; } el = el.parentNode; } }, true); // 鼠标移出视频时释放悬停跟踪(避免指向已离开的元素) document.addEventListener('mouseout', function (e) { var el = e.target; if (el && (el.tagName === 'VIDEO' || el.tagName === 'AUDIO')) { var to = e.relatedTarget; if (!to || !el.contains(to)) hoverVideo = null; } }, true); // 动态视频出现时自动套用(仅对新插入的媒体套用,避免全量扫描) var mo = new MutationObserver(function (muts) { if (!settings.autoApply) return; muts.forEach(function (m) { Array.prototype.forEach.call(m.addedNodes || [], function (n) { if (n.nodeType !== 1) return; if (n.tagName === 'VIDEO' || n.tagName === 'AUDIO') applyToVideo(n); else if (n.querySelectorAll) { Array.prototype.forEach.call(n.querySelectorAll('video, audio'), applyToVideo); } }); }); }); mo.observe(document.documentElement, { childList: true, subtree: true }); // 页面卸载时清理监听/定时器/通道,避免泄漏 window.addEventListener('pagehide', function () { try { mo.disconnect(); } catch (e) {} if (bc) { try { bc.close(); } catch (e) {} } hideIndicator(); }); // ---------- UI:控制面板(仅顶层窗口构建,避免嵌套 iframe 重复出现面板) ---------- var root = null, shadow = null; if (isTop) { root = document.createElement('div'); root.id = 'vsc-root'; if (root.attachShadow) { shadow = root.attachShadow({ mode: 'open' }); } else { shadow = root; } document.documentElement.appendChild(root); var style = '\ :host{all:initial;position:fixed;top:16px;right:16px;z-index:2147483647;font-size:14px;}\n\ #box{background:rgba(20,22,28,.92);color:#eee;border:1px solid #3a3f4b;border-radius:10px;box-shadow:0 6px 22px rgba(0,0,0,.4);width:220px;overflow:hidden;font-family:system-ui,Arial,sans-serif;user-select:none;}\n\ #bar{display:flex;align-items:center;gap:6px;padding:7px 9px;background:rgba(255,255,255,.06);cursor:move;}\n\ #bar .t{font-weight:600;flex:1;font-size:13px;}\n\ #bar .x,#bar .f{background:none;border:none;color:#bbb;cursor:pointer;font-size:15px;line-height:1;padding:2px 4px;}\n\ #bar .x:hover,#bar .f:hover{color:#fff;}\n\ #body{padding:9px;display:flex;flex-direction:column;gap:9px;}\n\ #body.hide{display:none;}\n\ .row{display:flex;gap:4px;flex-wrap:wrap;}\n\ .btn{flex:1;min-width:34px;background:#2a2f3a;border:1px solid #3a3f4b;color:#eee;border-radius:6px;padding:4px 0;cursor:pointer;font-size:11.5px;}\n\ .btn:hover{background:#343b48;}\n\ .btn.active{background:#2d7df6;border-color:#2d7df6;color:#fff;}\n\ .more{width:100%;background:#222732;border:1px dashed #3a3f4b;color:#9fb4d8;border-radius:6px;padding:4px 0;cursor:pointer;font-size:11.5px;}\n\ .more:hover{background:#2a313e;}\n\ .lbl{font-size:11px;color:#9aa3b2;margin-bottom:2px;}\n\ input[type=range]{width:100%;accent-color:#2d7df6;}\n\ .chk{display:flex;align-items:center;gap:6px;font-size:12px;color:#cfd4dd;}\n\ .help{font-size:11px;color:#8b93a3;line-height:1.5;background:rgba(255,255,255,.04);border-radius:6px;padding:6px;}\n\ .help b{color:#cfd4dd;}\n\ '; var body = '\ \n\
\n\
⏩ 倍速控制器
\n\
\n\
\n\
倍速(' + MIN_SPEED + 'x ~ ' + MAX_SPEED + 'x)
\n\
\n\
\n\
\n\
微调:当前 1.00x
\n\ \n\
\n\
\n\
亮度 100%
\n\ \n\
\n\
\n\
对比度 100%
\n\ \n\
\n\ \n\ \n\ \n\ \n\
快捷提醒
S/D 加减速 · R 复原 · 空格 播放/暂停 · ←/→ 前后 5 秒 · ,/. 逐帧 · C 截图
拖动标题栏可移动面板
\n\
\n\
'; shadow.innerHTML = body; // 折叠/关闭 var boxEl = shadow.getElementById('box'); shadow.getElementById('fold').addEventListener('click', function () { var b = shadow.getElementById('body'); b.classList.toggle('hide'); this.textContent = b.classList.contains('hide') ? '+' : '-'; }); shadow.getElementById('close').addEventListener('click', function () { boxEl.style.display = 'none'; var badge = shadow.getElementById('badge'); if (badge) badge.style.display = 'block'; }); // 关闭后小圆标恢复 var badge = document.createElement('div'); badge.id = 'badge'; badge.textContent = '⏩'; badge.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483647;width:40px;height:40px;border-radius:50%;background:#2d7df6;color:#fff;display:none;align-items:center;justify-content:center;cursor:pointer;font-size:18px;box-shadow:0 4px 14px rgba(0,0,0,.4);'; shadow.appendChild(badge); badge.addEventListener('click', function () { boxEl.style.display = 'block'; badge.style.display = 'none'; }); // 预设按钮(基础档常驻;更高倍速点「更多」在 4x 后展开) var presetsBox = shadow.getElementById('presets'); var presetsMoreBox = shadow.getElementById('presetsMore'); PRESETS_BASE.concat(PRESETS_MORE).forEach(function (p) { var b = document.createElement('button'); b.className = 'btn'; b.textContent = p + 'x'; b.addEventListener('click', function () { setSpeed(p); }); (PRESETS_BASE.indexOf(p) >= 0 ? presetsBox : presetsMoreBox).appendChild(b); }); var moreOpen = false; var moreBtn = shadow.getElementById('moreBtn'); moreBtn.addEventListener('click', function () { moreOpen = !moreOpen; presetsMoreBox.style.display = moreOpen ? 'flex' : 'none'; this.textContent = moreOpen ? '收起 4x–16x ▴' : '更多 4x–16x ▾'; }); // 滑块 var slider = shadow.getElementById('slider'); slider.addEventListener('input', function () { setSpeed(parseFloat(this.value)); }); var bright = shadow.getElementById('bright'), contrast = shadow.getElementById('contrast'); function applyFilter() { settings.brightness = parseInt(bright.value, 10); settings.contrast = parseInt(contrast.value, 10); shadow.getElementById('bval').textContent = settings.brightness; shadow.getElementById('cval').textContent = settings.contrast; applyToAll(); saveStore(); broadcast(); } bright.addEventListener('input', applyFilter); contrast.addEventListener('input', applyFilter); // 勾选 function bindChk(id, key) { var el = shadow.getElementById(id); el.checked = !!settings[key]; el.addEventListener('change', function () { settings[key] = this.checked; saveStore(); broadcast(); if (key === 'autoApply' || key === 'indicator') { if (settings[key]) showIndicator(true); else hideIndicator(); } }); } bindChk('auto', 'autoApply'); bindChk('xTab', 'crossTab'); bindChk('ind', 'indicator'); // 记住倍速开关(单独绑定,避免重复;关闭时立即把当前视频倍速归 1,且不再持久化 speed) var remEl = shadow.getElementById('rem'); remEl.checked = !!settings.remember; remEl.addEventListener('change', function () { settings.remember = this.checked; if (!this.checked) { settings.speed = 1; applyToAll(); } saveStore(); broadcast(); }); // 拖拽(鼠标 + 触摸双支持,手机也可推动面板) (function () { var bar = shadow.getElementById('bar'), drag = false, ox = 0, oy = 0; function start(clientX, clientY, t) { if (t && (t.classList.contains('f') || t.classList.contains('x'))) return; drag = true; var r = root.getBoundingClientRect(); ox = clientX - r.left; oy = clientY - r.top; } function move(clientX, clientY) { if (!drag) return; root.style.left = (clientX - ox) + 'px'; root.style.top = (clientY - oy) + 'px'; root.style.right = 'auto'; } bar.addEventListener('mousedown', function (e) { start(e.clientX, e.clientY, e.target); }); document.addEventListener('mousemove', function (e) { move(e.clientX, e.clientY); }); document.addEventListener('mouseup', function () { drag = false; }); bar.addEventListener('touchstart', function (e) { var t = e.touches[0]; start(t.clientX, t.clientY, e.target); }, { passive: true }); document.addEventListener('touchmove', function (e) { if (drag) { var t = e.touches[0]; move(t.clientX, t.clientY); e.preventDefault(); } }, { passive: false }); document.addEventListener('touchend', function () { drag = false; }); })(); // (syncUI 定义见下方 IIFE 顶层,以兼容严格模式块级函数作用域) syncUI(); } // ---------- 同步 UI(IIFE 顶层,供块外 setSpeed / applyRemote 调用) ---------- function syncUI() { if (!shadow) return; slider.value = currentSpeed; shadow.getElementById('spd').textContent = currentSpeed.toFixed(2); Array.prototype.forEach.call(shadow.querySelectorAll('#presets .btn, #presetsMore .btn'), function (b) { b.classList.toggle('active', Math.abs(parseFloat(b.textContent) - currentSpeed) < 1e-6); }); bright.value = settings.brightness; contrast.value = settings.contrast; shadow.getElementById('bval').textContent = settings.brightness; shadow.getElementById('cval').textContent = settings.contrast; remEl.checked = settings.remember; shadow.getElementById('auto').checked = settings.autoApply; shadow.getElementById('xTab').checked = settings.crossTab; shadow.getElementById('ind').checked = settings.indicator; } // ---------- 倍速/进度弹窗(指示器) ---------- var ind = null, indTimer = null; function showIndicator(force) { if (!isTop) return; if (!settings.indicator && !force) return; if (!ind) { ind = document.createElement('div'); ind.style.cssText = 'position:fixed;z-index:2147483646;background:rgba(0,0,0,.7);color:#fff;padding:4px 8px;border-radius:6px;font:12px/1.4 system-ui;pointer-events:none;display:none;max-width:320px;'; document.documentElement.appendChild(ind); } if (indTimer) clearInterval(indTimer); indTimer = setInterval(updateIndicator, 400); updateIndicator(); ind.style.display = 'block'; if (ind._hide) clearTimeout(ind._hide); ind._hide = setTimeout(function () { if (ind) ind.style.display = 'none'; if (indTimer) { clearInterval(indTimer); indTimer = null; } }, force ? 1200 : 1500); } function updateIndicator() { var v = getActiveVideo(); if (!v) return; try { var cur = v.currentTime || 0, dur = v.duration || 0; var pct = dur ? (cur / dur) * 100 : 0; var rect = v.getBoundingClientRect(); ind.style.left = Math.max(4, rect.left + 6) + 'px'; ind.style.top = Math.max(4, rect.top + 6) + 'px'; ind.innerHTML = '▶ ' + currentSpeed.toFixed(2) + 'x ' + fmt(cur) + ' / ' + fmt(dur) + '
'; } catch (e) {} } function fmt(s) { if (!isFinite(s)) return '0:00'; s = Math.floor(s); var m = Math.floor(s / 60), ss = s % 60; return m + ':' + (ss < 10 ? '0' : '') + ss; } function hideIndicator() { if (ind) ind.style.display = 'none'; if (indTimer) { clearInterval(indTimer); indTimer = null; } } // 在视频上方常驻显示倍速/进度(indicator 开启时,仅顶层窗口) if (isTop && settings.indicator) { setInterval(function () { if (settings.indicator && ind && ind.style.display !== 'none') updateIndicator(); }, 500); } // ---------- 轻提示 ---------- var toastEl = null, toastTimer = null; function toast(msg) { if (!isTop) return; if (!toastEl) { toastEl = document.createElement('div'); toastEl.style.cssText = 'position:fixed;left:50%;top:18%;transform:translateX(-50%);z-index:2147483647;background:rgba(0,0,0,.78);color:#fff;padding:8px 14px;border-radius:8px;font:14px system-ui;opacity:0;transition:opacity .2s;pointer-events:none;'; document.documentElement.appendChild(toastEl); } toastEl.textContent = msg; toastEl.style.opacity = '1'; if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(function () { toastEl.style.opacity = '0'; }, 1000); } // 初始:套用倍速/滤镜 if (settings.autoApply) applyToAll(); // 暴露给调试/后续强制倍速集成 window.__vsc = { setSpeed: setSpeed, getSpeed: function () { return currentSpeed; }, applyToAll: applyToAll, settings: settings }; })();