dsh-mpkg-wallpaper 3.13.8 → 3.13.9

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
@@ -400,6 +400,7 @@ Start here when something is wrong: one click sends state back to the local host
400
400
  | `?mpwstore=0\|mem` | Disable the in-frame storage facade / keep it in memory without persisting |
401
401
  | `?mpwpersist=legacy` | Settings persistence back to the old behaviour |
402
402
  | `?bgwrapfix=legacy`, `?hdrfrostwatch=off` | Wallpaper-layer visibility / frost watchdog |
403
+ | `?hiddengate=legacy` | **Whole hidden-gate family back to the old behaviour** (no more blocking our autoplay/retries while the page is hidden, frozen or background-reloaded; for A/B and emergencies. Default = never start playback while hidden, stop every sound surface we own when hidden, resume per policy on return; see [`docs/WALLPAPER-LIFECYCLE.md`](docs/WALLPAPER-LIFECYCLE.md) §6.4) |
403
404
 
404
405
  ### Renderer-side diagnostics flags (pure Scene wallpapers)
405
406
 
package/README.md CHANGED
@@ -401,6 +401,7 @@ node tools/build-bundle.mjs
401
401
  | `?mpwstore=0\|mem` | 关帧内存储 facade / 只留内存不落盘 |
402
402
  | `?mpwpersist=legacy` | 设置持久化回旧行为 |
403
403
  | `?bgwrapfix=legacy`、`?hdrfrostwatch=off` | 壁纸层可见性 / 磨砂看门狗 |
404
+ | `?hiddengate=legacy` | **隐藏闸门整族回旧行为**(后台挂载/冻结/切页时不再拦我们的起播与重试;A/B 与线上救急用。默认档=页面隐藏时一律不起播、隐藏时停住所有我们的出声面、回到可见按当时策略恢复;详见 [`docs/WALLPAPER-LIFECYCLE.md`](docs/WALLPAPER-LIFECYCLE.md) §6.4) |
404
405
 
405
406
  ### 渲染器侧诊断开关(纯 Scene 壁纸)
406
407
 
package/lib/client.js CHANGED
@@ -147,7 +147,13 @@ function mpwTrace(ev, data) {
147
147
  count("retries");
148
148
  try { console.warn("[dsh-mpkg-wallpaper] 🔁 " + tag + " rs=0 无元数据 → 第 " + tries + "/" + RETRY_MAX + " 次补 load()(上游 Firefox Bug " + BUG + ";依据渲染器仓探针 tests/mpkg-videobase-recovery-probe.mjs:只补一次 load() 即恢复)"); } catch (e) {}
149
149
  try { el.load() } catch (e) {}
150
- try { const p = el.play(); if (p && typeof p.catch === "function") p.catch(() => {}) } catch (e) {}
150
+ /* H(2026-09-25) hidden 期间**只补 load()、不起播**(补 load 是为了解上游 Bug 2056444 的
151
+ 元数据挂起;起播要等可见 —— 见 mpwHiddenBootGate 那一节)。
152
+ 写法用 `typeof … === "function"` 是**故意的**:本块被 tools/blob-media-retry-test.mjs
153
+ 按 BEGIN/END 标记**整段切出来独立求值**(那条判据 S1-4 明确要求"不依赖闭包里的其它名字")
154
+ ⇒ 生产里它是闭包函数、照常过闸;切出来跑时它是 undefined、行为与修前逐字一致。
155
+ "这一发真的接上了闸门"由 tools/hidden-gate-test.mjs 的源码锚点单独钉住(删掉即红)。 */
156
+ if (!(typeof mpwPlayBlockedBy === "function" && mpwPlayBlockedBy("blob-retry"))) { try { const p = el.play(); if (p && typeof p.catch === "function") p.catch(() => {}) } catch (e) {} }
151
157
  timer = setTimeout(tick, RETRY_GAP_MS);
152
158
  } catch (e) {}
153
159
  };
@@ -819,7 +825,9 @@ function sectionSigNow() {
819
825
  img.src = desired;
820
826
  try { img.load && img.load() } catch {}
821
827
  }
822
- if (video) { try { video.load(); const p = video.play(); if (p && p.catch) p.catch(() => {}) } catch {} }
828
+ /* H(2026-09-25) 这条 120ms 延迟重放是**后台重载**那条路上最容易漏的一发:它不查 hidden,
829
+ 而 `video.load()` 之后的 `play()` 正是"挂载期起播"的形态(hidden 时不 play)。 */
830
+ if (video) { try { video.load(); if (!mpwPlayBlockedBy("auto-refresh")) { const p = video.play(); if (p && p.catch) p.catch(() => {}) } } catch {} }
823
831
  try { applyFromStorage(); } catch (e) { mpwErr("applyFromStorage(autoRefresh 延迟重放)", e); }
824
832
  } catch (e) {}
825
833
  }, 120);
