wallpaper-engine-dsh 1.0.0 → 1.0.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,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,6 +273,8 @@ 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,
276
+ // Older builds could persist zoom < 1 / unclamped pans — clampCrop()
277
+ // at store creation re-seats both into the gap-free envelope.
274
278
  zoom: clampNum(o.zoom, 0.25, 4, DEFAULTS.zoom),
275
279
  offsetX: clampNum(o.offsetX, -200, 200, DEFAULTS.offsetX),
276
280
  offsetY: clampNum(o.offsetY, -200, 200, DEFAULTS.offsetY),
@@ -298,6 +302,9 @@ window.__ModuleLoader__.load({
298
302
  inventory: { installDir: null, wallpapers: [], total: 0, portableCount: 0, playlists: [], error: null },
299
303
  loaded: false,
300
304
  };
305
+ // Older builds allowed zoom < 1 and unclamped pans — re-seat any persisted
306
+ // crop into the gap-free envelope before the first render.
307
+ clampCrop();
301
308
 
302
309
  const listeners = new Set();
303
310
  function emit() { for (const fn of [...listeners]) fn(); }
@@ -334,21 +341,19 @@ window.__ModuleLoader__.load({
334
341
  }
335
342
 
336
343
  // ── Wallpaper predicates ─────────────────────────────────────────────────────
337
- // playable: real media (video/web) renders live.
338
344
  // Renderable kinds ONLY — scene/application wallpapers are .pkg scene
339
345
  // packages the browser cannot render (their live animation is Wallpaper
340
346
  // Engine's desktop job), so they are filtered out of the grid entirely
341
- // 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.
342
351
  function isPlayable(w) {
343
352
  return Boolean(w && w.playable && (w.type === "video" || w.type === "web"));
344
353
  }
345
354
 
346
- function isSelectable(w) {
347
- return isPlayable(w);
348
- }
349
-
350
355
  function selectableInventory() {
351
- return selection.inventory.wallpapers.filter(isSelectable);
356
+ return selection.inventory.wallpapers.filter(isPlayable);
352
357
  }
353
358
 
354
359
  // ── Inventory ────────────────────────────────────────────────────────────────
@@ -412,7 +417,7 @@ window.__ModuleLoader__.load({
412
417
  }
413
418
 
414
419
  // Drop a selection whose wallpaper vanished or became unusable.
415
- if (selection.id && !selection.inventory.wallpapers.some((w) => w.id === selection.id && isSelectable(w))) {
420
+ if (selection.id && !selection.inventory.wallpapers.some((w) => w.id === selection.id && isPlayable(w))) {
416
421
  selection.id = "";
417
422
  persistSelection();
418
423
  }
@@ -436,7 +441,7 @@ window.__ModuleLoader__.load({
436
441
  function groupWallpapers(group) {
437
442
  if (!group || !Array.isArray(group.wallpaperIds)) return [];
438
443
  const byId = new Map(selection.inventory.wallpapers.map((w) => [w.id, w]));
439
- return group.wallpaperIds.map((id) => byId.get(id)).filter(isSelectable);
444
+ return group.wallpaperIds.map((id) => byId.get(id)).filter(isPlayable);
440
445
  }
441
446
 
442
447
  function rotationCandidates() {
@@ -452,7 +457,7 @@ window.__ModuleLoader__.load({
452
457
  if (!source) return false;
453
458
  const byId = new Map(selection.inventory.wallpapers.map((w) => [w.id, w]));
454
459
  const ids = Array.isArray(source.wallpaperIds)
455
- ? source.wallpaperIds.filter((id) => isSelectable(byId.get(id)))
460
+ ? source.wallpaperIds.filter((id) => isPlayable(byId.get(id)))
456
461
  : [];
457
462
  if (ids.length < 2) return false;
458
463
  selection.rotationGroups.push({
@@ -667,7 +672,7 @@ window.__ModuleLoader__.load({
667
672
  media.setAttribute("frameborder", "0");
668
673
  media.setAttribute("scrolling", "no");
669
674
  media.setAttribute("title", S().iframeTitle);
670
- media.className = "webg-media webg-iframe";
675
+ media.className = "webg-media";
671
676
  } else {
672
677
  // Static fallback: a playable wallpaper demoted after repeated media
673
678
  // failures — its preview still beats a black screen.
@@ -684,7 +689,10 @@ window.__ModuleLoader__.load({
684
689
  }
685
690
 
686
691
  function attachStillSampling(media, sel) {
687
- const type = (sel && sel.type) || (media.tagName === "IMG" ? "image" : null);
692
+ const type = sel && sel.type;
693
+ // Web wallpapers cannot be sampled — the sandboxed iframe's origin is
694
+ // opaque, so its pixels are unreadable. Only video/img frames feed the
695
+ // smart veil.
688
696
  if (type !== "video" && type !== "image") return;
689
697
  media.addEventListener(type === "video" ? "loadeddata" : "load", () => {
690
698
  sampleWallpaperLuminance();
@@ -724,6 +732,18 @@ window.__ModuleLoader__.load({
724
732
  // The layer normally sits at z-index -2 behind the entire app, so it can
725
733
  // never receive pointer events. Adjust mode moves the LIVE layer node into a
726
734
  // topmost transparent overlay — the user edits the real thing, WYSIWYG.
735
+ //
736
+ // Crop geometry invariants: the media box covers the viewport exactly at
737
+ // zoom 1; zooming creates headroom, panning spends it. Zoom floors at 1
738
+ // and pan is clamped to ±(zoom-1)/2·100 %, so a gap-free fullscreen image
739
+ // is guaranteed at every setting.
740
+ function clampCrop() {
741
+ selection.zoom = Math.round(Math.min(4, Math.max(1, selection.zoom)) * 100) / 100;
742
+ const pan = (selection.zoom - 1) * 50;
743
+ selection.offsetX = Math.min(pan, Math.max(-pan, selection.offsetX));
744
+ selection.offsetY = Math.min(pan, Math.max(-pan, selection.offsetY));
745
+ }
746
+
727
747
  let adjustOverlay = null;
728
748
  let adjustCleanup = null;
729
749
 
@@ -775,16 +795,13 @@ window.__ModuleLoader__.load({
775
795
  };
776
796
  const onPointerMove = (e) => {
777
797
  if (!drag) return;
778
- // translate % refers to the MEDIA BOX, which (blur overscan) is wider
779
- // than the viewport by 2× the wallpaper blur — divide by that, so the
780
- // content tracks the cursor 1:1 at any blur setting.
781
- const w = (overlay.clientWidth || 1) + 2 * selection.wallpaperBlur;
782
- const h = (overlay.clientHeight || 1) + 2 * selection.wallpaperBlur;
783
- // True clamping (clampNum is a validate-or-fallback for persisted data).
784
- selection.offsetX = Math.min(200, Math.max(-200,
785
- selection.offsetX + ((e.clientX - drag.x) / w) * 100));
786
- selection.offsetY = Math.min(200, Math.max(-200,
787
- selection.offsetY + ((e.clientY - drag.y) / h) * 100));
798
+ // translate % refers to the media box, which is the viewport exactly —
799
+ // divide the pixel delta by it so the content tracks the cursor 1:1.
800
+ const w = overlay.clientWidth || 1;
801
+ const h = overlay.clientHeight || 1;
802
+ selection.offsetX += ((e.clientX - drag.x) / w) * 100;
803
+ selection.offsetY += ((e.clientY - drag.y) / h) * 100;
804
+ clampCrop(); // gap-free guarantee: pan never outruns the zoom headroom
788
805
  drag = { x: e.clientX, y: e.clientY };
789
806
  applyEffects();
790
807
  };
@@ -802,12 +819,12 @@ window.__ModuleLoader__.load({
802
819
  };
803
820
  const onWheel = (e) => {
804
821
  if (typeof e.preventDefault === "function") e.preventDefault();
805
- // True clamping to [0.25, 4] — clampNum is a validate-or-fallback for
806
- // persisted data and would REJECT an out-of-range zoom instead of
807
- // pinning it to the boundary.
808
- const next = Math.min(4, Math.max(0.25,
809
- selection.zoom * Math.exp(-(e.deltaY || 0) * 0.0012)));
810
- selection.zoom = Math.round(next * 100) / 100;
822
+ // Crop semantics: zoom IN only (1–4×). Zooming below cover would float a
823
+ // shrunken box in empty space — that is not a crop. Tightening zoom also
824
+ // re-clamps the pan into the new headroom, so zooming back to 1 pulls
825
+ // the framing home instead of leaving edge gaps.
826
+ selection.zoom *= Math.exp(-(e.deltaY || 0) * 0.0012);
827
+ clampCrop();
811
828
  applyEffects();
812
829
  persistSelection();
813
830
  };
@@ -887,7 +904,7 @@ window.__ModuleLoader__.load({
887
904
  if (existing) {
888
905
  existing.classList.add("webg-layer--leave");
889
906
  const stale = existing;
890
- schedule(() => stale.remove(), CROSSFADE_MS + 120);
907
+ schedule(() => stale.remove(), LEAVE_MS);
891
908
  }
892
909
  }
893
910
  const node = document.getElementById(LAYER_ID);
@@ -909,7 +926,7 @@ window.__ModuleLoader__.load({
909
926
  if (s) s.remove();
910
927
  document.body.removeAttribute(ACTIVE_ATTR);
911
928
  }
912
- }, CROSSFADE_MS + 120);
929
+ }, LEAVE_MS);
913
930
  }
914
931
 
915
932
  // Scrim: present while a wallpaper is active. On clear it outlives the
@@ -931,6 +948,11 @@ window.__ModuleLoader__.load({
931
948
  // sample; iframes of web wallpapers can never be sampled — opaque origin).
932
949
  let sampledLuminance = null;
933
950
  let sampleCanvas = null;
951
+ // Snapshot of every input applyEffects reads, from its last full write. The
952
+ // store emits once per second for the FPS readout — without this guard every
953
+ // tick would rewrite all CSS variables AND force a synchronous layout for
954
+ // nothing. clearEffects() resets it so the next apply always writes.
955
+ let lastEffectKey = null;
934
956
 
935
957
  /** Average Rec.601 luminance (0..1) of the current video/img, or null. */
936
958
  function sampleWallpaperLuminance() {
@@ -958,11 +980,18 @@ window.__ModuleLoader__.load({
958
980
  }
959
981
 
960
982
  function applyEffects() {
961
- const s = document.body.style;
962
983
  // The veil OPPOSES the text colour: dark theme (light text) darkens bright
963
984
  // wallpapers, light theme (dark text) brightens dark ones — either way the
964
985
  // text side wins. This is what makes ANY wallpaper survivable.
965
986
  const dark = Boolean(document.body.hasAttribute && document.body.hasAttribute("data-ds-dark-theme"));
987
+ const key = [
988
+ dark, selection.scrim, selection.border, selection.blur, selection.wallpaperBlur,
989
+ selection.autoScrim, sampledLuminance, selection.fit, selection.zoom,
990
+ selection.offsetX, selection.offsetY,
991
+ ].join("\u0000");
992
+ if (key === lastEffectKey) return; // nothing it reads changed → no writes, no reflow
993
+ lastEffectKey = key;
994
+ const s = document.body.style;
966
995
  let alpha = selection.scrim;
967
996
  if (selection.autoScrim && sampledLuminance !== null) {
968
997
  // Distance of the wallpaper's brightness from the "comfortable middle"
@@ -979,7 +1008,6 @@ window.__ModuleLoader__.load({
979
1008
  // The glass "colour melt" scales with blur radius: 0 blur → no melt.
980
1009
  s.setProperty("--webg-saturate", String(1.15 + selection.blur * 0.028));
981
1010
  s.setProperty("--webg-glass-brightness", "1.04");
982
- s.setProperty("--webg-wallpaper-blur", selection.wallpaperBlur + "px");
983
1011
  // At 0 blur, keep the media OUT of the filter pipeline entirely: even
984
1012
  // blur(0px) forces an extra raster/compositing pass, and the noise overlay
985
1013
  // would still occupy a layer at opacity 0. filter:none + display:none keep
@@ -1010,13 +1038,13 @@ window.__ModuleLoader__.load({
1010
1038
  }
1011
1039
 
1012
1040
  function clearEffects() {
1041
+ lastEffectKey = null;
1013
1042
  const s = document.body.style;
1014
1043
  s.removeProperty("--webg-scrim-color");
1015
1044
  s.removeProperty("--webg-border-alpha");
1016
1045
  s.removeProperty("--webg-blur");
1017
1046
  s.removeProperty("--webg-saturate");
1018
1047
  s.removeProperty("--webg-glass-brightness");
1019
- s.removeProperty("--webg-wallpaper-blur");
1020
1048
  s.removeProperty("--webg-media-filter");
1021
1049
  s.removeProperty("--webg-noise-display");
1022
1050
  s.removeProperty("--webg-noise-opacity");
@@ -1070,6 +1098,16 @@ window.__ModuleLoader__.load({
1070
1098
  return (w.title || "").toLowerCase().includes(needle) || (w.id || "").toLowerCase().includes(needle);
1071
1099
  }
1072
1100
 
1101
+ /** Preview <img>, or the "no preview" placeholder — shared by grid + editor. */
1102
+ function PreviewMedia(w, t) {
1103
+ return w.preview
1104
+ ? React.createElement("img", {
1105
+ src: w.preview, alt: w.title, loading: "lazy", decoding: "async",
1106
+ onError: (e) => { e.target.style.display = "none"; },
1107
+ })
1108
+ : React.createElement("span", { className: "webg-card-placeholder" }, t.noPreview);
1109
+ }
1110
+
1073
1111
  function ThumbCard(w, selected, onClick, t) {
1074
1112
  return React.createElement("button", {
1075
1113
  key: w.id,
@@ -1078,426 +1116,480 @@ window.__ModuleLoader__.load({
1078
1116
  onClick: onClick,
1079
1117
  title: w.title + "(" + typeBadge(w, t) + ")",
1080
1118
  },
1081
- w.preview
1082
- ? React.createElement("img", {
1083
- src: w.preview, alt: w.title, loading: "lazy", decoding: "async",
1084
- onError: (e) => { e.target.style.display = "none"; },
1085
- })
1086
- : React.createElement("span", { className: "webg-card-placeholder" }, t.noPreview),
1119
+ PreviewMedia(w, t),
1087
1120
  React.createElement("span", { className: "webg-card-type" }, typeBadge(w, t)),
1088
1121
  React.createElement("span", { className: "webg-card-title" }, w.title),
1089
1122
  );
1090
1123
  }
1091
1124
 
1092
- function WallpaperPicker() {
1093
- const sel = useStore();
1094
- const [query, setQuery] = React.useState("");
1095
- const [editorQuery, setEditorQuery] = React.useState("");
1096
- const t = S();
1097
- const onTogglePlay = () => { selection.playing = !selection.playing; emit(); };
1098
- const onClear = () => applySelection("");
1099
- const onRefresh = () => {
1100
- // A manual refresh also retries wallpapers previously demoted to their
1101
- // preview after decode failures.
1102
- demotedToPreview.clear();
1103
- mediaFailures.clear();
1104
- lastRecoveryAt.clear();
1105
- loadInventory(true);
1106
- };
1107
- const onLangChange = (e) => {
1108
- selection.lang = e.target.value;
1109
- persistSelection();
1110
- emit();
1111
- };
1112
- const onGroupChange = (e) => {
1113
- selection.rotationGroupId = e.target.value;
1114
- if (selection.rotationEnabled) {
1115
- const first = rotationCandidates()[0];
1116
- if (first) applySelection(first.id);
1117
- else applySelection("");
1118
- return;
1119
- }
1120
- persistSelection();
1121
- syncRotationTimer();
1122
- emit();
1123
- };
1124
- const onToggleRotation = () => {
1125
- selection.rotationEnabled = !selection.rotationEnabled;
1126
- // The checkbox is disabled without a usable group, so a group id always
1127
- // exists here when enabling.
1128
- if (selection.rotationEnabled &&
1129
- !rotationCandidates().some((w) => w.id === selection.id)) {
1130
- const first = rotationCandidates()[0];
1131
- if (first) { applySelection(first.id); return; }
1132
- }
1133
- persistSelection();
1134
- syncRotationTimer();
1135
- emit();
1136
- };
1137
- const onGroupInterval = (e) => {
1138
- const group = activeRotationGroup();
1139
- if (!group) return;
1140
- group.interval = clampNum(Number(e.target.value), 1, 1440, DEFAULTS.rotationInterval);
1141
- persistSelection();
1142
- syncRotationTimer();
1143
- emit();
1144
- };
1145
- const onDeleteGroup = () => {
1146
- const group = activeRotationGroup();
1147
- if (!group) return;
1148
- if (typeof window !== "undefined" && typeof window.confirm === "function") {
1149
- if (!window.confirm(t.confirmDelete(group.name))) return;
1150
- }
1151
- deleteGroup(group.id);
1152
- };
1153
- const onTogglePauseOnHidden = () => {
1154
- selection.pauseOnHidden = !selection.pauseOnHidden;
1155
- if (selection.pauseOnHidden) {
1156
- // Re-enabling while already hidden must re-apply the reason — the
1157
- // visibilitychange listener only fires on CHANGES.
1158
- if (typeof document !== "undefined" && document.visibilityState === "hidden") {
1159
- setAutoPause("hidden", true);
1160
- }
1161
- } else {
1162
- setAutoPause("hidden", false);
1163
- }
1164
- persistSelection();
1165
- emit();
1166
- };
1167
- const onTogglePauseOnBattery = () => {
1168
- selection.pauseOnBattery = !selection.pauseOnBattery;
1169
- if (selection.pauseOnBattery) applyBatteryPause(); // re-evaluate NOW
1170
- else setAutoPause("battery", false);
1171
- persistSelection();
1172
- emit();
1173
- };
1174
- const onToggleAutoScrim = () => {
1175
- selection.autoScrim = !selection.autoScrim;
1176
- persistSelection();
1177
- applyEffects();
1178
- emit();
1179
- };
1125
+ /** Preset switch intervals offered by the rotation UI (minutes). */
1126
+ const ROTATION_INTERVALS = [1, 5, 10, 30, 60, 120];
1127
+
1128
+ /**
1129
+ * <option> list for an interval select. A persisted interval outside the
1130
+ * preset steps (imported groups, hand-edited storage) still gets a matching
1131
+ * option — otherwise the select would silently display the first preset
1132
+ * instead of reality.
1133
+ */
1134
+ function intervalOptions(current, t) {
1135
+ return [...new Set([...ROTATION_INTERVALS, current])]
1136
+ .sort((x, y) => x - y)
1137
+ .map((minutes) => React.createElement("option", {
1138
+ key: minutes, value: String(minutes),
1139
+ }, t.minutes(minutes)));
1140
+ }
1180
1141
 
1181
- const onScrim = (pct) => { selection.scrim = pct / 100; persistSelection(); applyEffects(); emit(); };
1182
- const onBorder = (pct) => { selection.border = pct / 100; persistSelection(); applyEffects(); emit(); };
1183
- const onBlur = (px) => { selection.blur = px; persistSelection(); applyEffects(); emit(); };
1184
- const onWallpaperBlur = (px) => { selection.wallpaperBlur = px; persistSelection(); applyEffects(); emit(); };
1185
- const onFit = (e) => {
1186
- selection.fit = FIT_MODES.indexOf(e.target.value) >= 0 ? e.target.value : "cover";
1187
- persistSelection();
1188
- applyEffects();
1189
- emit();
1190
- };
1142
+ // ── Picker actions (module scope) ────────────────────────────────────────────
1143
+ // Every control mutates the shared selection store and emits; none closes
1144
+ // over React state, so they live outside the component — stable across
1145
+ // re-renders (the picker re-renders once per second for the FPS readout)
1146
+ // and testable without mounting anything.
1191
1147
 
1192
- if (!sel.loaded) {
1193
- return React.createElement("div", { className: "webg-picker" },
1194
- React.createElement("span", { className: "webg-hint" }, t.scanning));
1148
+ function onTogglePlay() { selection.playing = !selection.playing; emit(); }
1149
+
1150
+ function onClear() { applySelection(""); }
1151
+
1152
+ function onRefresh() {
1153
+ // A manual refresh also retries wallpapers previously demoted to their
1154
+ // preview after decode failures.
1155
+ demotedToPreview.clear();
1156
+ mediaFailures.clear();
1157
+ lastRecoveryAt.clear();
1158
+ loadInventory(true);
1159
+ }
1160
+
1161
+ function onLangChange(e) {
1162
+ selection.lang = e.target.value;
1163
+ persistSelection();
1164
+ emit();
1165
+ }
1166
+
1167
+ function onGroupChange(e) {
1168
+ selection.rotationGroupId = e.target.value;
1169
+ if (selection.rotationEnabled) {
1170
+ const first = rotationCandidates()[0];
1171
+ if (first) applySelection(first.id);
1172
+ else applySelection("");
1173
+ return;
1195
1174
  }
1196
- if (sel.inventory.error) {
1197
- return React.createElement("div", { className: "webg-picker" },
1198
- React.createElement("div", { className: "webg-error" },
1199
- t.connError + sel.inventory.error),
1200
- React.createElement("button", {
1201
- className: "webg-btn", type: "button", onClick: onRefresh, disabled: sel.loading,
1202
- }, sel.loading ? t.refreshing : t.retry));
1175
+ persistSelection();
1176
+ syncRotationTimer();
1177
+ emit();
1178
+ }
1179
+
1180
+ function onToggleRotation() {
1181
+ selection.rotationEnabled = !selection.rotationEnabled;
1182
+ // The checkbox is disabled without a usable group, so a group id always
1183
+ // exists here when enabling.
1184
+ if (selection.rotationEnabled &&
1185
+ !rotationCandidates().some((w) => w.id === selection.id)) {
1186
+ const first = rotationCandidates()[0];
1187
+ if (first) { applySelection(first.id); return; }
1203
1188
  }
1189
+ persistSelection();
1190
+ syncRotationTimer();
1191
+ emit();
1192
+ }
1204
1193
 
1205
- const usableList = selectableInventory();
1206
- const q = query.trim();
1207
- const filteredList = usableList.filter((w) => matchesQuery(w, q));
1208
- const groups = sel.rotationGroups;
1194
+ function onGroupInterval(e) {
1209
1195
  const group = activeRotationGroup();
1210
- const candidates = rotationCandidates();
1211
- const usableCount = candidates.length;
1212
- const editing = sel.editing;
1213
- const autoPaused = sel.autoPauseReasons.size > 0;
1214
- const INTERVALS = [1, 5, 10, 30, 60, 120];
1196
+ if (!group) return;
1197
+ group.interval = clampNum(Number(e.target.value), 1, 1440, DEFAULTS.rotationInterval);
1198
+ persistSelection();
1199
+ syncRotationTimer();
1200
+ emit();
1201
+ }
1215
1202
 
1216
- return React.createElement("div", { className: "webg-picker" },
1217
- // ── Search + language ──
1203
+ function onDeleteGroup() {
1204
+ const group = activeRotationGroup();
1205
+ if (!group) return;
1206
+ if (typeof window !== "undefined" && typeof window.confirm === "function") {
1207
+ if (!window.confirm(S().confirmDelete(group.name))) return;
1208
+ }
1209
+ deleteGroup(group.id);
1210
+ }
1211
+
1212
+ function onTogglePauseOnHidden() {
1213
+ selection.pauseOnHidden = !selection.pauseOnHidden;
1214
+ if (selection.pauseOnHidden) {
1215
+ // Re-enabling while already hidden must re-apply the reason — the
1216
+ // visibilitychange listener only fires on CHANGES.
1217
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
1218
+ setAutoPause("hidden", true);
1219
+ }
1220
+ } else {
1221
+ setAutoPause("hidden", false);
1222
+ }
1223
+ persistSelection();
1224
+ emit();
1225
+ }
1226
+
1227
+ function onTogglePauseOnBattery() {
1228
+ selection.pauseOnBattery = !selection.pauseOnBattery;
1229
+ if (selection.pauseOnBattery) applyBatteryPause(); // re-evaluate NOW
1230
+ else setAutoPause("battery", false);
1231
+ persistSelection();
1232
+ emit();
1233
+ }
1234
+
1235
+ function onToggleAutoScrim() {
1236
+ selection.autoScrim = !selection.autoScrim;
1237
+ persistSelection();
1238
+ applyEffects();
1239
+ emit();
1240
+ }
1241
+
1242
+ function onScrim(pct) { selection.scrim = pct / 100; persistSelection(); applyEffects(); emit(); }
1243
+ function onBorder(pct) { selection.border = pct / 100; persistSelection(); applyEffects(); emit(); }
1244
+ function onBlur(px) { selection.blur = px; persistSelection(); applyEffects(); emit(); }
1245
+ function onWallpaperBlur(px) { selection.wallpaperBlur = px; persistSelection(); applyEffects(); emit(); }
1246
+
1247
+ function onFit(e) {
1248
+ selection.fit = FIT_MODES.indexOf(e.target.value) >= 0 ? e.target.value : "cover";
1249
+ persistSelection();
1250
+ applyEffects();
1251
+ emit();
1252
+ }
1253
+
1254
+ // ── Picker sections (presentational; every input arrives as an argument) ────
1255
+ // WallpaperPicker composes these in a fixed order. Each one owns exactly one
1256
+ // visual row/block of the settings panel and produces the same DOM the old
1257
+ // monolithic render did.
1258
+
1259
+ function SearchRow(t, sel, query, setQuery, matched, total) {
1260
+ return React.createElement("div", { className: "webg-row" },
1261
+ React.createElement("input", {
1262
+ className: "webg-text webg-search", type: "search",
1263
+ placeholder: t.searchPlaceholder,
1264
+ value: query,
1265
+ onInput: (e) => setQuery(e.target.value),
1266
+ }),
1267
+ query.trim() && React.createElement("span", { className: "webg-hint" },
1268
+ t.matched(matched, total)),
1269
+ React.createElement("label", { className: "webg-lang" },
1270
+ React.createElement("span", { className: "webg-hint" }, t.language),
1271
+ React.createElement("select", {
1272
+ className: "webg-select webg-lang-select",
1273
+ value: sel.lang,
1274
+ onChange: onLangChange,
1275
+ },
1276
+ React.createElement("option", { value: "auto" }, t.langAuto),
1277
+ React.createElement("option", { value: "zh" }, "中文"),
1278
+ React.createElement("option", { value: "en" }, "English"),
1279
+ ),
1280
+ ),
1281
+ );
1282
+ }
1283
+
1284
+ function WallpaperGrid(t, sel, usableList, filteredList, q) {
1285
+ return React.createElement("div", { className: "webg-grid" },
1286
+ React.createElement("button", {
1287
+ className: "webg-card" + (sel.id ? "" : " webg-card--selected"),
1288
+ type: "button",
1289
+ onClick: onClear,
1290
+ title: t.closeTitle,
1291
+ },
1292
+ React.createElement("span", { className: "webg-card-close" }, t.closeCard),
1293
+ ),
1294
+ usableList.length === 0
1295
+ ? React.createElement("span", { className: "webg-hint" },
1296
+ sel.inventory.installDir
1297
+ ? (sel.inventory.total > 0
1298
+ ? t.emptyNoUsable(sel.inventory.total)
1299
+ : t.emptyNoProjects)
1300
+ : t.emptyNoInstall)
1301
+ : filteredList.length === 0
1302
+ ? React.createElement("span", { className: "webg-hint" }, t.noMatch(q))
1303
+ : filteredList.map((w) => ThumbCard(w, w.id === sel.id, () => applySelection(w.id), t)),
1304
+ );
1305
+ }
1306
+
1307
+ function ActionRow(t, sel) {
1308
+ return React.createElement("div", { className: "webg-row" },
1309
+ React.createElement("button", {
1310
+ className: "webg-btn", type: "button",
1311
+ onClick: onTogglePlay, disabled: !sel.url || sel.type !== "video",
1312
+ }, sel.playing ? t.pause : t.play),
1313
+ React.createElement("button", {
1314
+ className: "webg-btn", type: "button",
1315
+ onClick: onClear, disabled: !sel.id,
1316
+ }, t.close),
1317
+ React.createElement("button", {
1318
+ className: "webg-btn", type: "button",
1319
+ onClick: onRefresh, disabled: sel.loading,
1320
+ }, sel.loading ? t.refreshing : t.refresh),
1321
+ );
1322
+ }
1323
+
1324
+ function RotationListRow(t, sel, groups) {
1325
+ return React.createElement("div", { className: "webg-row webg-playlist-row" },
1326
+ React.createElement("span", { className: "webg-hint webg-label" }, t.rotationList),
1327
+ React.createElement("select", {
1328
+ className: "webg-select webg-playlist-select",
1329
+ value: sel.rotationGroupId,
1330
+ onChange: onGroupChange,
1331
+ disabled: groups.length === 0,
1332
+ },
1333
+ React.createElement("option", { value: "" }, groups.length ? t.selectList : t.noLists),
1334
+ ...groups.map((g) => React.createElement("option", {
1335
+ key: g.id, value: g.id,
1336
+ }, t.groupOption(g.name, groupWallpapers(g).length, g.interval))),
1337
+ ),
1338
+ React.createElement("button", {
1339
+ className: "webg-btn", type: "button", onClick: startCreateGroup,
1340
+ }, t.newList),
1341
+ React.createElement("button", {
1342
+ className: "webg-btn", type: "button",
1343
+ onClick: () => startEditGroup(sel.rotationGroupId),
1344
+ disabled: !sel.rotationGroupId,
1345
+ }, t.editList),
1346
+ React.createElement("button", {
1347
+ className: "webg-btn", type: "button",
1348
+ onClick: onDeleteGroup,
1349
+ disabled: !sel.rotationGroupId,
1350
+ }, t.deleteList),
1351
+ );
1352
+ }
1353
+
1354
+ /** One toggleable wallpaper chip inside the group editor. */
1355
+ function EditorCard(w, editing, t) {
1356
+ const checked = editing.wallpaperIds.indexOf(w.id) >= 0;
1357
+ return React.createElement("button", {
1358
+ key: w.id,
1359
+ className: "webg-editor-card" + (checked ? " webg-editor-card--checked" : ""),
1360
+ type: "button",
1361
+ title: w.title + "(" + typeBadge(w, t) + ")",
1362
+ onClick: () => {
1363
+ const i = editing.wallpaperIds.indexOf(w.id);
1364
+ if (i >= 0) editing.wallpaperIds.splice(i, 1);
1365
+ else editing.wallpaperIds.push(w.id);
1366
+ emit();
1367
+ },
1368
+ },
1369
+ PreviewMedia(w, t),
1370
+ checked && React.createElement("span", { className: "webg-editor-check" }, "✓"),
1371
+ );
1372
+ }
1373
+
1374
+ function GroupEditorPanel(t, sel, usableList, editorQuery, setEditorQuery) {
1375
+ const editing = sel.editing;
1376
+ return React.createElement("div", { className: "webg-editor" },
1218
1377
  React.createElement("div", { className: "webg-row" },
1378
+ React.createElement("span", { className: "webg-hint webg-label" }, t.name),
1219
1379
  React.createElement("input", {
1220
- className: "webg-text webg-search", type: "search",
1221
- placeholder: t.searchPlaceholder,
1222
- value: query,
1223
- onInput: (e) => setQuery(e.target.value),
1380
+ className: "webg-text", type: "text",
1381
+ value: editing.name,
1382
+ onInput: (e) => { editing.name = e.target.value; emit(); },
1224
1383
  }),
1225
- q && React.createElement("span", { className: "webg-hint" },
1226
- t.matched(filteredList.length, usableList.length)),
1227
- React.createElement("label", { className: "webg-lang" },
1228
- React.createElement("span", { className: "webg-hint" }, t.language),
1229
- React.createElement("select", {
1230
- className: "webg-select webg-lang-select",
1231
- value: sel.lang,
1232
- onChange: onLangChange,
1233
- },
1234
- React.createElement("option", { value: "auto" }, t.langAuto),
1235
- React.createElement("option", { value: "zh" }, "中文"),
1236
- React.createElement("option", { value: "en" }, "English"),
1237
- ),
1238
- ),
1239
1384
  ),
1240
- // ── Grid ──
1241
- React.createElement("div", { className: "webg-grid" },
1242
- React.createElement("button", {
1243
- className: "webg-card" + (sel.id ? "" : " webg-card--selected"),
1244
- type: "button",
1245
- onClick: onClear,
1246
- title: t.closeTitle,
1385
+ React.createElement("div", { className: "webg-row" },
1386
+ React.createElement("span", { className: "webg-hint webg-label" }, t.interval),
1387
+ React.createElement("select", {
1388
+ className: "webg-select",
1389
+ value: String(editing.interval),
1390
+ onChange: (e) => { editing.interval = clampNum(Number(e.target.value), 1, 1440, DEFAULTS.rotationInterval); emit(); },
1391
+ },
1392
+ ...intervalOptions(editing.interval, t)),
1393
+ React.createElement("span", { className: "webg-hint webg-label" }, t.order),
1394
+ React.createElement("select", {
1395
+ className: "webg-select webg-playlist-select",
1396
+ value: editing.order,
1397
+ onChange: (e) => { editing.order = e.target.value; emit(); },
1247
1398
  },
1248
- React.createElement("span", { className: "webg-card-close" }, t.closeCard),
1399
+ React.createElement("option", { value: "sequence" }, t.sequence),
1400
+ React.createElement("option", { value: "random" }, t.random),
1249
1401
  ),
1250
- usableList.length === 0
1251
- ? React.createElement("span", { className: "webg-hint" },
1252
- sel.inventory.installDir
1253
- ? (sel.inventory.total > 0
1254
- ? t.emptyNoUsable(sel.inventory.total)
1255
- : t.emptyNoProjects)
1256
- : t.emptyNoInstall)
1257
- : filteredList.length === 0
1258
- ? React.createElement("span", { className: "webg-hint" }, t.noMatch(q))
1259
- : filteredList.map((w) => ThumbCard(w, w.id === sel.id, () => applySelection(w.id), t)),
1260
1402
  ),
1261
- // ── Actions ──
1262
- React.createElement("div", { className: "webg-row" },
1263
- React.createElement("button", {
1264
- className: "webg-btn", type: "button",
1265
- onClick: onTogglePlay, disabled: !sel.url || sel.type !== "video",
1266
- }, sel.playing ? t.pause : t.play),
1267
- React.createElement("button", {
1268
- className: "webg-btn", type: "button",
1269
- onClick: onClear, disabled: !sel.id,
1270
- }, t.close),
1271
- React.createElement("button", {
1272
- className: "webg-btn", type: "button",
1273
- onClick: onRefresh, disabled: sel.loading,
1274
- }, sel.loading ? t.refreshing : t.refresh),
1403
+ React.createElement("input", {
1404
+ className: "webg-text", type: "search",
1405
+ placeholder: t.filterPlaceholder,
1406
+ value: editorQuery,
1407
+ onInput: (e) => setEditorQuery(e.target.value),
1408
+ }),
1409
+ React.createElement("div", { className: "webg-editor-grid" },
1410
+ (() => {
1411
+ const eq = editorQuery.trim();
1412
+ const pool = usableList.filter((w) => matchesQuery(w, eq));
1413
+ if (usableList.length === 0) {
1414
+ return React.createElement("span", { className: "webg-hint" }, t.noUsable);
1415
+ }
1416
+ if (pool.length === 0) {
1417
+ return React.createElement("span", { className: "webg-hint" }, t.noMatch(eq));
1418
+ }
1419
+ return pool.map((w) => EditorCard(w, editing, t));
1420
+ })(),
1275
1421
  ),
1276
- // ── Rotation groups ──
1277
- React.createElement("div", { className: "webg-row webg-playlist-row" },
1278
- React.createElement("span", { className: "webg-hint webg-label" }, t.rotationList),
1279
- React.createElement("select", {
1422
+ React.createElement("div", { className: "webg-row" },
1423
+ React.createElement("span", { className: "webg-hint" }, t.selectedCount(editing.wallpaperIds.length)),
1424
+ sel.inventory.playlists.length > 0 && React.createElement("select", {
1280
1425
  className: "webg-select webg-playlist-select",
1281
- value: sel.rotationGroupId,
1282
- onChange: onGroupChange,
1283
- disabled: groups.length === 0,
1426
+ value: "",
1427
+ onChange: (e) => {
1428
+ const p = sel.inventory.playlists.find((pl) => pl.id === e.target.value);
1429
+ if (p) importPlaylistIntoDraft(p);
1430
+ },
1284
1431
  },
1285
- React.createElement("option", { value: "" }, groups.length ? t.selectList : t.noLists),
1286
- ...groups.map((g) => React.createElement("option", {
1287
- key: g.id, value: g.id,
1288
- }, t.groupOption(g.name, groupWallpapers(g).length, g.interval))),
1432
+ React.createElement("option", { value: "" }, t.importPlaylist),
1433
+ ...sel.inventory.playlists.map((p) => React.createElement("option", {
1434
+ key: p.id, value: p.id,
1435
+ }, t.playlistOption(p.name, p.portableCount || 0))),
1289
1436
  ),
1437
+ ),
1438
+ React.createElement("div", { className: "webg-row" },
1290
1439
  React.createElement("button", {
1291
- className: "webg-btn", type: "button", onClick: startCreateGroup,
1292
- }, t.newList),
1293
- React.createElement("button", {
1294
- className: "webg-btn", type: "button",
1295
- onClick: () => startEditGroup(sel.rotationGroupId),
1296
- disabled: !sel.rotationGroupId,
1297
- }, t.editList),
1440
+ className: "webg-btn webg-btn--primary", type: "button",
1441
+ onClick: saveEditingGroup,
1442
+ }, t.save),
1298
1443
  React.createElement("button", {
1299
1444
  className: "webg-btn", type: "button",
1300
- onClick: onDeleteGroup,
1301
- disabled: !sel.rotationGroupId,
1302
- }, t.deleteList),
1445
+ onClick: cancelEditGroup,
1446
+ }, t.cancel),
1303
1447
  ),
1304
- editing && React.createElement("div", { className: "webg-editor" },
1305
- React.createElement("div", { className: "webg-row" },
1306
- React.createElement("span", { className: "webg-hint webg-label" }, t.name),
1307
- React.createElement("input", {
1308
- className: "webg-text", type: "text",
1309
- value: editing.name,
1310
- onInput: (e) => { editing.name = e.target.value; emit(); },
1311
- }),
1312
- ),
1313
- React.createElement("div", { className: "webg-row" },
1314
- React.createElement("span", { className: "webg-hint webg-label" }, t.interval),
1315
- React.createElement("select", {
1316
- className: "webg-select",
1317
- value: String(editing.interval),
1318
- onChange: (e) => { editing.interval = clampNum(Number(e.target.value), 1, 1440, DEFAULTS.rotationInterval); emit(); },
1319
- },
1320
- // A persisted interval outside the preset steps (imported groups,
1321
- // hand-edited storage) still gets a matching option — otherwise the
1322
- // select would silently display the first preset instead of reality.
1323
- ...[...new Set([...INTERVALS, editing.interval])].sort((x, y) => x - y).map((minutes) =>
1324
- React.createElement("option", { key: minutes, value: String(minutes) }, t.minutes(minutes)),
1325
- )),
1326
- React.createElement("span", { className: "webg-hint webg-label" }, t.order),
1327
- React.createElement("select", {
1328
- className: "webg-select webg-playlist-select",
1329
- value: editing.order,
1330
- onChange: (e) => { editing.order = e.target.value; emit(); },
1331
- },
1332
- React.createElement("option", { value: "sequence" }, t.sequence),
1333
- React.createElement("option", { value: "random" }, t.random),
1334
- ),
1335
- ),
1448
+ );
1449
+ }
1450
+
1451
+ function RotationControlsRow(t, sel, group, usableCount) {
1452
+ return React.createElement("div", { className: "webg-row webg-rotation-row" },
1453
+ React.createElement("label", { className: "webg-toggle" },
1336
1454
  React.createElement("input", {
1337
- className: "webg-text", type: "search",
1338
- placeholder: t.filterPlaceholder,
1339
- value: editorQuery,
1340
- onInput: (e) => setEditorQuery(e.target.value),
1455
+ type: "checkbox",
1456
+ checked: sel.rotationEnabled,
1457
+ onChange: onToggleRotation,
1458
+ disabled: !sel.rotationGroupId || usableCount < 2,
1341
1459
  }),
1342
- React.createElement("div", { className: "webg-editor-grid" },
1343
- (() => {
1344
- const eq = editorQuery.trim();
1345
- const pool = usableList.filter((w) => matchesQuery(w, eq));
1346
- if (usableList.length === 0) {
1347
- return React.createElement("span", { className: "webg-hint" }, t.noUsable);
1348
- }
1349
- if (pool.length === 0) {
1350
- return React.createElement("span", { className: "webg-hint" }, t.noMatch(eq));
1351
- }
1352
- return pool.map((w) => {
1353
- const checked = editing.wallpaperIds.indexOf(w.id) >= 0;
1354
- return React.createElement("button", {
1355
- key: w.id,
1356
- className: "webg-editor-card" + (checked ? " webg-editor-card--checked" : ""),
1357
- type: "button",
1358
- title: w.title + "(" + typeBadge(w, t) + ")",
1359
- onClick: () => {
1360
- const i = editing.wallpaperIds.indexOf(w.id);
1361
- if (i >= 0) editing.wallpaperIds.splice(i, 1);
1362
- else editing.wallpaperIds.push(w.id);
1363
- emit();
1364
- },
1365
- },
1366
- w.preview
1367
- ? React.createElement("img", {
1368
- src: w.preview, alt: w.title, loading: "lazy", decoding: "async",
1369
- onError: (e) => { e.target.style.display = "none"; },
1370
- })
1371
- : React.createElement("span", { className: "webg-card-placeholder" }, t.noPreview),
1372
- checked && React.createElement("span", { className: "webg-editor-check" }, "✓"),
1373
- );
1374
- });
1375
- })(),
1376
- ),
1377
- React.createElement("div", { className: "webg-row" },
1378
- React.createElement("span", { className: "webg-hint" }, t.selectedCount(editing.wallpaperIds.length)),
1379
- sel.inventory.playlists.length > 0 && React.createElement("select", {
1380
- className: "webg-select webg-playlist-select",
1381
- value: "",
1382
- onChange: (e) => {
1383
- const p = sel.inventory.playlists.find((pl) => pl.id === e.target.value);
1384
- if (p) importPlaylistIntoDraft(p);
1385
- },
1386
- },
1387
- React.createElement("option", { value: "" }, t.importPlaylist),
1388
- ...sel.inventory.playlists.map((p) => React.createElement("option", {
1389
- key: p.id, value: p.id,
1390
- }, t.playlistOption(p.name, p.portableCount || 0))),
1391
- ),
1392
- ),
1393
- React.createElement("div", { className: "webg-row" },
1394
- React.createElement("button", {
1395
- className: "webg-btn webg-btn--primary", type: "button",
1396
- onClick: saveEditingGroup,
1397
- }, t.save),
1398
- React.createElement("button", {
1399
- className: "webg-btn", type: "button",
1400
- onClick: cancelEditGroup,
1401
- }, t.cancel),
1402
- ),
1460
+ t.autoRotate,
1403
1461
  ),
1404
- React.createElement("div", { className: "webg-row webg-rotation-row" },
1405
- React.createElement("label", { className: "webg-toggle" },
1406
- React.createElement("input", {
1407
- type: "checkbox",
1408
- checked: sel.rotationEnabled,
1409
- onChange: onToggleRotation,
1410
- disabled: !sel.rotationGroupId || usableCount < 2,
1411
- }),
1412
- t.autoRotate,
1413
- ),
1414
- React.createElement("select", {
1415
- className: "webg-select webg-rotation-interval",
1416
- value: String(group ? group.interval : DEFAULTS.rotationInterval),
1417
- onChange: onGroupInterval,
1418
- disabled: !sel.rotationEnabled || !sel.rotationGroupId || usableCount < 2,
1419
- title: t.intervalTitle,
1462
+ React.createElement("select", {
1463
+ className: "webg-select webg-rotation-interval",
1464
+ value: String(group ? group.interval : DEFAULTS.rotationInterval),
1465
+ onChange: onGroupInterval,
1466
+ disabled: !sel.rotationEnabled || !sel.rotationGroupId || usableCount < 2,
1467
+ title: t.intervalTitle,
1468
+ },
1469
+ ...intervalOptions(group ? group.interval : DEFAULTS.rotationInterval, t)),
1470
+ !sel.rotationGroupId && React.createElement("span", { className: "webg-hint" }, t.needList),
1471
+ sel.rotationGroupId && usableCount < 2 && React.createElement("span", { className: "webg-hint" }, t.needTwo),
1472
+ );
1473
+ }
1474
+
1475
+ function EffectsPanel(t, sel) {
1476
+ return React.createElement(React.Fragment, null,
1477
+ // object-fit only means something for video/still wallpapers — an
1478
+ // iframe ignores it and always fills its layer — so the base-mode
1479
+ // select is hidden for web. The manual crop (drag / wheel in the
1480
+ // adjust overlay) is a plain CSS transform and applies to EVERY kind,
1481
+ // so 调整画面 / 重置裁剪 stay available even for iframes.
1482
+ React.createElement("div", { className: "webg-row webg-fit-row" },
1483
+ sel.type !== "web" && React.createElement("span", { className: "webg-hint webg-label" }, t.fit),
1484
+ sel.type !== "web" && React.createElement("select", {
1485
+ className: "webg-select webg-fit-select",
1486
+ value: sel.fit,
1487
+ onChange: onFit,
1420
1488
  },
1421
- ...[...new Set([...INTERVALS, group ? group.interval : DEFAULTS.rotationInterval])]
1422
- .sort((x, y) => x - y).map((minutes) =>
1423
- React.createElement("option", { key: minutes, value: String(minutes) }, t.minutes(minutes)),
1424
- )),
1425
- !sel.rotationGroupId && React.createElement("span", { className: "webg-hint" }, t.needList),
1426
- sel.rotationGroupId && usableCount < 2 && React.createElement("span", { className: "webg-hint" }, t.needTwo),
1427
- ),
1428
- // ── Effects ──
1429
- sel.id && React.createElement(React.Fragment, null,
1430
- // Canvas fit only matters for video/still wallpapers — an iframe
1431
- // ignores object-fit and always fills its layer. Manual crop (drag /
1432
- // wheel in the adjust overlay) replaced the old fixed anchor select.
1433
- sel.type !== "web" && React.createElement("div", { className: "webg-row webg-fit-row" },
1434
- React.createElement("span", { className: "webg-hint webg-label" }, t.fit),
1435
- React.createElement("select", {
1436
- className: "webg-select webg-fit-select",
1437
- value: sel.fit,
1438
- onChange: onFit,
1439
- },
1440
- ...[t.fitCover, t.fitContain, t.fitFill, t.fitNone].map((label, i) =>
1441
- React.createElement("option", { key: FIT_MODES[i], value: FIT_MODES[i] }, label),
1442
- )),
1489
+ ...[t.fitCover, t.fitContain, t.fitFill, t.fitNone].map((label, i) =>
1490
+ React.createElement("option", { key: FIT_MODES[i], value: FIT_MODES[i] }, label),
1491
+ )),
1492
+ React.createElement("button", {
1493
+ className: "webg-btn", type: "button", onClick: enterAdjust,
1494
+ disabled: sel.adjusting,
1495
+ }, t.adjust),
1496
+ (sel.zoom !== 1 || sel.offsetX !== 0 || sel.offsetY !== 0) &&
1443
1497
  React.createElement("button", {
1444
- className: "webg-btn", type: "button", onClick: () => enterAdjust(),
1445
- disabled: sel.adjusting,
1446
- }, t.adjust),
1447
- (sel.zoom !== 1 || sel.offsetX !== 0 || sel.offsetY !== 0) &&
1448
- React.createElement("button", {
1449
- className: "webg-btn", type: "button", onClick: resetCrop,
1450
- }, t.resetCrop),
1451
- ),
1452
- React.createElement("div", { className: "webg-effects" },
1453
- SliderRow(t.wallpaperBlur, 0, 60, 1, sel.wallpaperBlur, onWallpaperBlur, sel.wallpaperBlur + "px"),
1454
- SliderRow(t.scrim, 0, 90, 5, Math.min(90, Math.round(sel.scrim * 100)), onScrim,
1455
- Math.round(sel.scrim * 100) + "%"),
1456
- SliderRow(t.border, 0, 90, 5, Math.round(sel.border * 100), onBorder, Math.round(sel.border * 100) + "%"),
1457
- SliderRow(t.glass, 0, 40, 1, sel.blur, onBlur, sel.blur + "px"),
1458
- ),
1498
+ className: "webg-btn", type: "button", onClick: resetCrop,
1499
+ }, t.resetCrop),
1459
1500
  ),
1460
- // ── Resource monitor ──
1461
- sel.id && React.createElement("div", { className: "webg-row webg-monitor-row" },
1462
- React.createElement("label", { className: "webg-toggle" },
1463
- React.createElement("input", {
1464
- type: "checkbox", checked: sel.pauseOnHidden, onChange: onTogglePauseOnHidden,
1465
- }),
1466
- t.pauseOnHidden,
1467
- ),
1468
- React.createElement("label", { className: "webg-toggle" },
1469
- React.createElement("input", {
1470
- type: "checkbox", checked: sel.pauseOnBattery, onChange: onTogglePauseOnBattery,
1471
- }),
1472
- t.pauseOnBattery,
1473
- ),
1474
- React.createElement("label", { className: "webg-toggle", title: t.autoScrimTitle },
1475
- React.createElement("input", {
1476
- type: "checkbox", checked: sel.autoScrim, onChange: onToggleAutoScrim,
1477
- }),
1478
- t.autoScrim,
1479
- ),
1480
- sel.fps !== null && React.createElement("span", {
1481
- className: "webg-hint webg-fps" + (sel.fps < LOW_FPS ? " webg-fps--low" : ""),
1482
- title: t.fpsTitle,
1483
- }, sel.fps + " fps"),
1501
+ React.createElement("div", { className: "webg-effects" },
1502
+ SliderRow(t.wallpaperBlur, 0, 60, 1, sel.wallpaperBlur, onWallpaperBlur, sel.wallpaperBlur + "px"),
1503
+ SliderRow(t.scrim, 0, 90, 5, Math.min(90, Math.round(sel.scrim * 100)), onScrim,
1504
+ Math.round(sel.scrim * 100) + "%"),
1505
+ SliderRow(t.border, 0, 90, 5, Math.min(90, Math.round(sel.border * 100)), onBorder,
1506
+ Math.round(sel.border * 100) + "%"),
1507
+ SliderRow(t.glass, 0, 40, 1, sel.blur, onBlur, sel.blur + "px"),
1508
+ ),
1509
+ );
1510
+ }
1511
+
1512
+ function MonitorRow(t, sel) {
1513
+ return React.createElement("div", { className: "webg-row webg-monitor-row" },
1514
+ React.createElement("label", { className: "webg-toggle" },
1515
+ React.createElement("input", {
1516
+ type: "checkbox", checked: sel.pauseOnHidden, onChange: onTogglePauseOnHidden,
1517
+ }),
1518
+ t.pauseOnHidden,
1519
+ ),
1520
+ React.createElement("label", { className: "webg-toggle" },
1521
+ React.createElement("input", {
1522
+ type: "checkbox", checked: sel.pauseOnBattery, onChange: onTogglePauseOnBattery,
1523
+ }),
1524
+ t.pauseOnBattery,
1484
1525
  ),
1526
+ React.createElement("label", { className: "webg-toggle", title: t.autoScrimTitle },
1527
+ React.createElement("input", {
1528
+ type: "checkbox", checked: sel.autoScrim, onChange: onToggleAutoScrim,
1529
+ }),
1530
+ t.autoScrim,
1531
+ ),
1532
+ sel.fps !== null && React.createElement("span", {
1533
+ className: "webg-hint webg-fps" + (sel.fps < LOW_FPS ? " webg-fps--low" : ""),
1534
+ title: t.fpsTitle,
1535
+ }, sel.fps + " fps"),
1536
+ );
1537
+ }
1538
+
1539
+ function StatusRow(t, sel, group, usableCount, usableTotal) {
1540
+ return React.createElement("div", { className: "webg-row" },
1541
+ React.createElement("span", { className: "webg-hint" },
1542
+ (group
1543
+ ? t.statusGroup(group.name, group.wallpaperIds.length, usableCount, group.interval,
1544
+ group.order === "random" ? t.random : t.sequence)
1545
+ : t.statusUsable(usableTotal)) +
1546
+ (sel.rotationEnabled ? " · " + t.autoRotating : "") +
1547
+ (sel.autoPauseReasons.size > 0 ? " · " + t.autoPaused : "") +
1548
+ (sel.id && demotedToPreview.has(sel.id) ? " · " + t.decodeFallback : "") +
1549
+ (sel.inventory.installDir ? " · " + sel.inventory.installDir : "")),
1550
+ );
1551
+ }
1552
+
1553
+ function WallpaperPicker() {
1554
+ const sel = useStore();
1555
+ const [query, setQuery] = React.useState("");
1556
+ const [editorQuery, setEditorQuery] = React.useState("");
1557
+ const t = S();
1558
+
1559
+ if (!sel.loaded) {
1560
+ return React.createElement("div", { className: "webg-picker" },
1561
+ React.createElement("span", { className: "webg-hint" }, t.scanning));
1562
+ }
1563
+ if (sel.inventory.error) {
1564
+ return React.createElement("div", { className: "webg-picker" },
1565
+ React.createElement("div", { className: "webg-error" },
1566
+ t.connError + sel.inventory.error),
1567
+ React.createElement("button", {
1568
+ className: "webg-btn", type: "button", onClick: onRefresh, disabled: sel.loading,
1569
+ }, sel.loading ? t.refreshing : t.retry));
1570
+ }
1571
+
1572
+ const usableList = selectableInventory();
1573
+ const q = query.trim();
1574
+ const filteredList = usableList.filter((w) => matchesQuery(w, q));
1575
+ const groups = sel.rotationGroups;
1576
+ const group = activeRotationGroup();
1577
+ const usableCount = rotationCandidates().length;
1578
+ const editing = sel.editing;
1579
+
1580
+ return React.createElement("div", { className: "webg-picker" },
1581
+ SearchRow(t, sel, query, setQuery, filteredList.length, usableList.length),
1582
+ WallpaperGrid(t, sel, usableList, filteredList, q),
1583
+ ActionRow(t, sel),
1584
+ RotationListRow(t, sel, groups),
1585
+ editing && GroupEditorPanel(t, sel, usableList, editorQuery, setEditorQuery),
1586
+ RotationControlsRow(t, sel, group, usableCount),
1587
+ sel.id && EffectsPanel(t, sel),
1588
+ sel.id && MonitorRow(t, sel),
1485
1589
  sel.id && sel.fps !== null && sel.fps < LOW_FPS && sel.type === "video" &&
1486
1590
  React.createElement("div", { className: "webg-row" },
1487
- React.createElement("span", { className: "webg-hint" }, t.lowFpsHint),
1488
- ),
1489
- // ── Status ──
1490
- React.createElement("div", { className: "webg-row" },
1491
- React.createElement("span", { className: "webg-hint" },
1492
- (group
1493
- ? t.statusGroup(group.name, group.wallpaperIds.length, usableCount, group.interval,
1494
- group.order === "random" ? t.random : t.sequence)
1495
- : t.statusUsable(usableList.length)) +
1496
- (sel.rotationEnabled ? " · " + t.autoRotating : "") +
1497
- (autoPaused ? " · " + t.autoPaused : "") +
1498
- (sel.id && demotedToPreview.has(sel.id) ? " · " + t.decodeFallback : "") +
1499
- (sel.inventory.installDir ? " · " + sel.inventory.installDir : "")),
1500
- ),
1591
+ React.createElement("span", { className: "webg-hint" }, t.lowFpsHint)),
1592
+ StatusRow(t, sel, group, usableCount, usableList.length),
1501
1593
  );
1502
1594
  }
1503
1595
 
@@ -1513,17 +1605,18 @@ window.__ModuleLoader__.load({
1513
1605
  }
1514
1606
  .webg-layer--enter { opacity: 0; }
1515
1607
  .webg-layer--leave { opacity: 0; }
1516
- /* Overscan: the media is LARGER than the layer by one blur radius on every
1517
- side, so the transparent fringe CSS blur produces is clipped by the
1518
- layer's overflow:hidden instead of compensated with a content-warping
1519
- scale. 1× is the fringe a Gaussian kernel actually needs; more would
1520
- double GPU fill and mis-anchor non-center object-positions. A slight
1521
- saturate keeps blurred colours from washing out. */
1608
+ /* The media box is ALWAYS exactly the layer. Blur must never re-frame the
1609
+ content: enlarging the box for overscan made object-fit:cover scale the
1610
+ picture up to fill the bigger box, silently cropping the wallpaper's
1611
+ four edges — and once cropped, no zoom could bring them back. Instead the
1612
+ Gaussian's natural edge fade (≤ one blur radius, under the veil and the
1613
+ UI chrome) is accepted. A slight saturate keeps blurred colours from
1614
+ washing out. */
1522
1615
  .webg-layer .webg-media {
1523
1616
  position: absolute;
1524
- inset: calc(-1 * var(--webg-wallpaper-blur, 0px));
1525
- width: calc(100% + 2 * var(--webg-wallpaper-blur, 0px));
1526
- height: calc(100% + 2 * var(--webg-wallpaper-blur, 0px));
1617
+ inset: 0;
1618
+ width: 100%;
1619
+ height: 100%;
1527
1620
  max-width: none; max-height: none;
1528
1621
  object-fit: var(--webg-fit, cover);
1529
1622
  object-position: center;
@@ -1616,10 +1709,10 @@ window.__ModuleLoader__.load({
1616
1709
  -webkit-backdrop-filter: blur(var(--webg-blur, 16px)) saturate(var(--webg-saturate, 1.8)) brightness(var(--webg-glass-brightness, 1.04)) contrast(1.01);
1617
1710
  backdrop-filter: blur(var(--webg-blur, 16px)) saturate(var(--webg-saturate, 1.8)) brightness(var(--webg-glass-brightness, 1.04)) contrast(1.01);
1618
1711
  box-shadow:
1619
- inset 0 1px 0 rgba(255, 255, 255, var(--webg-glass-highlight, 0.32)),
1712
+ inset 0 1px 0 rgba(255, 255, 255, 0.32),
1620
1713
  inset 0 -1px 0 rgba(255, 255, 255, 0.08),
1621
1714
  inset 0 0 0 0.5px rgba(255, 255, 255, 0.08),
1622
- 0 12px 40px rgba(0, 0, 0, var(--webg-glass-shadow, 0.12));
1715
+ 0 12px 40px rgba(0, 0, 0, 0.12);
1623
1716
  }
1624
1717
 
1625
1718
  /* ── Picker chrome (token-driven, theme-aware) ── */
@@ -1830,6 +1923,12 @@ window.__ModuleLoader__.load({
1830
1923
  const inject = ["slots"];
1831
1924
 
1832
1925
  function apply(ctx) {
1926
+ // A plugin UPDATE re-applies this module WITHOUT re-evaluating it: the old
1927
+ // fiber's cleanup has already set `disposed = true`, which would make the
1928
+ // new fiber's loadInventory return forever (blank picker, no wallpaper).
1929
+ // Every apply starts a fresh lifecycle.
1930
+ disposed = false;
1931
+
1833
1932
  // 0. Language: follow the shell's locale service when present, and react
1834
1933
  // to its change events. Everything is optional — without the service
1835
1934
  // the browser language decides, and the manual override always wins.