本文由 GLM-5.2 依据 https://cnb.cool/Anyexyz/PlayGuard 完成。
前言
你是否也遇到过这种抓狂的瞬间:
开着某个网页播放器听歌、听课、听播客,切到别的窗口敲点东西,或者把标签页随手切到后台——声音突然就没了。等切回来才发现,它早因为「页面失焦」「标签页不可见」而暂停了。
更气人的是,有些站点更狠:鼠标一离开窗口区域就暂停,甚至直接判断 document.hidden、document.hasFocus(),在脚本里主动掐掉播放。
对于「我只想在后台安安静静听个响」的用户来说,这种行为非常反直觉。于是就有了 这个小扩展。

🛡 PlayGuard 是什么?
PlayGuard 是一个轻量级 Chrome / Edge 扩展(Manifest V3),干的事就一件:在你明确授权的网站上,降低网页播放器因页面失焦、隐藏或脚本调用而停止播放的概率。
说白了,就是把这些「非自愿暂停」的信号和暂停行为,骗回「我还在前台正常播放」的状态。
✨ 核心特点
双 World 协作:充分利用 MV3 的
MAIN与隔离两种执行环境,既能拿到chrome.*权限,又能改写页面原型。精准拦截:伪造
document.hidden/visibilityState/hasFocus(),并拦下visibilitychange、blur等事件。自动续播:劫持
video.pause(),页面一暂停就偷偷 10ms 后续播,用WeakMap防重复。动态守护:
MutationObserver盯着新增的<video>,点开新一集 / 新一节课也照样保护。:500ms 一轮扫描,不放过任何「应续播却暂停」的视频。
无后台、按需启用:只有你主动加入列表的域名才会被守护,命中次数透明展示。
🏗 它是怎么做到的
整个架构看起来是这样的——脚本分处两个互不相通的执行域,靠 window.postMessage 桥接:
为什么要分两个 world?这是整个方案最妙的地方:
page-guard.js) 跟页面 JavaScript 共享 DOM、共享原型链。只有在这里才能改写HTMLMediaElement.prototype.pause、document.hidden这些「页面脚本能感知到」的东西。隔离 world(
content.js) 拥有chrome.storage等扩展权限,但看不到页面的变量。它负责读站点配置、判断当前域名是否启用,再把结果告诉page-guard.js。弹窗(
popup.html/popup.js) 管理域名列表、展示状态和命中次数,数据全落在chrome.storage.local这个唯一「真相源」上。
各司其职、隔离又协作,这就是它既能拿到 chrome.* 权限、又能改写页面原型的关键。
抢救一条视频的完整流程
当某个已启用站点的视频要被暂停时,PlayGuard 会走这样一条「抢救链」:
核心逻辑都集中在 page-guard.js,拆开看是四件事。
① 伪造页面可见性与焦点
在 MAIN world 里直接重写属性描述符,让页面脚本读到的永远是「我还在前台」:
Object.defineProperty(document, 'hidden', {
get: () => state.enabled ? false : originalHidden?.get?.call(document),
configurable: true
});
Object.defineProperty(document, 'visibilityState', {
get: () => state.enabled ? 'visible' : originalVisibilityState?.get?.call(document),
configurable: true
});
document.hasFocus = () => state.enabled ? true : originalHasFocus();同时把 document 上的 visibilitychange、window 上的 blur 用「捕获阶段 + stopImmediatePropagation」提前拦下,页面脚本根本收不到这些事件:
originalAddEventListener.call(document, 'visibilitychange', stopEvent, true);
originalAddEventListener.call(window, 'blur', stopEvent, true);pause() 并自动续播
更直接的招数是把 HTMLMediaElement.prototype.pause 换掉。页面想暂停一个「正在播放、未结束、无错误」的视频时,不真正暂停,而是 10ms 后偷偷调一次 play() 续上:
HTMLMediaElement.prototype.pause = function (...args) {
if (state.enabled && this.tagName === 'VIDEO' && shouldResumeVideo(this)) {
scheduleResume(this, 10);
return undefined; // 骗过调用方:暂停「成功」了
}
return originalPause.apply(this, args);
};为了避免重复排队,用 WeakMap 给每个 <video> 记一个定时器,互不干扰、还能随元素回收自动清理。
③ 守护新增的 <video>
网页常在交互后才动态插入 <video>(比如点开某节课、某集剧)。PlayGuard 用 MutationObserver 盯着整个文档,一旦冒出新的 <video>,立刻挂上 play / pause 监听并打上保护标记:
new MutationObserver(mutations => {
if (!state.enabled) return;
mutations.forEach(m => m.addedNodes.forEach(node => {
if (node.nodeName === 'VIDEO') protectVideo(node);
else if (node.querySelectorAll) node.querySelectorAll('video').forEach(protectVideo);
}));
}).observe(document.documentElement, { childList: true, subtree: true });万一页面用了非常规手段让视频停下,还有个每 500ms 跑一轮的 setInterval 兜底:凡是「应续播却处于暂停」的视频,再补一次 scheduleResume,尽量不留死角。
🚀 使用方法
项目没有构建步骤,加载即用,三步搞定:
打开
chrome://extensions(Edge 是edge://extensions)。右上角开启「开发者模式」。
点「加载已解压的扩展」,选中 PlayGuard 目录。

加载好之后,点扩展栏里的 PlayGuard 图标,把想保护的网站域名加进去就行:
域名规则很宽松:填
music.example.com、https://music.example.com、甚至带端口的地址都行,扩展会自动归一化(去www.、忽略协议)。子域名会跟随主域名一起生效,所以加个主域名就全覆盖了。
🎯 适用场景
说说我自己平时用它对付的几种站点:
在线音乐 / 播客站:后台听歌被打断的元凶,加进去就老实了
网课 / 直播回放:边听课边做笔记,切窗口再也不会暂停
电台 / 听书:长时间后台播放,离开页面也不掐
某些「防挂机」的视频站:那种鼠标离开就暂停的,直接拿捏
📊 数据与状态:一个存储,三处共享
PlayGuard 没有后台常驻脚本,所有状态都存在 chrome.storage.local 里:
cleanerSites:你启用的域名列表siteCounters:每个域名被「成功守护」的累计次数
content.js 每次进页面都会把当前域名归一化,再用「最长匹配」判断是否启用;命中就自增计数。弹窗和内容脚本都监听 chrome.storage.onChanged,所以你在弹窗里加一个站点,已经打开的页面会实时生效,无需刷新。命中次数也会随着每次访问自动累加,一目了然。
写在最后
PlayGuard 代码量不大,但把一组很干净的浏览器 API 组合起来,解决了一个非常贴近日常体验的小痛点:MV3 双 world、原型劫持、事件捕获拦截、WeakMap 去重、MutationObserver 动态守护、storage 驱动的无后台架构——每一块都不复杂,凑在一起就刚好。
它不会无差别改写所有网站:只有你主动加入列表的域名才会被守护,命中次数也透明地展示在弹窗里,用着安心。
如果你也有「后台听东西却被打断」的困扰,不妨试试,顺手点个 ⭐。