@@ -3517,7 +3525,9 @@ function mpwThumbFail(el, why) {
3517
3525
  // 无条件 play(),把用户手动暂停解开(用户实测"暂停后开启效果又解开暂停")。
3518
3526
  // ①(NP-4) 门控**在 npPrimePlay 里**(它自己判 wallUserPaused/powPaused),
3519
3527
  // 并且把"静音/电平落点"挪到**起播成功之后**(顺序根因见 npPrimePlay 注释)。
3520
- try { npPrimePlay(video); } catch {}
3528
+ // H(2026-09-25) 再加一道**挂载期**闸门(在 npPrimePlay 之前):挂载完成时页面隐藏 ⇒
3529
+ // 一律不起播,只记一条台账,等第一次可见时按当时策略恢复(见 mpwHiddenBootGate)。
3530
+ if (!mpwHiddenBootGate("showVideoEl")) { try { npPrimePlay(video); } catch {} }
3521
3531
  /* ②(2026-09-20 审计第 2 条) 视频档同样验活:源不可用 ⇒ 撤 src + 记状态 + 层隐藏
3522
3532
  (否则是一块黑屏,用户分不清"视频坏了"和"壁纸就是这个颜色")。 */
3523
3533
  try {
@@ -4514,6 +4524,11 @@ function mpwThumbFail(el, why) {
4514
4524
  // ①(修正) frame.muted 逻辑曾写反(!(mute) → 默认反而出声);现在正向。
4515
4525
  applyWebMute(frame);
4516
4526
  frame.style.display = "";
4527
+ /* H(2026-09-25) **挂载期隐藏 ⇒ 帧一律先 park**:web/场景壁纸的作者脚本在 load 之后会
4528
+ 自己起播(`?audio=1` 的场景音轨、web 壁纸的 BGM),而这条路上我们**没有** play()
4529
+ 可拦(声音是帧内作者发起的)⇒ 唯一能做的就是挂载后立刻把这一页收起来。
4530
+ `applyWebMute` 里已按 hidden 下发 park(含渲染器图级通道 + shim 通道),这里补记台账。 */
4531
+ try { if (String(frame.getAttribute("src") || "") && mpwHiddenBootGate("showWebEl")) pauseWebFrame(); } catch (e) {}
4517
4532
  // ⑳(新) web 壁纸倍速:iframe 内 <video> 的 playbackRate(同源可访问;
4518
4533
  // 部分 web 壁纸用 canvas/WebGL 渲染无 <video>,静默跳过)
4519
4534
  try { applyWebSpeed(frame); } catch {}
@@ -4631,6 +4646,29 @@ function mpwThumbFail(el, why) {
4631
4646
  /* ①(2026-09-25 任务 ㉑ 追加) 帧内音频归属/暂停状态也要跟着走:`npFrameSoundBlocked()` 为真
4632
4647
  (卡片暂停/我们真的在放音)时,把这一页**收起来**(停源),否则只是压增益。 */
4633
4648
  try { if (npFrameSoundBlocked()) sendRendererAudioPolicy(frame, true, true, "np-owns"); } catch {}
4649
+ /* H(2026-09-25) **隐藏/冻结 ⇒ 帧一律 park**:这是"web 帧 / 场景帧(`?audio=1`)在后台
4650
+ 挂载时仍起播"那条路的唯一落点(跨源帧读不到 contentDocument、`frame.muted` 对 iframe
4651
+ 本来就不是标准属性、shim 只覆盖 shim 帧)。park 会走渲染器侧 pause + ctx.suspend。
4652
+ 只在**真的挂了帧**(有 src)时下发,避免对空帧刷 shim 失败台账。 */
4653
+ try {
4654
+ if (frame && String(frame.getAttribute("src") || "") && mpwHiddenAudioBlock()) {
4655
+ if (sendRendererAudioPolicy(frame, true, true, "hidden-park")) {
4656
+ mpwHiddenLedgerPush("frame-park", { src: String(frame.getAttribute("src") || "").slice(0, 60) });
4657
+ }
4658
+ }
4659
+ } catch (e) {}
4660
+ /* H(2026-09-25 ③) **如实标注"这个帧我们够不着"**:不透明源(sandbox 无 allow-same-origin)
4661
+ 且不是 shim 帧 ⇒ `contentDocument` 为 null、shim 通道不存在、`frame.muted` 又是空写 ⇒
4662
+ 我们对它**一个字节都控制不了**。这里只记账(绝不假装"已经静音了"),并写进
4663
+ `window.__mpwUnreachableFrames` 供真机归因(owner=frame、无可用通道)。 */
4664
+ try {
4665
+ if (frame && String(frame.getAttribute("src") || "") && !frame.contentDocument && !mpwWebShimUrl(frame.getAttribute("src"))) {
4666
+ const rec = { at: Date.now(), src: String(frame.getAttribute("src") || "").replace(/token=[^&]*/, "token=…").slice(0, 90), why: "opaque-origin/no-shim → 我们的静音与暂停都到不了这个帧" };
4667
+ const a = window.__mpwUnreachableFrames || (window.__mpwUnreachableFrames = []);
4668
+ a.push(rec); if (a.length > 8) a.shift();
4669
+ if (!mpwHiddenLedgerSeen("frame-mute-unreachable")) mpwHiddenLedgerPush("frame-mute-unreachable", { src: rec.src });
4670
+ }
4671
+ } catch (e) {}
4634
4672
  // ①(2026-09-16 I 项) shim 通道:帧内静音由 shim 执行(不透明源读不到帧内元素)
4635
4673
  if (frame && mpwWebShimUrl(frame.getAttribute("src"))) {
4636
4674
  const want = { muted: mute, speed: npPolicySpeed(), volume: npWantVolume() };
@@ -4795,13 +4833,157 @@ function mpwThumbFail(el, why) {
4795
4833
  return s.powPauseHidden !== void 0 ? !!s.powPauseHidden : DEFAULT_POW_PAUSE_HIDDEN;
4796
4834
  } catch (e) { return DEFAULT_POW_PAUSE_HIDDEN }
4797
4835
  }
4798
- function mpwHiddenAudioBlock() {
4836
+ /* ══════════════════════════════════════════════════════════════════════════════════
4837
+ H(2026-09-25 真机修复)「**后台挂载期起播**」—— C 那一轮的三个缺口
4838
+ ──────────────────────────────────────────────────────────────────────────────────
4839
+ 现场(用户新报,与 C 那次**不同**):
4840
+ 「后台只有 Termux + Via 在跑;在哔哩哔哩 App 里看视频时**突然冒出声音**,与 B 站音频
4841
+ 叠加(B 站没被暂停);回到 Via 发现 DSH 页面**需要重新加载**(= 页面此前被 Android
4842
+ 冻结/丢弃/静默重载过)」。
4843
+ 三个现场特征指向**同一条链**:Android 把后台标签冻结→丢弃→**在后台重新加载**,
4844
+ 于是我们的插件在 `document.hidden === true` 的状态下**走了一遍完整挂载** ——
4845
+ · ① `visibilitychange` **可能根本不派发**(页面一挂载就已经是 hidden)⇒ 只挂在事件上的
4846
+ `powPauseHidden` 那一路(`setupPowerSave` 里的 `updatePowerPause()`)没跑,
4847
+ `powPaused` 仍是 **false**;
4848
+ · ② 各路挂载/重试路径的 `play()` 里,只有 `npPrimePlay` / `resumeWallpaperVideo` 查了
4849
+ `mpwHiddenAudioBlock()`;**其余 14 处只查 `!(wallUserPaused || powPaused)`,一处都
4850
+ 没查 `document.hidden`**(C 那轮只收口了"补起播"三条,见下表);
4851
+ · ③ `powPaused` 是**快照**:C 的判据是"上一轮省电跑过",冻结/丢弃后台重载这条路上它
4852
+ 从来没被置真 ⇒ C 的闸门在**这一条路径上等于没有**。
4853
+ 修法(与 C 同一个判据 `mpwHiddenAudioBlock()`,只是把落点补全 + 补启动期状态机):
4854
+ ① 启动期:挂载完成时 `document.hidden === true` ⇒ **一律不起播**(视频/BGM/web 帧/
4855
+ 场景帧/`npPrimePlay` 全部),只记**一条**台账;等到第一次
4856
+ `visibilitychange → visible`(或用户手势)再**按当时策略**恢复。
4857
+ 两种状态**必须可区分**(现场排障就靠这个):
4858
+ · `never-started`(`mpwHiddenBootBlocked`):从没起播过 ⇒ 回到可见时**可以**起播;
4859
+ · `paused-by-hidden`(`mpwHiddenPausedByUs`):隐藏前在播、被我们停的 ⇒ 回来按
4860
+ 原状态续播(`powHidWallWasPlaying` 那条既有语义,不动)。
4861
+ ② 隐藏/冻结/丢弃:`visibilitychange`(hidden) / `pagehide` / **`freeze`**(新增;不存在就
4862
+ 静默跳过)时把**所有我们的出声面**停住:`video.pause()`、web 帧下发 `park`、
4863
+ 场景帧下发 `park`(`sendRendererAudioPolicy(frame,true,true,…)`)。
4864
+ `AudioContext.suspend()`:**我们自己的面里没有 AudioContext**(唯一的 WebAudio 是
4865
+ 帧内作者的,跨源只能靠 `park` 让渲染器自己 suspend;总线的既有决定是"压增益而不是
4866
+ suspend",理由见 `MPW-AUDIO-BUS` 的注释)⇒ 不碰别人的 ctx,只**如实记账**。
4867
+ ③ 只对**我们自己的面**负责:第三方(鲸鱼挂件等)一个字节都不碰,但审计里如实标 owner,
4868
+ 且"hidden 期间起播"这条**无论 owner 是谁都记一条**(含 owner/src/栈,见 `mpwAuditPush`)。
4869
+ 语义与开关(两条逃生门,都登记在文档里):
4870
+ · 复用既有设置项 `powPauseHidden`:显式关掉 ⇒ 本闸门一并失效(= "切页也出声是我选的");
4871
+ · 新增 `?hiddengate=legacy`(URL,刷新即生效):**整族**回到没有 hidden 闸门的旧行为,
4872
+ A/B 对照与线上救急用(见 README「插件侧 URL 逃生口」表 + docs/WALLPAPER-LIFECYCLE.md)。
4873
+ 判据(离线、无浏览器):`node tools/hidden-gate-test.mjs`(含变异自证:删掉任一新增门控 ⇒ 必红)。
4874
+ ══════════════════════════════════════════════════════════════════════════════════ */
4875
+ function mpwHiddenGateLegacy() {
4876
+ try { return new URLSearchParams(location.search).get("hiddengate") === "legacy" } catch (e) { return false }
4877
+ }
4878
+ let mpwHiddenFrozen = false; // 见过 freeze(Android 冻结)⇒ 视为隐藏,直到 resume/可见
4879
+ let mpwHiddenBootBlocked = false; // 挂载期因隐藏被挡住(**从没起播过**)
4880
+ let mpwHiddenPausedByUs = false; // 隐藏时我们按下的暂停(**被隐藏暂停过**)
4881
+ let mpwHiddenBootLogged = false; // 启动期那条台账只记一次("只记一条")
4882
+ let mpwHiddenLedger = []; // 有界台账(≤64,真机可读:window.__mpwHiddenLedger)
4883
+ /** 台账:**只记账、零行为影响**。真机上"这次到底谁起的 / 为什么没起"就靠它。
4884
+ * **合并规则**(必须):有些调用点是 800ms/2s 周期驱动的(`applyWebMute` / `npApplyFrameMute`)
4885
+ * ⇒ 同一 `(kind, where, src)` 在 2s 内只留第一条,否则 64 条的环会被"同一件事"刷满,
4886
+ * 真正要看的那几条(`boot-hidden-no-autoplay` / `pause` / `hidden-play`)会被挤出去。 */
4887
+ function mpwHiddenLedgerPush(kind, extra) {
4888
+ try {
4889
+ const x = extra || {};
4890
+ const last = mpwHiddenLedger[mpwHiddenLedger.length - 1];
4891
+ if (last && last.kind === String(kind || "") && last.where === x.where && last.src === x.src
4892
+ && (Date.now() - Number(last.t || 0)) < 2000) return last;
4893
+ const rec = Object.assign({ t: Date.now(), kind: String(kind || ""),
4894
+ hidden: (() => { try { return document.hidden === true } catch (e) { return null } })(),
4895
+ vis: (() => { try { return String(document.visibilityState || "") } catch (e) { return "" } })(),
4896
+ pow: !!powPaused, block: null, bootBlocked: !!mpwHiddenBootBlocked, pausedByUs: !!mpwHiddenPausedByUs,
4897
+ frozen: !!mpwHiddenFrozen, legacy: mpwHiddenGateLegacy(), gateOn: mpwHiddenPauseOn(),
4898
+ }, extra || {});
4899
+ try { rec.block = mpwHiddenAudioBlock() } catch (e) {}
4900
+ mpwHiddenLedger.push(rec);
4901
+ if (mpwHiddenLedger.length > 64) mpwHiddenLedger.splice(0, mpwHiddenLedger.length - 64);
4902
+ try { window.__mpwHiddenLedger = mpwHiddenLedger } catch (e) {}
4903
+ return rec;
4904
+ } catch (e) { return null }
4905
+ }
4906
+ /** 台账去重读法:同类事件只记第一条(周期函数每 800ms 调一次,不刷屏)。 */
4907
+ function mpwHiddenLedgerSeen(kind) {
4908
+ try { for (let i = 0; i < mpwHiddenLedger.length; i++) if (mpwHiddenLedger[i] && mpwHiddenLedger[i].kind === kind) return true; } catch (e) {}
4909
+ return false;
4910
+ }
4911
+ /** `document.hidden` 的**实时**读法(不依赖任何事件):冻结/丢弃/后台重载三条路都要它。 */
4912
+ function mpwHiddenLive() {
4799
4913
  try {
4800
- if (!mpwHiddenPauseOn()) return false;
4914
+ if (mpwHiddenFrozen) return true;
4801
4915
  if (powHiddenNow) return true;
4802
4916
  return !!(typeof document !== "undefined" && document && document.hidden === true);
4803
4917
  } catch (e) { return false }
4804
4918
  }
4919
+ function mpwHiddenAudioBlock() {
4920
+ try {
4921
+ /* 逃生门:`?hiddengate=legacy` ⇒ 整族闸门失效(含 C 那一轮的三条),回到旧行为。 */
4922
+ if (mpwHiddenGateLegacy()) return false;
4923
+ if (!mpwHiddenPauseOn()) return false;
4924
+ return mpwHiddenLive();
4925
+ } catch (e) { return false }
4926
+ }
4927
+ /** H-③ 所有"**我们自己的**起播入口"共用的一道闸:用户暂停 / 省电暂停 / hidden。
4928
+ * 返回值 = true 表示"**拦住了**"(调用方必须不 play)。`where` 进台账,用于归因。 */
4929
+ function mpwPlayBlockedBy(where) {
4930
+ try {
4931
+ if (wallUserPaused || powPaused) return true;
4932
+ if (mpwHiddenAudioBlock()) { mpwHiddenLedgerPush("play-blocked", { why: "hidden", where: String(where || "") }); return true }
4933
+ return false;
4934
+ } catch (e) { return false }
4935
+ }
4936
+ /** C 那轮 7 处重试路径的既有写法 `!(wallUserPaused || powPaused)` 的**等价加强版**
4937
+ * (多出 hidden 一条;语义不变,见上面 H-②的根因)。 */
4938
+ function mpwMediaPlayBlocked() { return mpwPlayBlockedBy("media-retry") }
4939
+ /** H-① 挂载期闸门:`document.hidden === true` 时**一律不起播**,只记一条台账。
4940
+ * @returns {boolean} true = 拦住了(调用方不许 play / 不许 prime)。 */
4941
+ function mpwHiddenBootGate(where) {
4942
+ try {
4943
+ if (!mpwHiddenAudioBlock()) return false;
4944
+ /* "从没起播过"与"被隐藏暂停过"必须区分:挂载期这条只置 bootBlocked,绝不置 pausedByUs。 */
4945
+ mpwHiddenBootBlocked = true;
4946
+ if (!mpwHiddenBootLogged) {
4947
+ mpwHiddenBootLogged = true;
4948
+ mpwHiddenLedgerPush("boot-hidden-no-autoplay", { where: String(where || ""), state: "never-started" });
4949
+ try { console.warn("[dsh-mpkg-wallpaper] 挂载时页面处于隐藏状态 ⇒ **一律不起播**(视频/BGM/帧全部),等第一次可见时按当时策略恢复;台账 window.__mpwHiddenLedger(" + where + ")"); } catch (e) {}
4950
+ } else {
4951
+ mpwHiddenLedgerPush("mount-blocked", { where: String(where || "") });
4952
+ }
4953
+ return true;
4954
+ } catch (e) { return false }
4955
+ }
4956
+ /** H-① 恢复:**从没起播过**那一条路(`paused-by-hidden` 走既有的 resumeWallpaperVideo)。
4957
+ * 只在"策略允许"时动手:用户暂停 / 卡片暂停 / 浏览器拒绝过 ⇒ 一个字节都不碰。 */
4958
+ function mpwHiddenBootResume(where) {
4959
+ try {
4960
+ if (!mpwHiddenBootBlocked) return false;
4961
+ if (mpwHiddenAudioBlock()) return false; // 还隐藏着 ⇒ 不恢复
4962
+ mpwHiddenBootBlocked = false;
4963
+ const s = readSection();
4964
+ let did = [];
4965
+ /* 壁纸视频:闸门与 npPrimePlay 一模一样(它内部还会再判一次,这里只是留痕) */
4966
+ try {
4967
+ const vid = npActiveVideo(s) || bgElements().video;
4968
+ if (vid && vid.paused && vid.src && !npPlayBlocked && !wallUserPaused && !npCardPaused && !npUserPausedOf(vid)) {
4969
+ npPrimePlay(vid); did.push("video");
4970
+ }
4971
+ } catch (e) {}
4972
+ /* 我们自己的 <audio>(曲目档):只有"清单在 + 用户没按暂停"才续播 */
4973
+ try {
4974
+ const list = npTrackList(s);
4975
+ if (npAudio && npAudio.paused && list && list.tracks && list.tracks.length && !npCardPaused) {
4976
+ const a = String(npAudio.getAttribute("src") || "");
4977
+ if (!a) npLoadTrack(true); else { const p = npAudio.play(); if (p && p.catch) p.catch(() => {}); }
4978
+ did.push("audio");
4979
+ }
4980
+ } catch (e) {}
4981
+ /* 帧:只撤销**我们**按下的暂停(webFramePausedByUs 记账,作者自己停着的不碰) */
4982
+ try { const f = bgElements().frame; if (f && f.style.display !== "none") { resumeWebFrame(); did.push("frame") } } catch (e) {}
4983
+ mpwHiddenLedgerPush("boot-visible-resume", { where: String(where || ""), did: did.join("+") || "none", state: "never-started->running" });
4984
+ return true;
4985
+ } catch (e) { return false }
4986
+ }
4805
4987
  /* ══════════════════════════════════════════════════════════════════════════════════
4806
4988
  C3(2026-09-20 真机修复)「**换档即断开旧音源**」:一条真凶级漏洞
4807
4989
  ──────────────────────────────────────────────────────────────────────────────────
@@ -5028,6 +5210,16 @@ function mpwThumbFail(el, why) {
5028
5210
  * (否则重试那一下会被"状态校准"当场撤销,等于没重试)。 */
5029
5211
  function npPlayRetryMuted(video, err) {
5030
5212
  npOpTrace("prime-reject", null, String(err && err.name || "Error"));
5213
+ /* H(2026-09-25) hidden 期间**连 muted 重试也不做**(先判、再记"被拒"状态:否则会把一次
5214
+ 根本没发生的重试记成 `retryMuted:true`)。为什么这条也要挡:
5215
+ ① 它对"隐藏期 no-play"这条契约是噪声;② 真机审计 09-23 那条
5216
+ `hidden:true kind=play muted=true paused=true` 正是这类"被节流的 promise 回调在被隐藏
5217
+ 之后才落地"的形态 —— 元素当时还没起播(paused=true 就是证据)。 */
5218
+ if (mpwPlayBlockedBy("prime-retry-muted")) {
5219
+ try { npMarkPlayBlocked({ name: String(err && err.name || "Error"), message: String(err && err.message || "").slice(0, 200), at: Date.now(), retryMuted: false, hiddenSkipped: true }); } catch (e) {}
5220
+ npOpTrace("prime-retry", null, "skipped:hidden");
5221
+ return false;
5222
+ }
5031
5223
  npMarkPlayBlocked({
5032
5224
  name: String(err && err.name || "Error"),
5033
5225
  message: String(err && err.message || "").slice(0, 200), at: Date.now(), retryMuted: true,
@@ -5071,6 +5263,9 @@ function mpwThumbFail(el, why) {
5071
5263
  npGestureArmed = false;
5072
5264
  if (!npPlayBlocked && !npSoundBlocked) return;
5073
5265
  if (mpwHiddenAudioBlock()) { npOpTrace("gesture-retry", null, "gated:hidden"); return; } // C: 隐藏时不补起播
5266
+ /* H(2026-09-25 ①) 用户手势 = 挂载期被挡那条路的**第二个恢复口**(第一个是第一次可见;
5267
+ 见 mpwHiddenBootGate/mpwHiddenBootResume)。 */
5268
+ try { mpwHiddenBootResume("gesture"); } catch (e) {}
5074
5269
  /* ①(NP-4) 用户真的交互了 ⇒ 声音限制解除:清掉"被挡住"状态,取消静音并续播。 */
5075
5270
  npSoundBlocked = false; npSoundReason = null;
5076
5271
  npGestureUnlock("gesture"); // C4:这一次交互同时解除"必须 muted"(此后按设置)
@@ -6042,6 +6237,9 @@ function mpwThumbFail(el, why) {
6042
6237
  disarmSceneWatchdog(true); // ①(批次15 B1) 切到 canvas 合成壁纸:解除场景看门狗并清兜底视觉
6043
6238
  try { if (window.__mpwLnReset) window.__mpwLnReset(); } catch {} // ①(批次15 B3) 方向键守卫复位
6044
6239
  try { if (video) video.pause(); } catch {}
6240
+ /* H(2026-09-25) 挂载期隐藏 ⇒ 记一条"从没起播过"的台账(video 已 pause;场景合成的
6241
+ 音轨由渲染器帧那条 park 通道负责,见 applyWebMute / showWebEl)。 */
6242
+ try { mpwHiddenBootGate("showSceneEl"); } catch (e) {}
6045
6243
  wrap.classList.remove("mpw-img", "mpw-video", "mpw-web");
6046
6244
  wrap.classList.add("mpw-scene");
6047
6245
  if (img) img.style.display = "none";
@@ -6925,7 +7123,7 @@ function mpwThumbFail(el, why) {
6925
7123
  vid2.removeAttribute("src");
6926
7124
  vid2.src = hostUrl;
6927
7125
  vid2.load();
6928
- try { if (!(wallUserPaused || powPaused)) { const pp = vid2.play(); if (pp && pp.catch) pp.catch(() => {}); } } catch {}
7126
+ try { if (!mpwMediaPlayBlocked()) { const pp = vid2.play(); if (pp && pp.catch) pp.catch(() => {}); } } catch {}
6929
7127
  } catch {}
6930
7128
  }).catch(() => {});
6931
7129
  }
@@ -6981,7 +7179,7 @@ function mpwThumbFail(el, why) {
6981
7179
  vid.removeAttribute("src");
6982
7180
  vid.src = hostUrl;
6983
7181
  vid.load();
6984
- try { if (!(wallUserPaused || powPaused)) { const pp = vid.play(); if (pp && pp.catch) pp.catch(() => {}); } } catch {}
7182
+ try { if (!mpwMediaPlayBlocked()) { const pp = vid.play(); if (pp && pp.catch) pp.catch(() => {}); } } catch {}
6985
7183
  } catch {}
6986
7184
  return;
6987
7185
  }
@@ -6994,7 +7192,7 @@ function mpwThumbFail(el, why) {
6994
7192
  vid.removeAttribute("src");
6995
7193
  vid.src = resolveHostUrl(frameImg);
6996
7194
  vid.load();
6997
- try { if (!(wallUserPaused || powPaused)) { const pp = vid.play(); if (pp && pp.catch) pp.catch(() => {}); } } catch {}
7195
+ try { if (!mpwMediaPlayBlocked()) { const pp = vid.play(); if (pp && pp.catch) pp.catch(() => {}); } } catch {}
6998
7196
  } catch {}
6999
7197
  return;
7000
7198
  }
@@ -7022,7 +7220,7 @@ function mpwThumbFail(el, why) {
7022
7220
  vid.load();
7023
7221
  // ①(修正) 暂停门控:自动转码重试也尊重暂停,否则用户暂停后
7024
7222
  // 编码报错自动切转码会重播(绕过 showVideoEl 的门控)。
7025
- try { if (!(wallUserPaused || powPaused)) { const pp = vid.play(); if (pp && pp.catch) pp.catch(() => {}); } } catch {}
7223
+ try { if (!mpwMediaPlayBlocked()) { const pp = vid.play(); if (pp && pp.catch) pp.catch(() => {}); } } catch {}
7026
7224
  } catch {}
7027
7225
  };
7028
7226
  // 旧行为(回退开关)
@@ -7042,7 +7240,7 @@ function mpwThumbFail(el, why) {
7042
7240
  vid.removeAttribute("src");
7043
7241
  vid.src = cur;
7044
7242
  vid.load();
7045
- try { if (!(wallUserPaused || powPaused)) { const pp = vid.play(); if (pp && pp.catch) pp.catch(() => {}); } } catch {}
7243
+ try { if (!mpwMediaPlayBlocked()) { const pp = vid.play(); if (pp && pp.catch) pp.catch(() => {}); } } catch {}
7046
7244
  } catch {}
7047
7245
  };
7048
7246
  try {
@@ -7075,7 +7273,7 @@ function mpwThumbFail(el, why) {
7075
7273
  vid.src = cur;
7076
7274
  vid.load();
7077
7275
  // ①(修正) 暂停门控:404 重试不重播(同上,尊重暂停状态)
7078
- try { if (!(wallUserPaused || powPaused)) { const pp = vid.play(); if (pp && pp.catch) pp.catch(() => {}); } } catch {}
7276
+ try { if (!mpwMediaPlayBlocked()) { const pp = vid.play(); if (pp && pp.catch) pp.catch(() => {}); } } catch {}
7079
7277
  }, 800 * vid.__mpwHostTries);
7080
7278
  });
7081
7279
  }
