// IndexedDB-backed custom notification sound. Single-row object store — // user either has one uploaded clip overriding the built-in two-tone // chime, or doesn't and we fall back to the synthesized tone. // // Separate from `soundboardStorage.ts` because the soundboard is a // multi-entry user library with categories + hotkeys + per-clip gain; // this module only needs "one blob, replace-on-upload, wipe-on-reset". export interface NotificationSoundEntry { filename: string; mime: string; size: number; uploadedAt: number; } interface StoredNotificationSound extends NotificationSoundEntry { blob: Blob; } // 1 MB cap — notification sounds should be short (<5s), and we don't // want an IDB quota bust from a 200MB MP3. export const MAX_NOTIFICATION_SOUND_BYTES = 1 * 1024 * 1024; // --- Change observer — the notificationSound module subscribes to // invalidate its cached AudioBuffer when upload/reset happens, so the // next ping uses the fresh sound without an app restart. type Listener = () => void; const listeners = new Set(); export function subscribeNotificationSoundChanges(l: Listener): () => void { listeners.add(l); return () => { listeners.delete(l); }; } function notifyChange(): void { for (const l of listeners) { try { l(); } catch (err: unknown) { console.warn('notification-sound change listener threw', err); } } } const DB_NAME = 'netralax-notification'; const DB_VERSION = 1; const STORE = 'sound'; const KEY = 'current'; let dbPromise: Promise | null = null; function openDb(): Promise { if (dbPromise) return dbPromise; dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(STORE)) { db.createObjectStore(STORE); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error ?? new Error('indexedDB open failed')); }); return dbPromise; } export async function getCustomNotificationSound(): Promise< { blob: Blob; entry: NotificationSoundEntry } | null > { const db = await openDb(); return new Promise((resolve, reject) => { const t = db.transaction(STORE, 'readonly'); const req = t.objectStore(STORE).get(KEY); req.onsuccess = () => { const stored = req.result as StoredNotificationSound | undefined; if (!stored) { resolve(null); return; } const { blob, ...entry } = stored; resolve({ blob, entry }); }; req.onerror = () => reject(req.error); t.onerror = () => reject(t.error); }); } export async function getCustomNotificationSoundMeta(): Promise { const res = await getCustomNotificationSound(); return res ? res.entry : null; } export async function setCustomNotificationSound(file: File): Promise { if (file.size === 0) throw new Error('empty_file'); if (file.size > MAX_NOTIFICATION_SOUND_BYTES) throw new Error('sound_too_large'); const mime = file.type || 'application/octet-stream'; if (!mime.startsWith('audio/')) throw new Error('sound_not_audio'); const stored: StoredNotificationSound = { filename: file.name || 'notification.audio', mime, size: file.size, uploadedAt: Date.now(), blob: file, }; const db = await openDb(); await new Promise((resolve, reject) => { const t = db.transaction(STORE, 'readwrite'); const req = t.objectStore(STORE).put(stored, KEY); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); t.onerror = () => reject(t.error); }); notifyChange(); const { blob: _blob, ...entry } = stored; return entry; } export async function clearCustomNotificationSound(): Promise { const db = await openDb(); await new Promise((resolve, reject) => { const t = db.transaction(STORE, 'readwrite'); const req = t.objectStore(STORE).delete(KEY); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); t.onerror = () => reject(t.error); }); notifyChange(); }