trptk/components/player/PlayerContext.tsx
2026-02-24 17:14:07 +01:00

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>
);
}