// IndexedDB-backed storage for the user's custom incoming ringtone. // // Only one slot is exposed (`incoming`) — outgoing ringtone stays tied to the // bundled oscillator pattern. The blob is stored alongside its mime type + // original filename so playback + UI can show what's currently in use. const DB_NAME = 'netralax-ringtones'; const DB_VERSION = 1; const STORE_NAME = 'ringtones'; const SLOT_INCOMING = 'incoming'; export interface StoredRingtone { blob: Blob; mime: string; filename: string; updatedAt: number; } export const MAX_RINGTONE_BYTES = 2 * 1024 * 1024; // 2 MB cap export const SUPPORTED_RINGTONE_MIMES = [ 'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/x-wav', 'audio/ogg', 'audio/webm', 'audio/mp4', 'audio/aac', 'audio/x-m4a', 'audio/m4a', ]; 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_NAME)) { db.createObjectStore(STORE_NAME); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error ?? new Error('indexedDB open failed')); }); return dbPromise; } function runTx( mode: IDBTransactionMode, fn: (store: IDBObjectStore) => IDBRequest | void, ): Promise { return openDb().then( (db) => new Promise((resolve, reject) => { const tx = db.transaction(STORE_NAME, mode); const store = tx.objectStore(STORE_NAME); let result: T | undefined = undefined; const maybeReq = fn(store); if (maybeReq) { maybeReq.onsuccess = () => { result = maybeReq.result; }; maybeReq.onerror = () => reject(maybeReq.error); } tx.oncomplete = () => resolve(result); tx.onerror = () => reject(tx.error); tx.onabort = () => reject(tx.error); }), ); } export async function saveIncomingRingtone(file: File): Promise { if (file.size === 0) throw new Error('empty file'); if (file.size > MAX_RINGTONE_BYTES) { throw new Error('ringtone_too_large'); } const mime = file.type || 'application/octet-stream'; if (!mime.startsWith('audio/')) { throw new Error('ringtone_not_audio'); } const stored: StoredRingtone = { blob: file, mime, filename: file.name, updatedAt: Date.now(), }; await runTx('readwrite', (store) => store.put(stored, SLOT_INCOMING)); } export async function getIncomingRingtone(): Promise { const result = await runTx('readonly', (store) => store.get(SLOT_INCOMING) as IDBRequest, ); return result ?? null; } export async function clearIncomingRingtone(): Promise { await runTx('readwrite', (store) => store.delete(SLOT_INCOMING)); } export async function hasIncomingRingtone(): Promise { const cur = await getIncomingRingtone(); return cur !== null; }