dsh-mpkg-wallpaper 3.9.0 → 3.10.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 +3 -3
- package/README.md +4 -4
- package/lib/client.js +253 -23
- package/lib/index.js +74 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
Adds background wallpapers to the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web UI (`dsh web`): **Wallpaper Engine `.mpkg` parsing, Steam Workshop folders, video/web/image wallpapers, time-of-day switching for Time Variation wallpapers, a full-screen blur system, theme colours and glass surfaces, a local wallpaper library, scheduled rotation, a Now playing widget and one-click update**. Almost every appearance detail is adjustable.
|
|
8
8
|
|
|
9
|
-
> Version scope: this document describes the implementation shipped as **`3.
|
|
9
|
+
> Version scope: this document describes the implementation shipped as **`3.10.0`** in `package.json`. The publish surface is **15 files** (`lib/` runtime files + `package.json`, `icon.svg`, `cordis.patch.yml`, `README.md`, `README.en.md`, `THIRD-PARTY.md`, `LICENSE`; measured with `npm pack --dry-run`: 15 files / 2,088,240 B unpacked); `lib/liquid-glass/**`, `lib/liquid-glass-bundle.js`, `dist/`, `tools/` and `docs/` never enter the npm package (`package.json:8-21`). Defaults that changed: **`npNowPlaying` off → on** (since 3.8.0) and **`powPauseHidden` off → on** (since 3.9.0, migrating only profiles that never set it; see [What changed in this version](#what-changed-in-this-version)).
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
@@ -560,7 +560,7 @@ This section is a file-by-file map of the repository: what enters the npm packag
|
|
|
560
560
|
|
|
561
561
|
```text
|
|
562
562
|
dsh-mpkg-wallpaper/
|
|
563
|
-
├── package.json # version 3.
|
|
563
|
+
├── package.json # version 3.10.0; dsh.bundle + dsh.client declarations; files allow-list = publish surface (15 files)
|
|
564
564
|
├── cordis.patch.yml # install declaration used by dsh plugin add
|
|
565
565
|
├── LICENSE # MIT
|
|
566
566
|
├── THIRD-PARTY.md # third-party provenance / clean-room records / licence attribution (shipped)
|
|
@@ -610,7 +610,7 @@ The 12 steps of `tools/check.sh`: ① syntax ② panel smoke + P-66 + folder pic
|
|
|
610
610
|
|
|
611
611
|
## What changed in this version
|
|
612
612
|
|
|
613
|
-
Current version = **3.
|
|
613
|
+
Current version = **3.10.0** in `package.json` (this release line: 3.8.0 → 3.8.1 → 3.8.2 → 3.9.0 → 3.9.1 → **3.10.0**). Release prerequisites, commands and rollback live in [`docs/RELEASE.md`](docs/RELEASE.md); this round's change list is in [`docs/RELEASE-READY-3.8.0.md`](docs/RELEASE-READY-3.8.0.md); the root causes and on-device readings for Now playing / wallpaper sound are in [`docs/NOW-PLAYING-DSH.md`](docs/NOW-PLAYING-DSH.md) §7.7.
|
|
614
614
|
|
|
615
615
|
- **Now playing is on by default and yields its slot**: the `npNowPlaying` setting is **on by default** (`DEFAULT_NP_NOW_PLAYING` in `lib/client.js`), still in the *Wallpaper* tab directly under the existing `mute` toggle (`toggleRow(t("npNowPlaying"), …)` in `lib/client.js`). Turning it off returns to zero injection (mechanism and criteria in [Now playing and wallpaper sound](#now-playing-and-wallpaper-sound)). Being on by default requires **yielding**: when another plugin has already injected an element into the same host slot we do not mount (or we retract) and leave a queryable state `data-mpw-np-yield` (`occupantOf()` in `lib/now-playing.js`; judged both before and after mounting, and it comes back once the occupant leaves). The component itself is a **line-by-line port of Bencho's "Now playing" (MIT)** with the upstream comments kept verbatim (attribution in `THIRD-PARTY.md` §6); pure math and component are split into `lib/now-playing-math.js` / `lib/now-playing.js` and **inlined byte-for-byte** into the `MPW-NP-GEN-START/END` region of `lib/client.js` by `tools/build-now-playing.mjs`, guarded by two independent drift gates. Mounting and collapse criteria: see [Now playing and wallpaper sound](#now-playing-and-wallpaper-sound).
|
|
616
616
|
- **Wallpaper sound is actually wired up**: the data source only accepts the media element that is **really playing right now** (the hidden shell `#mpw-bgVideo` exists in the DOM under **every** wallpaper type, and the old implementation treated the first selector match as "the current media") ⇒ a video wallpaper's play/pause/mute land on the real element (the **`mute` switch now really reaches the element — unmuting really produces sound**; the old code hard-coded `video.muted = true` and never assigned it from the setting again); audio files that **really exist** in the wallpaper folder play through our own `<audio>` (the scope accepts `mpkgKey="custom|<folder>"`; the old code only accepted `folderName`, so a web wallpaper in a custom folder always built a library route and got a 404) ⇒ **previous/next follow the track-list order and wrap around**, instead of "restart"; for a web wallpaper's in-frame sound only **mute** is a real channel (`canPlay=false` is reported honestly — we do not pretend we can pause in-frame WebAudio); while our player is playing, the frame is force-muted so the same track cannot play twice.
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
给 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web 界面(`dsh web`)添加背景壁纸的插件:**Wallpaper Engine `.mpkg` 解析、Steam 创意工坊目录、视频/网页/图片壁纸、时间变化壁纸的多时段切换、整屏虚化体系、主题色与玻璃外观、本地壁纸库、定时轮换、Now playing 控件、一键更新**。外观细节几乎全部可调。
|
|
8
8
|
|
|
9
|
-
> 版本口径:本文件描述的是 `package.json` 里 **`3.
|
|
9
|
+
> 版本口径:本文件描述的是 `package.json` 里 **`3.10.0`** 这一版实现。发布面共 **15 个文件**(`lib/` 8 个运行时文件 + `package.json`、`icon.svg`、`cordis.patch.yml`、`README.md`、`README.en.md`、`THIRD-PARTY.md`、`LICENSE`;`npm pack --dry-run` 实测 15 文件 / unpacked 2 088 240 B);`lib/liquid-glass/**`、`lib/liquid-glass-bundle.js`、`dist/`、`tools/`、`docs/` 都不进 npm 包(`package.json:8-21`)。本轮的默认档变化:**`npNowPlaying` 关 → 开**(3.8.0 起)与 **`powPauseHidden` 关 → 开**(3.9.0 起,只迁移"从没设过"的存量档;详见[这一版新增/变更](#这一版新增变更))。
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
@@ -560,7 +560,7 @@ node tools/build-bundle.mjs
|
|
|
560
560
|
|
|
561
561
|
```text
|
|
562
562
|
dsh-mpkg-wallpaper/
|
|
563
|
-
├── package.json # 版本 3.
|
|
563
|
+
├── package.json # 版本 3.10.0;dsh.bundle + dsh.client 声明;files 白名单 = 发布面(15 文件)
|
|
564
564
|
├── cordis.patch.yml # 插件安装声明(dsh plugin add 使用)
|
|
565
565
|
├── LICENSE # MIT
|
|
566
566
|
├── THIRD-PARTY.md # 第三方来历/洁净室记录与许可归属(随包发布)
|
|
@@ -610,7 +610,7 @@ node tools/style-scope-guard.mjs # 判据是末行「… OK / … ALLOWLISTED
|
|
|
610
610
|
|
|
611
611
|
## 这一版新增/变更
|
|
612
612
|
|
|
613
|
-
当前版本 = `package.json` 的 **3.
|
|
613
|
+
当前版本 = `package.json` 的 **3.10.0**(本轮发布线 3.8.0 → 3.8.1 → 3.8.2 → 3.9.0 → 3.9.1 → **3.10.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
614
|
|
|
615
615
|
- **Now playing 默认挂载 + 会让位**:设置项 `npNowPlaying` **默认开**(`lib/client.js` 的 `DEFAULT_NP_NOW_PLAYING`),仍在「壁纸设置」tab、紧挨既有 `mute` 下方(`lib/client.js` 的 `toggleRow(t("npNowPlaying"), …)`)。关掉即回到零注入(机制与判据见 [Now playing 与壁纸声音](#now-playing-与壁纸声音))。默认开的前提是**礼让**:宿主同一位置已经有别的插件注入的元素时不挂/撤下,并留一个可查询状态 `data-mpw-np-yield`(`lib/now-playing.js` 的 `occupantOf()`;挂载前与挂载后都判,占用者走了再回来)。组件本体是 **Bencho 的 "Now playing"(MIT)逐行移植**(注释原文保留,归属见 `THIRD-PARTY.md` §6);纯数学与组件拆成 `lib/now-playing-math.js` / `lib/now-playing.js`,由 `tools/build-now-playing.mjs` **逐字节内联**进 `lib/client.js` 的 `MPW-NP-GEN-START/END` 生成区,配两道漂移门禁(生成器自比 + 产物反抠比对)。挂载与收起判据见 [Now playing 与壁纸声音](#now-playing-与壁纸声音)。
|
|
616
616
|
- **壁纸声音真的接线**:数据源只认**当前真的在放**的那个媒体元素(页面里那个隐藏空壳 `#mpw-bgVideo` 在**任何壁纸类型下都在 DOM 里**,旧实现按选择器命中它就当"当前媒体")⇒ 视频壁纸的播放/暂停/静音落在真实元素上(**`mute` 开关真的落到元素上:关掉静音就真的出声**,旧写法把 `video.muted` 写死 `true`,之后再没有代码按设置赋值);壁纸目录里**真实存在**的音频文件由我们自己的 `<audio>` 播(作用域认 `mpkgKey="custom|<folder>"`,旧写法只认 `folderName` ⇒ 自定义目录里的 web 壁纸永远拼成 library 路由 404)⇒ **上一首/下一首按清单顺序环形切换**,不再是"回到开头";web 壁纸帧内那份声音只有**静音**这一条真通道(`canPlay=false`,如实显示,不假装能暂停帧内 WebAudio);我们放音时帧内被强制静音(防同一首放两遍)。
|
|
@@ -633,7 +633,7 @@ node tools/style-scope-guard.mjs # 判据是末行「… OK / … ALLOWLISTED
|
|
|
633
633
|
**宿主超限语义**:两个 POST 接收端超限从"先掐连接"改成 **413 + JSON 说明**(旧行为客户端只看到 ECONNRESET)。
|
|
634
634
|
真机读数、根因链与 10 条诚实清单见 [`docs/WALLPAPER-LIFECYCLE.md`](docs/WALLPAPER-LIFECYCLE.md);发布记录见 [`docs/RELEASE.md`](docs/RELEASE.md)。
|
|
635
635
|
- **3.8.1 / 3.8.2:Now playing 第二次真机批次(NP-4)** —— 起播顺序(旧写法先取消静音再 `play()`,等于一次有声自动播放 ⇒ 被浏览器拒绝且被吞掉)、被浏览器策略停住(muted 起播 resolve 后取消静音,元素被直接停住)、**新增音量电平**(0..100,落到真实元素)与**可拖动进度(seek)**、**新增「播放/暂停同时控制壁纸」开关(`npLinkWallpaper`,默认开 = 与改动前逐字节同行为)**、悬浮态卡片放大、音轨清单的"能不能播"如实分类。可交付口径:**首次用户手势后必定出声**(被策略停住时退回 muted 保画面并记 `window.__mpwNpSoundBlocked`)。详见 [`docs/RELEASE.md`](docs/RELEASE.md) 的 3.8.1/3.8.2 发布记录与 [`docs/NOW-PLAYING-DSH.md`](docs/NOW-PLAYING-DSH.md) §7.8。
|
|
636
|
-
- **仍在同一发布面上的前一轮能力**:网页壁纸渲染 / API 覆盖(帧内存储 facade + 宿主 `/web-store`、主音量、源级 `file:///` 改写、CSP 跳过注入、`/media-audio`)与网页壁纸触控(`op:'touch'` 真 `TouchEvent`);系统媒体会话的**宿主端适配器** `lib/media-session.js`(MPRIS / SMTC
|
|
636
|
+
- **仍在同一发布面上的前一轮能力**:网页壁纸渲染 / API 覆盖(帧内存储 facade + 宿主 `/web-store`、主音量、源级 `file:///` 改写、CSP 跳过注入、`/media-audio`)与网页壁纸触控(`op:'touch'` 真 `TouchEvent`);系统媒体会话的**宿主端适配器** `lib/media-session.js`(MPRIS / SMTC)**已接线**:宿主 `GET /media-session`(21 键快照)/`POST /media-control`(六个 op,能力位先判)/`GET /media-art`(`file://` 封面代理),客户端 `npSystemMedia*` 自适应轮询(有系统媒体 2s、本机无会话总线 30s)⇒ 卡片显示**真实系统曲目/封面/进度**,本机如实 `available:false / no-session-bus`(接线判据 `tools/media-session-wiring-test.mjs` 24 断言 + 3 变异)。
|
|
637
637
|
- **门禁与护栏**:CSS 组合矩阵、样式作用域护栏、表面 token 命名空间等价性、开关接线审计、秒级 pre-commit(见[历史台账](#历史台账)与「[判据](#判据与门禁)」)。
|
|
638
638
|
|
|
639
639
|
## 历史台账
|
package/lib/client.js
CHANGED
|
@@ -7621,6 +7621,21 @@ function sectionSigNow() {
|
|
|
7621
7621
|
let did = "";
|
|
7622
7622
|
const trace = () => npOpTrace(op, value, did || "no-target");
|
|
7623
7623
|
try {
|
|
7624
|
+
/* ①(MEDIA-1 接线) **系统媒体优先**:卡片现在显示的是系统曲目时,按钮必须控系统播放器
|
|
7625
|
+
(否则用户按"下一首",动的是壁纸而不是他耳机里那首歌)。能力位先判:够不着就不发命令。 */
|
|
7626
|
+
if (npSystemMediaActive() && ["play", "pause", "restart", "next", "prev"].includes(op)) {
|
|
7627
|
+
const last = npSysLast || {};
|
|
7628
|
+
const capOk = (op === "next" ? last.canNext !== false : (op === "prev" ? last.canPrev !== false : true));
|
|
7629
|
+
if (capOk) {
|
|
7630
|
+
did = "system:" + op;
|
|
7631
|
+
trace();
|
|
7632
|
+
npSystemControl(op);
|
|
7633
|
+
return true;
|
|
7634
|
+
}
|
|
7635
|
+
did = "system:no-cap:" + op;
|
|
7636
|
+
trace();
|
|
7637
|
+
return false;
|
|
7638
|
+
}
|
|
7624
7639
|
if (op === "play" || op === "pause") {
|
|
7625
7640
|
const vid = npActiveVideo(s);
|
|
7626
7641
|
/* ①(NP-4)② 联动开 ⇒ 壁纸媒体一起动;关 ⇒ **一个字节都不碰壁纸**(画面不受控件影响)。 */
|
|
@@ -7706,6 +7721,127 @@ function sectionSigNow() {
|
|
|
7706
7721
|
setTimeout(() => { try { applyNowPlaying(readSection()); } catch {} }, 60);
|
|
7707
7722
|
}
|
|
7708
7723
|
|
|
7724
|
+
/* ══════════════════════════════════════════════════════════════════════════════════
|
|
7725
|
+
①(MEDIA-1 接线 2026-09-20)「系统媒体」补充路径:把宿主读到的**系统正在播放**喂给卡片
|
|
7726
|
+
──────────────────────────────────────────────────────────────────────────────────
|
|
7727
|
+
为什么是**另一条**路径(而不是塞进 `npResolveMedia`):那个函数是**同步**的、解的是"**壁纸自己的**
|
|
7728
|
+
媒体/音频轨"(封面来自 preview、标题来自 project.json)。系统媒体(MPRIS/SMTC)是**异步**的外部世界,
|
|
7729
|
+
两者可以同时存在(壁纸在放自己的 BGM 时,Spotify 也在放)。口径:
|
|
7730
|
+
· 宿主 `GET /media-session` 回 `available:false`(如本机无会话总线)⇒ **不接管**,卡片继续显示壁纸自己的媒体
|
|
7731
|
+
—— 这是本机的**正常**结局,不是失败(诚实降级);
|
|
7732
|
+
· `available:true` ⇒ 用系统曲目覆盖标题/副标题/封面/时长,进度按 position 走;
|
|
7733
|
+
`canNext/canPrev/canPlay` 决定卡片按钮的可用性;封面 `file://` 走宿主代理 `/media-art?player=`。
|
|
7734
|
+
轮询纪律(本机是手机,这条不是可选项):**≥2000ms**、只在开关打开且页面可见时跑、开关关掉立刻停。 */
|
|
7735
|
+
const NP_SYS_POLL_MS = 2000; // 有系统媒体时:2s 一次(进度条要跟得上)
|
|
7736
|
+
const NP_SYS_IDLE_MS = 30000; // **本机常态**(没有会话总线):30s 一次
|
|
7737
|
+
/* 为什么不是死板的 `setInterval(2000)`:一次 `snapshot()` = 宿主机上**起一个进程**。本机(手机)
|
|
7738
|
+
没有会话总线 ⇒ 每 2s 起一个必然查不到东西的进程是纯浪费(用户点名过资源纪律)。
|
|
7739
|
+
所以用**自适应延时**:拿到系统媒体 ⇒ 2s;不可用/失败 ⇒ 30s 慢慢探。 */
|
|
7740
|
+
let npSysTimer = 0, npSysLast = null, npSysFails = 0, npSysProxyWarned = false, npSysDelay = NP_SYS_POLL_MS;
|
|
7741
|
+
function npSystemMediaStop() {
|
|
7742
|
+
try { if (npSysTimer) { clearTimeout(npSysTimer); npSysTimer = 0 } } catch (e) {}
|
|
7743
|
+
}
|
|
7744
|
+
/** 定时器工厂:**有 `unref` 就 unref**(Node/门禁环境)。
|
|
7745
|
+
* 为什么必须这样:门禁把 client.js 跑在桩 DOM 里,断言跑完后**进程要能自己退出**——
|
|
7746
|
+
* 一个挂着的 30s 定时器会让事件循环一直有活干 ⇒ 整条门禁"跑完了却不结束"(实测踩到:
|
|
7747
|
+
* `now-playing-test` 卡到 600s 超时)。浏览器里没有 `unref`,这一行等于不存在。 */
|
|
7748
|
+
function npSysTimerSet(fn, ms) {
|
|
7749
|
+
let t = null;
|
|
7750
|
+
try { t = setTimeout(fn, ms) } catch (e) { return null }
|
|
7751
|
+
try { if (t && typeof t.unref === "function") t.unref() } catch (e) {}
|
|
7752
|
+
return t;
|
|
7753
|
+
}
|
|
7754
|
+
/** 排下一次探测(单链,不叠定时器;`npSysTimer` 同时是"在跑"的哨兵)。 */
|
|
7755
|
+
function npSystemMediaSchedule(ms) {
|
|
7756
|
+
try {
|
|
7757
|
+
npSystemMediaStop();
|
|
7758
|
+
npSysDelay = Math.max(NP_SYS_POLL_MS, Math.min(NP_SYS_IDLE_MS, Number(ms) || NP_SYS_POLL_MS));
|
|
7759
|
+
npSysTimer = npSysTimerSet(() => { npSysTimer = 0; try { npSystemMediaTick() } catch (e) {} }, npSysDelay);
|
|
7760
|
+
} catch (e) {}
|
|
7761
|
+
}
|
|
7762
|
+
/** 把宿主快照映射到卡片(`kind:'system'` 只是个信息字段:组件侧对 `media.kind` 零分支)。 */
|
|
7763
|
+
function npApplySystemMedia(d) {
|
|
7764
|
+
try {
|
|
7765
|
+
const ctl = npEnsureCtl();
|
|
7766
|
+
if (!ctl || !d || d.available !== true) return false;
|
|
7767
|
+
const cover = d.artUrlKind === 'file'
|
|
7768
|
+
? (HOST_BASE + '/media-art?player=' + encodeURIComponent(String(d.player || '')))
|
|
7769
|
+
: (d.artUrl || '');
|
|
7770
|
+
if (d.artUrlKind === 'file' && !npSysProxyWarned) { npSysProxyWarned = true; try { mpwTrace("media-session:art-proxy", { player: d.player }) } catch (e) {} }
|
|
7771
|
+
ctl.setMedia({
|
|
7772
|
+
kind: 'system',
|
|
7773
|
+
title: String(d.title || ''),
|
|
7774
|
+
byline: [d.artist, d.album].filter(Boolean).join(' · ') || String(d.player || ''),
|
|
7775
|
+
cover: cover, total: d.duration ? Math.round(Number(d.duration) / 1000) : null,
|
|
7776
|
+
playing: !!d.playing, canPlay: !!d.canPlay, canNext: !!d.canNext, canPrev: !!d.canPrev,
|
|
7777
|
+
canVolume: true, system: true, player: String(d.player || ''), adapter: String(d.adapter || ''),
|
|
7778
|
+
});
|
|
7779
|
+
if (d.position !== null && d.position !== undefined) ctl.setProgress(Math.round(Number(d.position) / 1000));
|
|
7780
|
+
npSysLast = { at: Date.now(), player: String(d.player || ''), title: String(d.title || ''), playing: !!d.playing, canNext: !!d.canNext, canPrev: !!d.canPrev, canPlay: !!d.canPlay };
|
|
7781
|
+
return true;
|
|
7782
|
+
} catch (e) { mpwErr("npApplySystemMedia", e); return false }
|
|
7783
|
+
}
|
|
7784
|
+
/** 一次拉取 + 落卡(轮询的每一步都走它;`?describe=1` 不带 —— 那是诊断用的)。 */
|
|
7785
|
+
async function npRefreshSystemMedia() {
|
|
7786
|
+
try {
|
|
7787
|
+
const ctl = npEnsureCtl();
|
|
7788
|
+
if (!ctl || !ctl.isEnabled()) { npSystemMediaStop(); return null }
|
|
7789
|
+
const r = await fetch(HOST_BASE + '/media-session', { cache: 'no-store' });
|
|
7790
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
7791
|
+
const d = await r.json();
|
|
7792
|
+
npSysFails = 0;
|
|
7793
|
+
if (d && d.available === true) { npApplySystemMedia(d); return d }
|
|
7794
|
+
/* 不可用:**不接管**(卡片继续显示壁纸自己的媒体),但把原因留痕 —— 排障时一眼看到"为什么没接上"。 */
|
|
7795
|
+
try { window.__mpwSystemMedia = { available: false, reason: d && d.reason, at: Date.now() } } catch (e) {}
|
|
7796
|
+
return d || null;
|
|
7797
|
+
} catch (e) {
|
|
7798
|
+
npSysFails++;
|
|
7799
|
+
try { window.__mpwSystemMedia = { available: false, reason: 'fetch:' + String(e && e.message || e).slice(0, 80), fails: npSysFails, at: Date.now() } } catch (e2) {}
|
|
7800
|
+
return null;
|
|
7801
|
+
}
|
|
7802
|
+
}
|
|
7803
|
+
/** 一拍:不可见就**不发请求**(只把下一次排后一点);可见才拉一次,并按结果决定节奏。 */
|
|
7804
|
+
async function npSystemMediaTick() {
|
|
7805
|
+
try {
|
|
7806
|
+
const ctl = npEnsureCtl();
|
|
7807
|
+
if (!ctl || !ctl.isEnabled()) { npSystemMediaStop(); return null }
|
|
7808
|
+
if (document.visibilityState !== 'visible') { npSystemMediaSchedule(NP_SYS_IDLE_MS); return null }
|
|
7809
|
+
const d = await npRefreshSystemMedia();
|
|
7810
|
+
if (npSysFails >= 3) { npSystemMediaSchedule(NP_SYS_IDLE_MS); return d } // 连续失败:降到慢探(不再骚扰宿主,也不彻底停 ⇒ 后端起来了还能自己接上)
|
|
7811
|
+
npSystemMediaSchedule(d && d.available === true ? NP_SYS_POLL_MS : NP_SYS_IDLE_MS);
|
|
7812
|
+
return d;
|
|
7813
|
+
} catch (e) { npSystemMediaSchedule(NP_SYS_IDLE_MS); return null }
|
|
7814
|
+
}
|
|
7815
|
+
/** 起轮询(幂等):第一次立刻探一拍,之后由 `npSystemMediaTick` 自适应续期。 */
|
|
7816
|
+
function npSystemMediaStart() {
|
|
7817
|
+
try {
|
|
7818
|
+
const ctl = npEnsureCtl();
|
|
7819
|
+
if (!ctl || !ctl.isEnabled()) return false;
|
|
7820
|
+
npSysFails = 0;
|
|
7821
|
+
npSysTimerSet(() => { try { npSystemMediaTick() } catch (e) {} }, 0);
|
|
7822
|
+
npSysTimer = npSysTimerSet(() => { npSysTimer = 0; try { npSystemMediaTick() } catch (e) {} }, NP_SYS_POLL_MS);
|
|
7823
|
+
return true;
|
|
7824
|
+
} catch (e) { return false }
|
|
7825
|
+
}
|
|
7826
|
+
/** 系统媒体是否是当前数据源(供传输动作改道;`npSysLast` 只记"真的接上过")。 */
|
|
7827
|
+
function npSystemMediaActive() { try { return !!(npSysLast && Date.now() - npSysLast.at < 5 * NP_SYS_POLL_MS) } catch (e) { return false } }
|
|
7828
|
+
/** 传输动作改道:系统媒体在场时,卡片按钮控制**系统播放器**(能力位先判,够不着就不发)。 */
|
|
7829
|
+
async function npSystemControl(op) {
|
|
7830
|
+
try {
|
|
7831
|
+
const map = { play: 'playpause', pause: 'playpause', restart: 'playpause', next: 'next', prev: 'prev' };
|
|
7832
|
+
const sysOp = map[op];
|
|
7833
|
+
if (!sysOp) return { ok: false, reason: 'unsupported-op' };
|
|
7834
|
+
const last = npSysLast || {};
|
|
7835
|
+
const r = await fetch(HOST_BASE + '/media-control', {
|
|
7836
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ op: sysOp }),
|
|
7837
|
+
});
|
|
7838
|
+
const d = await r.json().catch(() => null);
|
|
7839
|
+
try { npOpTrace('system-control', sysOp, (d && d.ok ? 'ok' : 'fail:' + String(d && d.reason || r.status)) + ' player=' + last.player) } catch (e) {}
|
|
7840
|
+
setTimeout(() => { try { npRefreshSystemMedia() } catch (e) {} }, 250); // 控制完立刻回读一次状态
|
|
7841
|
+
return d || { ok: false, reason: 'bad-reply' };
|
|
7842
|
+
} catch (e) { return { ok: false, reason: 'fetch:' + String(e && e.message || e).slice(0, 60) } }
|
|
7843
|
+
}
|
|
7844
|
+
|
|
7709
7845
|
let npSlotAction = null; // 宿主 slot `sidebar.footer.action` 的占用组件
|
|
7710
7846
|
|
|
7711
7847
|
/** 控制器**单例**(幂等):slot 组件与 apply 路径共用同一个。 */
|
|
@@ -7765,6 +7901,7 @@ function sectionSigNow() {
|
|
|
7765
7901
|
npDropAudio();
|
|
7766
7902
|
npMediaCache = { key: "", at: 0, data: null };
|
|
7767
7903
|
npMediaToken++;
|
|
7904
|
+
npSystemMediaStop(); // ①(MEDIA-1 接线) 开关关掉 ⇒ 系统媒体轮询也要停(不留后台定时器)
|
|
7768
7905
|
return;
|
|
7769
7906
|
}
|
|
7770
7907
|
ctl.setEnabled(true);
|
|
@@ -7839,6 +7976,9 @@ function sectionSigNow() {
|
|
|
7839
7976
|
/* ①(NP-4)① 电平:刷新/切壁纸后把 `npVolume` 补落到真实元素上。 */
|
|
7840
7977
|
try { npApplyVolume(); } catch (e) {}
|
|
7841
7978
|
ctl.setMedia(npResolveMedia(s));
|
|
7979
|
+
/* ①(MEDIA-1 接线 2026-09-20) 壁纸自己的媒体先落卡(同步、不变),**再**异步补系统媒体:
|
|
7980
|
+
宿主回 `available:false`(本机无会话总线)⇒ 什么都不覆盖,卡片还是壁纸自己的标题/预览。 */
|
|
7981
|
+
try { npSystemMediaStart(); } catch (e) {}
|
|
7842
7982
|
/* ①(NP-3) 清单/播放器:有目录作用域才拉清单;清单到了之后把当前曲目装进播放器
|
|
7843
7983
|
(**只装源、不自动播放** —— 浏览器自动播放策略 + "不替用户按播放"两条都要求这样)。 */
|
|
7844
7984
|
try {
|
|
@@ -8712,6 +8852,18 @@ function sectionSigNow() {
|
|
|
8712
8852
|
return st.join(" <- ");
|
|
8713
8853
|
} catch (e) { return "" }
|
|
8714
8854
|
}
|
|
8855
|
+
/** ①(2026-09-20 彻查「静音下仍出声」) 这一条记录**来自哪个窗口**:
|
|
8856
|
+
* 主页面 / 同源 iframe(壁纸帧、别的插件帧…)。跨源帧我们的钩子装不进去 ⇒ 如实标 `foreign:true`,
|
|
8857
|
+
* 绝不把"看不到"说成"没有"(用户能据此知道"声音来自某个跨源帧,本插件管不到它的 WebAudio")。 */
|
|
8858
|
+
function mpwAuditWin(win) {
|
|
8859
|
+
try {
|
|
8860
|
+
const w = win || window;
|
|
8861
|
+
const isTop = (() => { try { return w === w.top } catch (e) { return false } })();
|
|
8862
|
+
let url = "";
|
|
8863
|
+
try { url = String((w.location && w.location.href) || "") } catch (e) { url = "cross-origin" }
|
|
8864
|
+
return { top: isTop, url: url.replace(/token=[^&]*/, "token=…").slice(0, 120), foreign: url === "cross-origin" };
|
|
8865
|
+
} catch (e) { return { top: true, url: "", foreign: false } }
|
|
8866
|
+
}
|
|
8715
8867
|
function mpwAuditEl(el) {
|
|
8716
8868
|
try {
|
|
8717
8869
|
if (!el) return null;
|
|
@@ -8721,6 +8873,17 @@ function sectionSigNow() {
|
|
|
8721
8873
|
connected: el.isConnected !== false,
|
|
8722
8874
|
src: src.replace(/token=[^&]*/, "token=…").slice(0, 90),
|
|
8723
8875
|
hasSrcAttr: !!(el.getAttribute && el.getAttribute("src")),
|
|
8876
|
+
/* 声音素材的**归属指纹**(用户问"这声音是谁放的"时,一眼能分类):
|
|
8877
|
+
ours = 我们自己的元素/接口;whale = 那个第三方鲸鱼插件;frame = 壁纸帧内;other = 未知 */
|
|
8878
|
+
owner: (() => {
|
|
8879
|
+
try {
|
|
8880
|
+
const own = (String(el.id || "") === "mpw-bgVideo") || /(^|\s)mpw[-_]/.test(String(el.className || "")) || /\/api\/mpkg-wallpaper\//.test(src);
|
|
8881
|
+
if (own) return "ours";
|
|
8882
|
+
if (/\/dsh-whale\//.test(src)) return "whale-widget";
|
|
8883
|
+
if (el.ownerDocument && el.ownerDocument !== document) return "frame";
|
|
8884
|
+
return "other";
|
|
8885
|
+
} catch (e) { return "unknown" }
|
|
8886
|
+
})(),
|
|
8724
8887
|
};
|
|
8725
8888
|
} catch (e) { return null }
|
|
8726
8889
|
}
|
|
@@ -8730,11 +8893,11 @@ function sectionSigNow() {
|
|
|
8730
8893
|
return { on: s.npNowPlaying !== void 0 ? !!s.npNowPlaying : DEFAULT_NP_NOW_PLAYING, link: npLinkOn(s), mute: s.mute !== void 0 ? !!s.mute : true };
|
|
8731
8894
|
} catch (e) { return null }
|
|
8732
8895
|
}
|
|
8733
|
-
function mpwAuditPush(kind, el, extra) {
|
|
8896
|
+
function mpwAuditPush(kind, el, extra, win) {
|
|
8734
8897
|
try {
|
|
8735
8898
|
if (!mpwAuditOn()) return null;
|
|
8736
8899
|
const rec = Object.assign({
|
|
8737
|
-
t: Date.now(), kind: String(kind || ""), who: mpwAuditWho(), el: mpwAuditEl(el),
|
|
8900
|
+
t: Date.now(), kind: String(kind || ""), who: mpwAuditWho(), el: mpwAuditEl(el), win: mpwAuditWin(win),
|
|
8738
8901
|
muted: el ? !!el.muted : null, volume: el && typeof el.volume === "number" ? Number(el.volume.toFixed(2)) : null,
|
|
8739
8902
|
paused: el ? !!el.paused : null, currentTime: el && typeof el.currentTime === "number" ? Number(el.currentTime.toFixed(2)) : null,
|
|
8740
8903
|
hidden: (() => { try { return document.hidden === true } catch (e) { return null } })(),
|
|
@@ -8751,7 +8914,14 @@ function sectionSigNow() {
|
|
|
8751
8914
|
③ hidden 期间的 play / 取消静音。
|
|
8752
8915
|
命中 ⇒ 立刻把**审计窗口**(最近 12 条)POST 到 /diag(脱敏、截断、5s 节流)。 */
|
|
8753
8916
|
const audible = !!(rec.el && rec.paused === false && rec.muted === false && Number(rec.volume) > 0);
|
|
8917
|
+
/* ①(2026-09-20 用户点名「静音状态下还是突然冒出来的声音」) **这一条**才是那次投诉的直接判据:
|
|
8918
|
+
设置项 `mute === true`(面板写着静音),元素却处在**可听**状态(`!paused && !muted && volume>0`)
|
|
8919
|
+
—— 不管是谁把它改成这样的(我们的哪条路径 / 壁纸帧内 / 别的插件),都必须立刻上报并带上**是谁改的**。
|
|
8920
|
+
旧判据只有"可听播放"这一条泛判据(设置是开是关都报),既漏了"静音下可听"这个组合的强调,
|
|
8921
|
+
也没把 `np.mute` 与可听状态**放在一起**判断。 */
|
|
8922
|
+
const muteOnAudible = !!(rec.np && rec.np.mute === true && audible);
|
|
8754
8923
|
const suspicious = audible
|
|
8924
|
+
|| muteOnAudible
|
|
8755
8925
|
|| !!(rec.el && rec.el.connected === false && rec.paused === false)
|
|
8756
8926
|
|| (kind === "play" && rec.hidden === true)
|
|
8757
8927
|
|| ((kind === "unmute" || kind === "volume") && rec.hidden === true && rec.muted === false);
|
|
@@ -8761,7 +8931,8 @@ function sectionSigNow() {
|
|
|
8761
8931
|
mpwAuditLastBeacon = Date.now();
|
|
8762
8932
|
try {
|
|
8763
8933
|
mpwWebDiag("audio-audit", {
|
|
8764
|
-
trigger:
|
|
8934
|
+
trigger: muteOnAudible ? "mute-on-but-audible"
|
|
8935
|
+
: (audible ? "audible-playback" : (rec.el && rec.el.connected === false ? "detached-playing" : "hidden-transition")),
|
|
8765
8936
|
record: rec, window: list.slice(-12), count: list.length,
|
|
8766
8937
|
visibility: (() => { try { return document.visibilityState } catch (e) { return null } })(),
|
|
8767
8938
|
});
|
|
@@ -8772,19 +8943,24 @@ function sectionSigNow() {
|
|
|
8772
8943
|
return rec;
|
|
8773
8944
|
} catch (e) { return null }
|
|
8774
8945
|
}
|
|
8775
|
-
|
|
8776
|
-
|
|
8777
|
-
|
|
8778
|
-
|
|
8779
|
-
|
|
8780
|
-
|
|
8781
|
-
|
|
8782
|
-
|
|
8946
|
+
/** ①(2026-09-20 彻查「静音下仍出声」) 对**任意窗口**装审计钩子。
|
|
8947
|
+
* 为什么必须能装进子窗口:真机上"突然响一声"的声源有两类是我们看不到的 ——
|
|
8948
|
+
* ① **壁纸帧内**的 `<audio>/<video>`(同源模式帧有**自己独立的 `HTMLMediaElement.prototype`**,
|
|
8949
|
+
* 在主窗口上打补丁对它完全无效 ⇒ 旧审计对帧内声音是"瞎的");
|
|
8950
|
+
* ② 别的插件帧里的声音(同一原因)。
|
|
8951
|
+
* 跨源帧装不进去(浏览器安全模型)⇒ 由 `mpwAuditWin()` 如实标 `foreign:true`,不谎报"没有声音"。
|
|
8952
|
+
* `who` 的栈里会带上帧 URL,用户/我们都能据此指认"这条声音是谁放的"。 */
|
|
8953
|
+
function mpwAuditPatchWin(win) {
|
|
8954
|
+
if (!win) return false;
|
|
8955
|
+
try { if (win.__mpwAuditedWin) return false } catch (e) { return false }
|
|
8956
|
+
try { win.__mpwAuditedWin = true } catch (e) { /* 只读窗口也继续装 */ }
|
|
8957
|
+
const push = (kind, el, extra) => { try { return mpwAuditPush(kind, el, extra, win) } catch (e) { return null } };
|
|
8958
|
+
/* ① `<audio>/<video>`:play / muted / volume */
|
|
8783
8959
|
try {
|
|
8784
|
-
const P =
|
|
8960
|
+
const P = win.HTMLMediaElement && win.HTMLMediaElement.prototype;
|
|
8785
8961
|
if (P && typeof P.play === "function" && !P.__mpwAudited) {
|
|
8786
8962
|
const origPlay = P.play;
|
|
8787
|
-
P.play = function () {
|
|
8963
|
+
P.play = function () { push("play", this); return origPlay.apply(this, arguments) };
|
|
8788
8964
|
for (const prop of ["muted", "volume"]) {
|
|
8789
8965
|
try {
|
|
8790
8966
|
const d = Object.getOwnPropertyDescriptor(P, prop);
|
|
@@ -8794,7 +8970,7 @@ function sectionSigNow() {
|
|
|
8794
8970
|
const bMuted = !!this.muted, bVol = this.volume;
|
|
8795
8971
|
set.call(this, v);
|
|
8796
8972
|
const kind = prop === "muted" ? (this.muted ? "mute" : "unmute") : "volume";
|
|
8797
|
-
if (prop === "muted" ? bMuted !== !!this.muted : bVol !== this.volume)
|
|
8973
|
+
if (prop === "muted" ? bMuted !== !!this.muted : bVol !== this.volume) push(kind, this, { before: { muted: bMuted, volume: bVol } });
|
|
8798
8974
|
return v;
|
|
8799
8975
|
};
|
|
8800
8976
|
wrapped.__mpwAudited = true;
|
|
@@ -8804,34 +8980,88 @@ function sectionSigNow() {
|
|
|
8804
8980
|
P.__mpwAudited = true;
|
|
8805
8981
|
}
|
|
8806
8982
|
} catch (e) {}
|
|
8983
|
+
/* ② `new Audio(...)`(绕过 DOM 标签的声源,最常见的"响一声") */
|
|
8807
8984
|
try {
|
|
8808
|
-
if (typeof
|
|
8809
|
-
const OrigAudio =
|
|
8810
|
-
const Wrapped = function () { const a = new OrigAudio(...arguments); try {
|
|
8985
|
+
if (typeof win.Audio === "function" && !win.Audio.__mpwAudited) {
|
|
8986
|
+
const OrigAudio = win.Audio;
|
|
8987
|
+
const Wrapped = function () { const a = new OrigAudio(...arguments); try { push("new-Audio", a, { args: Array.prototype.slice.call(arguments).join(",").slice(0, 80) }) } catch (e) {} return a };
|
|
8811
8988
|
Wrapped.__mpwAudited = true;
|
|
8812
8989
|
try { Wrapped.prototype = OrigAudio.prototype } catch (e) {}
|
|
8813
|
-
|
|
8990
|
+
win.Audio = Wrapped;
|
|
8814
8991
|
}
|
|
8815
8992
|
} catch (e) {}
|
|
8993
|
+
/* ③ WebAudio:构造 / decodeAudioData / **真的出声那一刻**(AudioBufferSourceNode.start)
|
|
8994
|
+
④(2026-09-20 补) 只记"建了 AudioContext"是没用的:Live2D 角色语音那类声源
|
|
8995
|
+
走 `AudioBufferSourceNode.start()` 出声,**不碰任何 `<audio>` 标签** ——
|
|
8996
|
+
这正是"设置静音却仍然响一声"最可能的声源,必须记到"谁在什么时候 start 了它"。 */
|
|
8816
8997
|
for (const key of ["AudioContext", "webkitAudioContext"]) {
|
|
8817
8998
|
try {
|
|
8818
|
-
const Orig =
|
|
8999
|
+
const Orig = win[key];
|
|
8819
9000
|
if (typeof Orig !== "function" || Orig.__mpwAudited) continue;
|
|
8820
|
-
const Wrapped = function () { const c = new Orig(...arguments); try {
|
|
9001
|
+
const Wrapped = function () { const c = new Orig(...arguments); try { push("AudioContext", null, { ctor: key, state: c && c.state }) } catch (e) {} return c };
|
|
8821
9002
|
Wrapped.__mpwAudited = true;
|
|
8822
9003
|
try { Wrapped.prototype = Orig.prototype } catch (e) {}
|
|
8823
|
-
|
|
9004
|
+
win[key] = Wrapped;
|
|
8824
9005
|
} catch (e) {}
|
|
8825
9006
|
}
|
|
8826
9007
|
try {
|
|
8827
|
-
const AC =
|
|
9008
|
+
const AC = win.AudioContext || win.webkitAudioContext;
|
|
8828
9009
|
const proto = AC && AC.prototype;
|
|
8829
9010
|
if (proto && typeof proto.decodeAudioData === "function" && !proto.__mpwAudited) {
|
|
8830
9011
|
const orig = proto.decodeAudioData;
|
|
8831
|
-
proto.decodeAudioData = function () {
|
|
9012
|
+
proto.decodeAudioData = function () { push("decodeAudioData", null, { ctxState: this && this.state }); return orig.apply(this, arguments) };
|
|
8832
9013
|
proto.__mpwAudited = true;
|
|
8833
9014
|
}
|
|
9015
|
+
if (proto && typeof proto.resume === "function" && !proto.__mpwResumed) {
|
|
9016
|
+
const origResume = proto.resume;
|
|
9017
|
+
proto.resume = function () { push("audioctx-resume", null, { ctxState: this && this.state }); return origResume.apply(this, arguments) };
|
|
9018
|
+
proto.__mpwResumed = true;
|
|
9019
|
+
}
|
|
9020
|
+
} catch (e) {}
|
|
9021
|
+
for (const [nodeKey, nodeName] of [["AudioBufferSourceNode", "buffer-source"], ["OscillatorNode", "oscillator"], ["ConstantSourceNode", "constant"]]) {
|
|
9022
|
+
try {
|
|
9023
|
+
const Ctor = win[nodeKey];
|
|
9024
|
+
const np = Ctor && Ctor.prototype;
|
|
9025
|
+
if (np && typeof np.start === "function" && !np.__mpwAudited) {
|
|
9026
|
+
const origStart = np.start;
|
|
9027
|
+
np.start = function () {
|
|
9028
|
+
let ctxState = null, dur = null;
|
|
9029
|
+
try { ctxState = this.context && this.context.state } catch (e) {}
|
|
9030
|
+
try { dur = this.buffer && this.buffer.duration } catch (e) {}
|
|
9031
|
+
push("webaudio-start", null, { node: nodeName, ctxState: ctxState, bufferSec: dur == null ? null : Number(Number(dur).toFixed(3)), ourCtx: !!this.context });
|
|
9032
|
+
return origStart.apply(this, arguments)
|
|
9033
|
+
};
|
|
9034
|
+
np.__mpwAudited = true;
|
|
9035
|
+
}
|
|
9036
|
+
} catch (e) {}
|
|
9037
|
+
}
|
|
9038
|
+
/* ④ 递归同源子帧(帧内元素有自己的原型对象;跨源会抛 ⇒ 记一条"装不上"的痕) */
|
|
9039
|
+
try {
|
|
9040
|
+
for (let i = 0; i < win.frames.length; i++) {
|
|
9041
|
+
try { mpwAuditPatchWin(win.frames[i]) } catch (e) { try { mpwAuditPush("frame-cross-origin", null, { idx: i, reason: String(e && e.name || e).slice(0, 40) }, win) } catch (e2) {} }
|
|
9042
|
+
}
|
|
9043
|
+
} catch (e) {}
|
|
9044
|
+
return true;
|
|
9045
|
+
}
|
|
9046
|
+
/** 新出现的 iframe(壁纸帧常是晚一步插进来的)⇒ 重新扫一遍;每秒一次、上限 60 次,零长尾开销。 */
|
|
9047
|
+
let mpwAuditFrameScans = 0;
|
|
9048
|
+
function mpwAuditScanFrames() {
|
|
9049
|
+
try {
|
|
9050
|
+
if (mpwAuditFrameScans++ > 60) return;
|
|
9051
|
+
mpwAuditPatchWin(window);
|
|
9052
|
+
} catch (e) {}
|
|
9053
|
+
}
|
|
9054
|
+
function mpwInstallAudioAudit() {
|
|
9055
|
+
if (mpwAuditInstalled || !mpwAuditOn()) return false;
|
|
9056
|
+
mpwAuditInstalled = true;
|
|
9057
|
+
try {
|
|
9058
|
+
window.__mpwAudioAudit = window.__mpwAudioAudit || { list: [], suspicious: 0 };
|
|
9059
|
+
window.__mpwAudioAudit.push = mpwAuditPush;
|
|
9060
|
+
window.__mpwAudioAudit.clear = () => { try { window.__mpwAudioAudit.list.length = 0 } catch (e) {} };
|
|
9061
|
+
window.__mpwAudioAudit.scanFrames = () => { try { mpwAuditFrameScans = 0; mpwAuditScanFrames(); return true } catch (e) { return false } };
|
|
8834
9062
|
} catch (e) {}
|
|
9063
|
+
try { mpwAuditPatchWin(window); } catch (e) {}
|
|
9064
|
+
try { setInterval(mpwAuditScanFrames, 1000); } catch (e) {}
|
|
8835
9065
|
try { mpwTrace("audio-audit:installed", {}); } catch (e) {}
|
|
8836
9066
|
return true;
|
|
8837
9067
|
}
|
package/lib/index.js
CHANGED
|
@@ -26,6 +26,12 @@ import {
|
|
|
26
26
|
MEDIA_AUDIO_ROUTE, MEDIA_AUDIO_DEFAULTS, normalizeMediaAudio, mediaAudioReport, mediaAudioPatchForEntry,
|
|
27
27
|
} from './web-wallpaper.js';
|
|
28
28
|
|
|
29
|
+
/* ①(MEDIA-1 接线 2026-09-20) 系统媒体会话(MPRIS / SMTC):模块早就在(`media-session-test` 95/0),
|
|
30
|
+
但一直**没接线** ⇒ NP 卡片拿不到系统曲目/封面/进度(喂它的是测试台自己的播放器状态)。
|
|
31
|
+
接线口径见 docs/MEDIA-SESSION.md §6:宿主三条只读/控制路由 + 客户端异步补充路径,
|
|
32
|
+
**不改** `npResolveMedia` 那条同步语义(它解的是"壁纸自己的媒体")。 */
|
|
33
|
+
import { createMediaSession, CONTROL_OPS, SEEK_MAX_MS, MAX_ART_CHARS } from './media-session.js';
|
|
34
|
+
|
|
29
35
|
const BASE = '/api/mpkg-wallpaper';
|
|
30
36
|
/** ①(修正) 更新检测:版本号主导(semver),哈希仅作内容差异提示。
|
|
31
37
|
* 之前纯哈希对比——本地有未推送改动就误报"新版本 3.1.2 → 3.1.2"(用户实测)。 */
|
|
@@ -1916,6 +1922,74 @@ function apply(ctx) {
|
|
|
1916
1922
|
});
|
|
1917
1923
|
|
|
1918
1924
|
// ①(新) 自定义本地壁纸目录:POST 设置并扫描目录内的媒体文件
|
|
1925
|
+
/* ═══ ①(MEDIA-1 接线 2026-09-20) 系统媒体会话三条路由(契约:docs/MEDIA-SESSION.md §6) ═══════════
|
|
1926
|
+
· GET /media-session ⇒ `snapshot()` 21 键**原样透传**(路由里不"顺手"改形状)
|
|
1927
|
+
· POST /media-control {op,arg} ⇒ op 只允许 CONTROL_OPS 六项(坏 op 在模块里就被拒,这里再挡一道)
|
|
1928
|
+
· GET /media-art?player=<名> ⇒ MPRIS 的 `file://` 封面代理(浏览器读不到宿主磁盘;
|
|
1929
|
+
只允许**快照里出现过的** player + 那个 artUrl 路径)
|
|
1930
|
+
为什么是三条而不是一条:封面是二进制(不能塞进 JSON)、控制是副作用(不能混进只读快照)、
|
|
1931
|
+
快照要能被 2s 轮询(越简单越好)。 */
|
|
1932
|
+
const mediaSession = createMediaSession({ env: process.env });
|
|
1933
|
+
webServer.register({
|
|
1934
|
+
kind: 'exact', path: BASE + '/media-session',
|
|
1935
|
+
handler: async (req, res) => {
|
|
1936
|
+
try {
|
|
1937
|
+
const snap = await mediaSession.snapshot();
|
|
1938
|
+
/* 默认档**只回 21 键快照**(契约:路由里不改形状)。`?describe=1` 才附带模块自述
|
|
1939
|
+
(平台/适配器/超时/上限;纯函数、不触发任何命令),给诊断面板与探针用。 */
|
|
1940
|
+
let extra = {};
|
|
1941
|
+
try { if (new URL(req.url, 'http://localhost').searchParams.get('describe') === '1') extra.describe = mediaSession.describe(); } catch (e) {}
|
|
1942
|
+
json(res, 200, Object.assign({ ok: true }, snap, extra));
|
|
1943
|
+
} catch (e) { json(res, 500, { ok: false, error: String(e && e.message || e) }); }
|
|
1944
|
+
},
|
|
1945
|
+
});
|
|
1946
|
+
webServer.register({
|
|
1947
|
+
kind: 'exact', path: BASE + '/media-control',
|
|
1948
|
+
handler: async (req, res) => {
|
|
1949
|
+
try {
|
|
1950
|
+
if (req.method !== 'POST') { json(res, 405, { ok: false, error: '只接受 POST(body = {op,arg})', allow: 'POST' }); return; }
|
|
1951
|
+
let body = '';
|
|
1952
|
+
for await (const c of req) { body += String(c); if (body.length > 64 * 1024) { json(res, 413, { ok: false, error: 'body 过大' }); return; } }
|
|
1953
|
+
let d = {};
|
|
1954
|
+
try { d = JSON.parse(body || '{}'); } catch { json(res, 400, { ok: false, error: 'bad json' }); return; }
|
|
1955
|
+
const op = String(d && d.op || '');
|
|
1956
|
+
if (!CONTROL_OPS.includes(op)) { json(res, 400, { ok: false, error: 'bad op', ops: CONTROL_OPS.slice() }); return; }
|
|
1957
|
+
let arg = d && d.arg;
|
|
1958
|
+
if (op === 'seek') {
|
|
1959
|
+
const n = Number(arg);
|
|
1960
|
+
if (!Number.isFinite(n) || n < 0) { json(res, 400, { ok: false, error: 'bad seek arg', maxMs: SEEK_MAX_MS }); return; }
|
|
1961
|
+
arg = Math.min(Math.floor(n), SEEK_MAX_MS);
|
|
1962
|
+
} else arg = null;
|
|
1963
|
+
const out = await mediaSession.control(op, arg);
|
|
1964
|
+
json(res, out && out.ok ? 200 : 200, Object.assign({ ok: !!(out && out.ok) }, out || {}));
|
|
1965
|
+
} catch (e) { json(res, 500, { ok: false, error: String(e && e.message || e) }); }
|
|
1966
|
+
},
|
|
1967
|
+
});
|
|
1968
|
+
webServer.register({
|
|
1969
|
+
kind: 'exact', path: BASE + '/media-art',
|
|
1970
|
+
handler: async (req, res) => {
|
|
1971
|
+
try {
|
|
1972
|
+
const u = new URL(req.url, 'http://localhost');
|
|
1973
|
+
const player = String(u.searchParams.get('player') || '');
|
|
1974
|
+
const snap = await mediaSession.snapshot();
|
|
1975
|
+
// 只服务**当前快照里**那个玩家的封面:调用方不能拿任意路径来读宿主文件。
|
|
1976
|
+
if (!snap || !snap.available || !player || player !== snap.player) { json(res, 404, { ok: false, error: 'no such player in current snapshot' }); return; }
|
|
1977
|
+
if (snap.artUrlKind !== 'file') { json(res, 409, { ok: false, error: 'artUrl 不是本地文件(直接用 artUrl 即可)', kind: snap.artUrlKind }); return; }
|
|
1978
|
+
const p = decodeURIComponent(String(snap.artUrl).replace(/^file:\/\//, ''));
|
|
1979
|
+
let st = null;
|
|
1980
|
+
try { st = statSync(p); } catch { json(res, 404, { ok: false, error: '封面文件不存在' }); return; }
|
|
1981
|
+
if (!st.isFile()) { json(res, 400, { ok: false, error: '不是文件' }); return; }
|
|
1982
|
+
if (st.size > MAX_ART_CHARS) { json(res, 413, { ok: false, error: '封面过大', bytes: st.size, maxBytes: MAX_ART_CHARS }); return; }
|
|
1983
|
+
const buf = readFileSync(p);
|
|
1984
|
+
const mime = /^\x89PNG/.test(buf.slice(0, 4).toString('latin1')) ? 'image/png'
|
|
1985
|
+
: (buf.slice(0, 3).toString('latin1') === 'GIF' ? 'image/gif'
|
|
1986
|
+
: (buf.slice(0, 2).toString('latin1') === '\xff\xd8' ? 'image/jpeg' : 'application/octet-stream'));
|
|
1987
|
+
res.writeHead(200, { 'content-type': mime, 'content-length': String(buf.length), 'cache-control': 'no-store' });
|
|
1988
|
+
res.end(buf);
|
|
1989
|
+
} catch (e) { json(res, 500, { ok: false, error: String(e && e.message || e) }); }
|
|
1990
|
+
},
|
|
1991
|
+
});
|
|
1992
|
+
|
|
1919
1993
|
webServer.register({
|
|
1920
1994
|
kind: 'exact', path: BASE + '/custom-dir',
|
|
1921
1995
|
handler: async (req, res) => {
|
package/package.json
CHANGED