wallpaper-engine-dsh 0.1.0 → 0.1.2

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 CHANGED
@@ -14,6 +14,10 @@ window.__ModuleLoader__.load({
14
14
  const SCRIM_ID = "wallpaper-engine-dsh-scrim";
15
15
  const STYLE_TAG_ID = "wallpaper-engine-dsh/styles";
16
16
  const CROSSFADE_MS = 520;
17
+ /** Battery at or below this level (and discharging) auto-pauses playback. */
18
+ const BATTERY_SAVER_LEVEL = 0.2;
19
+ /** Below this FPS the picker shows a performance hint. */
20
+ const LOW_FPS = 24;
17
21
 
18
22
  // Respect the OS "reduce motion" preference: start paused rather than
19
23
  // autoplaying a video loop at someone who asked for less motion.
@@ -21,6 +25,160 @@ window.__ModuleLoader__.load({
21
25
  typeof window.matchMedia === "function" &&
22
26
  window.matchMedia("(prefers-reduced-motion: reduce)").matches;
23
27
 
28
+ // ── i18n ─────────────────────────────────────────────────────────────────────
29
+ const STRINGS = {
30
+ zh: {
31
+ settingsLabel: "壁纸背景 (Wallpaper Engine)",
32
+ scanning: "正在扫描 Wallpaper Engine…",
33
+ connError: "未能连接 Wallpaper Engine:",
34
+ retry: "重试",
35
+ refreshing: "刷新中…",
36
+ refresh: "刷新",
37
+ pause: "暂停",
38
+ play: "播放",
39
+ close: "关闭",
40
+ searchPlaceholder: "搜索壁纸(标题或 ID)…",
41
+ matched: (m, n) => "匹配 " + m + " / " + n + " 张",
42
+ emptyNoInstall: "未检测到 Wallpaper Engine — 请确认已通过 Steam 安装",
43
+ emptyNoUsable: (n) => "已发现 " + n + " 张壁纸,但都缺少可播放内容或预览图",
44
+ emptyNoProjects: "未在任何项目目录中发现壁纸(默认项目/我的项目/创意工坊)",
45
+ noMatch: (q) => "没有匹配「" + q + "」的壁纸",
46
+ closeCard: "✕ 关闭",
47
+ closeTitle: "关闭壁纸",
48
+ noPreview: "无预览",
49
+ badgeVideo: "视频",
50
+ badgeWeb: "网页",
51
+ badgeStill: "静态",
52
+ rotationList: "轮播列表",
53
+ selectList: "— 选择轮播列表 —",
54
+ noLists: "— 暂无轮播列表 —",
55
+ groupOption: (name, count, interval) => name + "(" + count + " 可用 · " + interval + " 分钟)",
56
+ newList: "新建",
57
+ editList: "编辑",
58
+ deleteList: "删除",
59
+ name: "名称",
60
+ interval: "间隔",
61
+ order: "顺序",
62
+ sequence: "顺序",
63
+ random: "随机",
64
+ minutes: (m) => m + " 分钟",
65
+ filterPlaceholder: "搜索以筛选…",
66
+ noUsable: "没有可用壁纸",
67
+ selectedCount: (n) => "已选 " + n + " 个",
68
+ importPlaylist: "从 WE 播放列表导入…",
69
+ playlistOption: (name, count) => name + "(" + count + " 可播放)",
70
+ save: "保存",
71
+ cancel: "取消",
72
+ autoRotate: "自动轮转",
73
+ intervalTitle: "当前列表的切换间隔",
74
+ needList: "请先选择或新建一个轮播列表",
75
+ needTwo: "当前列表至少需要 2 张可用壁纸",
76
+ wallpaperBlur: "壁纸模糊",
77
+ scrim: "暗化",
78
+ border: "边框",
79
+ glass: "玻璃",
80
+ pauseOnHidden: "页面隐藏时暂停",
81
+ pauseOnBattery: "低电量时暂停",
82
+ fpsTitle: "页面当前渲染帧率",
83
+ lowFpsHint: "帧率偏低,可尝试暂停视频、增大壁纸模糊或换用静态壁纸",
84
+ language: "语言",
85
+ langAuto: "自动",
86
+ statusGroup: (name, total, usable, interval, order) =>
87
+ "列表「" + name + "」:" + total + " 项 · " + usable + " 可用 · 每 " + interval + " 分钟 · " + order,
88
+ statusUsable: (n) => n + " 张可用壁纸",
89
+ autoRotating: "自动轮转中",
90
+ autoPaused: "已自动暂停",
91
+ confirmDelete: (name) => "删除轮播列表「" + name + "」?",
92
+ defaultGroupName: (n) => "轮播列表 " + n,
93
+ defaultGroupBase: "轮播列表",
94
+ },
95
+ en: {
96
+ settingsLabel: "Wallpaper Engine",
97
+ scanning: "Scanning Wallpaper Engine…",
98
+ connError: "Could not reach Wallpaper Engine: ",
99
+ retry: "Retry",
100
+ refreshing: "Refreshing…",
101
+ refresh: "Refresh",
102
+ pause: "Pause",
103
+ play: "Play",
104
+ close: "Close",
105
+ searchPlaceholder: "Search wallpapers (title or ID)…",
106
+ matched: (m, n) => m + " / " + n + " matched",
107
+ emptyNoInstall: "Wallpaper Engine not found — make sure it is installed via Steam",
108
+ emptyNoUsable: (n) => "Found " + n + " wallpapers, but none have playable media or a preview image",
109
+ emptyNoProjects: "No wallpapers found in any project folder (defaults / my projects / workshop)",
110
+ noMatch: (q) => "No wallpapers match \"" + q + "\"",
111
+ closeCard: "✕ Close",
112
+ closeTitle: "Clear wallpaper",
113
+ noPreview: "No preview",
114
+ badgeVideo: "Video",
115
+ badgeWeb: "Web",
116
+ badgeStill: "Still",
117
+ rotationList: "Rotation list",
118
+ selectList: "— Select a list —",
119
+ noLists: "— No lists yet —",
120
+ groupOption: (name, count, interval) => name + "(" + count + " usable · " + interval + " min)",
121
+ newList: "New",
122
+ editList: "Edit",
123
+ deleteList: "Delete",
124
+ name: "Name",
125
+ interval: "Interval",
126
+ order: "Order",
127
+ sequence: "Sequence",
128
+ random: "Random",
129
+ minutes: (m) => m + " min",
130
+ filterPlaceholder: "Filter…",
131
+ noUsable: "No usable wallpapers",
132
+ selectedCount: (n) => n + " selected",
133
+ importPlaylist: "Import from WE playlist…",
134
+ playlistOption: (name, count) => name + "(" + count + " playable)",
135
+ save: "Save",
136
+ cancel: "Cancel",
137
+ autoRotate: "Auto-rotate",
138
+ intervalTitle: "Switch interval for the current list",
139
+ needList: "Create or select a rotation list first",
140
+ needTwo: "The list needs at least 2 usable wallpapers",
141
+ wallpaperBlur: "Wallpaper blur",
142
+ scrim: "Scrim",
143
+ border: "Borders",
144
+ glass: "Glass",
145
+ pauseOnHidden: "Pause when tab hidden",
146
+ pauseOnBattery: "Pause on low battery",
147
+ fpsTitle: "Current page frame rate",
148
+ lowFpsHint: "Low frame rate — try pausing the video, raising wallpaper blur, or using a still wallpaper",
149
+ language: "Language",
150
+ langAuto: "Auto",
151
+ statusGroup: (name, total, usable, interval, order) =>
152
+ "List \"" + name + "\": " + total + " items · " + usable + " usable · every " + interval + " min · " + order,
153
+ statusUsable: (n) => n + " usable wallpapers",
154
+ autoRotating: "Auto-rotating",
155
+ autoPaused: "Auto-paused",
156
+ confirmDelete: (name) => "Delete rotation list \"" + name + "\"?",
157
+ defaultGroupName: (n) => "Rotation list " + n,
158
+ defaultGroupBase: "Rotation list",
159
+ },
160
+ };
161
+
162
+ /** Language the DSH shell reports via its locale service (null = unknown). */
163
+ let shellLang = null;
164
+
165
+ function browserLang() {
166
+ if (typeof navigator !== "undefined" && navigator.language) {
167
+ return String(navigator.language).toLowerCase().startsWith("zh") ? "zh" : "en";
168
+ }
169
+ return null;
170
+ }
171
+
172
+ function resolveLang() {
173
+ if (selection.lang === "zh" || selection.lang === "en") return selection.lang;
174
+ return shellLang || browserLang() || "zh";
175
+ }
176
+
177
+ /** Current string table. */
178
+ function S() {
179
+ return STRINGS[resolveLang()];
180
+ }
181
+
24
182
  // ── Defaults ─────────────────────────────────────────────────────────────────
25
183
  const DEFAULTS = {
26
184
  scrim: 0.25,
@@ -32,6 +190,9 @@ window.__ModuleLoader__.load({
32
190
  rotationGroupId: "",
33
191
  rotationGroups: [],
34
192
  rotationSeeded: false,
193
+ pauseOnHidden: true,
194
+ pauseOnBattery: true,
195
+ lang: "auto",
35
196
  };
36
197
 
37
198
  // ── Persisted selection ─────────────────────────────────────────────────────
@@ -39,11 +200,7 @@ window.__ModuleLoader__.load({
39
200
  return typeof v === "number" && isFinite(v) && v >= lo && v <= hi ? v : fallback;
40
201
  }
41
202
 
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) {
203
+ function readRotationGroups(raw, baseName) {
47
204
  if (!Array.isArray(raw)) return [];
48
205
  const groups = [];
49
206
  for (const g of raw) {
@@ -52,7 +209,7 @@ window.__ModuleLoader__.load({
52
209
  if (!id) continue;
53
210
  groups.push({
54
211
  id,
55
- name: typeof g.name === "string" && g.name.trim() ? g.name.trim() : "轮播列表",
212
+ name: typeof g.name === "string" && g.name.trim() ? g.name.trim() : baseName,
56
213
  interval: clampNum(g.interval, 1, 1440, DEFAULTS.rotationInterval),
57
214
  order: g.order === "random" ? "random" : "sequence",
58
215
  wallpaperIds: Array.isArray(g.wallpaperIds)
@@ -76,8 +233,11 @@ window.__ModuleLoader__.load({
76
233
  wallpaperBlur: clampNum(o.wallpaperBlur, 0, 60, DEFAULTS.wallpaperBlur),
77
234
  rotationEnabled: o.rotationEnabled === true,
78
235
  rotationGroupId: typeof o.rotationGroupId === "string" ? o.rotationGroupId : "",
79
- rotationGroups: readRotationGroups(o.rotationGroups),
236
+ rotationGroups: readRotationGroups(o.rotationGroups, STRINGS.zh.defaultGroupBase),
80
237
  rotationSeeded: o.rotationSeeded === true,
238
+ pauseOnHidden: o.pauseOnHidden !== false,
239
+ pauseOnBattery: o.pauseOnBattery !== false,
240
+ lang: o.lang === "zh" || o.lang === "en" ? o.lang : "auto",
81
241
  };
82
242
  } catch {
83
243
  return { id: "", ...DEFAULTS };
@@ -92,6 +252,10 @@ window.__ModuleLoader__.load({
92
252
  playing: !REDUCED_MOTION,
93
253
  loading: false,
94
254
  rotationTimer: null,
255
+ // Reasons playback is auto-paused (hidden tab / low battery). Distinct from
256
+ // the user's play/pause choice: effective playback = playing && no reasons.
257
+ autoPauseReasons: new Set(),
258
+ fps: null, // last sampled frames-per-second, null when unsampled
95
259
  // Live draft of the group being created/edited (null when editor closed).
96
260
  editing: null,
97
261
  inventory: { installDir: null, wallpapers: [], total: 0, portableCount: 0, playlists: [], error: null },
@@ -120,10 +284,32 @@ window.__ModuleLoader__.load({
120
284
  rotationGroupId: selection.rotationGroupId,
121
285
  rotationGroups: selection.rotationGroups,
122
286
  rotationSeeded: selection.rotationSeeded,
287
+ pauseOnHidden: selection.pauseOnHidden,
288
+ pauseOnBattery: selection.pauseOnBattery,
289
+ lang: selection.lang,
123
290
  }));
124
291
  } catch { /* storage full / blocked → session-only state, not fatal */ }
125
292
  }
126
293
 
294
+ // ── Wallpaper predicates ─────────────────────────────────────────────────────
295
+ // playable: real media (video/web) renders live.
296
+ // static: scene/application with a preview image renders as a still.
297
+ function isPlayable(w) {
298
+ return Boolean(w && w.playable && (w.type === "video" || w.type === "web"));
299
+ }
300
+
301
+ function isStatic(w) {
302
+ return Boolean(w && !isPlayable(w) && typeof w.preview === "string" && w.preview);
303
+ }
304
+
305
+ function isSelectable(w) {
306
+ return isPlayable(w) || isStatic(w);
307
+ }
308
+
309
+ function selectableInventory() {
310
+ return selection.inventory.wallpapers.filter(isSelectable);
311
+ }
312
+
127
313
  // ── Inventory ────────────────────────────────────────────────────────────────
128
314
  async function loadInventory(forceRefresh) {
129
315
  selection.loading = true;
@@ -173,8 +359,8 @@ window.__ModuleLoader__.load({
173
359
  persistSelection();
174
360
  }
175
361
 
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))) {
362
+ // Drop a selection whose wallpaper vanished or became unusable.
363
+ if (selection.id && !selection.inventory.wallpapers.some((w) => w.id === selection.id && isSelectable(w))) {
178
364
  selection.id = "";
179
365
  persistSelection();
180
366
  }
@@ -190,14 +376,6 @@ window.__ModuleLoader__.load({
190
376
  emit();
191
377
  }
192
378
 
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
379
  // ── Rotation groups ─────────────────────────────────────────────────────────
202
380
  function activeRotationGroup() {
203
381
  return selection.rotationGroups.find((g) => g.id === selection.rotationGroupId) || null;
@@ -206,7 +384,7 @@ window.__ModuleLoader__.load({
206
384
  function groupWallpapers(group) {
207
385
  if (!group || !Array.isArray(group.wallpaperIds)) return [];
208
386
  const byId = new Map(selection.inventory.wallpapers.map((w) => [w.id, w]));
209
- return group.wallpaperIds.map((id) => byId.get(id)).filter(isPlayable);
387
+ return group.wallpaperIds.map((id) => byId.get(id)).filter(isSelectable);
210
388
  }
211
389
 
212
390
  function rotationCandidates() {
@@ -220,14 +398,14 @@ window.__ModuleLoader__.load({
220
398
  function seedGroupsFromPlaylists() {
221
399
  const source = selection.inventory.playlists.find((p) => (p.portableCount || 0) >= 2);
222
400
  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
- }) : [];
401
+ const byId = new Map(selection.inventory.wallpapers.map((w) => [w.id, w]));
402
+ const ids = Array.isArray(source.wallpaperIds)
403
+ ? source.wallpaperIds.filter((id) => isSelectable(byId.get(id)))
404
+ : [];
227
405
  if (ids.length < 2) return false;
228
406
  selection.rotationGroups.push({
229
407
  id: nextGroupId(),
230
- name: typeof source.name === "string" && source.name.trim() ? source.name.trim() : "轮播列表",
408
+ name: typeof source.name === "string" && source.name.trim() ? source.name.trim() : S().defaultGroupBase,
231
409
  interval: DEFAULTS.rotationInterval,
232
410
  order: source.order === "random" ? "random" : "sequence",
233
411
  wallpaperIds: ids,
@@ -286,7 +464,7 @@ window.__ModuleLoader__.load({
286
464
  function startCreateGroup() {
287
465
  selection.editing = {
288
466
  id: nextGroupId(),
289
- name: "轮播列表 " + (selection.rotationGroups.length + 1),
467
+ name: S().defaultGroupName(selection.rotationGroups.length + 1),
290
468
  interval: DEFAULTS.rotationInterval,
291
469
  order: "sequence",
292
470
  wallpaperIds: [],
@@ -300,7 +478,7 @@ window.__ModuleLoader__.load({
300
478
  const idx = selection.rotationGroups.findIndex((g) => g.id === draft.id);
301
479
  const cleaned = {
302
480
  id: draft.id,
303
- name: typeof draft.name === "string" && draft.name.trim() ? draft.name.trim() : "轮播列表",
481
+ name: typeof draft.name === "string" && draft.name.trim() ? draft.name.trim() : S().defaultGroupBase,
304
482
  interval: clampNum(draft.interval, 1, 1440, DEFAULTS.rotationInterval),
305
483
  order: draft.order === "random" ? "random" : "sequence",
306
484
  wallpaperIds: Array.isArray(draft.wallpaperIds)
@@ -346,8 +524,8 @@ window.__ModuleLoader__.load({
346
524
 
347
525
  function importPlaylistIntoDraft(playlist) {
348
526
  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));
527
+ const usable = new Set(selectableInventory().map((w) => w.id));
528
+ selection.editing.wallpaperIds = playlist.wallpaperIds.filter((id) => usable.has(id));
351
529
  emit();
352
530
  }
353
531
 
@@ -357,32 +535,52 @@ window.__ModuleLoader__.load({
357
535
  const w = selection.id
358
536
  ? selection.inventory.wallpapers.find((x) => x.id === selection.id)
359
537
  : null;
360
- if (!w || !isPlayable(w)) {
538
+ if (isPlayable(w)) {
539
+ selection.url = w.media;
540
+ selection.type = w.type; // "video" | "web"
541
+ } else if (w && isStatic(w)) {
542
+ selection.url = w.preview;
543
+ selection.type = "image";
544
+ } else {
361
545
  selection.url = null;
362
546
  selection.type = null;
363
- } else {
364
- selection.url = w.media;
365
- selection.type = w.type;
366
547
  }
367
548
  syncRotationTimer();
368
549
  emit();
369
550
  }
370
551
 
552
+ // ── Auto-pause (resource monitor) ───────────────────────────────────────────
553
+ function setAutoPause(reason, on) {
554
+ const had = selection.autoPauseReasons.size > 0;
555
+ if (on) selection.autoPauseReasons.add(reason);
556
+ else selection.autoPauseReasons.delete(reason);
557
+ if ((selection.autoPauseReasons.size > 0) !== had) emit();
558
+ }
559
+
560
+ function effectivePlaying() {
561
+ return selection.playing && selection.autoPauseReasons.size === 0;
562
+ }
563
+
371
564
  // ── Behind-body layer: wallpaper + scrim, with crossfade ────────────────────
565
+ // Set by a media error handler to force a remount with fresh URLs; cleared
566
+ // once the remount happens.
567
+ let forceRemount = false;
568
+ let lastMediaRecoveryAt = 0;
569
+
372
570
  function buildMedia(sel) {
373
- const media = sel.type === "video"
374
- ? document.createElement("video")
375
- : document.createElement("iframe");
571
+ let media;
376
572
  if (sel.type === "video") {
573
+ media = document.createElement("video");
377
574
  media.src = sel.url;
378
- media.autoplay = !REDUCED_MOTION;
575
+ media.autoplay = effectivePlaying();
379
576
  media.loop = true;
380
577
  media.muted = true;
381
578
  media.setAttribute("playsinline", "");
382
579
  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.
580
+ } else if (sel.type === "web") {
581
+ // Sandboxed opaque origin: wallpaper JS runs but cannot touch DSH
582
+ // storage or APIs.
583
+ media = document.createElement("iframe");
386
584
  media.src = sel.url;
387
585
  media.setAttribute("sandbox", "allow-scripts");
388
586
  media.setAttribute("referrerpolicy", "no-referrer");
@@ -390,24 +588,45 @@ window.__ModuleLoader__.load({
390
588
  media.setAttribute("scrolling", "no");
391
589
  media.setAttribute("title", "Wallpaper Engine web wallpaper");
392
590
  media.className = "webg-media webg-iframe";
591
+ } else {
592
+ // Static wallpaper: the scene/application preview as a still image.
593
+ media = document.createElement("img");
594
+ media.src = sel.url;
595
+ media.alt = "";
596
+ media.draggable = false;
597
+ media.setAttribute("decoding", "async");
598
+ media.className = "webg-media";
393
599
  }
600
+ // Self-heal: if the media fails to load (e.g. its token died on an
601
+ // inventory rebuild), refetch fresh URLs and remount — throttled to once
602
+ // per 10 s so a permanently-broken wallpaper cannot loop.
603
+ media.addEventListener("error", () => {
604
+ const now = Date.now();
605
+ if (now - lastMediaRecoveryAt < 10000) return;
606
+ lastMediaRecoveryAt = now;
607
+ forceRemount = true;
608
+ loadInventory(true);
609
+ });
394
610
  return media;
395
611
  }
396
612
 
397
613
  function syncLayers() {
398
614
  const existing = document.getElementById(LAYER_ID);
615
+ const hasTimeout = typeof window !== "undefined" && typeof window.setTimeout === "function";
399
616
 
400
617
  if (selection.url) {
401
- const wantKey = selection.type + "\u0000" + selection.url;
618
+ // Key by wallpaper id, NOT by media URL: inventory rebuilds re-mint
619
+ // tokens, so a URL-keyed comparison would reload the playing video on
620
+ // every refresh. A genuinely dead URL is recovered via forceRemount.
621
+ const wantKey = selection.type + "|" + selection.id;
402
622
  const gotKey = existing && existing.dataset.webgKey;
403
623
 
404
- if (existing && gotKey === wantKey) {
624
+ if (existing && gotKey === wantKey && !forceRemount) {
405
625
  // Same wallpaper — only play/pause may have changed.
406
626
  } 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.
627
+ forceRemount = false;
628
+ // Crossfade: the new layer fades in over the old one; the old layer
629
+ // surrenders the id FIRST so getElementById resolves to the new one.
411
630
  if (existing) existing.removeAttribute("id");
412
631
  const node = document.createElement("div");
413
632
  node.id = LAYER_ID;
@@ -415,38 +634,43 @@ window.__ModuleLoader__.load({
415
634
  node.dataset.webgKey = wantKey;
416
635
  node.appendChild(buildMedia(selection));
417
636
  document.body.appendChild(node);
418
- // Force a style flush so the enter transition actually runs.
419
- void node.offsetHeight;
637
+ void node.offsetHeight; // style flush so the enter transition runs
420
638
  node.classList.remove("webg-layer--enter");
421
639
  if (existing) {
422
640
  existing.classList.add("webg-layer--leave");
423
641
  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
- }
642
+ if (hasTimeout) window.setTimeout(() => stale.remove(), CROSSFADE_MS + 120);
643
+ else stale.remove();
429
644
  }
430
645
  }
431
646
  const node = document.getElementById(LAYER_ID);
432
647
  const video = node && node.querySelector("video");
433
648
  if (video) {
434
- if (selection.playing) { try { const p = video.play(); if (p && p.catch) p.catch(() => {}); } catch {} }
649
+ if (effectivePlaying()) { try { const p = video.play(); if (p && p.catch) p.catch(() => {}); } catch {} }
435
650
  else video.pause();
436
651
  }
437
652
  } else if (existing) {
653
+ // Clearing: fade out, then tear down scrim + frame transparency when the
654
+ // fade completes — unless a new wallpaper was applied meanwhile.
655
+ existing.removeAttribute("id");
438
656
  existing.classList.add("webg-layer--leave");
439
657
  const stale = existing;
440
- if (typeof window !== "undefined" && typeof window.setTimeout === "function") {
441
- window.setTimeout(() => stale.remove(), CROSSFADE_MS + 120);
442
- } else {
658
+ const finish = () => {
443
659
  stale.remove();
444
- }
660
+ if (!document.getElementById(LAYER_ID)) {
661
+ const s = document.getElementById(SCRIM_ID);
662
+ if (s) s.remove();
663
+ document.body.removeAttribute(ACTIVE_ATTR);
664
+ }
665
+ };
666
+ if (hasTimeout) window.setTimeout(finish, CROSSFADE_MS + 120);
667
+ else finish();
445
668
  }
446
669
 
447
- // Scrim: always present while a wallpaper is active.
448
- const scrim = document.getElementById(SCRIM_ID);
670
+ // Scrim: present while a wallpaper is active. On clear it outlives the
671
+ // layer until the fade-out finishes, so the veil fades WITH the wallpaper.
449
672
  if (selection.url) {
673
+ const scrim = document.getElementById(SCRIM_ID);
450
674
  if (!scrim) {
451
675
  const s = document.createElement("div");
452
676
  s.id = SCRIM_ID;
@@ -454,9 +678,6 @@ window.__ModuleLoader__.load({
454
678
  document.body.appendChild(s);
455
679
  }
456
680
  document.body.setAttribute(ACTIVE_ATTR, "on");
457
- } else {
458
- if (scrim) scrim.remove();
459
- document.body.removeAttribute(ACTIVE_ATTR);
460
681
  }
461
682
  }
462
683
 
@@ -512,30 +733,50 @@ window.__ModuleLoader__.load({
512
733
  );
513
734
  }
514
735
 
515
- function ThumbCard(w, selected, onClick) {
736
+ function typeBadge(w, t) {
737
+ if (w.type === "video") return t.badgeVideo;
738
+ if (w.type === "web") return t.badgeWeb;
739
+ return t.badgeStill;
740
+ }
741
+
742
+ function matchesQuery(w, q) {
743
+ if (!q) return true;
744
+ const needle = q.toLowerCase();
745
+ return (w.title || "").toLowerCase().includes(needle) || (w.id || "").toLowerCase().includes(needle);
746
+ }
747
+
748
+ function ThumbCard(w, selected, onClick, t) {
516
749
  return React.createElement("button", {
517
750
  key: w.id,
518
751
  className: "webg-card" + (selected ? " webg-card--selected" : ""),
519
752
  type: "button",
520
753
  onClick: onClick,
521
- title: w.title,
754
+ title: w.title + "(" + typeBadge(w, t) + ")",
522
755
  },
523
756
  w.preview
524
757
  ? React.createElement("img", {
525
- src: w.preview, alt: w.title, loading: "lazy",
758
+ src: w.preview, alt: w.title, loading: "lazy", decoding: "async",
526
759
  onError: (e) => { e.target.style.display = "none"; },
527
760
  })
528
- : React.createElement("span", { className: "webg-card-placeholder" }, "无预览"),
529
- React.createElement("span", { className: "webg-card-type" }, w.type === "video" ? "视频" : "网页"),
761
+ : React.createElement("span", { className: "webg-card-placeholder" }, t.noPreview),
762
+ React.createElement("span", { className: "webg-card-type" }, typeBadge(w, t)),
530
763
  React.createElement("span", { className: "webg-card-title" }, w.title),
531
764
  );
532
765
  }
533
766
 
534
767
  function WallpaperPicker() {
535
768
  const sel = useStore();
769
+ const [query, setQuery] = React.useState("");
770
+ const [editorQuery, setEditorQuery] = React.useState("");
771
+ const t = S();
536
772
  const onTogglePlay = () => { selection.playing = !selection.playing; emit(); };
537
773
  const onClear = () => applySelection("");
538
774
  const onRefresh = () => loadInventory(true);
775
+ const onLangChange = (e) => {
776
+ selection.lang = e.target.value;
777
+ persistSelection();
778
+ emit();
779
+ };
539
780
  const onGroupChange = (e) => {
540
781
  selection.rotationGroupId = e.target.value;
541
782
  if (selection.rotationEnabled) {
@@ -576,10 +817,22 @@ window.__ModuleLoader__.load({
576
817
  const group = activeRotationGroup();
577
818
  if (!group) return;
578
819
  if (typeof window !== "undefined" && typeof window.confirm === "function") {
579
- if (!window.confirm("删除轮播列表「" + group.name + "」?")) return;
820
+ if (!window.confirm(t.confirmDelete(group.name))) return;
580
821
  }
581
822
  deleteGroup(group.id);
582
823
  };
824
+ const onTogglePauseOnHidden = () => {
825
+ selection.pauseOnHidden = !selection.pauseOnHidden;
826
+ if (!selection.pauseOnHidden) setAutoPause("hidden", false);
827
+ persistSelection();
828
+ emit();
829
+ };
830
+ const onTogglePauseOnBattery = () => {
831
+ selection.pauseOnBattery = !selection.pauseOnBattery;
832
+ if (!selection.pauseOnBattery) setAutoPause("battery", false);
833
+ persistSelection();
834
+ emit();
835
+ };
583
836
 
584
837
  const onScrim = (pct) => { selection.scrim = pct / 100; persistSelection(); applyEffects(); emit(); };
585
838
  const onBorder = (pct) => { selection.border = pct / 100; persistSelection(); applyEffects(); emit(); };
@@ -588,87 +841,119 @@ window.__ModuleLoader__.load({
588
841
 
589
842
  if (!sel.loaded) {
590
843
  return React.createElement("div", { className: "webg-picker" },
591
- React.createElement("span", { className: "webg-hint" }, "正在扫描 Wallpaper Engine…"));
844
+ React.createElement("span", { className: "webg-hint" }, t.scanning));
592
845
  }
593
846
  if (sel.inventory.error) {
594
847
  return React.createElement("div", { className: "webg-picker" },
595
848
  React.createElement("div", { className: "webg-error" },
596
- "未能连接 Wallpaper Engine:" + sel.inventory.error),
849
+ t.connError + sel.inventory.error),
597
850
  React.createElement("button", {
598
851
  className: "webg-btn", type: "button", onClick: onRefresh, disabled: sel.loading,
599
- }, sel.loading ? "刷新中…" : "重试"));
852
+ }, sel.loading ? t.refreshing : t.retry));
600
853
  }
601
854
 
602
- const playableList = playableInventory();
855
+ const usableList = selectableInventory();
856
+ const q = query.trim();
857
+ const filteredList = usableList.filter((w) => matchesQuery(w, q));
603
858
  const groups = sel.rotationGroups;
604
859
  const group = activeRotationGroup();
605
860
  const candidates = rotationCandidates();
606
- const playableCount = candidates.length;
861
+ const usableCount = candidates.length;
607
862
  const editing = sel.editing;
863
+ const autoPaused = sel.autoPauseReasons.size > 0;
608
864
  const INTERVALS = [1, 5, 10, 30, 60, 120];
609
865
 
610
866
  return React.createElement("div", { className: "webg-picker" },
867
+ // ── Search + language ──
868
+ React.createElement("div", { className: "webg-row" },
869
+ React.createElement("input", {
870
+ className: "webg-text webg-search", type: "search",
871
+ placeholder: t.searchPlaceholder,
872
+ value: query,
873
+ onInput: (e) => setQuery(e.target.value),
874
+ }),
875
+ q && React.createElement("span", { className: "webg-hint" },
876
+ t.matched(filteredList.length, usableList.length)),
877
+ React.createElement("label", { className: "webg-lang" },
878
+ React.createElement("span", { className: "webg-hint" }, t.language),
879
+ React.createElement("select", {
880
+ className: "webg-select webg-lang-select",
881
+ value: sel.lang,
882
+ onChange: onLangChange,
883
+ },
884
+ React.createElement("option", { value: "auto" }, t.langAuto),
885
+ React.createElement("option", { value: "zh" }, "中文"),
886
+ React.createElement("option", { value: "en" }, "English"),
887
+ ),
888
+ ),
889
+ ),
890
+ // ── Grid ──
611
891
  React.createElement("div", { className: "webg-grid" },
612
892
  React.createElement("button", {
613
893
  className: "webg-card" + (sel.id ? "" : " webg-card--selected"),
614
894
  type: "button",
615
895
  onClick: onClear,
616
- title: "关闭壁纸",
896
+ title: t.closeTitle,
617
897
  },
618
- React.createElement("span", { className: "webg-card-close" }, "✕ 关闭"),
898
+ React.createElement("span", { className: "webg-card-close" }, t.closeCard),
619
899
  ),
620
- playableList.length === 0
900
+ usableList.length === 0
621
901
  ? 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))),
902
+ sel.inventory.installDir
903
+ ? (sel.inventory.total > 0
904
+ ? t.emptyNoUsable(sel.inventory.total)
905
+ : t.emptyNoProjects)
906
+ : t.emptyNoInstall)
907
+ : filteredList.length === 0
908
+ ? React.createElement("span", { className: "webg-hint" }, t.noMatch(q))
909
+ : filteredList.map((w) => ThumbCard(w, w.id === sel.id, () => applySelection(w.id), t)),
626
910
  ),
911
+ // ── Actions ──
627
912
  React.createElement("div", { className: "webg-row" },
628
913
  React.createElement("button", {
629
914
  className: "webg-btn", type: "button",
630
915
  onClick: onTogglePlay, disabled: !sel.url || sel.type !== "video",
631
- }, sel.playing ? "暂停" : "播放"),
916
+ }, sel.playing ? t.pause : t.play),
632
917
  React.createElement("button", {
633
918
  className: "webg-btn", type: "button",
634
919
  onClick: onClear, disabled: !sel.id,
635
- }, "关闭"),
920
+ }, t.close),
636
921
  React.createElement("button", {
637
922
  className: "webg-btn", type: "button",
638
923
  onClick: onRefresh, disabled: sel.loading,
639
- }, sel.loading ? "刷新中…" : "刷新"),
924
+ }, sel.loading ? t.refreshing : t.refresh),
640
925
  ),
641
926
  // ── Rotation groups ──
642
927
  React.createElement("div", { className: "webg-row webg-playlist-row" },
643
- React.createElement("span", { className: "webg-hint webg-label" }, "轮播列表"),
928
+ React.createElement("span", { className: "webg-hint webg-label" }, t.rotationList),
644
929
  React.createElement("select", {
645
930
  className: "webg-select webg-playlist-select",
646
931
  value: sel.rotationGroupId,
647
932
  onChange: onGroupChange,
648
933
  disabled: groups.length === 0,
649
934
  },
650
- React.createElement("option", { value: "" }, groups.length ? "— 选择轮播列表 —" : "— 暂无轮播列表 —"),
935
+ React.createElement("option", { value: "" }, groups.length ? t.selectList : t.noLists),
651
936
  ...groups.map((g) => React.createElement("option", {
652
937
  key: g.id, value: g.id,
653
- }, g.name + "(" + groupWallpapers(g).length + " 可播放 · " + g.interval + " 分钟)")),
938
+ }, t.groupOption(g.name, groupWallpapers(g).length, g.interval))),
654
939
  ),
655
940
  React.createElement("button", {
656
941
  className: "webg-btn", type: "button", onClick: startCreateGroup,
657
- }, "新建"),
942
+ }, t.newList),
658
943
  React.createElement("button", {
659
944
  className: "webg-btn", type: "button",
660
945
  onClick: () => startEditGroup(sel.rotationGroupId),
661
946
  disabled: !sel.rotationGroupId,
662
- }, "编辑"),
947
+ }, t.editList),
663
948
  React.createElement("button", {
664
949
  className: "webg-btn", type: "button",
665
950
  onClick: onDeleteGroup,
666
951
  disabled: !sel.rotationGroupId,
667
- }, "删除"),
952
+ }, t.deleteList),
668
953
  ),
669
954
  editing && React.createElement("div", { className: "webg-editor" },
670
955
  React.createElement("div", { className: "webg-row" },
671
- React.createElement("span", { className: "webg-hint webg-label" }, "名称"),
956
+ React.createElement("span", { className: "webg-hint webg-label" }, t.name),
672
957
  React.createElement("input", {
673
958
  className: "webg-text", type: "text",
674
959
  value: editing.name,
@@ -676,54 +961,68 @@ window.__ModuleLoader__.load({
676
961
  }),
677
962
  ),
678
963
  React.createElement("div", { className: "webg-row" },
679
- React.createElement("span", { className: "webg-hint webg-label" }, "间隔"),
964
+ React.createElement("span", { className: "webg-hint webg-label" }, t.interval),
680
965
  React.createElement("select", {
681
966
  className: "webg-select",
682
967
  value: String(editing.interval),
683
968
  onChange: (e) => { editing.interval = clampNum(Number(e.target.value), 1, 1440, DEFAULTS.rotationInterval); emit(); },
684
969
  },
685
970
  ...INTERVALS.map((minutes) =>
686
- React.createElement("option", { key: minutes, value: String(minutes) }, minutes + " 分钟"),
971
+ React.createElement("option", { key: minutes, value: String(minutes) }, t.minutes(minutes)),
687
972
  )),
688
- React.createElement("span", { className: "webg-hint webg-label" }, "顺序"),
973
+ React.createElement("span", { className: "webg-hint webg-label" }, t.order),
689
974
  React.createElement("select", {
690
975
  className: "webg-select webg-playlist-select",
691
976
  value: editing.order,
692
977
  onChange: (e) => { editing.order = e.target.value; emit(); },
693
978
  },
694
- React.createElement("option", { value: "sequence" }, "顺序"),
695
- React.createElement("option", { value: "random" }, "随机"),
979
+ React.createElement("option", { value: "sequence" }, t.sequence),
980
+ React.createElement("option", { value: "random" }, t.random),
696
981
  ),
697
982
  ),
983
+ React.createElement("input", {
984
+ className: "webg-text", type: "search",
985
+ placeholder: t.filterPlaceholder,
986
+ value: editorQuery,
987
+ onInput: (e) => setEditorQuery(e.target.value),
988
+ }),
698
989
  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
- },
990
+ (() => {
991
+ const eq = editorQuery.trim();
992
+ const pool = usableList.filter((w) => matchesQuery(w, eq));
993
+ if (usableList.length === 0) {
994
+ return React.createElement("span", { className: "webg-hint" }, t.noUsable);
995
+ }
996
+ if (pool.length === 0) {
997
+ return React.createElement("span", { className: "webg-hint" }, t.noMatch(eq));
998
+ }
999
+ return pool.map((w) => {
1000
+ const checked = editing.wallpaperIds.indexOf(w.id) >= 0;
1001
+ return React.createElement("button", {
1002
+ key: w.id,
1003
+ className: "webg-editor-card" + (checked ? " webg-editor-card--checked" : ""),
1004
+ type: "button",
1005
+ title: w.title + "(" + typeBadge(w, t) + ")",
1006
+ onClick: () => {
1007
+ const i = editing.wallpaperIds.indexOf(w.id);
1008
+ if (i >= 0) editing.wallpaperIds.splice(i, 1);
1009
+ else editing.wallpaperIds.push(w.id);
1010
+ emit();
714
1011
  },
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
- }),
1012
+ },
1013
+ w.preview
1014
+ ? React.createElement("img", {
1015
+ src: w.preview, alt: w.title, loading: "lazy", decoding: "async",
1016
+ onError: (e) => { e.target.style.display = "none"; },
1017
+ })
1018
+ : React.createElement("span", { className: "webg-card-placeholder" }, t.noPreview),
1019
+ checked && React.createElement("span", { className: "webg-editor-check" }, "✓"),
1020
+ );
1021
+ });
1022
+ })(),
724
1023
  ),
725
1024
  React.createElement("div", { className: "webg-row" },
726
- React.createElement("span", { className: "webg-hint" }, "已选 " + editing.wallpaperIds.length + " 个"),
1025
+ React.createElement("span", { className: "webg-hint" }, t.selectedCount(editing.wallpaperIds.length)),
727
1026
  sel.inventory.playlists.length > 0 && React.createElement("select", {
728
1027
  className: "webg-select webg-playlist-select",
729
1028
  value: "",
@@ -732,58 +1031,86 @@ window.__ModuleLoader__.load({
732
1031
  if (p) importPlaylistIntoDraft(p);
733
1032
  },
734
1033
  },
735
- React.createElement("option", { value: "" }, "从 WE 播放列表导入…"),
1034
+ React.createElement("option", { value: "" }, t.importPlaylist),
736
1035
  ...sel.inventory.playlists.map((p) => React.createElement("option", {
737
1036
  key: p.id, value: p.id,
738
- }, p.name + "(" + (p.portableCount || 0) + " 可播放)")),
1037
+ }, t.playlistOption(p.name, p.portableCount || 0))),
739
1038
  ),
740
1039
  ),
741
1040
  React.createElement("div", { className: "webg-row" },
742
1041
  React.createElement("button", {
743
1042
  className: "webg-btn webg-btn--primary", type: "button",
744
1043
  onClick: saveEditingGroup,
745
- }, "保存"),
1044
+ }, t.save),
746
1045
  React.createElement("button", {
747
1046
  className: "webg-btn", type: "button",
748
1047
  onClick: cancelEditGroup,
749
- }, "取消"),
1048
+ }, t.cancel),
750
1049
  ),
751
1050
  ),
752
1051
  React.createElement("div", { className: "webg-row webg-rotation-row" },
753
- React.createElement("label", { className: "webg-rotation-toggle" },
1052
+ React.createElement("label", { className: "webg-toggle" },
754
1053
  React.createElement("input", {
755
1054
  type: "checkbox",
756
1055
  checked: sel.rotationEnabled,
757
1056
  onChange: onToggleRotation,
758
- disabled: !sel.rotationGroupId || playableCount < 2,
1057
+ disabled: !sel.rotationGroupId || usableCount < 2,
759
1058
  }),
760
- "自动轮转",
1059
+ t.autoRotate,
761
1060
  ),
762
1061
  React.createElement("select", {
763
1062
  className: "webg-select webg-rotation-interval",
764
1063
  value: String(group ? group.interval : DEFAULTS.rotationInterval),
765
1064
  onChange: onGroupInterval,
766
- disabled: !sel.rotationEnabled || !sel.rotationGroupId || playableCount < 2,
767
- title: "当前列表的切换间隔",
1065
+ disabled: !sel.rotationEnabled || !sel.rotationGroupId || usableCount < 2,
1066
+ title: t.intervalTitle,
768
1067
  },
769
1068
  ...INTERVALS.map((minutes) =>
770
- React.createElement("option", { key: minutes, value: String(minutes) }, minutes + " 分钟"),
1069
+ React.createElement("option", { key: minutes, value: String(minutes) }, t.minutes(minutes)),
771
1070
  )),
772
- !sel.rotationGroupId && React.createElement("span", { className: "webg-hint" }, "请先选择或新建一个轮播列表"),
773
- sel.rotationGroupId && playableCount < 2 && React.createElement("span", { className: "webg-hint" }, "当前列表至少需要 2 个可播放壁纸"),
1071
+ !sel.rotationGroupId && React.createElement("span", { className: "webg-hint" }, t.needList),
1072
+ sel.rotationGroupId && usableCount < 2 && React.createElement("span", { className: "webg-hint" }, t.needTwo),
774
1073
  ),
1074
+ // ── Effects ──
775
1075
  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"),
1076
+ SliderRow(t.wallpaperBlur, 0, 60, 1, sel.wallpaperBlur, onWallpaperBlur, sel.wallpaperBlur + "px"),
1077
+ SliderRow(t.scrim, 0, 90, 5, Math.round(sel.scrim * 100), onScrim, Math.round(sel.scrim * 100) + "%"),
1078
+ SliderRow(t.border, 0, 90, 5, Math.round(sel.border * 100), onBorder, Math.round(sel.border * 100) + "%"),
1079
+ SliderRow(t.glass, 0, 40, 1, sel.blur, onBlur, sel.blur + "px"),
780
1080
  ),
1081
+ // ── Resource monitor ──
1082
+ sel.id && React.createElement("div", { className: "webg-row webg-monitor-row" },
1083
+ React.createElement("label", { className: "webg-toggle" },
1084
+ React.createElement("input", {
1085
+ type: "checkbox", checked: sel.pauseOnHidden, onChange: onTogglePauseOnHidden,
1086
+ }),
1087
+ t.pauseOnHidden,
1088
+ ),
1089
+ React.createElement("label", { className: "webg-toggle" },
1090
+ React.createElement("input", {
1091
+ type: "checkbox", checked: sel.pauseOnBattery, onChange: onTogglePauseOnBattery,
1092
+ }),
1093
+ t.pauseOnBattery,
1094
+ ),
1095
+ sel.fps !== null && React.createElement("span", {
1096
+ className: "webg-hint webg-fps" + (sel.fps < LOW_FPS ? " webg-fps--low" : ""),
1097
+ title: t.fpsTitle,
1098
+ }, sel.fps + " fps"),
1099
+ ),
1100
+ sel.id && sel.fps !== null && sel.fps < LOW_FPS && sel.type === "video" &&
1101
+ React.createElement("div", { className: "webg-row" },
1102
+ React.createElement("span", { className: "webg-hint" }, t.lowFpsHint),
1103
+ ),
1104
+ // ── Status ──
781
1105
  React.createElement("div", { className: "webg-row" },
782
1106
  React.createElement("span", { className: "webg-hint" },
783
1107
  (group
784
- ? "列表「" + group.name + "」:" + group.wallpaperIds.length + " 项 · " + playableCount + " 可播放 · 每 " + group.interval + " 分钟 · " + (group.order === "random" ? "随机" : "顺序")
785
- : playableList.length + " 个可播放壁纸") +
786
- (sel.rotationEnabled ? " · 自动轮转中" : "")),
1108
+ ? t.statusGroup(group.name, group.wallpaperIds.length, usableCount, group.interval,
1109
+ group.order === "random" ? t.random : t.sequence)
1110
+ : t.statusUsable(usableList.length)) +
1111
+ (sel.rotationEnabled ? " · " + t.autoRotating : "") +
1112
+ (autoPaused ? " · " + t.autoPaused : "") +
1113
+ (sel.inventory.installDir ? " · " + sel.inventory.installDir : "")),
787
1114
  ),
788
1115
  );
789
1116
  }
@@ -813,6 +1140,7 @@ window.__ModuleLoader__.load({
813
1140
  position: fixed; inset: 0; z-index: -1;
814
1141
  pointer-events: none;
815
1142
  background: var(--webg-scrim-color, rgba(0, 0, 0, 0.25));
1143
+ transition: background 120ms linear;
816
1144
  }
817
1145
 
818
1146
  /* While active: transparent frame + sidebar, higher border contrast. */
@@ -904,13 +1232,23 @@ window.__ModuleLoader__.load({
904
1232
  font-size: 0.85em;
905
1233
  }
906
1234
  .webg-text { flex: 1; min-width: 0; }
1235
+ .webg-search { max-width: 320px; }
1236
+ .webg-lang { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; }
907
1237
  .webg-playlist-select { flex: 1; min-width: 0; }
908
1238
  .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); }
1239
+ .webg-toggle {
1240
+ display: inline-flex; align-items: center; gap: 6px;
1241
+ accent-color: var(--dsw-alias-brand-primary, #3964fe);
1242
+ font-size: 0.85em; cursor: pointer;
1243
+ }
910
1244
 
911
1245
  .webg-slider { flex: 1; accent-color: var(--dsw-alias-brand-primary, #3964fe); }
912
1246
  .webg-slider-row { display: flex; align-items: center; gap: 8px; }
913
1247
 
1248
+ .webg-monitor-row { gap: 14px; }
1249
+ .webg-fps { font-variant-numeric: tabular-nums; }
1250
+ .webg-fps--low { color: var(--dsw-alias-state-warn-primary, #b93); opacity: 1; }
1251
+
914
1252
  /* Wallpaper thumbnail grid. */
915
1253
  .webg-grid {
916
1254
  display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
@@ -994,8 +1332,26 @@ window.__ModuleLoader__.load({
994
1332
  const inject = ["slots"];
995
1333
 
996
1334
  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).
1335
+ // 0. Language: follow the shell's locale service when present, and react
1336
+ // to its change events. Everything is optional — without the service
1337
+ // the browser language decides, and the manual override always wins.
1338
+ const localeSvc = typeof ctx.get === "function" ? ctx.get("locale") : undefined;
1339
+ if (localeSvc && typeof localeSvc.getLocale === "function") {
1340
+ try {
1341
+ const snap = localeSvc.getLocale();
1342
+ if (snap && snap.active) shellLang = snap.active === "en" ? "en" : "zh";
1343
+ } catch { /* keep browser fallback */ }
1344
+ }
1345
+ if (typeof ctx.on === "function") {
1346
+ try {
1347
+ ctx.on("locale/change", (snap) => {
1348
+ const next = snap && snap.active === "en" ? "en" : "zh";
1349
+ if (next !== shellLang) { shellLang = next; emit(); }
1350
+ });
1351
+ } catch { /* event bus unavailable → static language */ }
1352
+ }
1353
+
1354
+ // 1. Styles: owned by the fiber — injected on apply, REMOVED on dispose.
999
1355
  if (ctx.effect && typeof document !== "undefined") {
1000
1356
  ctx.effect(() => {
1001
1357
  let tag = document.querySelector("style[data-plugin-css=" + JSON.stringify(STYLE_TAG_ID) + "]");
@@ -1032,11 +1388,83 @@ window.__ModuleLoader__.load({
1032
1388
  });
1033
1389
  }
1034
1390
 
1035
- // 3. Settings row (this slot is NOT the overlay; safe to register into).
1391
+ // 3. Resource monitor: auto-pause on hidden tab / low battery, FPS meter.
1392
+ if (ctx.effect && typeof document !== "undefined" &&
1393
+ typeof document.addEventListener === "function") {
1394
+ ctx.effect(() => {
1395
+ const onVisibility = () => {
1396
+ if (!selection.pauseOnHidden) return;
1397
+ setAutoPause("hidden", document.visibilityState === "hidden");
1398
+ };
1399
+ document.addEventListener("visibilitychange", onVisibility);
1400
+ onVisibility();
1401
+ return () => {
1402
+ document.removeEventListener("visibilitychange", onVisibility);
1403
+ setAutoPause("hidden", false);
1404
+ };
1405
+ });
1406
+ }
1407
+ if (ctx.effect && typeof navigator !== "undefined" &&
1408
+ typeof navigator.getBattery === "function") {
1409
+ ctx.effect(() => {
1410
+ let battery = null;
1411
+ let cancelled = false;
1412
+ const update = () => {
1413
+ if (!battery || !selection.pauseOnBattery) return;
1414
+ setAutoPause("battery", !battery.charging && battery.level <= BATTERY_SAVER_LEVEL);
1415
+ };
1416
+ navigator.getBattery().then((b) => {
1417
+ if (cancelled) return;
1418
+ battery = b;
1419
+ b.addEventListener("chargingchange", update);
1420
+ b.addEventListener("levelchange", update);
1421
+ update();
1422
+ }).catch(() => {});
1423
+ return () => {
1424
+ cancelled = true;
1425
+ if (battery) {
1426
+ battery.removeEventListener("chargingchange", update);
1427
+ battery.removeEventListener("levelchange", update);
1428
+ }
1429
+ setAutoPause("battery", false);
1430
+ };
1431
+ });
1432
+ }
1433
+ if (ctx.effect && typeof window !== "undefined" &&
1434
+ typeof window.requestAnimationFrame === "function") {
1435
+ ctx.effect(() => {
1436
+ // FPS sampler: counts rAF callbacks once per second. rAF stops by
1437
+ // itself in hidden tabs, so the meter idles exactly when the page does.
1438
+ let running = true;
1439
+ let rafId = 0;
1440
+ let frames = 0;
1441
+ let window0 = 0;
1442
+ const loop = (t) => {
1443
+ if (!running) return;
1444
+ frames++;
1445
+ if (window0 === 0) window0 = t;
1446
+ else if (t - window0 >= 1000) {
1447
+ selection.fps = Math.round((frames * 1000) / (t - window0));
1448
+ frames = 0;
1449
+ window0 = t;
1450
+ emit();
1451
+ }
1452
+ rafId = window.requestAnimationFrame(loop);
1453
+ };
1454
+ rafId = window.requestAnimationFrame(loop);
1455
+ return () => {
1456
+ running = false;
1457
+ if (typeof window.cancelAnimationFrame === "function") window.cancelAnimationFrame(rafId);
1458
+ selection.fps = null;
1459
+ };
1460
+ });
1461
+ }
1462
+
1463
+ // 4. Settings row (this slot is NOT the overlay; safe to register into).
1036
1464
  if (ctx.slots) {
1037
1465
  ctx.slots.inject("settings.general.item", () =>
1038
1466
  ctx.slots.register(
1039
- { name: "settings.general.item", id: "we-background", order: 500, label: "壁纸背景 (Wallpaper Engine)" },
1467
+ { name: "settings.general.item", id: "we-background", order: 500, label: S().settingsLabel },
1040
1468
  () => React.createElement(WallpaperPicker),
1041
1469
  ),
1042
1470
  );