feat: 사운드바 추가

develop
김성경 2026-06-17 16:51:50 +09:00
parent 8a67094510
commit 7c42bd5277
9 changed files with 649 additions and 6 deletions

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

View File

@ -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 (
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/match/:matchId" element={<MatchDetail />} />
<Route path="/leaderboard" element={<Leaderboard />} />
<Route path="*" element={<NotFound />} />
</Routes>
<PlayerProvider>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/match/:matchId" element={<MatchDetail />} />
<Route path="/leaderboard" element={<Leaderboard />} />
<Route path="*" element={<NotFound />} />
</Routes>
<MusicBar />
</PlayerProvider>
);
}

View File

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

View File

@ -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",
},
};

View File

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

View File

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