Files
TX/artifacts/tx-os/src/lib/notification-sounds.ts
T

122 lines
3.7 KiB
TypeScript
Raw Normal View History

import type { NotificationSoundId } from "@workspace/api-client-react";
import {
NOTIFICATION_SOUND_MANIFEST,
SOUND_BY_ID,
} from "@/lib/notification-sound-manifest";
export const ALL_SOUND_IDS: ReadonlyArray<NotificationSoundId> =
NOTIFICATION_SOUND_MANIFEST.map((s) => s.id);
/**
* Custom DOM event the player dispatches when a play attempt is denied
* because the user hasn't yet interacted with the page (browser autoplay
* policy). The App listens for this and shows a one-time toast hint.
*/
export const AUTOPLAY_BLOCKED_EVENT = "txos:notification-autoplay-blocked";
class NotificationPlayer {
private cache = new Map<NotificationSoundId, HTMLAudioElement>();
private unlocked = false;
private lastPlayAt = 0;
private hintDispatched = false;
isUnlocked(): boolean {
return this.unlocked;
}
/**
* Browsers require a user gesture before audio can play. Call this once
* the user interacts with the app to "unlock" the audio elements.
*/
unlock(): void {
if (this.unlocked) return;
this.unlocked = true;
// Touch each cached element with a silent play()/pause() so iOS/Safari
// marks them as user-initiated. Pre-warm cache for snappier playback.
for (const entry of NOTIFICATION_SOUND_MANIFEST) {
let el = this.cache.get(entry.id);
if (!el) {
el = new Audio(entry.url);
el.preload = "auto";
this.cache.set(entry.id, el);
}
el.muted = true;
const p = el.play();
if (p && typeof p.then === "function") {
p.then(() => {
el!.pause();
el!.currentTime = 0;
el!.muted = false;
}).catch(() => {
el!.muted = false;
});
} else {
el.muted = false;
}
}
}
private dispatchHint() {
if (this.hintDispatched) return;
this.hintDispatched = true;
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(AUTOPLAY_BLOCKED_EVENT));
}
// 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);
}
play(
soundId: NotificationSoundId,
opts: { vibrate?: boolean } = {},
): void {
// Throttle: at most one sound every 3s to coalesce bursts of
// notifications (e.g. several orders arriving in quick succession)
// into a single chime instead of a string of overlapping beeps.
const now =
typeof performance !== "undefined" ? performance.now() : Date.now();
if (now - this.lastPlayAt < 3000) return;
this.lastPlayAt = now;
const entry = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding");
if (!entry) return;
let el = this.cache.get(entry.id);
if (!el) {
el = new Audio(entry.url);
el.preload = "auto";
this.cache.set(entry.id, el);
}
try {
el.currentTime = 0;
} catch {
/* some browsers throw if not loaded */
}
// Use the device/system volume — explicit volume control is out of
// scope. Setting el.volume = 1 ensures we don't attenuate the OS
// mixer level the user already controls.
el.volume = 1;
const promise = el.play();
if (promise && typeof promise.then === "function") {
promise.catch(() => {
// Most likely autoplay blocked — surface a hint exactly once
// per cool-down so the user knows to click anywhere to enable.
if (!this.unlocked) this.dispatchHint();
});
}
if (opts.vibrate && typeof navigator !== "undefined" && navigator.vibrate) {
try {
navigator.vibrate([60, 40, 60]);
} catch {
/* ignore */
}
}
}
}
export const notificationPlayer = new NotificationPlayer();