Files
spsays-training/components/audio-player/audio-context.tsx

463 lines
12 KiB
TypeScript

"use client";
import {
createContext,
useContext,
useRef,
useState,
useCallback,
useEffect,
type ReactNode,
} from "react";
// ---------------------------------------------------------------------------
// 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;
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";
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);
// Sync refs in an effect to avoid "cannot access refs during render"
useEffect(() => {
playlistRef.current = playlist;
}, [playlist]);
useEffect(() => {
currentIndexRef.current = currentIndex;
}, [currentIndex]);
// --- 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);
}
function handleLoadedMetadata() {
setDuration(audio!.duration);
}
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);
// 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];
setCurrentIndex(nextIdx);
setCurrentTrack(nextTrack);
setCurrentTime(0);
setDuration(0);
setIsLoading(true);
if (audio) {
audio.src = 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);
};
}, []);
// --- Actions ---
const play = useCallback((track: AudioTrack) => {
const audio = audioRef.current;
if (!audio) return;
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);
});
}, []);
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];
setPlaylist(tracks);
setCurrentIndex(idx);
setCurrentTrack(track);
setCurrentTime(0);
setDuration(0);
setIsLoading(true);
audio.src = track.url;
audio.load();
audio.play().catch((err) => {
console.error("[AudioPlayer] PlayPlaylist failed:", err);
setIsPlaying(false);
setIsLoading(false);
});
},
[],
);
const pause = useCallback(() => {
audioRef.current?.pause();
}, []);
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();
}
}, []);
const stop = useCallback(() => {
const audio = audioRef.current;
if (audio) {
audio.pause();
audio.removeAttribute("src");
audio.load();
}
setCurrentTrack(null);
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setIsLoading(false);
setPlaylist([]);
setCurrentIndex(-1);
}, []);
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];
setCurrentIndex(nextIdx);
setCurrentTrack(nextTrack);
setCurrentTime(0);
setDuration(0);
setIsLoading(true);
audio.src = nextTrack.url;
audio.load();
audio.play().catch((err) => {
console.error("[AudioPlayer] Next failed:", err);
setIsPlaying(false);
setIsLoading(false);
});
}, []);
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];
setCurrentIndex(prevIdx);
setCurrentTrack(prevTrack);
setCurrentTime(0);
setDuration(0);
setIsLoading(true);
audio.src = prevTrack.url;
audio.load();
audio.play().catch((err) => {
console.error("[AudioPlayer] Previous failed:", err);
setIsPlaying(false);
setIsLoading(false);
});
}, []);
// --- Context value ---
const value: AudioPlayerContextValue = {
currentTrack,
isPlaying,
currentTime,
duration,
volume,
isMuted,
isLoading,
playlist,
currentIndex,
play,
playPlaylist,
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;
}