o2o-triple-pick/frontend/src/lib/usePlayer.tsx

271 lines
8.0 KiB
TypeScript

import {
createContext,
useContext,
useRef,
useState,
useEffect,
useCallback,
type ReactNode,
} from "react";
import { type Track } from "./playlist";
// ---------------------------------------------------------------------------
// 타입
// ---------------------------------------------------------------------------
type PlayerState = {
currentIndex: number;
currentTrack: Track | null;
isPlaying: boolean;
progress: number;
currentTime: number;
duration: number;
volume: number;
muted: boolean;
userClosed: boolean;
hasTrack: boolean;
};
type PlayerActions = {
play: () => void;
pause: () => void;
toggle: () => void;
next: () => void;
prev: () => void;
seek: (ratio: number) => void;
setVolume: (v: number) => void;
toggleMute: () => void;
close: () => void;
open: () => void;
goTo: (index: number) => void;
loadPlaylist: (tracks: Track[]) => void;
};
type PlayerContextValue = PlayerState & PlayerActions;
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const PlayerContext = createContext<PlayerContextValue | null>(null);
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function PlayerProvider({ children }: { children: ReactNode }) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentSrcRef = useRef<string>("");
const autoPlayRef = useRef(false);
const isPlayingRef = useRef(false);
const [playlist, setPlaylistState] = useState<Track[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [loadKey, setLoadKey] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolumeState] = useState(0.4);
const [muted, setMuted] = useState(false);
const [userClosed, setUserClosed] = useState(false);
const hasTrack = playlist.length > 0;
const currentTrack = hasTrack ? playlist[currentIndex] : null;
// 오디오 인스턴스 초기화 (마운트 시 1회)
useEffect(() => {
const audio = new Audio();
audio.preload = "metadata";
audio.volume = 0.4;
audioRef.current = audio;
const onTimeUpdate = () => {
setCurrentTime(audio.currentTime);
setProgress(audio.duration ? audio.currentTime / audio.duration : 0);
};
const onLoaded = () => setDuration(audio.duration || 0);
const onPlay = () => { setIsPlaying(true); isPlayingRef.current = true; };
const onPause = () => { setIsPlaying(false); isPlayingRef.current = false; };
audio.addEventListener("timeupdate", onTimeUpdate);
audio.addEventListener("loadedmetadata", onLoaded);
audio.addEventListener("durationchange", onLoaded);
audio.addEventListener("play", onPlay);
audio.addEventListener("pause", onPause);
return () => {
audio.pause();
audio.removeEventListener("timeupdate", onTimeUpdate);
audio.removeEventListener("loadedmetadata", onLoaded);
audio.removeEventListener("durationchange", onLoaded);
audio.removeEventListener("play", onPlay);
audio.removeEventListener("pause", onPause);
audioRef.current = null;
};
}, []);
// ended 시 순환 처리 (playlist 길이 반영)
const playlistLenRef = useRef(playlist.length);
useEffect(() => { playlistLenRef.current = playlist.length; }, [playlist.length]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onEnded = () => {
autoPlayRef.current = true;
setCurrentIndex((prev) =>
playlistLenRef.current > 0 ? (prev + 1) % playlistLenRef.current : 0
);
};
audio.addEventListener("ended", onEnded);
return () => audio.removeEventListener("ended", onEnded);
}, []);
// 트랙 변경 시 src 교체
useEffect(() => {
const audio = audioRef.current;
if (!audio || !currentTrack) return;
const shouldPlay = isPlayingRef.current || autoPlayRef.current;
autoPlayRef.current = false;
currentSrcRef.current = currentTrack.src;
audio.src = currentTrack.src;
audio.load();
setProgress(0);
setCurrentTime(0);
setDuration(0);
if (shouldPlay) audio.play().catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentIndex, currentTrack?.src, loadKey]);
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
const play = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
if (currentTrack && currentSrcRef.current !== currentTrack.src) {
currentSrcRef.current = currentTrack.src;
audio.src = currentTrack.src;
audio.load();
}
audio.play().catch(() => {});
}, [currentTrack]);
const pause = useCallback(() => { audioRef.current?.pause(); }, []);
const toggle = useCallback(() => {
if (isPlaying) {
audioRef.current?.pause();
} else {
const audio = audioRef.current;
if (!audio) return;
if (currentTrack && currentSrcRef.current !== currentTrack.src) {
currentSrcRef.current = currentTrack.src;
audio.src = currentTrack.src;
audio.load();
}
audio.play().catch(() => {});
}
}, [isPlaying, currentTrack]);
const next = useCallback(() => {
if (!hasTrack) return;
setCurrentIndex((prev) => (prev + 1) % playlist.length);
}, [hasTrack, playlist.length]);
const prev = useCallback(() => {
if (!hasTrack) return;
setCurrentIndex((prev) => (prev - 1 + playlist.length) % playlist.length);
}, [hasTrack, playlist.length]);
const seek = useCallback((ratio: number) => {
const audio = audioRef.current;
if (!audio || !audio.duration) return;
audio.currentTime = ratio * audio.duration;
}, []);
const setVolume = useCallback((v: number) => {
const clamped = Math.max(0, Math.min(1, v));
setVolumeState(clamped);
if (audioRef.current) {
audioRef.current.volume = clamped;
if (clamped > 0) { audioRef.current.muted = false; setMuted(false); }
}
}, []);
const toggleMute = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
const next = !audio.muted;
audio.muted = next;
setMuted(next);
}, []);
const close = useCallback(() => setUserClosed(true), []);
const open = useCallback(() => setUserClosed(false), []);
const goTo = useCallback((index: number) => {
if (index < 0 || index >= playlist.length) return;
setCurrentIndex(index);
}, [playlist.length]);
// 경로 변경 시 새 플레이리스트 로드
const loadPlaylist = useCallback((tracks: Track[]) => {
const audio = audioRef.current;
if (audio) {
audio.pause();
audio.removeAttribute("src");
}
currentSrcRef.current = "";
autoPlayRef.current = true;
setPlaylistState([...tracks]);
setLoadKey((k) => k + 1);
setCurrentIndex(0);
setProgress(0);
setCurrentTime(0);
setDuration(0);
setIsPlaying(false);
isPlayingRef.current = false;
}, []);
const value: PlayerContextValue = {
currentIndex,
currentTrack,
isPlaying,
progress,
currentTime,
duration,
volume,
muted,
userClosed,
hasTrack,
play,
pause,
toggle,
next,
prev,
seek,
setVolume,
toggleMute,
close,
open,
goTo,
loadPlaylist,
};
return (
<PlayerContext.Provider value={value}>{children}</PlayerContext.Provider>
);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function usePlayer(): PlayerContextValue {
const ctx = useContext(PlayerContext);
if (!ctx) throw new Error("usePlayer must be used inside <PlayerProvider>");
return ctx;
}