dsh-mpkg-wallpaper 3.11.0 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -441,7 +441,7 @@ This section gathers what environment and what sizes work, and what is out of re
441
441
 
442
442
  ### Without a Wallpaper Engine install (missing / non-Windows)
443
443
 
444
- A "WE install" means the Steam edition of Wallpaper Engine (appid **431960**). The host's `locateWallpaperEngine()` (`lib/index.js:303-327`) searches in this order: Windows registry `HKCU\Software\Valve\Steam\SteamPath` → common Steam directories → non-Windows Steam directories (macOS `~/Library/Application Support/Steam`, Linux/Android `~/.local/share/Steam`, WSL `/mnt/c/...`) → every library's `steamapps/libraryfolders.vdf` containing 431960 → and only accepts the one where `<library>/steamapps/common/wallpaper_engine/wallpaper32.exe` exists. **If nothing is found it returns `null`** and everything falls back:
444
+ A "WE install" means the Steam edition of Wallpaper Engine (appid **431960**). The host's `locateWallpaperEngine()` (`lib/index.js:311-399`) searches in this order: Windows registry `HKCU\Software\Valve\Steam\SteamPath` → common Steam directories → non-Windows Steam directories (macOS `~/Library/Application Support/Steam`, Linux/Android `~/.local/share/Steam`, WSL `/mnt/c/...` plus a per-drive enumeration of `/mnt/d`…`/mnt/z`) → every library's `steamapps/libraryfolders.vdf` containing 431960 → and only accepts the one where `<library>/steamapps/common/wallpaper_engine/wallpaper32.exe` exists. **If nothing is found it returns `null`** and everything falls back:
445
445
 
446
446
  | Scenario | Real behaviour (with code location) |
447
447
  |---|---|
package/README.md CHANGED
@@ -442,7 +442,7 @@ node tools/build-bundle.mjs
442
442
 
443
443
  ### 没有 Wallpaper Engine 安装时(缺失 / 非 Windows)
444
444
 
445
- 「WE 安装」指 Steam 版 Wallpaper Engine(appid **431960**)。宿主端 `locateWallpaperEngine()`(`lib/index.js:303-327`)按这个顺序找:Windows 注册表 `HKCU\Software\Valve\Steam\SteamPath` → 常见 Steam 目录 → 非 Windows 的 Steam 目录(macOS `~/Library/Application Support/Steam`、Linux/Android `~/.local/share/Steam`、WSL `/mnt/c/...`)→ 各库 `steamapps/libraryfolders.vdf` 里含 431960 的库 → 只认 `<库>/steamapps/common/wallpaper_engine/wallpaper32.exe` 存在的那一个。**找不到就返回 `null`**,后续走降级路径:
445
+ 「WE 安装」指 Steam 版 Wallpaper Engine(appid **431960**)。宿主端 `locateWallpaperEngine()`(`lib/index.js:311-399`)按这个顺序找:Windows 注册表 `HKCU\Software\Valve\Steam\SteamPath` → 常见 Steam 目录 → 非 Windows 的 Steam 目录(macOS `~/Library/Application Support/Steam`、Linux/Android `~/.local/share/Steam`、WSL `/mnt/c/...` 与按盘符枚举的 `/mnt/d`…`/mnt/z`)→ 各库 `steamapps/libraryfolders.vdf` 里含 431960 的库 → 只认 `<库>/steamapps/common/wallpaper_engine/wallpaper32.exe` 存在的那一个。**找不到就返回 `null`**,后续走降级路径:
446
446
 
447
447
  | 场景 | 真实行为(含代码位置) |
448
448
  |---|---|
@@ -611,8 +611,11 @@ node tools/style-scope-guard.mjs # 判据是末行「… OK / … ALLOWLISTED
611
611
 
612
612
  ## 这一版新增/变更
613
613
 
614
- 当前版本 = `package.json` 的 **3.11.0**(本轮发布线 3.8.0 → 3.8.1 → 3.8.2 → 3.9.0 → 3.9.1 → 3.10.0 → 3.10.1 → **3.11.0**)。发布前置/命令/回滚见 [`docs/RELEASE.md`](docs/RELEASE.md),本轮改动清单见 [`docs/RELEASE-READY-3.8.0.md`](docs/RELEASE-READY-3.8.0.md),Now playing / 壁纸声音的根因与真机读数见 [`docs/NOW-PLAYING-DSH.md`](docs/NOW-PLAYING-DSH.md) §7.7。
614
+ 当前版本 = `package.json` 的 **3.12.0**(本轮发布线 3.8.0 → 3.8.1 → 3.8.2 → 3.9.0 → 3.9.1 → 3.10.0 → 3.10.1 → 3.11.0 → **3.12.0**)。发布前置/命令/回滚见 [`docs/RELEASE.md`](docs/RELEASE.md),本轮改动清单见 [`docs/RELEASE-READY-3.8.0.md`](docs/RELEASE-READY-3.8.0.md),Now playing / 壁纸声音的根因与真机读数见 [`docs/NOW-PLAYING-DSH.md`](docs/NOW-PLAYING-DSH.md) §7.7。
615
615
 
