feat: 사운드바 추가
parent
8a67094510
commit
7c42bd5277
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 575 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 437 KiB |
|
|
@ -5,6 +5,8 @@ import MatchDetail from "./pages/MatchDetail";
|
|||
import Leaderboard from "./pages/Leaderboard";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import { recordVisit } from "./lib/api";
|
||||
import { PlayerProvider } from "./lib/usePlayer";
|
||||
import MusicBar from "./components/MusicBar";
|
||||
|
||||
export default function App() {
|
||||
// 접속 시 1회 방문 기록 (하루 1기기 1회 집계는 백엔드가 처리)
|
||||
|
|
@ -13,11 +15,14 @@ export default function App() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<PlayerProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/match/:matchId" element={<MatchDetail />} />
|
||||
<Route path="/leaderboard" element={<Leaderboard />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<MusicBar />
|
||||
</PlayerProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,339 @@
|
|||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { usePlayer } from "@/lib/usePlayer";
|
||||
import { useLang } from "@/lib/useLang";
|
||||
import { dict } from "@/lib/i18n";
|
||||
import { VISIBLE_PATHS, PATH_PLAYLISTS } from "@/lib/playlist";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 시간 포맷
|
||||
// ---------------------------------------------------------------------------
|
||||
function fmtTime(sec: number): string {
|
||||
if (!isFinite(sec) || sec < 0) return "0:00";
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 아이콘
|
||||
// ---------------------------------------------------------------------------
|
||||
function IconPrev() {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 6h2v12H6zm3.5 6 8.5 6V6z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function IconNext() {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function IconPlay() {
|
||||
return (
|
||||
<svg width="27" height="27" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function IconPause() {
|
||||
return (
|
||||
<svg width="27" height="27" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function IconMusic() {
|
||||
return (
|
||||
<svg width="30" height="30" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
function IconVolume({ level }: { level: number; muted: boolean }) {
|
||||
if (level === 0) {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M16.5 12A4.5 4.5 0 0 0 14 7.97v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.796 8.796 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06A8.99 8.99 0 0 0 17.73 18l2 2L21 18.73 5.27 3 4.27 3zM12 4 9.91 6.09 12 8.18V4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (level < 0.5) {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.5 12A4.5 4.5 0 0 0 16 7.97v8.05A4.478 4.478 0 0 0 18.5 12zM5 9v6h4l5 5V4L9 9H5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05A4.478 4.478 0 0 0 16.5 12zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 접기/펼치기 탭 (바 상단 중앙에 고정)
|
||||
// ---------------------------------------------------------------------------
|
||||
function CollapseTab({
|
||||
minimized,
|
||||
onToggle,
|
||||
}: {
|
||||
minimized: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
aria-label={minimized ? "플레이어 펼치기" : "플레이어 접기"}
|
||||
className="absolute -top-[30px] left-1/2 -translate-x-1/2 flex items-center justify-center
|
||||
w-[60px] h-[30px] rounded-t-md transition-colors"
|
||||
style={{
|
||||
background: "var(--bg2)",
|
||||
border: "1px solid var(--line-d)",
|
||||
borderBottom: "none",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="8"
|
||||
viewBox="0 0 12 8"
|
||||
fill="none"
|
||||
className="text-white/40 transition-transform"
|
||||
style={{ transform: minimized ? "rotate(180deg)" : "rotate(0deg)" }}
|
||||
>
|
||||
<path
|
||||
d="M1 1.5L6 6.5L11 1.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MusicBar
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function MusicBar() {
|
||||
const lang = useLang();
|
||||
const t = dict(lang);
|
||||
const { pathname } = useLocation();
|
||||
const {
|
||||
currentTrack,
|
||||
isPlaying,
|
||||
progress,
|
||||
currentTime,
|
||||
duration,
|
||||
volume,
|
||||
muted,
|
||||
userClosed,
|
||||
hasTrack,
|
||||
toggle,
|
||||
next,
|
||||
prev,
|
||||
seek,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
close,
|
||||
open,
|
||||
loadPlaylist,
|
||||
} = usePlayer();
|
||||
|
||||
// 경로가 바뀌면 해당 경로의 플레이리스트 로드
|
||||
useEffect(() => {
|
||||
const tracks = PATH_PLAYLISTS[pathname];
|
||||
if (tracks) loadPlaylist(tracks);
|
||||
}, [pathname, loadPlaylist]);
|
||||
|
||||
if (!hasTrack) return null;
|
||||
|
||||
if (VISIBLE_PATHS.length > 0) {
|
||||
const visible = VISIBLE_PATHS.some(
|
||||
(p: string) => pathname === p || pathname.startsWith(p)
|
||||
);
|
||||
if (!visible) return null;
|
||||
}
|
||||
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
seek(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
||||
};
|
||||
|
||||
const handleVolumeClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setVolume((e.clientX - rect.left) / rect.width);
|
||||
};
|
||||
|
||||
const handleVolumeDrag = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
const update = (ev: PointerEvent) => {
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
setVolume(Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width)));
|
||||
};
|
||||
const stop = () => {
|
||||
window.removeEventListener("pointermove", update);
|
||||
window.removeEventListener("pointerup", stop);
|
||||
};
|
||||
window.addEventListener("pointermove", update);
|
||||
window.addEventListener("pointerup", stop);
|
||||
};
|
||||
|
||||
const displayVolume = muted ? 0 : volume;
|
||||
|
||||
/* ── 최소화 상태: 우측 하단 고정 pill (썸네일 + 재생/정지) ── */
|
||||
if (userClosed) {
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-5 right-5 z-40 flex items-center gap-2 px-2 py-2"
|
||||
style={{
|
||||
background: "var(--bg2)",
|
||||
border: "1px solid var(--line-d)",
|
||||
borderRadius: "999px",
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,0.55)",
|
||||
}}
|
||||
>
|
||||
{/* 썸네일 — 클릭 시 펼치기 */}
|
||||
<div
|
||||
className="shrink-0 w-10 h-10 rounded-full overflow-hidden flex items-center justify-center cursor-pointer"
|
||||
style={{ background: "var(--bg)", border: "1px solid var(--line-d)" }}
|
||||
onClick={open}
|
||||
>
|
||||
{currentTrack?.cover ? (
|
||||
<img src={currentTrack.cover} alt={currentTrack.title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-[var(--green)] opacity-60"><IconMusic /></span>
|
||||
)}
|
||||
</div>
|
||||
{/* 재생/일시정지 */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-label={isPlaying ? t.playerPause : t.playerPlay}
|
||||
className="shrink-0 w-10 h-10 rounded-full flex items-center justify-center text-[var(--bg)] hover:opacity-90 transition-opacity"
|
||||
style={{ background: "var(--mint)" }}
|
||||
>
|
||||
{isPlaying ? <IconPause /> : <IconPlay />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t.playerLabel}
|
||||
className="fixed bottom-0 left-0 w-full z-40"
|
||||
>
|
||||
<div className="relative">
|
||||
<CollapseTab minimized={false} onToggle={close} />
|
||||
|
||||
<div
|
||||
className="border-t border-[var(--line-d)] bg-[var(--bg2)]/95 backdrop-blur-sm"
|
||||
style={{ boxShadow: "0 -4px 24px rgba(0,0,0,0.45)" }}
|
||||
>
|
||||
<>
|
||||
{/* 모바일 진행선 (seekbar 대신, md 이상에선 숨김) */}
|
||||
<div className="md:hidden w-full h-[2px] bg-white/10">
|
||||
<div
|
||||
className="h-full transition-none"
|
||||
style={{ width: `${progress * 100}%`, background: "var(--green)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 전체 레이아웃 */}
|
||||
<div className="max-w-5xl mx-auto flex items-center gap-2 px-3 py-3 md:gap-4 md:px-6">
|
||||
|
||||
{/* ── 좌: 썸네일 + 곡 정보 ── */}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1 md:flex-none md:w-[260px] md:shrink-0">
|
||||
<div
|
||||
className="shrink-0 w-[60px] h-[60px] rounded overflow-hidden flex items-center justify-center"
|
||||
style={{ background: "var(--bg)", border: "1px solid var(--line-d)" }}
|
||||
>
|
||||
{currentTrack?.cover ? (
|
||||
<img src={currentTrack.cover} alt={currentTrack.title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-[var(--green)] opacity-60"><IconMusic /></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[16px] font-semibold text-white/90 truncate leading-tight">
|
||||
{currentTrack?.title ?? "—"}
|
||||
</p>
|
||||
<p className="text-[14px] text-white/45 truncate leading-tight">
|
||||
{currentTrack?.artist ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 중: 재생 컨트롤 (모바일 포함 전체 표시) ── */}
|
||||
<div className="flex items-center gap-0 shrink-0">
|
||||
<button onClick={prev} aria-label={t.playerPrev} className="p-1.5 md:p-2 text-white/55 hover:text-white/90 transition-colors">
|
||||
<IconPrev />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-label={isPlaying ? t.playerPause : t.playerPlay}
|
||||
className="p-1.5 md:p-2 mx-1 rounded-full text-[var(--bg)] hover:opacity-90 transition-opacity"
|
||||
style={{ background: "var(--mint)" }}
|
||||
>
|
||||
{isPlaying ? <IconPause /> : <IconPlay />}
|
||||
</button>
|
||||
<button onClick={next} aria-label={t.playerNext} className="p-1.5 md:p-2 text-white/55 hover:text-white/90 transition-colors">
|
||||
<IconNext />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── 우: seekbar(데스크톱만) + 볼륨(전체) ── */}
|
||||
<div className="flex items-center gap-2 md:gap-3 shrink-0 md:flex-1 md:min-w-0">
|
||||
{/* seekbar — 데스크톱만 */}
|
||||
<span className="hidden md:inline text-[11px] text-white/40 tabular-nums shrink-0">
|
||||
{fmtTime(currentTime)}
|
||||
</span>
|
||||
<div
|
||||
className="hidden md:block relative flex-1 h-[3px] bg-white/20 rounded-full cursor-pointer"
|
||||
onClick={handleProgressClick}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full"
|
||||
style={{ width: `${progress * 100}%`, background: "rgba(255,255,255,0.85)" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-white shadow"
|
||||
style={{ left: `calc(${progress * 100}% - 6px)` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="hidden md:inline text-[11px] text-white/40 tabular-nums shrink-0">
|
||||
{fmtTime(duration)}
|
||||
</span>
|
||||
{/* 볼륨 — 모바일 포함 전체 표시 */}
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
aria-label={muted ? t.playerPlay : t.playerPause}
|
||||
className="p-1 text-white/45 hover:text-white/80 transition-colors shrink-0"
|
||||
>
|
||||
<IconVolume level={displayVolume} muted={muted} />
|
||||
</button>
|
||||
<div
|
||||
className="w-16 md:w-20 h-[3px] bg-white/20 rounded-full cursor-pointer shrink-0 relative"
|
||||
onClick={handleVolumeClick}
|
||||
onPointerDown={handleVolumeDrag}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full"
|
||||
style={{ width: `${displayVolume * 100}%`, background: "rgba(255,255,255,0.85)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -117,6 +117,13 @@ type Dict = {
|
|||
ado2Tagline: string;
|
||||
ado2Sub: string;
|
||||
ado2Cta: string;
|
||||
// 음악 플레이어 바
|
||||
playerLabel: string;
|
||||
playerPlay: string;
|
||||
playerPause: string;
|
||||
playerNext: string;
|
||||
playerPrev: string;
|
||||
playerClose: string;
|
||||
};
|
||||
|
||||
export const DICT: Record<Lang, Dict> = {
|
||||
|
|
@ -203,6 +210,12 @@ export const DICT: Record<Lang, Dict> = {
|
|||
ado2Tagline: "AI 마케팅 자동화 플랫폼",
|
||||
ado2Sub: "AI Marketing Automation Platform →",
|
||||
ado2Cta: "사용해보기",
|
||||
playerLabel: "음악 플레이어",
|
||||
playerPlay: "재생",
|
||||
playerPause: "일시정지",
|
||||
playerNext: "다음 곡",
|
||||
playerPrev: "이전 곡",
|
||||
playerClose: "플레이어 닫기",
|
||||
},
|
||||
en: {
|
||||
heroPill: "AI-powered Global Football Festival 2026 match predictions",
|
||||
|
|
@ -287,6 +300,12 @@ export const DICT: Record<Lang, Dict> = {
|
|||
ado2Tagline: "AI Marketing Automation Platform",
|
||||
ado2Sub: "Visit ado2.o2osolution.ai →",
|
||||
ado2Cta: "Try it",
|
||||
playerLabel: "Music player",
|
||||
playerPlay: "Play",
|
||||
playerPause: "Pause",
|
||||
playerNext: "Next track",
|
||||
playerPrev: "Previous track",
|
||||
playerClose: "Close player",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
// 음악 플레이어 설정 SSOT
|
||||
// src: 로컬("/audio/foo.mp3") 또는 외부 URL("https://...") 둘 다 허용
|
||||
|
||||
export type Track = {
|
||||
title: string;
|
||||
artist?: string;
|
||||
src: string;
|
||||
/** 앨범 아트 이미지 경로 또는 URL */
|
||||
cover?: string;
|
||||
};
|
||||
|
||||
// 경로별 플레이리스트 — 키가 표시할 경로, 값이 해당 경로의 트랙 목록
|
||||
// VISIBLE_PATHS는 이 맵의 키에서 자동 생성됩니다.
|
||||
export const PATH_PLAYLISTS: Record<string, Track[]> = {
|
||||
"/match/A_MEX_KOR_20260619": [
|
||||
{ title: "붉은 물결", artist: "aio2o", src: "/audio/붉은 물결.mp3", cover: "/thumbnail/붉은 물결.webp" },
|
||||
{ title: "Triple Pick, We Believe", artist: "aio2o", src: "/audio/Triple Pick, We Believe.mp3", cover: "/thumbnail/Triple Pick, We Believe.webp" },
|
||||
],
|
||||
"/match/A_RSA_KOR_20260625": [
|
||||
{ title: "붉은 물결", artist: "aio2o", src: "/audio/붉은 물결.mp3", cover: "/thumbnail/붉은 물결.webp" },
|
||||
],
|
||||
};
|
||||
|
||||
// VISIBLE_PATHS — PATH_PLAYLISTS 키에서 자동 도출 (직접 수정 불필요)
|
||||
export const VISIBLE_PATHS: string[] = Object.keys(PATH_PLAYLISTS);
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
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 [playlist, setPlaylistState] = useState<Track[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = 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.8);
|
||||
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.8;
|
||||
audioRef.current = audio;
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
setCurrentTime(audio.currentTime);
|
||||
setProgress(audio.duration ? audio.currentTime / audio.duration : 0);
|
||||
};
|
||||
const onLoaded = () => setDuration(audio.duration || 0);
|
||||
const onEnded = () =>
|
||||
setCurrentIndex((prev) =>
|
||||
// playlist.length는 클로저 문제 방지를 위해 ref 대신 함수형 업데이트 외부에서 읽음
|
||||
prev + 1 // ended 핸들러에서는 playlist ref를 통해 처리
|
||||
);
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
|
||||
audio.addEventListener("timeupdate", onTimeUpdate);
|
||||
audio.addEventListener("loadedmetadata", onLoaded);
|
||||
audio.addEventListener("durationchange", onLoaded);
|
||||
audio.addEventListener("ended", onEnded);
|
||||
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("ended", onEnded);
|
||||
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 = () => {
|
||||
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 wasPlaying = isPlaying;
|
||||
audio.src = currentTrack.src;
|
||||
audio.load();
|
||||
setProgress(0);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
if (wasPlaying) audio.play().catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentIndex, currentTrack?.src]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const play = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (!audio.src && currentTrack) { 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 (!audio.src && currentTrack) { 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.src = ""; }
|
||||
setPlaylistState(tracks);
|
||||
setCurrentIndex(0);
|
||||
setProgress(0);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsPlaying(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;
|
||||
}
|
||||
Loading…
Reference in New Issue