271 lines
8.0 KiB
TypeScript
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;
|
|
}
|