@olares/lares-core 0.15.29 → 0.15.31

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/i18n/voice.js CHANGED
@@ -4,6 +4,7 @@ export const ZH = {
4
4
  "tts.default": "模型默认",
5
5
  "tts.loading": "正在加载音色…",
6
6
  "tts.loadFailed": "音色加载失败,请重试",
7
+ "tts.stale": "这次没能刷新音色,显示的是上次成功加载的列表。",
7
8
  "tts.noPresets": "此模型不提供预设音色,使用模型默认声音。",
8
9
  "tts.preview": "试听",
9
10
  "tts.stopPreview": "停止试听",
@@ -314,6 +315,7 @@ export const EN = {
314
315
  "tts.default": "Model default",
315
316
  "tts.loading": "Loading voices…",
316
317
  "tts.loadFailed": "Could not load voices. Try again.",
318
+ "tts.stale": "Could not refresh voices; showing the last list that loaded.",
317
319
  "tts.noPresets": "This model has no preset voices. Its default voice will be used.",
318
320
  "tts.preview": "Preview voice",
319
321
  "tts.stopPreview": "Stop preview",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-core",
3
- "version": "0.15.29",
3
+ "version": "0.15.31",
4
4
  "description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
5
5
  "type": "module",
6
6
  "repository": {
@@ -115,6 +115,7 @@
115
115
  "./voice/stream-client": "./voice/stream-client.js",
116
116
  "./voice/pcm-player": "./voice/pcm-player.js",
117
117
  "./voice/pcm-worklet": "./voice/pcm-worklet.js",
118
+ "./voice/wake-lock": "./voice/wake-lock.js",
118
119
  "./voice/config": "./voice/config.js",
119
120
  "./voice/languages": "./voice/languages.js",
120
121
  "./voice/service": "./voice/service.js",
@@ -20,6 +20,9 @@ const BARGE_IN_LIMIT_MS = 10_000;
20
20
  // AudioWorklet keeps a bounded queue; sending a whole answer at network speed
21
21
  // overflowed it and dropped audio.
22
22
  const PLAY_AHEAD_MS = 2_000;
23
+ // A temporarily interrupted AudioContext (device switch, lock screen) must not
24
+ // end a reply; only a client that stays silent this long is treated as stuck.
25
+ const PLAYOUT_STALL_MS = 30_000;
23
26
  const TICKET_MS = 30_000;
24
27
  // Usage lives in the child ASR/LLM/TTS requests; the OpenAI shape stays parseable (Router ADR-83).
25
28
  const ZERO_USAGE = Object.freeze({ total_tokens: 0, input_tokens: 0, output_tokens: 0, input_token_details: { text_tokens: 0, audio_tokens: 0, cached_tokens: 0 }, output_token_details: { text_tokens: 0, audio_tokens: 0 } });
@@ -179,7 +182,7 @@ export function createLaresRealtimeGateway({ service, config, transcribe, synthe
179
182
  if (current.abort.signal.aborted || current.done || closed || client.readyState !== 1) return;
180
183
  const heard = played.get(current.itemId) ?? 0;
181
184
  if (heard > lastPlayed) { lastPlayed = heard; playedAt = Date.now(); }
182
- if (Date.now() - playedAt > 10_000) throw new Error("voice_audio_backpressure");
185
+ if (Date.now() - playedAt > PLAYOUT_STALL_MS) throw new Error("voice_audio_backpressure");
183
186
  await new Promise((resolve) => setTimeout(resolve, 20));
184
187
  }
185
188
  if (current.abort.signal.aborted || current.done || closed || client.readyState !== 1) return;
@@ -3,6 +3,21 @@ import { pcmSamples } from "./realtime-audio.js";
3
3
 
4
4
  let primed = null;
5
5
  const worklets = new WeakMap();
6
+ let watchingVisibility = false;
7
+
8
+ /** iOS/macOS suspend the page's context on a call, the lock screen or a device switch. */
9
+ function resumeIfInterrupted(context) {
10
+ if (!context || context.state === "closed" || context.state === "running") return;
11
+ void context.resume?.().catch(() => {});
12
+ }
13
+
14
+ function watchVisibility() {
15
+ if (watchingVisibility || typeof document === "undefined" || !document.addEventListener) return;
16
+ watchingVisibility = true;
17
+ document.addEventListener("visibilitychange", () => {
18
+ if (document.visibilityState === "visible") resumeIfInterrupted(primed);
19
+ });
20
+ }
6
21
 
7
22
  function ensureWorklet(ctx) {
8
23
  const pending = worklets.get(ctx);
@@ -35,7 +50,9 @@ export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () =>
35
50
  * gets a fresh context instead of a silent failure.
36
51
  */
37
52
  const openContext = async (shared) => {
53
+ watchVisibility();
38
54
  if (shared) {
55
+ shared.addEventListener?.("statechange", () => resumeIfInterrupted(shared));
39
56
  try {
40
57
  await ensureWorklet(shared);
41
58
  await shared.resume?.();
@@ -49,6 +66,7 @@ export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () =>
49
66
  }
50
67
  const fresh = new Context();
51
68
  owned = true;
69
+ fresh.addEventListener?.("statechange", () => resumeIfInterrupted(fresh));
52
70
  try {
53
71
  await ensureWorklet(fresh);
54
72
  await fresh.resume?.();
@@ -117,6 +135,9 @@ export function createPcmStreamPlayer({ onProgress = () => {}, onMetric = () =>
117
135
  pause() { paused = true; node?.port.postMessage({ type: "pause" }); },
118
136
  resume() { paused = false; node?.port.postMessage({ type: "resume" }); },
119
137
  get paused() { return paused; },
138
+ /** The browser's own view; a phone can report "running" while the OS has muted it. */
139
+ contextState: () => context?.state || "missing",
140
+ sampleRate: () => context?.sampleRate || 0,
120
141
  playedFrames: () => played,
121
142
  stop,
122
143
  };
@@ -39,17 +39,14 @@ export function voiceCallFailure(view) {
39
39
  }
40
40
 
41
41
  /**
42
- * Playback stays visible at the composer until it ends.
43
- * A hidden readout call and a chat reader both use this one strip:
44
- * status plus the control that stops it. The hold button is not that strip.
42
+ * A voice call that is still playing keeps one strip at the composer until it
43
+ * ends: status plus the control that stops it. The hold button is not that
44
+ * strip. A chat readout stays on the assistant row that is being read.
45
45
  */
46
- export function voicePlaybackChrome(view, readout) {
46
+ export function voicePlaybackChrome(view) {
47
47
  const playback = view?.snapshot?.playback;
48
- if (playback && playback !== "idle") {
49
- return { status: voicePanelPresentation(view).status, stop: "call", stopLabel: "panel.close" };
50
- }
51
- if (!readout) return null;
52
- return { status: `readout.${readout.status}`, stop: "readout", stopLabel: readout.status === "error" ? "readout.dismiss" : "panel.close" };
48
+ if (!playback || playback === "idle") return null;
49
+ return { status: voicePanelPresentation(view).status, stop: "call", stopLabel: "panel.close" };
53
50
  }
54
51
 
55
52
  /** Shared inline labels; clients own focus, positioning and the timer tick. */
@@ -30,13 +30,34 @@ function streamMetric(options, event) {
30
30
  });
31
31
  }
32
32
 
33
+ /**
34
+ * What a phone-side failure needs next to the frames: a WebView can keep
35
+ * reporting a running context while the app is hidden or the OS muted it, so
36
+ * the page and platform state travel with the metric.
37
+ */
38
+ function clientFacts() {
39
+ const nav = globalThis.navigator || {};
40
+ return {
41
+ visibility: String(globalThis.document?.visibilityState || "unknown").slice(0, 16),
42
+ hidden: globalThis.document?.hidden === true,
43
+ platform: String(nav.userAgentData?.platform || nav.platform || "").slice(0, 40),
44
+ ua: String(nav.userAgent || "").slice(0, 180),
45
+ };
46
+ }
47
+
33
48
  export async function playAudioStream(options) {
34
49
  const started = Date.now();
35
50
  const connection = await openAudioStream({ ...options, operation: "tts" });
36
51
  if (!connection) throw new Error("voice_stream_failed");
37
52
  const file = createBrowserPlayer();
38
53
  let progressNoted = false, progressBlocked = false;
54
+ // A phone that suspends the WebView stops the worklet without closing the
55
+ // socket, so the host waits for frames that will never come. Report that gap
56
+ // with the page/context state instead of leaving the next test guessing.
57
+ let lastProgressAt = Date.now(), gapReported = false, gapAt = 0, formatNoted = false;
39
58
  const player = createPcmStreamPlayer({ onProgress: (frames) => {
59
+ lastProgressAt = Date.now();
60
+ if (gapReported) { gapReported = false; streamMetric(options, { stage: "playback_gap_end", frames, gapMs: Date.now() - gapAt, ...clientFacts(), context: player.contextState() }); }
40
61
  if (connection.readyState !== 1) {
41
62
  if (!progressBlocked) { progressBlocked = true; streamMetric(options, { stage: "playback_progress_blocked", readyState: connection.readyState, frames }); }
42
63
  return;
@@ -50,6 +71,17 @@ export async function playAudioStream(options) {
50
71
  },
51
72
  onMetric: (event) => { if (event.stage === "playback_started") options.onStatus?.("playing"); streamMetric(options, { ...event, durationMs: Date.now() - started }); } });
52
73
  let chain = Promise.resolve(), complete = false, ending = false, firstReceived = false, ready = false, paused = false, segmentIndex = -1;
74
+ const gapWatch = setInterval(() => {
75
+ if (!ready || paused || complete || gapReported || Date.now() - lastProgressAt < 4000) return;
76
+ gapReported = true;
77
+ gapAt = lastProgressAt;
78
+ streamMetric(options, { stage: "playback_gap", gapMs: Date.now() - lastProgressAt, frames: player.playedFrames(), ...clientFacts(), context: player.contextState(), sampleRate: player.sampleRate() });
79
+ }, 1000);
80
+ gapWatch.unref?.();
81
+ const noteVisibility = () => {
82
+ if (ready && !complete) streamMetric(options, { stage: "playback_visibility", frames: player.playedFrames(), paused, ...clientFacts(), context: player.contextState() });
83
+ };
84
+ if (typeof document !== "undefined" && document.addEventListener) document.addEventListener("visibilitychange", noteVisibility);
53
85
  options.controls?.({
54
86
  get canPause() { return ready; },
55
87
  pause() { if (!ready || paused) return; paused = true; player.pause(); file.pause(); if (connection.readyState === 1) connection.send(JSON.stringify({ type: "pause" })); },
@@ -82,7 +114,10 @@ export async function playAudioStream(options) {
82
114
  segmentIndex = event.index;
83
115
  }
84
116
  else if (event.type === "segment.end") options.onSegment?.(event);
85
- else if (event.type === "format") { await player.start(event); ready = true; if (paused) player.pause(); }
117
+ else if (event.type === "format") {
118
+ await player.start(event); ready = true; lastProgressAt = Date.now(); if (paused) player.pause();
119
+ if (!formatNoted) { formatNoted = true; streamMetric(options, { stage: "playback_client", sampleRate: event.sampleRate, context: player.contextState(), ...clientFacts() }); }
120
+ }
86
121
  else if (event.type === "file") { ready = true; options.onStatus?.("playing"); await file.play(wavBlob(event.audio)); if (connection.readyState === 1) connection.send(JSON.stringify({ type: "file.done" })); }
87
122
  else if (event.type === "error") throw new Error(event.code || "voice_stream_failed");
88
123
  else if (event.type === "done") { await player.finish(); complete = true; resolve(); }
@@ -97,7 +132,11 @@ export async function playAudioStream(options) {
97
132
  if (options.signal?.aborted) cancelled();
98
133
  });
99
134
  return true;
100
- } finally { player.stop(); file.stop(); connection.close(); }
135
+ } finally {
136
+ clearInterval(gapWatch);
137
+ if (typeof document !== "undefined" && document.removeEventListener) document.removeEventListener("visibilitychange", noteVisibility);
138
+ player.stop(); file.stop(); connection.close();
139
+ }
101
140
  }
102
141
 
103
142
  /** A single final ASR result; partials never submit a task or replace the draft. */
@@ -10,7 +10,7 @@ import { routerGatewayUrl, routerAuthHeaders } from "../router/gateway.js";
10
10
  const fault = (code, status = 409) => new VoiceError(code, status, code);
11
11
 
12
12
  /** Single-use HTTP grants bind raw audio sockets to the authenticated Host scope. */
13
- export function createAudioStreamGateway({ config, models, replies, synthesize = synthesizeStream, env = process.env, metric = () => {} }) {
13
+ export function createAudioStreamGateway({ config, models, replies, synthesize = synthesizeStream, env = process.env, metric = () => {}, playbackStallMs = 45_000, pauseHoldMs = 120_000, lifetimeMs = 300_000 }) {
14
14
  const tickets = new Map(), clients = new Set();
15
15
  const synthesis = createSynthesisRuns({ synthesize, metric });
16
16
  const server = new WebSocketServer({ noServer: true, maxPayload: 96 * 1024, perMessageDeflate: false,
@@ -66,7 +66,7 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
66
66
  if (grant.operation === "tts" && code !== "voice_cancelled") metric({ stage: "playback_stop", voiceId: grant.scope.id, code, sentFrames, playedFrames });
67
67
  send({ type: "error", code }); close();
68
68
  };
69
- const lifetime = setTimeout(() => error(fault("voice_timeout")), 180000);
69
+ const lifetime = setTimeout(() => error(fault("voice_timeout")), lifetimeMs);
70
70
  client.on("close", close); client.on("error", close);
71
71
  client.on("message", (raw, binary) => {
72
72
  try {
@@ -145,7 +145,17 @@ export function createAudioStreamGateway({ config, models, replies, synthesize =
145
145
  for (let offset = 0; offset < event.chunk.length; offset += 16384) {
146
146
  while (pausedAt || sentFrames - playedFrames > rate * 2 || client.bufferedAmount > 128 * 1024) {
147
147
  if (!reportedWait) { reportedWait = true; metric({ stage: "playback_wait", voiceId: grant.scope.id, sentFrames, playedFrames, paused: Boolean(pausedAt) }); }
148
- await new Promise((resolve, reject) => { const timer = setTimeout(() => { waiter = null; reject(fault(pausedAt ? "voice_interruption_timeout" : "voice_audio_backpressure")); }, pausedAt ? Math.max(1, 10000 - (Date.now() - pausedAt)) : 10000); waiter = () => { clearTimeout(timer); resolve(); }; });
148
+ // A device switch or a locked screen can stop playout for a
149
+ // while without the client being gone. Wait for it to come
150
+ // back instead of ending the answer after ten seconds.
151
+ await new Promise((resolve, reject) => {
152
+ // A locked screen or a device switch is a pause, not a
153
+ // lost client: hold the answer long enough for the page
154
+ // to come back before failing it.
155
+ const limit = pausedAt ? Math.max(1, pauseHoldMs - (Date.now() - pausedAt)) : playbackStallMs;
156
+ const timer = setTimeout(() => { waiter = null; reject(fault(pausedAt ? "voice_interruption_timeout" : "voice_audio_backpressure")); }, limit);
157
+ waiter = () => { clearTimeout(timer); resolve(); };
158
+ });
149
159
  if (abort.signal.aborted || client.readyState !== 1) throw fault("voice_cancelled");
150
160
  }
151
161
  if (client.readyState !== 1) throw fault("voice_cancelled");
@@ -12,7 +12,10 @@ export function ttsSettingsModel(value) {
12
12
  /** Framework-neutral settings lifecycle; model switches invalidate both audio and directory results. */
13
13
  export function createTtsSettings({ preferences, list = fetchTtsVoices, preview, activity = voiceActivity }) {
14
14
  const initialModel = ttsSettingsModel(preferences.getSnapshot().value);
15
- let state = { model: initialModel, voices: [], selected: ttsVoiceForModel(preferences.getSnapshot().value?.config || {}, initialModel) || "", loading: Boolean(initialModel), error: null, saving: false, playing: false, previewError: null, blocked: activity.getSnapshot(), unavailable: false };
15
+ let state = { model: initialModel, voices: [], selected: ttsVoiceForModel(preferences.getSnapshot().value?.config || {}, initialModel) || "", loading: Boolean(initialModel), error: null, stale: false, saving: false, playing: false, previewError: null, blocked: activity.getSnapshot(), unavailable: false };
16
+ // A transient failure keeps the last directory this page loaded for the model
17
+ // instead of turning a working voice picker into an error screen.
18
+ const cached = new Map();
16
19
  const listeners = new Set();
17
20
  let generation = 0, listing = null, playing = null, disposed = false;
18
21
  const emit = patch => { if (disposed) return; state = { ...state, ...patch }; for (const listener of listeners) listener(); };
@@ -25,16 +28,20 @@ export function createTtsSettings({ preferences, list = fetchTtsVoices, preview,
25
28
  const load = async () => {
26
29
  const token = ++generation; listing?.abort(); stop();
27
30
  const model = ttsSettingsModel(preferences.getSnapshot().value);
28
- emit({ model, voices: [], loading: Boolean(model), error: null, unavailable: false, previewError: null });
31
+ emit({ model, voices: [], loading: Boolean(model), error: null, stale: false, unavailable: false, previewError: null });
29
32
  updateSelection();
30
33
  if (!model) return;
31
34
  const abort = listing = new AbortController();
32
35
  try {
33
- const voices = await list(model, { signal: AbortSignal.any([abort.signal, AbortSignal.timeout(10000)]) });
36
+ const voices = await list(model, { signal: AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)]) });
34
37
  if (token !== generation || disposed) return;
35
- emit({ voices, loading: false }); updateSelection();
38
+ cached.set(model, voices);
39
+ emit({ voices, loading: false, error: null, stale: false }); updateSelection();
36
40
  } catch (error) {
37
- if (token === generation && !disposed && !abort.signal.aborted) emit({ loading: false, error: "tts_voices_failed" });
41
+ if (token !== generation || disposed || abort.signal.aborted) return;
42
+ const previous = cached.get(model);
43
+ if (previous) { emit({ voices: previous, loading: false, error: null, stale: true }); updateSelection(); }
44
+ else emit({ loading: false, error: "tts_voices_failed", stale: false });
38
45
  }
39
46
  };
40
47
  let wasLoading = preferences.getSnapshot().loading, catalog = JSON.stringify(preferences.getSnapshot().value?.ttsModels || []);
@@ -4,17 +4,58 @@ export function ttsVoiceForModel(config, model) {
4
4
  return config.ttsModel === model ? config.voice || undefined : undefined;
5
5
  }
6
6
 
7
- export async function fetchTtsVoices(model, { signal, fetcher = fetch } = {}) {
8
- const res = await fetcher(`/llm/v1/audio/voices?model=${encodeURIComponent(model)}`, { signal: signal ?? AbortSignal.timeout(10000) });
9
- const body = await res.json().catch(() => null);
10
- if ((res.status === 404 && /no preset voices/i.test(String(body?.detail || body?.error?.message || ""))) || res.status === 405 || res.status === 501) return [];
11
- if (!res.ok) throw new Error("tts_voices_failed");
12
- if (!Array.isArray(body?.voices)) throw new Error("tts_voices_failed");
13
- const seen = new Set();
14
- return body.voices.flatMap(row => {
15
- const id = typeof row === "string" ? row : row?.id;
16
- if (typeof id !== "string" || !id || seen.has(id)) return [];
17
- seen.add(id);
18
- return [{ id, label: typeof row?.name === "string" && row.name.trim() ? row.name : id }];
7
+ // A cold or busy TTS engine answers 5xx until its model is ready. The directory
8
+ // is metadata, so a couple of short retries are enough and keep the settings
9
+ // usable instead of failing on the first probe.
10
+ const ATTEMPT_TIMEOUT_MS = 8_000;
11
+ const RETRY_DELAYS_MS = [500, 1_500];
12
+ const TRANSIENT_STATUS = new Set([429, 500, 502, 503, 504]);
13
+
14
+ function delay(ms, signal) {
15
+ return new Promise((resolve, reject) => {
16
+ const timer = setTimeout(() => { cleanup(); resolve(); }, ms);
17
+ const stop = () => { cleanup(); reject(new Error("voice_voices_aborted")); };
18
+ const cleanup = () => { clearTimeout(timer); signal?.removeEventListener("abort", stop); };
19
+ signal?.addEventListener("abort", stop, { once: true });
20
+ if (signal?.aborted) stop();
19
21
  });
20
22
  }
23
+
24
+ function attemptSignal(signal) {
25
+ const timeout = typeof AbortSignal?.timeout === "function" ? AbortSignal.timeout(ATTEMPT_TIMEOUT_MS) : null;
26
+ if (signal && timeout && typeof AbortSignal.any === "function") {
27
+ try { return AbortSignal.any([signal, timeout]); } catch { /* caller signal only */ }
28
+ }
29
+ return signal ?? timeout ?? undefined;
30
+ }
31
+
32
+ export async function fetchTtsVoices(model, { signal, fetcher = fetch, retryDelays = RETRY_DELAYS_MS } = {}) {
33
+ const url = `/llm/v1/audio/voices?model=${encodeURIComponent(model)}`;
34
+ const failure = new Error("tts_voices_failed");
35
+ for (let attempt = 0; ; attempt += 1) {
36
+ try {
37
+ const res = await fetcher(url, { signal: attemptSignal(signal) });
38
+ const body = await res.json().catch(() => null);
39
+ if ((res.status === 404 && /no preset voices/i.test(String(body?.detail || body?.error?.message || ""))) || res.status === 405 || res.status === 501) return [];
40
+ if (!res.ok) {
41
+ if (TRANSIENT_STATUS.has(res.status) && attempt < retryDelays.length && !signal?.aborted) {
42
+ await delay(retryDelays[attempt], signal);
43
+ continue;
44
+ }
45
+ throw failure;
46
+ }
47
+ if (!Array.isArray(body?.voices)) throw failure;
48
+ const seen = new Set();
49
+ return body.voices.flatMap(row => {
50
+ const id = typeof row === "string" ? row : row?.id;
51
+ if (typeof id !== "string" || !id || seen.has(id)) return [];
52
+ seen.add(id);
53
+ return [{ id, label: typeof row?.name === "string" && row.name.trim() ? row.name : id }];
54
+ });
55
+ } catch (error) {
56
+ if (error === failure) throw failure;
57
+ if (signal?.aborted || attempt >= retryDelays.length) throw failure;
58
+ await delay(retryDelays[attempt], signal);
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * A phone that turns its screen off suspends the WebView: the AudioContext is
3
+ * interrupted and the socket is torn down, so a long answer stops after the
4
+ * first sentences. Keeping the screen awake for the length of a readout is the
5
+ * only part of that chain the page itself can control.
6
+ *
7
+ * The lock is only a hint. A WebView without `navigator.wakeLock`, or a request
8
+ * the platform refuses (no gesture, low power mode), must leave playback alone.
9
+ */
10
+ export function createScreenWakeLock(nav = globalThis.navigator, doc = globalThis.document) {
11
+ let sentinel = null;
12
+ let wanted = false;
13
+ let watching = false;
14
+ const supported = () => typeof nav?.wakeLock?.request === "function";
15
+
16
+ const drop = () => {
17
+ const held = sentinel;
18
+ sentinel = null;
19
+ if (!held) return;
20
+ try { void held.release?.()?.catch?.(() => {}); } catch { /* already released */ }
21
+ };
22
+
23
+ const acquire = async () => {
24
+ if (!wanted || sentinel || !supported() || doc?.visibilityState === "hidden") return false;
25
+ try {
26
+ const held = await nav.wakeLock.request("screen");
27
+ // The answer may have ended while the platform was answering the request.
28
+ if (!wanted) { try { void held.release?.()?.catch?.(() => {}); } catch { /* already released */ } return false; }
29
+ sentinel = held;
30
+ held.addEventListener?.("release", () => { if (sentinel === held) sentinel = null; });
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ };
36
+
37
+ // A hidden page loses its lock. Re-acquire through the platform's own rule
38
+ // instead of guessing when the page is allowed to hold one again.
39
+ const watch = () => {
40
+ if (watching || !doc?.addEventListener) return;
41
+ watching = true;
42
+ doc.addEventListener("visibilitychange", () => { if (wanted && doc.visibilityState === "visible") void acquire(); });
43
+ };
44
+
45
+ return {
46
+ get supported() { return supported(); },
47
+ get held() { return Boolean(sentinel); },
48
+ /** Ask for the lock; resolves to whether the platform granted it. */
49
+ async hold() { wanted = true; watch(); return acquire(); },
50
+ release() { wanted = false; drop(); },
51
+ };
52
+ }