UI-UX/src/components/artist/PerformanceVideo.tsx
iye ed222d1c5f
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 5m17s
feat(ui): scroll-aware nav glass + floating back button + hero polish
- 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>
2026-05-14 12:25:54 +08:00

251 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}