// ==UserScript== // @name Web Video Play Helper // @namespace https://workbuddy.local/web-video-play-helper // @version 2.1.0 // @description 全站通用的网页视频播放辅助:倍速滑杆(可对平台隐藏真实倍速)、防切屏/失焦误暂停、静音、自动播放、每站倍速记忆。在超星/学习通下额外启用防挂机保活、自动连播下一节、弹题提醒。带可拖拽悬浮面板 + 快捷键 + 设置持久化。 // @author WorkBuddy // @license MIT // @match *://*/* // @run-at document-start // @grant GM_getValue // @grant GM_setValue // @grant unsafeWindow // ==/UserScript== /* eslint-disable no-empty */ (function () { 'use strict'; /* --- 0. 基础常量与工具 --- */ const IS_TOP = (function () { try { return window.top === window.self; } catch (e) { return false; } })(); const FRAME_ID = 'wvph-' + Math.random().toString(36).slice(2, 9); const MSG_TAG = '__wvph_msg__'; const CFG_KEY = 'wvph_cfg_v1'; const SITE_KEY = 'wvph_sitespeed_v1'; // 每站倍速记忆(独立存储,避免污染配置) const PANEL_HOST_ID = 'wvph-panel-host'; /** 是否处于超星 / 学习通(学习通专属功能只在这里真正生效) */ function isChaoxing() { try { return /(^|\.)(chaoxing\.com|chaoxing\.com\.cn|xuexitong\.com|xuexitong\.com\.cn)$/i.test(location.hostname); } catch (e) { return false; } } const log = function () { try { console.log.apply(console, ['%c[Web Video Play Helper]', 'color:#1f9d6b;font-weight:600'].concat(Array.prototype.slice.call(arguments))); } catch (e) {} }; function fmtTime(s) { if (!isFinite(s) || s < 0) s = 0; var m = Math.floor(s / 60); var ss = Math.floor(s % 60); return String(m).padStart(2, '0') + ':' + String(ss).padStart(2, '0'); } function isVisible(el, win) { if (!el || !el.getBoundingClientRect) return false; try { var r = el.getBoundingClientRect(); if (r.width <= 0 || r.height <= 0) return false; var cs = (win || window).getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false; if (cs.pointerEvents === 'none') return false; return true; } catch (e) { return false; } } /* --- 1. 配置与状态 --- */ var DEFAULTS = { speed: 1, // 目标倍速 spoof: 'auto', // 隐藏真实倍速:'auto'(仅学习通)/ 'on' / 'off' antiPause: true, // 被平台强制暂停时自动恢复 keepAlive: true, // 模拟操作避免挂机弹窗(仅学习通真正生效) mute: false, // 静音 autoplay: true, // 视频出来后自动开始播放 autoNext: true, // 播完自动切下一节(仅学习通有匹配入口) rememberSpeed: true, // 按站点记住倍速 swsOpen: true, // 通用开关分组是否展开 swsCxOpen: false, // 学习通专属分组是否展开 panelX: null, panelY: null, collapsed: false, hidden: false }; var state = { cfg: Object.assign({}, DEFAULTS), media: new Set(), // 当前帧内的 video/audio 元素 lastGesture: 0, // 页面上最后一次真实用户交互时间 quiz: false, // 是否检测到弹题 playing: false, // 最近一次上报的播放状态(顶层帧里是全局聚合结果) siteSpeedDone: false // 本页是否已应用过"每站倍速记忆" }; /** * GM 存储读写:有 GM API 就用,没有则退化为 localStorage。 * GM 存储在脚本的所有 frame 之间共享,这是跨 iframe 同步配置与指令的基础。 */ function readStore(key) { try { var raw = (typeof GM_getValue === 'function') ? GM_getValue(key, null) : localStorage.getItem(key); if (!raw) return null; return typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (e) { return null; } } function writeStore(key, val) { try { var raw = JSON.stringify(val); if (typeof GM_setValue === 'function') GM_setValue(key, raw); else localStorage.setItem(key, raw); } catch (e) {} } /** 隐藏真实倍速是否生效:auto 模式下只有学习通需要 * (其他站点开着会让它们自带的倍速菜单读到恒为 1×,显示错乱) */ function spoofOn() { return state.cfg.spoof === 'auto' ? isChaoxing() : state.cfg.spoof === 'on'; } /* --- 更名迁移:旧前缀是 cxh_,把已有设置搬到新键,避免升级后丢掉配置与每站倍速 --- */ (function migrateLegacyKeys() { ['cfg_v1', 'sitespeed_v1', 'cmd_v1'].forEach(function (suffix) { var nk = 'wvph_' + suffix; if (readStore(nk) !== null) return; var old = readStore('cxh_' + suffix); if (old !== null) writeStore(nk, old); }); })(); var storage = { read: function () { var obj = readStore(CFG_KEY); if (obj) Object.assign(state.cfg, DEFAULTS, obj); }, write: function () { writeStore(CFG_KEY, state.cfg); } }; storage.read(); /* ---- 每站倍速记忆:按 hostname 存,只由顶层帧读写 ---- */ function siteSpeedGet() { if (!state.cfg.rememberSpeed || !IS_TOP) return null; var map = readStore(SITE_KEY); var v = map && map[location.hostname]; return typeof v === 'number' && v > 0 ? v : null; } function siteSpeedSet(v) { if (!state.cfg.rememberSpeed || !IS_TOP) return; var map = readStore(SITE_KEY) || {}; if (map[location.hostname] === v) return; map[location.hostname] = v; writeStore(SITE_KEY, map); } /** 更新单个配置项:写盘 + 广播。 * 注意不要再显式调 applyCfgLocally —— sendMessage 会先把消息投递给本帧, * handleMessage 的 'cfg' 分支已经统一做了"写入 + 本地生效",两处都调就会应用两次。 */ function setCfg(key, value) { state.cfg[key] = value; storage.write(); sendMessage({ type: 'cfg', key: key, value: value }); return value; } function applyCfgLocally(key) { if (key === 'speed') { doSpeed(state.cfg.speed); // 同时会同步包装播放器的倍速菜单 siteSpeedSet(state.cfg.speed); // 顶层帧顺手记下本站的倍速(内部自带 IS_TOP 判断) } else if (key === 'mute') { doMute(state.cfg.mute); } else if (key === 'autoplay') { if (state.cfg.autoplay) { liveMedia().forEach(function (m) { if (m.paused && !m.ended && !m.__wvphIntentPaused) safePlay(m); }); } } reportSoon(); } /* --- 2. 原型级 Hook(必须在 document-start 完成,越早越有效) --- */ var rawRateDesc = null; // playbackRate 原始描述符 var rawDefaultRateDesc = null; function installProtoPatches() { var proto; try { proto = HTMLMediaElement.prototype; } catch (e) { return; } /* ---- 2.1 倍速伪装:对外读出恒为 1,对外写入被忽略 ---- */ try { rawRateDesc = Object.getOwnPropertyDescriptor(proto, 'playbackRate'); rawDefaultRateDesc = Object.getOwnPropertyDescriptor(proto, 'defaultPlaybackRate'); } catch (e) {} [['playbackRate', rawRateDesc], ['defaultPlaybackRate', rawDefaultRateDesc]].forEach(function (pair) { var prop = pair[0], desc = pair[1]; if (!desc || !desc.get || !desc.set) return; try { Object.defineProperty(proto, prop, { configurable: true, enumerable: true, get: function () { try { if (spoofOn() && this && this.__wvphRate != null) { return this.__wvphFakeRate != null ? this.__wvphFakeRate : 1; } return desc.get.call(this); } catch (e) { return 1; } }, set: function (v) { // 伪装开启且已被我们接管时,平台写倍速一律忽略 try { if (spoofOn() && this && this.__wvphRate != null) return; } catch (e) {} return desc.set.call(this, v); } }); } catch (e) {} }); /* ---- 2.2 页面可见性伪装:切标签页/最小化时,页面仍认为自己是可见且聚焦的 ---- */ patchVisibility(); /* ---- 2.3 拦截可见性/失焦类事件,避免平台监听后主动暂停 ---- */ var BLOCK = ['visibilitychange', 'webkitvisibilitychange', 'blur', 'pagehide', 'freeze']; BLOCK.forEach(function (t) { var fn = function (e) { if (!state.cfg.antiPause) return; try { e.stopImmediatePropagation(); } catch (err) {} }; try { window.addEventListener(t, fn, true); } catch (e) {} try { document.addEventListener(t, fn, true); } catch (e) {} }); } /** 让页面认为自己始终可见且聚焦 —— 平台据此判断是否该暂停视频 */ function patchVisibility() { var keys = { hidden: false, webkitHidden: false, visibilityState: 'visible', webkitVisibilityState: 'visible' }; var targets = [document]; try { targets.push(Document.prototype); } catch (e) {} targets.forEach(function (t) { Object.keys(keys).forEach(function (k) { try { Object.defineProperty(t, k, { configurable: true, get: function () { return keys[k]; } }); } catch (e) {} }); try { t.hasFocus = function () { return true; }; } catch (e) {} }); } /* --- 3. 跨 frame 消息总线(同源 iframe 之间同步配置与指令) --- */ function rootWindow() { try { return window.top; } catch (e) { return window; } } function walkWindows(fn, fromWindow, depth) { var win = fromWindow || rootWindow(); var d = depth || 0; if (d > 6) return; try { fn(win); } catch (e) {} var frames = []; try { frames = Array.prototype.slice.call(win.frames); } catch (e) {} frames.forEach(function (f) { walkWindows(fn, f, d + 1); }); } var msgSeq = 0; var seenMsg = new Set(); /** 消息去重:同一条消息可能同时经"直接调用"和"postMessage 回声"到达 */ function alreadySeen(id) { if (!id) return false; if (seenMsg.has(id)) return true; seenMsg.add(id); if (seenMsg.size > 300) { seenMsg.delete(seenMsg.values().next().value); } return false; } /* ---- 指令兜底通道:GM 存储跨 frame 共享,postMessage 被拦也照样生效 ---- * 两条通道(广播 + 存储轮询)会各自投递同一条指令,若不去重, * "下一节"会被点两次从而跳章。故给每条指令分配全局递增序号 cs, * 每个 frame 只执行序号更大的指令,实现跨通道的"恰好一次"。 */ var CMD_KEY = 'wvph_cmd_v1'; // 只有需要跨 frame 送达的动作才走兜底通道(其余靠 cfg 广播就够了) var JOURNAL_CMDS = { play: 1, pause: 1, next: 1 }; var executedCmdSeq = 0; // 本帧已执行到的指令序号 /** 取下一个全局指令序号,并落盘到兜底通道 */ function publishCmd(cmd) { if (!JOURNAL_CMDS[cmd]) return 0; var prev = readStore(CMD_KEY) || {}; var seq = (typeof prev.seq === 'number' ? prev.seq : 0) + 1; writeStore(CMD_KEY, { seq: seq, cmd: cmd, t: Date.now() }); return seq; } function pollCmd() { var rec = readStore(CMD_KEY); if (!rec || typeof rec.seq !== 'number' || rec.seq <= executedCmdSeq) return; executedCmdSeq = rec.seq; if (Date.now() - (rec.t || 0) > 10000) return; // 过期指令丢弃(页面刷新后不该补跑) log('通过兜底通道收到指令:' + rec.cmd); runCommand(rec.cmd); } // 启动时对齐序号,避免重放历史指令 (function () { var rec = readStore(CMD_KEY); if (rec && typeof rec.seq === 'number') executedCmdSeq = rec.seq; })(); /** 向整棵 frame 树广播(含自身) */ function sendMessage(payload) { var msg = Object.assign({}, payload); msg[MSG_TAG] = true; msg.from = FRAME_ID; msg.id = FRAME_ID + ':' + (++msgSeq); if (payload && payload.type === 'cmd') { msg.cs = publishCmd(payload.cmd); // 0 表示走兜底通道 } // 关键:先在本帧无条件执行一次。这样即使 window.top 的代理比较、 // 或 frames 枚举在当前沙箱里失效,只要视频就在本窗口也一定能生效。 // 重复投递由消息 id / 指令序号 cs 去重,不会执行两次。 handleMessage(msg, 'self'); walkWindows(function (w) { if (w === window) return; try { w.postMessage(msg, '*'); } catch (e) {} }); } try { window.addEventListener('message', function (e) { var d = e.data; if (!d || typeof d !== 'object' || !d[MSG_TAG]) return; handleMessage(d, e.source); }, false); } catch (e) {} function handleMessage(msg, source) { if (!msg || !msg[MSG_TAG]) return; if (alreadySeen(msg.id)) return; switch (msg.type) { case 'cfg': if (msg.key) { state.cfg[msg.key] = msg.value; applyCfgLocally(msg.key); } if (IS_TOP) renderPanel(); break; case 'cmd': // 跨通道去重:广播与存储轮询会送来同一条指令,只执行序号更大的那条 if (typeof msg.cs === 'number' && msg.cs > 0) { if (msg.cs <= executedCmdSeq) break; executedCmdSeq = msg.cs; } runCommand(msg.cmd); break; case 'status': if (IS_TOP && source && source !== window) { if (msg.data && msg.data.count > 0) ensurePanel(); // 子帧里发现视频 → 该出面了 statusMap.set(source, { data: msg.data, t: Date.now() }); if (!tickScheduled) { tickScheduled = true; requestAnimationFrame(function () { tickScheduled = false; renderStatus(); }); } } break; case 'quiz': state.quiz = !!msg.value; if (IS_TOP) { renderStatus(); if (state.quiz) { beep(); flashTitle(); } } break; case 'diagInfo': if (IS_TOP && source && source !== window) { diagMap.set(source, { data: msg.data, t: Date.now() }); renderDiag(); } break; default: break; } } /* --- 4. 媒体元素:发现 / 注册 / 控制 --- */ var hooked = new WeakSet(); var statusMap = new Map(); var diagMap = new Map(); var diagOpen = false; var tickScheduled = false; function liveMedia() { var out = []; state.media.forEach(function (m) { if (m && m.isConnected) out.push(m); }); return out; } /** 用原始描述符写入真实倍速(绕过我们自己的伪装层) */ function setRealRate(el, rate) { try { el.__wvphRate = rate; // 标记:已被接管 if (el.__wvphFakeRate == null) el.__wvphFakeRate = 1; // 对外读数 if (rawRateDesc && rawRateDesc.set) rawRateDesc.set.call(el, rate); else el.playbackRate = rate; } catch (e) {} } function safePlay(el) { try { var p = el.play(); if (p && typeof p.catch === 'function') { p.catch(function () { // 多半被浏览器自动播放策略拦截:先静音播放,再按用户偏好恢复 try { var wantMuted = state.cfg.mute; el.muted = true; var p2 = el.play(); if (p2 && typeof p2.catch === 'function') p2.catch(function () {}); if (!wantMuted) setTimeout(function () { try { el.muted = false; } catch (e) {} }, 400); } catch (e) {} }); } } catch (e) {} } function scanMedia() { // 清理已从 DOM 移除的元素,避免长会话内存泄漏 state.media.forEach(function (m) { if (!m || !m.isConnected) state.media.delete(m); }); var nodes; try { nodes = document.querySelectorAll('video, audio'); } catch (e) { return; } for (var i = 0; i < nodes.length; i++) { var el = nodes[i]; if (hooked.has(el)) continue; hooked.add(el); state.media.add(el); hookMedia(el); onNewMedia(el); } } function onNewMedia(el) { setRealRate(el, state.cfg.speed); try { el.muted = state.cfg.mute; } catch (e) {} if (state.cfg.autoplay && el.paused && !el.ended) { setTimeout(function () { // 到点时要重新判断:这 600ms 内用户可能已经手动暂停了,不能抢回 if (state.cfg.autoplay && el.paused && !el.ended && !el.__wvphIntentPaused && !el.__wvphUserPaused) safePlay(el); }, 600); } reportSoon(); } function hookMedia(el) { /* 平台强制暂停 → 自动恢复;用户主动暂停 → 上锁,绝不抢回 */ el.addEventListener('pause', function () { if (!state.cfg.antiPause) return; if (el.__wvphUserPaused || el.__wvphIntentPaused) return; // 用户意图是暂停 → 尊重 if (el.ended) return; if (Date.now() - state.lastGesture < 800) { // 刚有真实点击 → 判定为用户手动暂停 el.__wvphUserPaused = true; el.__wvphIntentPaused = Date.now(); return; } setTimeout(function () { if (!state.cfg.antiPause || el.__wvphUserPaused || el.__wvphIntentPaused) return; if (el.ended || !el.paused) return; log('检测到被动暂停,自动恢复播放'); safePlay(el); }, 350); }); /* 重新从头播时复位连播标记。 * 注意:不能在 play 事件里无条件解除"用户暂停锁"—— * 包装播放器会派发抖动性的 play 事件,那样会把锁冲掉, * 导致防暂停逻辑把自己的暂停又抢回播放(表现为"暂停键不起作用")。 */ el.addEventListener('play', function () { if (Date.now() - state.lastGesture < 1500) { // 只有本帧刚有真实用户操作才解锁 el.__wvphUserPaused = false; el.__wvphIntentPaused = 0; } if (isFinite(el.duration) && el.duration - el.currentTime > 5) el.__wvphNextFired = false; reportSoon(); }); /* 倍速被外部篡改 → 拉回目标值 */ el.addEventListener('ratechange', function () { if (!spoofOn() || !rawRateDesc || el.__wvphRate == null) return; var real; try { real = rawRateDesc.get.call(el); } catch (e) { return; } if (Math.abs(real - el.__wvphRate) > 0.001) { var want = state.cfg.speed; setTimeout(function () { if (el.__wvphRate != null) setRealRate(el, want); }, 80); } }); el.addEventListener('loadedmetadata', function () { setRealRate(el, state.cfg.speed); reportSoon(); }); /* 自动连播:timeupdate 兜底(部分超星播放器到结尾不派发 ended)+ ended 双触发, * 用 __wvphNextFired 保证只触发一次。 */ function fireNext(why) { if (!state.cfg.autoNext || el.__wvphNextFired) return; el.__wvphNextFired = true; log(why); setTimeout(function () { if (state.cfg.autoNext) goNext(); }, 1500); } el.addEventListener('timeupdate', function () { var dur = el.duration; if (isFinite(dur) && dur > 3 && dur - el.currentTime <= 0.8) fireNext('视频接近结尾,准备切换下一节'); reportSoon(); }); el.addEventListener('ended', function () { fireNext('视频播放结束,准备切换下一节'); }); } /* --- 5. 播放器适配层 --- * 【为什么必须有这一层】 * 超星的播放器不是"裸