/** * Per-audio-file playback resume points for the floating player. * * A single store abstraction the audio context talks to, backed by one of * two persistence layers chosen at call time by auth state: * - signed-in → the BFF (`/users/me/audio-progress`), so positions roam * across devices; * - anonymous → the browser's IndexedDB, synced up to the BFF on login. * * The wire shape (`ProgressEntry`) is identical across the BFF, IndexedDB, * and the in-memory map the context keeps, so nothing needs remapping. The * BFF owns nothing about the schema beyond storing it; conflicts resolve * newest-`updated_at`-wins in SQL. */ const BFF_BASE = process.env.NEXT_PUBLIC_BFF_URL || "http://localhost:8080"; /** One saved resume point. `key` is the stable file identity (see trackKey). */ export type ProgressEntry = { key: string; position: number; // seconds (HTMLAudioElement.currentTime) duration?: number; // seconds, when known updated_at: string; // ISO-8601; drives newest-wins merge }; /** * Stable per-file key: the track URL with query string + hash stripped. * Audio URLs may be signed or cache-busted, so the origin+path is the part * that stays constant for a given file across sessions. */ export function trackKey(url: string): string { try { const base = typeof window !== "undefined" ? window.location.href : "http://local"; const u = new URL(url, base); return u.origin + u.pathname; } catch { return url.split(/[?#]/)[0]; } } // --------------------------------------------------------------------------- // IndexedDB (anonymous users) // --------------------------------------------------------------------------- const IDB_NAME = "spsays-audio-progress"; const IDB_STORE = "progress"; function openDB(): Promise { return new Promise((resolve, reject) => { const req = indexedDB.open(IDB_NAME, 1); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(IDB_STORE)) { db.createObjectStore(IDB_STORE, { keyPath: "key" }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function idbGetAll(): Promise { if (typeof indexedDB === "undefined") return []; try { const db = await openDB(); return await new Promise((resolve, reject) => { const tx = db.transaction(IDB_STORE, "readonly"); const req = tx.objectStore(IDB_STORE).getAll(); req.onsuccess = () => resolve((req.result as ProgressEntry[]) ?? []); req.onerror = () => reject(req.error); tx.oncomplete = () => db.close(); }); } catch { return []; } } async function idbPut(entry: ProgressEntry): Promise { if (typeof indexedDB === "undefined") return; try { const db = await openDB(); await new Promise((resolve, reject) => { const tx = db.transaction(IDB_STORE, "readwrite"); tx.objectStore(IDB_STORE).put(entry); tx.oncomplete = () => { db.close(); resolve(); }; tx.onerror = () => reject(tx.error); }); } catch { // best-effort } } async function idbDelete(key: string): Promise { if (typeof indexedDB === "undefined") return; try { const db = await openDB(); await new Promise((resolve, reject) => { const tx = db.transaction(IDB_STORE, "readwrite"); tx.objectStore(IDB_STORE).delete(key); tx.oncomplete = () => { db.close(); resolve(); }; tx.onerror = () => reject(tx.error); }); } catch { // best-effort } } // --------------------------------------------------------------------------- // BFF (signed-in users) // --------------------------------------------------------------------------- type Envelope = { success?: boolean; data?: T; error?: { message: string } }; async function call(path: string, init?: RequestInit): Promise { const res = await fetch(`${BFF_BASE}/api/v1${path}`, { credentials: "include", headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, ...init, }); if (res.status === 401 || res.status === 403) return null; if (!res.ok) throw new Error(`audio-progress ${path}: ${res.status}`); const body = (await res.json().catch(() => ({}))) as Envelope; return (body.data ?? null) as T | null; } async function remoteGetAll(): Promise { const data = await call<{ items: ProgressEntry[] }>("/users/me/audio-progress"); return data?.items ?? []; } async function remotePut( items: ProgressEntry[], opts?: { keepalive?: boolean }, ): Promise { if (items.length === 0) return; await call("/users/me/audio-progress", { method: "PUT", body: JSON.stringify({ items }), keepalive: opts?.keepalive, }); } async function remoteDelete(key: string): Promise { await call(`/users/me/audio-progress?key=${encodeURIComponent(key)}`, { method: "DELETE", }); } // --------------------------------------------------------------------------- // Public store API — dispatches to the right backend by auth state // --------------------------------------------------------------------------- /** All saved entries, to hydrate the player's in-memory map. */ export async function loadAllProgress(authed: boolean): Promise { return authed ? remoteGetAll() : idbGetAll(); } /** Persist one resume point. */ export async function saveProgress( entry: ProgressEntry, authed: boolean, opts?: { keepalive?: boolean }, ): Promise { if (authed) await remotePut([entry], opts); else await idbPut(entry); } /** Remove one resume point (e.g. when a track finishes). */ export async function removeProgress(key: string, authed: boolean): Promise { if (authed) await remoteDelete(key); else await idbDelete(key); } /** * Push every locally-stored (anonymous) entry up to the BFF. Called once on * the anon→signed-in transition; the server merges newest-wins so nothing a * device already has that is fresher gets clobbered. */ export async function syncLocalToRemote(): Promise { const local = await idbGetAll(); if (local.length) await remotePut(local); }