diff --git a/frontend/public/audio/Triple Pick, We Believe.mp3 b/frontend/public/audio/Triple Pick, We Believe.mp3 new file mode 100644 index 0000000..4f11bea Binary files /dev/null and b/frontend/public/audio/Triple Pick, We Believe.mp3 differ diff --git a/frontend/public/audio/붉은 물결.mp3 b/frontend/public/audio/붉은 물결.mp3 new file mode 100644 index 0000000..6fe8bc6 Binary files /dev/null and b/frontend/public/audio/붉은 물결.mp3 differ diff --git a/frontend/public/thumbnail/Triple Pick, We Believe.webp b/frontend/public/thumbnail/Triple Pick, We Believe.webp new file mode 100644 index 0000000..634f1be Binary files /dev/null and b/frontend/public/thumbnail/Triple Pick, We Believe.webp differ diff --git a/frontend/public/thumbnail/붉은 물결.webp b/frontend/public/thumbnail/붉은 물결.webp new file mode 100644 index 0000000..b50ea63 Binary files /dev/null and b/frontend/public/thumbnail/붉은 물결.webp differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b5a85bb..e932955 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( - - } /> - } /> - } /> - } /> - + + + } /> + } /> + } /> + } /> + + + ); } diff --git a/frontend/src/components/MusicBar.tsx b/frontend/src/components/MusicBar.tsx new file mode 100644 index 0000000..0c62cd7 --- /dev/null +++ b/frontend/src/components/MusicBar.tsx @@ -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 ( + + + + ); +} +function IconNext() { + return ( + + + + ); +} +function IconPlay() { + return ( + + + + ); +} +function IconPause() { + return ( + + + + ); +} +function IconMusic() { + return ( + + + + ); +} +function IconVolume({ level }: { level: number; muted: boolean }) { + if (level === 0) { + return ( + + + + ); + } + if (level < 0.5) { + return ( + + + + ); + } + return ( + + + + ); +} + +// --------------------------------------------------------------------------- +// 접기/펼치기 탭 (바 상단 중앙에 고정) +// --------------------------------------------------------------------------- +function CollapseTab({ + minimized, + onToggle, +}: { + minimized: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// 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) => { + const rect = e.currentTarget.getBoundingClientRect(); + seek(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))); + }; + + const handleVolumeClick = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + setVolume((e.clientX - rect.left) / rect.width); + }; + + const handleVolumeDrag = (e: React.PointerEvent) => { + 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 ( +
+ {/* 썸네일 — 클릭 시 펼치기 */} +
+ {currentTrack?.cover ? ( + {currentTrack.title} + ) : ( + + )} +
+ {/* 재생/일시정지 */} + +
+ ); + } + + return ( +
+
+ + +
+ <> + {/* 모바일 진행선 (seekbar 대신, md 이상에선 숨김) */} +
+
+
+ + {/* 전체 레이아웃 */} +
+ + {/* ── 좌: 썸네일 + 곡 정보 ── */} +
+
+ {currentTrack?.cover ? ( + {currentTrack.title} + ) : ( + + )} +
+
+

+ {currentTrack?.title ?? "—"} +

+

+ {currentTrack?.artist ?? ""} +

+
+
+ + {/* ── 중: 재생 컨트롤 (모바일 포함 전체 표시) ── */} +
+ + + +
+ + {/* ── 우: seekbar(데스크톱만) + 볼륨(전체) ── */} +
+ {/* seekbar — 데스크톱만 */} + + {fmtTime(currentTime)} + +
+
+
+
+ + {fmtTime(duration)} + + {/* 볼륨 — 모바일 포함 전체 표시 */} + +
+
+
+
+ +
+ +
+
+
+ ); +} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 28e0d62..57784a8 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -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 = { @@ -203,6 +210,12 @@ export const DICT: Record = { 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 = { 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", }, }; diff --git a/frontend/src/lib/playlist.ts b/frontend/src/lib/playlist.ts new file mode 100644 index 0000000..e2649bf --- /dev/null +++ b/frontend/src/lib/playlist.ts @@ -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 = { + "/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); diff --git a/frontend/src/lib/usePlayer.tsx b/frontend/src/lib/usePlayer.tsx new file mode 100644 index 0000000..1afc52e --- /dev/null +++ b/frontend/src/lib/usePlayer.tsx @@ -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(null); + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +export function PlayerProvider({ children }: { children: ReactNode }) { + const audioRef = useRef(null); + const [playlist, setPlaylistState] = useState([]); + 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 ( + {children} + ); +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function usePlayer(): PlayerContextValue { + const ctx = useContext(PlayerContext); + if (!ctx) throw new Error("usePlayer must be used inside "); + return ctx; +}