208 lines
5.7 KiB
TypeScript
208 lines
5.7 KiB
TypeScript
"use client";
|
|
|
|
import { createContext, useContext, useCallback, useRef, useState, useEffect } from "react";
|
|
import type { SanityImageSource } from "@sanity/image-url";
|
|
|
|
export type PlayerTrack = {
|
|
previewMp3Url: string;
|
|
title: string;
|
|
/** Movement name, displayed in regular weight after the bold work title. */
|
|
movement?: string;
|
|
subtitle: string;
|
|
albumCover?: SanityImageSource;
|
|
/** Full track duration in seconds (from tracklist metadata). */
|
|
trackDuration?: number;
|
|
/** Release slug for linking back to the release page. */
|
|
releaseSlug?: string;
|
|
};
|
|
|
|
type PlayerState = {
|
|
playlist: PlayerTrack[];
|
|
currentIndex: number | null;
|
|
isPlaying: boolean;
|
|
currentTime: number;
|
|
audioDuration: number;
|
|
};
|
|
|
|
type PlayerActions = {
|
|
loadPlaylist: (tracks: PlayerTrack[], startIndex: number) => void;
|
|
togglePlayback: () => void;
|
|
next: () => void;
|
|
previous: () => void;
|
|
seekTo: (fraction: number) => void;
|
|
stop: () => void;
|
|
};
|
|
|
|
type PlayerContextValue = PlayerState & PlayerActions;
|
|
|
|
const PlayerContext = createContext<PlayerContextValue | null>(null);
|
|
|
|
export function usePlayer() {
|
|
const ctx = useContext(PlayerContext);
|
|
if (!ctx) throw new Error("usePlayer must be used within a PlayerProvider");
|
|
return ctx;
|
|
}
|
|
|
|
export function usePlayerOptional() {
|
|
return useContext(PlayerContext);
|
|
}
|
|
|
|
export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
const [playlist, setPlaylist] = useState<PlayerTrack[]>([]);
|
|
const [currentIndex, setCurrentIndex] = useState<number | null>(null);
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [audioDuration, setAudioDuration] = useState(0);
|
|
|
|
// Create audio element once
|
|
useEffect(() => {
|
|
const audio = new Audio();
|
|
audioRef.current = audio;
|
|
|
|
let rafId: number | null = null;
|
|
|
|
// Poll currentTime at ~60fps for a silky-smooth progress bar
|
|
const tick = () => {
|
|
setCurrentTime(audio.currentTime);
|
|
rafId = requestAnimationFrame(tick);
|
|
};
|
|
|
|
const onDurationChange = () => setAudioDuration(audio.duration || 0);
|
|
const onPlay = () => {
|
|
setIsPlaying(true);
|
|
if (rafId === null) rafId = requestAnimationFrame(tick);
|
|
};
|
|
const onPause = () => {
|
|
setIsPlaying(false);
|
|
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; }
|
|
};
|
|
|
|
audio.addEventListener("durationchange", onDurationChange);
|
|
audio.addEventListener("play", onPlay);
|
|
audio.addEventListener("pause", onPause);
|
|
|
|
return () => {
|
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
audio.removeEventListener("durationchange", onDurationChange);
|
|
audio.removeEventListener("play", onPlay);
|
|
audio.removeEventListener("pause", onPause);
|
|
audio.pause();
|
|
audio.src = "";
|
|
};
|
|
}, []);
|
|
|
|
// Handle track ended → auto-advance
|
|
useEffect(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
|
|
const onEnded = () => {
|
|
if (currentIndex !== null && currentIndex < playlist.length - 1) {
|
|
const nextIdx = currentIndex + 1;
|
|
setCurrentIndex(nextIdx);
|
|
audio.src = playlist[nextIdx].previewMp3Url;
|
|
audio.play().catch(() => {});
|
|
} else {
|
|
audio.pause();
|
|
audio.src = "";
|
|
setPlaylist([]);
|
|
setCurrentIndex(null);
|
|
setCurrentTime(0);
|
|
setAudioDuration(0);
|
|
setIsPlaying(false);
|
|
}
|
|
};
|
|
|
|
audio.addEventListener("ended", onEnded);
|
|
return () => audio.removeEventListener("ended", onEnded);
|
|
}, [currentIndex, playlist]);
|
|
|
|
const loadPlaylist = useCallback((tracks: PlayerTrack[], startIndex: number) => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
|
|
setPlaylist(tracks);
|
|
setCurrentIndex(startIndex);
|
|
setCurrentTime(0);
|
|
setAudioDuration(0);
|
|
|
|
audio.src = tracks[startIndex].previewMp3Url;
|
|
audio.play().catch(() => {});
|
|
}, []);
|
|
|
|
const togglePlayback = useCallback(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio || currentIndex === null) return;
|
|
|
|
if (audio.paused) {
|
|
audio.play().catch(() => {});
|
|
} else {
|
|
audio.pause();
|
|
}
|
|
}, [currentIndex]);
|
|
|
|
const next = useCallback(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio || currentIndex === null) return;
|
|
|
|
if (currentIndex < playlist.length - 1) {
|
|
const nextIdx = currentIndex + 1;
|
|
setCurrentIndex(nextIdx);
|
|
setCurrentTime(0);
|
|
audio.src = playlist[nextIdx].previewMp3Url;
|
|
audio.play().catch(() => {});
|
|
}
|
|
}, [currentIndex, playlist]);
|
|
|
|
const previous = useCallback(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio || currentIndex === null) return;
|
|
|
|
if (currentIndex > 0) {
|
|
const prevIdx = currentIndex - 1;
|
|
setCurrentIndex(prevIdx);
|
|
setCurrentTime(0);
|
|
audio.src = playlist[prevIdx].previewMp3Url;
|
|
audio.play().catch(() => {});
|
|
}
|
|
}, [currentIndex, playlist]);
|
|
|
|
const seekTo = useCallback((fraction: number) => {
|
|
const audio = audioRef.current;
|
|
if (!audio || !audio.duration) return;
|
|
audio.currentTime = fraction * audio.duration;
|
|
}, []);
|
|
|
|
const stop = useCallback(() => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
audio.pause();
|
|
audio.src = "";
|
|
setPlaylist([]);
|
|
setCurrentIndex(null);
|
|
setCurrentTime(0);
|
|
setAudioDuration(0);
|
|
setIsPlaying(false);
|
|
}, []);
|
|
|
|
return (
|
|
<PlayerContext.Provider
|
|
value={{
|
|
playlist,
|
|
currentIndex,
|
|
isPlaying,
|
|
currentTime,
|
|
audioDuration,
|
|
loadPlaylist,
|
|
togglePlayback,
|
|
next,
|
|
previous,
|
|
seekTo,
|
|
stop,
|
|
}}
|
|
>
|
|
{children}
|
|
</PlayerContext.Provider>
|
|
);
|
|
}
|