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
|
|
|
|
|
* policy). The App listens for this and shows a one-time toast hint.
|
|
|
|
|
*/
|
|
|
|
|
export const AUTOPLAY_BLOCKED_EVENT = "txos:notification-autoplay-blocked";
|
2026-05-05 08:13:38 +00:00
|
|
|
|
|
|
|
|
class NotificationPlayer {
|
2026-05-05 08:20:54 +00:00
|
|
|
private cache = new Map<NotificationSoundId, HTMLAudioElement>();
|
2026-05-05 08:13:38 +00:00
|
|
|
private unlocked = false;
|
|
|
|
|
private lastPlayAt = 0;
|
2026-05-05 08:20:54 +00:00
|
|
|
private hintDispatched = false;
|
2026-05-05 08:13:38 +00:00
|
|
|
|
2026-05-05 08:20:54 +00:00
|
|
|
isUnlocked(): boolean {
|
|
|
|
|
return this.unlocked;
|
2026-05-05 08:13:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Browsers require a user gesture before audio can play. Call this once
|
2026-05-05 08:20:54 +00:00
|
|
|
* the user interacts with the app to "unlock" the audio elements.
|
2026-05-05 08:13:38 +00:00
|
|
|
*/
|
|
|
|
|
unlock(): void {
|
|
|
|
|
if (this.unlocked) return;
|
2026-05-05 08:20:54 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2026-05-05 08:13:38 +00:00
|
|
|
}
|
2026-05-05 08:20:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private dispatchHint() {
|
|
|
|
|
if (this.hintDispatched) return;
|
|
|
|
|
this.hintDispatched = true;
|
|
|
|
|
if (typeof window !== "undefined") {
|
|
|
|
|
window.dispatchEvent(new CustomEvent(AUTOPLAY_BLOCKED_EVENT));
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
play(
|
|
|
|
|
soundId: NotificationSoundId,
|
2026-05-05 08:24:27 +00:00
|
|
|
opts: { vibrate?: boolean } = {},
|
2026-05-05 08:13:38 +00:00
|
|
|
): void {
|
2026-05-05 12:30:00 +00:00
|
|
|
// 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.
|
2026-05-05 08:13:38 +00:00
|
|
|
const now =
|
|
|
|
|
typeof performance !== "undefined" ? performance.now() : Date.now();
|
2026-05-05 12:30:00 +00:00
|
|
|
if (now - this.lastPlayAt < 3000) return;
|
2026-05-05 08:13:38 +00:00
|
|
|
this.lastPlayAt = now;
|
|
|
|
|
|
2026-05-05 08:20:54 +00:00
|
|
|
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 */
|
|
|
|
|
}
|
2026-05-05 08:24:27 +00:00
|
|
|
// 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;
|
2026-05-05 08:20:54 +00:00
|
|
|
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();
|
|
|
|
|
});
|
2026-05-05 08:13:38 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (opts.vibrate && typeof navigator !== "undefined" && navigator.vibrate) {
|
|
|
|
|
try {
|
|
|
|
|
navigator.vibrate([60, 40, 60]);
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const notificationPlayer = new NotificationPlayer();
|