@@ -7103,7 +7301,7 @@ function mpwThumbFail(el, why) {
7103
7301
  vid.removeAttribute("src");
7104
7302
  vid.src = hostUrl;
7105
7303
  vid.load();
7106
- try { if (!(wallUserPaused || powPaused)) { const pp = vid.play(); if (pp && pp.catch) pp.catch(() => {}); } } catch {}
7304
+ try { if (!mpwMediaPlayBlocked()) { const pp = vid.play(); if (pp && pp.catch) pp.catch(() => {}); } } catch {}
7107
7305
  // ②(fix) 转码完成自动切换:回退后轮询 host 转码进度,
7108
7306
  // done → 重新 apply(此时 /transcode 命中缓存,秒开转码产物)。
7109
7307
  // 只轮询到转码完成或超时(上限 3 分钟,避免无限轮询)。
@@ -8890,7 +9088,7 @@ function mpwThumbFail(el, why) {
8890
9088
  on("ended", () => { try {
8891
9089
  const list = npTrackList(readSection());
8892
9090
  if (list && list.tracks.length > 1) npStepTrack(1, true);
8893
- else if (npAudio) { npAudio.currentTime = 0; const p = npAudio.play(); if (p && p.catch) p.catch(() => {}); }
9091
+ else if (npAudio && !mpwPlayBlockedBy("audio-loop")) { npAudio.currentTime = 0; const p = npAudio.play(); if (p && p.catch) p.catch(() => {}); }
8894
9092
  } catch (e) {} });
8895
9093
  on("error", () => { try { npAudioError() } catch (e) {} });
8896
9094
  (document.body || document.documentElement).appendChild(npAudio);
@@ -8961,6 +9159,11 @@ function mpwThumbFail(el, why) {
8961
9159
  if (!npWantPlay) return;
8962
9160
  if (!(video.currentTime < 1.5)) return; /* 放起来之后才停(播完/切源)⇒ 不关我们的事 */
8963
9161
  if (npSoundBlocked) return;
9162
+ /* H(2026-09-25) hidden 期间连这一发 muted 补播也不做:它虽然不出声,但会让
9163
+ "隐藏期间有 play()" 这条审计噪声盖住真凶(且解码白白耗电)。
9164
+ **必须在置 npSoundBlocked 之前判**:否则"被策略停住"这条状态会在隐藏期被
9165
+ 单方面点亮,回到可见时没人再装手势重试(`npArmGestureRetry` 在下面那一行)。 */
9166
+ if (mpwPlayBlockedBy("pause-replay")) { npOpTrace("audio-blocked", null, "skipped:hidden"); return; }
8964
9167
  npSoundBlocked = true;
8965
9168
  npSoundReason = { at: Date.now(), currentTime: +video.currentTime.toFixed(2), muted: video.muted, hint: "没有用户手势时浏览器不允许有声播放(Firefox 会直接把它停住)" };
8966
9169
  try { window.__mpwNpSoundBlocked = npSoundReason; } catch (e) {}
@@ -9045,7 +9248,8 @@ function mpwThumbFail(el, why) {
9045
9248
  ≤32MiB 的 Blob 钉在 registry 里,自动连播 N 首 = 32MiB × N 单调增长。 */
9046
9249
  npAudio.src = obj;
9047
9250
  npBlobUrlSet(obj);
9048
- const p = npAudio.play(); if (p && p.catch) p.catch(() => {});
9251
+ /* H(2026-09-25) hidden 时只装源、不起播(闸门见 mpwHiddenBootGate)。 */
9252
+ if (!mpwPlayBlockedBy("audio-blob-retry")) { const p = npAudio.play(); if (p && p.catch) p.catch(() => {}); }
9049
9253
  /* ①(2026-09-23 上游 Firefox Bug 2056444) 同一族的静默挂起(blob 音轨 rs=0 / 无 error、
9050
9254
  play() 的 Promise 永不 settle;`decodeAudioData` 亦然)⇒ 同一个有界重试兜底。 */
9051
9255
  mpwBlobMediaRetry(npAudio, "音轨");
@@ -9078,7 +9282,12 @@ function mpwThumbFail(el, why) {
9078
9282
  const st = readSection();
9079
9283
  a.muted = !!(st.mute !== void 0 ? !!st.mute : true);
9080
9284
  if (npVolumeSet()) a.volume = npWantVolume(); /* ①(NP-4)① 用户设过电平才写(默认不碰) */
9081
- if (play) { const p = a.play(); if (p && p.catch) p.catch(() => {}); }
9285
+ if (play) {
9286
+ /* H(2026-09-25) 起播前过闸:hidden 期间**只装源不播**。
9287
+ 刻意**不**置"挂载期被挡"状态(那是 `showVideoEl/showWebEl/showSceneEl` 三个挂载点
9288
+ 的职责)⇒ 换曲/next 这类被挡的起播不会在回到可见时自作主张补播。 */
9289
+ if (!mpwPlayBlockedBy("npLoadTrack")) { const p = a.play(); if (p && p.catch) p.catch(() => {}); }
9290
+ }
9082
9291
  } catch (e) { mpwErr("npLoadTrack", e) }
9083
9292
  npApplyFrameMute();
9084
9293
  npSyncAudio();
@@ -9174,7 +9383,13 @@ function mpwThumbFail(el, why) {
9174
9383
  const a = npEnsureAudio();
9175
9384
  /* 还没装源(清单刚到/换过壁纸)⇒ 先装当前曲目再放 */
9176
9385
  if (a && !String(a.getAttribute("src") || "")) { npLoadTrack(true); did = "audio.load+play"; }
9177
- else if (a) { const p = a.play(); if (p && p.catch) p.catch(() => {}); did = "audio.play"; }
9386
+ else if (a) {
9387
+ /* H(2026-09-25) 卡片/系统媒体键的播放也要过 hidden 闸:真机上"页面隐藏时
9388
+ 被程序化调用 transport('play')"是后台重放那条链的一环(用户意图只在
9389
+ 可见时可能发生 —— 隐藏页面上按不到卡片)。同样**不**置"挂载期被挡"。 */
9390
+ if (mpwPlayBlockedBy("transport:play")) { did = "audio.play(blocked-hidden)"; npOpTrace("transport-hidden", op, did); }
9391
+ else { const p = a.play(); if (p && p.catch) p.catch(() => {}); did = "audio.play"; }
9392
+ }
9178
9393
  } else if (npAudio) { npCardPaused = true; npPersistPaused(true); npAudio.pause(); did = "audio.pause"; }
9179
9394
  } else if (!vid) {
9180
9395
  /* 清单还没到:拉一次,到位后由 applyNowPlaying 把键的状态刷新(不假装按过了) */
@@ -9483,7 +9698,7 @@ function mpwThumbFail(el, why) {
9483
9698
  else { npSetUserPaused(vid2, true); npWantPlay = false; try { vid2.pause() } catch (e) {} }
9484
9699
  npOpTrace("link-align", act, "video");
9485
9700
  } else if (act === "pause") { try { if (npAudio) npAudio.pause() } catch (e) {} npOpTrace("link-align", act, "audio") }
9486
- else { try { if (npAudio && !npCardPaused) { const p = npAudio.play(); if (p && p.catch) p.catch(() => {}) } } catch (e) {} npOpTrace("link-align", act, "audio") }
9701
+ else { try { if (npAudio && !npCardPaused && !mpwPlayBlockedBy("link-align")) { const p = npAudio.play(); if (p && p.catch) p.catch(() => {}) } } catch (e) {} npOpTrace("link-align", act, "audio") }
9487
9702
  try { npApplyFrameMute() } catch (e) {}
9488
9703
  }
9489
9704
  npLinkPrev = linkNow2;
@@ -9570,6 +9785,10 @@ function mpwThumbFail(el, why) {
9570
9785
  const { frame } = bgElements();
9571
9786
  if (!frame) return;
9572
9787
  if (!webFramePausedByUs) return; // ⑤ 我们没停过 ⇒ 一个字节都不碰
9788
+ /* H(2026-09-25) **hidden 期间一个字节都不 play()**:本函数会被 `npApplyFrameMute`
9789
+ 在"设置变化/延迟重放"里叫到,那条路上页面可能已经是隐藏的(C 那轮漏的就是这类)。
9790
+ 早退**不**清 `webFramePausedByUs` ⇒ 回到可见时 `resumeWallpaperVideo` 会再叫一次。 */
9791
+ if (mpwHiddenAudioBlock()) { mpwHiddenLedgerPush("frame-resume-blocked", { why: "hidden" }); return; }
9573
9792
  webFramePausedByUs = false;
9574
9793
  /* ①(2026-09-25 任务 ㉑ 追加) 撤销我们自己按下的"收起来"(跨源帧那条)。 */
9575
9794
  try { sendRendererAudioPolicy(frame, !!readSection().mute || npFrameSoundBlocked(), false, "resume-frame") } catch (e) {}
@@ -9584,6 +9803,18 @@ function mpwThumbFail(el, why) {
9584
9803
  function pauseWallpaperVideo() {
9585
9804
  try {
9586
9805
  const { video, frame } = bgElements();
9806
+ /* H(2026-09-25) 状态区分:`paused-by-hidden` = "隐藏前真的在播 ⇒ 我们把它停住的"
9807
+ (回到可见时按原状态续播);与"从没起播过"(`mpwHiddenBootBlocked`)是两回事。
9808
+ **只有"真的停住了一个正在播的东西"才算**:
9809
+ ① 用户手动暂停壁纸那条路(`pauseWallpaperVideo` 也被 `toggleWallPause` 调)不算;
9810
+ ② **后台重载挂载**那条路也不算 —— 那时元素**从没起播过**(`never-started`),
9811
+ 该由 `mpwHiddenBootBlocked` 表达。否则两种状态同时为真,
9812
+ "谁被隐藏暂停过 / 谁从没起播过"就答不准了(真浏览器档 B5 抓到的就是这个)。
9813
+ 判据用下面两行马上要算的"暂停前在不在播"(video / 我们自己的 audio)。 */
9814
+ try {
9815
+ const wasPlaying = !!(video && !video.paused && video.src) || !!(npAudio && !npAudio.paused);
9816
+ if (mpwHiddenAudioBlock() && wasPlaying) mpwHiddenPausedByUs = true;
9817
+ } catch (e) {}
9587
9818
  /* C(2026-09-20) 记下"暂停之前壁纸在不在播":恢复时**按原状态**续播
9588
9819
  (原本就暂停的绝不自作主张起播 —— 用户要的是"切页静音",不是"回来自己开始放")。 */
9589
9820
  try { powHidWallWasPlaying = !!(video && !video.paused && video.src) } catch (e) {}
@@ -9617,17 +9848,21 @@ function mpwThumbFail(el, why) {
9617
9848
  try { const p = video.play(); if (p && p.catch) p.catch(() => {}); } catch {}
9618
9849
  }
9619
9850
  // ⑤ 只撤销**我们**按下的暂停(webFramePausedByUs 记账;作者自己停着的媒体不碰)
9620
- if (frame && frame.style.display !== "none") resumeWebFrame();
9851
+ /* H(2026-09-25) 隐藏期间**一次都不许 play()**:这条恢复路径也会被"设置变化/延迟重放"
9852
+ 在后台叫到(C 那轮只挡住了 video 那一行,帧与我们自己的 audio 两行漏着)。 */
9853
+ if (frame && frame.style.display !== "none" && !mpwHiddenAudioBlock()) resumeWebFrame();
9621
9854
  /* ⑤ 静音回落到设置/卡片口径(不能把"卡片暂停"那条压住的静音也一起放开) */
9622
9855
  try { npApplyFrameMute(); } catch (e) {}
9623
9856
  /* ⑤ 隐藏前在放才续播;隐藏前就是暂停的绝不自作主张播放 */
9624
9857
  try {
9625
- if (powHidNpWasPlaying && npAudio && npAudio.paused && !npCardPaused) {
9858
+ if (powHidNpWasPlaying && npAudio && npAudio.paused && !npCardPaused && !mpwHiddenAudioBlock()) {
9626
9859
  const p = npAudio.play(); if (p && p.catch) p.catch(() => {});
9627
9860
  }
9628
9861
  powHidNpWasPlaying = false;
9629
9862
  } catch (e) {}
9630
9863
  powHidWallWasPlaying = false;
9864
+ mpwHiddenPausedByUs = false;
9865
+ mpwHiddenLedgerPush("visible-resume", { force: force === true });
9631
9866
  } catch {}
9632
9867
  }
9633
9868
  /** 按三档状态决定暂停/恢复(任一档触发即暂停,全恢复才继续)。 */
@@ -9639,9 +9874,14 @@ function mpwThumbFail(el, why) {
9639
9874
  const battOn = s.powPauseBattery !== void 0 ? !!s.powPauseBattery : false;
9640
9875
  let hiddenNow = powHiddenNow;
9641
9876
  try { if (!hiddenNow && typeof document !== "undefined" && document && document.hidden === true) hiddenNow = true } catch (e) {}
9877
+ if (mpwHiddenFrozen) hiddenNow = true; // H: freeze(Android 冻结)与 hidden 同一待遇
9642
9878
  const shouldPause = (hiddenOn && hiddenNow) || (blurOn && !powFocusedNow) || (battOn && powBattery && powBattery.charging === false);
9643
- if (shouldPause && !powPaused) { powPaused = true; pauseWallpaperVideo(); }
9879
+ if (shouldPause && !powPaused) { powPaused = true; mpwHiddenLedgerPush("pause", { by: "power" }); pauseWallpaperVideo(); }
9644
9880
  else if (!shouldPause && powPaused) { powPaused = false; resumeWallpaperVideo(); }
9881
+ /* H(2026-09-25 ①) **挂载期被挡**那条独立状态:挂载完成时就是隐藏的 ⇒ 从没起播过;
9882
+ 回到可见(且策略允许)时由它负责把那一次"补上"。与上面"按原状态续播"互不干扰:
9883
+ `resumeWallpaperVideo` 只在 `powHidWallWasPlaying` 为真时动手,而这条置的是 false。 */
9884
+ if (!shouldPause) { try { mpwHiddenBootResume("updatePowerPause"); } catch (e) {} }
9645
9885
  } catch {}
9646
9886
  }
9647
9887
  /** 注册省电监听(一次性)。
@@ -9658,6 +9898,8 @@ function mpwThumbFail(el, why) {
9658
9898
  try {
9659
9899
  document.addEventListener("visibilitychange", () => {
9660
9900
  powHiddenNow = document.hidden === true;
9901
+ if (!powHiddenNow) mpwHiddenFrozen = false; // H: 可见 ⇒ 冻结状态一并解除
9902
+ mpwHiddenLedgerPush("visibilitychange", { state: (() => { try { return String(document.visibilityState || "") } catch (e) { return "" } })() });
9661
9903
  updatePowerPause();
9662
9904
  });
9663
9905
  } catch {}
@@ -9667,9 +9909,31 @@ function mpwThumbFail(el, why) {
9667
9909
  } catch {}
9668
9910
  // ⑤ pagehide/pageshow:bfcache / 切标签在部分浏览器不派发 visibilitychange
9669
9911
  try {
9670
- window.addEventListener("pagehide", () => { powHiddenNow = true; updatePowerPause(); });
9912
+ window.addEventListener("pagehide", () => { powHiddenNow = true; mpwHiddenLedgerPush("pagehide"); updatePowerPause(); });
9671
9913
  window.addEventListener("pageshow", () => {
9672
9914
  try { powHiddenNow = document.hidden === true } catch (e) { powHiddenNow = false }
9915
+ mpwHiddenLedgerPush("pageshow");
9916
+ updatePowerPause();
9917
+ });
9918
+ } catch {}
9919
+ /* H(2026-09-25 ②) `freeze` / `resume`:页面生命周期 API(Android 冻结后台标签时派发)。
9920
+ 为什么必须单独接:冻结**不一定**派发 `visibilitychange`(页面可能"一挂载就已经是 hidden"),
9921
+ 而冻结期间我们的解码/音频仍可能被系统放行 ⇒ 把 freeze 当成 hidden 是最保守也最正确的读法。
9922
+ `document.addEventListener('freeze')` 在不支持的浏览器上等于挂了个永不触发的监听(无害,
9923
+ 不抛错)⇒ 不做能力探测、也不写能力位(免得把"支持"说成"不支持")。 */
9924
+ try {
9925
+ document.addEventListener("freeze", () => {
9926
+ mpwHiddenFrozen = true;
9927
+ mpwHiddenLedgerPush("freeze");
9928
+ /* 走**同一条状态机**(而不是单独 pause 一下):`updatePowerPause` 里
9929
+ `mpwHiddenFrozen ⇒ hiddenNow` ⇒ `powPaused=true` + `pauseWallpaperVideo()` 全落点;
9930
+ 对应地 `resume`/可见时它会走 `resumeWallpaperVideo()` 按**原状态**续播
9931
+ (单独 pause 会让画面永久冻在冻结那一刻 —— 恢复没人管)。 */
9932
+ updatePowerPause();
9933
+ });
9934
+ document.addEventListener("resume", () => {
9935
+ mpwHiddenFrozen = false;
9936
+ mpwHiddenLedgerPush("resume");
9673
9937
  updatePowerPause();
9674
9938
  });
9675
9939
  } catch {}
@@ -10383,6 +10647,11 @@ function mpwThumbFail(el, why) {
10383
10647
  下一次就能直接从上报里读到来源。开关 `?npaudit=0` 完全不装(零开销);缺省装。
10384
10648
  ══════════════════════════════════════════════════════════════════════════════════ */
10385
10649
  const MPW_AUDIT_MAX = 200;
10650
+ /* H(2026-09-25 ③) 「hidden 期间起播」**无论 owner 是谁都记一条**的独立台账(≤16 条):
10651
+ 为什么不能只靠上面那个 200 条环形缓冲 —— 它**刷新即清空**,而本条现场恰恰是"页面在后台
10652
+ 被丢弃/重载",等用户回到 Via 时缓冲里往往什么都没有了。这份台账挂在 `window` 上并与
10653
+ `mpwHiddenLedger` 一起进 `/diag` 信标,owner/src/栈三样齐全(第三方也照记,只是不控制它)。 */
10654
+ const MPW_HIDDEN_PLAY_MAX = 16;
10386
10655
  let mpwAuditInstalled = false;
10387
10656
  let mpwAuditLastBeacon = 0;
10388
10657
  function mpwAuditOn() {
@@ -10477,6 +10746,18 @@ function mpwThumbFail(el, why) {
10477
10746
  || !!(rec.el && rec.el.connected === false && rec.paused === false)
10478
10747
  || (kind === "play" && rec.hidden === true)
10479
10748
  || ((kind === "unmute" || kind === "volume") && rec.hidden === true && rec.muted === false);
10749
+ /* H(2026-09-25 ③) hidden 期间那**两类**转变(起播 / 取消静音)另存一份**独立台账**:
10750
+ ① owner 是谁都记(我们自己、壁纸帧、别的插件、跨源帧的 foreign 条目)—— 只记录、不控制;
10751
+ ② 刷新丢不掉的那些行会跟着 /diag 信标一起落盘(见下面 beacon 的 hiddenPlays 字段)。 */
10752
+ if (rec.hidden === true && (kind === "play" || kind === "unmute" || kind === "volume" || kind === "webaudio-start" || kind === "audioctx-resume")) {
10753
+ try {
10754
+ const hp = window.__mpwHiddenPlays || (window.__mpwHiddenPlays = []);
10755
+ hp.push({ t: rec.t, kind: rec.kind, who: rec.who, win: rec.win, el: rec.el, muted: rec.muted, volume: rec.volume, paused: rec.paused, npMute: rec.np && rec.np.mute });
10756
+ if (hp.length > MPW_HIDDEN_PLAY_MAX) hp.splice(0, hp.length - MPW_HIDDEN_PLAY_MAX);
10757
+ if (store) store.hiddenPlays = hp;
10758
+ mpwHiddenLedgerPush("hidden-play", { owner: (rec.el && rec.el.owner) || ((rec.win && rec.win.foreign) ? "foreign-frame" : "unknown"), playKind: rec.kind, src: (rec.el && rec.el.src) || (rec.win && rec.win.url) || "", audible: !!audible });
10759
+ } catch (e) {}
10760
+ }
10480
10761
  if (suspicious) {
10481
10762
  if (store) { try { store.suspicious = (store.suspicious || 0) + 1 } catch (e) {} }
10482
10763
  if (Date.now() - mpwAuditLastBeacon > 5000) {
@@ -10488,6 +10769,11 @@ function mpwThumbFail(el, why) {
10488
10769
  : (audible ? "audible-playback" : (rec.el && rec.el.connected === false ? "detached-playing" : "hidden-transition"))),
10489
10770
  record: rec, window: list.slice(-12), count: list.length,
10490
10771
  visibility: (() => { try { return document.visibilityState } catch (e) { return null } })(),
10772
+ /* H(2026-09-25 ③) 两条"刷新也丢不掉"的台账一起进信标:hidden 期间的起播
10773
+ (含 owner/src/栈)与我们自己的隐藏闸门台账。真机下次复现时,**即使页面
10774
+ 在后台被丢弃/重载**,落盘的那一份里也有这两样。 */
10775
+ hiddenPlays: (() => { try { return (window.__mpwHiddenPlays || []).slice(-8) } catch (e) { return null } })(),
10776
+ hiddenLedger: (() => { try { return mpwHiddenLedger.slice(-12) } catch (e) { return null } })(),
10491
10777
  });
10492
10778
  } catch (e) {}
10493
10779
  try { console.warn("[dsh-mpkg-wallpaper][audio-audit] 可疑出声:", JSON.stringify(rec).slice(0, 400)); } catch (e) {}
@@ -11864,6 +12150,34 @@ function installAudioBus(win, opts = {}) {
11864
12150
  npSource: () => { try { return { kind: npSourceKind(readSection()), id: npSourceId(readSection()), prev: npSrcIdPrev, audioPaused: npAudio ? !!npAudio.paused : null, audioSrc: npAudio ? String(npAudio.getAttribute('src') || '').slice(0, 60) : null } } catch (e) { return null } },
11865
12151
  hiddenAudioBlock: () => { try { return mpwHiddenAudioBlock() } catch (e) { return null } },
11866
12152
  hiddenPauseOn: () => { try { return mpwHiddenPauseOn() } catch (e) { return null } },
12153
+ /* ══ H(2026-09-25) 隐藏闸门的钩子(tools/hidden-gate-test.mjs 用;生产不引用)══
12154
+ 暴露的都是**真实现**:状态机(gate/bootBlocked/pausedByUs/frozen)、台账、以及三个
12155
+ 真挂载点(showVideoEl/showWebEl/showSceneEl)+ 两条真恢复路径(pause/resume)。 */
12156
+ hiddenState: () => {
12157
+ try {
12158
+ return {
12159
+ legacy: mpwHiddenGateLegacy(), on: mpwHiddenPauseOn(), live: mpwHiddenLive(),
12160
+ block: mpwHiddenAudioBlock(), frozen: !!mpwHiddenFrozen,
12161
+ bootBlocked: !!mpwHiddenBootBlocked, pausedByUs: !!mpwHiddenPausedByUs,
12162
+ bootLogged: !!mpwHiddenBootLogged, gateOn: !!mpwHiddenPauseOn(),
12163
+ };
12164
+ } catch (e) { return { err: String(e && e.message || e) } }
12165
+ },
12166
+ hiddenLedger: () => { try { return mpwHiddenLedger.slice() } catch (e) { return [] } },
12167
+ hiddenLedgerClear: () => { try { mpwHiddenLedger.length = 0; mpwHiddenBootLogged = false; return true } catch (e) { return false } },
12168
+ hiddenPlays: () => { try { return (window.__mpwHiddenPlays || []).slice() } catch (e) { return [] } },
12169
+ /* 挂载点:走的是**生产函数本身**(含 boot 闸门、applyWebMute、pauseWebFrame 那几段) */
12170
+ showVideo: (url) => { try { showVideoEl(url || readSection().image || "host:?token=t&index=0"); return true } catch (e) { return String(e && e.message || e) } },
12171
+ showWeb: (url) => { try { showWebEl(url || readSection().webUrl || ""); return true } catch (e) { return String(e && e.message || e) } },
12172
+ /* 恢复/暂停两条真路径 + 挂载期那条独立恢复口 */
12173
+ pauseWallpaperVideo: () => { try { pauseWallpaperVideo(); return true } catch (e) { return String(e && e.message || e) } },
12174
+ resumeWallpaperVideo: (force) => { try { resumeWallpaperVideo(force === true); return true } catch (e) { return String(e && e.message || e) } },
12175
+ bootGate: (where) => { try { return mpwHiddenBootGate(where || "test") } catch (e) { return String(e && e.message || e) } },
12176
+ bootResume: (where) => { try { return mpwHiddenBootResume(where || "test") } catch (e) { return String(e && e.message || e) } },
12177
+ /* 帧策略的可判定读数(渲染器 park / shim policy 都落在这本台账上) */
12178
+ rendererPolicy: () => { try { return (window.__mpwRendererAudioPush || { n: 0, last: null, log: [] }) } catch (e) { return null } },
12179
+ rendererPolicyClear: () => { try { window.__mpwRendererAudioPush = { n: 0, last: null, log: [] }; return true } catch (e) { return false } },
12180
+ unreachableFrames: () => { try { return (window.__mpwUnreachableFrames || []).slice() } catch (e) { return [] } },
11867
12181
  ops: () => { try { return (window.__mpwNpOps || []).slice(-12) } catch (e) { return [] } },
11868
12182
  // ⑤ 可见性/省电
11869
12183
  defaultPowPauseHidden: DEFAULT_POW_PAUSE_HIDDEN,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mpkg-wallpaper",
3
- "version": "3.13.8",
3
+ "version": "3.13.9",
4
4
  "description": "DSH Web 壁纸引擎 Wallpaper Engine mpkg 背景插件:浏览器内直接解析 .mpkg(preview.gif 动态背景/内嵌 mp4 视频/多时段切换),整屏统一虚化/对话框/弹层/遮罩独立虚化、镜头缩放平移、时钟、冲突检测。",
5
5
  "private": false,
6
6
  "type": "module",