feat: 新增音乐播放模块

- 新增 Music 数据模型 + 迁移(title/subtitle/audioFile)
- 后端:公共 /api/music 查询接口 + 管理端 CRUD
  (音频上传专用 multer,限制 20MB)
- 移动端 /music/:id 播放页:
  - 金色印章式唱片 + 旋转虚线环 + 三重金色涟漪
  - preload=auto + HTTP Range 流式加载
  - 浏览器禁止 autoplay 时显示「轻点聆听」overlay
  - 自定义进度条与时间显示
- Admin:新增音乐管理三页(列表/表单/二维码)与侧栏入口
- 导入示例音乐:朝天宫之歌
- Dockerfile + entrypoint 增加 music 资产回灌

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 18:37:44 +08:00
parent dbe8ea5460
commit ae63cb1d85
19 changed files with 999 additions and 2 deletions

View File

@@ -3,6 +3,7 @@ import { AuthProvider } from "./lib/auth";
import LandingPage from "./pages/LandingPage";
import AlbumPage from "./pages/AlbumPage";
import ArticlePage from "./pages/ArticlePage";
import MusicPage from "./pages/MusicPage";
import AdminLogin from "./admin/AdminLogin";
import AdminGuard from "./admin/AdminGuard";
import AdminLayout from "./admin/AdminLayout";
@@ -12,6 +13,9 @@ import StampQRCode from "./admin/StampQRCode";
import ArticleList from "./admin/ArticleList";
import ArticleForm from "./admin/ArticleForm";
import ArticleQRCode from "./admin/ArticleQRCode";
import MusicList from "./admin/MusicList";
import MusicForm from "./admin/MusicForm";
import MusicQRCode from "./admin/MusicQRCode";
import RuleList from "./admin/RuleList";
import RuleForm from "./admin/RuleForm";
import RedemptionLog from "./admin/RedemptionLog";
@@ -30,6 +34,7 @@ export default function App() {
<Route path="/album" element={<AlbumPage />} />
<Route path="/collect/:stampId" element={<CollectRedirect />} />
<Route path="/article/:id" element={<ArticlePage />} />
<Route path="/music/:id" element={<MusicPage />} />
{/* Admin panel */}
<Route path="/admin" element={<AdminLogin />} />
@@ -43,6 +48,10 @@ export default function App() {
<Route path="/admin/articles/new" element={<ArticleForm />} />
<Route path="/admin/articles/:id/edit" element={<ArticleForm />} />
<Route path="/admin/articles/:id/qrcode" element={<ArticleQRCode />} />
<Route path="/admin/music" element={<MusicList />} />
<Route path="/admin/music/new" element={<MusicForm />} />
<Route path="/admin/music/:id/edit" element={<MusicForm />} />
<Route path="/admin/music/:id/qrcode" element={<MusicQRCode />} />
<Route path="/admin/rules" element={<RuleList />} />
<Route path="/admin/rules/new" element={<RuleForm />} />
<Route path="/admin/rules/:id/edit" element={<RuleForm />} />

View File

@@ -3,6 +3,7 @@ import { NavLink, Outlet, useNavigate } from "react-router-dom";
const navItems = [
{ path: "/admin/stamps", label: "图章管理" },
{ path: "/admin/articles", label: "文章管理" },
{ path: "/admin/music", label: "音乐管理" },
{ path: "/admin/rules", label: "兑换规则" },
{ path: "/admin/redemptions", label: "兑换记录" },
];

View File

@@ -0,0 +1,198 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { adminFetch } from "./adminApi";
type Music = {
id: string;
title: string;
subtitle: string | null;
audioFile: string;
sortOrder: number;
enabled: boolean;
};
export default function MusicForm() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = !!id;
const [title, setTitle] = useState("");
const [subtitle, setSubtitle] = useState("");
const [audioFile, setAudioFile] = useState("");
const [sortOrder, setSortOrder] = useState(0);
const [enabled, setEnabled] = useState(true);
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!id) return;
adminFetch<Music[]>("/music").then((list) => {
const item = list.find((m) => m.id === id);
if (item) {
setTitle(item.title);
setSubtitle(item.subtitle || "");
setAudioFile(item.audioFile);
setSortOrder(item.sortOrder);
setEnabled(item.enabled);
}
});
}, [id]);
const handleUpload = async (file: File) => {
if (!id) {
setError("请先保存后再上传音频");
return;
}
setError("");
setUploading(true);
try {
const formData = new FormData();
formData.append("audio", file);
const data = await adminFetch<{ path: string }>(`/music/${id}/upload`, {
method: "POST",
body: formData,
});
setAudioFile(data.path);
} catch (e) {
setError(e instanceof Error ? e.message : "上传失败");
} finally {
setUploading(false);
}
};
const handleSave = async () => {
setError("");
if (!title.trim()) {
setError("请输入标题");
return;
}
setSaving(true);
try {
const payload = {
title: title.trim(),
subtitle: subtitle.trim() || undefined,
sortOrder,
enabled,
};
if (isEdit) {
await adminFetch(`/music/${id}`, {
method: "PUT",
body: JSON.stringify(payload),
});
} else {
const music = await adminFetch<Music>("/music", {
method: "POST",
body: JSON.stringify(payload),
});
navigate(`/admin/music/${music.id}/edit`, { replace: true });
return;
}
navigate("/admin/music");
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<div className="max-w-xl">
<h2 className="text-lg font-semibold text-gray-800 mb-4">
{isEdit ? "编辑音乐" : "添加音乐"}
</h2>
<div className="bg-white rounded-lg border border-gray-200 p-5 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:朝天宫之歌"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
value={subtitle}
onChange={(e) => setSubtitle(e.target.value)}
placeholder="选填,如:金陵千年韵"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1"></label>
<input
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value))}
className="w-24 px-3 py-2 border border-gray-300 rounded-md text-sm
focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="flex items-center pt-6">
<label className="flex items-center gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
</label>
</div>
</div>
{isEdit && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-xs text-gray-400 font-normal ml-2">
MP3 / M4A / WAV 20 MB
</span>
</label>
{audioFile && (
<audio src={audioFile} controls preload="metadata" className="w-full mb-2" />
)}
<input
type="file"
accept="audio/*,.mp3,.m4a,.wav,.ogg"
onChange={(e) => e.target.files?.[0] && handleUpload(e.target.files[0])}
disabled={uploading}
className="text-xs text-gray-500"
/>
{uploading && <p className="text-xs text-gray-500 mt-1"></p>}
</div>
)}
{!isEdit && (
<p className="text-xs text-gray-400"></p>
)}
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-3 pt-2">
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md
hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "保存中..." : "保存"}
</button>
<button
onClick={() => navigate("/admin/music")}
className="px-4 py-2 border border-gray-300 text-gray-600 text-sm rounded-md hover:bg-gray-50"
>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { adminFetch } from "./adminApi";
type Music = {
id: string;
title: string;
subtitle: string | null;
audioFile: string;
sortOrder: number;
enabled: boolean;
};
export default function MusicList() {
const [music, setMusic] = useState<Music[]>([]);
const [loading, setLoading] = useState(true);
const fetchMusic = async () => {
setLoading(true);
try {
const data = await adminFetch<Music[]>("/music");
setMusic(data);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchMusic(); }, []);
const handleDelete = async (id: string, title: string) => {
if (!confirm(`确定删除音乐「${title}」?`)) return;
await adminFetch(`/music/${id}`, { method: "DELETE" });
fetchMusic();
};
const handleToggle = async (id: string, enabled: boolean) => {
await adminFetch(`/music/${id}`, {
method: "PUT",
body: JSON.stringify({ enabled: !enabled }),
});
fetchMusic();
};
if (loading) return <p className="text-gray-500">...</p>;
return (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-800"></h2>
<Link
to="/admin/music/new"
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
</Link>
</div>
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-left px-4 py-3 font-medium text-gray-600"></th>
<th className="text-center px-4 py-3 font-medium text-gray-600"></th>
<th className="text-center px-4 py-3 font-medium text-gray-600"></th>
<th className="text-right px-4 py-3 font-medium text-gray-600"></th>
</tr>
</thead>
<tbody>
{music.map((item) => (
<tr key={item.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="px-4 py-3 text-gray-800 font-medium">{item.title}</td>
<td className="px-4 py-3 text-gray-500 max-w-[220px] truncate">{item.subtitle || "—"}</td>
<td className="px-4 py-3 text-gray-500 text-xs">
{item.audioFile ? (
<audio src={item.audioFile} controls preload="none" className="h-8 max-w-[220px]" />
) : (
<span className="text-gray-300"></span>
)}
</td>
<td className="px-4 py-3 text-center text-gray-500">{item.sortOrder}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggle(item.id, item.enabled)}
className={`px-2 py-0.5 rounded text-xs ${
item.enabled ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
}`}
>
{item.enabled ? "启用" : "禁用"}
</button>
</td>
<td className="px-4 py-3 text-right space-x-2 whitespace-nowrap">
<Link to={`/admin/music/${item.id}/edit`} className="text-blue-600 hover:underline">
</Link>
<Link to={`/admin/music/${item.id}/qrcode`} className="text-blue-600 hover:underline">
</Link>
<button onClick={() => handleDelete(item.id, item.title)} className="text-red-500 hover:underline">
</button>
</td>
</tr>
))}
{music.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
import { useState, useEffect, useRef } from "react";
import { useParams, Link } from "react-router-dom";
import { adminFetch } from "./adminApi";
type QRData = {
qrDataUrl: string;
musicUrl: string;
musicTitle: string;
};
export default function MusicQRCode() {
const { id } = useParams();
const [data, setData] = useState<QRData | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!id) return;
adminFetch<QRData>(`/music/${id}/qrcode`)
.then(setData)
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
if (!data || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d")!;
const img = new Image();
img.onload = () => {
const padding = 20;
const textHeight = 40;
canvas.width = img.width + padding * 2;
canvas.height = img.height + padding * 2 + textHeight;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, padding, padding);
ctx.fillStyle = "#666666";
ctx.font = "12px sans-serif";
ctx.textAlign = "center";
ctx.fillText(data.musicUrl, canvas.width / 2, img.height + padding * 2 + 12);
};
img.src = data.qrDataUrl;
}, [data]);
const handleDownload = () => {
if (!canvasRef.current || !data) return;
const link = document.createElement("a");
link.download = `${data.musicTitle}-二维码.png`;
link.href = canvasRef.current.toDataURL("image/png");
link.click();
};
const handleCopy = async () => {
if (!data) return;
try {
await navigator.clipboard.writeText(data.musicUrl);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
} catch {
const ta = document.createElement("textarea");
ta.value = data.musicUrl;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
}
};
if (loading) return <p className="text-gray-500">...</p>;
if (!data) return <p className="text-gray-500"></p>;
return (
<div className="max-w-md">
<div className="flex items-center gap-2 mb-4">
<Link to="/admin/music" className="text-gray-400 hover:text-gray-600">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</Link>
<h2 className="text-lg font-semibold text-gray-800">{data.musicTitle} </h2>
</div>
<div className="bg-white rounded-lg border border-gray-200 p-6 text-center space-y-4">
<div className="inline-block">
<img src={data.qrDataUrl} alt="二维码" className="w-64 h-64" />
</div>
<p className="text-xs text-gray-500 break-all select-all">{data.musicUrl}</p>
<canvas ref={canvasRef} className="hidden" />
<div className="flex flex-wrap items-center justify-center gap-3">
<button
onClick={handleCopy}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 min-w-[160px]"
>
{copied ? "已复制 ✓" : "复制链接(写入 NFC"}
</button>
<button
onClick={handleDownload}
className="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-md hover:bg-gray-50"
>
</button>
</div>
<p className="text-xs text-gray-400 pt-1">
NFC
</p>
</div>
</div>
);
}

