wallpaper-engine-dsh 0.1.5 → 0.1.7

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
@@ -79,6 +79,8 @@ window.__ModuleLoader__.load({
79
79
  glass: "玻璃",
80
80
  pauseOnHidden: "页面隐藏时暂停",
81
81
  pauseOnBattery: "低电量时暂停",
82
+ autoScrim: "智能可视",
83
+ autoScrimTitle: "采样壁纸亮度,过亮或过暗时自动加强遮罩,保证文字可读",
82
84
  fpsTitle: "页面当前渲染帧率",
83
85
  lowFpsHint: "帧率偏低,可尝试暂停视频、增大壁纸模糊或换用静态壁纸",
84
86
  language: "语言",
@@ -102,6 +104,8 @@ window.__ModuleLoader__.load({
102
104
  confirmDelete: (name) => "删除轮播列表「" + name + "」?",
103
105
  defaultGroupName: (n) => "轮播列表 " + n,
104
106
  defaultGroupBase: "轮播列表",
107
+ iframeTitle: "Wallpaper Engine 网页壁纸",
108
+ decodeFallback: "媒体无法解码,已改用预览图",
105
109
  },
106
110
  en: {
107
111
  settingsLabel: "Wallpaper Engine",
@@ -155,6 +159,8 @@ window.__ModuleLoader__.load({
155
159
  glass: "Glass",
156
160
  pauseOnHidden: "Pause when tab hidden",
157
161
  pauseOnBattery: "Pause on low battery",
162
+ autoScrim: "Smart veil",
163
+ autoScrimTitle: "Samples wallpaper brightness and strengthens the veil when needed so text stays readable",
158
164
  fpsTitle: "Current page frame rate",
159
165
  lowFpsHint: "Low frame rate — try pausing the video, raising wallpaper blur, or using a still wallpaper",
160
166
  language: "Language",
@@ -178,6 +184,8 @@ window.__ModuleLoader__.load({
178
184
  confirmDelete: (name) => "Delete rotation list \"" + name + "\"?",
179
185
  defaultGroupName: (n) => "Rotation list " + n,
180
186
  defaultGroupBase: "Rotation list",
187
+ iframeTitle: "Wallpaper Engine web wallpaper",
188
+ decodeFallback: "Media cannot be decoded here — showing the preview image",
181
189
  },
182
190
  };
183
191
 
@@ -214,6 +222,7 @@ window.__ModuleLoader__.load({
214
222
  rotationSeeded: false,
215
223
  pauseOnHidden: true,
216
224
  pauseOnBattery: true,
225
+ autoScrim: true,
217
226
  lang: "auto",
218
227
  fit: "cover",
219
228
  position: "center",
@@ -260,10 +269,11 @@ window.__ModuleLoader__.load({
260
269
  wallpaperBlur: clampNum(o.wallpaperBlur, 0, 60, DEFAULTS.wallpaperBlur),
261
270
  rotationEnabled: o.rotationEnabled === true,
262
271
  rotationGroupId: typeof o.rotationGroupId === "string" ? o.rotationGroupId : "",
263
- rotationGroups: readRotationGroups(o.rotationGroups, STRINGS.zh.defaultGroupBase),
272
+ rotationGroups: readRotationGroups(o.rotationGroups, STRINGS[browserLang() || "zh"].defaultGroupBase),
264
273
  rotationSeeded: o.rotationSeeded === true,
265
274
  pauseOnHidden: o.pauseOnHidden !== false,
266
275
  pauseOnBattery: o.pauseOnBattery !== false,
276
+ autoScrim: o.autoScrim !== false,
267
277
  lang: o.lang === "zh" || o.lang === "en" ? o.lang : "auto",
268
278
  fit: FIT_MODES.indexOf(o.fit) >= 0 ? o.fit : DEFAULTS.fit,
269
279
  position: POSITIONS.indexOf(o.position) >= 0 ? o.position : DEFAULTS.position,
@@ -315,6 +325,7 @@ window.__ModuleLoader__.load({
315
325
  rotationSeeded: selection.rotationSeeded,
316
326
  pauseOnHidden: selection.pauseOnHidden,
317
327
  pauseOnBattery: selection.pauseOnBattery,
328
+ autoScrim: selection.autoScrim,
318
329
  lang: selection.lang,
319
330
  fit: selection.fit,
320
331
  position: selection.position,
@@ -493,9 +504,14 @@ window.__ModuleLoader__.load({
493
504
  }
494
505
 
495
506
  function startCreateGroup() {
507
+ // Default names must stay unique even after deletions: take the smallest
508
+ // free index instead of count+1.
509
+ const taken = new Set(selection.rotationGroups.map((g) => g.name));
510
+ let n = 1;
511
+ while (taken.has(S().defaultGroupName(n))) n++;
496
512
  selection.editing = {
497
513
  id: nextGroupId(),
498
- name: S().defaultGroupName(selection.rotationGroups.length + 1),
514
+ name: S().defaultGroupName(n),
499
515
  interval: DEFAULTS.rotationInterval,
500
516
  order: "sequence",
501
517
  wallpaperIds: [],
@@ -513,7 +529,7 @@ window.__ModuleLoader__.load({
513
529
  interval: clampNum(draft.interval, 1, 1440, DEFAULTS.rotationInterval),
514
530
  order: draft.order === "random" ? "random" : "sequence",
515
531
  wallpaperIds: Array.isArray(draft.wallpaperIds)
516
- ? draft.wallpaperIds.filter((x) => typeof x === "string" && x)
532
+ ? [...new Set(draft.wallpaperIds.filter((x) => typeof x === "string" && x))]
517
533
  : [],
518
534
  };
519
535
  if (idx >= 0) selection.rotationGroups[idx] = cleaned;
@@ -522,8 +538,13 @@ window.__ModuleLoader__.load({
522
538
  selection.editing = null;
523
539
  if (selection.rotationEnabled && !rotationCandidates().some((w) => w.id === selection.id)) {
524
540
  const first = rotationCandidates()[0];
525
- applySelection(first ? first.id : "");
526
- return;
541
+ if (first) {
542
+ applySelection(first.id);
543
+ return;
544
+ }
545
+ // Saved list has nothing usable — keep the current wallpaper and simply
546
+ // stop rotation instead of clearing the screen.
547
+ selection.rotationEnabled = false;
527
548
  }
528
549
  persistSelection();
529
550
  syncRotationTimer();
@@ -556,7 +577,7 @@ window.__ModuleLoader__.load({
556
577
  function importPlaylistIntoDraft(playlist) {
557
578
  if (!selection.editing || !playlist || !Array.isArray(playlist.wallpaperIds)) return;
558
579
  const usable = new Set(selectableInventory().map((w) => w.id));
559
- selection.editing.wallpaperIds = playlist.wallpaperIds.filter((id) => usable.has(id));
580
+ selection.editing.wallpaperIds = [...new Set(playlist.wallpaperIds.filter((id) => usable.has(id)))];
560
581
  emit();
561
582
  }
562
583
 
@@ -566,10 +587,12 @@ window.__ModuleLoader__.load({
566
587
  const w = selection.id
567
588
  ? selection.inventory.wallpapers.find((x) => x.id === selection.id)
568
589
  : null;
569
- if (isPlayable(w)) {
590
+ if (isPlayable(w) && !demotedToPreview.has(selection.id)) {
570
591
  selection.url = w.media;
571
592
  selection.type = w.type; // "video" | "web"
572
- } else if (w && isStatic(w)) {
593
+ } else if (w && w.preview) {
594
+ // Static path: scene/application wallpapers, and playable wallpapers
595
+ // demoted after repeated media failures (undecodable container…).
573
596
  selection.url = w.preview;
574
597
  selection.type = "image";
575
598
  } else {
@@ -592,11 +615,27 @@ window.__ModuleLoader__.load({
592
615
  return selection.playing && selection.autoPauseReasons.size === 0;
593
616
  }
594
617
 
618
+ // Latest known battery state (null until getBattery resolves). Kept at module
619
+ // scope so re-enabling the toggle can re-evaluate immediately instead of
620
+ // waiting for the next battery event.
621
+ let batteryState = null;
622
+ function applyBatteryPause() {
623
+ if (!batteryState || !selection.pauseOnBattery) return;
624
+ setAutoPause("battery",
625
+ !batteryState.charging && batteryState.level <= BATTERY_SAVER_LEVEL);
626
+ }
627
+
595
628
  // ── Behind-body layer: wallpaper + scrim, with crossfade ────────────────────
596
629
  // Set by a media error handler to force a remount with fresh URLs; cleared
597
630
  // once the remount happens.
598
631
  let forceRemount = false;
599
632
  let lastMediaRecoveryAt = 0;
633
+ // Media failure bookkeeping: the first error for a wallpaper may just be a
634
+ // stale token (fixed by a refresh+remount); a SECOND error for the same id
635
+ // means the bytes themselves are unusable (e.g. an mkv/avi Chromium cannot
636
+ // decode) — demote it to its preview image instead of looping forever.
637
+ const mediaFailures = new Map(); // wallpaperId → consecutive failure count
638
+ const demotedToPreview = new Set(); // wallpaperIds now rendered as stills
600
639
 
601
640
  function buildMedia(sel) {
602
641
  let media;
@@ -617,7 +656,7 @@ window.__ModuleLoader__.load({
617
656
  media.setAttribute("referrerpolicy", "no-referrer");
618
657
  media.setAttribute("frameborder", "0");
619
658
  media.setAttribute("scrolling", "no");
620
- media.setAttribute("title", "Wallpaper Engine web wallpaper");
659
+ media.setAttribute("title", S().iframeTitle);
621
660
  media.className = "webg-media webg-iframe";
622
661
  } else {
623
662
  // Static wallpaper: the scene/application preview as a still image.
@@ -628,22 +667,53 @@ window.__ModuleLoader__.load({
628
667
  media.setAttribute("decoding", "async");
629
668
  media.className = "webg-media";
630
669
  }
631
- // Self-heal: if the media fails to load (e.g. its token died on an
632
- // inventory rebuild), refetch fresh URLs and remount — throttled to once
633
- // per 10 s so a permanently-broken wallpaper cannot loop.
670
+ // First luminance sample as soon as the frame is decodable (video/still
671
+ // only — iframe contents are opaque to us). Later samples ride the timer.
672
+ if (sel.type === "video" || sel.type === "image") {
673
+ media.addEventListener(sel.type === "video" ? "loadeddata" : "load", () => {
674
+ sampleWallpaperLuminance();
675
+ applyEffects();
676
+ });
677
+ }
634
678
  media.addEventListener("error", () => {
635
- const now = Date.now();
636
- if (now - lastMediaRecoveryAt < 10000) return;
637
- lastMediaRecoveryAt = now;
638
- forceRemount = true;
639
- loadInventory(true);
679
+ const id = sel.id;
680
+ const count = (mediaFailures.get(id) || 0) + 1;
681
+ mediaFailures.set(id, count);
682
+ const w = selection.inventory.wallpapers.find((x) => x.id === id);
683
+ if (count === 1) {
684
+ // Maybe just a dead token: refetch fresh URLs and remount (throttled).
685
+ const now = Date.now();
686
+ if (now - lastMediaRecoveryAt < 10000) return;
687
+ lastMediaRecoveryAt = now;
688
+ forceRemount = true;
689
+ loadInventory(true);
690
+ } else if (w && w.preview && !demotedToPreview.has(id)) {
691
+ // The media itself is unusable here — fall back to the still preview.
692
+ demotedToPreview.add(id);
693
+ applySelection(id);
694
+ }
640
695
  });
641
696
  return media;
642
697
  }
643
698
 
699
+ // Crossfade/clear leave timers: tracked so dispose can cancel them instead of
700
+ // letting them fire on torn-down nodes afterwards.
701
+ const pendingTimers = new Set();
702
+ function schedule(fn, ms) {
703
+ if (typeof window === "undefined" || typeof window.setTimeout !== "function") {
704
+ fn();
705
+ return null;
706
+ }
707
+ const token = window.setTimeout(() => {
708
+ pendingTimers.delete(token);
709
+ fn();
710
+ }, ms);
711
+ pendingTimers.add(token);
712
+ return token;
713
+ }
714
+
644
715
  function syncLayers() {
645
716
  const existing = document.getElementById(LAYER_ID);
646
- const hasTimeout = typeof window !== "undefined" && typeof window.setTimeout === "function";
647
717
 
648
718
  if (selection.url) {
649
719
  // Key by wallpaper id, NOT by media URL: inventory rebuilds re-mint
@@ -670,8 +740,7 @@ window.__ModuleLoader__.load({
670
740
  if (existing) {
671
741
  existing.classList.add("webg-layer--leave");
672
742
  const stale = existing;
673
- if (hasTimeout) window.setTimeout(() => stale.remove(), CROSSFADE_MS + 120);
674
- else stale.remove();
743
+ schedule(() => stale.remove(), CROSSFADE_MS + 120);
675
744
  }
676
745
  }
677
746
  const node = document.getElementById(LAYER_ID);
@@ -686,16 +755,14 @@ window.__ModuleLoader__.load({
686
755
  existing.removeAttribute("id");
687
756
  existing.classList.add("webg-layer--leave");
688
757
  const stale = existing;
689
- const finish = () => {
758
+ schedule(() => {
690
759
  stale.remove();
691
760
  if (!document.getElementById(LAYER_ID)) {
692
761
  const s = document.getElementById(SCRIM_ID);
693
762
  if (s) s.remove();
694
763
  document.body.removeAttribute(ACTIVE_ATTR);
695
764
  }
696
- };
697
- if (hasTimeout) window.setTimeout(finish, CROSSFADE_MS + 120);
698
- else finish();
765
+ }, CROSSFADE_MS + 120);
699
766
  }
700
767
 
701
768
  // Scrim: present while a wallpaper is active. On clear it outlives the
@@ -713,15 +780,61 @@ window.__ModuleLoader__.load({
713
780
  }
714
781
 
715
782
  // ── Effect application: push the knobs into CSS variables ───────────────────
783
+ // Wallpaper luminance sampled from the live media (null until the first
784
+ // sample; iframes of web wallpapers can never be sampled — opaque origin).
785
+ let sampledLuminance = null;
786
+ let sampleCanvas = null;
787
+
788
+ /** Average Rec.601 luminance (0..1) of the current video/img, or null. */
789
+ function sampleWallpaperLuminance() {
790
+ if (typeof document === "undefined") return;
791
+ const layer = document.getElementById(LAYER_ID);
792
+ const media = layer && (layer.querySelector("video") || layer.querySelector("img"));
793
+ if (!media) { sampledLuminance = null; return; }
794
+ try {
795
+ if (!sampleCanvas) {
796
+ const c = document.createElement("canvas");
797
+ if (typeof c.getContext !== "function") return; // no canvas → no sampling
798
+ c.width = 16; c.height = 16;
799
+ sampleCanvas = c;
800
+ }
801
+ const g = sampleCanvas.getContext("2d", { willReadFrequently: true });
802
+ g.drawImage(media, 0, 0, 16, 16);
803
+ const d = g.getImageData(0, 0, 16, 16).data;
804
+ let sum = 0;
805
+ for (let i = 0; i < d.length; i += 4) {
806
+ sum += 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2];
807
+ }
808
+ sampledLuminance = sum / (d.length / 4) / 255;
809
+ } catch { /* frame not ready / unreadable → keep the previous sample */ }
810
+ }
811
+
716
812
  function applyEffects() {
717
813
  const s = document.body.style;
718
- s.setProperty("--webg-scrim-color", "rgba(0,0,0," + selection.scrim + ")");
814
+ // The veil OPPOSES the text colour: dark theme (light text) darkens bright
815
+ // wallpapers, light theme (dark text) brightens dark ones — either way the
816
+ // text side wins. This is what makes ANY wallpaper survivable.
817
+ const dark = Boolean(document.body.hasAttribute && document.body.hasAttribute("data-ds-dark-theme"));
818
+ let alpha = selection.scrim;
819
+ if (selection.autoScrim && sampledLuminance !== null) {
820
+ // Distance of the wallpaper's brightness from the "comfortable middle"
821
+ // towards the text colour's weakness, mapped to a veil-alpha floor.
822
+ const need = dark
823
+ ? Math.min(0.55, Math.max(0, sampledLuminance - 0.5))
824
+ : Math.min(0.55, Math.max(0, 0.5 - sampledLuminance));
825
+ alpha = Math.max(alpha, Math.round(need * 1000) / 1000);
826
+ }
827
+ const veilColor = "rgba(" + (dark ? "0,0,0" : "255,255,255") + "," + alpha + ")";
828
+ s.setProperty("--webg-scrim-color", veilColor);
719
829
  s.setProperty("--webg-border-alpha", String(selection.border));
720
830
  s.setProperty("--webg-blur", selection.blur + "px");
721
831
  // The glass "colour melt" scales with blur radius: 0 blur → no melt.
722
832
  s.setProperty("--webg-saturate", String(1.15 + selection.blur * 0.028));
723
833
  s.setProperty("--webg-glass-brightness", "1.04");
724
834
  s.setProperty("--webg-wallpaper-blur", selection.wallpaperBlur + "px");
835
+ // Dither-noise strength scales with blur: invisible at 0, ~4% at 60px.
836
+ s.setProperty("--webg-noise-opacity",
837
+ selection.wallpaperBlur > 0 ? (0.02 + selection.wallpaperBlur * 0.0004).toFixed(4) : "0");
725
838
  // Canvas fit: object-fit/position for video and still wallpapers (iframes
726
839
  // ignore object-fit — web wallpapers always fill their layer).
727
840
  s.setProperty("--webg-fit", selection.fit);
@@ -730,7 +843,7 @@ window.__ModuleLoader__.load({
730
843
  // Write the scrim colour directly too, then force a synchronous layout, so
731
844
  // slider feedback lands on this frame even on stalled compositors.
732
845
  const scrim = document.getElementById(SCRIM_ID);
733
- if (scrim) scrim.style.background = "rgba(0,0,0," + selection.scrim + ")";
846
+ if (scrim) scrim.style.background = veilColor;
734
847
  if (document.body && document.body.offsetHeight !== undefined) {
735
848
  void document.body.offsetHeight;
736
849
  }
@@ -744,6 +857,7 @@ window.__ModuleLoader__.load({
744
857
  s.removeProperty("--webg-saturate");
745
858
  s.removeProperty("--webg-glass-brightness");
746
859
  s.removeProperty("--webg-wallpaper-blur");
860
+ s.removeProperty("--webg-noise-opacity");
747
861
  s.removeProperty("--webg-fit");
748
862
  s.removeProperty("--webg-position");
749
863
  const scrim = document.getElementById(SCRIM_ID);
@@ -755,16 +869,25 @@ window.__ModuleLoader__.load({
755
869
  // A Fragment of three cells — the surrounding .webg-effects grid keeps
756
870
  // every slider starting and ending at the same x regardless of how wide
757
871
  // the label text is (壁纸模糊 vs 暗化 vs Wallpaper blur…).
872
+ // --webg-p feeds the filled-track gradient; updated inline while dragging
873
+ // so the fill tracks the thumb even between re-renders.
874
+ const pct = ((value - min) / (max - min)) * 100;
875
+ const set = (e) => {
876
+ const v = Number(e.target.value);
877
+ if (e.target.style && typeof e.target.style.setProperty === "function") {
878
+ e.target.style.setProperty("--webg-p", String(((v - min) / (max - min)) * 100));
879
+ }
880
+ onInput(v);
881
+ };
758
882
  return React.createElement(React.Fragment, null,
759
883
  React.createElement("span", { className: "webg-hint webg-label" }, label),
760
884
  React.createElement("input", {
761
885
  className: "webg-slider", type: "range",
762
886
  min: String(min), max: String(max), step: String(step),
763
887
  value: String(value),
764
- // onInput fires continuously while dragging (onChange may wait for
765
- // release) — that is what makes the visual feedback instant.
766
- onInput: (e) => onInput(Number(e.target.value)),
767
- onChange: (e) => onInput(Number(e.target.value)),
888
+ style: { "--webg-p": String(pct) },
889
+ onInput: set,
890
+ onChange: set,
768
891
  }),
769
892
  React.createElement("span", { className: "webg-hint webg-value" }, suffix),
770
893
  );
@@ -808,7 +931,13 @@ window.__ModuleLoader__.load({
808
931
  const t = S();
809
932
  const onTogglePlay = () => { selection.playing = !selection.playing; emit(); };
810
933
  const onClear = () => applySelection("");
811
- const onRefresh = () => loadInventory(true);
934
+ const onRefresh = () => {
935
+ // A manual refresh also retries wallpapers previously demoted to their
936
+ // preview after decode failures.
937
+ demotedToPreview.clear();
938
+ mediaFailures.clear();
939
+ loadInventory(true);
940
+ };
812
941
  const onLangChange = (e) => {
813
942
  selection.lang = e.target.value;
814
943
  persistSelection();
@@ -828,15 +957,12 @@ window.__ModuleLoader__.load({
828
957
  };
829
958
  const onToggleRotation = () => {
830
959
  selection.rotationEnabled = !selection.rotationEnabled;
831
- if (selection.rotationEnabled) {
832
- if (!selection.rotationGroupId) {
833
- const usable = firstUsableGroup();
834
- if (usable) selection.rotationGroupId = usable.id;
835
- }
836
- if (!rotationCandidates().some((w) => w.id === selection.id)) {
837
- const first = rotationCandidates()[0];
838
- if (first) { applySelection(first.id); return; }
839
- }
960
+ // The checkbox is disabled without a usable group, so a group id always
961
+ // exists here when enabling.
962
+ if (selection.rotationEnabled &&
963
+ !rotationCandidates().some((w) => w.id === selection.id)) {
964
+ const first = rotationCandidates()[0];
965
+ if (first) { applySelection(first.id); return; }
840
966
  }
841
967
  persistSelection();
842
968
  syncRotationTimer();
@@ -860,16 +986,31 @@ window.__ModuleLoader__.load({
860
986
  };
861
987
  const onTogglePauseOnHidden = () => {
862
988
  selection.pauseOnHidden = !selection.pauseOnHidden;
863
- if (!selection.pauseOnHidden) setAutoPause("hidden", false);
989
+ if (selection.pauseOnHidden) {
990
+ // Re-enabling while already hidden must re-apply the reason — the
991
+ // visibilitychange listener only fires on CHANGES.
992
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
993
+ setAutoPause("hidden", true);
994
+ }
995
+ } else {
996
+ setAutoPause("hidden", false);
997
+ }
864
998
  persistSelection();
865
999
  emit();
866
1000
  };
867
1001
  const onTogglePauseOnBattery = () => {
868
1002
  selection.pauseOnBattery = !selection.pauseOnBattery;
869
- if (!selection.pauseOnBattery) setAutoPause("battery", false);
1003
+ if (selection.pauseOnBattery) applyBatteryPause(); // re-evaluate NOW
1004
+ else setAutoPause("battery", false);
870
1005
  persistSelection();
871
1006
  emit();
872
1007
  };
1008
+ const onToggleAutoScrim = () => {
1009
+ selection.autoScrim = !selection.autoScrim;
1010
+ persistSelection();
1011
+ applyEffects();
1012
+ emit();
1013
+ };
873
1014
 
874
1015
  const onScrim = (pct) => { selection.scrim = pct / 100; persistSelection(); applyEffects(); emit(); };
875
1016
  const onBorder = (pct) => { selection.border = pct / 100; persistSelection(); applyEffects(); emit(); };
@@ -1165,6 +1306,12 @@ window.__ModuleLoader__.load({
1165
1306
  }),
1166
1307
  t.pauseOnBattery,
1167
1308
  ),
1309
+ React.createElement("label", { className: "webg-toggle", title: t.autoScrimTitle },
1310
+ React.createElement("input", {
1311
+ type: "checkbox", checked: sel.autoScrim, onChange: onToggleAutoScrim,
1312
+ }),
1313
+ t.autoScrim,
1314
+ ),
1168
1315
  sel.fps !== null && React.createElement("span", {
1169
1316
  className: "webg-hint webg-fps" + (sel.fps < LOW_FPS ? " webg-fps--low" : ""),
1170
1317
  title: t.fpsTitle,
@@ -1183,6 +1330,7 @@ window.__ModuleLoader__.load({
1183
1330
  : t.statusUsable(usableList.length)) +
1184
1331
  (sel.rotationEnabled ? " · " + t.autoRotating : "") +
1185
1332
  (autoPaused ? " · " + t.autoPaused : "") +
1333
+ (sel.id && demotedToPreview.has(sel.id) ? " · " + t.decodeFallback : "") +
1186
1334
  (sel.inventory.installDir ? " · " + sel.inventory.installDir : "")),
1187
1335
  ),
1188
1336
  );
@@ -1200,21 +1348,35 @@ window.__ModuleLoader__.load({
1200
1348
  }
1201
1349
  .webg-layer--enter { opacity: 0; }
1202
1350
  .webg-layer--leave { opacity: 0; }
1203
- /* Overscan: the media is LARGER than the layer by two blur radii on every
1351
+ /* Overscan: the media is LARGER than the layer by one blur radius on every
1204
1352
  side, so the transparent fringe CSS blur produces is clipped by the
1205
1353
  layer's overflow:hidden instead of compensated with a content-warping
1206
- scale. A slight saturate keeps blurred colours from washing out. */
1354
+ scale. 1× is the fringe a Gaussian kernel actually needs; more would
1355
+ double GPU fill and mis-anchor non-center object-positions. A slight
1356
+ saturate keeps blurred colours from washing out. */
1207
1357
  .webg-layer .webg-media {
1208
1358
  position: absolute;
1209
- inset: calc(-2 * var(--webg-wallpaper-blur, 0px));
1210
- width: calc(100% + 4 * var(--webg-wallpaper-blur, 0px));
1211
- height: calc(100% + 4 * var(--webg-wallpaper-blur, 0px));
1359
+ inset: calc(-1 * var(--webg-wallpaper-blur, 0px));
1360
+ width: calc(100% + 2 * var(--webg-wallpaper-blur, 0px));
1361
+ height: calc(100% + 2 * var(--webg-wallpaper-blur, 0px));
1212
1362
  max-width: none; max-height: none;
1213
1363
  object-fit: var(--webg-fit, cover);
1214
1364
  object-position: var(--webg-position, center);
1215
1365
  display: block;
1216
1366
  background: transparent; border: 0;
1217
- filter: blur(var(--webg-wallpaper-blur, 0px)) saturate(1.06);
1367
+ filter: blur(var(--webg-wallpaper-blur, 0px)) saturate(1.08);
1368
+ }
1369
+
1370
+ /* Gaussian blur alone shows banding on smooth gradients; a whisper of
1371
+ fractal noise over the layer dithers it away (iOS does the same inside
1372
+ its frosted materials). Opacity rides the blur radius — zero blur, zero
1373
+ noise. */
1374
+ .webg-layer::after {
1375
+ content: ""; position: absolute; inset: 0; pointer-events: none;
1376
+ opacity: var(--webg-noise-opacity, 0);
1377
+ background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" stitchTiles="stitch"/></filter><rect width="120" height="120" filter="url(%23n)"/></svg>');
1378
+ background-size: 120px 120px;
1379
+ mix-blend-mode: overlay;
1218
1380
  }
1219
1381
 
1220
1382
  /* Scrim: above the wallpaper (-1 > -2), below the UI. */
@@ -1314,13 +1476,33 @@ window.__ModuleLoader__.load({
1314
1476
  }
1315
1477
 
1316
1478
  .webg-select, .webg-text {
1317
- padding: 4px 8px;
1318
- border-radius: 6px;
1479
+ padding: 5px 10px;
1480
+ border-radius: 8px;
1319
1481
  border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35));
1320
1482
  background: var(--dsw-alias-bg-layer-1, rgba(128, 128, 128, 0.12));
1321
1483
  color: var(--dsw-alias-label-primary, inherit);
1322
1484
  font-size: 13px;
1485
+ transition: border-color 120ms ease, background-color 120ms ease;
1323
1486
  }
1487
+ /* Custom select: drop the OS chrome, draw our own chevron (mid-gray reads
1488
+ on both light and dark themes), roomier hit area, shell-style focus. */
1489
+ .webg-select {
1490
+ -webkit-appearance: none;
1491
+ appearance: none;
1492
+ padding-right: 28px;
1493
+ cursor: pointer;
1494
+ background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="6" viewBox="0 0 10 6"><path d="M1 1l4 4 4-4" fill="none" stroke="%23888" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>');
1495
+ background-repeat: no-repeat;
1496
+ background-position: right 10px center;
1497
+ }
1498
+ .webg-select:hover:not(:disabled), .webg-text:hover:not(:disabled) {
1499
+ border-color: var(--dsw-alias-border-l1, rgba(128, 128, 128, 0.5));
1500
+ }
1501
+ .webg-select:focus-visible, .webg-text:focus-visible {
1502
+ outline: 2px solid var(--dsw-alias-brand-primary, rgb(15, 17, 21));
1503
+ outline-offset: 1px;
1504
+ }
1505
+ .webg-select:disabled { opacity: 0.45; cursor: not-allowed; }
1324
1506
  .webg-text { flex: 1; min-width: 0; }
1325
1507
  .webg-search { max-width: 320px; }
1326
1508
  .webg-lang { display: inline-flex; align-items: center; gap: 6px; margin-left: auto; }
@@ -1332,7 +1514,33 @@ window.__ModuleLoader__.load({
1332
1514
  font-size: 13px; cursor: pointer;
1333
1515
  }
1334
1516
 
1335
- .webg-slider { width: 100%; accent-color: var(--dsw-alias-brand-primary, rgb(15, 17, 21)); }
1517
+ /* Custom range slider (Chromium): slim filled track + ringed thumb instead
1518
+ of the default chunky control. --webg-p (0–100) drives the fill. */
1519
+ .webg-slider {
1520
+ -webkit-appearance: none;
1521
+ appearance: none;
1522
+ width: 100%; height: 22px; margin: 0;
1523
+ background: transparent; cursor: pointer;
1524
+ }
1525
+ .webg-slider::-webkit-slider-runnable-track {
1526
+ height: 4px; border-radius: 2px;
1527
+ background: linear-gradient(to right,
1528
+ var(--dsw-alias-brand-primary, rgb(15, 17, 21)) 0%,
1529
+ var(--dsw-alias-brand-primary, rgb(15, 17, 21)) calc(var(--webg-p, 0) * 1%),
1530
+ var(--dsw-alias-border-l2, rgba(128, 128, 128, 0.35)) calc(var(--webg-p, 0) * 1%));
1531
+ }
1532
+ .webg-slider::-webkit-slider-thumb {
1533
+ -webkit-appearance: none;
1534
+ width: 14px; height: 14px; border-radius: 50%;
1535
+ margin-top: -5px; /* centers the 14px thumb on the 4px track */
1536
+ background: var(--dsw-alias-brand-primary-invert, rgb(249, 250, 251));
1537
+ border: 2.5px solid var(--dsw-alias-brand-primary, rgb(15, 17, 21));
1538
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28);
1539
+ transition: transform 120ms ease;
1540
+ }
1541
+ .webg-slider:hover::-webkit-slider-thumb,
1542
+ .webg-slider:focus-visible::-webkit-slider-thumb { transform: scale(1.18); }
1543
+ .webg-slider:focus-visible { outline: none; }
1336
1544
  /* Effects grid: one shared grid across all slider rows, so every slider
1337
1545
  starts and ends at the same x no matter how wide its label is. */
1338
1546
  .webg-effects {
@@ -1474,10 +1682,37 @@ window.__ModuleLoader__.load({
1474
1682
  const unsubEffects = subscribe(applyEffects);
1475
1683
  syncLayers();
1476
1684
  applyEffects();
1685
+ // Luminance sampler: re-reads the wallpaper periodically so a bright
1686
+ // scene arriving mid-video re-raises the veil. (rAF-free: 2.5 s is
1687
+ // plenty for legibility, and an idle timer costs ~nothing.)
1688
+ let samplerId = null;
1689
+ if (typeof window !== "undefined" && typeof window.setInterval === "function") {
1690
+ samplerId = window.setInterval(() => {
1691
+ sampleWallpaperLuminance();
1692
+ applyEffects();
1693
+ }, 2500);
1694
+ }
1695
+ // A theme switch flips the veil colour immediately (black ↔ white).
1696
+ let observer = null;
1697
+ if (typeof MutationObserver === "function") {
1698
+ observer = new MutationObserver(() => applyEffects());
1699
+ observer.observe(document.body, { attributes: true, attributeFilter: ["data-ds-dark-theme"] });
1700
+ }
1477
1701
  return () => {
1478
1702
  unsubLayers();
1479
1703
  unsubEffects();
1704
+ if (samplerId !== null) window.clearInterval(samplerId);
1705
+ if (observer) observer.disconnect();
1706
+ sampledLuminance = null;
1480
1707
  clearRotationTimer();
1708
+ // Cancel pending crossfade/clear timers so they never fire on
1709
+ // torn-down nodes after dispose.
1710
+ for (const token of [...pendingTimers]) {
1711
+ if (typeof window !== "undefined" && typeof window.clearTimeout === "function") {
1712
+ window.clearTimeout(token);
1713
+ }
1714
+ }
1715
+ pendingTimers.clear();
1481
1716
  // Remove EVERY layer instance (a crossfade-leaving layer has no id).
1482
1717
  const doomed = document.querySelectorAll(".webg-layer");
1483
1718
  for (const node of [...doomed]) node.remove();
@@ -1511,8 +1746,9 @@ window.__ModuleLoader__.load({
1511
1746
  let battery = null;
1512
1747
  let cancelled = false;
1513
1748
  const update = () => {
1514
- if (!battery || !selection.pauseOnBattery) return;
1515
- setAutoPause("battery", !battery.charging && battery.level <= BATTERY_SAVER_LEVEL);
1749
+ if (!battery) return;
1750
+ batteryState = { charging: battery.charging, level: battery.level };
1751
+ applyBatteryPause();
1516
1752
  };
1517
1753
  navigator.getBattery().then((b) => {
1518
1754
  if (cancelled) return;
@@ -1527,6 +1763,7 @@ window.__ModuleLoader__.load({
1527
1763
  battery.removeEventListener("chargingchange", update);
1528
1764
  battery.removeEventListener("levelchange", update);
1529
1765
  }
1766
+ batteryState = null;
1530
1767
  setAutoPause("battery", false);
1531
1768
  };
1532
1769
  });