2026-05-05 08:13:38 +00:00
|
|
|
import type { NotificationSoundId } from "@workspace/api-client-react";
|
2026-05-05 08:20:54 +00:00
|
|
|
import {
|
|
|
|
|
NOTIFICATION_SOUND_MANIFEST,
|
|
|
|
|
SOUND_BY_ID,
|
|
|
|
|
} from "@/lib/notification-sound-manifest";
|
2026-05-05 08:13:38 +00:00
|
|
|
|
2026-05-05 08:20:54 +00:00
|
|
|
export const ALL_SOUND_IDS: ReadonlyArray<NotificationSoundId> =
|
|
|
|
|
NOTIFICATION_SOUND_MANIFEST.map((s) => s.id);
|
2026-05-05 08:13:38 +00:00
|
|
|
|
2026-05-05 08:20:54 +00:00
|
|
|
/**
|
|
|
|
|
* Custom DOM event the player dispatches when a play attempt is denied
|
|
|
|
|
* because the user hasn't yet interacted with the page (browser autoplay
|
2026-05-07 14:15:53 +00:00
|
|
|
* policy) or the AudioContext could not be resumed. The App listens for
|
|
|
|
|
* this and shows a one-time toast hint. The event `detail` carries an
|
2026-05-10 11:40:07 +00:00
|
|
|
* `isIos` flag so the toast can include iPad-specific guidance (raise
|
|
|
|
|
* the *media* volume, since the iOS path now plays through the media
|
|
|
|
|
* channel via HTMLAudioElement — see the iOS notes on `class
|
|
|
|
|
* NotificationPlayer` below).
|
2026-05-05 08:20:54 +00:00
|
|
|
*/
|
|
|
|
|
export const AUTOPLAY_BLOCKED_EVENT = "txos:notification-autoplay-blocked";
|
2026-05-05 08:13:38 +00:00
|
|
|
|
2026-05-07 14:15:53 +00:00
|
|
|
export type AutoplayBlockedDetail = {
|
|
|
|
|
isIos: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// iPad/iPhone/iPod detection used to surface a more actionable hint
|
2026-05-10 11:40:07 +00:00
|
|
|
// (e.g. "raise the media volume") AND to switch the playback engine to
|
|
|
|
|
// HTMLAudioElement, which is the only way to route notification chimes
|
|
|
|
|
// through the iOS *media* channel instead of the *ringer* channel.
|
|
|
|
|
// Modern iPadOS reports as Mac in `userAgent`, so we also check for
|
|
|
|
|
// Mac + multi-touch.
|
2026-05-07 14:15:53 +00:00
|
|
|
function detectIos(): boolean {
|
|
|
|
|
if (typeof navigator === "undefined") return false;
|
|
|
|
|
const ua = navigator.userAgent || "";
|
|
|
|
|
if (/iPad|iPhone|iPod/.test(ua)) return true;
|
|
|
|
|
// iPadOS 13+ desktop Safari masquerade.
|
|
|
|
|
if (
|
|
|
|
|
ua.includes("Macintosh") &&
|
|
|
|
|
typeof navigator.maxTouchPoints === "number" &&
|
|
|
|
|
navigator.maxTouchPoints > 1
|
|
|
|
|
) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type WindowWithWebkitAC = Window & {
|
|
|
|
|
webkitAudioContext?: typeof AudioContext;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-10 12:08:45 +00:00
|
|
|
/**
|
|
|
|
|
* A ~30ms-of-silence WAV file encoded as a data URL. Used on iOS to
|
|
|
|
|
* "prime" the shared HTMLAudioElement inside a user gesture without
|
|
|
|
|
* making any network request and without producing audible output.
|
|
|
|
|
*
|
|
|
|
|
* iOS Safari's autoplay rule is: an `<audio>` element becomes allowed to
|
|
|
|
|
* play out-of-gesture only after it has had at least one successful
|
|
|
|
|
* `play()` *inside* a gesture. Crucially, the unlock is keyed on the
|
|
|
|
|
* **element**, not the URL — once an element has played anything, you
|
|
|
|
|
* can later swap `.src` to a different file and `.play()` it without
|
|
|
|
|
* any further gesture. That's why we keep a single shared element on
|
|
|
|
|
* iOS and just rotate `.src` per sound, instead of trying to unlock
|
|
|
|
|
* eight separate elements (which fails on iOS — only the first
|
|
|
|
|
* `play()` per gesture actually counts as the unlock).
|
|
|
|
|
*
|
|
|
|
|
* Format: 8 kHz, 16-bit PCM mono, 8 frames (16 bytes) of zero samples.
|
|
|
|
|
* 60 bytes total. Inline so we don't need an extra HTTP fetch.
|
|
|
|
|
*/
|
|
|
|
|
const SILENT_WAV_DATA_URL =
|
|
|
|
|
"data:audio/wav;base64,UklGRjQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YRAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
|
|
|
|
|
2026-05-10 11:40:07 +00:00
|
|
|
/**
|
|
|
|
|
* NotificationPlayer
|
|
|
|
|
* --------------------------------------------------------------------
|
|
|
|
|
* Two playback engines, picked once per process based on `detectIos()`:
|
|
|
|
|
*
|
|
|
|
|
* • Non-iOS (desktop, Android Chrome, Firefox, etc.):
|
|
|
|
|
* Web Audio API (`AudioContext` + decoded `AudioBuffer`s). Lower
|
|
|
|
|
* latency, can pre-decode in the background, and respects the
|
|
|
|
|
* user's expectation of "the sound the page plays."
|
|
|
|
|
*
|
|
|
|
|
* • iOS (iPad / iPhone Safari + iPadOS-as-Mac masquerade):
|
2026-05-10 12:08:45 +00:00
|
|
|
* A SINGLE shared `HTMLAudioElement` whose `.src` we rotate per
|
|
|
|
|
* play. On iOS, Web Audio is routed through the *ringer* channel,
|
|
|
|
|
* which is silenced by the silent switch / Control Center mute and
|
|
|
|
|
* uses the *ringer* volume — even when the user has the *media*
|
|
|
|
|
* volume turned up (which is the volume that controls YouTube and
|
|
|
|
|
* other media). HTMLAudioElement plays through the media channel,
|
|
|
|
|
* identical to a `<video>` tag, so notifications stay audible
|
|
|
|
|
* whenever YouTube/Safari videos would also be audible.
|
|
|
|
|
*
|
|
|
|
|
* We deliberately keep ONE element (not one-per-sound) because iOS
|
|
|
|
|
* only honors a single `play()` per user gesture as the "unlock"
|
|
|
|
|
* for that element. Looping `play()` over eight different elements
|
|
|
|
|
* in a gesture handler unlocks only the first; the rest stay
|
|
|
|
|
* gesture-locked and silently fail when a socket event tries to
|
|
|
|
|
* play them later.
|
2026-05-10 11:40:07 +00:00
|
|
|
*
|
|
|
|
|
* Both engines share the same throttling, autoplay-hint, vibration,
|
|
|
|
|
* and test-only observability so callers (`socket`, settings preview)
|
|
|
|
|
* see one uniform `play()` / `testPlay()` interface.
|
|
|
|
|
*/
|
2026-05-05 08:13:38 +00:00
|
|
|
class NotificationPlayer {
|
2026-05-10 11:40:07 +00:00
|
|
|
// ---- Engine selection ----
|
|
|
|
|
// Frozen at first use: detectIos() reads navigator, which is stable
|
|
|
|
|
// for a session. Caching avoids repeated UA sniffing on every play.
|
|
|
|
|
private isIosEngine: boolean | null = null;
|
|
|
|
|
|
|
|
|
|
// ---- Web Audio (non-iOS) state ----
|
2026-05-07 14:15:53 +00:00
|
|
|
private ctx: AudioContext | null = null;
|
|
|
|
|
private buffers = new Map<NotificationSoundId, AudioBuffer>();
|
|
|
|
|
private loading = new Map<NotificationSoundId, Promise<AudioBuffer | null>>();
|
2026-05-10 11:40:07 +00:00
|
|
|
|
|
|
|
|
// ---- HTMLAudioElement (iOS) state ----
|
2026-05-10 12:08:45 +00:00
|
|
|
// ONE shared element. We rotate `.src` per play. Created lazily on
|
|
|
|
|
// first use so SSR-safe.
|
|
|
|
|
private iosAudio: HTMLAudioElement | null = null;
|
2026-05-10 11:40:07 +00:00
|
|
|
|
|
|
|
|
// ---- Shared state ----
|
2026-05-05 08:13:38 +00:00
|
|
|
private unlocked = false;
|
2026-05-24 08:33:53 +00:00
|
|
|
// #628: per-bucket throttle timestamps. Was a single global value,
|
|
|
|
|
// which coupled channels: lowering the order throttle to 800ms
|
|
|
|
|
// caused rapid order chimes to keep refreshing the global timestamp
|
|
|
|
|
// and starve meeting/note chimes (whose 3s window kept restarting).
|
|
|
|
|
// Bucketing isolates each channel so order bursts can't mute
|
|
|
|
|
// meeting/note alerts and vice versa.
|
|
|
|
|
private lastPlayAtByBucket = new Map<string, number>();
|
2026-05-05 08:20:54 +00:00
|
|
|
private hintDispatched = false;
|
2026-05-05 08:13:38 +00:00
|
|
|
|
2026-05-10 11:40:07 +00:00
|
|
|
private get useIos(): boolean {
|
|
|
|
|
if (this.isIosEngine === null) this.isIosEngine = detectIos();
|
|
|
|
|
return this.isIosEngine;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 08:20:54 +00:00
|
|
|
isUnlocked(): boolean {
|
2026-05-07 14:15:53 +00:00
|
|
|
if (!this.unlocked) return false;
|
2026-05-10 12:08:45 +00:00
|
|
|
if (this.useIos) return true;
|
2026-05-07 14:15:53 +00:00
|
|
|
return this.ctx?.state === "running";
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 11:40:07 +00:00
|
|
|
// ---- Web Audio helpers (non-iOS) ----
|
|
|
|
|
|
2026-05-07 14:15:53 +00:00
|
|
|
/**
|
|
|
|
|
* Lazily build the shared AudioContext. Returns `null` when the
|
|
|
|
|
* Web Audio API is unavailable (very old browsers / SSR) so callers
|
|
|
|
|
* can degrade gracefully.
|
|
|
|
|
*/
|
|
|
|
|
private getContext(): AudioContext | null {
|
|
|
|
|
if (this.ctx) return this.ctx;
|
|
|
|
|
if (typeof window === "undefined") return null;
|
|
|
|
|
const w = window as WindowWithWebkitAC;
|
|
|
|
|
const Ctor = window.AudioContext ?? w.webkitAudioContext;
|
|
|
|
|
if (!Ctor) return null;
|
|
|
|
|
try {
|
|
|
|
|
this.ctx = new Ctor();
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return this.ctx;
|
2026-05-05 08:13:38 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-07 14:15:53 +00:00
|
|
|
private async loadBuffer(
|
|
|
|
|
soundId: NotificationSoundId,
|
|
|
|
|
): Promise<AudioBuffer | null> {
|
|
|
|
|
const cached = this.buffers.get(soundId);
|
|
|
|
|
if (cached) return cached;
|
|
|
|
|
const inFlight = this.loading.get(soundId);
|
|
|
|
|
if (inFlight) return inFlight;
|
|
|
|
|
const ctx = this.getContext();
|
|
|
|
|
const entry = SOUND_BY_ID.get(soundId);
|
|
|
|
|
if (!ctx || !entry) return null;
|
|
|
|
|
const promise = (async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(entry.url, { credentials: "same-origin" });
|
|
|
|
|
if (!res.ok) return null;
|
|
|
|
|
const ab = await res.arrayBuffer();
|
|
|
|
|
// Older WebKit only supports the callback form of decodeAudioData;
|
|
|
|
|
// wrap so we always get a Promise.
|
|
|
|
|
const buf = await new Promise<AudioBuffer>((resolve, reject) => {
|
|
|
|
|
try {
|
|
|
|
|
const maybe = ctx.decodeAudioData(
|
|
|
|
|
ab,
|
|
|
|
|
(b) => resolve(b),
|
|
|
|
|
(e) => reject(e ?? new Error("decode failed")),
|
|
|
|
|
);
|
|
|
|
|
if (maybe && typeof (maybe as Promise<AudioBuffer>).then === "function") {
|
|
|
|
|
(maybe as Promise<AudioBuffer>).then(resolve, reject);
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
reject(e);
|
|
|
|
|
}
|
2026-05-05 08:20:54 +00:00
|
|
|
});
|
2026-05-07 14:15:53 +00:00
|
|
|
this.buffers.set(soundId, buf);
|
|
|
|
|
return buf;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
} finally {
|
|
|
|
|
this.loading.delete(soundId);
|
2026-05-05 08:20:54 +00:00
|
|
|
}
|
2026-05-07 14:15:53 +00:00
|
|
|
})();
|
|
|
|
|
this.loading.set(soundId, promise);
|
|
|
|
|
return promise;
|
2026-05-05 08:20:54 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-10 11:40:07 +00:00
|
|
|
// ---- HTMLAudioElement helpers (iOS) ----
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-10 12:08:45 +00:00
|
|
|
* Lazily build (or fetch the cached) shared HTMLAudioElement for the
|
|
|
|
|
* iOS engine. We deliberately do NOT set `crossOrigin` — the .wav
|
|
|
|
|
* files are served same-origin without CORS headers, and setting
|
|
|
|
|
* `crossOrigin = "anonymous"` flips the request into CORS mode and
|
|
|
|
|
* causes Safari to fail the load silently.
|
2026-05-10 11:40:07 +00:00
|
|
|
*/
|
2026-05-10 12:08:45 +00:00
|
|
|
private getIosAudio(): HTMLAudioElement | null {
|
|
|
|
|
if (this.iosAudio) return this.iosAudio;
|
2026-05-10 11:40:07 +00:00
|
|
|
if (typeof window === "undefined") return null;
|
|
|
|
|
try {
|
2026-05-10 12:08:45 +00:00
|
|
|
const el = new Audio();
|
2026-05-10 11:40:07 +00:00
|
|
|
el.preload = "auto";
|
|
|
|
|
// `playsInline` is a video-only attribute on the type, but
|
|
|
|
|
// setting it on an Audio element is harmless and silences a
|
|
|
|
|
// theoretical fullscreen-takeover path on very old iOS.
|
|
|
|
|
(el as HTMLAudioElement & { playsInline?: boolean }).playsInline = true;
|
2026-05-10 12:08:45 +00:00
|
|
|
this.iosAudio = el;
|
2026-05-10 11:40:07 +00:00
|
|
|
return el;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- Public API ----
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Browsers require a user gesture before audio can play. Call this
|
|
|
|
|
* from inside a gesture handler (pointerdown/touchstart/keydown).
|
|
|
|
|
*
|
|
|
|
|
* On non-iOS we resume the AudioContext and play a 1-sample silent
|
|
|
|
|
* buffer (iOS Safari's own trick, kept for any WebKit-derived
|
|
|
|
|
* non-iOS browser still requiring it), then pre-decode all buffers.
|
|
|
|
|
*
|
2026-05-10 12:08:45 +00:00
|
|
|
* On iOS we point the shared element at a tiny silent WAV data URL
|
|
|
|
|
* and `play()` it. That counts as the gesture-bound unlock for the
|
|
|
|
|
* element; afterwards we can swap `.src` to any real sound and play
|
|
|
|
|
* it from outside any gesture (e.g. when a socket event arrives).
|
2026-05-10 11:40:07 +00:00
|
|
|
*
|
2026-05-10 12:08:45 +00:00
|
|
|
* Safe to call repeatedly; both engines tolerate it. On iOS we
|
|
|
|
|
* short-circuit once `unlocked` is true so subsequent pointerdowns
|
|
|
|
|
* don't race with an in-flight `play()` of a real sound.
|
2026-05-10 11:40:07 +00:00
|
|
|
*/
|
|
|
|
|
unlock(): void {
|
|
|
|
|
if (this.useIos) {
|
2026-05-10 12:08:45 +00:00
|
|
|
if (this.unlocked) return;
|
|
|
|
|
const el = this.getIosAudio();
|
|
|
|
|
if (!el) return;
|
|
|
|
|
try {
|
|
|
|
|
el.src = SILENT_WAV_DATA_URL;
|
|
|
|
|
el.currentTime = 0;
|
2026-05-10 11:40:07 +00:00
|
|
|
const p = el.play();
|
|
|
|
|
if (p && typeof p.then === "function") {
|
2026-05-10 12:08:45 +00:00
|
|
|
// Deliberately no `pause()` / `currentTime = 0` cleanup in
|
|
|
|
|
// the resolve branch: the silent clip is ~30ms and inaudible,
|
|
|
|
|
// so we just let it run to completion. Any cleanup here would
|
|
|
|
|
// race with a `playIos()` that arrives in the same gesture
|
|
|
|
|
// (e.g. `pointerdown → unlock()` followed immediately by
|
|
|
|
|
// `click → testPlay() → playIos()`); the cleanup could pause
|
|
|
|
|
// the real chime that just started. Letting the silent clip
|
|
|
|
|
// finish naturally — or get pre-empted by a `src` swap from
|
|
|
|
|
// `playIos()` (which simply aborts this promise) — is safe
|
|
|
|
|
// either way.
|
|
|
|
|
p.then(() => {
|
|
|
|
|
this.unlocked = true;
|
|
|
|
|
}).catch(() => {
|
|
|
|
|
// Likely either: no real gesture yet (programmatic call)
|
|
|
|
|
// and the autoplay policy denied us; or the silent play
|
|
|
|
|
// was pre-empted by a real chime via `playIos()` swapping
|
|
|
|
|
// `.src` (AbortError). Both are benign here — `playIos()`
|
|
|
|
|
// sets `unlocked = true` itself on its own resolve.
|
2026-05-10 11:40:07 +00:00
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// Synchronous return (very old WebKit). Treat as success.
|
2026-05-10 12:08:45 +00:00
|
|
|
this.unlocked = true;
|
2026-05-10 11:40:07 +00:00
|
|
|
}
|
2026-05-10 12:08:45 +00:00
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
2026-05-10 11:40:07 +00:00
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Non-iOS Web Audio path (original behavior).
|
|
|
|
|
const ctx = this.getContext();
|
|
|
|
|
if (!ctx) return;
|
|
|
|
|
|
|
|
|
|
// Always attempt resume() — even if `unlocked` is already true the
|
|
|
|
|
// context may have been auto-suspended.
|
|
|
|
|
if (ctx.state !== "running") {
|
|
|
|
|
void ctx.resume().catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const silent = ctx.createBuffer(1, 1, 22050);
|
|
|
|
|
const src = ctx.createBufferSource();
|
|
|
|
|
src.buffer = silent;
|
|
|
|
|
src.connect(ctx.destination);
|
|
|
|
|
src.start(0);
|
|
|
|
|
} catch {
|
|
|
|
|
/* not fatal — resume() alone may still be enough */
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.unlocked) return;
|
|
|
|
|
this.unlocked = true;
|
|
|
|
|
|
|
|
|
|
for (const entry of NOTIFICATION_SOUND_MANIFEST) {
|
|
|
|
|
void this.loadBuffer(entry.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 08:20:54 +00:00
|
|
|
private dispatchHint() {
|
|
|
|
|
if (this.hintDispatched) return;
|
|
|
|
|
this.hintDispatched = true;
|
|
|
|
|
if (typeof window !== "undefined") {
|
2026-05-10 11:40:07 +00:00
|
|
|
const detail: AutoplayBlockedDetail = { isIos: this.useIos };
|
2026-05-07 14:15:53 +00:00
|
|
|
window.dispatchEvent(
|
|
|
|
|
new CustomEvent<AutoplayBlockedDetail>(AUTOPLAY_BLOCKED_EVENT, {
|
|
|
|
|
detail,
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-05-05 08:13:38 +00:00
|
|
|
}
|
2026-05-05 08:20:54 +00:00
|
|
|
// Allow another hint after a generous cool-down so the user doesn't
|
|
|
|
|
// get spammed if they ignore the first toast.
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
this.hintDispatched = false;
|
|
|
|
|
}, 30_000);
|
2026-05-05 08:13:38 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-07 14:15:53 +00:00
|
|
|
/**
|
|
|
|
|
* Play a notification chime by id. Safe to call before `unlock()` —
|
|
|
|
|
* the play attempt will simply surface the autoplay-blocked hint
|
|
|
|
|
* once instead of producing audio. Throttled to at most one chime
|
|
|
|
|
* every 3 s to coalesce bursts.
|
|
|
|
|
*/
|
2026-05-05 08:13:38 +00:00
|
|
|
play(
|
|
|
|
|
soundId: NotificationSoundId,
|
2026-05-24 08:33:53 +00:00
|
|
|
opts: {
|
|
|
|
|
vibrate?: boolean;
|
|
|
|
|
bypassThrottle?: boolean;
|
|
|
|
|
// #628: per-call throttle override. Order chimes drop to 800ms
|
|
|
|
|
// so a small burst of incoming orders (rare but real during
|
|
|
|
|
// service rushes) still rings for each one instead of swallowing
|
|
|
|
|
// every order after the first for the next 3 seconds. Other
|
|
|
|
|
// channels keep the original 3000ms coalescing window so a
|
|
|
|
|
// single meeting fanout that touches several listeners doesn't
|
|
|
|
|
// produce a buzzy double-chime.
|
|
|
|
|
throttleMs?: number;
|
|
|
|
|
// #628: throttle bucket key. Defaults to "default" so legacy
|
|
|
|
|
// callers behave as before. Callers that want isolated coalescing
|
|
|
|
|
// (e.g. "order" vs "meeting" vs "note") pass distinct buckets so
|
|
|
|
|
// a burst on one channel can't extend the silence window of
|
|
|
|
|
// another channel.
|
|
|
|
|
throttleBucket?: string;
|
|
|
|
|
} = {},
|
2026-05-05 08:13:38 +00:00
|
|
|
): void {
|
|
|
|
|
const now =
|
|
|
|
|
typeof performance !== "undefined" ? performance.now() : Date.now();
|
2026-05-07 14:15:53 +00:00
|
|
|
if (!opts.bypassThrottle) {
|
2026-05-24 08:33:53 +00:00
|
|
|
const window =
|
|
|
|
|
typeof opts.throttleMs === "number" ? opts.throttleMs : 3000;
|
|
|
|
|
const bucket = opts.throttleBucket ?? "default";
|
|
|
|
|
const last = this.lastPlayAtByBucket.get(bucket) ?? 0;
|
|
|
|
|
if (now - last < window) return;
|
|
|
|
|
this.lastPlayAtByBucket.set(bucket, now);
|
2026-05-07 14:15:53 +00:00
|
|
|
}
|
2026-05-05 08:13:38 +00:00
|
|
|
|
2026-05-05 08:20:54 +00:00
|
|
|
const entry = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding");
|
|
|
|
|
if (!entry) return;
|
|
|
|
|
|
2026-05-05 16:31:44 +00:00
|
|
|
// Test-only observability: expose a counter + last sound so Playwright
|
|
|
|
|
// can assert chime invocation. Gated on dev/test bundles so production
|
|
|
|
|
// builds don't leak debug globals.
|
|
|
|
|
if (typeof window !== "undefined" && import.meta.env.MODE !== "production") {
|
2026-05-05 16:26:13 +00:00
|
|
|
const w = window as unknown as {
|
|
|
|
|
__txosNotifPlayCount?: number;
|
|
|
|
|
__txosNotifLastSound?: NotificationSoundId;
|
|
|
|
|
};
|
|
|
|
|
w.__txosNotifPlayCount = (w.__txosNotifPlayCount ?? 0) + 1;
|
|
|
|
|
w.__txosNotifLastSound = entry.id;
|
|
|
|
|
}
|
2026-05-07 14:15:53 +00:00
|
|
|
|
2026-05-10 11:40:07 +00:00
|
|
|
if (this.useIos) {
|
|
|
|
|
this.playIos(entry.id);
|
|
|
|
|
} else {
|
|
|
|
|
this.playWebAudio(entry.id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (opts.vibrate && typeof navigator !== "undefined" && navigator.vibrate) {
|
|
|
|
|
try {
|
|
|
|
|
navigator.vibrate([60, 40, 60]);
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private playIos(soundId: NotificationSoundId): void {
|
2026-05-10 12:08:45 +00:00
|
|
|
const el = this.getIosAudio();
|
|
|
|
|
const entry = SOUND_BY_ID.get(soundId);
|
|
|
|
|
if (!el || !entry) {
|
2026-05-10 11:40:07 +00:00
|
|
|
if (!this.unlocked) this.dispatchHint();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
2026-05-10 12:08:45 +00:00
|
|
|
// Rotate the src to the requested sound. Setting src on an
|
|
|
|
|
// already-unlocked element keeps the unlock — the unlock is
|
|
|
|
|
// bound to the element, not the URL.
|
|
|
|
|
el.src = entry.url;
|
2026-05-10 11:40:07 +00:00
|
|
|
el.currentTime = 0;
|
|
|
|
|
} catch {
|
2026-05-10 12:08:45 +00:00
|
|
|
/* ignore */
|
2026-05-10 11:40:07 +00:00
|
|
|
}
|
|
|
|
|
const p = el.play();
|
|
|
|
|
if (p && typeof p.then === "function") {
|
2026-05-10 12:08:45 +00:00
|
|
|
p.then(() => {
|
|
|
|
|
// A successful play also confirms the element is unlocked, so
|
|
|
|
|
// `isUnlocked()` reflects reality even if `unlock()` itself
|
|
|
|
|
// was never called (e.g. `testPlay()` from inside a click).
|
|
|
|
|
this.unlocked = true;
|
|
|
|
|
}).catch(() => {
|
2026-05-10 11:40:07 +00:00
|
|
|
// Most common rejections: NotAllowedError (autoplay policy
|
2026-05-10 12:08:45 +00:00
|
|
|
// before any gesture), or a transient AbortError from a
|
|
|
|
|
// pause/seek/src-swap racing with the play. The hint toast
|
|
|
|
|
// covers the autoplay case; the abort case is benign and we
|
|
|
|
|
// don't want to spam the user.
|
2026-05-10 11:40:07 +00:00
|
|
|
if (!this.unlocked) this.dispatchHint();
|
|
|
|
|
});
|
2026-05-10 12:08:45 +00:00
|
|
|
} else {
|
|
|
|
|
// Synchronous return — assume success.
|
|
|
|
|
this.unlocked = true;
|
2026-05-10 11:40:07 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private playWebAudio(soundId: NotificationSoundId): void {
|
2026-05-07 14:15:53 +00:00
|
|
|
const ctx = this.getContext();
|
|
|
|
|
if (!ctx) {
|
|
|
|
|
if (!this.unlocked) this.dispatchHint();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (ctx.state !== "running") {
|
|
|
|
|
void ctx.resume().catch(() => {});
|
2026-05-05 08:13:38 +00:00
|
|
|
}
|
2026-05-10 11:40:07 +00:00
|
|
|
void this.loadBuffer(soundId).then((buf) => {
|
2026-05-07 14:15:53 +00:00
|
|
|
if (!buf) return;
|
|
|
|
|
if (ctx.state !== "running") {
|
|
|
|
|
this.dispatchHint();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const src = ctx.createBufferSource();
|
|
|
|
|
src.buffer = buf;
|
|
|
|
|
src.connect(ctx.destination);
|
|
|
|
|
src.start(0);
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore one-off node errors */
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-05 08:13:38 +00:00
|
|
|
}
|
2026-05-07 14:15:53 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Force-play used by the "Test sound" button in settings. Bypasses
|
|
|
|
|
* the 3-second throttle (so users can audition several sounds in a
|
2026-05-10 12:08:45 +00:00
|
|
|
* row).
|
|
|
|
|
*
|
|
|
|
|
* On iOS, the requested sound's `play()` MUST happen synchronously
|
|
|
|
|
* inside the click handler — that's the gesture iOS binds the
|
|
|
|
|
* unlock to. We therefore call `play()` first (which doubles as the
|
|
|
|
|
* unlock) instead of running `unlock()`'s silent-prime first, which
|
|
|
|
|
* could consume the gesture allowance and leave the real play
|
|
|
|
|
* stranded outside it.
|
|
|
|
|
*
|
|
|
|
|
* On non-iOS we keep the original `unlock()`-then-`play()` order so
|
|
|
|
|
* the AudioContext is reliably resumed before the buffer plays.
|
2026-05-07 14:15:53 +00:00
|
|
|
*/
|
|
|
|
|
testPlay(soundId: NotificationSoundId): void {
|
2026-05-10 12:08:45 +00:00
|
|
|
if (this.useIos) {
|
|
|
|
|
this.play(soundId, { bypassThrottle: true });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-07 14:15:53 +00:00
|
|
|
this.unlock();
|
|
|
|
|
this.play(soundId, { bypassThrottle: true });
|
|
|
|
|
}
|
2026-05-05 08:13:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const notificationPlayer = new NotificationPlayer();
|
2026-05-07 14:15:53 +00:00
|
|
|
|
|
|
|
|
// Test-only accessor so Playwright can assert the singleton player's
|
2026-05-10 11:40:07 +00:00
|
|
|
// AudioContext state after a user gesture (the actual production path
|
|
|
|
|
// on non-iOS, not a freshly constructed test ctx). Gated on dev/test
|
|
|
|
|
// bundles to avoid leaking internals into production. Returns the
|
|
|
|
|
// engine name on iOS (where there is no AudioContext) so tests can
|
|
|
|
|
// still distinguish "unlocked" from "not yet unlocked".
|
2026-05-07 14:15:53 +00:00
|
|
|
if (typeof window !== "undefined" && import.meta.env.MODE !== "production") {
|
|
|
|
|
(window as unknown as {
|
|
|
|
|
__txosNotifCtxState?: () => string | null;
|
|
|
|
|
}).__txosNotifCtxState = () => {
|
2026-05-10 11:40:07 +00:00
|
|
|
const p = notificationPlayer as unknown as {
|
|
|
|
|
ctx: AudioContext | null;
|
|
|
|
|
isIosEngine: boolean | null;
|
|
|
|
|
unlocked: boolean;
|
|
|
|
|
};
|
|
|
|
|
if (p.isIosEngine) return p.unlocked ? "running" : "suspended";
|
2026-05-07 14:15:53 +00:00
|
|
|
return p.ctx?.state ?? null;
|
|
|
|
|
};
|
|
|
|
|
}
|