View File

@@ -101,6 +101,16 @@
to { opacity: 1; transform: scale(1); }
}
@keyframes music-ripple {
0% { transform: scale(0.9); opacity: 0.5; }
100% { transform: scale(2.0); opacity: 0; }
}
@keyframes music-disc-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* ===== Component Classes (in @layer components) ===== */
@layer components {
.safe-bottom {
@@ -116,6 +126,54 @@
.animate-overlay-fade { animation: overlay-fade 0.25s ease-out both; }
.animate-float { animation: float 4s ease-in-out infinite; }
.animate-scale-in { opacity: 0; animation: scale-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; }
.animate-music-ripple { animation: music-ripple 2.4s cubic-bezier(0.22, 1, 0.36, 1) infinite; }
.animate-music-spin { animation: music-disc-spin 18s linear infinite; }
.music-progress {
-webkit-appearance: none;
appearance: none;
height: 3px;
background: transparent;
cursor: pointer;
}
.music-progress::-webkit-slider-runnable-track {
height: 3px;
border-radius: 999px;
background: linear-gradient(to right,
var(--gold) 0%,
var(--gold) var(--progress, 0%),
rgba(212, 165, 116, 0.2) var(--progress, 0%),
rgba(212, 165, 116, 0.2) 100%);
}
.music-progress::-moz-range-track {
height: 3px;
border-radius: 999px;
background: rgba(212, 165, 116, 0.2);
}
.music-progress::-moz-range-progress {
height: 3px;
border-radius: 999px;
background: var(--gold);
}
.music-progress::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--gold-light);
border: 1px solid rgba(255, 255, 255, 0.3);
margin-top: -4.5px;
box-shadow: 0 0 8px rgba(212, 165, 116, 0.5);
}
.music-progress::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--gold-light);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 0 8px rgba(212, 165, 116, 0.5);
}
/* Stagger children */
.stagger-children > * {

View File

@@ -0,0 +1,276 @@
import { useEffect, useRef, useState } from "react";
import { useParams, Link } from "react-router-dom";
import { apiFetch } from "../lib/api";
type MusicDetail = {
id: string;
title: string;
subtitle: string | null;
audioFile: string;
};
type LoadState = "loading" | "ok" | "error";
function fmt(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export default function MusicPage() {
const { id } = useParams();
const audioRef = useRef<HTMLAudioElement | null>(null);
const [music, setMusic] = useState<MusicDetail | null>(null);
const [state, setState] = useState<LoadState>("loading");
const [playing, setPlaying] = useState(false);
const [needsGesture, setNeedsGesture] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
if (!id) return;
setState("loading");
apiFetch<MusicDetail>(`/music/${id}`)
.then((data) => {
setMusic(data);
setState("ok");
})
.catch(() => setState("error"));
}, [id]);
// Try to autoplay with sound once the audio is mounted. If the browser
// blocks it (iOS/Android usually do), surface the tap-to-play overlay.
useEffect(() => {
if (!music || !audioRef.current) return;
audioRef.current.play().catch(() => setNeedsGesture(true));
}, [music]);
const togglePlay = () => {
const el = audioRef.current;
if (!el) return;
if (el.paused) {
el.play().catch(() => setNeedsGesture(true));
} else {
el.pause();
}
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const el = audioRef.current;
if (!el || !duration) return;
const t = (Number(e.target.value) / 100) * duration;
el.currentTime = t;
setCurrentTime(t);
};
const handleUserStart = () => {
setNeedsGesture(false);
audioRef.current?.play().catch(() => setNeedsGesture(true));
};
if (state === "loading") {
return (
<div className="min-h-svh flex items-center justify-center bg-[var(--bg-dark-deep)]">
<p className="text-[var(--gold)]/70 text-sm tracking-widest"></p>
</div>
);
}
if (state === "error" || !music) {
return (
<div className="min-h-svh flex flex-col items-center justify-center gap-4 px-6 bg-[var(--bg-dark-deep)]">
<p className="text-[var(--gold-light)]/80"></p>
<Link
to="/"
className="px-5 py-2 rounded-full bg-[var(--gold)] text-[var(--bg-dark)] text-sm hover:bg-[var(--gold-hover)]"
>
</Link>
</div>
);
}
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div className="relative min-h-svh overflow-hidden bg-[var(--bg-dark-deep)] grain-overlay">
{/* Ambient gradient backdrop */}
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage: `
radial-gradient(ellipse 80% 50% at 50% 30%, rgba(212, 165, 116, 0.18) 0%, transparent 70%),
radial-gradient(ellipse 60% 40% at 50% 100%, rgba(199, 91, 57, 0.10) 0%, transparent 70%)
`,
}}
/>
{/* Top back */}
<Link
to="/"
aria-label="返回"
className="fixed top-4 left-4 z-20 w-10 h-10 rounded-full bg-white/5 backdrop-blur border border-white/10
flex items-center justify-center text-[var(--gold-light)] hover:bg-white/10"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</Link>
<div className="relative z-10 flex flex-col items-center justify-center min-h-svh px-6 py-16">
{/* Header */}
<div className="text-center animate-fade-in-up">
<div className="inline-flex items-center gap-3 mb-6">
<span className="block w-8 h-px bg-[var(--gold)]/50" />
<span className="text-[var(--gold)] text-[10px] tracking-[0.4em] uppercase"
style={{ fontFamily: "'Playfair Display', serif" }}>
CityWalk · Song
</span>
<span className="block w-8 h-px bg-[var(--gold)]/50" />
</div>
<h1
className="text-[var(--text-inverted)] leading-tight mb-2"
style={{
fontSize: "clamp(2rem, 7vw, 2.75rem)",
fontFamily: "'Playfair Display', serif",
fontWeight: 700,
letterSpacing: "-0.01em",
}}
>
{music.title}
</h1>
{music.subtitle && (
<p className="text-[var(--gold-light)]/70 text-sm tracking-[0.1em]">
{music.subtitle}
</p>
)}
</div>
{/* Disc */}
<div className="relative mt-14 animate-scale-in" style={{ animationDelay: "0.2s" }}>
{/* Ripples */}
{playing && (
<>
<span
className="absolute inset-0 rounded-full pointer-events-none animate-music-ripple"
style={{ border: "1.5px solid rgba(212, 165, 116, 0.55)" }}
/>
<span
className="absolute inset-0 rounded-full pointer-events-none animate-music-ripple"
style={{ border: "1.5px solid rgba(212, 165, 116, 0.55)", animationDelay: "0.8s" }}
/>
<span
className="absolute inset-0 rounded-full pointer-events-none animate-music-ripple"
style={{ border: "1.5px solid rgba(212, 165, 116, 0.55)", animationDelay: "1.6s" }}
/>
</>
)}
{/* Rotating dashed ring */}
<div
className={`absolute -inset-3 rounded-full pointer-events-none ${playing ? "animate-music-spin" : ""}`}
style={{
border: "1.5px dashed rgba(212, 165, 116, 0.45)",
}}
/>
<div
className="absolute -inset-8 rounded-full pointer-events-none"
style={{
border: "1px solid rgba(212, 165, 116, 0.12)",
}}
/>
{/* Central disc button */}
<button
onClick={togglePlay}
aria-label={playing ? "暂停" : "播放"}
className="relative w-56 h-56 rounded-full flex items-center justify-center transition-transform active:scale-95"
style={{
background: "radial-gradient(circle at 35% 30%, rgba(232, 201, 160, 0.55) 0%, rgba(212, 165, 116, 0.25) 45%, rgba(26, 26, 46, 0.9) 100%)",
boxShadow: "0 0 60px rgba(212, 165, 116, 0.25), inset 0 1px 0 rgba(255,255,255,0.15), inset 0 -8px 24px rgba(0,0,0,0.4)",
}}
>
{/* Inner darker ring for "record" feel */}
<span className="absolute inset-6 rounded-full pointer-events-none"
style={{
background: "radial-gradient(circle, rgba(10,10,18,0.7) 0%, rgba(26,26,46,0.4) 60%, transparent 70%)",
border: "1px solid rgba(212, 165, 116, 0.2)",
}}
/>
{/* Play / Pause glyph */}
{playing ? (
<svg width="36" height="36" viewBox="0 0 24 24" fill="currentColor" className="relative z-10 text-[var(--gold-light)] drop-shadow-[0_0_8px_rgba(212,165,116,0.6)]">
<rect x="6" y="5" width="4" height="14" rx="1" />
<rect x="14" y="5" width="4" height="14" rx="1" />
</svg>
) : (
<svg width="40" height="40" viewBox="0 0 24 24" fill="currentColor" className="relative z-10 text-[var(--gold-light)] drop-shadow-[0_0_8px_rgba(212,165,116,0.6)] ml-1">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
</div>
{/* Progress & time */}
<div className="w-full max-w-[320px] mt-14 animate-fade-in-up" style={{ animationDelay: "0.4s" }}>
<input
type="range"
min={0}
max={100}
step={0.1}
value={progress}
onChange={handleSeek}
className="w-full music-progress"
style={{ ["--progress" as string]: `${progress}%` }}
/>
<div className="mt-2 flex justify-between text-[11px] text-[var(--gold-light)]/70 tabular-nums tracking-wider">
<span>{fmt(currentTime)}</span>
<span>{fmt(duration)}</span>
</div>
</div>
{/* Hint text */}
<p className="mt-10 text-[var(--gold-light)]/40 text-[11px] tracking-[0.25em]">
{playing ? "PLAYING · 轻点圆盘暂停" : "PAUSED · 轻点圆盘播放"}
</p>
</div>
{/* Hidden audio element — preload auto so it starts buffering immediately */}
<audio
ref={audioRef}
src={music.audioFile}
preload="auto"
playsInline
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onEnded={() => setPlaying(false)}
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
/>
{/* Tap-to-play overlay when autoplay is blocked */}
{needsGesture && (
<div
className="fixed inset-0 z-30 flex flex-col items-center justify-center bg-[var(--bg-dark-deep)]/85 backdrop-blur-sm animate-overlay-fade"
onClick={handleUserStart}
>
<div className="text-center px-8">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-[var(--gold)]/15 border border-[var(--gold)]/50 flex items-center justify-center animate-pulse-soft">
<svg width="34" height="34" viewBox="0 0 24 24" fill="currentColor" className="text-[var(--gold)] ml-1">
<path d="M8 5v14l11-7z" />
</svg>
</div>
<p className="text-[var(--gold-light)] text-base mb-2" style={{ fontFamily: "'Playfair Display', serif" }}>
</p>
<p className="text-[var(--gold-light)]/50 text-xs tracking-[0.2em]">Tap anywhere to play</p>
</div>
</div>
)}
</div>
);
}