616
+ - **3.12.0:WSL 上 Steam 装在别的盘也能扫到(安装目录候选抽成可注入纯函数 + 盘符枚举)+ 插件侧跨平台静态门禁** ——
617
+ ①真漏网:WSL 分支原来只写死 `/mnt/c/...`,Steam 装在 D:/E:/… 的 WSL 用户**永远扫不到**(Windows 与 WSL 双装、Steam 装 D 盘都很常见)。现在 `lib/index.js` 的 `steamProbeDirs(env)`(**纯函数**,`platform`/`release`/`homedir`/`exists`/`procVersion` 全可注入)在 `platform === 'linux'` 且判为 WSL 时枚举 `/mnt/d` … `/mnt/z` × `Program Files (x86)/Steam`、`Program Files/Steam`、`SteamLibrary`、`Steam` 四个相对路径;WSL 判据两条(`os.release()` 含 `microsoft`,老 WSL1 再读 `/proc/version`,读失败一律吞掉不抛)。非 WSL 的 linux/darwin **不**产出 `/mnt/d…z`;win32/darwin/linux/android 四个平台的候选集合与改动前**逐字节相同**(门禁逐项断言)。
618
+ ②新增 `tools/cross-platform-test.mjs`(**63 断言 / 1.2s**,无浏览器无网络;登记进 `tools/check.sh` 的既有第 6 步,**不新增 step 编号**):WSL 分支可判(在非 WSL 机器上用注入入参断言 —— 含 `/mnt/d/SteamLibrary`,换普通内核后 0 条 `/mnt/d…z`)、三平台各自成根且互不串台、纯函数纪律(确定性/无重复/退化入参不抛)、代码里的 `/tmp` 字面量与五类宿主绝对路径必须"同行可覆盖"(存量 25 处走逐条带理由的账本,**每条反查必须仍命中**,腐烂即判红)、shell 可移植(9 种 bash 4+ 写法 + shebang 与语法匹配)、文件名可移植、BOM/CRLF、G 段逐类合成反例必红 + 干净样本零发现,以及两处**变异自证**(删掉盘符枚举 ⇒ 判红;把路径写死 ⇒ 判红)。
616
619
  - **3.11.0:音乐卡片 seek 落错元素(真机 P0)+ 音频格式路由 + 预览框几何 + 网页帧三档 + 风险预检接口** ——
617
620
  ①**seek 落错元素**(真机:卡片显示总时长 `−3:22`、点轨道 90% 视频只从 11.53 → 12.69):根因两条 —— `npActiveVideo` 把 `display:none` 当成"这不是壁纸媒体"(容器档换档窗口里画面确实不可见,于是卡片掉进曲目分支、绑到**上一张壁纸残留的游离 `<audio>`**),且 `npTransport("seek")` 会"顺手"也 seek 那个游离 audio、`npSyncAudio` 的 `timeupdate` 又**无条件**把 audio 的时钟写进同一根进度条。现在卡片的**数据源/时长/进度/seek/静音/上报全部走唯一目标** `npMediaTarget()`(`{el, kind:'video'|'audio'|'none'}`,判据 = src 非空 + 视频档 + 非 web 档;残留 audio 只在**这张壁纸真有曲目清单**时才认);判据 `tools/np-control-test.mjs` T 组 9 条 + **2 条变异自证**(改回旧写法 ⇒ T 组红 6/1 条),真机探针 `tools/np-seek-live-probe.mjs`(`--selftest` 8/0;**真机那一次要等宿主重启后同步生效才跑**,见诚实清单)。
618
621
  ②**更多音频格式**:真凶在**路由**而不是扫描表 —— `/raw` 对非 mpkg/pkg/json 一律 `application/octet-stream`、`/custom-media` 没有音频分支、`/custom-dir` 白名单没有音频后缀,另有 `AUDIO_MAGIC_MIME` 的 ISO-BMFF 分支写成 `fByp`(ftyp 在偏移 4)且整体被 `n>=12` 包住。现在**唯一表** `AUDIO_SUFFIX_MIME`(`.mp3/.ogg/.oga/.opus/.wav/.flac/.m4a/.aac`,与渲染器 `server/upload-policy.mjs` 同一份名单)+ `audioMimeFor(path, head)`(magic 优先 / 后缀兜底 / 带 ID3 前缀的 `.flac` 例外),四条路由改查表(`audio-scan` 79/0、`scene-audio-route` 37/0,实测 `/raw` 与 `/custom-folder` 的 `.flac` 都是 `audio/flac`)。
