wallpaper-engine-dsh 1.0.2 → 1.0.4

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,8 @@ 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
+ /** Crossfade settle time before a leaving layer is detached. */
18
+ const LEAVE_MS = CROSSFADE_MS + 120;
17
19
  /** Battery at or below this level (and discharging) auto-pauses playback. */
18
20
  const BATTERY_SAVER_LEVEL = 0.2;
19
21
  /** Below this FPS the picker shows a performance hint. */
@@ -271,9 +273,9 @@ window.__ModuleLoader__.load({
271
273
  autoScrim: o.autoScrim !== false,
272
274
  lang: o.lang === "zh" || o.lang === "en" ? o.lang : "auto",
273
275
  fit: FIT_MODES.indexOf(o.fit) >= 0 ? o.fit : DEFAULTS.fit,
274
- zoom: Math.max(1, clampNum(o.zoom, 0.25, 4, DEFAULTS.zoom)),
275
- // Pan may have been persisted with a larger headroom than the zoom
276
- // allows (older builds let zoom go below 1) — clampCrop re-seats it.
276
+ // Older builds could persist zoom < 1 / unclamped pans — clampCrop()
277
+ // at store creation re-seats both into the gap-free envelope.
278
+ zoom: clampNum(o.zoom, 0.25, 4, DEFAULTS.zoom),
277
279
  offsetX: clampNum(o.offsetX, -200, 200, DEFAULTS.offsetX),
278
280
  offsetY: clampNum(o.offsetY, -200, 200, DEFAULTS.offsetY),
279
281
  };
@@ -339,21 +341,19 @@ window.__ModuleLoader__.load({
339
341
  }
340
342
 
341
343
  // ── Wallpaper predicates ─────────────────────────────────────────────────────
342
- // playable: real media (video/web) renders live.
343
344
  // Renderable kinds ONLY — scene/application wallpapers are .pkg scene
344
345
  // packages the browser cannot render (their live animation is Wallpaper
345
346
  // Engine's desktop job), so they are filtered out of the grid entirely
346
- // rather than offered as degraded preview stills.
347
+ // rather than offered as degraded preview stills. A wallpaper is selectable
348
+ // exactly when it is playable (real media renders live); preview-only
349
+ // entries exist in the inventory for the decode-fallback lookup but are
350
+ // never offered in the grid.
347
351
  function isPlayable(w) {
348
352
  return Boolean(w && w.playable && (w.type === "video" || w.type === "web"));
349
353
  }
350
354
 
351
- function isSelectable(w) {
352
- return isPlayable(w);
353
- }
354
-
355
355
  function selectableInventory() {
356
- return selection.inventory.wallpapers.filter(isSelectable);
356
+ return selection.inventory.wallpapers.filter(isPlayable);
357
357
  }
358
358
 
359
359
  // ── Inventory ────────────────────────────────────────────────────────────────
@@ -403,25 +403,20 @@ window.__ModuleLoader__.load({
403
403
  selection.rotationGroupId = "";
404
404
  persistSelection();
405
405
  }
406
- if (selection.rotationEnabled) {
407
- if (!selection.rotationGroupId) {
408
- const usable = firstUsableGroup();
409
- if (usable) selection.rotationGroupId = usable.id;
410
- else selection.rotationEnabled = false;
411
- } else if (rotationCandidates().length < 2) {
412
- const usable = firstUsableGroup();
413
- if (usable && usable.id !== selection.rotationGroupId) selection.rotationGroupId = usable.id;
414
- else if (!usable) selection.rotationEnabled = false;
415
- }
406
+ // Rotation bookkeeping. A chosen list is never migrated away from — a
407
+ // list with fewer than 2 usable wallpapers simply keeps rotation
408
+ // disarmed (the timer needs a pair to alternate), and a hand-picked
409
+ // wallpaper outside the active list keeps playing until the next
410
+ // rotation tick brings the list back around.
411
+ if (selection.rotationEnabled && !selection.rotationGroupId) {
412
+ const usable = firstUsableGroup();
413
+ if (usable) selection.rotationGroupId = usable.id;
414
+ else selection.rotationEnabled = false;
416
415
  persistSelection();
417
416
  }
418
417
 
419
418
  // Drop a selection whose wallpaper vanished or became unusable.
420
- if (selection.id && !selection.inventory.wallpapers.some((w) => w.id === selection.id && isSelectable(w))) {
421
- selection.id = "";
422
- persistSelection();
423
- }
424
- if (selection.rotationEnabled && selection.id && !rotationCandidates().some((w) => w.id === selection.id)) {
419
+ if (selection.id && !selection.inventory.wallpapers.some((w) => w.id === selection.id && isPlayable(w))) {
425
420
  selection.id = "";
426
421
  persistSelection();
427
422
  }
@@ -441,7 +436,7 @@ window.__ModuleLoader__.load({
441
436
  function groupWallpapers(group) {
442
437
  if (!group || !Array.isArray(group.wallpaperIds)) return [];
443
438
  const byId = new Map(selection.inventory.wallpapers.map((w) => [w.id, w]));
444
- return group.wallpaperIds.map((id) => byId.get(id)).filter(isSelectable);
439
+ return group.wallpaperIds.map((id) => byId.get(id)).filter(isPlayable);
445
440
  }
446
441
 
447
442
  function rotationCandidates() {
@@ -457,7 +452,7 @@ window.__ModuleLoader__.load({
457
452
  if (!source) return false;
458
453
  const byId = new Map(selection.inventory.wallpapers.map((w) => [w.id, w]));
459
454
  const ids = Array.isArray(source.wallpaperIds)
460
- ? source.wallpaperIds.filter((id) => isSelectable(byId.get(id)))
455
+ ? source.wallpaperIds.filter((id) => isPlayable(byId.get(id)))
461
456
  : [];
462
457
  if (ids.length < 2) return false;
463
458
  selection.rotationGroups.push({
@@ -672,7 +667,7 @@ window.__ModuleLoader__.load({
672
667
  media.setAttribute("frameborder", "0");
673
668
  media.setAttribute("scrolling", "no");
674
669
  media.setAttribute("title", S().iframeTitle);
675
- media.className = "webg-media webg-iframe";
670
+ media.className = "webg-media";
676
671
  } else {
677
672
  // Static fallback: a playable wallpaper demoted after repeated media
678
673
  // failures — its preview still beats a black screen.
@@ -689,7 +684,10 @@ window.__ModuleLoader__.load({
689
684
  }
690
685
 
691
686
  function attachStillSampling(media, sel) {
692
- const type = (sel && sel.type) || (media.tagName === "IMG" ? "image" : null);
687
+ const type = sel && sel.type;
688
+ // Web wallpapers cannot be sampled — the sandboxed iframe's origin is
689
+ // opaque, so its pixels are unreadable. Only video/img frames feed the
690
+ // smart veil.
693
691
  if (type !== "video" && type !== "image") return;
694
692
  media.addEventListener(type === "video" ? "loadeddata" : "load", () => {
695
693
  sampleWallpaperLuminance();
@@ -717,9 +715,10 @@ window.__ModuleLoader__.load({
717
715
  // The media itself is unusable here — fall back to the still preview.
718
716
  demotedToPreview.add(id);
719
717
  applySelection(id);
720
- } else if (!w || (!w.preview && !w.media)) {
721
- // Nothing renderable at all (preview-less or vanished from the
722
- // inventory): clear the selection instead of a black screen loop.
718
+ } else {
719
+ // Unrenderable here with no preview to fall back on (or the entry has
720
+ // vanished from the inventory entirely): clear the selection rather
721
+ // than leave a dead element on the layer.
723
722
  applySelection("");
724
723
  }
725
724
  });
@@ -792,11 +791,10 @@ window.__ModuleLoader__.load({
792
791
  };
793
792
  const onPointerMove = (e) => {
794
793
  if (!drag) return;
795
- // translate % refers to the MEDIA BOX, which (blur overscan) is wider
796
- // than the viewport by 2× the wallpaper blur — divide by that, so the
797
- // content tracks the cursor 1:1 at any blur setting.
798
- const w = (overlay.clientWidth || 1) + 2 * selection.wallpaperBlur;
799
- const h = (overlay.clientHeight || 1) + 2 * selection.wallpaperBlur;
794
+ // translate % refers to the media box, which is the viewport exactly —
795
+ // divide the pixel delta by it so the content tracks the cursor 1:1.
796
+ const w = overlay.clientWidth || 1;
797
+ const h = overlay.clientHeight || 1;
800
798
  selection.offsetX += ((e.clientX - drag.x) / w) * 100;
801
799
  selection.offsetY += ((e.clientY - drag.y) / h) * 100;
802
800
  clampCrop(); // gap-free guarantee: pan never outruns the zoom headroom
@@ -874,6 +872,30 @@ window.__ModuleLoader__.load({
874
872
  return token;
875
873
  }
876
874
 
875
+ /**
876
+ * Swap re-minted URLs into the LIVE media element instead of remounting.
877
+ * An explicit refresh re-mints every token, so the element's old URL is
878
+ * already dead — the next seek or buffer-miss would 404 on it and trigger
879
+ * a full recovery restart. Hot-swapping keeps the element identity (and
880
+ * for video, the playback position) intact across the token change.
881
+ */
882
+ function refreshLiveMediaUrls(layer) {
883
+ const video = layer.querySelector("video");
884
+ if (video && video.getAttribute("src") !== selection.url) {
885
+ const at = video.currentTime;
886
+ video.src = selection.url;
887
+ if (at > 0) {
888
+ video.addEventListener("loadedmetadata", () => {
889
+ try { video.currentTime = at; } catch { /* not seekable yet */ }
890
+ }, { once: true });
891
+ }
892
+ }
893
+ const img = layer.querySelector("img");
894
+ if (img && img.getAttribute("src") !== selection.url) img.src = selection.url;
895
+ const frame = layer.querySelector("iframe");
896
+ if (frame && frame.getAttribute("src") !== selection.url) frame.src = selection.url;
897
+ }
898
+
877
899
  function syncLayers() {
878
900
  const existing = document.getElementById(LAYER_ID);
879
901
 
@@ -885,7 +907,9 @@ window.__ModuleLoader__.load({
885
907
  const gotKey = existing && existing.dataset.webgKey;
886
908
 
887
909
  if (existing && gotKey === wantKey && !forceRemount) {
888
- // Same wallpaper — only play/pause may have changed.
910
+ // Same wallpaper — adopt any re-minted URL, then only play/pause may
911
+ // have changed.
912
+ refreshLiveMediaUrls(existing);
889
913
  } else {
890
914
  forceRemount = false;
891
915
  // Crossfade: the new layer fades in over the old one; the old layer
@@ -902,7 +926,7 @@ window.__ModuleLoader__.load({
902
926
  if (existing) {
903
927
  existing.classList.add("webg-layer--leave");
904
928
  const stale = existing;
905
- schedule(() => stale.remove(), CROSSFADE_MS + 120);
929
+ schedule(() => stale.remove(), LEAVE_MS);
906
930
  }
907
931
  }
908
932
  const node = document.getElementById(LAYER_ID);
@@ -924,7 +948,7 @@ window.__ModuleLoader__.load({
924
948
  if (s) s.remove();
925
949
  document.body.removeAttribute(ACTIVE_ATTR);
926
950
  }
927
- }, CROSSFADE_MS + 120);
951
+ }, LEAVE_MS);
928
952
  }
929
953
 
930
954
  // Scrim: present while a wallpaper is active. On clear it outlives the
@@ -946,6 +970,11 @@ window.__ModuleLoader__.load({
946
970
  // sample; iframes of web wallpapers can never be sampled — opaque origin).
947
971
  let sampledLuminance = null;
948
972
  let sampleCanvas = null;
973
+ // Snapshot of every input applyEffects reads, from its last full write. The
974
+ // store emits once per second for the FPS readout — without this guard every
975
+ // tick would rewrite all CSS variables AND force a synchronous layout for
976
+ // nothing. clearEffects() resets it so the next apply always writes.
977
+ let lastEffectKey = null;
949
978
 
950
979
  /** Average Rec.601 luminance (0..1) of the current video/img, or null. */
951
980
  function sampleWallpaperLuminance() {
@@ -973,11 +1002,18 @@ window.__ModuleLoader__.load({
973
1002
  }
974
1003
 
975
1004
  function applyEffects() {
976
- const s = document.body.style;
977
1005
  // The veil OPPOSES the text colour: dark theme (light text) darkens bright
978
1006
  // wallpapers, light theme (dark text) brightens dark ones — either way the
979
1007
  // text side wins. This is what makes ANY wallpaper survivable.
980
1008
  const dark = Boolean(document.body.hasAttribute && document.body.hasAttribute("data-ds-dark-theme"));
1009
+ const key = [
1010
+ dark, selection.scrim, selection.border, selection.blur, selection.wallpaperBlur,
1011
+ selection.autoScrim, sampledLuminance, selection.fit, selection.zoom,
1012
+ selection.offsetX, selection.offsetY,
1013
+ ].join("\u0000");
1014
+ if (key === lastEffectKey) return; // nothing it reads changed → no writes, no reflow
1015
+ lastEffectKey = key;
1016
+ const s = document.body.style;
981
1017
  let alpha = selection.scrim;
982
1018
  if (selection.autoScrim && sampledLuminance !== null) {
983
1019
  // Distance of the wallpaper's brightness from the "comfortable middle"
@@ -994,7 +1030,6 @@ window.__ModuleLoader__.load({
994
1030
  // The glass "colour melt" scales with blur radius: 0 blur → no melt.
995
1031
  s.setProperty("--webg-saturate", String(1.15 + selection.blur * 0.028));
996
1032
  s.setProperty("--webg-glass-brightness", "1.04");
997
- s.setProperty("--webg-wallpaper-blur", selection.wallpaperBlur + "px");
998
1033
  // At 0 blur, keep the media OUT of the filter pipeline entirely: even
999
1034
  // blur(0px) forces an extra raster/compositing pass, and the noise overlay
1000
1035
  // would still occupy a layer at opacity 0. filter:none + display:none keep
@@ -1025,13 +1060,13 @@ window.__ModuleLoader__.load({
1025
1060
  }
1026
1061
 
1027
1062
  function clearEffects() {
1063
+ lastEffectKey = null;
1028
1064
  const s = document.body.style;
1029
1065
  s.removeProperty("--webg-scrim-color");
1030
1066
  s.removeProperty("--webg-border-alpha");
1031
1067
  s.removeProperty("--webg-blur");
1032
1068
  s.removeProperty("--webg-saturate");
1033
1069
  s.removeProperty("--webg-glass-brightness");
1034
- s.removeProperty("--webg-wallpaper-blur");
1035
1070
  s.removeProperty("--webg-media-filter");
1036
1071
  s.removeProperty("--webg-noise-display");
1037
1072
  s.removeProperty("--webg-noise-opacity");
@@ -1085,6 +1120,16 @@ window.__ModuleLoader__.load({
1085
1120
  return (w.title || "").toLowerCase().includes(needle) || (w.id || "").toLowerCase().includes(needle);
1086
1121
  }
1087
1122
 
1123
+ /** Preview <img>, or the "no preview" placeholder — shared by grid + editor. */
1124
+ function PreviewMedia(w, t) {
1125
+ return w.preview
1126
+ ? React.createElement("img", {
1127
+ src: w.preview, alt: w.title, loading: "lazy", decoding: "async",
1128
+ onError: (e) => { e.target.style.display = "none"; },
1129
+ })
1130
+ : React.createElement("span", { className: "webg-card-placeholder" }, t.noPreview);
1131
+ }
1132
+
1088
1133
  function ThumbCard(w, selected, onClick, t) {
1089
1134
  return React.createElement("button", {
1090
1135
  key: w.id,
@@ -1093,426 +1138,484 @@ window.__ModuleLoader__.load({
1093
1138
  onClick: onClick,
1094
1139
  title: w.title + "(" + typeBadge(w, t) + ")",
1095
1140
  },
1096
- w.preview
1097
- ? React.createElement("img", {
1098
- src: w.preview, alt: w.title, loading: "lazy", decoding: "async",
1099
- onError: (e) => { e.target.style.display = "none"; },
1100
- })
1101
- : React.createElement("span", { className: "webg-card-placeholder" }, t.noPreview),
1141
+ PreviewMedia(w, t),
1102
1142
  React.createElement("span", { className: "webg-card-type" }, typeBadge(w, t)),
1103
1143
  React.createElement("span", { className: "webg-card-title" }, w.title),
1104
1144
  );
1105
1145
  }
1106
1146
 
1107
- function WallpaperPicker() {
1108
- const sel = useStore();
1109
- const [query, setQuery] = React.useState("");
1110
- const [editorQuery, setEditorQuery] = React.useState("");
1111
- const t = S();
1112
- const onTogglePlay = () => { selection.playing = !selection.playing; emit(); };
1113
- const onClear = () => applySelection("");
1114
- const onRefresh = () => {
1115
- // A manual refresh also retries wallpapers previously demoted to their
1116
- // preview after decode failures.
1117
- demotedToPreview.clear();
1118
- mediaFailures.clear();
1119
- lastRecoveryAt.clear();
1120
- loadInventory(true);
1121
- };
1122
- const onLangChange = (e) => {
1123
- selection.lang = e.target.value;
1124
- persistSelection();
1125
- emit();
1126
- };
1127
- const onGroupChange = (e) => {
1128
- selection.rotationGroupId = e.target.value;
1129
- if (selection.rotationEnabled) {
1130
- const first = rotationCandidates()[0];
1131
- if (first) applySelection(first.id);
1132
- else applySelection("");
1133
- return;
1134
- }
1135
- persistSelection();
1136
- syncRotationTimer();
1137
- emit();
1138
- };
1139
- const onToggleRotation = () => {
1140
- selection.rotationEnabled = !selection.rotationEnabled;
1141
- // The checkbox is disabled without a usable group, so a group id always
1142
- // exists here when enabling.
1143
- if (selection.rotationEnabled &&
1144
- !rotationCandidates().some((w) => w.id === selection.id)) {
1145
- const first = rotationCandidates()[0];
1146
- if (first) { applySelection(first.id); return; }
1147
- }
1148
- persistSelection();
1149
- syncRotationTimer();
1150
- emit();
1151
- };
1152
- const onGroupInterval = (e) => {
1153
- const group = activeRotationGroup();
1154
- if (!group) return;
1155
- group.interval = clampNum(Number(e.target.value), 1, 1440, DEFAULTS.rotationInterval);
1156
- persistSelection();
1157
- syncRotationTimer();
1158
- emit();
1159
- };
1160
- const onDeleteGroup = () => {
1161
- const group = activeRotationGroup();
1162
- if (!group) return;
1163
- if (typeof window !== "undefined" && typeof window.confirm === "function") {
1164
- if (!window.confirm(t.confirmDelete(group.name))) return;
1165
- }
1166
- deleteGroup(group.id);
1167
- };
1168
- const onTogglePauseOnHidden = () => {
1169
- selection.pauseOnHidden = !selection.pauseOnHidden;
1170
- if (selection.pauseOnHidden) {
1171
- // Re-enabling while already hidden must re-apply the reason — the
1172
- // visibilitychange listener only fires on CHANGES.
1173
- if (typeof document !== "undefined" && document.visibilityState === "hidden") {
1174
- setAutoPause("hidden", true);
1175
- }
1176
- } else {
1177
- setAutoPause("hidden", false);
1178
- }
1179
- persistSelection();
1180
- emit();
1181
- };
1182
- const onTogglePauseOnBattery = () => {
1183
- selection.pauseOnBattery = !selection.pauseOnBattery;
1184
- if (selection.pauseOnBattery) applyBatteryPause(); // re-evaluate NOW
1185
- else setAutoPause("battery", false);
1186
- persistSelection();
1187
- emit();
1188
- };
1189
- const onToggleAutoScrim = () => {
1190
- selection.autoScrim = !selection.autoScrim;
1191
- persistSelection();
1192
- applyEffects();
1193
- emit();
1194
- };
1147
+ /** Preset switch intervals offered by the rotation UI (minutes). */
1148
+ const ROTATION_INTERVALS = [1, 5, 10, 30, 60, 120];
1149
+
1150
+ /**
1151
+ * <option> list for an interval select. A persisted interval outside the
1152
+ * preset steps (imported groups, hand-edited storage) still gets a matching
1153
+ * option — otherwise the select would silently display the first preset
1154
+ * instead of reality.
1155
+ */
1156
+ function intervalOptions(current, t) {
1157
+ return [...new Set([...ROTATION_INTERVALS, current])]
1158
+ .sort((x, y) => x - y)
1159
+ .map((minutes) => React.createElement("option", {
1160
+ key: minutes, value: String(minutes),
1161
+ }, t.minutes(minutes)));
1162
+ }
1195
1163
 
1196
- const onScrim = (pct) => { selection.scrim = pct / 100; persistSelection(); applyEffects(); emit(); };
1197
- const onBorder = (pct) => { selection.border = pct / 100; persistSelection(); applyEffects(); emit(); };
1198
- const onBlur = (px) => { selection.blur = px; persistSelection(); applyEffects(); emit(); };
1199
- const onWallpaperBlur = (px) => { selection.wallpaperBlur = px; persistSelection(); applyEffects(); emit(); };
1200
- const onFit = (e) => {
1201
- selection.fit = FIT_MODES.indexOf(e.target.value) >= 0 ? e.target.value : "cover";
1202
- persistSelection();
1203
- applyEffects();
1204
- emit();
1205
- };
1164
+ // ── Picker actions (module scope) ────────────────────────────────────────────
1165
+ // Every control mutates the shared selection store and emits; none closes
1166
+ // over React state, so they live outside the component — stable across
1167
+ // re-renders (the picker re-renders once per second for the FPS readout)
1168
+ // and testable without mounting anything.
1206
1169
 
1207
- if (!sel.loaded) {
1208
- return React.createElement("div", { className: "webg-picker" },
1209
- React.createElement("span", { className: "webg-hint" }, t.scanning));
1170
+ function onTogglePlay() { selection.playing = !selection.playing; emit(); }
1171
+
1172
+ function onClear() { applySelection(""); }
1173
+
1174
+ function onRefresh() {
1175
+ // A manual refresh also retries wallpapers previously demoted to their
1176
+ // preview after decode failures.
1177
+ demotedToPreview.clear();
1178
+ mediaFailures.clear();
1179
+ lastRecoveryAt.clear();
1180
+ loadInventory(true);
1181
+ }
1182
+
1183
+ function onLangChange(e) {
1184
+ selection.lang = e.target.value;
1185
+ persistSelection();
1186
+ emit();
1187
+ }
1188
+
1189
+ function onGroupChange(e) {
1190
+ selection.rotationGroupId = e.target.value;
1191
+ if (selection.rotationEnabled) {
1192
+ const first = rotationCandidates()[0];
1193
+ if (first) applySelection(first.id);
1194
+ else applySelection("");
1195
+ return;
1210
1196
  }
1211
- if (sel.inventory.error) {
1212
- return React.createElement("div", { className: "webg-picker" },
1213
- React.createElement("div", { className: "webg-error" },
1214
- t.connError + sel.inventory.error),
1215
- React.createElement("button", {
1216
- className: "webg-btn", type: "button", onClick: onRefresh, disabled: sel.loading,
1217
- }, sel.loading ? t.refreshing : t.retry));
1197
+ persistSelection();
1198
+ syncRotationTimer();
1199
+ emit();
1200
+ }
1201
+
1202
+ function onToggleRotation() {
1203
+ selection.rotationEnabled = !selection.rotationEnabled;
1204
+ // The checkbox is disabled without a usable group, so a group id always
1205
+ // exists here when enabling.
1206
+ if (selection.rotationEnabled &&
1207
+ !rotationCandidates().some((w) => w.id === selection.id)) {
1208
+ const first = rotationCandidates()[0];
1209
+ if (first) { applySelection(first.id); return; }
1218
1210
  }
1211
+ persistSelection();
1212
+ syncRotationTimer();
1213
+ emit();
1214
+ }
1219
1215
 
1220
- const usableList = selectableInventory();
1221
- const q = query.trim();
1222
- const filteredList = usableList.filter((w) => matchesQuery(w, q));
1223
- const groups = sel.rotationGroups;
1216
+ function onGroupInterval(e) {
1224
1217
  const group = activeRotationGroup();
1225
- const candidates = rotationCandidates();
1226
- const usableCount = candidates.length;
1227
- const editing = sel.editing;
1228
- const autoPaused = sel.autoPauseReasons.size > 0;
1229
- const INTERVALS = [1, 5, 10, 30, 60, 120];
1218
+ if (!group) return;
1219
+ group.interval = clampNum(Number(e.target.value), 1, 1440, DEFAULTS.rotationInterval);
1220
+ persistSelection();
1221
+ syncRotationTimer();
1222
+ emit();
1223
+ }
1230
1224
 
1231
- return React.createElement("div", { className: "webg-picker" },
1232
- // ── Search + language ──
1225
+ function onDeleteGroup() {
1226
+ const group = activeRotationGroup();
1227
+ if (!group) return;
1228
+ if (typeof window !== "undefined" && typeof window.confirm === "function") {
1229
+ if (!window.confirm(S().confirmDelete(group.name))) return;
1230
+ }
1231
+ deleteGroup(group.id);
1232
+ }
1233
+
1234
+ function onTogglePauseOnHidden() {
1235
+ selection.pauseOnHidden = !selection.pauseOnHidden;
1236
+ if (selection.pauseOnHidden) {
1237
+ // Re-enabling while already hidden must re-apply the reason — the
1238
+ // visibilitychange listener only fires on CHANGES.
1239
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
1240
+ setAutoPause("hidden", true);
1241
+ }
1242
+ } else {
1243
+ setAutoPause("hidden", false);
1244
+ }
1245
+ persistSelection();
1246
+ emit();
1247
+ }
1248
+
1249
+ function onTogglePauseOnBattery() {
1250
+ selection.pauseOnBattery = !selection.pauseOnBattery;
1251
+ if (selection.pauseOnBattery) applyBatteryPause(); // re-evaluate NOW
1252
+ else setAutoPause("battery", false);
1253
+ persistSelection();
1254
+ emit();
1255
+ }
1256
+
1257
+ function onToggleAutoScrim() {
1258
+ selection.autoScrim = !selection.autoScrim;
1259
+ persistSelection();
1260
+ applyEffects();
1261
+ emit();
1262
+ }
1263
+
1264
+ function onScrim(pct) { selection.scrim = pct / 100; persistSelection(); applyEffects(); emit(); }
1265
+ function onBorder(pct) { selection.border = pct / 100; persistSelection(); applyEffects(); emit(); }
1266
+ function onBlur(px) { selection.blur = px; persistSelection(); applyEffects(); emit(); }
1267
+ function onWallpaperBlur(px) { selection.wallpaperBlur = px; persistSelection(); applyEffects(); emit(); }
1268
+
1269
+ function onFit(e) {
1270
+ selection.fit = FIT_MODES.indexOf(e.target.value) >= 0 ? e.target.value : "cover";
1271
+ persistSelection();
1272
+ applyEffects();
1273
+ emit();
1274
+ }
1275
+
1276
+ // ── Picker sections (presentational; every input arrives as an argument) ────
1277
+ // WallpaperPicker composes these in a fixed order. Each one owns exactly one
1278
+ // visual row/block of the settings panel and produces the same DOM the old
1279
+ // monolithic render did.
1280
+
1281
+ function SearchRow(t, sel, query, setQuery, matched, total) {
1282
+ return React.createElement("div", { className: "webg-row" },
1283
+ React.createElement("input", {
1284
+ className: "webg-text webg-search", type: "search",
1285
+ placeholder: t.searchPlaceholder,
1286
+ value: query,
1287
+ onInput: (e) => setQuery(e.target.value),
1288
+ }),
1289
+ query.trim() && React.createElement("span", { className: "webg-hint" },
1290
+ t.matched(matched, total)),
1291
+ React.createElement("label", { className: "webg-lang" },
1292
+ React.createElement("span", { className: "webg-hint" }, t.language),
1293
+ React.createElement("select", {
1294
+ className: "webg-select webg-lang-select",
1295
+ value: sel.lang,
1296
+ onChange: onLangChange,
1297
+ },
1298
+ React.createElement("option", { value: "auto" }, t.langAuto),
1299
+ React.createElement("option", { value: "zh" }, "中文"),
1300
+ React.createElement("option", { value: "en" }, "English"),
1301
+ ),
1302
+ ),
1303
+ );
1304
+ }
1305
+
1306
+ function WallpaperGrid(t, sel, usableList, filteredList, q) {
1307
+ return React.createElement("div", { className: "webg-grid" },
1308
+ React.createElement("button", {
1309
+ className: "webg-card" + (sel.id ? "" : " webg-card--selected"),
1310
+ type: "button",
1311
+ onClick: onClear,
1312
+ title: t.closeTitle,
1313
+ },
1314
+ React.createElement("span", { className: "webg-card-close" }, t.closeCard),
1315
+ ),
1316
+ usableList.length === 0
1317
+ ? React.createElement("span", { className: "webg-hint" },
1318
+ sel.inventory.installDir
1319
+ ? (sel.inventory.total > 0
1320
+ ? t.emptyNoUsable(sel.inventory.total)
1321
+ : t.emptyNoProjects)
1322
+ : t.emptyNoInstall)
1323
+ : filteredList.length === 0
1324
+ ? React.createElement("span", { className: "webg-hint" }, t.noMatch(q))
1325
+ : filteredList.map((w) => ThumbCard(w, w.id === sel.id, () => applySelection(w.id), t)),
1326
+ );
1327
+ }
1328
+
1329
+ function ActionRow(t, sel) {
1330
+ return React.createElement("div", { className: "webg-row" },
1331
+ React.createElement("button", {
1332
+ className: "webg-btn", type: "button",
1333
+ onClick: onTogglePlay, disabled: !sel.url || sel.type !== "video",
1334
+ }, sel.playing ? t.pause : t.play),
1335
+ React.createElement("button", {
1336
+ className: "webg-btn", type: "button",
1337
+ onClick: onClear, disabled: !sel.id,
1338
+ }, t.close),
1339
+ React.createElement("button", {
1340
+ className: "webg-btn", type: "button",
1341
+ onClick: onRefresh, disabled: sel.loading,
1342
+ }, sel.loading ? t.refreshing : t.refresh),
1343
+ );
1344
+ }
1345
+
1346
+ function RotationListRow(t, sel, groups) {
1347
+ return React.createElement("div", { className: "webg-row webg-playlist-row" },
1348
+ React.createElement("span", { className: "webg-hint webg-label" }, t.rotationList),
1349
+ React.createElement("select", {
1350
+ className: "webg-select webg-playlist-select",
1351
+ value: sel.rotationGroupId,
1352
+ onChange: onGroupChange,
1353
+ disabled: groups.length === 0,
1354
+ },
1355
+ React.createElement("option", { value: "" }, groups.length ? t.selectList : t.noLists),
1356
+ ...groups.map((g) => React.createElement("option", {
1357
+ key: g.id, value: g.id,
1358
+ }, t.groupOption(g.name, groupWallpapers(g).length, g.interval))),
1359
+ ),
1360
+ React.createElement("button", {
1361
+ className: "webg-btn", type: "button", onClick: startCreateGroup,
1362
+ }, t.newList),
1363
+ React.createElement("button", {
1364
+ className: "webg-btn", type: "button",
1365
+ onClick: () => startEditGroup(sel.rotationGroupId),
1366
+ disabled: !sel.rotationGroupId,
1367
+ }, t.editList),
1368
+ React.createElement("button", {
1369
+ className: "webg-btn", type: "button",
1370
+ onClick: onDeleteGroup,
1371
+ disabled: !sel.rotationGroupId,
1372
+ }, t.deleteList),
1373
+ );
1374
+ }
1375
+
1376
+ /** One toggleable wallpaper chip inside the group editor. */
1377
+ function EditorCard(w, editing, t) {
1378
+ const checked = editing.wallpaperIds.indexOf(w.id) >= 0;
1379
+ return React.createElement("button", {
1380
+ key: w.id,
1381
+ className: "webg-editor-card" + (checked ? " webg-editor-card--checked" : ""),
1382
+ type: "button",
1383
+ title: w.title + "(" + typeBadge(w, t) + ")",
1384
+ onClick: () => {
1385
+ const i = editing.wallpaperIds.indexOf(w.id);
1386
+ if (i >= 0) editing.wallpaperIds.splice(i, 1);
1387
+ else editing.wallpaperIds.push(w.id);
1388
+ emit();
1389
+ },
1390
+ },
1391
+ PreviewMedia(w, t),
1392
+ checked && React.createElement("span", { className: "webg-editor-check" }, "✓"),
1393
+ );
1394
+ }
1395
+
1396
+ function GroupEditorPanel(t, sel, usableList, editorQuery, setEditorQuery) {
1397
+ const editing = sel.editing;
1398
+ return React.createElement("div", { className: "webg-editor" },
1233
1399
  React.createElement("div", { className: "webg-row" },
1400
+ React.createElement("span", { className: "webg-hint webg-label" }, t.name),
1234
1401
  React.createElement("input", {
1235
- className: "webg-text webg-search", type: "search",
1236
- placeholder: t.searchPlaceholder,
1237
- value: query,
1238
- onInput: (e) => setQuery(e.target.value),
1402
+ className: "webg-text", type: "text",
1403
+ value: editing.name,
1404
+ onInput: (e) => { editing.name = e.target.value; emit(); },
1239
1405
  }),
1240
- q && React.createElement("span", { className: "webg-hint" },
1241
- t.matched(filteredList.length, usableList.length)),
1242
- React.createElement("label", { className: "webg-lang" },
1243
- React.createElement("span", { className: "webg-hint" }, t.language),
1244
- React.createElement("select", {
1245
- className: "webg-select webg-lang-select",
1246
- value: sel.lang,
1247
- onChange: onLangChange,
1248
- },
1249
- React.createElement("option", { value: "auto" }, t.langAuto),
1250
- React.createElement("option", { value: "zh" }, "中文"),
1251
- React.createElement("option", { value: "en" }, "English"),
1252
- ),
1253
- ),
1254
1406
  ),
1255
- // ── Grid ──
1256
- React.createElement("div", { className: "webg-grid" },
1257
- React.createElement("button", {
1258
- className: "webg-card" + (sel.id ? "" : " webg-card--selected"),
1259
- type: "button",
1260
- onClick: onClear,
1261
- title: t.closeTitle,
1407
+ React.createElement("div", { className: "webg-row" },
1408
+ React.createElement("span", { className: "webg-hint webg-label" }, t.interval),
1409
+ React.createElement("select", {
1410
+ className: "webg-select",
1411
+ value: String(editing.interval),
1412
+ onChange: (e) => { editing.interval = clampNum(Number(e.target.value), 1, 1440, DEFAULTS.rotationInterval); emit(); },
1413
+ },
1414
+ ...intervalOptions(editing.interval, t)),
1415
+ React.createElement("span", { className: "webg-hint webg-label" }, t.order),
1416
+ React.createElement("select", {
1417
+ className: "webg-select webg-playlist-select",
1418
+ value: editing.order,
1419
+ onChange: (e) => { editing.order = e.target.value; emit(); },
1262
1420
  },
1263
- React.createElement("span", { className: "webg-card-close" }, t.closeCard),
1421
+ React.createElement("option", { value: "sequence" }, t.sequence),
1422
+ React.createElement("option", { value: "random" }, t.random),
1264
1423
  ),
1265
- usableList.length === 0
1266
- ? React.createElement("span", { className: "webg-hint" },
1267
- sel.inventory.installDir
1268
- ? (sel.inventory.total > 0
1269
- ? t.emptyNoUsable(sel.inventory.total)
1270
- : t.emptyNoProjects)
1271
- : t.emptyNoInstall)
1272
- : filteredList.length === 0
1273
- ? React.createElement("span", { className: "webg-hint" }, t.noMatch(q))
1274
- : filteredList.map((w) => ThumbCard(w, w.id === sel.id, () => applySelection(w.id), t)),
1275
1424
  ),
1276
- // ── Actions ──
1277
- React.createElement("div", { className: "webg-row" },
1278
- React.createElement("button", {
1279
- className: "webg-btn", type: "button",
1280
- onClick: onTogglePlay, disabled: !sel.url || sel.type !== "video",
1281
- }, sel.playing ? t.pause : t.play),
1282
- React.createElement("button", {
1283
- className: "webg-btn", type: "button",
1284
- onClick: onClear, disabled: !sel.id,
1285
- }, t.close),
1286
- React.createElement("button", {
1287
- className: "webg-btn", type: "button",
1288
- onClick: onRefresh, disabled: sel.loading,
1289
- }, sel.loading ? t.refreshing : t.refresh),
1425
+ React.createElement("input", {
1426
+ className: "webg-text", type: "search",
1427
+ placeholder: t.filterPlaceholder,
1428
+ value: editorQuery,
1429
+ onInput: (e) => setEditorQuery(e.target.value),
1430
+ }),
1431
+ React.createElement("div", { className: "webg-editor-grid" },
1432
+ (() => {
1433
+ const eq = editorQuery.trim();
1434
+ const pool = usableList.filter((w) => matchesQuery(w, eq));
1435
+ if (usableList.length === 0) {
1436
+ return React.createElement("span", { className: "webg-hint" }, t.noUsable);
1437
+ }
1438
+ if (pool.length === 0) {
1439
+ return React.createElement("span", { className: "webg-hint" }, t.noMatch(eq));
1440
+ }
1441
+ return pool.map((w) => EditorCard(w, editing, t));
1442
+ })(),
1290
1443
  ),
1291
- // ── Rotation groups ──
1292
- React.createElement("div", { className: "webg-row webg-playlist-row" },
1293
- React.createElement("span", { className: "webg-hint webg-label" }, t.rotationList),
1294
- React.createElement("select", {
1444
+ React.createElement("div", { className: "webg-row" },
1445
+ React.createElement("span", { className: "webg-hint" }, t.selectedCount(editing.wallpaperIds.length)),
1446
+ sel.inventory.playlists.length > 0 && React.createElement("select", {
1295
1447
  className: "webg-select webg-playlist-select",
1296
- value: sel.rotationGroupId,
1297
- onChange: onGroupChange,
1298
- disabled: groups.length === 0,
1448
+ value: "",
1449
+ onChange: (e) => {
1450
+ const p = sel.inventory.playlists.find((pl) => pl.id === e.target.value);
1451
+ if (p) importPlaylistIntoDraft(p);
1452
+ },
1299
1453
  },
1300
- React.createElement("option", { value: "" }, groups.length ? t.selectList : t.noLists),
1301
- ...groups.map((g) => React.createElement("option", {
1302
- key: g.id, value: g.id,
1303
- }, t.groupOption(g.name, groupWallpapers(g).length, g.interval))),
1454
+ React.createElement("option", { value: "" }, t.importPlaylist),
1455
+ ...sel.inventory.playlists.map((p) => React.createElement("option", {
1456
+ key: p.id, value: p.id,
1457
+ }, t.playlistOption(p.name, p.portableCount || 0))),
1304
1458
  ),
1459
+ ),
1460
+ React.createElement("div", { className: "webg-row" },
1305
1461
  React.createElement("button", {
1306
- className: "webg-btn", type: "button", onClick: startCreateGroup,
1307
- }, t.newList),
1462
+ className: "webg-btn webg-btn--primary", type: "button",
1463
+ onClick: saveEditingGroup,
1464
+ }, t.save),
1308
1465
  React.createElement("button", {
1309
1466
  className: "webg-btn", type: "button",
1310
- onClick: () => startEditGroup(sel.rotationGroupId),
1311
- disabled: !sel.rotationGroupId,
1312
- }, t.editList),
1313
- React.createElement("button", {
1314
- className: "webg-btn", type: "button",
1315
- onClick: onDeleteGroup,
1316
- disabled: !sel.rotationGroupId,
1317
- }, t.deleteList),
1467
+ onClick: cancelEditGroup,
1468
+ }, t.cancel),
1318
1469
  ),
1319
- editing && React.createElement("div", { className: "webg-editor" },
1320
- React.createElement("div", { className: "webg-row" },
1321
- React.createElement("span", { className: "webg-hint webg-label" }, t.name),
1322
- React.createElement("input", {
1323
- className: "webg-text", type: "text",
1324
- value: editing.name,
1325
- onInput: (e) => { editing.name = e.target.value; emit(); },
1326
- }),
1327
- ),
1328
- React.createElement("div", { className: "webg-row" },
1329
- React.createElement("span", { className: "webg-hint webg-label" }, t.interval),
1330
- React.createElement("select", {
1331
- className: "webg-select",
1332
- value: String(editing.interval),
1333
- onChange: (e) => { editing.interval = clampNum(Number(e.target.value), 1, 1440, DEFAULTS.rotationInterval); emit(); },
1334
- },
1335
- // A persisted interval outside the preset steps (imported groups,
1336
- // hand-edited storage) still gets a matching option — otherwise the
1337
- // select would silently display the first preset instead of reality.
1338
- ...[...new Set([...INTERVALS, editing.interval])].sort((x, y) => x - y).map((minutes) =>
1339
- React.createElement("option", { key: minutes, value: String(minutes) }, t.minutes(minutes)),
1340
- )),
1341
- React.createElement("span", { className: "webg-hint webg-label" }, t.order),
1342
- React.createElement("select", {
1343
- className: "webg-select webg-playlist-select",
1344
- value: editing.order,
1345
- onChange: (e) => { editing.order = e.target.value; emit(); },
1346
- },
1347
- React.createElement("option", { value: "sequence" }, t.sequence),
1348
- React.createElement("option", { value: "random" }, t.random),
1349
- ),
1350
- ),
1470
+ );
1471
+ }
1472
+
1473
+ function RotationControlsRow(t, sel, group, usableCount) {
1474
+ return React.createElement("div", { className: "webg-row webg-rotation-row" },
1475
+ React.createElement("label", { className: "webg-toggle" },
1351
1476
  React.createElement("input", {
1352
- className: "webg-text", type: "search",
1353
- placeholder: t.filterPlaceholder,
1354
- value: editorQuery,
1355
- onInput: (e) => setEditorQuery(e.target.value),
1477
+ type: "checkbox",
1478
+ checked: sel.rotationEnabled,
1479
+ onChange: onToggleRotation,
1480
+ disabled: !sel.rotationGroupId || usableCount < 2,
1356
1481
  }),
1357
- React.createElement("div", { className: "webg-editor-grid" },
1358
- (() => {
1359
- const eq = editorQuery.trim();
1360
- const pool = usableList.filter((w) => matchesQuery(w, eq));
1361
- if (usableList.length === 0) {
1362
- return React.createElement("span", { className: "webg-hint" }, t.noUsable);
1363
- }
1364
- if (pool.length === 0) {
1365
- return React.createElement("span", { className: "webg-hint" }, t.noMatch(eq));
1366
- }
1367
- return pool.map((w) => {
1368
- const checked = editing.wallpaperIds.indexOf(w.id) >= 0;
1369
- return React.createElement("button", {
1370
- key: w.id,
1371
- className: "webg-editor-card" + (checked ? " webg-editor-card--checked" : ""),
1372
- type: "button",
1373
- title: w.title + "(" + typeBadge(w, t) + ")",
1374
- onClick: () => {
1375
- const i = editing.wallpaperIds.indexOf(w.id);
1376
- if (i >= 0) editing.wallpaperIds.splice(i, 1);
1377
- else editing.wallpaperIds.push(w.id);
1378
- emit();
1379
- },
1380
- },
1381
- w.preview
1382
- ? React.createElement("img", {
1383
- src: w.preview, alt: w.title, loading: "lazy", decoding: "async",
1384
- onError: (e) => { e.target.style.display = "none"; },
1385
- })
1386
- : React.createElement("span", { className: "webg-card-placeholder" }, t.noPreview),
1387
- checked && React.createElement("span", { className: "webg-editor-check" }, "✓"),
1388
- );
1389
- });
1390
- })(),
1391
- ),
1392
- React.createElement("div", { className: "webg-row" },
1393
- React.createElement("span", { className: "webg-hint" }, t.selectedCount(editing.wallpaperIds.length)),
1394
- sel.inventory.playlists.length > 0 && React.createElement("select", {
1395
- className: "webg-select webg-playlist-select",
1396
- value: "",
1397
- onChange: (e) => {
1398
- const p = sel.inventory.playlists.find((pl) => pl.id === e.target.value);
1399
- if (p) importPlaylistIntoDraft(p);
1400
- },
1401
- },
1402
- React.createElement("option", { value: "" }, t.importPlaylist),
1403
- ...sel.inventory.playlists.map((p) => React.createElement("option", {
1404
- key: p.id, value: p.id,
1405
- }, t.playlistOption(p.name, p.portableCount || 0))),
1406
- ),
1407
- ),
1408
- React.createElement("div", { className: "webg-row" },
1409
- React.createElement("button", {
1410
- className: "webg-btn webg-btn--primary", type: "button",
1411
- onClick: saveEditingGroup,
1412
- }, t.save),
1413
- React.createElement("button", {
1414
- className: "webg-btn", type: "button",
1415
- onClick: cancelEditGroup,
1416
- }, t.cancel),
1417
- ),
1482
+ t.autoRotate,
1418
1483
  ),
1419
- React.createElement("div", { className: "webg-row webg-rotation-row" },
1420
- React.createElement("label", { className: "webg-toggle" },
1421
- React.createElement("input", {
1422
- type: "checkbox",
1423
- checked: sel.rotationEnabled,
1424
- onChange: onToggleRotation,
1425
- disabled: !sel.rotationGroupId || usableCount < 2,
1426
- }),
1427
- t.autoRotate,
1428
- ),
1429
- React.createElement("select", {
1430
- className: "webg-select webg-rotation-interval",
1431
- value: String(group ? group.interval : DEFAULTS.rotationInterval),
1432
- onChange: onGroupInterval,
1433
- disabled: !sel.rotationEnabled || !sel.rotationGroupId || usableCount < 2,
1434
- title: t.intervalTitle,
1484
+ React.createElement("select", {
1485
+ className: "webg-select webg-rotation-interval",
1486
+ value: String(group ? group.interval : DEFAULTS.rotationInterval),
1487
+ onChange: onGroupInterval,
1488
+ disabled: !sel.rotationEnabled || !sel.rotationGroupId || usableCount < 2,
1489
+ title: t.intervalTitle,
1490
+ },
1491
+ ...intervalOptions(group ? group.interval : DEFAULTS.rotationInterval, t)),
1492
+ !sel.rotationGroupId && React.createElement("span", { className: "webg-hint" }, t.needList),
1493
+ sel.rotationGroupId && usableCount < 2 && React.createElement("span", { className: "webg-hint" }, t.needTwo),
1494
+ );
1495
+ }
1496
+
1497
+ function EffectsPanel(t, sel) {
1498
+ return React.createElement(React.Fragment, null,
1499
+ // object-fit only means something for video/still wallpapers — an
1500
+ // iframe ignores it and always fills its layer — so the base-mode
1501
+ // select is hidden for web. The manual crop (drag / wheel in the
1502
+ // adjust overlay) is a plain CSS transform and applies to EVERY kind,
1503
+ // so 调整画面 / 重置裁剪 stay available even for iframes.
1504
+ React.createElement("div", { className: "webg-row webg-fit-row" },
1505
+ sel.type !== "web" && React.createElement("span", { className: "webg-hint webg-label" }, t.fit),
1506
+ sel.type !== "web" && React.createElement("select", {
1507
+ className: "webg-select webg-fit-select",
1508
+ value: sel.fit,
1509
+ onChange: onFit,
1435
1510
  },
1436
- ...[...new Set([...INTERVALS, group ? group.interval : DEFAULTS.rotationInterval])]
1437
- .sort((x, y) => x - y).map((minutes) =>
1438
- React.createElement("option", { key: minutes, value: String(minutes) }, t.minutes(minutes)),
1439
- )),
1440
- !sel.rotationGroupId && React.createElement("span", { className: "webg-hint" }, t.needList),
1441
- sel.rotationGroupId && usableCount < 2 && React.createElement("span", { className: "webg-hint" }, t.needTwo),
1442
- ),
1443
- // ── Effects ──
1444
- sel.id && React.createElement(React.Fragment, null,
1445
- // Canvas fit only matters for video/still wallpapers — an iframe
1446
- // ignores object-fit and always fills its layer. Manual crop (drag /
1447
- // wheel in the adjust overlay) replaced the old fixed anchor select.
1448
- sel.type !== "web" && React.createElement("div", { className: "webg-row webg-fit-row" },
1449
- React.createElement("span", { className: "webg-hint webg-label" }, t.fit),
1450
- React.createElement("select", {
1451
- className: "webg-select webg-fit-select",
1452
- value: sel.fit,
1453
- onChange: onFit,
1454
- },
1455
- ...[t.fitCover, t.fitContain, t.fitFill, t.fitNone].map((label, i) =>
1456
- React.createElement("option", { key: FIT_MODES[i], value: FIT_MODES[i] }, label),
1457
- )),
1511
+ ...[t.fitCover, t.fitContain, t.fitFill, t.fitNone].map((label, i) =>
1512
+ React.createElement("option", { key: FIT_MODES[i], value: FIT_MODES[i] }, label),
1513
+ )),
1514
+ React.createElement("button", {
1515
+ className: "webg-btn", type: "button", onClick: enterAdjust,
1516
+ disabled: sel.adjusting,
1517
+ }, t.adjust),
1518
+ (sel.zoom !== 1 || sel.offsetX !== 0 || sel.offsetY !== 0) &&
1458
1519
  React.createElement("button", {
1459
- className: "webg-btn", type: "button", onClick: () => enterAdjust(),
1460
- disabled: sel.adjusting,
1461
- }, t.adjust),
1462
- (sel.zoom !== 1 || sel.offsetX !== 0 || sel.offsetY !== 0) &&
1463
- React.createElement("button", {
1464
- className: "webg-btn", type: "button", onClick: resetCrop,
1465
- }, t.resetCrop),
1466
- ),
1467
- React.createElement("div", { className: "webg-effects" },
1468
- SliderRow(t.wallpaperBlur, 0, 60, 1, sel.wallpaperBlur, onWallpaperBlur, sel.wallpaperBlur + "px"),
1469
- SliderRow(t.scrim, 0, 90, 5, Math.min(90, Math.round(sel.scrim * 100)), onScrim,
1470
- Math.round(sel.scrim * 100) + "%"),
1471
- SliderRow(t.border, 0, 90, 5, Math.round(sel.border * 100), onBorder, Math.round(sel.border * 100) + "%"),
1472
- SliderRow(t.glass, 0, 40, 1, sel.blur, onBlur, sel.blur + "px"),
1473
- ),
1520
+ className: "webg-btn", type: "button", onClick: resetCrop,
1521
+ }, t.resetCrop),
1474
1522
  ),
1475
- // ── Resource monitor ──
1476
- sel.id && React.createElement("div", { className: "webg-row webg-monitor-row" },
1477
- React.createElement("label", { className: "webg-toggle" },
1478
- React.createElement("input", {
1479
- type: "checkbox", checked: sel.pauseOnHidden, onChange: onTogglePauseOnHidden,
1480
- }),
1481
- t.pauseOnHidden,
1482
- ),
1483
- React.createElement("label", { className: "webg-toggle" },
1484
- React.createElement("input", {
1485
- type: "checkbox", checked: sel.pauseOnBattery, onChange: onTogglePauseOnBattery,
1486
- }),
1487
- t.pauseOnBattery,
1488
- ),
1489
- React.createElement("label", { className: "webg-toggle", title: t.autoScrimTitle },
1490
- React.createElement("input", {
1491
- type: "checkbox", checked: sel.autoScrim, onChange: onToggleAutoScrim,
1492
- }),
1493
- t.autoScrim,
1494
- ),
1495
- sel.fps !== null && React.createElement("span", {
1496
- className: "webg-hint webg-fps" + (sel.fps < LOW_FPS ? " webg-fps--low" : ""),
1497
- title: t.fpsTitle,
1498
- }, sel.fps + " fps"),
1523
+ React.createElement("div", { className: "webg-effects" },
1524
+ SliderRow(t.wallpaperBlur, 0, 60, 1, sel.wallpaperBlur, onWallpaperBlur, sel.wallpaperBlur + "px"),
1525
+ SliderRow(t.scrim, 0, 90, 5, Math.min(90, Math.round(sel.scrim * 100)), onScrim,
1526
+ Math.round(sel.scrim * 100) + "%"),
1527
+ SliderRow(t.border, 0, 90, 5, Math.min(90, Math.round(sel.border * 100)), onBorder,
1528
+ Math.round(sel.border * 100) + "%"),
1529
+ SliderRow(t.glass, 0, 40, 1, sel.blur, onBlur, sel.blur + "px"),
1530
+ ),
1531
+ );
1532
+ }
1533
+
1534
+ function MonitorRow(t, sel) {
1535
+ return React.createElement("div", { className: "webg-row webg-monitor-row" },
1536
+ React.createElement("label", { className: "webg-toggle" },
1537
+ React.createElement("input", {
1538
+ type: "checkbox", checked: sel.pauseOnHidden, onChange: onTogglePauseOnHidden,
1539
+ }),
1540
+ t.pauseOnHidden,
1541
+ ),
1542
+ React.createElement("label", { className: "webg-toggle" },
1543
+ React.createElement("input", {
1544
+ type: "checkbox", checked: sel.pauseOnBattery, onChange: onTogglePauseOnBattery,
1545
+ }),
1546
+ t.pauseOnBattery,
1547
+ ),
1548
+ React.createElement("label", { className: "webg-toggle", title: t.autoScrimTitle },
1549
+ React.createElement("input", {
1550
+ type: "checkbox", checked: sel.autoScrim, onChange: onToggleAutoScrim,
1551
+ }),
1552
+ t.autoScrim,
1499
1553
  ),
1554
+ sel.fps !== null && React.createElement("span", {
1555
+ className: "webg-hint webg-fps" + (sel.fps < LOW_FPS ? " webg-fps--low" : ""),
1556
+ title: t.fpsTitle,
1557
+ }, sel.fps + " fps"),
1558
+ );
1559
+ }
1560
+
1561
+ function StatusRow(t, sel, group, usableCount, usableTotal) {
1562
+ // "Auto-rotating" is claimed only when the timer is genuinely armed —
1563
+ // rotation may be enabled on a list with fewer than 2 usable wallpapers,
1564
+ // in which case nothing switches until the list grows.
1565
+ const rotating = sel.rotationEnabled && usableCount >= 2;
1566
+ return React.createElement("div", { className: "webg-row" },
1567
+ React.createElement("span", { className: "webg-hint" },
1568
+ (group
1569
+ ? t.statusGroup(group.name, group.wallpaperIds.length, usableCount, group.interval,
1570
+ group.order === "random" ? t.random : t.sequence)
1571
+ : t.statusUsable(usableTotal)) +
1572
+ (rotating ? " · " + t.autoRotating : "") +
1573
+ (sel.autoPauseReasons.size > 0 ? " · " + t.autoPaused : "") +
1574
+ (sel.id && demotedToPreview.has(sel.id) ? " · " + t.decodeFallback : "") +
1575
+ (sel.inventory.installDir ? " · " + sel.inventory.installDir : "")),
1576
+ );
1577
+ }
1578
+
1579
+ function WallpaperPicker() {
1580
+ const sel = useStore();
1581
+ const [query, setQuery] = React.useState("");
1582
+ const [editorQuery, setEditorQuery] = React.useState("");
1583
+ const t = S();
1584
+
1585
+ if (!sel.loaded) {
1586
+ return React.createElement("div", { className: "webg-picker" },
1587
+ React.createElement("span", { className: "webg-hint" }, t.scanning));
1588
+ }
1589
+ if (sel.inventory.error) {
1590
+ return React.createElement("div", { className: "webg-picker" },
1591
+ React.createElement("div", { className: "webg-error" },
1592
+ t.connError + sel.inventory.error),
1593
+ React.createElement("button", {
1594
+ className: "webg-btn", type: "button", onClick: onRefresh, disabled: sel.loading,
1595
+ }, sel.loading ? t.refreshing : t.retry));
1596
+ }
1597
+
1598
+ const usableList = selectableInventory();
1599
+ const q = query.trim();
1600
+ const filteredList = usableList.filter((w) => matchesQuery(w, q));
1601
+ const groups = sel.rotationGroups;
1602
+ const group = activeRotationGroup();
1603
+ const usableCount = rotationCandidates().length;
1604
+ const editing = sel.editing;
1605
+
1606
+ return React.createElement("div", { className: "webg-picker" },
1607
+ SearchRow(t, sel, query, setQuery, filteredList.length, usableList.length),
1608
+ WallpaperGrid(t, sel, usableList, filteredList, q),
1609
+ ActionRow(t, sel),
1610
+ RotationListRow(t, sel, groups),
1611
+ editing && GroupEditorPanel(t, sel, usableList, editorQuery, setEditorQuery),
1612
+ RotationControlsRow(t, sel, group, usableCount),
1613
+ sel.id && EffectsPanel(t, sel),
1614
+ sel.id && MonitorRow(t, sel),
1500
1615
  sel.id && sel.fps !== null && sel.fps < LOW_FPS && sel.type === "video" &&
1501
1616
  React.createElement("div", { className: "webg-row" },
1502
- React.createElement("span", { className: "webg-hint" }, t.lowFpsHint),
1503
- ),
1504
- // ── Status ──
1505
- React.createElement("div", { className: "webg-row" },
1506
- React.createElement("span", { className: "webg-hint" },
1507
- (group
1508
- ? t.statusGroup(group.name, group.wallpaperIds.length, usableCount, group.interval,
1509
- group.order === "random" ? t.random : t.sequence)
1510
- : t.statusUsable(usableList.length)) +
1511
- (sel.rotationEnabled ? " · " + t.autoRotating : "") +
1512
- (autoPaused ? " · " + t.autoPaused : "") +
1513
- (sel.id && demotedToPreview.has(sel.id) ? " · " + t.decodeFallback : "") +
1514
- (sel.inventory.installDir ? " · " + sel.inventory.installDir : "")),
1515
- ),
1617
+ React.createElement("span", { className: "webg-hint" }, t.lowFpsHint)),
1618
+ StatusRow(t, sel, group, usableCount, usableList.length),
1516
1619
  );
1517
1620
  }
1518
1621
 
@@ -1528,17 +1631,18 @@ window.__ModuleLoader__.load({
1528
1631
  }
1529
1632
  .webg-layer--enter { opacity: 0; }
1530
1633
  .webg-layer--leave { opacity: 0; }
1531
- /* Overscan: the media is LARGER than the layer by one blur radius on every
1532
- side, so the transparent fringe CSS blur produces is clipped by the
1533
- layer's overflow:hidden instead of compensated with a content-warping
1534
- scale. 1× is the fringe a Gaussian kernel actually needs; more would
1535
- double GPU fill and mis-anchor non-center object-positions. A slight
1536
- saturate keeps blurred colours from washing out. */
1634
+ /* The media box is ALWAYS exactly the layer. Blur must never re-frame the
1635
+ content: enlarging the box for overscan made object-fit:cover scale the
1636
+ picture up to fill the bigger box, silently cropping the wallpaper's
1637
+ four edges — and once cropped, no zoom could bring them back. Instead the
1638
+ Gaussian's natural edge fade (≤ one blur radius, under the veil and the
1639
+ UI chrome) is accepted. A slight saturate keeps blurred colours from
1640
+ washing out. */
1537
1641
  .webg-layer .webg-media {
1538
1642
  position: absolute;
1539
- inset: calc(-1 * var(--webg-wallpaper-blur, 0px));
1540
- width: calc(100% + 2 * var(--webg-wallpaper-blur, 0px));
1541
- height: calc(100% + 2 * var(--webg-wallpaper-blur, 0px));
1643
+ inset: 0;
1644
+ width: 100%;
1645
+ height: 100%;
1542
1646
  max-width: none; max-height: none;
1543
1647
  object-fit: var(--webg-fit, cover);
1544
1648
  object-position: center;
@@ -1631,10 +1735,10 @@ window.__ModuleLoader__.load({
1631
1735
  -webkit-backdrop-filter: blur(var(--webg-blur, 16px)) saturate(var(--webg-saturate, 1.8)) brightness(var(--webg-glass-brightness, 1.04)) contrast(1.01);
1632
1736
  backdrop-filter: blur(var(--webg-blur, 16px)) saturate(var(--webg-saturate, 1.8)) brightness(var(--webg-glass-brightness, 1.04)) contrast(1.01);
1633
1737
  box-shadow:
1634
- inset 0 1px 0 rgba(255, 255, 255, var(--webg-glass-highlight, 0.32)),
1738
+ inset 0 1px 0 rgba(255, 255, 255, 0.32),
1635
1739
  inset 0 -1px 0 rgba(255, 255, 255, 0.08),
1636
1740
  inset 0 0 0 0.5px rgba(255, 255, 255, 0.08),
1637
- 0 12px 40px rgba(0, 0, 0, var(--webg-glass-shadow, 0.12));
1741
+ 0 12px 40px rgba(0, 0, 0, 0.12);
1638
1742
  }
1639
1743
 
1640
1744
  /* ── Picker chrome (token-driven, theme-aware) ── */