wallpaper-engine-dsh 0.1.0 → 0.1.3

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,182 @@ 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
+ fit: "排布",
87
+ fitCover: "填充",
88
+ fitContain: "适应",
89
+ fitFill: "拉伸",
90
+ fitNone: "原始",
91
+ position: "对齐",
92
+ posCenter: "居中",
93
+ posTop: "顶部",
94
+ posBottom: "底部",
95
+ posLeft: "居左",
96
+ posRight: "居右",
97
+ statusGroup: (name, total, usable, interval, order) =>
98
+ "列表「" + name + "」:" + total + " 项 · " + usable + " 可用 · 每 " + interval + " 分钟 · " + order,
99
+ statusUsable: (n) => n + " 张可用壁纸",
100
+ autoRotating: "自动轮转中",
101
+ autoPaused: "已自动暂停",
102
+ confirmDelete: (name) => "删除轮播列表「" + name + "」?",
103
+ defaultGroupName: (n) => "轮播列表 " + n,
104
+ defaultGroupBase: "轮播列表",
105
+ },
106
+ en: {
107
+ settingsLabel: "Wallpaper Engine",
108
+ scanning: "Scanning Wallpaper Engine…",
109
+ connError: "Could not reach Wallpaper Engine: ",
110
+ retry: "Retry",
111
+ refreshing: "Refreshing…",
112
+ refresh: "Refresh",
113
+ pause: "Pause",
114
+ play: "Play",
115
+ close: "Close",
116
+ searchPlaceholder: "Search wallpapers (title or ID)…",
117
+ matched: (m, n) => m + " / " + n + " matched",
118
+ emptyNoInstall: "Wallpaper Engine not found — make sure it is installed via Steam",
119
+ emptyNoUsable: (n) => "Found " + n + " wallpapers, but none have playable media or a preview image",
120
+ emptyNoProjects: "No wallpapers found in any project folder (defaults / my projects / workshop)",
121
+ noMatch: (q) => "No wallpapers match \"" + q + "\"",
122
+ closeCard: "✕ Close",
123
+ closeTitle: "Clear wallpaper",
124
+ noPreview: "No preview",
125
+ badgeVideo: "Video",
126
+ badgeWeb: "Web",
127
+ badgeStill: "Still",
128
+ rotationList: "Rotation list",
129
+ selectList: "— Select a list —",
130
+ noLists: "— No lists yet —",
131
+ groupOption: (name, count, interval) => name + "(" + count + " usable · " + interval + " min)",
132
+ newList: "New",
133
+ editList: "Edit",
134
+ deleteList: "Delete",
135
+ name: "Name",
136
+ interval: "Interval",
137
+ order: "Order",
138
+ sequence: "Sequence",
139
+ random: "Random",
140
+ minutes: (m) => m + " min",
141
+ filterPlaceholder: "Filter…",
142
+ noUsable: "No usable wallpapers",
143
+ selectedCount: (n) => n + " selected",
144
+ importPlaylist: "Import from WE playlist…",
145
+ playlistOption: (name, count) => name + "(" + count + " playable)",
146
+ save: "Save",
147
+ cancel: "Cancel",
148
+ autoRotate: "Auto-rotate",
149
+ intervalTitle: "Switch interval for the current list",
150
+ needList: "Create or select a rotation list first",
151
+ needTwo: "The list needs at least 2 usable wallpapers",
152
+ wallpaperBlur: "Wallpaper blur",
153
+ scrim: "Scrim",
154
+ border: "Borders",
155
+ glass: "Glass",
156
+ pauseOnHidden: "Pause when tab hidden",
157
+ pauseOnBattery: "Pause on low battery",
158
+ fpsTitle: "Current page frame rate",
159
+ lowFpsHint: "Low frame rate — try pausing the video, raising wallpaper blur, or using a still wallpaper",
160
+ language: "Language",
161
+ langAuto: "Auto",
162
+ fit: "Fit",
163
+ fitCover: "Cover",
164
+ fitContain: "Contain",
165
+ fitFill: "Stretch",
166
+ fitNone: "Original",
167
+ position: "Align",
168
+ posCenter: "Center",
169
+ posTop: "Top",
170
+ posBottom: "Bottom",
171
+ posLeft: "Left",
172
+ posRight: "Right",
173
+ statusGroup: (name, total, usable, interval, order) =>
174
+ "List \"" + name + "\": " + total + " items · " + usable + " usable · every " + interval + " min · " + order,
175
+ statusUsable: (n) => n + " usable wallpapers",
176
+ autoRotating: "Auto-rotating",
177
+ autoPaused: "Auto-paused",
178
+ confirmDelete: (name) => "Delete rotation list \"" + name + "\"?",
179
+ defaultGroupName: (n) => "Rotation list " + n,
180
+ defaultGroupBase: "Rotation list",
181
+ },
182
+ };
183
+
184
+ /** Language the DSH shell reports via its locale service (null = unknown). */
185
+ let shellLang = null;
186
+
187
+ function browserLang() {
188
+ if (typeof navigator !== "undefined" && navigator.language) {
189
+ return String(navigator.language).toLowerCase().startsWith("zh") ? "zh" : "en";
190
+ }
191
+ return null;
192
+ }
193
+
194
+ function resolveLang() {
195
+ if (selection.lang === "zh" || selection.lang === "en") return selection.lang;
196
+ return shellLang || browserLang() || "zh";
197
+ }
198
+
199
+ /** Current string table. */
200
+ function S() {
201
+ return STRINGS[resolveLang()];
202
+ }
203
+
24
204
  // ── Defaults ─────────────────────────────────────────────────────────────────
