dsh-plugin-notify-sounds 1.6.5 → 1.6.8

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
@@ -70,6 +70,33 @@ window.__ModuleLoader__.load({
70
70
  ".dsh-notify-sounds-quietDash{color:var(--dsw-alias-label-tertiary)}",
71
71
  ".dsh-notify-sounds-hint{color:var(--dsw-alias-label-tertiary);margin-top:4px;font-size:12px;line-height:18px}",
72
72
  ].join("");
73
+
74
+ /**
75
+ * Whether this page is running inside the Desktop shell rather than a
76
+ * real browser.
77
+ *
78
+ * Derived from what the page can see: the shell serves the same bundle
79
+ * over its own `dsh-app:` protocol and ships an Electron user agent,
80
+ * while a browser serves it over http(s) with no Electron token. Either
81
+ * signal alone would do; using both keeps one of them changing its mind
82
+ * from flipping the answer.
83
+ */
84
+ const isDesktopShell = () => {
85
+ if (typeof location !== "undefined" && location.protocol === "dsh-app:") return true;
86
+ return typeof navigator !== "undefined" && /Electron/i.test(navigator.userAgent ?? "");
87
+ };
88
+
89
+ /**
90
+ * Whether the page is on macOS.
91
+ *
92
+ * Note this is a pure OS check: it matches macOS browsers too. The
93
+ * places that need "macOS DESKTOP SHELL" combine this with
94
+ * `isDesktopShell()`, because the window chrome that breaks the title
95
+ * alert (`titleBarStyle: "hiddenInset"`) is the shell's own choice —
96
+ * a macOS browser tab shows the document title like any other.
97
+ */
98
+ const isMac = () => typeof navigator !== "undefined" && /Mac/i.test(`${navigator.platform ?? ""} ${navigator.userAgent ?? ""}`);
99
+
73
100
  /** Stylesheet class names, one projection the components read. */
74
101
  const css = {
75
102
  section: "dsh-notify-sounds-section",
@@ -151,10 +178,12 @@ window.__ModuleLoader__.load({
151
178
  "systemNotify.unsupported": "当前环境不支持系统通知",
152
179
  "titleFlash.title": "窗口标题提醒",
153
180
  "titleFlash.description": "页面处于后台时,在窗口标题前加文字标记(待处理 / 已完成),便于在任务栏察觉;回到页面后自动清除",
181
+ "titleFlash.macUnsupported": "macOS 暂不支持:标题栏隐藏、Dock 也没有窗口标题,标记无处显示(只在「窗口」菜单 / Mission Control 里可见)",
154
182
  "titleFlash.pending": "待处理",
155
183
  "titleFlash.done": "已完成",
156
184
  "renotify.title": "未处理决策重复提醒",
157
185
  "renotify.description": "有决策一直未处理时,每隔设定时间重新提醒;只要你已经看过它一眼(它出现时你正在窗口,或之后回到过窗口),就不再重复提醒,即使再切走也一样",
186
+ "renotify.browserHint": "浏览器的后台标签页会节流计时器,重复间隔可能被拉长(1 分钟档尤其明显)",
158
187
  "renotify.interval": "重复间隔",
159
188
  "renotify.minutes": "{n} 分钟",
160
189
  "quiet.title": "静音时段",
@@ -228,10 +257,12 @@ window.__ModuleLoader__.load({
228
257
  "systemNotify.unsupported": "System notifications are unavailable in this environment",
229
258
  "titleFlash.title": "Window title alert",
230
259
  "titleFlash.description": "While the page is hidden, mark the window title as waiting for you or finished, so the taskbar shows it; cleared when you return",
260
+ "titleFlash.macUnsupported": "Not supported on macOS: the title bar is hidden and the Dock shows no window title, so the mark has nowhere to appear (only the Window menu and Mission Control would show it)",
231
261
  "titleFlash.pending": "Waiting for you",
232
262
  "titleFlash.done": "Finished",
233
263
  "renotify.title": "Repeat unanswered decisions",
234
264
  "renotify.description": "Re-ping decisions that stay unanswered at a fixed interval; once you have seen one — it appeared while you were at the window, or you came back to it — it never repeats, even if you leave again",
265
+ "renotify.browserHint": "Background browser tabs throttle timers, so the interval can stretch (most visible at the 1-minute setting)",
235
266
  "renotify.interval": "Interval",
236
267
  "renotify.minutes": "{n} min",
237
268
  "quiet.title": "Quiet hours",
@@ -416,9 +447,15 @@ window.__ModuleLoader__.load({
416
447
  function normalizedGain(buffer) {
417
448
  try {
418
449
  let peak = 0;
450
+ // Sample every 8th frame: a peak that would clip is still
451
+ // caught by any reasonably strided scan (audio waveforms are
452
+ // smooth enough that adjacent samples carry the same
453
+ // magnitude), and this keeps a 30-second upload off the main
454
+ // thread's critical path.
455
+ const stride = 8;
419
456
  for (let channel = 0; channel < buffer.numberOfChannels; channel++) {
420
457
  const data = buffer.getChannelData(channel);
421
- for (let i = 0; i < data.length; i++) {
458
+ for (let i = 0; i < data.length; i += stride) {
422
459
  const magnitude = Math.abs(data[i]);
423
460
  if (magnitude > peak) peak = magnitude;
424
461
  }
@@ -456,12 +493,38 @@ window.__ModuleLoader__.load({
456
493
  }
457
494
 
458
495
  /**
459
- * Play one preset at a loudness in percent.
496
+ * Run `play` on an AudioContext that is actually running.
460
497
  *
461
- * The context is resumed when the browser suspended it; a page that has
462
- * not yet seen a user gesture stays silent (the browser's autoplay
463
- * policy, not an error). Peaks stay far below clipping even with every
464
- * preset voice summed.
498
+ * A suspended context has a FROZEN `currentTime`: scheduling sources on
499
+ * it parks them on the timeline and they all fire together whenever the
500
+ * context later resumes — the opposite of an alert. So nothing is ever
501
+ * scheduled while suspended. Where resuming needs a user gesture (every
502
+ * browser; the Electron shell needs none) this attempt is simply
503
+ * dropped, and the unlock effect has the context running for the next
504
+ * one rather than banking a stale sound for later.
505
+ *
506
+ * @param engine - the page's AudioContext.
507
+ * @param play - schedules the sound.
508
+ */
509
+ function whenRunning(engine, play) {
510
+ if (engine.state === "running") {
511
+ play();
512
+ return;
513
+ }
514
+ Promise.resolve(engine.resume())
515
+ .then(() => {
516
+ if (engine.state === "running") play();
517
+ })
518
+ .catch(() => {});
519
+ }
520
+
521
+ /**
522
+ * Play one preset (or the uploaded sound) at a loudness in percent.
523
+ *
524
+ * Nothing is scheduled while the AudioContext cannot play — see
525
+ * `whenRunning`, which defers to the first real user gesture on
526
+ * browsers. Peaks stay far below clipping even with every preset voice
527
+ * summed.
465
528
  *
466
529
  * @param id - preset id; `none`, unknown ids, and `custom` without an
467
530
  * upload are silent no-ops (the caller falls back first).
@@ -472,43 +535,51 @@ window.__ModuleLoader__.load({
472
535
  */
473
536
  function playSound(id, volumePercent, customBase64) {
474
537
  if (id === "none" || id === undefined) return;
538
+ const engine = audioEngine();
539
+ if (engine === undefined) return;
475
540
  if (id === "custom") {
476
- const engine = audioEngine();
477
- if (engine === undefined) return;
478
- if (engine.state === "suspended") engine.resume().catch(() => {});
479
- playCustomSound(engine, customBase64, volumePercent);
541
+ whenRunning(engine, () => playCustomSound(engine, customBase64, volumePercent));
480
542
  return;
481
543
  }
482
544
  const preset = SOUND_PRESETS[id];
483
545
  if (preset === undefined) return;
484
- const engine = audioEngine();
485
- if (engine === undefined) return;
486
- if (engine.state === "suspended") engine.resume().catch(() => {});
487
546
  const percent = Number.isFinite(volumePercent) ? volumePercent : DEFAULT_VOLUME;
488
547
  const loudness = Math.max(0, Math.min(100, percent)) / 100;
489
548
  if (loudness === 0) return;
490
- const master = engine.createGain();
491
- master.gain.value = loudness * 0.6;
492
- master.connect(engine.destination);
493
- const t0 = engine.currentTime + 0.02;
494
- for (const note of preset.notes) {
495
- const osc = engine.createOscillator();
496
- const gain = engine.createGain();
497
- osc.type = note.type ?? "sine";
498
- const start = t0 + note.at;
499
- osc.frequency.setValueAtTime(note.freq, start);
500
- if (note.glideTo !== undefined) {
501
- osc.frequency.exponentialRampToValueAtTime(note.glideTo, start + note.dur * 0.85);
549
+ whenRunning(engine, () => {
550
+ const master = engine.createGain();
551
+ master.gain.value = loudness * 0.6;
552
+ master.connect(engine.destination);
553
+ const t0 = engine.currentTime + 0.02;
554
+ let tail = 0;
555
+ for (const note of preset.notes) {
556
+ const osc = engine.createOscillator();
557
+ const gain = engine.createGain();
558
+ osc.type = note.type ?? "sine";
559
+ const start = t0 + note.at;
560
+ osc.frequency.setValueAtTime(note.freq, start);
561
+ if (note.glideTo !== undefined) {
562
+ osc.frequency.exponentialRampToValueAtTime(note.glideTo, start + note.dur * 0.85);
563
+ }
564
+ const peak = Math.max(0.0002, note.gain * 0.25);
565
+ gain.gain.setValueAtTime(0.0001, start);
566
+ gain.gain.exponentialRampToValueAtTime(peak, start + 0.012);
567
+ gain.gain.exponentialRampToValueAtTime(0.0001, start + note.dur);
568
+ osc.connect(gain);
569
+ gain.connect(master);
570
+ osc.start(start);
571
+ osc.stop(start + note.dur + 0.05);
572
+ tail = Math.max(tail, note.at + note.dur);
502
573
  }
503
- const peak = Math.max(0.0002, note.gain * 0.25);
504
- gain.gain.setValueAtTime(0.0001, start);
505
- gain.gain.exponentialRampToValueAtTime(peak, start + 0.012);
506
- gain.gain.exponentialRampToValueAtTime(0.0001, start + note.dur);
507
- osc.connect(gain);
508
- gain.connect(master);
509
- osc.start(start);
510
- osc.stop(start + note.dur + 0.05);
511
- }
574
+ // Release the master gain once the tail has faded. Without
575
+ // this, every play leaves a gain node permanently wired to
576
+ // the destination — a slow audio-node leak on long sessions.
577
+ setTimeout(() => {
578
+ try {
579
+ master.disconnect();
580
+ } catch {}
581
+ }, Math.max(0, (t0 + tail + 0.1 - engine.currentTime) * 1000 + 50));
582
+ });
512
583
  }
513
584
 
514
585
  /**
@@ -747,13 +818,17 @@ window.__ModuleLoader__.load({
747
818
  const UPDATE_APPLY_PATH = "/notify-sounds/update/apply";
748
819
 
749
820
  /**
750
- * Show one OS notification; every failure mode is silent. The tag
751
- * collapses repeats per trigger kind, and `silent` keeps the OS from
752
- * layering its own sound on top of ours.
821
+ * Show one OS notification; every failure mode is silent.
822
+ *
823
+ * `tag` is our OWN collapse key — we close the previous notification
824
+ * ourselves rather than handing the tag to the platform, because a
825
+ * superseded toast can swallow clicks aimed at it. `silent` keeps the
826
+ * OS from layering its own sound on top of ours.
753
827
  *
754
828
  * @param title - notification title.
755
829
  * @param body - notification body.
756
- * @param tag - collapse tag.
830
+ * @param tag - our internal key for closing the previous generation;
831
+ * deliberately NOT passed to the platform (see the comment inside).
757
832
  */
758
833
  function showSystemNotification(title, body, tag) {
759
834
  try {
@@ -806,9 +881,13 @@ window.__ModuleLoader__.load({
806
881
  };
807
882
  } catch (error) {
808
883
  // `new Notification` is what can throw here, when the
809
- // environment refuses to construct one. Worth one line: the
810
- // Settings row reads `Notification.permission`, which still
811
- // says "granted", so nothing else would explain the silence.
884
+ // environment refuses to construct one — nearly every mobile
885
+ // browser does exactly that, while still reporting a
886
+ // "granted" permission. So this is not just a log line: it is
887
+ // recorded and shown, because the Settings row otherwise reads
888
+ // `Notification.permission` and proudly says "granted" for a
889
+ // switch that can never do anything.
890
+ notificationConstructionFailed = true;
812
891
  if (!warnedNotificationFailure) {
813
892
  warnedNotificationFailure = true;
814
893
  console.warn("notify-sounds: could not show a system notification", error);
@@ -819,6 +898,15 @@ window.__ModuleLoader__.load({
819
898
  /** Whether the one-time notification-construction warning has been logged. */
820
899
  let warnedNotificationFailure = false;
821
900
 
901
+ /**
902
+ * Whether this environment refused to construct a Notification.
903
+ *
904
+ * Set by the first real attempt, read by the settings row: permission
905
+ * being "granted" proves nothing about whether a toast can actually be
906
+ * built (MDN: the constructor throws in nearly all mobile browsers).
907
+ */
908
+ let notificationConstructionFailed = false;
909
+
822
910
  /**
823
911
  * Human-readable summary of one pending interaction, for notification
824
912
  * bodies: the approval's localized reason first, then its raw reason,
@@ -876,14 +964,22 @@ window.__ModuleLoader__.load({
876
964
  * @param props - copy.
877
965
  * @returns the notice element, or null when there is nothing to say.
878
966
  */
879
- function UpdateNotice({ t }) {
967
+ function UpdateNotice({ t, autoCheck }) {
880
968
  const [applied, setApplied] = useState(undefined);
969
+ // Explicit `=== true`, not `!== false`: during the first frame
970
+ // `useConfig` may still be loading, and `undefined` must NOT be
971
+ // treated as "allowed" — that is the one case where a request
972
+ // could slip past a user who has the switch off. The dep array is
973
+ // `[autoCheck]` rather than `[]` so the effect picks up the real
974
+ // value when it arrives, instead of only ever reading the first
975
+ // render's prop.
881
976
  useEffect(() => {
977
+ if (autoCheck !== true) return;
882
978
  fetch(UPDATE_CHECK_PATH, { method: "GET" })
883
979
  .then((response) => response.json())
884
980
  .then((payload) => setApplied(payload?.applied))
885
981
  .catch(() => {});
886
- }, []);
982
+ }, [autoCheck]);
887
983
  if (applied === undefined || typeof applied.to !== "string") return null;
888
984
  const version = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
889
985
  return h("div", { className: css.hint, key: "text" }, `${t("update.done")}:${version(applied.from)} → ${version(applied.to)},${t("update.restart")}`);
@@ -937,7 +1033,7 @@ window.__ModuleLoader__.load({
937
1033
  return h("div", { className: css.section }, [
938
1034
  h("h2", { className: css.title, key: "title" }, t("title")),
939
1035
  h("p", { className: css.intro, key: "intro" }, t("intro")),
940
- h(UpdateNotice, { key: "update-notice", t }),
1036
+ h(UpdateNotice, { key: "update-notice", t, autoCheck: value === undefined ? undefined : value.updateAutoCheck !== false }),
941
1037
  h("div", { className: css.rows, key: "rows" }, [
942
1038
  h(SwitchRow, {
943
1039
  key: "enabled",
@@ -1068,7 +1164,13 @@ window.__ModuleLoader__.load({
1068
1164
  key: "titleFlash",
1069
1165
  t,
1070
1166
  title: t("titleFlash.title"),
1071
- description: t("titleFlash.description"),
1167
+ // macOS DESKTOP shell only: its title bar is hiddenInset
1168
+ // and the Dock shows no per-window title, so the mark
1169
+ // has nowhere to appear. A macOS BROWSER tab shows the
1170
+ // document title like any other, so this is not
1171
+ // applied there. iOS Safari's UA also contains "like
1172
+ // Mac OS X" — `isDesktopShell` keeps it out.
1173
+ description: isMac() && isDesktopShell() ? t("titleFlash.macUnsupported") : t("titleFlash.description"),
1072
1174
  label: t("titleFlash.title"),
1073
1175
  checked: value?.titleFlash === true,
1074
1176
  disabled,
@@ -1122,6 +1224,7 @@ window.__ModuleLoader__.load({
1122
1224
  h(UpdateRow, {
1123
1225
  key: "update",
1124
1226
  t,
1227
+ autoCheck: value === undefined ? undefined : value.updateAutoCheck !== false,
1125
1228
  }),
1126
1229
  ]),
1127
1230
  ]);
@@ -1433,7 +1536,11 @@ window.__ModuleLoader__.load({
1433
1536
  */
1434
1537
  function SystemNotifyRow({ t, checked, disabled, failed, onChange }) {
1435
1538
  const [busy, setBusy] = useState(false);
1436
- const supported = typeof Notification !== "undefined";
1539
+ // `typeof Notification` alone is not enough: nearly every mobile
1540
+ // browser exposes the API and happily reports "granted", yet the
1541
+ // constructor throws every time. Once a real attempt has failed,
1542
+ // this environment is treated as unsupported and the row says so.
1543
+ const supported = typeof Notification !== "undefined" && !notificationConstructionFailed;
1437
1544
  const permission = supported ? Notification.permission : "unsupported";
1438
1545
  const hintKey = !supported ? "systemNotify.unsupported"
1439
1546
  : permission === "denied" ? "systemNotify.denied"
@@ -1486,6 +1593,10 @@ window.__ModuleLoader__.load({
1486
1593
  h("div", { key: "text" }, [
1487
1594
  h("div", { className: css.rowTitle, key: "title" }, t("renotify.title")),
1488
1595
  h("div", { className: css.rowDescription, key: "description" }, t("renotify.description")),
1596
+ // Only where it is true: the Desktop shell runs this timer
1597
+ // in a real window that is never throttled, so the caveat
1598
+ // would only confuse its users.
1599
+ isDesktopShell() ? null : h("div", { className: css.hint, key: "hint" }, t("renotify.browserHint")),
1489
1600
  failed ? h("div", { className: css.error, role: "alert", key: "error" }, t("saveFailed")) : null,
1490
1601
  ]),
1491
1602
  h("div", { className: css.stackControl, key: "control" }, [
@@ -1586,7 +1697,7 @@ window.__ModuleLoader__.load({
1586
1697
  * @param props - copy.
1587
1698
  * @returns the row element tree.
1588
1699
  */
1589
- function UpdateRow({ t }) {
1700
+ function UpdateRow({ t, autoCheck }) {
1590
1701
  const [state, setState] = useState({ phase: "idle" });
1591
1702
  const check = (force) => async () => {
1592
1703
  setState({ phase: "checking" });
@@ -1609,9 +1720,13 @@ window.__ModuleLoader__.load({
1609
1720
  }
1610
1721
  };
1611
1722
  // A cached answer makes this free; only a cold Host reaches out.
1723
+ // Same guard as the notice: `=== true` (not `!== false`), because
1724
+ // during the loading frame `undefined` must not be treated as
1725
+ // permission to reach out. The button below works regardless.
1612
1726
  useEffect(() => {
1727
+ if (autoCheck !== true) return;
1613
1728
  check(false)();
1614
- }, []);
1729
+ }, [autoCheck]);
1615
1730
  const payload = state.payload;
1616
1731
  const version = (text) => (typeof text === "string" && text !== "" ? `v${text}` : "?");
1617
1732
  // The Host remembers the last install and repeats it with every
@@ -1707,7 +1822,11 @@ window.__ModuleLoader__.load({
1707
1822
  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1708
1823
  svg.setAttribute("width", "16");
1709
1824
  svg.setAttribute("height", "16");
1710
- svg.setAttribute("viewBox", "0 0 16 16");
1825
+ // 17×17, matching the artwork's own coordinate system
1826
+ // (`IconAlarmClockOutlineArtwork`); the gear it replaces is 16×16,
1827
+ // and reusing ITS viewport here would scale the alarm paths up by
1828
+ // 17/16 and make the glyph visibly larger than its neighbours.
1829
+ svg.setAttribute("viewBox", "0 0 17 17");
1711
1830
  svg.setAttribute("fill", "none");
1712
1831
  svg.setAttribute("stroke-width", "1.3");
1713
1832
  svg.setAttribute("aria-hidden", "true");
@@ -1742,7 +1861,18 @@ window.__ModuleLoader__.load({
1742
1861
  const shippedClass = icon.getAttribute("class");
1743
1862
  if (shippedClass !== null && shippedClass !== "") replacement.setAttribute("class", shippedClass);
1744
1863
  replacement.setAttribute(NAV_ICON_MARKER, "");
1745
- cell.replaceChild(replacement, icon);
1864
+ // Keep the original so the disposer can restore it. `replaceChild`
1865
+ // detaches a node React still holds in its fiber tree; if React
1866
+ // ever re-renders that slot it throws NotFoundError trying to
1867
+ // remove the detached node. Restoring on dispose prevents that.
1868
+ replacement._originalIcon = icon;
1869
+ try {
1870
+ cell.replaceChild(replacement, icon);
1871
+ } catch {
1872
+ // A React re-render raced the swap; the next observer pass
1873
+ // will try again on the fresh node.
1874
+ return false;
1875
+ }
1746
1876
  return true;
1747
1877
  }
1748
1878
 
@@ -1791,6 +1921,18 @@ window.__ModuleLoader__.load({
1791
1921
  return () => {
1792
1922
  detachPanel();
1793
1923
  bodyObserver.disconnect();
1924
+ // Restore every swapped glyph so React never finds a detached
1925
+ // node in its fiber tree (see swapNavIcon).
1926
+ if (typeof document !== "undefined") {
1927
+ for (const el of document.querySelectorAll(`[${NAV_ICON_MARKER}]`)) {
1928
+ const original = el._originalIcon;
1929
+ if (original !== undefined && el.parentNode !== null) {
1930
+ try {
1931
+ el.parentNode.replaceChild(original, el);
1932
+ } catch {}
1933
+ }
1934
+ }
1935
+ }
1794
1936
  };
1795
1937
  }
1796
1938
  //#endregion
@@ -1863,13 +2005,22 @@ window.__ModuleLoader__.load({
1863
2005
  *
1864
2006
  * @returns the `byId` row map, or undefined when unavailable.
1865
2007
  */
2008
+ // Cache one snapshot per synchronous block: `isNotifiable` is
2009
+ // called per session in read()/trackPending loops, and each call
2010
+ // would otherwise take a fresh snapshot (O8).
2011
+ let cachedRows;
2012
+ let cachedRowsAt = 0;
1866
2013
  const sessionRows = () => {
2014
+ const now = Date.now();
2015
+ if (cachedRows !== undefined && now === cachedRowsAt) return cachedRows;
1867
2016
  try {
1868
- return ctx.sessions?.list?.getSnapshot?.()?.byId
2017
+ cachedRows = ctx.sessions?.list?.getSnapshot?.()?.byId
1869
2018
  ?? ctx.uiSession?.sessions?.list?.getSnapshot?.()?.byId;
1870
2019
  } catch {
1871
- return undefined;
2020
+ cachedRows = undefined;
1872
2021
  }
2022
+ cachedRowsAt = now;
2023
+ return cachedRows;
1873
2024
  };
1874
2025
 
1875
2026
  /**
@@ -1942,7 +2093,10 @@ window.__ModuleLoader__.load({
1942
2093
  // triggers are edge-fired that alert is lost rather than
1943
2094
  // delayed.
1944
2095
  if (!reminder) {
1945
- if (cooldown.size >= 96) cooldown.clear();
2096
+ // Evict the oldest entry rather than clearing the whole
2097
+ // table: one session hitting the cap must not silence
2098
+ // every other session's cooldown.
2099
+ if (cooldown.size >= 96) cooldown.delete(cooldown.keys().next().value);
1946
2100
  cooldown.set(`${kind}:${sessionId}`, now);
1947
2101
  }
1948
2102
  const fallback = kind === "completion" ? "ding" : kind === "question" ? "chime" : "bell";
@@ -2183,6 +2337,28 @@ window.__ModuleLoader__.load({
2183
2337
  };
2184
2338
  }, "notify-sounds: session status watcher");
2185
2339
 
2340
+ // Browsers gate Web Audio behind a user gesture: until one happens
2341
+ // the context starts suspended and `resume()` simply stays pending,
2342
+ // so a trigger firing in the meantime would be lost — and worse,
2343
+ // anything scheduled on the frozen timeline would all fire at once
2344
+ // whenever some later gesture unlocked it. So the FIRST real
2345
+ // gesture starts the context and keeps it running, and nothing is
2346
+ // scheduled while suspended (see `whenRunning`). The Desktop shell
2347
+ // needs none of this — Electron starts audio without a gesture,
2348
+ // which is exactly why the problem is invisible on the Desktop.
2349
+ ctx.effect(() => {
2350
+ if (typeof document === "undefined") return () => {};
2351
+ const unlock = () => {
2352
+ const engine = audioEngine();
2353
+ if (engine?.state === "suspended") engine.resume().catch(() => {});
2354
+ };
2355
+ const events = ["pointerdown", "keydown", "touchstart"];
2356
+ for (const type of events) document.addEventListener(type, unlock, { capture: true, passive: true });
2357
+ return () => {
2358
+ for (const type of events) document.removeEventListener(type, unlock, { capture: true });
2359
+ };
2360
+ }, "notify-sounds: audio unlock");
2361
+
2186
2362
  // Quiet update check: shortly after the page settles, then at most
2187
2363
  // once a day, and never while the user has it turned off. It asks
2188
2364
  // the Host's own route — one small query, nothing downloaded — and