o2o-triple-pick/frontend/src/components/MusicBar.tsx

341 lines
13 KiB
TypeScript

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 }) {
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];
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>) => {
const el = e.currentTarget;
el.setPointerCapture(e.pointerId);
const update = (ev: PointerEvent) => {
const rect = el.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.playerUnmute : t.playerMute}
className="p-1 text-white/45 hover:text-white/80 transition-colors shrink-0"
>
<IconVolume level={displayVolume} />
</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>
);
}