25
205
  const DEFAULTS = {
26
206
  scrim: 0.25,
@@ -32,18 +212,22 @@ window.__ModuleLoader__.load({
32
212
  rotationGroupId: "",
33
213
  rotationGroups: [],
34
214
  rotationSeeded: false,
215
+ pauseOnHidden: true,
216
+ pauseOnBattery: true,
217
+ lang: "auto",
218
+ fit: "cover",
219
+ position: "center",
35
220
  };
36
221
 
222
+ const FIT_MODES = ["cover", "contain", "fill", "none"];
223
+ const POSITIONS = ["center", "top", "bottom", "left", "right"];
224
+
37
225
  // ── Persisted selection ─────────────────────────────────────────────────────
38
226
  function clampNum(v, lo, hi, fallback) {
39
227
  return typeof v === "number" && isFinite(v) && v >= lo && v <= hi ? v : fallback;
40
228
  }
41
229
 
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) {
230
+ function readRotationGroups(raw, baseName) {
47
231
  if (!Array.isArray(raw)) return [];
48
232
  const groups = [];
49
233
  for (const g of raw) {
@@ -52,7 +236,7 @@ window.__ModuleLoader__.load({
52
236
  if (!id) continue;
53
237
  groups.push({
54
238
  id,
55
- name: typeof g.name === "string" && g.name.trim() ? g.name.trim() : "轮播列表",
239
+ name: typeof g.name === "string" && g.name.trim() ? g.name.trim() : baseName,
56
240
  interval: clampNum(g.interval, 1, 1440, DEFAULTS.rotationInterval),
57
241
  order: g.order === "random" ? "random" : "sequence",
58
242
  wallpaperIds: Array.isArray(g.wallpaperIds)
@@ -76,8 +260,13 @@ window.__ModuleLoader__.load({
76
260
  wallpaperBlur: clampNum(o.wallpaperBlur, 0, 60, DEFAULTS.wallpaperBlur),
77
261
  rotationEnabled: o.rotationEnabled === true,
78
262
  rotationGroupId: typeof o.rotationGroupId === "string" ? o.rotationGroupId : "",
79
- rotationGroups: readRotationGroups(o.rotationGroups),
263
+ rotationGroups: readRotationGroups(o.rotationGroups, STRINGS.zh.defaultGroupBase),
80
264
  rotationSeeded: o.rotationSeeded === true,
265
+ pauseOnHidden: o.pauseOnHidden !== false,
266
+ pauseOnBattery: o.pauseOnBattery !== false,
267
+ lang: o.lang === "zh" || o.lang === "en" ? o.lang : "auto",
268
+ fit: FIT_MODES.indexOf(o.fit) >= 0 ? o.fit : DEFAULTS.fit,
269
+ position: POSITIONS.indexOf(o.position) >= 0 ? o.position : DEFAULTS.position,
81
270
  };
82
271
  } catch {
83
272
  return { id: "", ...DEFAULTS };
@@ -92,6 +281,10 @@ window.__ModuleLoader__.load({
92
281
  playing: !REDUCED_MOTION,
93
282
  loading: false,
94
283
  rotationTimer: null,
284
+ // Reasons playback is auto-paused (hidden tab / low battery). Distinct from
285
+ // the user's play/pause choice: effective playback = playing && no reasons.
286
+ autoPauseReasons: new Set(),
287
+ fps: null, // last sampled frames-per-second, null when unsampled
95
288
  // Live draft of the group being created/edited (null when editor closed).
96
289
  editing: null,
97
290
  inventory: { installDir: null, wallpapers: [], total: 0, portableCount: 0, playlists: [], error: null },
@@ -120,10 +313,34 @@ window.__ModuleLoader__.load({
120
313
  rotationGroupId: selection.rotationGroupId,
121
314
  rotationGroups: selection.rotationGroups,
122
315
  rotationSeeded: selection.rotationSeeded,
316
+ pauseOnHidden: selection.pauseOnHidden,
317
+ pauseOnBattery: selection.pauseOnBattery,
318
+ lang: selection.lang,
319
+ fit: selection.fit,
320
+ position: selection.position,
123
321
  }));
124
322
  } catch { /* storage full / blocked → session-only state, not fatal */ }
125
323
  }
126
324
 
325
+ // ── Wallpaper predicates ─────────────────────────────────────────────────────
326
+ // playable: real media (video/web) renders live.
327
+ // static: scene/application with a preview image renders as a still.
328
+ function isPlayable(w) {
329
+ return Boolean(w && w.playable && (w.type === "video" || w.type === "web"));
330
+ }
331
+
332
+ function isStatic(w) {
333
+ return Boolean(w && !isPlayable(w) && typeof w.preview === "string" && w.preview);
334
+ }
335
+
336
+ function isSelectable(w) {
337
+ return isPlayable(w) || isStatic(w);
338
+ }
339
+
340
+ function selectableInventory() {
341
+ return selection.inventory.wallpapers.filter(isSelectable);
342
+ }
343
+
127
344
  // ── Inventory ────────────────────────────────────────────────────────────────
128
345
  async function loadInventory(forceRefresh) {
129
346
  selection.loading = true;
@@ -173,8 +390,8 @@ window.__ModuleLoader__.load({
173
390
  persistSelection();
174
391
  }
175
392
 
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))) {
393
+ // Drop a selection whose wallpaper vanished or became unusable.
394
+ if (selection.id && !selection.inventory.wallpapers.some((w) => w.id === selection.id && isSelectable(w))) {
178
395
  selection.id = "";
179
396
  persistSelection();
180
397
  }
@@ -190,14 +407,6 @@ window.__ModuleLoader__.load({
190
407
  emit();
191
408
  }
192
409
 
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
410
  // ── Rotation groups ─────────────────────────────────────────────────────────
202
411
  function activeRotationGroup() {
203
412
  return selection.rotationGroups.find((g) => g.id === selection.rotationGroupId) || null;
@@ -206,7 +415,7 @@ window.__ModuleLoader__.load({
206
415
  function groupWallpapers(group) {
207
416
  if (!group || !Array.isArray(group.wallpaperIds)) return [];
208
417
  const byId = new Map(selection.inventory.wallpapers.map((w) => [w.id, w]));
209
- return group.wallpaperIds.map((id) => byId.get(id)).filter(isPlayable);
418
+ return group.wallpaperIds.map((id) => byId.get(id)).filter(isSelectable);
210
419
  }
211
420
 
212
421
  function rotationCandidates() {
@@ -220,14 +429,14 @@ window.__ModuleLoader__.load({
220
429
  function seedGroupsFromPlaylists() {
221
430
  const source = selection.inventory.playlists.find((p) => (p.portableCount || 0) >= 2);
222
431
  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
- }) : [];
432
+ const byId = new Map(selection.inventory.wallpapers.map((w) => [w.id, w]));
433
+ const ids = Array.isArray(source.wallpaperIds)
434
+ ? source.wallpaperIds.filter((id) => isSelectable(byId.get(id)))
435
+ : [];
227
436
  if (ids.length < 2) return false;
228
437
  selection.rotationGroups.push({
229
438
  id: nextGroupId(),
230
- name: typeof source.name === "string" && source.name.trim() ? source.name.trim() : "轮播列表",
439
+ name: typeof source.name === "string" && source.name.trim() ? source.name.trim() : S().defaultGroupBase,
231
440
  interval: DEFAULTS.rotationInterval,
232
441
  order: source.order === "random" ? "random" : "sequence",
233
442
  wallpaperIds: ids,
@@ -286,7 +495,7 @@ window.__ModuleLoader__.load({
286
495
  function startCreateGroup() {
287
496
  selection.editing = {
288
497
  id: nextGroupId(),
289
- name: "轮播列表 " + (selection.rotationGroups.length + 1),
498
+ name: S().defaultGroupName(selection.rotationGroups.length + 1),
290
499
  interval: DEFAULTS.rotationInterval,
291
500
  order: "sequence",
292
501
  wallpaperIds: [],
@@ -300,7 +509,7 @@ window.__ModuleLoader__.load({
300
509
  const idx = selection.rotationGroups.findIndex((g) => g.id === draft.id);
301
510
  const cleaned = {
302
511
  id: draft.id,
303
- name: typeof draft.name === "string" && draft.name.trim() ? draft.name.trim() : "轮播列表",
512
+ name: typeof draft.name === "string" && draft.name.trim() ? draft.name.trim() : S().defaultGroupBase,
304
513
  interval: clampNum(draft.interval, 1, 1440, DEFAULTS.rotationInterval),
305
514
  order: draft.order === "random" ? "random" : "sequence",
306
515
  wallpaperIds: Array.isArray(draft.wallpaperIds)
@@ -346,8 +555,8 @@ window.__ModuleLoader__.load({
346
555
 
347
556
  function importPlaylistIntoDraft(playlist) {
348
557
  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));
558
+ const usable = new Set(selectableInventory().map((w) => w.id));
559
+ selection.editing.wallpaperIds = playlist.wallpaperIds.filter((id) => usable.has(id));
351
560
  emit();
352
561
  }
353
562
 
@@ -357,32 +566,52 @@ window.__ModuleLoader__.load({
357
566
  const w = selection.id
358
567
  ? selection.inventory.wallpapers.find((x) => x.id === selection.id)
359
568
  : null;
360
- if (!w || !isPlayable(w)) {
569
+ if (isPlayable(w)) {
570
+ selection.url = w.media;
571
+ selection.type = w.type; // "video" | "web"
572
+ } else if (w && isStatic(w)) {
573
+ selection.url = w.preview;
574
+ selection.type = "image";
575
+ } else {
361
576
  selection.url = null;
362
577
  selection.type = null;
363
- } else {
364
- selection.url = w.media;
365
- selection.type = w.type;
366
578
  }
367
579
  syncRotationTimer();
368
580
  emit();
369
581
  }
370
582
 
583
+ // ── Auto-pause (resource monitor) ───────────────────────────────────────────
584
+ function setAutoPause(reason, on) {
585
+ const had = selection.autoPauseReasons.size > 0;
586
+ if (on) selection.autoPauseReasons.add(reason);
587
+ else selection.autoPauseReasons.delete(reason);
588
+ if ((selection.autoPauseReasons.size > 0) !== had) emit();
589
+ }
590
+
591
+ function effectivePlaying() {
592
+ return selection.playing && selection.autoPauseReasons.size === 0;
593
+ }
594
+
371
595
  // ── Behind-body layer: wallpaper + scrim, with crossfade ────────────────────
596
+ // Set by a media error handler to force a remount with fresh URLs; cleared
597
+ // once the remount happens.
598
+ let forceRemount = false;
599
+ let lastMediaRecoveryAt = 0;
600
+
372
601
  function buildMedia(sel) {
373
- const media = sel.type === "video"
374
- ? document.createElement("video")
375
- : document.createElement("iframe");
602
+ let media;
376
603
  if (sel.type === "video") {
604
+ media = document.createElement("video");
377
605
  media.src = sel.url;
378
- media.autoplay = !REDUCED_MOTION;
606
+ media.autoplay = effectivePlaying();
379
607
  media.loop = true;
380
608
  media.muted = true;
381
609
  media.setAttribute("playsinline", "");
382
610
  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.
611
+ } else if (sel.type === "web") {
612
+ // Sandboxed opaque origin: wallpaper JS runs but cannot touch DSH
613
+ // storage or APIs.
614
+ media = document.createElement("iframe");
386
615
  media.src = sel.url;
387
616
  media.setAttribute("sandbox", "allow-scripts");
388
617
  media.setAttribute("referrerpolicy", "no-referrer");
@@ -390,24 +619,45 @@ window.__ModuleLoader__.load({
390
619
  media.setAttribute("scrolling", "no");
391
620
  media.setAttribute("title", "Wallpaper Engine web wallpaper");
392
621
  media.className = "webg-media webg-iframe";
622
+ } else {
623
+ // Static wallpaper: the scene/application preview as a still image.
624
+ media = document.createElement("img");
625
+ media.src = sel.url;
626
+ media.alt = "";
627
+ media.draggable = false;
628
+ media.setAttribute("decoding", "async");
629
+ media.className = "webg-media";
393
630
  }
631
+ // Self-heal: if the media fails to load (e.g. its token died on an
632
+ // inventory rebuild), refetch fresh URLs and remount — throttled to once
633
+ // per 10 s so a permanently-broken wallpaper cannot loop.
634
+ media.addEventListener("error", () => {
635
+ const now = Date.now();
636
+ if (now - lastMediaRecoveryAt < 10000) return;
637
+ lastMediaRecoveryAt = now;
638
+ forceRemount = true;
639
+ loadInventory(true);
640
+ });
394
641
  return media;
395
642
  }
396
643
 
397
644
  function syncLayers() {
398
645
  const existing = document.getElementById(LAYER_ID);
646
+ const hasTimeout = typeof window !== "undefined" && typeof window.setTimeout === "function";
399
647
 
400
648
  if (selection.url) {
401
- const wantKey = selection.type + "\u0000" + selection.url;
649
+ // Key by wallpaper id, NOT by media URL: inventory rebuilds re-mint
650
+ // tokens, so a URL-keyed comparison would reload the playing video on
651
+ // every refresh. A genuinely dead URL is recovered via forceRemount.
652
+ const wantKey = selection.type + "|" + selection.id;
402
653
  const gotKey = existing && existing.dataset.webgKey;
403
654
 
404
- if (existing && gotKey === wantKey) {
655
+ if (existing && gotKey === wantKey && !forceRemount) {
405
656
  // Same wallpaper — only play/pause may have changed.
406
657
  } 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.
658
+ forceRemount = false;
659
+ // Crossfade: the new layer fades in over the old one; the old layer
660
+ // surrenders the id FIRST so getElementById resolves to the new one.
411
661
  if (existing) existing.removeAttribute("id");
412
662
  const node = document.createElement("div");
413
663
  node.id = LAYER_ID;
@@ -415,38 +665,43 @@ window.__ModuleLoader__.load({
415
665
  node.dataset.webgKey = wantKey;
416
666
  node.appendChild(buildMedia(selection));
417
667
  document.body.appendChild(node);
418
- // Force a style flush so the enter transition actually runs.
419
- void node.offsetHeight;
668
+ void node.offsetHeight; // style flush so the enter transition runs
420
669
  node.classList.remove("webg-layer--enter");
421
670
  if (existing) {
422
671
  existing.classList.add("webg-layer--leave");
423
672
  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
- }
673
+ if (hasTimeout) window.setTimeout(() => stale.remove(), CROSSFADE_MS + 120);
674
+ else stale.remove();
429
675
  }
430
676
  }
431
677
  const node = document.getElementById(LAYER_ID);
432
678
  const video = node && node.querySelector("video");
433
679
  if (video) {
434
- if (selection.playing) { try { const p = video.play(); if (p && p.catch) p.catch(() => {}); } catch {} }
680
+ if (effectivePlaying()) { try { const p = video.play(); if (p && p.catch) p.catch(() => {}); } catch {} }
435
681
  else video.pause();
436
682
  }
437
683
  } else if (existing) {
684
+ // Clearing: fade out, then tear down scrim + frame transparency when the
685
+ // fade completes — unless a new wallpaper was applied meanwhile.
686
+ existing.removeAttribute("id");
438
687
  existing.classList.add("webg-layer--leave");
439
688
  const stale = existing;
440
- if (typeof window !== "undefined" && typeof window.setTimeout === "function") {
441
- window.setTimeout(() => stale.remove(), CROSSFADE_MS + 120);
442
- } else {
689
+ const finish = () => {
443
690
  stale.remove();
444
- }
691
+ if (!document.getElementById(LAYER_ID)) {
692
+ const s = document.getElementById(SCRIM_ID);
693
+ if (s) s.remove();
694
+ document.body.removeAttribute(ACTIVE_ATTR);
695
+ }
696
+ };
697
+ if (hasTimeout) window.setTimeout(finish, CROSSFADE_MS + 120);
698
+ else finish();
445
699
  }
446
700
 
447
- // Scrim: always present while a wallpaper is active.
448
- const scrim = document.getElementById(SCRIM_ID);
701
+ // Scrim: present while a wallpaper is active. On clear it outlives the
702
+ // layer until the fade-out finishes, so the veil fades WITH the wallpaper.
449
703
  if (selection.url) {
704
+ const scrim = document.getElementById(SCRIM_ID);
450
705
  if (!scrim) {
451
706
  const s = document.createElement("div");
452
707
  s.id = SCRIM_ID;
@@ -454,9 +709,6 @@ window.__ModuleLoader__.load({
454
709
  document.body.appendChild(s);
455
710
  }
456
711
  document.body.setAttribute(ACTIVE_ATTR, "on");
457
- } else {
458
- if (scrim) scrim.remove();
459
- document.body.removeAttribute(ACTIVE_ATTR);
460
712
  }
461
713
  }
462
714
 
@@ -472,6 +724,10 @@ window.__ModuleLoader__.load({
472
724
  s.setProperty("--webg-wallpaper-blur", selection.wallpaperBlur + "px");
473
725
  // Scaling compensates the transparent fringe CSS blur reveals at edges.
474
726
  s.setProperty("--webg-wallpaper-scale", (1 + selection.wallpaperBlur * 0.006).toFixed(4));
727
+ // Canvas fit: object-fit/position for video and still wallpapers (iframes
728
+ // ignore object-fit — web wallpapers always fill their layer).
729
+ s.setProperty("--webg-fit", selection.fit);
730
+ s.setProperty("--webg-position", selection.position);
475
731
 
476
732
  // Write the scrim colour directly too, then force a synchronous layout, so
477
733
  // slider feedback lands on this frame even on stalled compositors.
@@ -491,6 +747,8 @@ window.__ModuleLoader__.load({
491
747
  s.removeProperty("--webg-glass-brightness");
492
748
  s.removeProperty("--webg-wallpaper-blur");
493
749
  s.removeProperty("--webg-wallpaper-scale");
750
+ s.removeProperty("--webg-fit");
751
+ s.removeProperty("--webg-position");
494
752
  const scrim = document.getElementById(SCRIM_ID);
495
753
  if (scrim) scrim.style.background = "";
496
754
  }
@@ -512,30 +770,50 @@ window.__ModuleLoader__.load({
512
770
  );
513
771
  }
514
772
 
515
- function ThumbCard(w, selected, onClick) {
773
+ function typeBadge(w, t) {
774
+ if (w.type === "video") return t.badgeVideo;
775
+ if (w.type === "web") return t.badgeWeb;
776
+ return t.badgeStill;
777
+ }
778
+
779
+ function matchesQuery(w, q) {
780
+ if (!q) return true;
781
+ const needle = q.toLowerCase();
782
+ return (w.title || "").toLowerCase().includes(needle) || (w.id || "").toLowerCase().includes(needle);
783
+ }
784
+
785
+ function ThumbCard(w, selected, onClick, t) {
516
786
  return React.createElement("button", {
517
787
  key: w.id,
518
788
  className: "webg-card" + (selected ? " webg-card--selected" : ""),
519
789
  type: "button",
520
790
  onClick: onClick,
521
- title: w.title,
791
+ title: w.title + "(" + typeBadge(w, t) + ")",
522
792
  },
523
793
  w.preview
524
794
  ? React.createElement("img", {
525
- src: w.preview, alt: w.title, loading: "lazy",
795
+ src: w.preview, alt: w.title, loading: "lazy", decoding: "async",
526
796
  onError: (e) => { e.target.style.display = "none"; },
527
797
  })
528
- : React.createElement("span", { className: "webg-card-placeholder" }, "无预览"),
529
- React.createElement("span", { className: "webg-card-type" }, w.type === "video" ? "视频" : "网页"),
798
+ : React.createElement("span", { className: "webg-card-placeholder" }, t.noPreview),
799
+ React.createElement("span", { className: "webg-card-type" }, typeBadge(w, t)),
530
800
  React.createElement("span", { className: "webg-card-title" }, w.title),
531
801
  );
532
802
  }
533
803
 
534
804
  function WallpaperPicker() {
535
805
  const sel = useStore();
806
+ const [query, setQuery] = React.useState("");
807
+ const [editorQuery, setEditorQuery] = React.useState("");
808
+ const t = S();
536
809
  const onTogglePlay = () => { selection.playing = !selection.playing; emit(); };
537
810
  const onClear = () => applySelection("");
538
811
  const onRefresh = () => loadInventory(true);
812
+ const onLangChange = (e) => {
813
+ selection.lang = e.target.value;
814
+ persistSelection();
815
+ emit();
816
+ };
539
817
  const onGroupChange = (e) => {
540
818
  selection.rotationGroupId = e.target.value;
541
819
  if (selection.rotationEnabled) {
@@ -576,99 +854,155 @@ window.__ModuleLoader__.load({
576
854
  const group = activeRotationGroup();
577
855
  if (!group) return;
578
856
  if (typeof window !== "undefined" && typeof window.confirm === "function") {
579
- if (!window.confirm("删除轮播列表「" + group.name + "」?")) return;
857
+ if (!window.confirm(t.confirmDelete(group.name))) return;
580
858
  }
581
859
  deleteGroup(group.id);
582
860
  };
861
+ const onTogglePauseOnHidden = () => {
862
+ selection.pauseOnHidden = !selection.pauseOnHidden;
863
+ if (!selection.pauseOnHidden) setAutoPause("hidden", false);
864
+ persistSelection();
865
+ emit();
866
+ };
867
+ const onTogglePauseOnBattery = () => {
868
+ selection.pauseOnBattery = !selection.pauseOnBattery;
869
+ if (!selection.pauseOnBattery) setAutoPause("battery", false);
870
+ persistSelection();
871
+ emit();
872
+ };
583
873
 
584
874
  const onScrim = (pct) => { selection.scrim = pct / 100; persistSelection(); applyEffects(); emit(); };
585
875
  const onBorder = (pct) => { selection.border = pct / 100; persistSelection(); applyEffects(); emit(); };
586
876
  const onBlur = (px) => { selection.blur = px; persistSelection(); applyEffects(); emit(); };
587
877
  const onWallpaperBlur = (px) => { selection.wallpaperBlur = px; persistSelection(); applyEffects(); emit(); };
878
+ const onFit = (e) => {
879
+ selection.fit = FIT_MODES.indexOf(e.target.value) >= 0 ? e.target.value : "cover";
880
+ persistSelection();
881
+ applyEffects();
882
+ emit();
883
+ };
884
+ const onPosition = (e) => {
885
+ selection.position = POSITIONS.indexOf(e.target.value) >= 0 ? e.target.value : "center";
886
+ persistSelection();
887
+ applyEffects();
888
+ emit();
889
+ };
588
890
 
589
891
  if (!sel.loaded) {
590
892
  return React.createElement("div", { className: "webg-picker" },
591
- React.createElement("span", { className: "webg-hint" }, "正在扫描 Wallpaper Engine…"));
893
+ React.createElement("span", { className: "webg-hint" }, t.scanning));
592
894
  }
593
895
  if (sel.inventory.error) {
594
896
  return React.createElement("div", { className: "webg-picker" },
595
897
  React.createElement("div", { className: "webg-error" },
596
- "未能连接 Wallpaper Engine:" + sel.inventory.error),
898
+ t.connError + sel.inventory.error),
597
899
  React.createElement("button", {
598
900
  className: "webg-btn", type: "button", onClick: onRefresh, disabled: sel.loading,
599
- }, sel.loading ? "刷新中…" : "重试"));
901
+ }, sel.loading ? t.refreshing : t.retry));
600
902
  }
601
903
 
602
- const playableList = playableInventory();
904
+ const usableList = selectableInventory();
905
+ const q = query.trim();
906
+ const filteredList = usableList.filter((w) => matchesQuery(w, q));
603
907
  const groups = sel.rotationGroups;
604
908
  const group = activeRotationGroup();
605
909
  const candidates = rotationCandidates();
606
- const playableCount = candidates.length;
910
+ const usableCount = candidates.length;
607
911
  const editing = sel.editing;
912
+ const autoPaused = sel.autoPauseReasons.size > 0;
608
913
  const INTERVALS = [1, 5, 10, 30, 60, 120];
609
914
 
610
915
  return React.createElement("div", { className: "webg-picker" },
916
+ // ── Search + language ──
917
+ React.createElement("div", { className: "webg-row" },
918
+ React.createElement("input", {
919
+ className: "webg-text webg-search", type: "search",
920
+ placeholder: t.searchPlaceholder,
921
+ value: query,
922
+ onInput: (e) => setQuery(e.target.value),
923
+ }),
924
+ q && React.createElement("span", { className: "webg-hint" },
925
+ t.matched(filteredList.length, usableList.length)),
926
+ React.createElement("label", { className: "webg-lang" },
927
+ React.createElement("span", { className: "webg-hint" }, t.language),
928
+ React.createElement("select", {
929
+ className: "webg-select webg-lang-select",
930
+ value: sel.lang,
931
+ onChange: onLangChange,
932
+ },
933
+ React.createElement("option", { value: "auto" }, t.langAuto),
934
+ React.createElement("option", { value: "zh" }, "中文"),
935
+ React.createElement("option", { value: "en" }, "English"),
936
+ ),
937
+ ),
938
+ ),
939
+ // ── Grid ──
611
940
  React.createElement("div", { className: "webg-grid" },
612
941
  React.createElement("button", {
613
942
  className: "webg-card" + (sel.id ? "" : " webg-card--selected"),
614
943
  type: "button",
615
944
  onClick: onClear,
616
- title: "关闭壁纸",
945
+ title: t.closeTitle,
617
946
  },
618
- React.createElement("span", { className: "webg-card-close" }, "✕ 关闭"),
947
+ React.createElement("span", { className: "webg-card-close" }, t.closeCard),
619
948
  ),
620
- playableList.length === 0
949
+ usableList.length === 0
621
950
  ? 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))),
951
+ sel.inventory.installDir
952
+ ? (sel.inventory.total > 0
953
+ ? t.emptyNoUsable(sel.inventory.total)
954
+ : t.emptyNoProjects)
955
+ : t.emptyNoInstall)
956
+ : filteredList.length === 0
957
+ ? React.createElement("span", { className: "webg-hint" }, t.noMatch(q))
958
+ : filteredList.map((w) => ThumbCard(w, w.id === sel.id, () => applySelection(w.id), t)),
626
959
  ),
960
+ // ── Actions ──
627
961
  React.createElement("div", { className: "webg-row" },
628
962
  React.createElement("button", {
629
963
  className: "webg-btn", type: "button",
630
964
  onClick: onTogglePlay, disabled: !sel.url || sel.type !== "video",
631
- }, sel.playing ? "暂停" : "播放"),
965
+ }, sel.playing ? t.pause : t.play),
632
966
  React.createElement("button", {
633
967
  className: "webg-btn", type: "button",
634
968
  onClick: onClear, disabled: !sel.id,
635
- }, "关闭"),
969
+ }, t.close),
636
970
  React.createElement("button", {
637
971
  className: "webg-btn", type: "button",
638
972
  onClick: onRefresh, disabled: sel.loading,
639
- }, sel.loading ? "刷新中…" : "刷新"),
973
+ }, sel.loading ? t.refreshing : t.refresh),
640
974
  ),
641
975
  // ── Rotation groups ──
642
976
  React.createElement("div", { className: "webg-row webg-playlist-row" },
643
- React.createElement("span", { className: "webg-hint webg-label" }, "轮播列表"),
977
+ React.createElement("span", { className: "webg-hint webg-label" }, t.rotationList),
644
978
  React.createElement("select", {
645
979
  className: "webg-select webg-playlist-select",
646
980
  value: sel.rotationGroupId,
647
981
  onChange: onGroupChange,
648
982
  disabled: groups.length === 0,
649
983
  },
650
- React.createElement("option", { value: "" }, groups.length ? "— 选择轮播列表 —" : "— 暂无轮播列表 —"),
984
+ React.createElement("option", { value: "" }, groups.length ? t.selectList : t.noLists),
651
985
  ...groups.map((g) => React.createElement("option", {
652
986
  key: g.id, value: g.id,
653
- }, g.name + "(" + groupWallpapers(g).length + " 可播放 · " + g.interval + " 分钟)")),
987
+ }, t.groupOption(g.name, groupWallpapers(g).length, g.interval))),
654
988
  ),
655
989
  React.createElement("button", {
656
990
  className: "webg-btn", type: "button", onClick: startCreateGroup,
657
- }, "新建"),
991
+ }, t.newList),
658
992
  React.createElement("button", {
659
993
  className: "webg-btn", type: "button",
660
994
  onClick: () => startEditGroup(sel.rotationGroupId),
661
995
  disabled: !sel.rotationGroupId,
662
- }, "编辑"),
996
+ }, t.editList),
663
997
  React.createElement("button", {
664
998
  className: "webg-btn", type: "button",
665
999
  onClick: onDeleteGroup,
666
1000
  disabled: !sel.rotationGroupId,
667
- }, "删除"),
1001
+ }, t.deleteList),
668
1002
  ),
669
1003
  editing && React.createElement("div", { className: "webg-editor" },
670
1004
  React.createElement("div", { className: "webg-row" },
671
- React.createElement("span", { className: "webg-hint webg-label" }, "名称"),
1005
+ React.createElement("span", { className: "webg-hint webg-label" }, t.name),
672
1006
  React.createElement("input", {
673
1007
  className: "webg-text", type: "text",
674
1008
  value: editing.name,
@@ -676,54 +1010,68 @@ window.__ModuleLoader__.load({
676
1010
  }),
677
1011
  ),
678
1012
  React.createElement("div", { className: "webg-row" },
679
- React.createElement("span", { className: "webg-hint webg-label" }, "间隔"),
1013
+ React.createElement("span", { className: "webg-hint webg-label" }, t.interval),
680
1014
  React.createElement("select", {
681
1015
  className: "webg-select",
682
1016
  value: String(editing.interval),
683
1017
  onChange: (e) => { editing.interval = clampNum(Number(e.target.value), 1, 1440, DEFAULTS.rotationInterval); emit(); },
684
1018
  },
685
1019
  ...INTERVALS.map((minutes) =>
686
- React.createElement("option", { key: minutes, value: String(minutes) }, minutes + " 分钟"),
1020
+ React.createElement("option", { key: minutes, value: String(minutes) }, t.minutes(minutes)),
687
1021
  )),
688
- React.createElement("span", { className: "webg-hint webg-label" }, "顺序"),
1022
+ React.createElement("span", { className: "webg-hint webg-label" }, t.order),
689
1023
  React.createElement("select", {
690
1024
  className: "webg-select webg-playlist-select",
691
1025
  value: editing.order,
692
1026
  onChange: (e) => { editing.order = e.target.value; emit(); },
693
1027
  },
694
- React.createElement("option", { value: "sequence" }, "顺序"),
695
- React.createElement("option", { value: "random" }, "随机"),
1028
+ React.createElement("option", { value: "sequence" }, t.sequence),
1029
+ React.createElement("option", { value: "random" }, t.random),
696
1030
  ),
697
1031
  ),
1032
+ React.createElement("input", {
1033
+ className: "webg-text", type: "search",
1034
+ placeholder: t.filterPlaceholder,
1035
+ value: editorQuery,
1036
+ onInput: (e) => setEditorQuery(e.target.value),
1037
+ }),
698
1038
  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
- },
1039
+ (() => {
1040
+ const eq = editorQuery.trim();
1041
+ const pool = usableList.filter((w) => matchesQuery(w, eq));
1042
+ if (usableList.length === 0) {
1043
+ return React.createElement("span", { className: "webg-hint" }, t.noUsable);
1044
+ }
1045
+ if (pool.length === 0) {
1046
+ return React.createElement("span", { className: "webg-hint" }, t.noMatch(eq));
1047
+ }
1048
+ return pool.map((w) => {
1049
+ const checked = editing.wallpaperIds.indexOf(w.id) >= 0;
1050
+ return React.createElement("button", {
1051
+ key: w.id,
1052
+ className: "webg-editor-card" + (checked ? " webg-editor-card--checked" : ""),
1053
+ type: "button",
1054
+ title: w.title + "(" + typeBadge(w, t) + ")",
1055
+ onClick: () => {
1056
+ const i = editing.wallpaperIds.indexOf(w.id);
1057
+ if (i >= 0) editing.wallpaperIds.splice(i, 1);
1058
+ else editing.wallpaperIds.push(w.id);
1059
+ emit();
714
1060
  },
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
- }),
1061
+ },
1062
+ w.preview
1063
+ ? React.createElement("img", {
1064
+ src: w.preview, alt: w.title, loading: "lazy", decoding: "async",
1065
+ onError: (e) => { e.target.style.display = "none"; },
1066
+ })
1067
+ : React.createElement("span", { className: "webg-card-placeholder" }, t.noPreview),
1068
+ checked && React.createElement("span", { className: "webg-editor-check" }, "✓"),
1069
+ );
1070
+ });
1071
+ })(),
724
1072
  ),
725
1073
  React.createElement("div", { className: "webg-row" },
726
- React.createElement("span", { className: "webg-hint" }, "已选 " + editing.wallpaperIds.length + " 个"),
1074
+ React.createElement("span", { className: "webg-hint" }, t.selectedCount(editing.wallpaperIds.length)),
727
1075
  sel.inventory.playlists.length > 0 && React.createElement("select", {
728
1076
  className: "webg-select webg-playlist-select",
729
1077
  value: "",
@@ -732,58 +1080,108 @@ window.__ModuleLoader__.load({
732
1080
  if (p) importPlaylistIntoDraft(p);
733
1081
  },
734
1082
  },
735
- React.createElement("option", { value: "" }, "从 WE 播放列表导入…"),
1083
+ React.createElement("option", { value: "" }, t.importPlaylist),
736
1084
  ...sel.inventory.playlists.map((p) => React.createElement("option", {
737
1085
  key: p.id, value: p.id,
738
- }, p.name + "(" + (p.portableCount || 0) + " 可播放)")),
1086
+ }, t.playlistOption(p.name, p.portableCount || 0))),
739
1087
  ),
740
1088
  ),
741
1089
  React.createElement("div", { className: "webg-row" },
742
1090
  React.createElement("button", {
743
1091
  className: "webg-btn webg-btn--primary", type: "button",
744
1092
  onClick: saveEditingGroup,
745
- }, "保存"),
1093
+ }, t.save),
746
1094
  React.createElement("button", {
747
1095
  className: "webg-btn", type: "button",
748
1096
  onClick: cancelEditGroup,
749
- }, "取消"),
1097
+ }, t.cancel),
750
1098
  ),
751
1099
  ),
752
1100
  React.createElement("div", { className: "webg-row webg-rotation-row" },
753
- React.createElement("label", { className: "webg-rotation-toggle" },
1101
+ React.createElement("label", { className: "webg-toggle" },
754
1102
  React.createElement("input", {
755
1103
  type: "checkbox",
756
1104
  checked: sel.rotationEnabled,
757
1105
  onChange: onToggleRotation,
758
- disabled: !sel.rotationGroupId || playableCount < 2,
1106
+ disabled: !sel.rotationGroupId || usableCount < 2,
759
1107
  }),
760
- "自动轮转",
1108
+ t.autoRotate,
761
1109
  ),
762
1110
  React.createElement("select", {
763
1111
  className: "webg-select webg-rotation-interval",
764
1112
  value: String(group ? group.interval : DEFAULTS.rotationInterval),
765
1113
  onChange: onGroupInterval,
766
- disabled: !sel.rotationEnabled || !sel.rotationGroupId || playableCount < 2,
767
- title: "当前列表的切换间隔",
1114
+ disabled: !sel.rotationEnabled || !sel.rotationGroupId || usableCount < 2,
1115
+ title: t.intervalTitle,
768
1116
  },
769
1117
  ...INTERVALS.map((minutes) =>
770
- React.createElement("option", { key: minutes, value: String(minutes) }, minutes + " 分钟"),
1118
+ React.createElement("option", { key: minutes, value: String(minutes) }, t.minutes(minutes)),
771
1119
  )),
772
- !sel.rotationGroupId && React.createElement("span", { className: "webg-hint" }, "请先选择或新建一个轮播列表"),
773
- sel.rotationGroupId && playableCount < 2 && React.createElement("span", { className: "webg-hint" }, "当前列表至少需要 2 个可播放壁纸"),
1120
+ !sel.rotationGroupId && React.createElement("span", { className: "webg-hint" }, t.needList),
1121
+ sel.rotationGroupId && usableCount < 2 && React.createElement("span", { className: "webg-hint" }, t.needTwo),
774
1122
  ),
1123
+ // ── Effects ──
775
1124
  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"),
1125
+ // Canvas fit only matters for video/still wallpapers — an iframe
1126
+ // ignores object-fit and always fills its layer.
1127
+ sel.type !== "web" && React.createElement("div", { className: "webg-row webg-fit-row" },
1128
+ React.createElement("span", { className: "webg-hint webg-label" }, t.fit),
1129
+ React.createElement("select", {
1130
+ className: "webg-select webg-fit-select",
1131
+ value: sel.fit,
1132
+ onChange: onFit,
1133
+ },
1134
+ ...[t.fitCover, t.fitContain, t.fitFill, t.fitNone].map((label, i) =>
1135
+ React.createElement("option", { key: FIT_MODES[i], value: FIT_MODES[i] }, label),
1136
+ )),
1137
+ React.createElement("span", { className: "webg-hint webg-label" }, t.position),
1138
+ React.createElement("select", {
1139
+ className: "webg-select webg-position-select",
1140
+ value: sel.position,
1141
+ onChange: onPosition,
1142
+ },
1143
+ ...[t.posCenter, t.posTop, t.posBottom, t.posLeft, t.posRight].map((label, i) =>
1144
+ React.createElement("option", { key: POSITIONS[i], value: POSITIONS[i] }, label),
1145
+ )),
1146
+ ),
1147
+ SliderRow(t.wallpaperBlur, 0, 60, 1, sel.wallpaperBlur, onWallpaperBlur, sel.wallpaperBlur + "px"),
1148
+ SliderRow(t.scrim, 0, 90, 5, Math.round(sel.scrim * 100), onScrim, Math.round(sel.scrim * 100) + "%"),
1149
+ SliderRow(t.border, 0, 90, 5, Math.round(sel.border * 100), onBorder, Math.round(sel.border * 100) + "%"),
1150
+ SliderRow(t.glass, 0, 40, 1, sel.blur, onBlur, sel.blur + "px"),
780
1151
  ),
1152
+ // ── Resource monitor ──
1153
+ sel.id && React.createElement("div", { className: "webg-row webg-monitor-row" },
1154
+ React.createElement("label", { className: "webg-toggle" },
1155
+ React.createElement("input", {
1156
+ type: "checkbox", checked: sel.pauseOnHidden, onChange: onTogglePauseOnHidden,
1157
+ }),
1158
+ t.pauseOnHidden,
1159
+ ),
1160
+ React.createElement("label", { className: "webg-toggle" },
1161
+ React.createElement("input", {
1162
+ type: "checkbox", checked: sel.pauseOnBattery, onChange: onTogglePauseOnBattery,
1163
+ }),
1164
+ t.pauseOnBattery,
1165
+ ),
1166
+ sel.fps !== null && React.createElement("span", {
1167
+ className: "webg-hint webg-fps" + (sel.fps < LOW_FPS ? " webg-fps--low" : ""),
1168
+ title: t.fpsTitle,
1169
+ }, sel.fps + " fps"),
1170
+ ),
1171
+ sel.id && sel.fps !== null && sel.fps < LOW_FPS && sel.type === "video" &&
1172
+ React.createElement("div", { className: "webg-row" },
1173
+ React.createElement("span", { className: "webg-hint" }, t.lowFpsHint),
1174
+ ),
1175
+ // ── Status ──
781
1176
  React.createElement("div", { className: "webg-row" },
782
1177
  React.createElement("span", { className: "webg-hint" },
783
1178
  (group
784
- ? "列表「" + group.name + "」:" + group.wallpaperIds.length + " 项 · " + playableCount + " 可播放 · 每 " + group.interval + " 分钟 · " + (group.order === "random" ? "随机" : "顺序")
785
- : playableList.length + " 个可播放壁纸") +
786
- (sel.rotationEnabled ? " · 自动轮转中" : "")),
1179
+ ? t.statusGroup(group.name, group.wallpaperIds.length, usableCount, group.interval,
1180
+ group.order === "random" ? t.random : t.sequence)
1181
+ : t.statusUsable(usableList.length)) +
1182
+ (sel.rotationEnabled ? " · " + t.autoRotating : "") +
1183
+ (autoPaused ? " · " + t.autoPaused : "") +
1184
+ (sel.inventory.installDir ? " · " + sel.inventory.installDir : "")),
787
1185
  ),
788
1186
  );
789
1187
  }
@@ -801,7 +1199,9 @@ window.__ModuleLoader__.load({
801
1199
  .webg-layer--enter { opacity: 0; }
802
1200
  .webg-layer--leave { opacity: 0; }
803
1201
  .webg-layer .webg-media {
804
- width: 100%; height: 100%; object-fit: cover; display: block;
1202
+ width: 100%; height: 100%; object-fit: var(--webg-fit, cover);
1203
+ object-position: var(--webg-position, center);
1204
+ display: block;
805
1205
  background: transparent; border: 0;
806
1206
  filter: blur(var(--webg-wallpaper-blur, 0px));
807
1207
  transform: scale(var(--webg-wallpaper-scale, 1));
@@ -813,6 +1213,7 @@ window.__ModuleLoader__.load({
813
1213
  position: fixed; inset: 0; z-index: -1;
814
1214
  pointer-events: none;
815
1215
  background: var(--webg-scrim-color, rgba(0, 0, 0, 0.25));
1216
+ transition: background 120ms linear;
816
1217
  }
817
1218
 
818
1219
  /* While active: transparent frame + sidebar, higher border contrast. */
@@ -861,7 +1262,9 @@ window.__ModuleLoader__.load({
861
1262
  }
862
1263
 
863
1264
  /* ── Picker chrome (token-driven, theme-aware) ── */
864
- .webg-picker { display: flex; flex-direction: column; gap: 10px; }
1265
+ /* padding-top lifts the first row off the settings-section divider so the
1266
+ search/language row breathes; gap keeps every row evenly spaced. */
1267
+ .webg-picker { display: flex; flex-direction: column; gap: 12px; padding-top: 10px; }
865
1268
  .webg-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
866
1269
  .webg-hint { font-size: 0.8em; opacity: 0.7; color: var(--dsw-alias-label-tertiary, inherit); }
867
1270
  .webg-error {
@@ -904,13 +1307,23 @@ window.__ModuleLoader__.load({
904
1307
  font-size: 0.85em;
905
1308
  }
906
1309
  .webg-text { flex: 1; min-width: 0; }
1310
+ .webg-search { max-width: 320px; }
1311
+ .webg-lang { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; }
907
1312
  .webg-playlist-select { flex: 1; min-width: 0; }
908
1313
  .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); }
1314
+ .webg-toggle {
1315
+ display: inline-flex; align-items: center; gap: 6px;
1316
+ accent-color: var(--dsw-alias-brand-primary, #3964fe);
1317
+ font-size: 0.85em; cursor: pointer;
1318
+ }
910
1319
 
911
1320
  .webg-slider { flex: 1; accent-color: var(--dsw-alias-brand-primary, #3964fe); }
912
1321
  .webg-slider-row { display: flex; align-items: center; gap: 8px; }
913
1322
 
1323
+ .webg-monitor-row { gap: 14px; }
1324
+ .webg-fps { font-variant-numeric: tabular-nums; }
1325
+ .webg-fps--low { color: var(--dsw-alias-state-warn-primary, #b93); opacity: 1; }
1326
+
914
1327
  /* Wallpaper thumbnail grid. */
915
1328
  .webg-grid {
916
1329
  display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
@@ -994,8 +1407,26 @@ window.__ModuleLoader__.load({
994
1407
  const inject = ["slots"];
995
1408
 
996
1409
  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).
1410
+ // 0. Language: follow the shell's locale service when present, and react
1411
+ // to its change events. Everything is optional — without the service
1412
+ // the browser language decides, and the manual override always wins.
1413
+ const localeSvc = typeof ctx.get === "function" ? ctx.get("locale") : undefined;
1414
+ if (localeSvc && typeof localeSvc.getLocale === "function") {
1415
+ try {
1416
+ const snap = localeSvc.getLocale();
1417
+ if (snap && snap.active) shellLang = snap.active === "en" ? "en" : "zh";
1418
+ } catch { /* keep browser fallback */ }
1419
+ }
1420
+ if (typeof ctx.on === "function") {
1421
+ try {
1422
+ ctx.on("locale/change", (snap) => {
1423
+ const next = snap && snap.active === "en" ? "en" : "zh";
1424
+ if (next !== shellLang) { shellLang = next; emit(); }
1425
+ });
1426
+ } catch { /* event bus unavailable → static language */ }
1427
+ }
1428
+
1429
+ // 1. Styles: owned by the fiber — injected on apply, REMOVED on dispose.
999
1430
  if (ctx.effect && typeof document !== "undefined") {
1000
1431
  ctx.effect(() => {
1001
1432
  let tag = document.querySelector("style[data-plugin-css=" + JSON.stringify(STYLE_TAG_ID) + "]");
@@ -1032,11 +1463,83 @@ window.__ModuleLoader__.load({
1032
1463
  });
1033
1464
  }
1034
1465
 
1035
- // 3. Settings row (this slot is NOT the overlay; safe to register into).
1466
+ // 3. Resource monitor: auto-pause on hidden tab / low battery, FPS meter.
1467
+ if (ctx.effect && typeof document !== "undefined" &&
1468
+ typeof document.addEventListener === "function") {
1469
+ ctx.effect(() => {
1470
+ const onVisibility = () => {
1471
+ if (!selection.pauseOnHidden) return;
1472
+ setAutoPause("hidden", document.visibilityState === "hidden");
1473
+ };
1474
+ document.addEventListener("visibilitychange", onVisibility);
1475
+ onVisibility();
1476
+ return () => {
1477
+ document.removeEventListener("visibilitychange", onVisibility);
1478
+ setAutoPause("hidden", false);
1479
+ };
1480
+ });
1481
+ }
1482
+ if (ctx.effect && typeof navigator !== "undefined" &&
1483
+ typeof navigator.getBattery === "function") {
1484
+ ctx.effect(() => {
1485
+ let battery = null;
1486
+ let cancelled = false;
1487
+ const update = () => {
1488
+ if (!battery || !selection.pauseOnBattery) return;
1489
+ setAutoPause("battery", !battery.charging && battery.level <= BATTERY_SAVER_LEVEL);
1490
+ };
1491
+ navigator.getBattery().then((b) => {
1492
+ if (cancelled) return;
1493
+ battery = b;
1494
+ b.addEventListener("chargingchange", update);
1495
+ b.addEventListener("levelchange", update);
1496
+ update();
1497
+ }).catch(() => {});
1498
+ return () => {
1499
+ cancelled = true;
1500
+ if (battery) {
1501
+ battery.removeEventListener("chargingchange", update);
1502
+ battery.removeEventListener("levelchange", update);
1503
+ }
1504
+ setAutoPause("battery", false);
1505
+ };
1506
+ });
1507
+ }
1508
+ if (ctx.effect && typeof window !== "undefined" &&
1509
+ typeof window.requestAnimationFrame === "function") {
1510
+ ctx.effect(() => {
1511
+ // FPS sampler: counts rAF callbacks once per second. rAF stops by
1512
+ // itself in hidden tabs, so the meter idles exactly when the page does.
1513
+ let running = true;
1514
+ let rafId = 0;
1515
+ let frames = 0;
1516
+ let window0 = 0;
1517
+ const loop = (t) => {
1518
+ if (!running) return;
1519
+ frames++;
1520
+ if (window0 === 0) window0 = t;
1521
+ else if (t - window0 >= 1000) {
1522
+ selection.fps = Math.round((frames * 1000) / (t - window0));
1523
+ frames = 0;
1524
+ window0 = t;
1525
+ emit();
1526
+ }
1527
+ rafId = window.requestAnimationFrame(loop);
1528
+ };
1529
+ rafId = window.requestAnimationFrame(loop);
1530
+ return () => {
1531
+ running = false;
1532
+ if (typeof window.cancelAnimationFrame === "function") window.cancelAnimationFrame(rafId);
1533
+ selection.fps = null;
1534
+ };
1535
+ });
1536
+ }
1537
+
1538
+ // 4. Settings row (this slot is NOT the overlay; safe to register into).
1036
1539
  if (ctx.slots) {
1037
1540
  ctx.slots.inject("settings.general.item", () =>
1038
1541
  ctx.slots.register(
1039
- { name: "settings.general.item", id: "we-background", order: 500, label: "壁纸背景 (Wallpaper Engine)" },
1542
+ { name: "settings.general.item", id: "we-background", order: 500, label: S().settingsLabel },
1040
1543
  () => React.createElement(WallpaperPicker),
1041
1544
  ),
1042
1545
  );