Files
spsays-training/lib/audio-progress.ts
T
2026-07-02 19:23:22 +05:30

190 lines
6.2 KiB
TypeScript

/**
* 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<IDBDatabase> {
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<ProgressEntry[]> {
if (typeof indexedDB === "undefined") return [];
try {
const db = await openDB();
return await new Promise<ProgressEntry[]>((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<void> {
if (typeof indexedDB === "undefined") return;
try {
const db = await openDB();
await new Promise<void>((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<void> {
if (typeof indexedDB === "undefined") return;
try {
const db = await openDB();
await new Promise<void>((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<T> = { success?: boolean; data?: T; error?: { message: string } };
async function call<T>(path: string, init?: RequestInit): Promise<T | null> {
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<T>;
return (body.data ?? null) as T | null;
}
async function remoteGetAll(): Promise<ProgressEntry[]> {
const data = await call<{ items: ProgressEntry[] }>("/users/me/audio-progress");
return data?.items ?? [];
}
async function remotePut(
items: ProgressEntry[],
opts?: { keepalive?: boolean },
): Promise<void> {
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<void> {
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<ProgressEntry[]> {
return authed ? remoteGetAll() : idbGetAll();
}
/** Persist one resume point. */
export async function saveProgress(
entry: ProgressEntry,
authed: boolean,
opts?: { keepalive?: boolean },
): Promise<void> {
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<void> {
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<void> {
const local = await idbGetAll();
if (local.length) await remotePut(local);
}