341 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|