190 lines
6.2 KiB
TypeScript
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);
|
|
}
|