All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m17s
- Navigation: fixed + transparent over Hero (home) / at page top (other routes); fades to glass-on-scroll. Glass uses surface tone matching site cards. - Filter bar sticky glass synced to nav recipe (no seam between layers). - HeroBanner: full-viewport video, center title removed, bottom dim overlay removed, eyebrow/countdown repositioned below the nav. - ArtistDetail: removed portrait shadow; added FloatingBackButton that uses router.back() with internal-history fallback to /. - Floating buttons (back + vote) translateY upward to avoid footer rather than disappearing, via useFooterPush. - Home: useScrollRestore preserves scroll position on return from detail pages; temporarily disables scroll-snap during restore. - PerformanceVideo: max-w capped by 85svh*16/9 so small viewports never crop. - ArtistFilters: hide horizontal scrollbar thumb in tag container. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
251 lines
8.4 KiB
TypeScript
251 lines
8.4 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
MouseEvent as ReactMouseEvent,
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { Play, Pause, Volume2, VolumeX, Maximize2 } from "lucide-react";
|
||
import { cn } from "@/lib/cn";
|
||
|
||
interface PerformanceVideoProps {
|
||
src?: string;
|
||
poster?: string;
|
||
className?: string;
|
||
}
|
||
|
||
function fmtTime(s: number): string {
|
||
if (!isFinite(s) || s < 0) return "00:00";
|
||
const m = Math.floor(s / 60);
|
||
const sec = Math.floor(s % 60);
|
||
return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||
}
|
||
|
||
export default function PerformanceVideo({
|
||
src,
|
||
poster,
|
||
className,
|
||
}: PerformanceVideoProps) {
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
const progressRef = useRef<HTMLDivElement>(null);
|
||
const [playing, setPlaying] = useState(false);
|
||
const [muted, setMuted] = useState(false);
|
||
const [current, setCurrent] = useState(0);
|
||
const [duration, setDuration] = useState(0);
|
||
const [seeking, setSeeking] = useState(false);
|
||
|
||
// 视频时间事件 + 首帧封面(loadedmetadata 后 seek 0.001s 让浏览器渲染首帧)
|
||
useEffect(() => {
|
||
const v = videoRef.current;
|
||
if (!v) return;
|
||
const onTime = () => !seeking && setCurrent(v.currentTime);
|
||
const onMeta = () => {
|
||
setDuration(v.duration);
|
||
// 没有 poster 时强制渲染首帧
|
||
if (!poster && v.currentTime === 0) {
|
||
try {
|
||
v.currentTime = 0.001;
|
||
} catch {
|
||
/* noop */
|
||
}
|
||
}
|
||
};
|
||
const onEnd = () => setPlaying(false);
|
||
v.addEventListener("timeupdate", onTime);
|
||
v.addEventListener("loadedmetadata", onMeta);
|
||
v.addEventListener("ended", onEnd);
|
||
return () => {
|
||
v.removeEventListener("timeupdate", onTime);
|
||
v.removeEventListener("loadedmetadata", onMeta);
|
||
v.removeEventListener("ended", onEnd);
|
||
};
|
||
}, [seeking, poster]);
|
||
|
||
const togglePlay = () => {
|
||
const v = videoRef.current;
|
||
if (!v) return;
|
||
if (v.paused) {
|
||
v.play();
|
||
setPlaying(true);
|
||
} else {
|
||
v.pause();
|
||
setPlaying(false);
|
||
}
|
||
};
|
||
|
||
const toggleMute = () => {
|
||
const v = videoRef.current;
|
||
if (!v) return;
|
||
v.muted = !v.muted;
|
||
setMuted(v.muted);
|
||
};
|
||
|
||
const goFullscreen = () => {
|
||
videoRef.current?.requestFullscreen?.();
|
||
};
|
||
|
||
// 进度条:点击 / 拖拽 seek
|
||
const seekTo = (clientX: number) => {
|
||
const bar = progressRef.current;
|
||
const v = videoRef.current;
|
||
if (!bar || !v || !duration) return;
|
||
const rect = bar.getBoundingClientRect();
|
||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||
const t = ratio * duration;
|
||
v.currentTime = t;
|
||
setCurrent(t);
|
||
};
|
||
|
||
const handleBarMouseDown = (e: ReactMouseEvent<HTMLDivElement>) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setSeeking(true);
|
||
seekTo(e.clientX);
|
||
const onMove = (ev: MouseEvent) => seekTo(ev.clientX);
|
||
const onUp = () => {
|
||
setSeeking(false);
|
||
window.removeEventListener("mousemove", onMove);
|
||
window.removeEventListener("mouseup", onUp);
|
||
};
|
||
window.addEventListener("mousemove", onMove);
|
||
window.addEventListener("mouseup", onUp);
|
||
};
|
||
|
||
const handleBarTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
|
||
e.stopPropagation();
|
||
setSeeking(true);
|
||
const t = e.touches[0];
|
||
if (t) seekTo(t.clientX);
|
||
const onMove = (ev: TouchEvent) => {
|
||
const tt = ev.touches[0];
|
||
if (tt) seekTo(tt.clientX);
|
||
};
|
||
const onEnd = () => {
|
||
setSeeking(false);
|
||
window.removeEventListener("touchmove", onMove);
|
||
window.removeEventListener("touchend", onEnd);
|
||
};
|
||
window.addEventListener("touchmove", onMove, { passive: true });
|
||
window.addEventListener("touchend", onEnd);
|
||
};
|
||
|
||
const progress = duration > 0 ? (current / duration) * 100 : 0;
|
||
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"relative w-full bg-black rounded-xl overflow-hidden border border-white/[0.08] group",
|
||
// 16:9 比例 + 高度封顶 85svh:在小屏(笔记本)上等比缩窄宽度,确保视频可全显
|
||
"aspect-video max-w-[calc(85svh*16/9)] mx-auto",
|
||
className,
|
||
)}
|
||
onClick={src ? togglePlay : undefined}
|
||
role={src ? "button" : undefined}
|
||
aria-label={src ? (playing ? "暂停" : "播放") : undefined}
|
||
>
|
||
{src ? (
|
||
<video
|
||
ref={videoRef}
|
||
src={src}
|
||
poster={poster || undefined}
|
||
playsInline
|
||
preload="metadata"
|
||
className="absolute inset-0 w-full h-full object-contain"
|
||
/>
|
||
) : (
|
||
<div className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(circle_at_50%_40%,rgba(139,92,246,0.30)_0%,#1a1638_55%,#08051a_100%)]">
|
||
<p className="font-label text-[10px] tracking-[0.3em] uppercase text-white/40">
|
||
视频暂未上线
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 中央播放提示(未播放时) · 仅作视觉提示,真正的点击区是整个容器 */}
|
||
{!playing && src && (
|
||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center z-10">
|
||
<span className="w-16 h-16 rounded-full bg-white/15 backdrop-blur-md border-2 border-white/40 flex items-center justify-center text-white transition-transform group-hover:scale-110">
|
||
<Play size={22} fill="white" />
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 播放中 hover 中央暂停提示 */}
|
||
{playing && (
|
||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center z-10 opacity-0 group-hover:opacity-100 transition-opacity bg-black/30">
|
||
<span className="w-14 h-14 rounded-full bg-white/15 backdrop-blur-md border-2 border-white/40 flex items-center justify-center text-white">
|
||
<Pause size={20} />
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 底部控制条 · 进度 + 时间 + 音量 + 全屏。点击控件不触发外层播放/暂停 */}
|
||
{src && (
|
||
<div
|
||
className={cn(
|
||
"absolute inset-x-0 bottom-0 z-20 px-3 pt-10 pb-3 bg-gradient-to-t from-black/85 via-black/35 to-transparent transition-opacity",
|
||
playing && !seeking
|
||
? "opacity-0 group-hover:opacity-100"
|
||
: "opacity-100",
|
||
)}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* 进度条 · 可点击 / 拖拽 */}
|
||
<div
|
||
ref={progressRef}
|
||
onMouseDown={handleBarMouseDown}
|
||
onTouchStart={handleBarTouchStart}
|
||
className="relative h-1.5 rounded-full bg-white/15 cursor-pointer group/bar"
|
||
role="slider"
|
||
aria-label="视频进度"
|
||
aria-valuemin={0}
|
||
aria-valuemax={duration || 0}
|
||
aria-valuenow={current}
|
||
>
|
||
<div
|
||
className="absolute inset-y-0 left-0 rounded-full bg-purple-400 shadow-[0_0_10px_rgba(167,139,250,0.6)]"
|
||
style={{ width: `${progress}%` }}
|
||
/>
|
||
<div
|
||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-[0_0_8px_rgba(255,255,255,0.7)] opacity-0 group-hover/bar:opacity-100 transition-opacity"
|
||
style={{ left: `${progress}%` }}
|
||
/>
|
||
</div>
|
||
|
||
<div className="mt-2.5 flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={togglePlay}
|
||
aria-label={playing ? "暂停" : "播放"}
|
||
className="text-white/85 hover:text-white"
|
||
>
|
||
{playing ? <Pause size={16} /> : <Play size={16} fill="currentColor" />}
|
||
</button>
|
||
<span className="font-display text-[11px] text-white/75 tabular-nums">
|
||
{fmtTime(current)} / {fmtTime(duration)}
|
||
</span>
|
||
<div className="ml-auto flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={toggleMute}
|
||
aria-label={muted ? "取消静音" : "静音"}
|
||
className="text-white/85 hover:text-white"
|
||
>
|
||
{muted ? <VolumeX size={14} /> : <Volume2 size={14} />}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={goFullscreen}
|
||
aria-label="全屏"
|
||
className="text-white/85 hover:text-white"
|
||
>
|
||
<Maximize2 size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|