wallpaper-engine-dsh 0.1.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/lib/client.js ADDED
@@ -0,0 +1,1052 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "wallpaper-engine-dsh",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+
8
+ const React = require("react");
9
+
10
+ const SETTINGS_KEY = "wallpaper-engine-dsh:selection";
11
+ const INVENTORY_URL = "/we-background/inventory";
12
+ const ACTIVE_ATTR = "data-webg-wallpaper";
13
+ const LAYER_ID = "wallpaper-engine-dsh-layer";
14
+ const SCRIM_ID = "wallpaper-engine-dsh-scrim";
15
+ const STYLE_TAG_ID = "wallpaper-engine-dsh/styles";
16
+ const CROSSFADE_MS = 520;
17
+
18
+ // Respect the OS "reduce motion" preference: start paused rather than
19
+ // autoplaying a video loop at someone who asked for less motion.
20
+ const REDUCED_MOTION = typeof window !== "undefined" &&
21
+ typeof window.matchMedia === "function" &&
22
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
23
+
24
+ // ── Defaults ─────────────────────────────────────────────────────────────────
25
+ const DEFAULTS = {
26
+ scrim: 0.25,
27
+ border: 0.35,
28
+ blur: 16,
29
+ wallpaperBlur: 0,
30
+ rotationEnabled: false,
31
+ rotationInterval: 30,
32
+ rotationGroupId: "",
33
+ rotationGroups: [],
34
+ rotationSeeded: false,
35
+ };
36
+
37
+ // ── Persisted selection ─────────────────────────────────────────────────────
38
+ function clampNum(v, lo, hi, fallback) {
39
+ return typeof v === "number" && isFinite(v) && v >= lo && v <= hi ? v : fallback;
40
+ }
41
+
42
+ // Rotation groups are user-defined carousel lists: each holds wallpaper ids
43
+ // picked from the inventory, its own switch interval (minutes) and order.
44
+ // Fully client-side (localStorage) — rotation never depends on Wallpaper
45
+ // Engine's own config.json playlist paths.
46
+ function readRotationGroups(raw) {
47
+ if (!Array.isArray(raw)) return [];
48
+ const groups = [];
49
+ for (const g of raw) {
50
+ if (!g || typeof g !== "object") continue;
51
+ const id = typeof g.id === "string" && g.id ? g.id : "";
52
+ if (!id) continue;
53
+ groups.push({
54
+ id,
55
+ name: typeof g.name === "string" && g.name.trim() ? g.name.trim() : "轮播列表",
56
+ interval: clampNum(g.interval, 1, 1440, DEFAULTS.rotationInterval),
57
+ order: g.order === "random" ? "random" : "sequence",
58
+ wallpaperIds: Array.isArray(g.wallpaperIds)
59
+ ? g.wallpaperIds.filter((x) => typeof x === "string" && x)
60
+ : [],
61
+ });
62
+ }
63
+ return groups;
64
+ }
65
+
66
+ function readPersisted() {
67
+ try {
68
+ const raw = localStorage.getItem(SETTINGS_KEY);
69
+ if (!raw) return { id: "", ...DEFAULTS };
70
+ const o = JSON.parse(raw);
71
+ return {
72
+ id: typeof o.id === "string" ? o.id : "",
73
+ scrim: clampNum(o.scrim, 0, 1, DEFAULTS.scrim),
74
+ border: clampNum(o.border, 0, 1, DEFAULTS.border),
75
+ blur: clampNum(o.blur, 0, 40, DEFAULTS.blur),
76
+ wallpaperBlur: clampNum(o.wallpaperBlur, 0, 60, DEFAULTS.wallpaperBlur),
77
+ rotationEnabled: o.rotationEnabled === true,
78
+ rotationGroupId: typeof o.rotationGroupId === "string" ? o.rotationGroupId : "",
79
+ rotationGroups: readRotationGroups(o.rotationGroups),
80
+ rotationSeeded: o.rotationSeeded === true,
81
+ };
82
+ } catch {
83
+ return { id: "", ...DEFAULTS };
84
+ }
85
+ }
86
+
87
+ // ── Shared selection store (React picker + DOM layer both read it) ──────────
88
+ const selection = {
89
+ ...readPersisted(),
90
+ url: null,
91
+ type: null,
92
+ playing: !REDUCED_MOTION,
93
+ loading: false,
94
+ rotationTimer: null,
95
+ // Live draft of the group being created/edited (null when editor closed).
96
+ editing: null,
97
+ inventory: { installDir: null, wallpapers: [], total: 0, portableCount: 0, playlists: [], error: null },
98
+ loaded: false,
99
+ };
100
+
101
+ const listeners = new Set();
102
+ function emit() { for (const fn of [...listeners]) fn(); }
103
+ function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
104
+
105
+ function useStore() {
106
+ const [, setTick] = React.useState(0);
107
+ React.useEffect(() => subscribe(() => setTick((n) => n + 1)), []);
108
+ return selection;
109
+ }
110
+
111
+ function persistSelection() {
112
+ try {
113
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify({
114
+ id: selection.id,
115
+ scrim: selection.scrim,
116
+ border: selection.border,
117
+ blur: selection.blur,
118
+ wallpaperBlur: selection.wallpaperBlur,
119
+ rotationEnabled: selection.rotationEnabled,
120
+ rotationGroupId: selection.rotationGroupId,
121
+ rotationGroups: selection.rotationGroups,
122
+ rotationSeeded: selection.rotationSeeded,
123
+ }));
124
+ } catch { /* storage full / blocked → session-only state, not fatal */ }
125
+ }
126
+
127
+ // ── Inventory ────────────────────────────────────────────────────────────────
128
+ async function loadInventory(forceRefresh) {
129
+ selection.loading = true;
130
+ emit();
131
+ try {
132
+ const res = await fetch(forceRefresh ? INVENTORY_URL + "?refresh=1" : INVENTORY_URL, { cache: "no-store" });
133
+ if (!res.ok) throw new Error("inventory HTTP " + res.status);
134
+ const data = await res.json();
135
+ selection.inventory = {
136
+ installDir: data.installDir || null,
137
+ wallpapers: Array.isArray(data.wallpapers) ? data.wallpapers : [],
138
+ total: data.total || 0,
139
+ portableCount: data.portableCount || 0,
140
+ playlists: Array.isArray(data.playlists) ? data.playlists : [],
141
+ error: null,
142
+ };
143
+ } catch (err) {
144
+ selection.inventory = {
145
+ installDir: null, wallpapers: [], total: 0, portableCount: 0, playlists: [],
146
+ error: String(err && err.message ? err.message : err),
147
+ };
148
+ }
149
+ selection.loading = false;
150
+ selection.loaded = true;
151
+
152
+ // Seed a first rotation group from a playable WE playlist (once only, so
153
+ // deleting every list stays respected across reloads).
154
+ if (!selection.rotationGroups.length && !selection.rotationSeeded) {
155
+ selection.rotationSeeded = true;
156
+ seedGroupsFromPlaylists();
157
+ persistSelection();
158
+ }
159
+ if (selection.rotationGroupId && !activeRotationGroup()) {
160
+ selection.rotationGroupId = "";
161
+ persistSelection();
162
+ }
163
+ if (selection.rotationEnabled) {
164
+ if (!selection.rotationGroupId) {
165
+ const usable = firstUsableGroup();
166
+ if (usable) selection.rotationGroupId = usable.id;
167
+ else selection.rotationEnabled = false;
168
+ } else if (rotationCandidates().length < 2) {
169
+ const usable = firstUsableGroup();
170
+ if (usable && usable.id !== selection.rotationGroupId) selection.rotationGroupId = usable.id;
171
+ else if (!usable) selection.rotationEnabled = false;
172
+ }
173
+ persistSelection();
174
+ }
175
+
176
+ // Drop a selection whose wallpaper vanished or stopped being playable.
177
+ if (selection.id && !selection.inventory.wallpapers.some((w) => w.id === selection.id && isPlayable(w))) {
178
+ selection.id = "";
179
+ persistSelection();
180
+ }
181
+ if (selection.rotationEnabled && selection.id && !rotationCandidates().some((w) => w.id === selection.id)) {
182
+ selection.id = "";
183
+ persistSelection();
184
+ }
185
+ if (!selection.id && selection.rotationEnabled) {
186
+ const first = rotationCandidates()[0];
187
+ if (first) selection.id = first.id;
188
+ }
189
+ applySelection(selection.id);
190
+ emit();
191
+ }
192
+
193
+ function isPlayable(w) {
194
+ return Boolean(w && w.playable && (w.type === "video" || w.type === "web"));
195
+ }
196
+
197
+ function playableInventory() {
198
+ return selection.inventory.wallpapers.filter(isPlayable);
199
+ }
200
+
201
+ // ── Rotation groups ─────────────────────────────────────────────────────────
202
+ function activeRotationGroup() {
203
+ return selection.rotationGroups.find((g) => g.id === selection.rotationGroupId) || null;
204
+ }
205
+
206
+ function groupWallpapers(group) {
207
+ if (!group || !Array.isArray(group.wallpaperIds)) return [];
208
+ const byId = new Map(selection.inventory.wallpapers.map((w) => [w.id, w]));
209
+ return group.wallpaperIds.map((id) => byId.get(id)).filter(isPlayable);
210
+ }
211
+
212
+ function rotationCandidates() {
213
+ return groupWallpapers(activeRotationGroup());
214
+ }
215
+
216
+ function firstUsableGroup() {
217
+ return selection.rotationGroups.find((g) => groupWallpapers(g).length >= 2) || null;
218
+ }
219
+
220
+ function seedGroupsFromPlaylists() {
221
+ const source = selection.inventory.playlists.find((p) => (p.portableCount || 0) >= 2);
222
+ if (!source) return false;
223
+ const ids = Array.isArray(source.wallpaperIds) ? source.wallpaperIds.filter((id) => {
224
+ const w = selection.inventory.wallpapers.find((x) => x.id === id);
225
+ return isPlayable(w);
226
+ }) : [];
227
+ if (ids.length < 2) return false;
228
+ selection.rotationGroups.push({
229
+ id: nextGroupId(),
230
+ name: typeof source.name === "string" && source.name.trim() ? source.name.trim() : "轮播列表",
231
+ interval: DEFAULTS.rotationInterval,
232
+ order: source.order === "random" ? "random" : "sequence",
233
+ wallpaperIds: ids,
234
+ });
235
+ selection.rotationGroupId = selection.rotationGroups[selection.rotationGroups.length - 1].id;
236
+ return true;
237
+ }
238
+
239
+ function nextGroupId() {
240
+ return "grp-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
241
+ }
242
+
243
+ function nextRotationWallpaper() {
244
+ const list = rotationCandidates();
245
+ if (list.length < 2) return null;
246
+ const group = activeRotationGroup();
247
+ if (group && group.order === "random") {
248
+ const candidates = list.filter((w) => w.id !== selection.id);
249
+ return candidates[Math.floor(Math.random() * candidates.length)] || null;
250
+ }
251
+ const current = list.findIndex((w) => w.id === selection.id);
252
+ return list[(current + 1 + list.length) % list.length] || null;
253
+ }
254
+
255
+ function clearRotationTimer() {
256
+ if (selection.rotationTimer === null) return;
257
+ if (typeof window !== "undefined" && typeof window.clearTimeout === "function") {
258
+ window.clearTimeout(selection.rotationTimer);
259
+ }
260
+ selection.rotationTimer = null;
261
+ }
262
+
263
+ function syncRotationTimer() {
264
+ clearRotationTimer();
265
+ if (!selection.rotationEnabled || !selection.id) return;
266
+ if (rotationCandidates().length < 2) return;
267
+ if (typeof window === "undefined" || typeof window.setTimeout !== "function") return;
268
+ const group = activeRotationGroup();
269
+ const minutes = group ? group.interval : DEFAULTS.rotationInterval;
270
+ selection.rotationTimer = window.setTimeout(() => {
271
+ selection.rotationTimer = null;
272
+ if (!selection.rotationEnabled || !selection.id) return;
273
+ const next = nextRotationWallpaper();
274
+ if (next) applySelection(next.id);
275
+ }, minutes * 60 * 1000);
276
+ }
277
+
278
+ // ── Rotation group CRUD (draft-based editor) ────────────────────────────────
279
+ function startEditGroup(id) {
280
+ const group = selection.rotationGroups.find((g) => g.id === id);
281
+ if (!group) return;
282
+ selection.editing = JSON.parse(JSON.stringify(group));
283
+ emit();
284
+ }
285
+
286
+ function startCreateGroup() {
287
+ selection.editing = {
288
+ id: nextGroupId(),
289
+ name: "轮播列表 " + (selection.rotationGroups.length + 1),
290
+ interval: DEFAULTS.rotationInterval,
291
+ order: "sequence",
292
+ wallpaperIds: [],
293
+ };
294
+ emit();
295
+ }
296
+
297
+ function saveEditingGroup() {
298
+ const draft = selection.editing;
299
+ if (!draft) return;
300
+ const idx = selection.rotationGroups.findIndex((g) => g.id === draft.id);
301
+ const cleaned = {
302
+ id: draft.id,
303
+ name: typeof draft.name === "string" && draft.name.trim() ? draft.name.trim() : "轮播列表",
304
+ interval: clampNum(draft.interval, 1, 1440, DEFAULTS.rotationInterval),
305
+ order: draft.order === "random" ? "random" : "sequence",
306
+ wallpaperIds: Array.isArray(draft.wallpaperIds)
307
+ ? draft.wallpaperIds.filter((x) => typeof x === "string" && x)
308
+ : [],
309
+ };
310
+ if (idx >= 0) selection.rotationGroups[idx] = cleaned;
311
+ else selection.rotationGroups.push(cleaned);
312
+ selection.rotationGroupId = cleaned.id;
313
+ selection.editing = null;
314
+ if (selection.rotationEnabled && !rotationCandidates().some((w) => w.id === selection.id)) {
315
+ const first = rotationCandidates()[0];
316
+ applySelection(first ? first.id : "");
317
+ return;
318
+ }
319
+ persistSelection();
320
+ syncRotationTimer();
321
+ emit();
322
+ }
323
+
324
+ function cancelEditGroup() {
325
+ selection.editing = null;
326
+ emit();
327
+ }
328
+
329
+ function deleteGroup(id) {
330
+ const idx = selection.rotationGroups.findIndex((g) => g.id === id);
331
+ if (idx < 0) return;
332
+ selection.rotationGroups.splice(idx, 1);
333
+ if (selection.rotationGroupId === id) {
334
+ selection.rotationGroupId = "";
335
+ if (selection.rotationEnabled) {
336
+ const fallback = firstUsableGroup();
337
+ if (fallback) selection.rotationGroupId = fallback.id;
338
+ else selection.rotationEnabled = false;
339
+ }
340
+ }
341
+ if (selection.editing && selection.editing.id === id) selection.editing = null;
342
+ persistSelection();
343
+ syncRotationTimer();
344
+ emit();
345
+ }
346
+
347
+ function importPlaylistIntoDraft(playlist) {
348
+ if (!selection.editing || !playlist || !Array.isArray(playlist.wallpaperIds)) return;
349
+ const playable = new Set(playableInventory().map((w) => w.id));
350
+ selection.editing.wallpaperIds = playlist.wallpaperIds.filter((id) => playable.has(id));
351
+ emit();
352
+ }
353
+
354
+ function applySelection(id) {
355
+ selection.id = id || "";
356
+ persistSelection();
357
+ const w = selection.id
358
+ ? selection.inventory.wallpapers.find((x) => x.id === selection.id)
359
+ : null;
360
+ if (!w || !isPlayable(w)) {
361
+ selection.url = null;
362
+ selection.type = null;
363
+ } else {
364
+ selection.url = w.media;
365
+ selection.type = w.type;
366
+ }
367
+ syncRotationTimer();
368
+ emit();
369
+ }
370
+
371
+ // ── Behind-body layer: wallpaper + scrim, with crossfade ────────────────────
372
+ function buildMedia(sel) {
373
+ const media = sel.type === "video"
374
+ ? document.createElement("video")
375
+ : document.createElement("iframe");
376
+ if (sel.type === "video") {
377
+ media.src = sel.url;
378
+ media.autoplay = !REDUCED_MOTION;
379
+ media.loop = true;
380
+ media.muted = true;
381
+ media.setAttribute("playsinline", "");
382
+ media.className = "webg-media";
383
+ } else {
384
+ // SECURITY: sandboxed, opaque origin. allow-scripts alone runs the
385
+ // wallpaper's animation but denies it DSH's localStorage/cookies/APIs.
386
+ media.src = sel.url;
387
+ media.setAttribute("sandbox", "allow-scripts");
388
+ media.setAttribute("referrerpolicy", "no-referrer");
389
+ media.setAttribute("frameborder", "0");
390
+ media.setAttribute("scrolling", "no");
391
+ media.setAttribute("title", "Wallpaper Engine web wallpaper");
392
+ media.className = "webg-media webg-iframe";
393
+ }
394
+ return media;
395
+ }
396
+
397
+ function syncLayers() {
398
+ const existing = document.getElementById(LAYER_ID);
399
+
400
+ if (selection.url) {
401
+ const wantKey = selection.type + "\u0000" + selection.url;
402
+ const gotKey = existing && existing.dataset.webgKey;
403
+
404
+ if (existing && gotKey === wantKey) {
405
+ // Same wallpaper — only play/pause may have changed.
406
+ } else {
407
+ // Crossfade: keep the old layer alive while the new one fades in on
408
+ // top, then remove the old one after the transition settles. The old
409
+ // layer surrenders the id FIRST — getElementById must resolve to the
410
+ // new layer for the play/pause step below.
411
+ if (existing) existing.removeAttribute("id");
412
+ const node = document.createElement("div");
413
+ node.id = LAYER_ID;
414
+ node.className = "webg-layer webg-layer--enter";
415
+ node.dataset.webgKey = wantKey;
416
+ node.appendChild(buildMedia(selection));
417
+ document.body.appendChild(node);
418
+ // Force a style flush so the enter transition actually runs.
419
+ void node.offsetHeight;
420
+ node.classList.remove("webg-layer--enter");
421
+ if (existing) {
422
+ existing.classList.add("webg-layer--leave");
423
+ const stale = existing;
424
+ if (typeof window !== "undefined" && typeof window.setTimeout === "function") {
425
+ window.setTimeout(() => stale.remove(), CROSSFADE_MS + 120);
426
+ } else {
427
+ stale.remove();
428
+ }
429
+ }
430
+ }
431
+ const node = document.getElementById(LAYER_ID);
432
+ const video = node && node.querySelector("video");
433
+ if (video) {
434
+ if (selection.playing) { try { const p = video.play(); if (p && p.catch) p.catch(() => {}); } catch {} }
435
+ else video.pause();
436
+ }
437
+ } else if (existing) {
438
+ existing.classList.add("webg-layer--leave");
439
+ const stale = existing;
440
+ if (typeof window !== "undefined" && typeof window.setTimeout === "function") {
441
+ window.setTimeout(() => stale.remove(), CROSSFADE_MS + 120);
442
+ } else {
443
+ stale.remove();
444
+ }
445
+ }
446
+
447
+ // Scrim: always present while a wallpaper is active.
448
+ const scrim = document.getElementById(SCRIM_ID);
449
+ if (selection.url) {
450
+ if (!scrim) {
451
+ const s = document.createElement("div");
452
+ s.id = SCRIM_ID;
453
+ s.className = "webg-scrim";
454
+ document.body.appendChild(s);
455
+ }
456
+ document.body.setAttribute(ACTIVE_ATTR, "on");
457
+ } else {
458
+ if (scrim) scrim.remove();
459
+ document.body.removeAttribute(ACTIVE_ATTR);
460
+ }
461
+ }
462
+
463
+ // ── Effect application: push the knobs into CSS variables ───────────────────
464
+ function applyEffects() {
465
+ const s = document.body.style;
466
+ s.setProperty("--webg-scrim-color", "rgba(0,0,0," + selection.scrim + ")");
467
+ s.setProperty("--webg-border-alpha", String(selection.border));
468
+ s.setProperty("--webg-blur", selection.blur + "px");
469
+ // The glass "colour melt" scales with blur radius: 0 blur → no melt.
470
+ s.setProperty("--webg-saturate", String(1.15 + selection.blur * 0.028));
471
+ s.setProperty("--webg-glass-brightness", "1.04");
472
+ s.setProperty("--webg-wallpaper-blur", selection.wallpaperBlur + "px");
473
+ // Scaling compensates the transparent fringe CSS blur reveals at edges.
474
+ s.setProperty("--webg-wallpaper-scale", (1 + selection.wallpaperBlur * 0.006).toFixed(4));
475
+
476
+ // Write the scrim colour directly too, then force a synchronous layout, so
477
+ // slider feedback lands on this frame even on stalled compositors.
478
+ const scrim = document.getElementById(SCRIM_ID);
479
+ if (scrim) scrim.style.background = "rgba(0,0,0," + selection.scrim + ")";
480
+ if (document.body && document.body.offsetHeight !== undefined) {
481
+ void document.body.offsetHeight;
482
+ }
483
+ }
484
+
485
+ function clearEffects() {
486
+ const s = document.body.style;
487
+ s.removeProperty("--webg-scrim-color");
488
+ s.removeProperty("--webg-border-alpha");
489
+ s.removeProperty("--webg-blur");
490
+ s.removeProperty("--webg-saturate");
491
+ s.removeProperty("--webg-glass-brightness");
492
+ s.removeProperty("--webg-wallpaper-blur");
493
+ s.removeProperty("--webg-wallpaper-scale");
494
+ const scrim = document.getElementById(SCRIM_ID);
495
+ if (scrim) scrim.style.background = "";
496
+ }
497
+
498
+ // ── Settings picker ─────────────────────────────────────────────────────────
499
+ function SliderRow(label, min, max, step, value, onInput, suffix) {
500
+ return React.createElement("div", { className: "webg-row webg-slider-row" },
501
+ React.createElement("span", { className: "webg-hint webg-label" }, label),
502
+ React.createElement("input", {
503
+ className: "webg-slider", type: "range",
504
+ min: String(min), max: String(max), step: String(step),
505
+ value: String(value),
506
+ // onInput fires continuously while dragging (onChange may wait for
507
+ // release) — that is what makes the visual feedback instant.
508
+ onInput: (e) => onInput(Number(e.target.value)),
509
+ onChange: (e) => onInput(Number(e.target.value)),
510
+ }),
511
+ React.createElement("span", { className: "webg-hint webg-value" }, suffix),
512
+ );
513
+ }
514
+
515
+ function ThumbCard(w, selected, onClick) {
516
+ return React.createElement("button", {
517
+ key: w.id,
518
+ className: "webg-card" + (selected ? " webg-card--selected" : ""),
519
+ type: "button",
520
+ onClick: onClick,
521
+ title: w.title,
522
+ },
523
+ w.preview
524
+ ? React.createElement("img", {
525
+ src: w.preview, alt: w.title, loading: "lazy",
526
+ onError: (e) => { e.target.style.display = "none"; },
527
+ })
528
+ : React.createElement("span", { className: "webg-card-placeholder" }, "无预览"),
529
+ React.createElement("span", { className: "webg-card-type" }, w.type === "video" ? "视频" : "网页"),
530
+ React.createElement("span", { className: "webg-card-title" }, w.title),
531
+ );
532
+ }
533
+
534
+ function WallpaperPicker() {
535
+ const sel = useStore();
536
+ const onTogglePlay = () => { selection.playing = !selection.playing; emit(); };
537
+ const onClear = () => applySelection("");
538
+ const onRefresh = () => loadInventory(true);
539
+ const onGroupChange = (e) => {
540
+ selection.rotationGroupId = e.target.value;
541
+ if (selection.rotationEnabled) {
542
+ const first = rotationCandidates()[0];
543
+ if (first) applySelection(first.id);
544
+ else applySelection("");
545
+ return;
546
+ }
547
+ persistSelection();
548
+ syncRotationTimer();
549
+ emit();
550
+ };
551
+ const onToggleRotation = () => {
552
+ selection.rotationEnabled = !selection.rotationEnabled;
553
+ if (selection.rotationEnabled) {
554
+ if (!selection.rotationGroupId) {
555
+ const usable = firstUsableGroup();
556
+ if (usable) selection.rotationGroupId = usable.id;
557
+ }
558
+ if (!rotationCandidates().some((w) => w.id === selection.id)) {
559
+ const first = rotationCandidates()[0];
560
+ if (first) { applySelection(first.id); return; }
561
+ }
562
+ }
563
+ persistSelection();
564
+ syncRotationTimer();
565
+ emit();
566
+ };
567
+ const onGroupInterval = (e) => {
568
+ const group = activeRotationGroup();
569
+ if (!group) return;
570
+ group.interval = clampNum(Number(e.target.value), 1, 1440, DEFAULTS.rotationInterval);
571
+ persistSelection();
572
+ syncRotationTimer();
573
+ emit();
574
+ };
575
+ const onDeleteGroup = () => {
576
+ const group = activeRotationGroup();
577
+ if (!group) return;
578
+ if (typeof window !== "undefined" && typeof window.confirm === "function") {
579
+ if (!window.confirm("删除轮播列表「" + group.name + "」?")) return;
580
+ }
581
+ deleteGroup(group.id);
582
+ };
583
+
584
+ const onScrim = (pct) => { selection.scrim = pct / 100; persistSelection(); applyEffects(); emit(); };
585
+ const onBorder = (pct) => { selection.border = pct / 100; persistSelection(); applyEffects(); emit(); };
586
+ const onBlur = (px) => { selection.blur = px; persistSelection(); applyEffects(); emit(); };
587
+ const onWallpaperBlur = (px) => { selection.wallpaperBlur = px; persistSelection(); applyEffects(); emit(); };
588
+
589
+ if (!sel.loaded) {
590
+ return React.createElement("div", { className: "webg-picker" },
591
+ React.createElement("span", { className: "webg-hint" }, "正在扫描 Wallpaper Engine…"));
592
+ }
593
+ if (sel.inventory.error) {
594
+ return React.createElement("div", { className: "webg-picker" },
595
+ React.createElement("div", { className: "webg-error" },
596
+ "未能连接 Wallpaper Engine:" + sel.inventory.error),
597
+ React.createElement("button", {
598
+ className: "webg-btn", type: "button", onClick: onRefresh, disabled: sel.loading,
599
+ }, sel.loading ? "刷新中…" : "重试"));
600
+ }
601
+
602
+ const playableList = playableInventory();
603
+ const groups = sel.rotationGroups;
604
+ const group = activeRotationGroup();
605
+ const candidates = rotationCandidates();
606
+ const playableCount = candidates.length;
607
+ const editing = sel.editing;
608
+ const INTERVALS = [1, 5, 10, 30, 60, 120];
609
+
610
+ return React.createElement("div", { className: "webg-picker" },
611
+ React.createElement("div", { className: "webg-grid" },
612
+ React.createElement("button", {
613
+ className: "webg-card" + (sel.id ? "" : " webg-card--selected"),
614
+ type: "button",
615
+ onClick: onClear,
616
+ title: "关闭壁纸",
617
+ },
618
+ React.createElement("span", { className: "webg-card-close" }, "✕ 关闭"),
619
+ ),
620
+ playableList.length === 0
621
+ ? React.createElement("span", { className: "webg-hint" },
622
+ sel.inventory.total > 0
623
+ ? "已发现 " + sel.inventory.total + " 张壁纸,但没有可嵌入的 Video/Web 类型"
624
+ : "未发现壁纸 — 请先在 Wallpaper Engine 中订阅 Video/Web 类型")
625
+ : playableList.map((w) => ThumbCard(w, w.id === sel.id, () => applySelection(w.id))),
626
+ ),
627
+ React.createElement("div", { className: "webg-row" },
628
+ React.createElement("button", {
629
+ className: "webg-btn", type: "button",
630
+ onClick: onTogglePlay, disabled: !sel.url || sel.type !== "video",
631
+ }, sel.playing ? "暂停" : "播放"),
632
+ React.createElement("button", {
633
+ className: "webg-btn", type: "button",
634
+ onClick: onClear, disabled: !sel.id,
635
+ }, "关闭"),
636
+ React.createElement("button", {
637
+ className: "webg-btn", type: "button",
638
+ onClick: onRefresh, disabled: sel.loading,
639
+ }, sel.loading ? "刷新中…" : "刷新"),
640
+ ),
641
+ // ── Rotation groups ──
642
+ React.createElement("div", { className: "webg-row webg-playlist-row" },
643
+ React.createElement("span", { className: "webg-hint webg-label" }, "轮播列表"),
644
+ React.createElement("select", {
645
+ className: "webg-select webg-playlist-select",
646
+ value: sel.rotationGroupId,
647
+ onChange: onGroupChange,
648
+ disabled: groups.length === 0,
649
+ },
650
+ React.createElement("option", { value: "" }, groups.length ? "— 选择轮播列表 —" : "— 暂无轮播列表 —"),
651
+ ...groups.map((g) => React.createElement("option", {
652
+ key: g.id, value: g.id,
653
+ }, g.name + "(" + groupWallpapers(g).length + " 可播放 · " + g.interval + " 分钟)")),
654
+ ),
655
+ React.createElement("button", {
656
+ className: "webg-btn", type: "button", onClick: startCreateGroup,
657
+ }, "新建"),
658
+ React.createElement("button", {
659
+ className: "webg-btn", type: "button",
660
+ onClick: () => startEditGroup(sel.rotationGroupId),
661
+ disabled: !sel.rotationGroupId,
662
+ }, "编辑"),
663
+ React.createElement("button", {
664
+ className: "webg-btn", type: "button",
665
+ onClick: onDeleteGroup,
666
+ disabled: !sel.rotationGroupId,
667
+ }, "删除"),
668
+ ),
669
+ editing && React.createElement("div", { className: "webg-editor" },
670
+ React.createElement("div", { className: "webg-row" },
671
+ React.createElement("span", { className: "webg-hint webg-label" }, "名称"),
672
+ React.createElement("input", {
673
+ className: "webg-text", type: "text",
674
+ value: editing.name,
675
+ onInput: (e) => { editing.name = e.target.value; emit(); },
676
+ }),
677
+ ),
678
+ React.createElement("div", { className: "webg-row" },
679
+ React.createElement("span", { className: "webg-hint webg-label" }, "间隔"),
680
+ React.createElement("select", {
681
+ className: "webg-select",
682
+ value: String(editing.interval),
683
+ onChange: (e) => { editing.interval = clampNum(Number(e.target.value), 1, 1440, DEFAULTS.rotationInterval); emit(); },
684
+ },
685
+ ...INTERVALS.map((minutes) =>
686
+ React.createElement("option", { key: minutes, value: String(minutes) }, minutes + " 分钟"),
687
+ )),
688
+ React.createElement("span", { className: "webg-hint webg-label" }, "顺序"),
689
+ React.createElement("select", {
690
+ className: "webg-select webg-playlist-select",
691
+ value: editing.order,
692
+ onChange: (e) => { editing.order = e.target.value; emit(); },
693
+ },
694
+ React.createElement("option", { value: "sequence" }, "顺序"),
695
+ React.createElement("option", { value: "random" }, "随机"),
696
+ ),
697
+ ),
698
+ React.createElement("div", { className: "webg-editor-grid" },
699
+ playableInventory().length === 0
700
+ ? React.createElement("span", { className: "webg-hint" }, "没有可播放的 Video/Web 壁纸")
701
+ : playableInventory().map((w) => {
702
+ const checked = editing.wallpaperIds.indexOf(w.id) >= 0;
703
+ return React.createElement("button", {
704
+ key: w.id,
705
+ className: "webg-editor-card" + (checked ? " webg-editor-card--checked" : ""),
706
+ type: "button",
707
+ title: w.title,
708
+ onClick: () => {
709
+ const i = editing.wallpaperIds.indexOf(w.id);
710
+ if (i >= 0) editing.wallpaperIds.splice(i, 1);
711
+ else editing.wallpaperIds.push(w.id);
712
+ emit();
713
+ },
714
+ },
715
+ w.preview
716
+ ? React.createElement("img", {
717
+ src: w.preview, alt: w.title, loading: "lazy",
718
+ onError: (e) => { e.target.style.display = "none"; },
719
+ })
720
+ : React.createElement("span", { className: "webg-card-placeholder" }, "无预览"),
721
+ checked && React.createElement("span", { className: "webg-editor-check" }, "✓"),
722
+ );
723
+ }),
724
+ ),
725
+ React.createElement("div", { className: "webg-row" },
726
+ React.createElement("span", { className: "webg-hint" }, "已选 " + editing.wallpaperIds.length + " 个"),
727
+ sel.inventory.playlists.length > 0 && React.createElement("select", {
728
+ className: "webg-select webg-playlist-select",
729
+ value: "",
730
+ onChange: (e) => {
731
+ const p = sel.inventory.playlists.find((pl) => pl.id === e.target.value);
732
+ if (p) importPlaylistIntoDraft(p);
733
+ },
734
+ },
735
+ React.createElement("option", { value: "" }, "从 WE 播放列表导入…"),
736
+ ...sel.inventory.playlists.map((p) => React.createElement("option", {
737
+ key: p.id, value: p.id,
738
+ }, p.name + "(" + (p.portableCount || 0) + " 可播放)")),
739
+ ),
740
+ ),
741
+ React.createElement("div", { className: "webg-row" },
742
+ React.createElement("button", {
743
+ className: "webg-btn webg-btn--primary", type: "button",
744
+ onClick: saveEditingGroup,
745
+ }, "保存"),
746
+ React.createElement("button", {
747
+ className: "webg-btn", type: "button",
748
+ onClick: cancelEditGroup,
749
+ }, "取消"),
750
+ ),
751
+ ),
752
+ React.createElement("div", { className: "webg-row webg-rotation-row" },
753
+ React.createElement("label", { className: "webg-rotation-toggle" },
754
+ React.createElement("input", {
755
+ type: "checkbox",
756
+ checked: sel.rotationEnabled,
757
+ onChange: onToggleRotation,
758
+ disabled: !sel.rotationGroupId || playableCount < 2,
759
+ }),
760
+ "自动轮转",
761
+ ),
762
+ React.createElement("select", {
763
+ className: "webg-select webg-rotation-interval",
764
+ value: String(group ? group.interval : DEFAULTS.rotationInterval),
765
+ onChange: onGroupInterval,
766
+ disabled: !sel.rotationEnabled || !sel.rotationGroupId || playableCount < 2,
767
+ title: "当前列表的切换间隔",
768
+ },
769
+ ...INTERVALS.map((minutes) =>
770
+ React.createElement("option", { key: minutes, value: String(minutes) }, minutes + " 分钟"),
771
+ )),
772
+ !sel.rotationGroupId && React.createElement("span", { className: "webg-hint" }, "请先选择或新建一个轮播列表"),
773
+ sel.rotationGroupId && playableCount < 2 && React.createElement("span", { className: "webg-hint" }, "当前列表至少需要 2 个可播放壁纸"),
774
+ ),
775
+ sel.id && React.createElement(React.Fragment, null,
776
+ SliderRow("壁纸模糊", 0, 60, 1, sel.wallpaperBlur, onWallpaperBlur, sel.wallpaperBlur + "px"),
777
+ SliderRow("暗化", 0, 90, 5, Math.round(sel.scrim * 100), onScrim, Math.round(sel.scrim * 100) + "%"),
778
+ SliderRow("边框", 0, 90, 5, Math.round(sel.border * 100), onBorder, Math.round(sel.border * 100) + "%"),
779
+ SliderRow("玻璃", 0, 40, 1, sel.blur, onBlur, sel.blur + "px"),
780
+ ),
781
+ React.createElement("div", { className: "webg-row" },
782
+ React.createElement("span", { className: "webg-hint" },
783
+ (group
784
+ ? "列表「" + group.name + "」:" + group.wallpaperIds.length + " 项 · " + playableCount + " 可播放 · 每 " + group.interval + " 分钟 · " + (group.order === "random" ? "随机" : "顺序")
785
+ : playableList.length + " 个可播放壁纸") +
786
+ (sel.rotationEnabled ? " · 自动轮转中" : "")),
787
+ ),
788
+ );
789
+ }
790
+
791
+ // ── Styles ──────────────────────────────────────────────────────────────────
792
+ // Everything reads DSH design tokens (--dsw-*) so the UI blends into the
793
+ // shell and follows light/dark theme switches automatically.
794
+ const CSS = `
795
+ /* Wallpaper layers: fixed, sunk below the app frame, crossfading. */
796
+ .webg-layer {
797
+ position: fixed; inset: 0; z-index: -2; overflow: hidden;
798
+ pointer-events: none; opacity: 1;
799
+ transition: opacity ${CROSSFADE_MS}ms ease;
800
+ }
801
+ .webg-layer--enter { opacity: 0; }
802
+ .webg-layer--leave { opacity: 0; }
803
+ .webg-layer .webg-media {
804
+ width: 100%; height: 100%; object-fit: cover; display: block;
805
+ background: transparent; border: 0;
806
+ filter: blur(var(--webg-wallpaper-blur, 0px));
807
+ transform: scale(var(--webg-wallpaper-scale, 1));
808
+ transform-origin: center;
809
+ }
810
+
811
+ /* Scrim: above the wallpaper (-1 > -2), below the UI. */
812
+ .webg-scrim {
813
+ position: fixed; inset: 0; z-index: -1;
814
+ pointer-events: none;
815
+ background: var(--webg-scrim-color, rgba(0, 0, 0, 0.25));
816
+ }
817
+
818
+ /* While active: transparent frame + sidebar, higher border contrast. */
819
+ body[${ACTIVE_ATTR}] {
820
+ --dsw-alias-bg-base: transparent;
821
+ --dsw-specific-sidebar-fill: transparent;
822
+ --dsw-alias-border-l1: rgba(180, 180, 180, var(--webg-border-alpha, 0.35));
823
+ --dsw-alias-border-l2: rgba(180, 180, 180, var(--webg-border-alpha, 0.35));
824
+ --dsw-alias-border-l2-darkmode-thin: rgba(180, 180, 180, var(--webg-border-alpha, 0.35));
825
+ }
826
+
827
+ /* Light-scheme text ramp: grays were tuned against a near-white page and
828
+ lose contrast over busy wallpapers — push them darker while active. */
829
+ body[${ACTIVE_ATTR}]:not([data-ds-dark-theme]) {
830
+ --dsw-alias-label-primary: rgb(0, 0, 0);
831
+ --dsw-alias-label-primary-dimmed: rgb(10, 10, 12);
832
+ --dsw-alias-label-secondary: rgb(40, 42, 46);
833
+ --dsw-alias-label-tertiary: rgb(70, 73, 79);
834
+ --dsw-alias-label-caption: rgb(110, 114, 120);
835
+ --dsw-alias-label-dimmed: rgb(50, 52, 56);
836
+ }
837
+
838
+ /* iOS liquid glass: opaque conversation surfaces become frosted glass.
839
+ Transparency rides the design tokens the surfaces already read;
840
+ backdrop-filter still needs element selectors ([data-composer-card] is
841
+ authored in the shell source and survives rebuilds; bubbles fall back to
842
+ the module-CSS suffix convention and degrade gracefully). */
843
+ body[${ACTIVE_ATTR}] {
844
+ --dsw-specific-input-major: rgba(255, 255, 255, 0.15);
845
+ --dsw-specific-bubble: rgba(255, 255, 255, 0.12);
846
+ }
847
+ body[data-ds-dark-theme][${ACTIVE_ATTR}] {
848
+ --dsw-specific-input-major: rgba(255, 255, 255, 0.06);
849
+ --dsw-specific-bubble: rgba(255, 255, 255, 0.05);
850
+ }
851
+ body[${ACTIVE_ATTR}] [data-composer-card],
852
+ body[${ACTIVE_ATTR}] [class*="_bubble"] {
853
+ background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.05) 38%, rgba(255, 255, 255, 0.02));
854
+ -webkit-backdrop-filter: blur(var(--webg-blur, 16px)) saturate(var(--webg-saturate, 1.8)) brightness(var(--webg-glass-brightness, 1.04)) contrast(1.01);
855
+ backdrop-filter: blur(var(--webg-blur, 16px)) saturate(var(--webg-saturate, 1.8)) brightness(var(--webg-glass-brightness, 1.04)) contrast(1.01);
856
+ box-shadow:
857
+ inset 0 1px 0 rgba(255, 255, 255, var(--webg-glass-highlight, 0.32)),
858
+ inset 0 -1px 0 rgba(255, 255, 255, 0.08),
859
+ inset 0 0 0 0.5px rgba(255, 255, 255, 0.08),
860
+ 0 12px 40px rgba(0, 0, 0, var(--webg-glass-shadow, 0.12));
861
+ }
862
+
863
+ /* ── Picker chrome (token-driven, theme-aware) ── */
864
+ .webg-picker { display: flex; flex-direction: column; gap: 10px; }
865
+ .webg-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
866
+ .webg-hint { font-size: 0.8em; opacity: 0.7; color: var(--dsw-alias-label-tertiary, inherit); }
867
+ .webg-error {
868
+ font-size: 0.85em;
869
+ color: var(--dsw-alias-state-error-primary, #d44);
870
+ }
871
+ .webg-label { min-width: 28px; }
872
+ .webg-value { min-width: 42px; text-align: right; font-variant-numeric: tabular-nums; }
873
+
874
+ .webg-btn {
875
+ cursor: pointer;
876
+ padding: 4px 12px;
877
+ border-radius: 6px;
878
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
879
+ background: var(--dsw-alias-bg-layer-1, rgba(128, 128, 128, 0.12));
880
+ color: var(--dsw-alias-label-primary, inherit);
881
+ font-size: 0.85em;
882
+ transition: background-color 120ms ease, border-color 120ms ease;
883
+ }
884
+ .webg-btn:hover:not(:disabled) {
885
+ background: var(--dsw-alias-bg-layer-2, rgba(128, 128, 128, 0.2));
886
+ border-color: var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.5));
887
+ }
888
+ .webg-btn:disabled { opacity: 0.45; cursor: not-allowed; }
889
+ .webg-btn--primary {
890
+ background: var(--dsw-alias-brand-primary, #3964fe);
891
+ border-color: transparent;
892
+ color: #fff;
893
+ }
894
+ .webg-btn--primary:hover:not(:disabled) {
895
+ background: var(--dsw-alias-brand-primary-hover, #4f74ff);
896
+ }
897
+
898
+ .webg-select, .webg-text {
899
+ padding: 4px 8px;
900
+ border-radius: 6px;
901
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
902
+ background: var(--dsw-alias-bg-layer-1, rgba(128, 128, 128, 0.12));
903
+ color: var(--dsw-alias-label-primary, inherit);
904
+ font-size: 0.85em;
905
+ }
906
+ .webg-text { flex: 1; min-width: 0; }
907
+ .webg-playlist-select { flex: 1; min-width: 0; }
908
+ .webg-rotation-interval { margin-left: auto; }
909
+ .webg-rotation-toggle { display: inline-flex; align-items: center; gap: 6px; accent-color: var(--dsw-alias-brand-primary, #3964fe); }
910
+
911
+ .webg-slider { flex: 1; accent-color: var(--dsw-alias-brand-primary, #3964fe); }
912
+ .webg-slider-row { display: flex; align-items: center; gap: 8px; }
913
+
914
+ /* Wallpaper thumbnail grid. */
915
+ .webg-grid {
916
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
917
+ gap: 8px; max-height: 300px; overflow-y: auto; padding: 2px;
918
+ }
919
+ .webg-card {
920
+ position: relative; width: 100%; padding: 0; cursor: pointer;
921
+ aspect-ratio: 16 / 9; display: block; overflow: hidden;
922
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
923
+ border-radius: 8px;
924
+ background: var(--dsw-alias-bg-layer-1, rgba(128, 128, 128, 0.15));
925
+ transition: transform 140ms ease, box-shadow 140ms ease, outline-color 140ms ease;
926
+ }
927
+ .webg-card:hover {
928
+ transform: translateY(-2px);
929
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
930
+ }
931
+ .webg-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
932
+ .webg-card--selected {
933
+ outline: 2px solid var(--dsw-alias-brand-primary, #3964fe);
934
+ outline-offset: -2px;
935
+ }
936
+ .webg-card-close {
937
+ position: absolute; inset: 0;
938
+ display: flex; align-items: center; justify-content: center;
939
+ font-size: 0.8em; color: var(--dsw-alias-label-secondary, #888);
940
+ }
941
+ .webg-card-type {
942
+ position: absolute; top: 4px; right: 4px;
943
+ padding: 1px 6px; border-radius: 4px;
944
+ font-size: 0.65em; line-height: 1.5;
945
+ color: #fff; background: rgba(0, 0, 0, 0.5);
946
+ }
947
+ .webg-card-title {
948
+ position: absolute; left: 0; right: 0; bottom: 0; padding: 3px 6px;
949
+ font-size: 0.7em; line-height: 1.2; color: #fff;
950
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
951
+ text-overflow: ellipsis; white-space: nowrap; overflow: hidden;
952
+ text-align: left;
953
+ }
954
+ .webg-card-placeholder {
955
+ position: absolute; inset: 0;
956
+ display: flex; align-items: center; justify-content: center;
957
+ font-size: 0.72em; opacity: 0.55;
958
+ }
959
+
960
+ /* Rotation group editor. */
961
+ .webg-editor {
962
+ display: flex; flex-direction: column; gap: 8px;
963
+ padding: 10px;
964
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
965
+ border-radius: 10px;
966
+ background: var(--dsw-alias-bg-layer-1, rgba(128, 128, 128, 0.08));
967
+ }
968
+ .webg-editor-grid {
969
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
970
+ gap: 6px; max-height: 220px; overflow-y: auto; padding: 2px;
971
+ }
972
+ .webg-editor-card {
973
+ position: relative; width: 100%; padding: 0; cursor: pointer;
974
+ aspect-ratio: 16 / 10; display: block; overflow: hidden;
975
+ border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
976
+ border-radius: 6px;
977
+ background: var(--dsw-alias-bg-layer-1, rgba(128, 128, 128, 0.15));
978
+ transition: transform 140ms ease;
979
+ }
980
+ .webg-editor-card:hover { transform: translateY(-1px); }
981
+ .webg-editor-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
982
+ .webg-editor-card--checked {
983
+ outline: 2px solid var(--dsw-alias-brand-primary, #3964fe);
984
+ outline-offset: -2px;
985
+ }
986
+ .webg-editor-check {
987
+ position: absolute; top: 4px; left: 4px; width: 18px; height: 18px;
988
+ border-radius: 4px; background: var(--dsw-alias-brand-primary, #3964fe); color: #fff;
989
+ font-size: 12px; line-height: 18px; text-align: center;
990
+ }
991
+ `;
992
+
993
+ // ── Plugin exports ──────────────────────────────────────────────────────────
994
+ const inject = ["slots"];
995
+
996
+ function apply(ctx) {
997
+ // 1. Styles: owned by the fiber — injected on apply, REMOVED on dispose
998
+ // (a module-level injection would leak styling past plugin stop).
999
+ if (ctx.effect && typeof document !== "undefined") {
1000
+ ctx.effect(() => {
1001
+ let tag = document.querySelector("style[data-plugin-css=" + JSON.stringify(STYLE_TAG_ID) + "]");
1002
+ if (!tag) {
1003
+ tag = document.createElement("style");
1004
+ tag.dataset.plugin = "wallpaper-engine-dsh";
1005
+ tag.dataset.pluginCss = STYLE_TAG_ID;
1006
+ tag.textContent = CSS;
1007
+ document.head.appendChild(tag);
1008
+ }
1009
+ return () => { tag.remove(); };
1010
+ });
1011
+ }
1012
+
1013
+ // 2. Behind-body layers + effect knobs, synced to the selection store.
1014
+ if (ctx.effect && typeof document !== "undefined") {
1015
+ ctx.effect(() => {
1016
+ const unsubLayers = subscribe(syncLayers);
1017
+ const unsubEffects = subscribe(applyEffects);
1018
+ syncLayers();
1019
+ applyEffects();
1020
+ return () => {
1021
+ unsubLayers();
1022
+ unsubEffects();
1023
+ clearRotationTimer();
1024
+ // Remove EVERY layer instance (a crossfade-leaving layer has no id).
1025
+ const doomed = document.querySelectorAll(".webg-layer");
1026
+ for (const node of [...doomed]) node.remove();
1027
+ const scrim = document.getElementById(SCRIM_ID);
1028
+ if (scrim) scrim.remove();
1029
+ clearEffects();
1030
+ document.body.removeAttribute(ACTIVE_ATTR);
1031
+ };
1032
+ });
1033
+ }
1034
+
1035
+ // 3. Settings row (this slot is NOT the overlay; safe to register into).
1036
+ if (ctx.slots) {
1037
+ ctx.slots.inject("settings.general.item", () =>
1038
+ ctx.slots.register(
1039
+ { name: "settings.general.item", id: "we-background", order: 500, label: "壁纸背景 (Wallpaper Engine)" },
1040
+ () => React.createElement(WallpaperPicker),
1041
+ ),
1042
+ );
1043
+ }
1044
+
1045
+ loadInventory(false);
1046
+ }
1047
+
1048
+ exports.apply = apply;
1049
+ exports.inject = inject;
1050
+ return module.exports;
1051
+ },
1052
+ });