680 lines
20 KiB
TypeScript
680 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
createContext,
|
|
useContext,
|
|
useRef,
|
|
useState,
|
|
useCallback,
|
|
useEffect,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { useAuth } from "@/components/auth/auth-provider";
|
|
import {
|
|
trackKey,
|
|
loadAllProgress,
|
|
saveProgress,
|
|
removeProgress,
|
|
syncLocalToRemote,
|
|
type ProgressEntry,
|
|
} from "@/lib/audio-progress";
|
|
import {
|
|
primeOfflineAudio,
|
|
offlineAudioURL,
|
|
releaseOfflineAudio,
|
|
} from "@/lib/offline";
|
|
|
|
/**
|
|
* Resolve the media source for a track: prefer a saved offline object URL
|
|
* (works with no network + full seeking) and fall back to the remote URL.
|
|
*/
|
|
function resolveSrc(url: string): string {
|
|
return offlineAudioURL(trackKey(url)) ?? url;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type AudioTrack = {
|
|
url: string;
|
|
title: string;
|
|
subtitle?: string;
|
|
coverImage?: string;
|
|
sourceUrl?: string;
|
|
type?: "lecture" | "conversation" | "kirtan" | "music" | string;
|
|
};
|
|
|
|
type AudioPlayerState = {
|
|
currentTrack: AudioTrack | null;
|
|
isPlaying: boolean;
|
|
currentTime: number;
|
|
duration: number;
|
|
volume: number;
|
|
isMuted: boolean;
|
|
isLoading: boolean;
|
|
playlist: AudioTrack[];
|
|
currentIndex: number;
|
|
};
|
|
|
|
type AudioPlayerActions = {
|
|
play: (track: AudioTrack) => void;
|
|
playPlaylist: (tracks: AudioTrack[], startIndex?: number) => void;
|
|
/**
|
|
* Gives the track that's already playing a queue to sit in, without
|
|
* restarting it — for a queue that arrives after playback began. Ignored
|
|
* if the current track isn't in it (the user moved on meanwhile).
|
|
*/
|
|
attachQueue: (tracks: AudioTrack[]) => void;
|
|
pause: () => void;
|
|
resume: () => void;
|
|
togglePlayPause: () => void;
|
|
stop: () => void;
|
|
seekTo: (time: number) => void;
|
|
setVolume: (vol: number) => void;
|
|
toggleMute: () => void;
|
|
next: () => void;
|
|
previous: () => void;
|
|
};
|
|
|
|
type AudioPlayerContextValue = AudioPlayerState & AudioPlayerActions;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Context
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const AudioPlayerContext = createContext<AudioPlayerContextValue | null>(null);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// localStorage helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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 {
|
|
const raw = localStorage.getItem(VOLUME_KEY);
|
|
if (raw !== null) {
|
|
const v = parseFloat(raw);
|
|
if (isFinite(v) && v >= 0 && v <= 1) return v;
|
|
}
|
|
} catch {
|
|
// localStorage unavailable
|
|
}
|
|
return 0.75;
|
|
}
|
|
|
|
function readStoredMuted(): boolean {
|
|
if (typeof window === "undefined") return false;
|
|
try {
|
|
return localStorage.getItem(MUTED_KEY) === "true";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function persistVolume(vol: number) {
|
|
try {
|
|
localStorage.setItem(VOLUME_KEY, String(vol));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function persistMuted(muted: boolean) {
|
|
try {
|
|
localStorage.setItem(MUTED_KEY, String(muted));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Provider
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function AudioPlayerProvider({ children }: { children: ReactNode }) {
|
|
const audioRef = useRef<HTMLAudioElement>(null);
|
|
|
|
// --- State ---
|
|
const [currentTrack, setCurrentTrack] = useState<AudioTrack | null>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
const [volume, setVolumeState] = useState(readStoredVolume);
|
|
const [isMuted, setIsMuted] = useState(readStoredMuted);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [playlist, setPlaylist] = useState<AudioTrack[]>([]);
|
|
const [currentIndex, setCurrentIndex] = useState(-1);
|
|
|
|
// Keep refs for values accessed in callbacks to avoid stale closures
|
|
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;
|
|
}, [playlist]);
|
|
useEffect(() => {
|
|
currentIndexRef.current = currentIndex;
|
|
}, [currentIndex]);
|
|
|
|
// Build object URLs for any audio saved offline so `resolveSrc` can prefer
|
|
// them synchronously at play time (offline playback + full seeking).
|
|
useEffect(() => {
|
|
void primeOfflineAudio();
|
|
return () => releaseOfflineAudio();
|
|
}, []);
|
|
|
|
// --- Sync volume/muted to the <audio> element ---
|
|
useEffect(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
audio.volume = isMuted ? 0 : volume;
|
|
}, [volume, isMuted]);
|
|
|
|
// --- Audio event listeners ---
|
|
useEffect(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
|
|
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() {
|
|
if (isFinite(audio!.duration)) {
|
|
setDuration(audio!.duration);
|
|
}
|
|
}
|
|
|
|
function handleCanPlay() {
|
|
setIsLoading(false);
|
|
}
|
|
|
|
function handleWaiting() {
|
|
setIsLoading(true);
|
|
}
|
|
|
|
function handlePlaying() {
|
|
setIsPlaying(true);
|
|
setIsLoading(false);
|
|
}
|
|
|
|
function handlePause() {
|
|
setIsPlaying(false);
|
|
}
|
|
|
|
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);
|
|
setDuration(0);
|
|
setIsLoading(true);
|
|
if (audio) {
|
|
audio.src = resolveSrc(nextTrack.url);
|
|
audio.load();
|
|
audio.play().catch((err) => {
|
|
console.error("[AudioPlayer] Auto-play next failed:", err);
|
|
setIsPlaying(false);
|
|
setIsLoading(false);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleError() {
|
|
console.error("[AudioPlayer] Audio error:", audio!.error);
|
|
setIsPlaying(false);
|
|
setIsLoading(false);
|
|
}
|
|
|
|
audio.addEventListener("timeupdate", handleTimeUpdate);
|
|
audio.addEventListener("loadedmetadata", handleLoadedMetadata);
|
|
audio.addEventListener("durationchange", handleDurationChange);
|
|
audio.addEventListener("canplay", handleCanPlay);
|
|
audio.addEventListener("waiting", handleWaiting);
|
|
audio.addEventListener("playing", handlePlaying);
|
|
audio.addEventListener("pause", handlePause);
|
|
audio.addEventListener("ended", handleEnded);
|
|
audio.addEventListener("error", handleError);
|
|
|
|
return () => {
|
|
audio.removeEventListener("timeupdate", handleTimeUpdate);
|
|
audio.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
|
audio.removeEventListener("durationchange", handleDurationChange);
|
|
audio.removeEventListener("canplay", handleCanPlay);
|
|
audio.removeEventListener("waiting", handleWaiting);
|
|
audio.removeEventListener("playing", handlePlaying);
|
|
audio.removeEventListener("pause", handlePause);
|
|
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;
|
|
|
|
// Save the outgoing track's position before swapping sources.
|
|
saveCurrent();
|
|
currentTrackRef.current = track;
|
|
|
|
setCurrentTrack(track);
|
|
setPlaylist([track]);
|
|
setCurrentIndex(0);
|
|
setCurrentTime(0);
|
|
setDuration(0);
|
|
setIsLoading(true);
|
|
|
|
audio.src = resolveSrc(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) => {
|
|
const audio = audioRef.current;
|
|
if (!audio || tracks.length === 0) return;
|
|
|
|
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);
|
|
setCurrentTime(0);
|
|
setDuration(0);
|
|
setIsLoading(true);
|
|
|
|
audio.src = resolveSrc(track.url);
|
|
audio.load();
|
|
audio.play().catch((err) => {
|
|
console.error("[AudioPlayer] PlayPlaylist failed:", err);
|
|
setIsPlaying(false);
|
|
setIsLoading(false);
|
|
});
|
|
},
|
|
[saveCurrent],
|
|
);
|
|
|
|
const attachQueue = useCallback((tracks: AudioTrack[]) => {
|
|
const current = currentTrackRef.current;
|
|
const idx = current ? tracks.findIndex((t) => t.url === current.url) : -1;
|
|
if (idx < 0) return;
|
|
setPlaylist(tracks);
|
|
setCurrentIndex(idx);
|
|
}, []);
|
|
|
|
const pause = useCallback(() => {
|
|
audioRef.current?.pause();
|
|
saveCurrent();
|
|
}, [saveCurrent]);
|
|
|
|
const resume = useCallback(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio || !audio.src) return;
|
|
audio.play().catch((err) => {
|
|
console.error("[AudioPlayer] Resume failed:", err);
|
|
setIsPlaying(false);
|
|
});
|
|
}, []);
|
|
|
|
const togglePlayPause = useCallback(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio || !audio.src) return;
|
|
if (audio.paused) {
|
|
audio.play().catch((err) => {
|
|
console.error("[AudioPlayer] Toggle play failed:", err);
|
|
setIsPlaying(false);
|
|
});
|
|
} 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);
|
|
setDuration(0);
|
|
setIsLoading(false);
|
|
setPlaylist([]);
|
|
setCurrentIndex(-1);
|
|
}, [saveCurrent]);
|
|
|
|
const seekTo = useCallback((time: number) => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
const clamped = Math.max(0, Math.min(time, audio.duration || 0));
|
|
audio.currentTime = clamped;
|
|
setCurrentTime(clamped);
|
|
}, []);
|
|
|
|
const setVolume = useCallback(
|
|
(vol: number) => {
|
|
const clamped = Math.max(0, Math.min(1, vol));
|
|
setVolumeState(clamped);
|
|
persistVolume(clamped);
|
|
// If setting volume while muted, unmute
|
|
if (isMuted && clamped > 0) {
|
|
setIsMuted(false);
|
|
persistMuted(false);
|
|
}
|
|
},
|
|
[isMuted],
|
|
);
|
|
|
|
const toggleMute = useCallback(() => {
|
|
setIsMuted((prev) => {
|
|
const next = !prev;
|
|
persistMuted(next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const next = useCallback(() => {
|
|
const audio = audioRef.current;
|
|
const idx = currentIndexRef.current;
|
|
const pl = playlistRef.current;
|
|
|
|
if (!audio || idx < 0 || idx >= pl.length - 1) return;
|
|
|
|
const nextIdx = idx + 1;
|
|
const nextTrack = pl[nextIdx];
|
|
|
|
saveCurrent();
|
|
currentTrackRef.current = nextTrack;
|
|
|
|
setCurrentIndex(nextIdx);
|
|
setCurrentTrack(nextTrack);
|
|
setCurrentTime(0);
|
|
setDuration(0);
|
|
setIsLoading(true);
|
|
|
|
audio.src = resolveSrc(nextTrack.url);
|
|
audio.load();
|
|
audio.play().catch((err) => {
|
|
console.error("[AudioPlayer] Next failed:", err);
|
|
setIsPlaying(false);
|
|
setIsLoading(false);
|
|
});
|
|
}, [saveCurrent]);
|
|
|
|
const previous = useCallback(() => {
|
|
const audio = audioRef.current;
|
|
const idx = currentIndexRef.current;
|
|
const pl = playlistRef.current;
|
|
|
|
if (!audio || idx < 0) return;
|
|
|
|
// If more than 3 seconds in, restart the current track instead
|
|
if (audio.currentTime > 3) {
|
|
audio.currentTime = 0;
|
|
setCurrentTime(0);
|
|
return;
|
|
}
|
|
|
|
if (idx <= 0) {
|
|
// At start of playlist — just restart
|
|
audio.currentTime = 0;
|
|
setCurrentTime(0);
|
|
return;
|
|
}
|
|
|
|
const prevIdx = idx - 1;
|
|
const prevTrack = pl[prevIdx];
|
|
|
|
saveCurrent();
|
|
currentTrackRef.current = prevTrack;
|
|
|
|
setCurrentIndex(prevIdx);
|
|
setCurrentTrack(prevTrack);
|
|
setCurrentTime(0);
|
|
setDuration(0);
|
|
setIsLoading(true);
|
|
|
|
audio.src = resolveSrc(prevTrack.url);
|
|
audio.load();
|
|
audio.play().catch((err) => {
|
|
console.error("[AudioPlayer] Previous failed:", err);
|
|
setIsPlaying(false);
|
|
setIsLoading(false);
|
|
});
|
|
}, [saveCurrent]);
|
|
|
|
// --- Context value ---
|
|
|
|
const value: AudioPlayerContextValue = {
|
|
currentTrack,
|
|
isPlaying,
|
|
currentTime,
|
|
duration,
|
|
volume,
|
|
isMuted,
|
|
isLoading,
|
|
playlist,
|
|
currentIndex,
|
|
play,
|
|
playPlaylist,
|
|
attachQueue,
|
|
pause,
|
|
resume,
|
|
togglePlayPause,
|
|
stop,
|
|
seekTo,
|
|
setVolume,
|
|
toggleMute,
|
|
next,
|
|
previous,
|
|
};
|
|
|
|
return (
|
|
<AudioPlayerContext.Provider value={value}>
|
|
{children}
|
|
<audio ref={audioRef} preload="metadata" style={{ display: "none" }} />
|
|
</AudioPlayerContext.Provider>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hook
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function useAudioPlayer(): AudioPlayerContextValue {
|
|
const ctx = useContext(AudioPlayerContext);
|
|
if (!ctx) {
|
|
throw new Error(
|
|
"useAudioPlayer must be used within an <AudioPlayerProvider>",
|
|
);
|
|
}
|
|
return ctx;
|
|
}
|