dsh-mpkg-wallpaper 3.8.2 → 3.9.1

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
@@ -6,26 +6,125 @@
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.8.0`** in `package.json`. The publish surface is **14 files** (`lib/` runtime files + `icon.svg`, `cordis.patch.yml`, `README.md`, `README.en.md`, `THIRD-PARTY.md`, `LICENSE`); `lib/liquid-glass/**`, `lib/liquid-glass-bundle.js`, `dist/`, `tools/` and `docs/` never enter the npm package (`package.json:8-21`).
9
+ > Version scope: this document describes the implementation shipped as **`3.9.1`** 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
 
13
- ## What changed in this version
13
+ ## Download · Install
14
14
 
15
- Current version = **3.8.0** in `package.json`. 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.
15
+ The plugin is published on npm (`dsh-mpkg-wallpaper`). Four ways to load it pick one from this table, then read its section:
16
16
 
17
- - **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 is still zero injection** — no DOM, no observers, not a single NP rule in the `buildCss` output (group B of `tools/now-playing-test.mjs`). 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 widget](#now-playing-widget).
18
- - **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.
19
- - **The card is no longer clipped in floating mode**: fit-scaling now measures **our own container** (the old code measured `[class*="sidebarCol"]`, and that class name appears more than once on a real machine — it measured 280 while our container was 256), `.mpw_np` is pinned to `math.W` with the overflow shared evenly, and the web-wallpaper branch now applies the `data-mpw-float` gate and calls `applyNowPlaying()` (the old branch returned early, so after switching to a web wallpaper the widget was not mounted at all).
20
- - **Gate expansion**: `tools/now-playing-test.mjs` now reports **83 passed / 0 failed** (including 7 mutations); new `tools/np-media-test.mjs` (track-list scope / data-source decision / playback target / mute target / yielding / mark / the web path not skipping apply / card geometry — **82 passed / 0 failed**, including **12 mutation self-proofs**) is registered in step 2 of `tools/check.sh`; new on-device probe `tools/np-media-live-probe.mjs` (`:3080` + headless Firefox; 16 PASS / 22 FAIL before the fix → 45 PASS / 0 FAIL after, not part of the standing gate).
21
- - **Several on-device fixes**: Now playing no longer mistakes the expanded state for collapsed on the frame right after slot rendering (so the widget no longer disappears after a refresh) — the criterion is now **physical width first** with threshold `NP_COLLAPSE_MAX_W = 96`, and when an anchor appears late it watches the document and mounts itself; the play/pause quads are now driven by a **tween of the playback state itself** (they used to be driven by expand progress, so the collapsed state always drew a triangle and the expanded state always drew the two bars); the header-frost `ReferenceError` and the transparent right-side timeline rail (see [Historical ledger](#historical-ledger)); the `lgCss` block that never executed because of a TDZ error; `sessionFollow` that had a toggle but no reader; the folder picker scrolling back to the top.
22
- - **Capabilities from the previous round still on the same publish surface**: web-wallpaper rendering / API coverage (in-frame storage facade + host `/web-store`, master volume, source-level `file:///` rewriting, CSP injection skip, `/media-audio`) and web-wallpaper touch (`op:'touch'` with real `TouchEvent`s); the **host-side system media session adapter** `lib/media-session.js` (MPRIS / SMTC, **implemented but not wired up yet**, below).
23
- - **Gates and guardrails**: CSS matrix, style-scope guard, surface-token namespace equivalence, switch-wiring audit and the second-scale pre-commit hook (see the [historical ledger](#historical-ledger) and [Gates](#gates)).
17
+ | Option | Who it is for | How to update | Client UI |
18
+ |---|---|---|---|
19
+ | 1 `dsh plugin add` (recommended) | the default choice; the market shows "installed" | `dsh plugin --profile web update …` | full |
20
+ | 2 pnpm manual install | you manage the profile's dependency table yourself | same (through the dependency table) | full |
21
+ | 3 Git clone | developers / offline / you want to edit the code | `git pull` | full |
22
+ | 4 single-file bundle | offline emergencies; reusing the routes from a non-DSH host | regenerate and replace that `.mjs` | **host half only** |
23
+
24
+ ### Option 1: `dsh plugin add` (recommended, market-recognized)
25
+
26
+ ```bash
27
+ dsh plugin --profile web add dsh-mpkg-wallpaper
28
+ # restart dsh web, then Ctrl+F5 in the browser
29
+ ```
30
+
31
+ ### Option 2: pnpm manual install
32
+
33
+ ```bash
34
+ pnpm --dir $DSH_HOME/profiles/<profile> add dsh-mpkg-wallpaper
35
+ # restart dsh web, then Ctrl+F5
36
+ ```
37
+
38
+ Same source as option 1, just without the `dsh plugin` wrapper.
39
+
40
+ ### Option 3: Git clone (developers / offline)
41
+
42
+ ```bash
43
+ git clone https://github.com/XHR666/dsh-mpkg-wallpaper.git $DSH_HOME/profiles/<profile>/node_modules/dsh-mpkg-wallpaper
44
+ # then register in the profile's cordis.patch.yml:
45
+ # - insert:
46
+ # - id: dsh-mpkg-wallpaper
47
+ # name: dsh-mpkg-wallpaper
48
+ # restart to take effect
49
+ ```
50
+
51
+ > Option 3 writes no dependency entry ⇒ the market does not show "installed" (display only, functionality unaffected).
52
+
53
+ ### Option 4: single-file bundle (offline / drop-in; **host half only**)
54
+
55
+ Inline the host half into a self-contained ESM and register that:
56
+
57
+ ```bash
58
+ cd /path/to/dsh-mpkg-wallpaper
59
+ node tools/build-bundle.mjs # output: dist/dsh-mpkg-wallpaper.bundle.mjs (measured 449,671 B / 439.1KB; the gate output is authoritative)
60
+ node tools/build-bundle.mjs --check # cross-check against the source: export surface / route table / ping JSON shape (20 assertions)
61
+ node tools/bundle-equivalence-test.mjs # the fuller equivalence gate (38 assertions; gate step 11)
62
+ ```
63
+
64
+ Copy `dist/dsh-mpkg-wallpaper.bundle.mjs` anywhere (e.g. `~/.dsh/plugins/`), register it by **absolute path** in the profile's `cordis.patch.yml`, then restart `dsh web`:
65
+
66
+ ```yaml
67
+ # $DSH_HOME/profiles/<profile>/cordis.patch.yml
68
+ - insert:
69
+ - id: dsh-mpkg-wallpaper
70
+ name: /absolute/path/dsh-mpkg-wallpaper.bundle.mjs # ← points at the .mjs file itself
71
+ ```
72
+
73
+ **What this path loads and what it does not** (code and gate facts):
74
+
75
+ | Item | Option 4 behaviour | Evidence |
76
+ |---|---|---|
77
+ | Host half (upload/Range streaming, scene extraction, audio lists, settings persistence, diag reporting — **41 routes**) | **Complete** (four host modules inlined; node builtins only) | route table identical `[41 entries]` |
78
+ | `/api/mpkg-wallpaper/ping` | `{ok, version, betterSidebar, betterSidebarVersion}` key set identical to the source | `build-bundle.mjs --check` |
79
+ | **Client half (settings panel / wallpaper layer / frost / Now playing)** | **Not loaded** — only the host export surface (`apply` / `inject` / `__mpwTest`) is present | DSH finds the client half **per package** (Loader entries declaring `dsh.client` → `exports["./client"]`); a bare `.mjs` has no package.json |
80
+ | `GET /api/mpkg-wallpaper/lg/*` (legacy WebGL hosting route, no client caller) | **404** unless a `liquid-glass/` folder sits next to the bundle | the route resolves `liquid-glass/` from `import.meta.url` (`lib/index.js:3453`) |
81
+ | `ping.version` | `null` when the parent directory has no `package.json` (version display only) | `new URL('../package.json', import.meta.url)` (`lib/index.js:1622`) |
82
+ | "Check for updates / one-click update" | No companion `package.json` ⇒ `update-check` 500s and `update-apply` writes next to the bundle: **not recommended here** | `lib/index.js:1792-1860` |
83
+ | Uninstall | Delete that `.mjs` and its line in `cordis.patch.yml` | — |
84
+
85
+ > Conclusion: **option 4 is a degraded "host capability only" load** (handy offline, as an emergency path, or when reusing the routes from a non-DSH host); use options 1/2/3 for the full UI. The artifact is **not committed** (`dist/` is in `.gitignore`: it is a pure derivative of `lib/*.js`, two builds are byte-identical — section ② of `tools/bundle-equivalence-test.mjs`; generate it at release time and publish the hash).
86
+
87
+ ### Update
88
+
89
+ ```bash
90
+ # options 1 / 2: through npm's latest tag
91
+ dsh plugin --profile web update dsh-mpkg-wallpaper
92
+
93
+ # option 3: inside the clone
94
+ git pull
24
95
 
25
- > Exactly one default changed, stated plainly: **`npNowPlaying` moved from off to on** (turning it off returns to zero injection; it yields automatically when another plugin already occupies the slot). Every other default is unchanged: `webInteraction` still defaults to `pointer`, and web-wallpaper sound is still muted by default (`mute` defaults to on).
96
+ # option 4: regenerate and replace that .mjs
97
+ node tools/build-bundle.mjs
98
+ ```
99
+
100
+ Every route ends the same way: restart `dsh web`, then press `Ctrl+F5`.
101
+
102
+ ### Uninstall
103
+
104
+ Options 1/2/3: `dsh plugin --profile web remove dsh-mpkg-wallpaper`.
105
+ Option 4: delete the `.mjs` and its line in `cordis.patch.yml`.
106
+ Leftover data (optional cleanup): browser `localStorage['dsh.mpkg-wallpaper.v2']` and the host directory `~/.dsh-mpkg-wallpaper/` (`settings.json`, `web-store.json`, `media-audio.json`, uploaded mpkg files, transcode cache, `diag-*.json`).
107
+
108
+ ## 30-second quick start
109
+
110
+ The shortest path from installed to wallpaper on screen — three steps.
111
+
112
+ 1. **Install and restart**: pick any option in the previous section, restart `dsh web`, press `Ctrl+F5`.
113
+ 2. **Open the panel**: left sidebar → Settings → *MPKG Wallpaper*.
114
+ 3. **Pick a wallpaper**, any one of:
115
+ - drop in an `.mpkg` file (video wallpapers play directly; scene wallpapers use the static frame / layer composite)
116
+ - choose a local image/video, or paste an image URL
117
+ - *Custom local wallpaper folder*: point it at any folder (the Steam `steamapps/workshop/content/431960` root works — every subfolder counts as one wallpaper)
118
+
119
+ The defaults are already usable: master switch on, hybrid mode on, unified blur on (30px), Now playing mounted in the left sidebar.
120
+ To fine-tune, three controls are enough to start: **Wallpaper → Frosted blur** (0–40), **Surface unify → Full-screen blur degree** (0–40), **Wallpaper → Lens zoom** (10–2000%).
121
+
122
+ > If nothing happens, press **one-click diagnostics report** in the *Other* tab first (it downloads a JSON file when the host is unavailable), then take it to [Bug reports](#bug-reports).
26
123
 
27
124
  ## Core features
28
125
 
126
+ This section groups features by **what you can perceive** (sources, time variation, blur, appearance, playback, library and rotation, safety, backup) rather than by code module.
127
+
29
128
  **📦 Wallpaper sources**
30
129
  - **Wallpaper Engine `.mpkg`**: the container is parsed in the browser (nothing is uploaded to a third party); video wallpapers play their embedded mp4 / video textures, scene wallpapers have their assets extracted, and **Time Variation** wallpapers pick the material for the current time slot
31
130
  - **Steam Workshop folders**: Wallpaper Engine installs are discovered automatically (registry + `libraryfolders.vdf`, non-default drives included) and listed as `video / web / scene`; the **workshop root** (`steamapps/workshop/content/431960`) can also be set as a custom folder — every subfolder is auto-detected as one wallpaper
@@ -74,13 +173,15 @@ Current version = **3.8.0** in `package.json`. Release prerequisites, commands a
74
173
 
75
174
  ## Supported types and boundaries
76
175
 
176
+ This section answers "does my material work, and how well"; the boundary list under the table explains why some things are out of reach.
177
+
77
178
  | Type | Status | What it can / cannot control |
78
179
  |---|---|---|
79
180
  | **mpkg (video)** | ✅ Full | Embedded mp4 / video textures play directly; mute, speed, pause, blur/zoom/brightness all adjustable |
80
- | **mpkg (scene)** | 🟡 Compromise | Container assets are extracted: static frame / layer composite / embedded video slots; **Live2D puppets, shaders and scripts are out of reach** (below) |
181
+ | **mpkg (scene)** | 🟡 Compromise | Static frame / layer composite / embedded video slots; **Live2D puppets, shaders and scripts are out of reach** |
81
182
  | **Time Variation** | ✅ Multi-slot | Automatic switching + manual lock; only the current slot is extracted |
82
183
  | **Video (mp4/webm/mov/m4v)** | ✅ Full | Plays directly; fps/resolution caps require ffmpeg transcoding |
83
- | **Web (HTML)** | 🟡 Experimental | Sandboxed iframe + WE API shim; **Live2D-class wallpapers with read-only settings are editable**; external-SDK / heavily interactive ones are not adapted |
184
+ | **Web (HTML)** | 🟡 Experimental | Sandboxed iframe + WE API shim; **Live2D-class settings are editable**; external-SDK / heavily interactive ones are not adapted |
84
185
  | **scene.pkg loose folder** | 🟡 Compromise | Same as mpkg scene wallpapers |
85
186
  | **preview.gif / image / animation** | ✅ Full | Falls back to the author's preview animation when a scene has nothing better (`lib/client.js:887`, `:12272`) |
86
187
  | **Application (exe)** | ❌ Excluded | Content detection yields `unknown/excluded-application`; never read, never executed (`lib/web-wallpaper.js:199`) |
@@ -93,9 +194,9 @@ Current version = **3.8.0** in `package.json`. Release prerequisites, commands a
93
194
  - Web wallpapers **cannot** reproduce CSS `:hover/:active`, `isTrusted:true`, in-frame `contextmenu` or pointer lock/fullscreen/download popups (inherent limits of synthetic events, `docs/WEB-WALLPAPER.md` §11.4)
94
195
  - Assets above 600MB work only in **hybrid mode**; browser-only mode additionally caps video textures at 250MB, images at 200MB and local image files at 100MB
95
196
 
96
- ## Settings (7 tabs)
197
+ ## Settings reference (7 tabs)
97
198
 
98
- The tab order is fixed: `TAB_ORDER = ["source","wallpaper","appearance","unify","blur","other","liquid"]` (`lib/client.js:10730`), labelled **Background source / Wallpaper / Appearance / Surface unify / UI blur / Other / Liquid Glass (test)**.
199
+ This is the **authoritative table of every setting**: each row = panel label + internal key + default + what it does + how to turn it off. The tab order matches the panel and is fixed: `TAB_ORDER = ["source","wallpaper","appearance","unify","blur","other","liquid"]` (`lib/client.js:10730`), labelled **Background source / Wallpaper / Appearance / Surface unify / UI blur / Other / Liquid Glass (test)**.
99
200
 
100
201
  ### 1. Background source (source)
101
202
 
@@ -117,7 +218,8 @@ Contains the *Wallpaper picture* and *Power saving* sub-sections.
117
218
  | Label | Key | Default | Purpose | Off / rollback |
118
219
  |---|---|---|---|---|
119
220
  | Mute (web wallpapers) | `mute` | on | Web-wallpaper audio; off lets the wallpaper make sound | off |
120
- | **Now playing (above Settings in the sidebar)** | `npNowPlaying` | **on** | Mounts an expandable player in the left sidebar (its transport row is the wallpaper-sound control: previous / play-pause / next, plus mute inside the card); **off is still zero injection**; it yields and writes `data-mpw-np-yield` when another plugin has injected an element into the same slot | off / restore defaults |
221
+ | **Now playing (above Settings in the sidebar)** | `npNowPlaying` | **on** | Left-sidebar player whose transport row controls wallpaper sound. **Off = zero injection**; yields to another plugin (`data-mpw-np-yield`) | off / restore defaults |
222
+ | Play/pause also controls the wallpaper | `npLinkWallpaper` | on | Now playing's play/pause and position also drive the wallpaper itself; off = only this plugin's player (when the wallpaper media is the only source the controls are **honestly disabled**) | off |
121
223
  | Horizontal flip (mirror) | `flipX` | off | Mirrors the wallpaper horizontally | off |
122
224
  | Vertical flip (mirror) | `flipY` | off | Mirrors the wallpaper vertically | off |
123
225
  | Decode fps cap | `fpsCap` | unlimited | Host ffmpeg frame extraction when the source exceeds the cap (24/30/48/60) | "unlimited" |
@@ -214,20 +316,72 @@ These keys **exist and participate in the logic** but have no widget on the sett
214
316
 
215
317
  | Key | Default | Notes |
216
318
  |---|---|---|
217
- | `clock` / `clock24h` / `clockSec` / `clockDate` / `clockPos` / `clockSize` | off / on / off / off / `tr` / 40 | The clock is a **runtime compatibility item**: old configurations still render it, but the settings page has no toggle |
218
- | `bsAlpha` | off | better-sidebar panels follow the theme base colour; the CSS reads it, the panel has no widget |
219
- | `bsBottomAvoid` | off | A settled **deliberate no-op** (alignment belongs to better-sidebar's own ResizeObserver) |
220
- | `newStyle` | off | Only changes settings-page widget looks (JS picks class names); never enters `buildCss` |
319
+ | `clock` / `clock24h` / `clockSec` / `clockDate` / `clockPos` / `clockSize` | off / on / off / off / `tr` / 40 | Runtime compatibility item: old configurations still render the clock, the settings page has no toggle |
320
+ | `bsAlpha` | off | better-sidebar panels follow the theme base colour (CSS reads it, no widget) |
321
+ | `bsBottomAvoid` | off | A settled **deliberate no-op** (alignment belongs to better-sidebar's ResizeObserver) |
322
+ | `newStyle` | off | Only changes settings-page widget looks; never enters `buildCss` |
221
323
  | `forceEnabled` | off | Runtime priority flag for forcing the feature back on past conflict detection |
222
- | `opacity` | 82 | The "panel opacity" slider was removed (unified blur uses `sidebarAlpha` instead); the value is still read (`lib/client.js:5887`, `:5981`) |
223
- | `aquaTintStrength` | 45 | How much of the wallpaper's dominant colour is mixed into panel tints; read at runtime (`lib/client.js:5575`), no widget |
224
- | `glassColor` / `glassAlpha` | empty / 12 | Leftovers from the early WebGL liquid glass: they only travel through backup export/import and "restore defaults", with **no widget and no reader** (`lib/client.js:11968`, `:11986`) |
225
- | `webInteraction` | `pointer` | Web-wallpaper interaction mode (`off`/`pointer`/`full`): **no panel widget**; use `?mpwinteract=…` or write the stored settings |
226
- | `sceneRendererUrl` | `http://127.0.0.1:8899/` | Scene-renderer address, overridable (`lib/client.js:3129-3132`) |
227
- | `glassWindow` | | **Retired and deleted** (2026-09-19): no widget and no reader; its promised feature is covered by `settingsBlur` + `dialogBlur`/`popoverBlur`; zero leftovers in code and in both dictionaries |
324
+ | `opacity` | 82 | The "panel opacity" slider was removed (unified blur uses `sidebarAlpha`); the value is still read |
325
+ | `aquaTintStrength` | 45 | How much of the wallpaper colour is mixed into panel tints; read at runtime |
326
+ | `glassColor` / `glassAlpha` | empty / 12 | Early WebGL liquid-glass leftovers: backup/restore only, **no widget and no reader** |
327
+ | `webInteraction` | `pointer` | Web-wallpaper interaction mode (`off`/`pointer`/`full`); use `?mpwinteract=…` or write the stored settings |
328
+ | `sceneRendererUrl` | `http://127.0.0.1:8899/` | Scene-renderer address, overridable |
329
+ | `npVolume` | 100 | The **volume level** in the Now playing card (0..100, applied to the real element); the default profile **never writes element volume**, only your own change does |
330
+ | `glassWindow` | — | **Retired and deleted** (2026-09-19): no widget, no reader; covered by `settingsBlur` + `dialogBlur`/`popoverBlur` |
331
+
332
+ ## Now playing and wallpaper sound
333
+
334
+ This section covers **where the sidebar player mounts, what it shows and what it can control** — its source is the wallpaper's own sound.
335
+
336
+ > Location convention: this section refers to the implementation by **symbol name** (`resolveAnchor` / `shouldHide` / `occupantOf` / `PlayMark` / `markYield` in `lib/now-playing.js`, `opsX` and friends in `lib/now-playing-math.js`, `npResolveMedia` / `npActiveVideo` / `npAudioScope` / `npApplyMute` / `applyNowPlaying` in `lib/client.js`) — **line numbers drift between versions; symbols are authoritative**. The shape is "source + generated inline": `lib/now-playing-math.js` + `lib/now-playing.js` are inlined byte-for-byte into the `MPW-NP-GEN-START/END` region of `lib/client.js` by `tools/build-now-playing.mjs`.
337
+
338
+ - **Mount point**: the host slot `sidebar.footer.action` (the `createSlotAction` registration in `lib/client.js`: `id:"mpw-now-playing"`, `order:60`). When the slot is unavailable, `resolveAnchor()` falls back by mode: `slot` → `settings-slot` (before the host's settings cell) → `settings-area` (before `[class*="settingsArea"]`) → `foot` (first position in `[class*="footArea"]`); if none holds it **creates no node at all** and logs a `console.warn`. **A late anchor still gets mounted**: when no landing spot exists it watches the document and mounts itself as soon as the host's slot outlet renders (the old code only warned and returned, so after a wallpaper switch on a real machine the widget never came back).
339
+ - **Yielding (the counterpart of being on by default)**: `occupantOf(container, mode, selfNode)` walks the container's children and lets three kinds through — our own nodes, host-owned nodes (the slot outlet / settings cell) and effectively empty nodes; the first remaining element counts as an **occupant** ⇒ we do not mount (before mounting) or we retract (after mounting, via `MutationObserver` with `subtree:true`), write `data-mpw-np-yield="foreign-occupant"` and log one readable warning; when the occupant leaves we come back. The criteria are **two-way**: neither our own nodes nor the host's cells may be misread as occupants.
340
+ - **Hidden when the sidebar is collapsed**: `data-mpw-np-hidden` + CSS `display:none`. The criterion is **physical width first** (`shouldHide(width, hostCollapsed)`: a measured width ≥ `NP_COLLAPSE_MAX_W = 96` forbids hiding); host signals (slot `wide` / `data-sidebar-collapsed` / root class `collapsed`) are a fallback only when the width **cannot be measured**, and there is a **one-shot** re-check after the anchor moves. When space is tight the whole widget scales by `--mpw-np-fit = clamp(avail/260, 0.5, 1)`, where `avail` measures **our own container** (not `[class*="sidebarCol"]` — that class name appears more than once on a real machine).
341
+ - **Shape**: one pill that expands into a card — artwork (the current wallpaper's thumbnail), title/byline, progress rail + clock and a full-card hit target. **Four keys when expanded**: previous / play-pause / next / mute-unmute; **three keys when collapsed** — the mute key appears with the card, because the collapsed transport row is positioned as an 88px three-key row at `opsX(0) = 206` and forcing a fourth key in would overflow the right padding. Expanding is a self-stopping 0→1 tween (no resident rAF); play/pause is **not a swapped icon** but the pair of eight-point quads, now shaped by a **tween of the playback state itself** (`mark`: 0 = paused, 1 = playing), while the morph progress `p` only drives size and position.
342
+ - **Data sources** (`npResolveMedia`: **we only report what we actually know**):
343
+
344
+ | Current wallpaper | What NP shows | What it can control |
345
+ |---|---|---|
346
+ | Video wallpaper (the `<video>` that is **really playing** right now) | Real playback state, duration and progress; when we know there is no audio track the byline says so | Play / pause / mute-unmute (no audio info ⇒ we do not guess, mute stays enabled); **no track list ⇒ previous/next honestly disabled** |
347
+ | Audio files that **really exist** in the wallpaper folder (custom folder / library / scene folder) | File name + list index; progress and duration from the media element | Our own `<audio>`: play / pause / **previous · next step through the list in order and wrap around** (with only one track both side keys are disabled) / mute |
348
+ | Web wallpaper (no separate audio file in the folder) | The byline says "web wallpaper sound" | **Mute is the only channel** (`canPlay=false`; we do not pretend we can pause in-frame WebAudio) |
349
+ | No source (static image / list not there yet / scene without separate audio) | Idle state (title = nothing playing) | No "does nothing when clicked" buttons; pressing play produces a panel note plus one console line — **no fake actions** |
350
+
351
+ - **Two new controls (NP-4)**: ① a **volume level** — the volume bar in the card, key `npVolume` (0..100), really applied to the element (the default profile **never writes element volume**, only your own change does); ② a **draggable position (seek)**. Both are governed by the switch below.
352
+ - **"Play/pause also controls the wallpaper" (`npLinkWallpaper`, default on)**: on = the transport and the progress bar drive the wallpaper's own playback/position too (default on = byte-identical to the previous behaviour); off = only this plugin's own player is driven and the wallpaper media is **not touched at all**; when the wallpaper media is the only sound source the controls are **honestly disabled** and the byline says why (`np.note.linkOff`).
353
+ - **What it cannot do** (listed explicitly, no fake actions; `docs/NOW-PLAYING-DSH.md` §7.7.7 and §7.8): there is no system media source (that is what `lib/media-session.js` is for, and it is not wired up yet); a web wallpaper's in-frame sound supports **mute only** — its own play/pause is out of reach; a video wallpaper has no previous/next; the **mute key only appears in the expanded state** (expand the card first to unmute — a geometry trade-off, not a broken key); the heart button is not rendered; no waveform, no keyboard shortcuts; audio inside a package (`scene.pkg`) **can be listed but not played** (`np.note.pkgListOnly`); the progress bar is draggable, but **honestly not draggable when the link switch is off or nothing is seekable** (`np.note.noseek`). The double-audio combination where "the wallpaper also plays the same track" **has no on-device sample to verify against** (the mitigation in place: the frame is force-muted while our player is playing).
354
+ - **Rollback**: turn `npNowPlaying` off (or "Restore all defaults") ⇒ back to zero injection (no DOM, no observers, not a single NP rule in the output). Regression: `node tools/now-playing-test.mjs` (**83 passed / 0 failed**, including 7 mutations) + `node tools/np-media-test.mjs` (**82 passed / 0 failed**, including 12 mutation self-proofs; `--no-mutations` leaves 70 main assertions) — both registered in step 2 of `tools/check.sh`. On-device probes (need `:3080` + headless Firefox, not part of the standing gate): `node tools/np-sidebar-live-probe.mjs` (12 criteria) and `node tools/np-media-live-probe.mjs` (16 PASS / 22 FAIL before the fix → 45 PASS / 0 FAIL after).
355
+ - **Attribution**: the component is a **line-by-line port of Bencho's "Now playing" (MIT)** with the upstream comments kept verbatim; the sidebar mount controller, yield criteria, self-drawn icons, token mapping, data wiring and gates are written here. Registered in `THIRD-PARTY.md` §6.
356
+
357
+ ## System media session (MPRIS / SMTC)
358
+
359
+ This section covers the **host half** of "show what the system is playing": the capability matrix, the honest unavailable path, and the current wiring status.
360
+
361
+ `lib/media-session.js` (1052 lines, MIT, written in this repository, no third-party code) is the **host half** of the "let Now playing show what the *system* is playing" chain. Contract: `createMediaSession({run, platform, env, now, timeoutMs, log})` → `{probe(), snapshot(), control(op,arg), stats(), lastProbe()}` (`lib/media-session.js:22-40`).
362
+
363
+ **Capability matrix**
364
+
365
+ | Platform | Channel | Adapters (by priority) | Metadata / state / position / artwork | Control |
366
+ |---|---|---|---|---|
367
+ | Linux / FreeBSD / OpenBSD | MPRIS over D-Bus (`org.mpris.MediaPlayer2.*`) | `playerctl` → `dbus-send` | One call returns 7 fields: `status / mpris:length / position / xesam:title / xesam:artist / xesam:album / mpris:artUrl` | `play` `pause` `playpause` `next` `prev` `seek` |
368
+ | Windows 10/11 | SMTC (`GlobalSystemMediaTransportControlsSessionManager`) | `smtc` (`powershell.exe -NoProfile -NonInteractive`) | Real WinRT calls for properties / timeline / playback controls / thumbnail (as a base64 data URL) | Same ops (op and position passed as separate argv elements) |
369
+ | macOS | — | — | ❌ Not implemented: `unsupported-platform`, **0 commands** | ❌ |
370
+ | Anything else / no adapter | — | `none` | ❌ `not-installed` | ❌ |
371
+
372
+ - **Shape**: the `snapshot` always has its 21 keys; unreadable values are empty/neutral (`duration`/`position` are milliseconds and `null` when unknown — it **never invents 0**); when `available:true`, `title` is non-empty (`lib/media-session.js:42-56`, `:220-244`).
373
+ - **With no desktop session bus it reports unavailable honestly**: `probe()` really probes (`dbus-send --session ListNames`, falling back to `busctl --user list`) and returns `available:false / reason:'no-session-bus'` with a `detail` line such as "总线不可达(via dbus-send):…" (`lib/media-session.js:684-706`, `:775-783`). This is **derived, not hard-coded** — a machine with no desktop environment (container/Termux) really takes this path.
374
+ - **It never throws**: `snapshot()` / `control()` never reject; every failure is a return value whose `reason` is one of 15 values (`unsupported-platform / disabled-by-env / not-installed / no-session-bus / no-player / no-metadata / empty-output / unparsable / timeout / not-available / bad-op / bad-arg / bad-player / error`).
375
+ - **Security**: command names and arguments are passed **separately** (argv arrays, never through `sh -c`); player names must match `/^[A-Za-z0-9_.-]{1,64}$/` or the result is `bad-player` with 0 commands; `op` is allow-listed and `seek` is bounded to `0..24h`; the timeout defaults to 800 ms (50–5000 ms); reads are single-flight with a global serial queue (at most one command at a time).
376
+ - **Zero dependencies**: only `node:child_process`; `package.json` gained no dependency at all.
377
+ - **Environment variables**: `MPW_MEDIA_ADAPTER` (pin an adapter), `MPW_MEDIA_PLAYER` (pin a player name), `MPW_MEDIA_TIMEOUT_MS`.
378
+
379
+ ⚠ **Wiring status (honest)**: the module is **not wired into the plugin yet** — `lib/index.js`, `lib/client.js` and `tools/build-bundle.mjs` do not import it, and there is no `/media-session` or `/media-control` host route, so **the Now playing widget still shows the wallpaper's own media, not a system player**. Host routes and UI display are planned in [`docs/MEDIA-SESSION.md`](docs/MEDIA-SESSION.md) §9/§10 but marked as not done. Gate: `node tools/media-session-test.mjs` (95 assertions = 89 main + 6 mutation self-proofs), **not part of `tools/check.sh`**.
228
380
 
229
381
  ## Diagnostics and troubleshooting
230
382
 
383
+ Start here when something is wrong: one click sends state back to the local host, and individual diagnostics switches are only for narrowing things down.
384
+
231
385
  ### Plugin side (this plugin's own switches)
232
386
 
233
387
  - **One-click diagnostics report** (*Other* tab): packs whether frost/sidebar/timeline were affected, the wallpaper type and path, whether the shim was injected, video decoding, surface tokens and scene health into one payload → `POST /api/mpkg-wallpaper/diag`, written to `~/.dsh/.dsh-mpkg-wallpaper/diag-<epochms>.json` (`lib/index.js:1605-1620`; directory capped at 50 files / 32MB, `lib/index.js:1523-1524`; client-side single-payload cap 512KB). **When the host is unavailable it automatically downloads `mpw-diag-<ISO>.json` instead**, so state can be reported offline. Every field carries `provenance`; unreadable ones are `value:null + degraded` ([`docs/DIAGNOSTICS.md`](docs/DIAGNOSTICS.md)).
@@ -269,138 +423,38 @@ Two different numbers — do not mix them up:
269
423
 
270
424
  > These are **renderer** URL parameters, not plugin settings; the plugin only appends them to the scene iframe (`lib/client.js:13981-14000`, `lib/client.js:3163`). The panel copy and its offline mirror must match the `common` set of `diag-flags.json`, asserted by `tools/panel-smoke.mjs` (`tools/panel-smoke.mjs:316-372`).
271
425
 
272
- ## System media session (MPRIS / SMTC)
273
-
274
- `lib/media-session.js` (1052 lines, MIT, written in this repository, no third-party code) is the **host half** of the "let Now playing show what the *system* is playing" chain. Contract: `createMediaSession({run, platform, env, now, timeoutMs, log})` → `{probe(), snapshot(), control(op,arg), stats(), lastProbe()}` (`lib/media-session.js:22-40`).
275
-
276
- **Capability matrix**
277
-
278
- | Platform | Channel | Adapters (by priority) | Metadata / state / position / artwork | Control |
279
- |---|---|---|---|---|
280
- | Linux / FreeBSD / OpenBSD | MPRIS over D-Bus (`org.mpris.MediaPlayer2.*`) | `playerctl` → `dbus-send` | One call returns 7 fields: `status / mpris:length / position / xesam:title / xesam:artist / xesam:album / mpris:artUrl` | `play` `pause` `playpause` `next` `prev` `seek` |
281
- | Windows 10/11 | SMTC (`GlobalSystemMediaTransportControlsSessionManager`) | `smtc` (`powershell.exe -NoProfile -NonInteractive`) | Real WinRT calls for properties / timeline / playback controls / thumbnail (as a base64 data URL) | Same ops (op and position passed as separate argv elements) |
282
- | macOS | — | — | ❌ Not implemented: `unsupported-platform`, **0 commands** | ❌ |
283
- | Anything else / no adapter | — | `none` | ❌ `not-installed` | ❌ |
284
-
285
- - **Shape**: the `snapshot` always has its 21 keys; unreadable values are empty/neutral (`duration`/`position` are milliseconds and `null` when unknown — it **never invents 0**); when `available:true`, `title` is non-empty (`lib/media-session.js:42-56`, `:220-244`).
286
- - **With no desktop session bus it reports unavailable honestly**: `probe()` really probes (`dbus-send --session ListNames`, falling back to `busctl --user list`) and returns `available:false / reason:'no-session-bus'` with a `detail` line such as "总线不可达(via dbus-send):…" (`lib/media-session.js:684-706`, `:775-783`). This is **derived, not hard-coded** — a machine with no desktop environment (container/Termux) really takes this path.
287
- - **It never throws**: `snapshot()` / `control()` never reject; every failure is a return value whose `reason` is one of 15 values (`unsupported-platform / disabled-by-env / not-installed / no-session-bus / no-player / no-metadata / empty-output / unparsable / timeout / not-available / bad-op / bad-arg / bad-player / error`).
288
- - **Security**: command names and arguments are passed **separately** (argv arrays, never through `sh -c`); player names must match `/^[A-Za-z0-9_.-]{1,64}$/` or the result is `bad-player` with 0 commands; `op` is allow-listed and `seek` is bounded to `0..24h`; the timeout defaults to 800 ms (50–5000 ms); reads are single-flight with a global serial queue (at most one command at a time).
289
- - **Zero dependencies**: only `node:child_process`; `package.json` gained no dependency at all.
290
- - **Environment variables**: `MPW_MEDIA_ADAPTER` (pin an adapter), `MPW_MEDIA_PLAYER` (pin a player name), `MPW_MEDIA_TIMEOUT_MS`.
291
-
292
- ⚠ **Wiring status (honest)**: the module is **not wired into the plugin yet** — `lib/index.js`, `lib/client.js` and `tools/build-bundle.mjs` do not import it, and there is no `/media-session` or `/media-control` host route, so **the Now playing widget still shows the wallpaper's own media, not a system player**. Host routes and UI display are planned in [`docs/MEDIA-SESSION.md`](docs/MEDIA-SESSION.md) §9/§10 but marked as not done. Gate: `node tools/media-session-test.mjs` (95 assertions = 89 main + 6 mutation self-proofs), **not part of `tools/check.sh`**.
293
-
294
- ## Now playing widget
295
-
296
- > Location convention: this section refers to the implementation by **symbol name** (`resolveAnchor` / `shouldHide` / `occupantOf` / `PlayMark` / `markYield` in `lib/now-playing.js`, `opsX` and friends in `lib/now-playing-math.js`, `npResolveMedia` / `npActiveVideo` / `npAudioScope` / `npApplyMute` / `applyNowPlaying` in `lib/client.js`) — **line numbers drift between versions; symbols are authoritative**. The shape is "source + generated inline": `lib/now-playing-math.js` + `lib/now-playing.js` are inlined byte-for-byte into the `MPW-NP-GEN-START/END` region of `lib/client.js` by `tools/build-now-playing.mjs`.
297
-
298
- - **Mount point**: the host slot `sidebar.footer.action` (the `createSlotAction` registration in `lib/client.js`: `id:"mpw-now-playing"`, `order:60`). When the slot is unavailable, `resolveAnchor()` falls back by mode: `slot` → `settings-slot` (before the host's settings cell) → `settings-area` (before `[class*="settingsArea"]`) → `foot` (first position in `[class*="footArea"]`); if none holds it **creates no node at all** and logs a `console.warn`. **A late anchor still gets mounted**: when no landing spot exists it watches the document and mounts itself as soon as the host's slot outlet renders (the old code only warned and returned, so after a wallpaper switch on a real machine the widget never came back).
299
- - **Yielding (the counterpart of being on by default)**: `occupantOf(container, mode, selfNode)` walks the container's children and lets three kinds through — our own nodes, host-owned nodes (the slot outlet / settings cell) and effectively empty nodes; the first remaining element counts as an **occupant** ⇒ we do not mount (before mounting) or we retract (after mounting, via `MutationObserver` with `subtree:true`), write `data-mpw-np-yield="foreign-occupant"` and log one readable warning; when the occupant leaves we come back. The criteria are **two-way**: neither our own nodes nor the host's cells may be misread as occupants.
300
- - **Hidden when the sidebar is collapsed**: `data-mpw-np-hidden` + CSS `display:none`. The criterion is **physical width first** (`shouldHide(width, hostCollapsed)`: a measured width ≥ `NP_COLLAPSE_MAX_W = 96` forbids hiding); host signals (slot `wide` / `data-sidebar-collapsed` / root class `collapsed`) are a fallback only when the width **cannot be measured**, and there is a **one-shot** re-check after the anchor moves. When space is tight the whole widget scales by `--mpw-np-fit = clamp(avail/260, 0.5, 1)`, where `avail` measures **our own container** (not `[class*="sidebarCol"]` — that class name appears more than once on a real machine).
301
- - **Shape**: one pill that expands into a card — artwork (the current wallpaper's thumbnail), title/byline, progress rail + clock and a full-card hit target. **Four keys when expanded**: previous / play-pause / next / mute-unmute; **three keys when collapsed** — the mute key appears with the card, because the collapsed transport row is positioned as an 88px three-key row at `opsX(0) = 206` and forcing a fourth key in would overflow the right padding. Expanding is a self-stopping 0→1 tween (no resident rAF); play/pause is **not a swapped icon** but the pair of eight-point quads, now shaped by a **tween of the playback state itself** (`mark`: 0 = paused, 1 = playing), while the morph progress `p` only drives size and position.
302
- - **Data sources** (`npResolveMedia`: **we only report what we actually know**):
303
-
304
- | Current wallpaper | What NP shows | What it can control |
305
- |---|---|---|
306
- | Video wallpaper (the `<video>` that is **really playing** right now) | Real playback state plus duration/progress; when we positively know there is no audio track the byline says "this video has no audio track" | Play / pause / mute-unmute (when we cannot tell whether there is audio we **do not guess** and leave the mute key enabled); **there is no track list ⇒ previous/next are honestly disabled** |
307
- | Audio files that **really exist** in the wallpaper folder (custom folder / library / scene folder) | File name plus its index in the list; progress and duration come from the media element | Our own `<audio>`: play / pause / **previous · next step through the list in order and wrap around** (with only one track both side keys are disabled) / mute |
308
- | Web wallpaper (no separate audio file in the folder) | The byline says "web wallpaper sound" | **Mute is the only channel** (`canPlay=false`; we do not pretend we can pause in-frame WebAudio) |
309
- | No source (static image / list not there yet / scene without separate audio) | Idle state (title = nothing playing) | No "does nothing when clicked" buttons; pressing play produces a panel note plus one console line — **no fake actions** |
310
-
311
- - **What it cannot do** (listed explicitly, no fake actions; `docs/NOW-PLAYING-DSH.md` §7.7.7): there is no system media source (that is what `lib/media-session.js` is for, and it is not wired up yet); a web wallpaper's in-frame sound supports **mute only** — play/pause is out of reach; **volume is a mute switch, not 0..1 fine control** (the only existing channel is the `mute` boolean plus the host `/media-audio` contract); a video wallpaper has no previous/next; the **mute key only appears in the expanded state** (expand the card first to unmute — a geometry trade-off, not a broken key); the heart button is not rendered; no seeking, no waveform, no keyboard shortcuts. The double-audio combination where "the wallpaper also plays the same track" **has no on-device sample to verify against** (the mitigation in place: the frame is force-muted while our player is playing).
312
- - **Rollback**: turn `npNowPlaying` off (or "Restore all defaults") ⇒ back to zero injection (no DOM, no observers, not a single NP rule in the output). Regression: `node tools/now-playing-test.mjs` (**83 passed / 0 failed**, including 7 mutations) + `node tools/np-media-test.mjs` (**82 passed / 0 failed**, including 12 mutation self-proofs; `--no-mutations` leaves 70 main assertions) — both registered in step 2 of `tools/check.sh`. On-device probes (need `:3080` + headless Firefox, not part of the standing gate): `node tools/np-sidebar-live-probe.mjs` (12 criteria) and `node tools/np-media-live-probe.mjs` (16 PASS / 22 FAIL before the fix → 45 PASS / 0 FAIL after).
313
- - **Attribution**: the component is a **line-by-line port of Bencho's "Now playing" (MIT)** with the upstream comments kept verbatim; the sidebar mount controller, yield criteria, self-drawn icons, token mapping, data wiring and gates are written here. Registered in `THIRD-PARTY.md` §6.
314
-
315
- ## Installation
316
-
317
- The plugin is published on npm (`dsh-mpkg-wallpaper`). Four ways to load it:
318
-
319
- ### Option 1: `dsh plugin add` (recommended, market-recognized)
320
-
321
- ```bash
322
- dsh plugin --profile web add dsh-mpkg-wallpaper
323
- # restart dsh web, then Ctrl+F5 in the browser
324
- ```
325
-
326
- Update: `dsh plugin --profile web update dsh-mpkg-wallpaper` (+ restart `dsh web` + Ctrl+F5). This resolves the `latest` tag.
327
-
328
- ### Option 2: pnpm manual install
329
-
330
- ```bash
331
- pnpm --dir $DSH_HOME/profiles/<profile> add dsh-mpkg-wallpaper
332
- # restart dsh web, then Ctrl+F5
333
- ```
334
-
335
- Same source as option 1, just without the `dsh plugin` wrapper; updates go through the dependency table too.
336
-
337
- ### Option 3: Git clone (developers / offline)
338
-
339
- ```bash
340
- git clone https://github.com/XHR666/dsh-mpkg-wallpaper.git $DSH_HOME/profiles/<profile>/node_modules/dsh-mpkg-wallpaper
341
- # then register in the profile's cordis.patch.yml:
342
- # - insert:
343
- # - id: dsh-mpkg-wallpaper
344
- # name: dsh-mpkg-wallpaper
345
- # restart to take effect
346
- ```
347
-
348
- > Option 3 writes no dependency entry ⇒ the market does not show "installed" (display only, functionality unaffected); update with `git pull`.
349
-
350
- ### Option 4: single-file bundle (offline / drop-in; **host half only**)
351
-
352
- Inline the host half into a self-contained ESM and register that:
353
-
354
- ```bash
355
- cd /path/to/dsh-mpkg-wallpaper
356
- node tools/build-bundle.mjs # output: dist/dsh-mpkg-wallpaper.bundle.mjs (measured 442,317 B / 432.0KB)
357
- node tools/build-bundle.mjs --check # cross-check against the source: export surface / route table / ping JSON shape (20 assertions)
358
- node tools/bundle-equivalence-test.mjs # the fuller equivalence gate (38 assertions; gate step 11)
359
- ```
360
-
361
- Copy `dist/dsh-mpkg-wallpaper.bundle.mjs` anywhere (e.g. `~/.dsh/plugins/`), register it by **absolute path** in the profile's `cordis.patch.yml`, then restart `dsh web`:
426
+ ## Compatibility, limits and what it cannot do
362
427
 
363
- ```yaml
364
- # $DSH_HOME/profiles/<profile>/cordis.patch.yml
365
- - insert:
366
- - id: dsh-mpkg-wallpaper
367
- name: /absolute/path/dsh-mpkg-wallpaper.bundle.mjs # ← points at the .mjs file itself
368
- ```
428
+ This section gathers what environment and what sizes work, and what is out of reach. Per-type boundaries live in the previous section; the web-wallpaper and Now playing cannot-do lists live in their own sections.
369
429
 
370
- **What this path loads and what it does not** (code and gate facts):
430
+ ### Browser compatibility (measured reference)
371
431
 
372
- | Item | Option 4 behaviour | Evidence |
432
+ | Browser | Rating | Notes |
373
433
  |---|---|---|
374
- | Host half (upload/Range streaming, scene extraction, audio lists, settings persistence, diag reporting — **41 routes**) | **Complete** (`lib/index.js` + `pkg-extract.js` + `web-wallpaper.js` + `web-interaction.js` all inlined; the only externals are node builtins) | `node tools/bundle-equivalence-test.mjs`: route table (kind + path) identical `[41 entries]` |
375
- | `/api/mpkg-wallpaper/ping` | `{ok, version, betterSidebar, betterSidebarVersion}` key set identical to the source | Same + `build-bundle.mjs --check` |
376
- | **Client half (settings panel / wallpaper layer / frost / Now playing)** | **Not loaded.** The single file only exposes the host export surface (`apply` / `inject` / `__mpwTest`) | The client half is discovered **per package** by the DSH client module system: it scans host Loader entries declaring `dsh.client` and resolves their `exports["./client"]`; a bare `.mjs` has no package.json ⇒ no `dsh.client` declaration |
377
- | `GET /api/mpkg-wallpaper/lg/*` (legacy WebGL hosting route, no client caller) | **404** unless a `liquid-glass/` folder sits next to the bundle; `cp -r lib/liquid-glass <bundle dir>/` makes it byte-identical to the source | The route locates `liquid-glass/` relative to `import.meta.url` (`lib/index.js:3453`); the gate asserts both layouts |
378
- | `ping.version` | `null` when the parent directory has no `package.json` (affects the version display only) | `new URL('../package.json', import.meta.url)` (`lib/index.js:1622`) |
379
- | "Check for updates / one-click update" | Without a companion `package.json`, `update-check` returns 500 and `update-apply` writes next to/above the bundle ⇒ **not recommended under option 4** | `lib/index.js:1792-1860` |
380
- | Uninstall | Delete that `.mjs` and its line in `cordis.patch.yml` | — |
381
-
382
- > Conclusion: **option 4 is a degraded "host capability only" load** (handy offline, as an emergency path, or when reusing the routes from a non-DSH host); use options 1/2/3 for the full UI. The artifact is **not committed** (`dist/` is in `.gitignore`: it is a pure derivative of `lib/*.js`, two builds are byte-identical — section ② of `tools/bundle-equivalence-test.mjs`; generate it at release time and publish the hash).
383
-
384
- ### Uninstall
434
+ | Chrome / Chromium (desktop) | ⭐⭐⭐ strong | Most complete: best `backdrop-filter` and `color-mix`, working `iframe.muted`, muted autoplay allowed |
435
+ | Edge (desktop) | ⭐⭐⭐ strong | Video wallpapers use a **separate canvas path** (dodging Edge's floating toolbar, `lib/client.js:1464-1520`); some versions show a static first frame only |
436
+ | Firefox | ⭐⭐ medium | Everything works (`backdrop-filter` 103+, transcode fallback for unsupported codecs); three deductions see the note below |
437
+ | Android WebView / mobile | ⭐⭐ weak-medium | Autoplay depends on the host WebView config; `getBattery` may be missing (guarded); for extreme combinations prefer a static image/GIF or turn blur off |
385
438
 
386
- Options 1/2/3: `dsh plugin --profile web remove dsh-mpkg-wallpaper`.
387
- Option 4: delete the `.mjs` and its line in `cordis.patch.yml`.
388
- Leftover data (optional cleanup): browser `localStorage['dsh.mpkg-wallpaper.v2']` and the host directory `~/.dsh-mpkg-wallpaper/` (`settings.json`, `web-store.json`, `media-audio.json`, uploaded mpkg files, transcode cache, `diag-*.json`).
439
+ > **Firefox's three deductions**: ① `backdrop-filter` is slower than Chromium (several blurs at once drop frames on low-end machines); ② `iframe.muted` is unsupported ⇒ a web wallpaper with sound may have its first frame blocked by the autoplay policy; ③ `color-mix` needs 113+ (older versions fall back visually).
440
+ > The source degrades for each browser (rAF fallback when `requestVideoFrameCallback` is missing, guards around `ResizeObserver`/`getBattery`, `.catch` on every `play()`, `backdrop-filter` detected with `CSS.supports` and degraded to opaque).
389
441
 
390
- ## Degraded behaviour without a Wallpaper Engine install (missing WE / non-Windows)
442
+ ### Without a Wallpaper Engine install (missing / non-Windows)
391
443
 
392
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:
393
445
 
394
446
  | Scenario | Real behaviour (with code location) |
395
447
  |---|---|
396
- | WE not installed (or `wallpaper32.exe` missing) | `GET /api/mpkg-wallpaper/steam-inventory` returns **200 `{ok:true, installDir:null, wallpapers:[]}`** (not an error, never a 500) `lib/index.js:3120-3180` |
397
- | Clicking "Scan local library" | Empty list plus the banner "Wallpaper Engine install not found (requires Windows + Steam Wallpaper Engine)" (checks `!d.installDir`, `lib/client.js:8781`/`:11555`); an empty list also shows "No usable wallpapers found (or not a Windows environment)". **The scan itself does not fail** |
398
- | WE installed but none of the asset folders exist | Each root is checked for existence individually (`if (!existsSync(root)) return`) the inventory is empty and the "install not found" banner does **not** appear (because `installDir` is non-null); the UI only shows the empty-list hint. **Not implemented**: there is no dedicated "WE installed but asset folders missing" message |
399
- | Non-Windows / mobile | Same as "WE not installed" (the registry branch returns null directly off win32); the Steam path candidates are plain strings and `existsSync` is simply false — no side effects |
400
- | WE playlists | Only when `installDir` exists and `config.json` parses; otherwise the client writes `rotSeeded:true` after the first scan and **stops re-seeding** (`lib/client.js:8782-8793`) so custom rotations are not overwritten |
401
- | **Still available without WE** | Choosing a folder manually (`/list-dirs` + `/custom-dir`); ② importing `.mpkg` directly (hybrid mode, no 600MB ceiling); ③ web/video wallpapers from a URL or local file; scene extraction, audio lists, settings persistence and diag reporting do not depend on WE at all |
402
- | Host half entirely unavailable (option 4 missing / port closed) | The `/ping` probe fails ⇒ **browser-only mode**: the status line shows "Host unavailable — fell back to browser-only mode (600MB limit)" (`lib/client.js:10203`/`:11603`); assets above 600MB cannot be handled |
403
- | `ffmpeg` missing | `GET /api/mpkg-wallpaper/ffmpeg-check` returns **200 `{ok:true, found:false, source:null, path:null, version:null}`** (`lib/index.js:3245-3259`); the panel shows "not installed" and only starts the download chain after a click. Videos that can be decoded directly are never transcoded |
448
+ | WE not installed (or `wallpaper32.exe` missing) | `steam-inventory` returns **200 `{ok:true, installDir:null, wallpapers:[]}`** not an error, never a 500 (`lib/index.js:3120-3180`) |
449
+ | Clicking "Scan local library" | Empty list plus the "install not found" banner (`lib/client.js:8781`/`:11555`) and the empty-list hint. **The scan itself does not fail** |
450
+ | WE installed but no asset folders exist | Each root is checked individually ⇒ empty inventory, no banner; only the empty-list hint shows. **Not implemented**: no dedicated message |
451
+ | Non-Windows / mobile | Same as "WE not installed" (the registry branch returns null off win32); no side effects |
452
+ | WE playlists | Only when `installDir` exists and `config.json` parses; otherwise `rotSeeded:true` stops re-seeding (`lib/client.js:8782-8793`) |
453
+ | **Still available without WE** | Manual folders, direct import, URL/local files, scene extraction and diagnostics all work without WE itemised below |
454
+ | Host half unavailable (option 4 missing / port closed) | The `/ping` probe fails ⇒ **browser-only mode** (`lib/client.js:10203`/`:11603`); assets above 600MB cannot be handled |
455
+ | `ffmpeg` missing | `ffmpeg-check` **200 `{ok:true, found:false, }`**; the panel shows "not installed" and only downloads after a click; directly decodable videos are never transcoded |
456
+
457
+ **Still available without WE**: ① choosing a folder manually (`/list-dirs` + `/custom-dir`); ② importing `.mpkg` directly (hybrid, no 600MB ceiling); ③ web/video wallpapers from a URL or local file; ④ scene extraction, audio lists, settings persistence and diag reporting.
404
458
 
405
459
  > In one line: **no WE install = one convenience channel (automatic local-library discovery) is missing**; the plugin still works. Every degradation is "empty inventory + explicit copy + the manual folder/upload paths stay open" — nothing fails silently and nothing returns a 500.
406
460
 
@@ -422,8 +476,16 @@ The plugin offers two paths:
422
476
 
423
477
  > The realistic route to full dynamics: **render externally to a video → use this plugin's video wallpaper feature** (record with the official WE client on Windows, with we-layerd on Linux, or with the Wallpaper Engine app on mobile).
424
478
 
479
+ ### Rendering feasibility: why a browser cannot do it
480
+
481
+ - A complete scene (Live2D puppets included) can only be produced by the proprietary renderer: the Wallpaper Engine app's native library (embedded Chromium + proprietary puppet rendering). The open-source [we-layerd](https://github.com/Aromatic05/we-layerd) (Rust) bundles the official renderer but is **Linux Wayland only**
482
+ - There is no mature WE scene renderer for the browser — **regardless of the operating system, no browser can render Live2D scenes directly**; the official renderer `.so` is a closed binary and cannot be compiled to WASM without source
483
+ - The feasible path for this plugin: **external renderer iframe (preferred) + static-frame extraction + layer compositing + (Time Variation) mpkg-style slot switching**; when full dynamics are needed, use "render externally to a video → video wallpaper"
484
+
425
485
  ## Web wallpapers (experimental)
426
486
 
487
+ This section covers how web (HTML) wallpapers load, how they are isolated, which settings are wired, and what is explicitly out of reach.
488
+
427
489
  - **Type detection is content-first, not declaration-first**: `detectWebWallpaperKind()` returns one of four states `web / scene / video / unknown`, and `general.type` in `project.json` is only a hint — a package that claims `web` but contains `scene.pkg` is treated as a **scene**, one that claims `scene`/`video` but only has `index.html` is treated as **web**, and `application/exe/app` always becomes `unknown/excluded-application` (`lib/web-wallpaper.js:163-207`)
428
490
  - **Two load modes** (chosen in the confirm dialog, recorded in the URL so a refresh keeps it):
429
491
  - **Sandbox mode (default, recommended)**: `sandbox="allow-scripts"` (**opaque origin**). The host injects the **WE API shim** at the very start of the entry HTML's `<head>`, gated by `?mpwshim=1`. Wallpaper scripts **cannot reach the host DOM / `localStorage` / cookies**; in-frame `fetch()` is cross-origin (`Origin: null`) and the host only answers CORS headers to the literal `null` (`lib/web-wallpaper.js:84`, `:529-537`)
@@ -441,6 +503,8 @@ The plugin offers two paths:
441
503
 
442
504
  ## Adjustable options and web-wallpaper settings wiring
443
505
 
506
+ This section covers what the plugin can read and change among a wallpaper's own properties.
507
+
444
508
  - **mpkg wallpapers**: the project's own **adjustable options** are shown **read-only** in the collapsed "adjustable options" area of the *Wallpaper* tab (the browser only has the pre-rendered assets, so changing a value does not change the picture). They are there for comparison.
445
509
  - **Web wallpapers (partly wired, Live2D-class)**: web wallpapers that ship `loadJson.json` / a `SettingModel` now expose their settings in the **same collapsed area**: resolution (2k/4k/8k, takes effect after a reload), language (whatever the wallpaper offers), background-music and voice volume (live), and switches such as showing touch areas or text boxes. Changes are written into the wallpaper iframe's storage (in sandbox mode through the facade → `/web-store`) and take effect after a reload.
446
510
  - **Hiding the wallpaper's own settings panel**: these wallpapers carry a "settings" button in the top-right corner of the wallpaper that cannot be interacted with; the plugin hides it once the iframe loads so it does not cover the picture.
@@ -450,6 +514,8 @@ The plugin offers two paths:
450
514
 
451
515
  ## Performance and stability
452
516
 
517
+ This section lists the **optimisations already in place and the numbers they bought**, and doubles as the reference when something feels slow or memory-hungry.
518
+
453
519
  - **mpkg header-only reads**: container parsing reads just the first 2MB (`lib/index.js:44`), so even an 834MB mpkg starts almost instantly
454
520
  - **The audio inventory does not wait for the whole package**: a `scene.pkg` keeps its directory table at the front ⇒ only the table plus 16 magic bytes per candidate entry are read (audio payloads never enter memory). `/custom-scene-audio` and `/library-scene-audio` return `{count,tracks:[…],stats}` directly, and `/raw` supports **Range/206**. Measured: reading whole packages 1.7–379 ms ⇒ index reads 1.3–6.9 ms cold / 0.4–0.9 ms warm
455
521
  - **Scene embedded-video probing is "index first"**: only the directory table plus candidate prefixes (`.tex` header and first mipmap record, the first 12 payload bytes for the `ftyp` box; when mip0 is LZ4 only the first sequence is decompressed), and anything uncertain falls back to reading the whole entry. Measured across 11 real packages: **2894 ms → 532 ms cold / 7 ms warm**; gate `node tools/scene-video-test.mjs`
@@ -461,19 +527,10 @@ The plugin offers two paths:
461
527
  - **Transcode resource caps live in one place**: 12 artifacts / 512MB, concurrency 1, 30 s queue, 15 min per job, default downscale to 1920 wide, 1024MB memory admission (see the [historical ledger](#video-wallpaper-transcoding-verdict-and-resource-caps))
462
528
  - **Weak-device degradation**: heavy composites (full-screen `backdrop-filter` plus streaming video) are throttled overall; for extreme combinations prefer Edge or a desktop browser
463
529
 
464
- **🌐 Browser compatibility (measured reference)**
465
-
466
- | Browser | Rating | Notes |
467
- |---|---|---|
468
- | Chrome / Chromium (desktop) | ⭐⭐⭐ strong | Most complete: best `backdrop-filter` and `color-mix`, working `iframe.muted`, muted autoplay allowed |
469
- | Edge (desktop) | ⭐⭐⭐ strong | Video wallpapers take a **separate canvas render path** (to dodge Edge's floating toolbar, `lib/client.js:1464-1520`); some versions show only a static first frame (not blank, not a crash) |
470
- | Firefox | ⭐⭐ medium | Everything is supported (`backdrop-filter` 103+, automatic transcode fallback for unsupported codecs); three deductions: `backdrop-filter` is slower than Chromium, `iframe.muted` is unsupported (a web wallpaper with sound may have its first frame blocked by the autoplay policy), `color-mix` needs 113+ |
471
- | Android WebView / mobile | ⭐⭐ weak-medium | Autoplay policy depends on the host WebView configuration; `getBattery` may be missing (guarded and skipped); for extreme combinations prefer a static image/GIF or turn blur off |
472
-
473
- > The source degrades for each browser (rAF fallback when `requestVideoFrameCallback` is missing, guards around `ResizeObserver`/`getBattery`, `.catch` on every `play()`, `backdrop-filter` detected with `CSS.supports` and degraded to opaque).
474
-
475
530
  ## Security notes
476
531
 
532
+ This section lists the plugin's network behaviour, data locations and isolation boundaries, for self-audit and compliance checks.
533
+
477
534
  - **No passive outbound network traffic by default**: the plugin never reaches out to the network on its own; everyday playback only talks to the local DSH host (`127.0.0.1`). The exceptions are all **explicitly user-triggered**: check-for-updates/one-click update contact GitHub (`raw.githubusercontent.com`, `api.github.com`), and the ffmpeg download contacts GitHub Releases / an npm binary mirror. In addition, **0.8 s after the settings panel opens a silent version check runs once** (it only lights the badge — no dialog, no download, no upload). Network image URLs typed by the user and resources loaded by a web wallpaper itself are external requests too.
478
535
  - **No sensitive content**: the source contains no paths, keys, tokens or personal information; `node tools/secret-scan-test.mjs` scans every tracked file (12 credential patterns, 3 local-path patterns) and must report 0 hits.
479
536
  - **Web-wallpaper sandbox**: in the default mode the iframe is an opaque origin, so author scripts cannot read the host DOM / `localStorage` / cookies and cannot remove their own sandbox; frame↔parent traffic is `postMessage` only (op allow-list + parent-origin check). Cross-origin reads of wallpaper assets are allowed **only for `Origin: null`**.
@@ -484,6 +541,8 @@ The plugin offers two paths:
484
541
 
485
542
  ## Attribution and licence
486
543
 
544
+ This section covers this package's own licence, the third-party attribution ledger that ships with it, and the "GPL never enters the plugin" boundary.
545
+
487
546
  - **This package is MIT** (`LICENSE`; the `license` field in `package.json` is `"MIT"`). Third-party provenance, clean-room records, per-item attribution and the "what was copied / why / what was not" ledger all live in **`THIRD-PARTY.md`** (**shipped with the package** — it carries the MIT attribution obligation).
488
547
  - **The only vendored third-party code** is `lib/liquid-glass/**` (the liquid-glass renderer library): **moved out of the publish surface by P-127** (`files` negative patterns `!lib/liquid-glass/**` + `!lib/liquid-glass-bundle.js`). **Every file is still in the repository** and all 10 sha256 digests are still registered (`THIRD-PARTY.md` §1.3). The host's `/api/mpkg-wallpaper/lg/*` hosting route is still a live path (on a hit it really reads `lib/liquid-glass/<file>`); on an npm-installed copy it returns 404 by design.
489
548
  - **Now playing component**: a **line-by-line port** of Bencho's "Now playing" (MIT) with upstream comments kept verbatim; the sidebar mount controller, self-drawn icons, token mapping and gates are written here (`THIRD-PARTY.md` §6).
@@ -497,9 +556,11 @@ The plugin offers two paths:
497
556
 
498
557
  ## File structure
499
558
 
500
- ```
559
+ This section is a file-by-file map of the repository: what enters the npm package, what stays in the repository only, and why.
560
+
561
+ ```text
501
562
  dsh-mpkg-wallpaper/
502
- ├── package.json # version 3.8.0; dsh.bundle + dsh.client declarations; files allow-list = publish surface (14 files)
563
+ ├── package.json # version 3.9.1; dsh.bundle + dsh.client declarations; files allow-list = publish surface (15 files)
503
564
  ├── cordis.patch.yml # install declaration used by dsh plugin add
504
565
  ├── LICENSE # MIT
505
566
  ├── THIRD-PARTY.md # third-party provenance / clean-room records / licence attribution (shipped)
@@ -534,7 +595,9 @@ dsh-mpkg-wallpaper/
534
595
  > Exclusions: `lib/liquid-glass/**`, `lib/liquid-glass-bundle.js` and `lib/**/*.bak*` are excluded by `files` negative patterns; `tools/`, `docs/`, `dist/` and `package-lock.json` are not in the `files` allow-list. Section ⑨ of `tools/integrity-check.mjs` asserts **both directions**: "not in the publish surface" and "must still be present in the repository".
535
596
  > `lib/now-playing.js` / `lib/now-playing-math.js` use a **source + generated inline** shape: `tools/build-now-playing.mjs` inlines them byte-for-byte into the generated region of `lib/client.js`. Why this is necessary: the host serves the **one file** that `exports["./client"]` points at, so any relative `import/require` inside `lib/client.js` breaks module resolution in the browser (asserted by section ⑩ of `tools/integrity-check.mjs`). The price is a drift gate, so **two independent implementations** run permanently in step 2 of the gate.
536
597
 
537
- ## Gates
598
+ ## Gates and guardrails
599
+
600
+ This section is **how a change proves it did not break anything**: four commands to run, plus what each of the 12 gate steps checks.
538
601
 
539
602
  ```sh