@@ -0,0 +1,457 @@
1
+ // audio-bus.js —— 「跨出口的总线级静音」内核(宿主侧,纯逻辑,可在桩环境单测)
2
+ //
3
+ // 为什么要它(事故链):用户反复报「设置里静音了,但还是会偶发卡顿地响一声」。根因不是"静音按钮没生效",
4
+ // 而是**声音出口不止一个**:我们自己的音乐控件走 `<audio>`(muted 够用),但壁纸/角色语音/可视化可能走
5
+ // Web Audio——`new AudioContext()` 是**别人**建的、`source.connect(ctx.destination)` 也是**别人**连的,
6
+ // 我们连"谁在响"都看不见。上几轮我们只做到了**记录**(`mpwInstallAudioAudit` 的钩子),
7
+ // 记录不等于接管:只要有一个节点直连 `destination`,它就绕过了我们的静音。
8
+ //
9
+ // 本模块做**接管**,而且是**总线级**的(逐条对应审阅意见里点出的坑):
10
+ // ① `AudioNode.prototype.connect` 原型级拦截:任何节点连 `ctx.destination` ⇒ 改接到该 ctx 的
11
+ // **懒创建 masterGain**。原型级 ⇒ "比我们更早创建的 AudioContext"也能被收进来(只要它**还没**连);
12
+ // ② masterGain 的创建与它到 destination 的连接**走原始引用**(否则懒创建那一下会自己拦自己 ⇒ 递归);
13
+ // ③ `connect` 的**返回值原样透传**(`a.connect(b).connect(c)` 链式依赖它);
14
+ // ④ 判定用 `dest === ctx.destination`(**引用比较**;`instanceof` 跨 realm 会失败);
15
+ // ⑤ `OfflineAudioContext` **放行**(它也有 destination;误挂 master 会改掉离线渲染结果);
16
+ // ⑥ 静音 = `master.gain.cancelScheduledValues(now) + setValueAtTime(0, now)`(**不用 `ctx.suspend()`**:
17
+ // 冻结 `currentTime` 会破坏第三方的调度与可视化);analyser 若接在 master **之前**,频谱仍有数据
18
+ // ⇒ 这同时修掉我们"元素被 muted ⇒ 音条恒 0"的老限制;
19
+ // ⑦ 归因补齐:`new Audio()`、`createMediaElementSource`(元素 muted 只影响输入源,输出在 Web Audio)、
20
+ // `createMediaStreamSource`(不吃 `track.enabled`)、`src`/`srcObject` setter(autoplay 绕过 `play()`)、
21
+ // `start(when)` 的迟到分布(**负很多** = bfcache/节流恢复;**负一点点** = 正常抖动,不误杀)、
22
+ // Shadow DOM 里的媒体元素、`speechSynthesis`、`RTCPeerConnection` 的 `track.enabled`;
23
+ // ⑧ **跨 realm 各装一遍**(同源 iframe 的 `AudioNode.prototype` 与父页不是同一个)。
24
+ //
25
+ // 档位(`?mpwhardmute=` 或 `bus.setMode()`)—— 二分定位用,不是"总开关":
26
+ // off 只归因,不动声音(缺省)
27
+ // redirect **只重定向不静音**:还能听见 ⇒ 有出口绕过 connect(历史遗留直连 / 跨 realm);
28
+ // 声音变闷变小 ⇒ 有节点被**重复挂** master(多次 connect destination)
29
+ // 1 重定向 + master 归零 + 媒体压制 + speechSynthesis.cancel + WebRTC track.enabled=false
30
+ // all 1 + 对跨域帧 postMessage 请求静音 + 连未归因的 ctx 也压
31
+ // report 只汇报不压(产出"不可控清单")
32
+ //
33
+ // 诚实边界:hook 之前**已经**连上 destination 的节点无法追溯(没有任何 API 能枚举已有连接)⇒
34
+ // report() 会把"能观测到出声但不在我们 bus 上"的 ctx 单列为 `bypassSuspected`,不假装收全了。
35
+
36
+ export const BUS_MODES = ['off', 'redirect', '1', 'all', 'report'];
37
+ export const BUS_DEFAULT_MODE = 'off';
38
+ export const BUS_ATTR = 'data-mpw-audio-bus';
39
+ export const BUS_MSG = 'mpw:audio-bus';
40
+ /** 迟到补播的判定阈值:负得比这个多 ⇒ 当"恢复后补播"(bfcache/节流),否则算正常抖动,不误杀。 */
41
+ export const LATE_START_SEC = 1.0;
42
+ export const JITTER_START_SEC = 0.05;
43
+
44
+ const now = (win) => { try { return win.performance && win.performance.now ? win.performance.now() : Date.now() } catch (e) { return Date.now() } };
45
+
46
+ /** 规范化档位(未知值 ⇒ 缺省;不抛)。 */
47
+ export function normalizeMode(m) {
48
+ const s = String(m === undefined || m === null ? '' : m).trim().toLowerCase();
49
+ return BUS_MODES.indexOf(s) >= 0 ? s : BUS_DEFAULT_MODE;
50
+ }
51
+
52
+ /** 该档是否真的**压**声音(report/off/redirect 都不压)。 */
53
+ export function modeMutes(m) {
54
+ const s = normalizeMode(m);
55
+ return s === '1' || s === 'all';
56
+ }
57
+ /** 该档是否要连"未归因的 ctx"一起压。 */
58
+ export function modeMutesAll(m) { return normalizeMode(m) === 'all'; }
59
+ /** 该档是否只重定向(诊断档:验证 bus 收没收全)。 */
60
+ export function modeRedirectOnly(m) { return normalizeMode(m) === 'redirect'; }
61
+
62
+ /**
63
+ * 在一个 window 上装总线。**幂等**(同一 win 多次调用只装一遍)。
64
+ * @param {object} win 目标 window(可传 iframe 的 contentWindow ⇒ 跨 realm 各装一遍)
65
+ * @param {object} opts { mode, onEvent(name, detail), mediaRoots: ()=>Element[] }
66
+ * @returns {object|null} api
67
+ */
68
+ export function installAudioBus(win, opts = {}) {
69
+ if (!win || typeof win !== 'object') return null;
70
+ if (win.__mpwAudioBus && win.__mpwAudioBus.__installed) { // 幂等:同一 realm 只装一遍
71
+ if (opts.mode !== undefined) win.__mpwAudioBus.setMode(opts.mode);
72
+ return win.__mpwAudioBus;
73
+ }
74
+ const log = [];
75
+ const emit = (name, detail) => {
76
+ const rec = { t: now(win), name, detail: detail || null };
77
+ log.push(rec);
78
+ if (log.length > 300) log.shift();
79
+ try { if (typeof opts.onEvent === 'function') opts.onEvent(name, rec.detail) } catch (e) { /* 上报失败不许影响音频 */ }
80
+ };
81
+
82
+ const state = {
83
+ mode: normalizeMode(opts.mode),
84
+ muted: false,
85
+ wasMuted: false, // 上一次是否处于"真的压着"的状态(决定恢复时要不要写回 1)
86
+ contexts: [], // { id, ctx, adopted, offline, createAt, createStack, frames }
87
+ masters: new WeakMap(), // ctx -> masterGain
88
+ adopted: new Set(), // 被我们接管过的 ctx(**含**安装前就存在的老 ctx)——静音要遍历这一份
89
+ media: new Set(), // 媒体元素(含 new Audio() 与 shadow DOM 里的)
90
+ tappedMedia: new WeakSet(), // 被 createMediaElementSource 接管的元素(不能 pause:analyser 要数据)
91
+ redirects: 0,
92
+ bypassSuspected: [], // 能观测到出声但不在 bus 上的 ctx
93
+ uncontrollable: [], // 跨域帧(拿不到 contentDocument)
94
+ frames: [],
95
+ };
96
+ let ctxSeq = 0;
97
+ let timer = 0;
98
+ /* 本 realm 的**原始 connect**。必须是 `AudioNode.prototype.connect` —— 真实浏览器里
99
+ `AudioContext.prototype` 上**没有** connect(我第一版写错过,那样一条都拦不到)。 */
100
+ let realmOrigConnect = null;
101
+ let realmNodeProto = null;
102
+
103
+ const protoOf = (Ctor) => { try { return Ctor && Ctor.prototype ? Ctor.prototype : null } catch (e) { return null } };
104
+ const stackOf = () => { try { return String(new win.Error().stack || '').split('\n').slice(2, 5).join(' | ').slice(0, 400) } catch (e) { return '' } };
105
+
106
+ /* ── ① 原型级 connect 拦截(含"懒创建 masterGain") ───────────────────────────── */
107
+ const patched = []; // [{ proto, origConnect }],卸载时还原
108
+ const patchConnect = (proto) => {
109
+ if (!proto || typeof proto.connect !== 'function' || proto.__mpwBusPatched) return false;
110
+ const origConnect = proto.connect;
111
+ proto.connect = function mpwBusConnect(dest) {
112
+ const rest = Array.prototype.slice.call(arguments, 1);
113
+ let ctx = null;
114
+ try { ctx = this && this.context } catch (e) { /* 某些桩没有 context */ }
115
+ // 只拦"直连 destination";用**引用比较**(instanceof 跨 realm 会失败)
116
+ if (ctx && dest && dest === ctx.destination && !isOfflineCtx(ctx)) {
117
+ let master = state.masters.get(ctx);
118
+ if (!master) {
119
+ master = createMaster(ctx); // 内部全走原始引用
120
+ state.masters.set(ctx, master);
121
+ if (master) {
122
+ state.adopted.add(ctx);
123
+ /* 比我们更早创建、我们没登记过的 ctx(历史遗留):**采用时补登记**,
124
+ 否则 applyMute 遍历的名单里没有它 ⇒ 它照样出声(这正是"假绿"的来源)。 */
125
+ if (ctxId(ctx) < 0) state.contexts.push({ id: 'ctx' + (++ctxSeq), ctx, adopted: true, legacy: true, createAt: now(win), createStack: stackOf(), frames: 0 });
126
+ }
127
+ }
128
+ if (this !== master) state.redirects++;
129
+ emit('reroute', { ctxId: ctxId(ctx), kind: nodeKind(this), stack: stackOf() });
130
+ origConnect.call(this, master, ...rest);
131
+ /* ③ 真 API 语义:`connect(dest)` 返回**调用方传进来的那个目标节点**(链式 `a.connect(b).connect(c)` 依赖它)。
132
+ 若把 master 返回出去,第三方的 `a.connect(ctx.destination).connect(y)` 会变成 `master→y`(语义被改)。 */
133
+ return dest;
134
+ }
135
+ return origConnect.call(this, dest, ...rest);
136
+ };
137
+ proto.__mpwBusPatched = true;
138
+ patched.push({ proto, origConnect });
139
+ if (!realmOrigConnect) { realmOrigConnect = origConnect; realmNodeProto = proto }
140
+ return true;
141
+ };
142
+ /** ② masterGain 的创建与连接**必须走原始引用**(否则懒创建那一下会自己拦自己 ⇒ 递归)。 */
143
+ const createMaster = (ctx) => {
144
+ const origCreate = ctx.__origCreateGain || (ctx.createGain && ctx.createGain.bind(ctx));
145
+ const master = origCreate ? origCreate() : null;
146
+ if (!master) return null;
147
+ try {
148
+ if (master.gain) { master.gain.value = state.muted ? 0 : 1 }
149
+ const origConnect = realmOrigConnect;
150
+ if (origConnect) origConnect.call(master, ctx.destination); // 原始引用,绕开 hook
151
+ else if (master.connect) master.connect(ctx.destination);
152
+ } catch (e) { emit('master-connect-fail', { err: String(e && e.message || e) }) }
153
+ return master;
154
+ };
155
+ const isOfflineCtx = (ctx) => {
156
+ try {
157
+ const OAC = win.OfflineAudioContext || win.webkitOfflineAudioContext;
158
+ return !!(OAC && ctx instanceof OAC); // ⑤ 离线渲染放行
159
+ } catch (e) { return false }
160
+ };
161
+ const ctxId = (ctx) => { const f = state.contexts.find((c) => c.ctx === ctx); return f ? f.id : -1 };
162
+ const nodeKind = (n) => { try { return String((n && n.constructor && n.constructor.name) || 'AudioNode') } catch (e) { return 'AudioNode' } };
163
+
164
+ /* ── ② AudioContext 构造拦截:登记 + 归因(接管本身靠 connect,构造只登记) ───── */
165
+ const patchCtor = (winObj, key) => {
166
+ try {
167
+ const Orig = winObj[key];
168
+ if (typeof Orig !== 'function' || Orig.__mpwBusWrapped) return;
169
+ const Wrapped = function (...args) {
170
+ const ctx = new Orig(...args);
171
+ try {
172
+ state.contexts.push({ id: 'ctx' + (++ctxSeq), ctx, adopted: false, offline: false, createAt: now(win), createStack: stackOf(), frames: 0 });
173
+ emit('ctx-created', { ctor: key, state: ctx && ctx.state });
174
+ } catch (e) {}
175
+ return ctx;
176
+ };
177
+ try { Wrapped.prototype = Orig.prototype } catch (e) {}
178
+ Wrapped.__mpwBusWrapped = true;
179
+ winObj[key] = Wrapped; // 构造只负责**登记/归因**;接管靠下面的 AudioNode.prototype
180
+ } catch (e) {}
181
+ };
182
+
183
+ /* ── ③ 归因钩子:MediaElement / MediaStream / src / srcObject / start(when) / speech / WebRTC ── */
184
+ const patchAttribution = () => {
185
+ const ACproto = protoOf(win.AudioContext || win.webkitAudioContext);
186
+ if (ACproto && !ACproto.__mpwBusAttr) {
187
+ ACproto.__mpwBusAttr = true;
188
+ for (const [fn, kind] of [['createMediaElementSource', 'media-element'], ['createMediaStreamSource', 'media-stream'], ['createMediaStreamDestination', 'media-stream-dest']]) {
189
+ try {
190
+ const orig = ACproto[fn];
191
+ if (typeof orig !== 'function') continue;
192
+ ACproto[fn] = function (...a) {
193
+ const node = orig.apply(this, a);
194
+ try {
195
+ if (kind === 'media-element' && a[0]) { state.tappedMedia.add(a[0]); registerMedia(a[0]); }
196
+ emit('source-node', { kind, ctxId: -1, stack: stackOf() });
197
+ } catch (e) {}
198
+ return node;
199
+ };
200
+ } catch (e) {}
201
+ }
202
+ }
203
+ // start(when):迟到分布(负很多 = bfcache/节流恢复;负一点点 = 抖动)
204
+ for (const [key, kind] of [['AudioBufferSourceNode', 'buffer'], ['OscillatorNode', 'oscillator'], ['ConstantSourceNode', 'constant'], ['AudioWorkletNode', 'worklet']]) {
205
+ try {
206
+ const Ctor = win[key]; const np = protoOf(Ctor);
207
+ if (!np || typeof np.start !== 'function' || np.__mpwBusStart) continue;
208
+ const origStart = np.start;
209
+ np.start = function (when) {
210
+ const rest = Array.prototype.slice.call(arguments, 1);
211
+ try {
212
+ const ctx = this.context;
213
+ const cur = ctx && ctx.currentTime;
214
+ const delta = (typeof when === 'number' && typeof cur === 'number') ? (when - cur) : null;
215
+ /* 只**记录**、绝不阻止/丢弃 start("误杀会掉音符"):负得多 ⇒ burst(bfcache/节流恢复后的补播);
216
+ 负一点点 ⇒ jitter(正常调度抖动,进分布供人判读)。未来时刻不记。 */
217
+ if (delta !== null && delta < 0) {
218
+ emit(delta < -LATE_START_SEC ? 'start-late-burst' : 'start-late-jitter', { kind, delta: Number(delta.toFixed(3)), mode: state.mode, muted: state.muted, stack: stackOf() });
219
+ }
220
+ } catch (e) {}
221
+ return origStart.apply(this, arguments);
222
+ };
223
+ np.__mpwBusStart = true;
224
+ } catch (e) {}
225
+ }
226
+ // new Audio() / src / srcObject(autoplay + src 赋值绕过 play())
227
+ try {
228
+ const OrigAudio = win.Audio;
229
+ if (typeof OrigAudio === 'function' && !OrigAudio.__mpwBusAudio) {
230
+ const Wrapped = function (...a) { const el = new OrigAudio(...a); try { registerMedia(el); emit('new-audio', { src: shortSrc(el && el.src) }) } catch (e) {} return el };
231
+ try { Wrapped.prototype = OrigAudio.prototype } catch (e) {}
232
+ Wrapped.__mpwBusAudio = true;
233
+ win.Audio = Wrapped;
234
+ }
235
+ } catch (e) {}
236
+ const HME = win.HTMLMediaElement && win.HTMLMediaElement.prototype;
237
+ if (HME && !HME.__mpwBusMedia) {
238
+ HME.__mpwBusMedia = true;
239
+ for (const prop of ['src', 'srcObject']) {
240
+ try {
241
+ const d = Object.getOwnPropertyDescriptor(HME, prop);
242
+ if (!d || !d.set) continue;
243
+ const origSet = d.set, origGet = d.get;
244
+ Object.defineProperty(HME, prop, {
245
+ configurable: true, enumerable: d.enumerable,
246
+ get: origGet,
247
+ set(v) {
248
+ try { if (state.muted && modeMutes(state.mode)) suppress(this) } catch (e) {}
249
+ emit(prop === 'src' ? 'src-set' : 'srcobject-set', { src: prop === 'src' ? shortSrc(v) : '[stream]', muted: !!(this && this.muted) });
250
+ return origSet.call(this, v);
251
+ },
252
+ });
253
+ } catch (e) {}
254
+ }
255
+ // play():静音期间**压制**且返回 resolved Promise(不排队——排队会在取消静音瞬间多声齐发)
256
+ try {
257
+ const origPlay = HME.play;
258
+ if (typeof origPlay === 'function' && !HME.__mpwBusPlay) {
259
+ HME.__mpwBusPlay = true;
260
+ HME.play = function () {
261
+ try {
262
+ if (state.muted && modeMutes(state.mode)) {
263
+ suppress(this);
264
+ emit('play-suppressed', { src: shortSrc(this && this.src), stack: stackOf() });
265
+ return win.Promise ? win.Promise.resolve() : undefined; // 必须 resolved,否则调用方 await 卡住
266
+ }
267
+ } catch (e) {}
268
+ return origPlay.apply(this, arguments);
269
+ };
270
+ }
271
+ } catch (e) {}
272
+ // volumechange:静音下有人抬 volume/muted ⇒ 立刻归零 + 记栈
273
+ try {
274
+ if (!HME.__mpwBusVol) {
275
+ HME.__mpwBusVol = true;
276
+ const origAdd = HME.addEventListener;
277
+ if (typeof origAdd === 'function') {
278
+ const self = win;
279
+ origAdd.call(self.document || self, 'volumechange', (e) => {
280
+ const el = e && e.target;
281
+ if (!el || !state.muted || !modeMutes(state.mode)) return;
282
+ if (!el.muted || Number(el.volume) > 0) {
283
+ emit('volume-raised-while-muted', { muted: !!el.muted, volume: Number(el.volume), stack: stackOf() });
284
+ suppress(el);
285
+ }
286
+ }, true);
287
+ }
288
+ }
289
+ } catch (e) {}
290
+ }
291
+ // speechSynthesis(角色语音可能走它,完全不受 <audio>/Web Audio 控制)
292
+ try {
293
+ const ss = win.speechSynthesis;
294
+ if (ss && typeof ss.speak === 'function' && !ss.__mpwBusSpeech) {
295
+ const origSpeak = ss.speak.bind(ss);
296
+ ss.__mpwBusSpeech = true;
297
+ ss.speak = function (u) {
298
+ if (state.muted && modeMutes(state.mode)) { try { ss.cancel() } catch (e) {} ; emit('speech-suppressed', { text: String(u && u.text || '').slice(0, 60) }); return }
299
+ emit('speech', { text: String(u && u.text || '').slice(0, 60) });
300
+ return origSpeak(u);
301
+ };
302
+ }
303
+ } catch (e) {}
304
+ // WebRTC:outbound 的 track.enabled 是唯一能压它的开关
305
+ try {
306
+ const RTC = win.RTCPeerConnection;
307
+ if (typeof RTC === 'function' && !RTC.__mpwBusRtc) {
308
+ const Wrapped = function (...a) {
309
+ const pc = new RTC(...a);
310
+ try {
311
+ pc.addEventListener('track', (ev) => {
312
+ const tracks = (ev && ev.streams && ev.streams[0] && ev.streams[0].getTracks) ? ev.streams[0].getTracks() : [];
313
+ for (const t of tracks) { try { if (t.kind === 'audio' && state.muted && modeMutes(state.mode)) t.enabled = false } catch (e) {} }
314
+ emit('rtc-track', { n: tracks.length });
315
+ });
316
+ } catch (e) {}
317
+ return pc;
318
+ };
319
+ try { Wrapped.prototype = RTC.prototype } catch (e) {}
320
+ Wrapped.__mpwBusRtc = true;
321
+ win.RTCPeerConnection = Wrapped;
322
+ }
323
+ } catch (e) {}
324
+ };
325
+
326
+ const shortSrc = (s) => String(s || '').replace(/token=[^&]*/, 'token=…').slice(0, 120);
327
+
328
+ const registerMedia = (el) => {
329
+ if (!el || typeof el !== 'object') return false;
330
+ if (state.media.has(el)) return false;
331
+ state.media.add(el);
332
+ return true;
333
+ };
334
+ /** 收集媒体元素:document + **Shadow DOM**(querySelectorAll 抓不到 shadow root 里的)+ 同源 iframe。 */
335
+ const collectMedia = (doc, out) => {
336
+ try {
337
+ for (const el of doc.querySelectorAll('audio,video')) out.push(el);
338
+ for (const host of doc.querySelectorAll('*')) { if (host.shadowRoot) collectMedia(host.shadowRoot, out) }
339
+ } catch (e) {}
340
+ return out;
341
+ };
342
+ const allMedia = () => {
343
+ const out = [];
344
+ try { collectMedia(win.document, out) } catch (e) {}
345
+ for (const el of state.media) out.push(el);
346
+ if (typeof opts.mediaRoots === 'function') { try { for (const el of opts.mediaRoots() || []) out.push(el) } catch (e) {} }
347
+ return out;
348
+ };
349
+ /** 压一个媒体元素:**被 createMediaElementSource 接管的不要 pause**(analyser 要数据,暂停 = 频谱恒 0)。 */
350
+ const suppress = (el) => {
351
+ try { el.muted = true } catch (e) {}
352
+ try {
353
+ if (state.tappedMedia.has(el)) { /* 只总线归零,保留元素输出给 analyser */ }
354
+ else { el.volume = 0; if (typeof el.pause === 'function' && !el.paused) el.pause() }
355
+ } catch (e) {}
356
+ };
357
+
358
+ /* ── ④ 总线静音:只动我们自己的 masterGain(不 suspend:冻结 currentTime 会破坏调度/可视化) ── */
359
+ const applyMute = () => {
360
+ const mute = state.muted && modeMutes(state.mode);
361
+ /* 非静音档(off/report/redirect)**不碰 gain**:我们的 master 初值就是 1,没人会去改它;
362
+ 反过来"每次都写 1"会盖掉第三方自己对 master 的调整,也会让 redirect 诊断档失去意义
363
+ (它要能回答"bus 到底收没收全",而不是顺手改了音量)。只有**从静音恢复**那一次必须写回 1。 */
364
+ if (!mute && !state.wasMuted) return;
365
+ state.wasMuted = mute;
366
+ for (const ctx of state.adopted) {
367
+ const master = state.masters.get(ctx);
368
+ if (!master || !master.gain) continue;
369
+ try {
370
+ const t = ctx.currentTime;
371
+ if (master.gain.cancelScheduledValues) master.gain.cancelScheduledValues(t);
372
+ if (master.gain.setValueAtTime) master.gain.setValueAtTime(mute ? 0 : 1, t);
373
+ else master.gain.value = mute ? 0 : 1;
374
+ } catch (e) { try { master.gain.value = mute ? 0 : 1 } catch (e2) {} }
375
+ }
376
+ for (const el of allMedia()) { try { if (mute) suppress(el); else { el.muted = false } } catch (e) {} }
377
+ if (mute) { try { win.speechSynthesis && win.speechSynthesis.cancel && win.speechSynthesis.cancel() } catch (e) {} }
378
+ emit('apply-mute', { muted: state.muted, mode: state.mode, contexts: state.contexts.length, media: state.media.size });
379
+ };
380
+
381
+ /* ── ⑤ 跨域帧:压不动就**如实报不可控**;同源帧各装一遍(跨 realm) ── */
382
+ const syncFrames = () => {
383
+ state.uncontrollable = [];
384
+ let frames = [];
385
+ try { frames = Array.prototype.slice.call(win.document.querySelectorAll('iframe')) } catch (e) {}
386
+ state.frames = frames.map((f, i) => {
387
+ let sameOrigin = false, cw = null;
388
+ try { cw = f.contentWindow; sameOrigin = !!(cw && cw.document) } catch (e) { sameOrigin = false }
389
+ if (sameOrigin && cw) { try { installAudioBus(cw, { mode: state.mode, onEvent: opts.onEvent, mediaRoots: opts.mediaRoots }) } catch (e) {} }
390
+ else state.uncontrollable.push({ i, src: shortSrc(f.src || f.getAttribute && f.getAttribute('src')) });
391
+ return { i, sameOrigin, src: shortSrc(f.src || '') };
392
+ });
393
+ if (modeMutesAll(state.mode)) {
394
+ for (const f of frames) { try { f.contentWindow && f.contentWindow.postMessage({ type: BUS_MSG, muted: state.muted, mode: state.mode }, '*') } catch (e) {} }
395
+ }
396
+ return state.frames;
397
+ };
398
+
399
+ const api = {
400
+ __installed: true,
401
+ get mode() { return state.mode },
402
+ get muted() { return state.muted },
403
+ setMode(m) { state.mode = normalizeMode(m); applyMute(); emit('mode', { mode: state.mode }); return state.mode },
404
+ setMuted(v) {
405
+ state.muted = !!v;
406
+ syncFrames();
407
+ applyMute();
408
+ if (state.muted && modeMutes(state.mode)) { // "偶发" ⇒ 周期性重压(新节点/自动化都要压住)
409
+ if (!timer) timer = win.setInterval(() => { try { applyMute(); syncFrames() } catch (e) {} }, 750);
410
+ } else if (timer) { try { win.clearInterval(timer) } catch (e) {} ; timer = 0 }
411
+ return state.muted;
412
+ },
413
+ installFrame(cw, o) { return installAudioBus(cw, Object.assign({ mode: state.mode, onEvent: opts.onEvent }, o || {})) },
414
+ syncFrames,
415
+ /** 自上报快照(探针/diag 直接读它,**不需要用户操作**)。 */
416
+ report() {
417
+ return {
418
+ mode: state.mode, muted: state.muted, redirects: state.redirects,
419
+ contexts: state.contexts.map((c) => Object.assign({}, c, { ctx: undefined, adopted: state.masters.has(c.ctx), state: safeState(c.ctx) })),
420
+ masters: state.contexts.filter((c) => state.masters.has(c.ctx)).length,
421
+ media: allMedia().length, registered: state.media.size, tapped: countTapped(),
422
+ bypassSuspected: state.bypassSuspected.slice(0, 12),
423
+ uncontrollableFrames: state.uncontrollable.slice(0, 12),
424
+ frames: state.frames.slice(0, 12),
425
+ log: log.slice(-40),
426
+ };
427
+ },
428
+ log: () => log.slice(-100),
429
+ dispose() {
430
+ if (timer) { try { win.clearInterval(timer) } catch (e) {} ; timer = 0 }
431
+ for (const p of patched) { try { p.proto.connect = p.origConnect; p.proto.__mpwBusPatched = false } catch (e) {} }
432
+ try { delete win.__mpwAudioBus } catch (e) { win.__mpwAudioBus = null }
433
+ },
434
+ };
435
+ const safeState = (ctx) => { try { return ctx.state } catch (e) { return null } };
436
+ const countTapped = () => { let n = 0; for (const el of state.media) if (state.tappedMedia.has(el)) n++; return n };
437
+
438
+ patchCtor(win, 'AudioContext'); patchCtor(win, 'webkitAudioContext');
439
+ /* ④ `AudioNode.prototype` 每 realm 各装一遍(同源 iframe 的 prototype 与父页不是同一个);
440
+ 用 `AudioNode` 而不是 `AudioContext` —— 后者身上没有 connect。 */
441
+ if (!patchConnect(protoOf(win.AudioNode))) patchConnect(protoOf(win.AudioContext));
442
+ patchAttribution();
443
+ try { win.__mpwAudioBus = api } catch (e) {}
444
+ try {
445
+ const doc = win.document;
446
+ if (doc && doc.addEventListener) {
447
+ const onVis = () => { emit('visibility', { state: doc.visibilityState }); if (state.muted && modeMutes(state.mode)) applyMute() };
448
+ doc.addEventListener('visibilitychange', onVis, true);
449
+ win.addEventListener && win.addEventListener('pageshow', (e) => { emit('pageshow', { persisted: !!(e && e.persisted) }); if (state.muted && modeMutes(state.mode)) applyMute() }, true);
450
+ win.addEventListener && win.addEventListener('message', (e) => { // 跨域帧配合时的入口
451
+ try { if (e && e.data && e.data.type === BUS_MSG) api.setMuted(!!e.data.muted) } catch (err) {}
452
+ }, false);
453
+ }
454
+ } catch (e) {}
455
+ emit('installed', { mode: state.mode });
456
+ return api;
457
+ }