feat: audio track history
This commit is contained in:
@@ -9,6 +9,15 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
|
import {
|
||||||
|
trackKey,
|
||||||
|
loadAllProgress,
|
||||||
|
saveProgress,
|
||||||
|
removeProgress,
|
||||||
|
syncLocalToRemote,
|
||||||
|
type ProgressEntry,
|
||||||
|
} from "@/lib/audio-progress";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -64,6 +73,13 @@ const AudioPlayerContext = createContext<AudioPlayerContextValue | null>(null);
|
|||||||
const VOLUME_KEY = "audio-player-volume";
|
const VOLUME_KEY = "audio-player-volume";
|
||||||
const MUTED_KEY = "audio-player-muted";
|
const MUTED_KEY = "audio-player-muted";
|
||||||
|
|
||||||
|
// Playback-resume tuning. Persist at most once every SAVE_INTERVAL_MS while
|
||||||
|
// playing (discrete events — pause/close/tab-hide — always flush immediately).
|
||||||
|
// Positions within RESUME_END_GUARD_S of the end are treated as "finished":
|
||||||
|
// never saved and never resumed, so a completed track restarts from 0.
|
||||||
|
const SAVE_INTERVAL_MS = 5000;
|
||||||
|
const RESUME_END_GUARD_S = 5;
|
||||||
|
|
||||||
function readStoredVolume(): number {
|
function readStoredVolume(): number {
|
||||||
if (typeof window === "undefined") return 0.75;
|
if (typeof window === "undefined") return 0.75;
|
||||||
try {
|
try {
|
||||||
@@ -125,6 +141,99 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
const playlistRef = useRef(playlist);
|
const playlistRef = useRef(playlist);
|
||||||
const currentIndexRef = useRef(currentIndex);
|
const currentIndexRef = useRef(currentIndex);
|
||||||
|
|
||||||
|
// --- Playback-resume memory ---
|
||||||
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
const authedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
authedRef.current = !!user;
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// The track currently loaded into the <audio> element. Set synchronously
|
||||||
|
// wherever we swap `audio.src` so the event handlers (which run before the
|
||||||
|
// React state settles) always read the right file identity.
|
||||||
|
const currentTrackRef = useRef<AudioTrack | null>(null);
|
||||||
|
// key → last-known resume point. Read synchronously on load to seek; every
|
||||||
|
// save write-throughs here and to the persistent store.
|
||||||
|
const progressRef = useRef<Map<string, ProgressEntry>>(new Map());
|
||||||
|
const lastFlushRef = useRef(0);
|
||||||
|
// null until the first hydrate settles; then tracks the previous auth state
|
||||||
|
// so we can detect the anon→signed-in transition and sync IndexedDB up once.
|
||||||
|
const wasAuthedRef = useRef<boolean | null>(null);
|
||||||
|
|
||||||
|
// Hydrate the in-memory map from the active store (BFF when signed in,
|
||||||
|
// IndexedDB otherwise). On the anon→signed-in flip, push local entries up
|
||||||
|
// first (newest-wins merge) so anonymous progress follows the account.
|
||||||
|
useEffect(() => {
|
||||||
|
if (authLoading) return;
|
||||||
|
const authed = !!user;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (authed && wasAuthedRef.current === false) {
|
||||||
|
await syncLocalToRemote();
|
||||||
|
}
|
||||||
|
const entries = await loadAllProgress(authed);
|
||||||
|
if (cancelled) return;
|
||||||
|
const map = new Map<string, ProgressEntry>();
|
||||||
|
for (const e of entries) map.set(e.key, e);
|
||||||
|
progressRef.current = map;
|
||||||
|
} catch {
|
||||||
|
// best-effort — resume simply has no data if this fails
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) wasAuthedRef.current = authed;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [user, authLoading]);
|
||||||
|
|
||||||
|
// Record the current position into the in-memory map. Returns the entry to
|
||||||
|
// flush, or undefined when there's nothing worth saving (no track, position
|
||||||
|
// < 1s, or within the end-guard i.e. effectively finished).
|
||||||
|
const recordProgress = useCallback(
|
||||||
|
(
|
||||||
|
track: AudioTrack | null,
|
||||||
|
position: number,
|
||||||
|
dur: number,
|
||||||
|
): ProgressEntry | undefined => {
|
||||||
|
if (!track || !isFinite(position) || position < 1) return undefined;
|
||||||
|
const duration = isFinite(dur) && dur > 0 ? dur : undefined;
|
||||||
|
if (duration !== undefined && position >= duration - RESUME_END_GUARD_S) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const entry: ProgressEntry = {
|
||||||
|
key: trackKey(track.url),
|
||||||
|
position,
|
||||||
|
duration,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
progressRef.current.set(entry.key, entry);
|
||||||
|
return entry;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persist an entry to the active store (fire-and-forget).
|
||||||
|
const flush = useCallback(
|
||||||
|
(entry: ProgressEntry | undefined, opts?: { keepalive?: boolean }) => {
|
||||||
|
if (!entry) return;
|
||||||
|
void saveProgress(entry, authedRef.current, opts).catch(() => {});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Snapshot + persist the currently-playing track's position immediately.
|
||||||
|
// Used on pause, close, track-switch, and tab-hide.
|
||||||
|
const saveCurrent = useCallback(
|
||||||
|
(opts?: { keepalive?: boolean }) => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
flush(recordProgress(currentTrackRef.current, audio.currentTime, audio.duration), opts);
|
||||||
|
},
|
||||||
|
[recordProgress, flush],
|
||||||
|
);
|
||||||
|
|
||||||
// Sync refs in an effect to avoid "cannot access refs during render"
|
// Sync refs in an effect to avoid "cannot access refs during render"
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
playlistRef.current = playlist;
|
playlistRef.current = playlist;
|
||||||
@@ -147,10 +256,37 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
function handleTimeUpdate() {
|
function handleTimeUpdate() {
|
||||||
setCurrentTime(audio!.currentTime);
|
setCurrentTime(audio!.currentTime);
|
||||||
|
// Throttled resume-point save while playing.
|
||||||
|
const entry = recordProgress(
|
||||||
|
currentTrackRef.current,
|
||||||
|
audio!.currentTime,
|
||||||
|
audio!.duration,
|
||||||
|
);
|
||||||
|
const now = Date.now();
|
||||||
|
if (entry && now - lastFlushRef.current >= SAVE_INTERVAL_MS) {
|
||||||
|
lastFlushRef.current = now;
|
||||||
|
flush(entry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLoadedMetadata() {
|
function handleLoadedMetadata() {
|
||||||
setDuration(audio!.duration);
|
setDuration(audio!.duration);
|
||||||
|
// Resume: if this file has a saved position that isn't at/near the end,
|
||||||
|
// seek there before playback gets going.
|
||||||
|
const track = currentTrackRef.current;
|
||||||
|
if (!track) return;
|
||||||
|
const saved = progressRef.current.get(trackKey(track.url));
|
||||||
|
if (!saved) return;
|
||||||
|
const dur = audio!.duration;
|
||||||
|
const pos = saved.position;
|
||||||
|
if (pos > 1 && (!isFinite(dur) || pos < dur - RESUME_END_GUARD_S)) {
|
||||||
|
try {
|
||||||
|
audio!.currentTime = pos;
|
||||||
|
setCurrentTime(pos);
|
||||||
|
} catch {
|
||||||
|
// seeking may throw if the source isn't seekable yet; ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDurationChange() {
|
function handleDurationChange() {
|
||||||
@@ -178,12 +314,20 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
function handleEnded() {
|
function handleEnded() {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
// Finished — drop this file's resume point so it restarts from 0.
|
||||||
|
const finished = currentTrackRef.current;
|
||||||
|
if (finished) {
|
||||||
|
const k = trackKey(finished.url);
|
||||||
|
progressRef.current.delete(k);
|
||||||
|
void removeProgress(k, authedRef.current).catch(() => {});
|
||||||
|
}
|
||||||
// Auto-play next track if playlist has more
|
// Auto-play next track if playlist has more
|
||||||
const idx = currentIndexRef.current;
|
const idx = currentIndexRef.current;
|
||||||
const pl = playlistRef.current;
|
const pl = playlistRef.current;
|
||||||
if (idx >= 0 && idx < pl.length - 1) {
|
if (idx >= 0 && idx < pl.length - 1) {
|
||||||
const nextIdx = idx + 1;
|
const nextIdx = idx + 1;
|
||||||
const nextTrack = pl[nextIdx];
|
const nextTrack = pl[nextIdx];
|
||||||
|
currentTrackRef.current = nextTrack;
|
||||||
setCurrentIndex(nextIdx);
|
setCurrentIndex(nextIdx);
|
||||||
setCurrentTrack(nextTrack);
|
setCurrentTrack(nextTrack);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
@@ -228,14 +372,36 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
audio.removeEventListener("ended", handleEnded);
|
audio.removeEventListener("ended", handleEnded);
|
||||||
audio.removeEventListener("error", handleError);
|
audio.removeEventListener("error", handleError);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [recordProgress, flush]);
|
||||||
|
|
||||||
|
// Flush the current position when the tab is hidden or the page is being
|
||||||
|
// unloaded (browser/tab close, refresh, navigation) — the last line of
|
||||||
|
// defence on top of the throttled in-play saves. `keepalive` lets the
|
||||||
|
// request outlive the page for signed-in users.
|
||||||
|
useEffect(() => {
|
||||||
|
const flushNow = () => saveCurrent({ keepalive: true });
|
||||||
|
const onVisibility = () => {
|
||||||
|
if (document.visibilityState === "hidden") flushNow();
|
||||||
|
};
|
||||||
|
window.addEventListener("pagehide", flushNow);
|
||||||
|
document.addEventListener("visibilitychange", onVisibility);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pagehide", flushNow);
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
|
};
|
||||||
|
}, [saveCurrent]);
|
||||||
|
|
||||||
// --- Actions ---
|
// --- Actions ---
|
||||||
|
|
||||||
const play = useCallback((track: AudioTrack) => {
|
const play = useCallback(
|
||||||
|
(track: AudioTrack) => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
|
|
||||||
|
// Save the outgoing track's position before swapping sources.
|
||||||
|
saveCurrent();
|
||||||
|
currentTrackRef.current = track;
|
||||||
|
|
||||||
setCurrentTrack(track);
|
setCurrentTrack(track);
|
||||||
setPlaylist([track]);
|
setPlaylist([track]);
|
||||||
setCurrentIndex(0);
|
setCurrentIndex(0);
|
||||||
@@ -250,7 +416,9 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
}, []);
|
},
|
||||||
|
[saveCurrent],
|
||||||
|
);
|
||||||
|
|
||||||
const playPlaylist = useCallback(
|
const playPlaylist = useCallback(
|
||||||
(tracks: AudioTrack[], startIndex: number = 0) => {
|
(tracks: AudioTrack[], startIndex: number = 0) => {
|
||||||
@@ -260,6 +428,9 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
const idx = Math.max(0, Math.min(startIndex, tracks.length - 1));
|
const idx = Math.max(0, Math.min(startIndex, tracks.length - 1));
|
||||||
const track = tracks[idx];
|
const track = tracks[idx];
|
||||||
|
|
||||||
|
saveCurrent();
|
||||||
|
currentTrackRef.current = track;
|
||||||
|
|
||||||
setPlaylist(tracks);
|
setPlaylist(tracks);
|
||||||
setCurrentIndex(idx);
|
setCurrentIndex(idx);
|
||||||
setCurrentTrack(track);
|
setCurrentTrack(track);
|
||||||
@@ -275,12 +446,13 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[],
|
[saveCurrent],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
const pause = useCallback(() => {
|
||||||
audioRef.current?.pause();
|
audioRef.current?.pause();
|
||||||
}, []);
|
saveCurrent();
|
||||||
|
}, [saveCurrent]);
|
||||||
|
|
||||||
const resume = useCallback(() => {
|
const resume = useCallback(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
@@ -301,16 +473,20 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
|
saveCurrent();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [saveCurrent]);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (audio) {
|
if (audio) {
|
||||||
|
// Capture the resume point before we tear the source down.
|
||||||
|
saveCurrent({ keepalive: true });
|
||||||
audio.pause();
|
audio.pause();
|
||||||
audio.removeAttribute("src");
|
audio.removeAttribute("src");
|
||||||
audio.load();
|
audio.load();
|
||||||
}
|
}
|
||||||
|
currentTrackRef.current = null;
|
||||||
setCurrentTrack(null);
|
setCurrentTrack(null);
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
@@ -318,7 +494,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setPlaylist([]);
|
setPlaylist([]);
|
||||||
setCurrentIndex(-1);
|
setCurrentIndex(-1);
|
||||||
}, []);
|
}, [saveCurrent]);
|
||||||
|
|
||||||
const seekTo = useCallback((time: number) => {
|
const seekTo = useCallback((time: number) => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
@@ -360,6 +536,9 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
const nextIdx = idx + 1;
|
const nextIdx = idx + 1;
|
||||||
const nextTrack = pl[nextIdx];
|
const nextTrack = pl[nextIdx];
|
||||||
|
|
||||||
|
saveCurrent();
|
||||||
|
currentTrackRef.current = nextTrack;
|
||||||
|
|
||||||
setCurrentIndex(nextIdx);
|
setCurrentIndex(nextIdx);
|
||||||
setCurrentTrack(nextTrack);
|
setCurrentTrack(nextTrack);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
@@ -373,7 +552,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
}, []);
|
}, [saveCurrent]);
|
||||||
|
|
||||||
const previous = useCallback(() => {
|
const previous = useCallback(() => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
@@ -399,6 +578,9 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
const prevIdx = idx - 1;
|
const prevIdx = idx - 1;
|
||||||
const prevTrack = pl[prevIdx];
|
const prevTrack = pl[prevIdx];
|
||||||
|
|
||||||
|
saveCurrent();
|
||||||
|
currentTrackRef.current = prevTrack;
|
||||||
|
|
||||||
setCurrentIndex(prevIdx);
|
setCurrentIndex(prevIdx);
|
||||||
setCurrentTrack(prevTrack);
|
setCurrentTrack(prevTrack);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
@@ -412,7 +594,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
}, []);
|
}, [saveCurrent]);
|
||||||
|
|
||||||
// --- Context value ---
|
// --- Context value ---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user