feat: audio track history

This commit is contained in:
gosangam
2026-07-02 19:23:22 +05:30
parent be28585294
commit 0b67a1142f
2 changed files with 395 additions and 24 deletions
+206 -24
View File
@@ -9,6 +9,15 @@ import {
useEffect,
type ReactNode,
} from "react";
import { useAuth } from "@/components/auth/auth-provider";
import {
trackKey,
loadAllProgress,
saveProgress,
removeProgress,
syncLocalToRemote,
type ProgressEntry,
} from "@/lib/audio-progress";
// ---------------------------------------------------------------------------
// Types
@@ -64,6 +73,13 @@ const AudioPlayerContext = createContext<AudioPlayerContextValue | null>(null);
const VOLUME_KEY = "audio-player-volume";
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 {
if (typeof window === "undefined") return 0.75;
try {
@@ -125,6 +141,99 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
const playlistRef = useRef(playlist);
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"
useEffect(() => {
playlistRef.current = playlist;
@@ -147,10 +256,37 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
function handleTimeUpdate() {
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() {
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() {
@@ -178,12 +314,20 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
function handleEnded() {
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
const idx = currentIndexRef.current;
const pl = playlistRef.current;
if (idx >= 0 && idx < pl.length - 1) {
const nextIdx = idx + 1;
const nextTrack = pl[nextIdx];
currentTrackRef.current = nextTrack;
setCurrentIndex(nextIdx);
setCurrentTrack(nextTrack);
setCurrentTime(0);
@@ -228,29 +372,53 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
audio.removeEventListener("ended", handleEnded);
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 ---
const play = useCallback((track: AudioTrack) => {
const audio = audioRef.current;
if (!audio) return;
const play = useCallback(
(track: AudioTrack) => {
const audio = audioRef.current;
if (!audio) return;
setCurrentTrack(track);
setPlaylist([track]);
setCurrentIndex(0);
setCurrentTime(0);
setDuration(0);
setIsLoading(true);
// Save the outgoing track's position before swapping sources.
saveCurrent();
currentTrackRef.current = track;
audio.src = track.url;
audio.load();
audio.play().catch((err) => {
console.error("[AudioPlayer] Play failed:", err);
setIsPlaying(false);
setIsLoading(false);
});
}, []);
setCurrentTrack(track);
setPlaylist([track]);
setCurrentIndex(0);
setCurrentTime(0);
setDuration(0);
setIsLoading(true);
audio.src = track.url;
audio.load();
audio.play().catch((err) => {
console.error("[AudioPlayer] Play failed:", err);
setIsPlaying(false);
setIsLoading(false);
});
},
[saveCurrent],
);
const playPlaylist = useCallback(
(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 track = tracks[idx];
saveCurrent();
currentTrackRef.current = track;
setPlaylist(tracks);
setCurrentIndex(idx);
setCurrentTrack(track);
@@ -275,12 +446,13 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
setIsLoading(false);
});
},
[],
[saveCurrent],
);
const pause = useCallback(() => {
audioRef.current?.pause();
}, []);
saveCurrent();
}, [saveCurrent]);
const resume = useCallback(() => {
const audio = audioRef.current;
@@ -301,16 +473,20 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
});
} else {
audio.pause();
saveCurrent();
}
}, []);
}, [saveCurrent]);
const stop = useCallback(() => {
const audio = audioRef.current;
if (audio) {
// Capture the resume point before we tear the source down.
saveCurrent({ keepalive: true });
audio.pause();
audio.removeAttribute("src");
audio.load();
}
currentTrackRef.current = null;
setCurrentTrack(null);
setIsPlaying(false);
setCurrentTime(0);
@@ -318,7 +494,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
setIsLoading(false);
setPlaylist([]);
setCurrentIndex(-1);
}, []);
}, [saveCurrent]);
const seekTo = useCallback((time: number) => {
const audio = audioRef.current;
@@ -360,6 +536,9 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
const nextIdx = idx + 1;
const nextTrack = pl[nextIdx];
saveCurrent();
currentTrackRef.current = nextTrack;
setCurrentIndex(nextIdx);
setCurrentTrack(nextTrack);
setCurrentTime(0);
@@ -373,7 +552,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
setIsPlaying(false);
setIsLoading(false);
});
}, []);
}, [saveCurrent]);
const previous = useCallback(() => {
const audio = audioRef.current;
@@ -399,6 +578,9 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
const prevIdx = idx - 1;
const prevTrack = pl[prevIdx];
saveCurrent();
currentTrackRef.current = prevTrack;
setCurrentIndex(prevIdx);
setCurrentTrack(prevTrack);
setCurrentTime(0);
@@ -412,7 +594,7 @@ export function AudioPlayerProvider({ children }: { children: ReactNode }) {
setIsPlaying(false);
setIsLoading(false);
});
}, []);
}, [saveCurrent]);
// --- Context value ---