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