1import type { ClientSettings } from "@t3tools/contracts/settings";
2
3import completionUrl from "./assets/notification-completion.mp3";
4import inputUrl from "./assets/notification-input.mp3";
5
6type NotificationMode = ClientSettings["notificationMode"];
7export const NOTIFICATION_MODE_LABELS = {
8 off: "Off",
9 notifications: "Notifications only",
10 sound: "Sound only",
11 "notifications-and-sound": "Notifications with sound",
12} satisfies Record<NotificationMode, string>;
13
14export function hasNotificationSound(mode: NotificationMode) {
15 return mode === "sound" || mode === "notifications-and-sound";
16}
17
18export function hasDesktopNotifications(mode: NotificationMode) {
19 return mode === "notifications" || mode === "notifications-and-sound";
20}
21
22let originalFavicon: HTMLLinkElement | undefined;
23let badgeFavicon: HTMLLinkElement | undefined;
24
25export function setNotificationBadge(count: number) {
26 const bridge = window.desktopBridge;
27 let image: string | null = null;
28 if (count > 0 && (!bridge || bridge.getClientPlatform?.() === "win32")) {
29 const canvas = document.createElement("canvas");
30 canvas.width = canvas.height = 64;
31 const context = canvas.getContext("2d");
32 if (context) {
33 context.fillStyle = "#e5484d";
34 context.beginPath();
35 context.arc(32, 32, 28, 0, Math.PI * 2);
36 context.fill();
37 context.fillStyle = "white";
38 context.font = `600 ${count > 9 ? 30 : 40}px "Segoe UI", sans-serif`;
39 context.textAlign = "center";
40 context.textBaseline = "middle";
41 context.fillText(count > 9 ? "9+" : String(count), 32, 34);
42 image = canvas.toDataURL("image/png");
43 }
44 }
45 if (!bridge) {
46 if (image) {
47 if (!badgeFavicon) {
48 originalFavicon = document.querySelector<HTMLLinkElement>('link[rel="icon"]') ?? undefined;
49 badgeFavicon = document.createElement("link");
50 badgeFavicon.rel = "icon";
51 badgeFavicon.type = "image/png";
52 badgeFavicon.sizes.value = "64x64";
53 originalFavicon?.remove();
54 document.head.append(badgeFavicon);
55 }
56 badgeFavicon.href = image;
57 } else if (badgeFavicon) {
58 badgeFavicon.remove();
59 badgeFavicon = undefined;
60 if (originalFavicon) document.head.append(originalFavicon);
61 originalFavicon = undefined;
62 }
63 }
64 void bridge?.setNotificationBadge?.({ count, image }).catch(() => undefined);
65}
66
67let audioContext: AudioContext | undefined;
68const buffers = new Map<string, Promise<AudioBuffer>>();
69
70/** Called from a gesture so browsers allow later background playback. */
71export function unlockNotificationAudio() {
72 audioContext ??= new AudioContext();
73 void audioContext.resume().catch(() => undefined);
74}
75
76export async function playNotificationSound(
77 kind: "completion" | "input",
78 shouldPlay: () => boolean,
79) {
80 if (!audioContext || audioContext.state !== "running") return;
81 const context = audioContext;
82 const url = kind === "completion" ? completionUrl : inputUrl;
83 try {
84 let buffer = buffers.get(url);
85 if (!buffer) {
86 buffer = fetch(url)
87 .then((response) => response.arrayBuffer())
88 .then((data) => context.decodeAudioData(data));
89 buffers.set(url, buffer);
90 }
91 const decoded = await buffer;
92 if (!shouldPlay() || context.state !== "running") return;
93 const source = context.createBufferSource();
94 source.buffer = decoded;
95 source.connect(context.destination);
96 source.start();
97 } catch {
98 buffers.delete(url);
99 }
100}