540
603
  node tools/integrity-check.mjs # 72 passed / 0 failed: files present, metadata, allow-list, no local paths, client self-containment
@@ -545,6 +608,36 @@ node tools/style-scope-guard.mjs # the criterion is the last line "… OK /
545
608
 
546
609
  The 12 steps of `tools/check.sh`: ① syntax ② panel smoke + P-66 + folder picker + wallpaper-layer visibility + persistence + NP (both drift gates, 83 assertions, plus the 82-assertion sound-wiring gate) ③ CSS matrix (all 512 combinations + 600 random + boundaries) ④ scene watchdog/debug params ⑤ sandbox and scene tokens + web-wallpaper shim + transcode resources ⑥ publish integrity self-check ⑦ audio-scan speed-up ⑧ scene video indexing ⑨ on-device replica A/B ⑩ better-sidebar compatibility ⑪ single-file bundle equivalence ⑫ style-scope guard + surface token namespace.
547
610
 
611
+ ## What changed in this version
612
+
613
+ Current version = **3.9.1** in `package.json` (this release line: 3.8.0 → 3.8.1 → 3.8.2 → 3.9.0 → **3.9.1**). 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
+
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
+ - **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.
617
+ - **The card is no longer clipped in floating mode**: fit-scaling now measures **our own container** (the old code measured `[class*="sidebarCol"]`, and that class name appears more than once on a real machine — it measured 280 while our container was 256), `.mpw_np` is pinned to `math.W` with the overflow shared evenly, and the web-wallpaper branch now applies the `data-mpw-float` gate and calls `applyNowPlaying()` (the old branch returned early, so after switching to a web wallpaper the widget was not mounted at all).
618
+ - **Gate expansion**: `tools/now-playing-test.mjs` now reports **83 passed / 0 failed** (including 7 mutations); new `tools/np-media-test.mjs` (track-list scope / data-source decision / playback target / mute target / yielding / mark / the web path not skipping apply / card geometry — **82 passed / 0 failed**, including **12 mutation self-proofs**) is registered in step 2 of `tools/check.sh`; new on-device probe `tools/np-media-live-probe.mjs` (`:3080` + headless Firefox; 16 PASS / 22 FAIL before the fix → 45 PASS / 0 FAIL after, not part of the standing gate).
619
+ - **Several on-device fixes**: Now playing no longer mistakes the expanded state for collapsed on the frame right after slot rendering (so the widget no longer disappears after a refresh) — the criterion is now **physical width first** with threshold `NP_COLLAPSE_MAX_W = 96`, and when an anchor appears late it watches the document and mounts itself; the play/pause quads are now driven by a **tween of the playback state itself** (they used to be driven by expand progress, so the collapsed state always drew a triangle and the expanded state always drew the two bars); the header-frost `ReferenceError` and the transparent right-side timeline rail (see [Historical ledger](#historical-ledger)); the `lgCss` block that never executed because of a TDZ error; `sessionFollow` that had a toggle but no reader; the folder picker scrolling back to the top.
620
+ - **3.9.0: on-device wallpaper-pipeline batch + audio semantics settled** (six user-reported bugs plus three audio issues) —
621
+ (1) the settings panel squeezed into the sidebar (`backdrop-filter` made the sidebar the containing block of the `fixed` overlay;
622
+ on-device `{x:320,w:800} → {x:13,w:254}`, reversible); (2) "Clear wallpaper" not clearing (`undefined` means "do not overwrite" in
623
+ invariant I, and the sticky guard brought `webUrl` back), a leftover `webUrl` arming first on switch (black screen + broken image),
624
+ a blank preview for web wallpapers, and an unavailable source being rendered as artwork (now `data-mpw-bg-error` + plain-language panel copy);
625
+ (3) sandbox wallpapers blocked by browser policy were invisible to the parent page (shim capability self-report `probeCaps` + one-shot
626
+ compat downgrade on **policy-class** errors only, never on the author's own bugs); (4) the card's pause could not silence in-frame audio
627
+ (`npFrameSoundBlocked()`); (5) no mute on tab switch (`powPauseHidden` **now defaults on**, migrating only untouched profiles, covering our
628
+ own `<audio>` and resuming by prior state on `pageshow`); A interactive-sound classification (`mpwClassifyWebAudio` + `?npvoice=keep|drop`;
629
+ on the corpus the list keeps only `BGM.wav`); B link switch off = **sound-only control** (pause/play mutes/unmutes that one track, the picture
630
+ keeps running); C "it makes a noise again after a while" on tab switch (single gate `mpwHiddenAudioBlock()` on every start path);
631
+ **NP-5 pause intent persistence** (new key `npPaused`, written only by explicit user action; a refresh must **not** resume — the on-device probe
632
+ `tools/np-pause-persist-live-probe.mjs` reports 11 PASS / 0 FAIL: 59 samples × 500 ms all `paused` after a reload, zero `play()` calls);
633
+ **audio audit** (hooks `play`/`volume`/`muted`/`new Audio`/`AudioContext`, bounded ring ≤200, suspicious transitions POST to `/diag`; `?npaudit=0` disables);
634
+ **host body-limit semantics**: both POST receivers now answer **413 + JSON explanation** instead of destroying the connection first
635
+ (the old behaviour showed clients nothing but ECONNRESET). Root-cause chains, on-device readings and the 10-item honest list live in
636
+ [`docs/WALLPAPER-LIFECYCLE.md`](docs/WALLPAPER-LIFECYCLE.md); the release record is in [`docs/RELEASE.md`](docs/RELEASE.md).
637
+ - **3.8.1 / 3.8.2: the second on-device Now playing batch (NP-4)** — start-up order (the old code unmuted *before* `play()`, i.e. an audible autoplay attempt the browser refuses and the code swallowed), being stopped by browser policy (a muted start resolves, then unmuting gets the element stopped outright), a **new volume level** (0..100, applied to the real element) and a **draggable position (seek)**, a **new "Play/pause also controls the wallpaper" switch (`npLinkWallpaper`, default on = byte-identical to the previous behaviour)**, a larger floating card, and an honest "can it play" classification for track lists. Deliverable contract: **sound is guaranteed after the first user gesture** (when policy stops the element it falls back to muted to keep the picture and records `window.__mpwNpSoundBlocked`). See the 3.8.1/3.8.2 records in [`docs/RELEASE.md`](docs/RELEASE.md) and [`docs/NOW-PLAYING-DSH.md`](docs/NOW-PLAYING-DSH.md) §7.8.
638
+ - **Capabilities from the previous round still on the same publish surface**: web-wallpaper rendering / API coverage (in-frame storage facade + host `/web-store`, master volume, source-level `file:///` rewriting, CSP injection skip, `/media-audio`) and web-wallpaper touch (`op:'touch'` with real `TouchEvent`s); the **host-side system media session adapter** `lib/media-session.js` (MPRIS / SMTC, **implemented but not wired up yet**, below).
639
+ - **Gates and guardrails**: CSS matrix, style-scope guard, surface-token namespace equivalence, switch-wiring audit and the second-scale pre-commit hook (see the [historical ledger](#historical-ledger) and [Gates](#gates-and-guardrails)).
640
+
548
641
  ## Historical ledger
549
642
 
550
643
  The sections below are **evidence that has already been booked** (incident, root cause, criterion, rollback switch). Their factual content is preserved rather than rewritten: they explain *why the current implementation looks the way it does*.
@@ -651,14 +744,9 @@ MPW_SKIP_PRECOMMIT=1 git commit -m … # one-off bypass (script-level explicit
651
744
 
652
745
  ## Bug reports
653
746
 
654
- When reporting an issue, please include:
747
+ Attach these and an issue can usually be diagnosed in one round:
748
+
655
749
  - The **original .mpkg or workshop folder** (required to reproduce)
656
750
  - The result of one "one-click diagnostics report" (*Other* tab → one-click report; a JSON file is downloaded automatically when the host is unavailable)
657
751
  - Browser console output (F12 → Console), if any
658
752
  - Your DSH version and platform (Windows / Linux / mobile)
659
-
660
- ## Rendering feasibility research
661
-
662
- - A complete scene (Live2D puppets included) can only be produced by the proprietary renderer: the Wallpaper Engine app's native library (embedded Chromium + proprietary puppet rendering). The open-source [we-layerd](https://github.com/Aromatic05/we-layerd) (Rust) bundles the official renderer but is **Linux Wayland only**
663
- - There is no mature WE scene renderer for the browser — **regardless of the operating system, no browser can render Live2D scenes directly**; the official renderer `.so` is a closed binary and cannot be compiled to WASM without source
664
- - The feasible path for this plugin: **external renderer iframe (preferred) + static-frame extraction + layer compositing + (Time Variation) mpkg-style slot switching**; when full dynamics are needed, use "render externally to a video → video wallpaper"