응원가 재생을 팀별 버튼 방식으로 전환

- 일정/메인 자동 로드 제거 — 상세 페이지 대결 카드의 팀 로고 아래
  "응원가 듣기" 버튼으로 각 팀 곡을 개별 재생 (재생 중 일시정지/이어듣기 토글)
- MusicBar 는 버튼이 실은 플레이리스트도 표시하도록 노출 조건 정리
- 두산 상설 응원가는 두산 버튼 트랙 뒤에 이어붙임

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jwkim 2026-08-25 09:53:53 +09:00
parent 277378477a
commit 390e06113d
3 changed files with 88 additions and 32 deletions

View File

@ -1,11 +1,64 @@
import { useEffect, useState } from "react";
import type { Match, TeamStanding } from "@/lib/types"; import type { Match, TeamStanding } from "@/lib/types";
import { kickoffDisplay } from "@/lib/format"; import { kickoffDisplay } from "@/lib/format";
import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n"; import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n";
import { getTeamSongTracks, type Track } from "@/lib/playlist";
import { usePlayer } from "@/lib/usePlayer";
import BallIcon from "./BallIcon"; import BallIcon from "./BallIcon";
import { FormBadges } from "./ScheduleBoard"; import { FormBadges } from "./ScheduleBoard";
import TeamFlag from "./TeamFlag"; import TeamFlag from "./TeamFlag";
import WaveStrip from "./WaveStrip"; import WaveStrip from "./WaveStrip";
// 팀별 '응원가 듣기' 버튼 — 오늘의 응원가(Suno 자동 생성)를 플레이어에 싣는다.
// 이 팀 곡이 재생 중이면 일시정지/이어듣기 토글. 곡이 없으면 렌더 안 함.
function CheerSongButton({
matchId,
teamCode,
lang,
}: {
matchId: string;
teamCode: string;
lang: Lang;
}) {
const { currentTrack, isPlaying, toggle, loadPlaylist, open } = usePlayer();
const [tracks, setTracks] = useState<Track[]>([]);
useEffect(() => {
let alive = true;
getTeamSongTracks(matchId, teamCode).then((ts) => alive && setTracks(ts));
return () => {
alive = false;
};
}, [matchId, teamCode]);
if (tracks.length === 0) return null;
const active = !!currentTrack && tracks.some((t) => t.src === currentTrack.src);
const label = active
? isPlaying
? lang === "en" ? "⏸ Pause" : "⏸ 일시정지"
: lang === "en" ? "▶ Resume" : "▶ 이어 듣기"
: lang === "en" ? "♪ Cheer song" : "♪ 응원가 듣기";
return (
<button
type="button"
onClick={() => {
if (active) toggle();
else {
loadPlaylist(tracks);
open();
}
}}
className={`rounded-full border px-3 py-1 text-[12px] font-bold transition active:scale-95 ${
active
? "border-[var(--green)] bg-[var(--green)]/15 text-[var(--green)]"
: "border-[var(--green)]/50 text-[var(--green)] hover:bg-[var(--green)]/10"
}`}
>
{label}
</button>
);
}
// 순위 캐시 → "3위 · 52승37패 (0.584)" 한 줄 (야구) // 순위 캐시 → "3위 · 52승37패 (0.584)" 한 줄 (야구)
function standingLine( function standingLine(
st: { rank?: number | null; w?: number; l?: number; wra?: string | number } | null | undefined, st: { rank?: number | null; w?: number; l?: number; wra?: string | number } | null | undefined,
@ -103,9 +156,14 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
</div> </div>
</div> </div>
{/* 국기/로고 + 라이트닝 VS (종료 시 최종 스코어) */} {/* 국기/로고 + 라이트닝 VS (종료 시 최종 스코어) + 팀별 응원가 버튼(KBO) */}
<div className="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-3"> <div className="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<div className="flex flex-col items-center gap-2.5">
<TeamFlag team={left} className={flagCls} /> <TeamFlag team={left} className={flagCls} />
{match.league === "kbo" && (
<CheerSongButton matchId={match.matchId} teamCode={left.code} lang={lang} />
)}
</div>
<div className="relative grid place-items-center"> <div className="relative grid place-items-center">
<div className="vs-glow absolute h-28 w-28" /> <div className="vs-glow absolute h-28 w-28" />
{finished && match.result ? ( {finished && match.result ? (
@ -123,7 +181,12 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
</span> </span>
)} )}
</div> </div>
<div className="flex flex-col items-center gap-2.5">
<TeamFlag team={right} className={flagCls} /> <TeamFlag team={right} className={flagCls} />
{match.league === "kbo" && (
<CheerSongButton matchId={match.matchId} teamCode={right.code} lang={lang} />
)}
</div>
</div> </div>
{/* 야구 정보 패널: 선발 매치업 · 순위 · 시즌 상대전적 (캐시 있을 때만) */} {/* 야구 정보 패널: 선발 매치업 · 순위 · 시즌 상대전적 (캐시 있을 때만) */}

View File

@ -1,9 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { usePlayer } from "@/lib/usePlayer"; import { usePlayer } from "@/lib/usePlayer";
import { useLang } from "@/lib/useLang"; import { useLang } from "@/lib/useLang";
import { dict } from "@/lib/i18n"; import { dict } from "@/lib/i18n";
import { getTracksForPathAsync, type Track } from "@/lib/playlist"; import { getTracksForPath } from "@/lib/playlist";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 시간 포맷 // 시간 포맷
@ -145,21 +145,14 @@ export default function MusicBar() {
loadPlaylist, loadPlaylist,
} = usePlayer(); } = usePlayer();
// 경로가 바뀌면 해당 경로의 플레이리스트 로드 (오늘의 응원가 API 포함), 없으면 초기화 // 경로가 바뀌면 해당 경로의 자동 플레이리스트 로드(월드컵 특설), 그 외는 초기화.
const [tracks, setTracks] = useState<Track[]>([]); // KBO 응원가는 상세 페이지 '응원가 듣기' 버튼이 loadPlaylist 로 직접 채운다.
useEffect(() => { useEffect(() => {
let alive = true; loadPlaylist(getTracksForPath(pathname));
getTracksForPathAsync(pathname).then((ts) => {
if (!alive) return;
setTracks(ts);
loadPlaylist(ts);
});
return () => {
alive = false;
};
}, [pathname, loadPlaylist]); }, [pathname, loadPlaylist]);
if (!hasTrack || tracks.length === 0) return null; // 자동이든 버튼이든 플레이리스트가 실리면 바 노출, 비면 숨김
if (!hasTrack) return null;
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();

View File

@ -35,13 +35,11 @@ const DOOSAN_TRACKS: Track[] = [
{ title: "Bearz Day", artist: "aio2o", src: "/audio/Bearz Day.mp3", cover: "/thumbnail/Bearz Day.webp" }, { title: "Bearz Day", artist: "aio2o", src: "/audio/Bearz Day.mp3", cover: "/thumbnail/Bearz Day.webp" },
]; ];
// 경로 → 트랙 목록. 정확일치(PATH_PLAYLISTS) 우선, 그 외 KBO 두산 경기 // 경로 → 자동 로드 트랙 목록 — 정확일치(PATH_PLAYLISTS, 월드컵 특설)만.
// (match_id 'KBO_{팀A}_{팀B}_{YYYYMMDD}' 에 OB 포함)면 두산 플레이리스트. // KBO 오늘의 응원가는 자동재생하지 않고 상세 페이지의 팀별 '응원가 듣기'
// 버튼(getTeamSongTracks)으로만 재생한다.
export function getTracksForPath(pathname: string): Track[] { export function getTracksForPath(pathname: string): Track[] {
const exact = PATH_PLAYLISTS[pathname]; return PATH_PLAYLISTS[pathname] ?? [];
if (exact) return exact;
if (/^\/match\/KBO_(OB_[A-Z]+|[A-Z]+_OB)_\d{8}$/.test(pathname)) return DOOSAN_TRACKS;
return [];
} }
// ── 오늘의 응원가 (백엔드 Suno 자동 생성) ───────────────────── // ── 오늘의 응원가 (백엔드 Suno 자동 생성) ─────────────────────
@ -76,14 +74,16 @@ function songToTracks(s: SongOut): Track[] {
})); }));
} }
// 경로 → 트랙 목록 (동적 응원가 포함). 경기 상세는 그 경기 두 팀의 곡, // 특정 경기·팀의 오늘의 응원가 트랙 — 상세 페이지 '응원가 듣기' 버튼용.
// 메인("/")은 오늘 전 경기 곡. 동적 곡이 없으면 기존 정적 목록 그대로. // 두산(OB)은 상설 응원가를 뒤에 이어붙인다. 곡이 없으면 빈 배열.
export async function getTracksForPathAsync(pathname: string): Promise<Track[]> { export async function getTeamSongTracks(
const staticTracks = getTracksForPath(pathname); matchId: string,
const m = pathname.match(/^\/match\/(KBO_.+)$/); teamCode: string,
if (!m && pathname !== "/") return staticTracks; ): Promise<Track[]> {
const songs = await fetchSongsCached(); const songs = await fetchSongsCached();
const picked = m ? songs.filter((s) => s.matchId === m[1]) : songs; const dyn = songs
const dyn = picked.flatMap(songToTracks); .filter((s) => s.matchId === matchId && s.teamCode === teamCode)
return dyn.length ? [...dyn, ...staticTracks] : staticTracks; .flatMap(songToTracks);
const fixed = teamCode === "OB" && matchId.startsWith("KBO_") ? DOOSAN_TRACKS : [];
return [...dyn, ...fixed];
} }