2025-08-21 18:37:40 +08:00

221 lines
8.4 KiB
TypeScript
Raw Permalink 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 { useState, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, Maximize2, Minimize2 } from "lucide-react";
import clsx from "clsx";
// 展台四模块视频配置(请将对应 mp4/webm 文件放入 public/videos/ 下)
const videos = [
{
key: "mechanics",
title: "力学 · 单摆高精度测量",
src: "/videos/mechanics.mp4",
desc: "CV 追踪 + AI 拟合g 误差压至 0.43%,提取阻尼 γ 与品质因数 Q。探究傅科摆效应。"
},
{
key: "circuit",
title: "电路 · 实物 → 虚拟转换",
src: "/videos/circuit.mp4",
desc: "YOLO 识别元件与连线,自动生成可交互虚拟电路 + AI 问答辅导。"
},
{
key: "electromag",
title: "电磁 · 能量转换优化",
src: "/videos/electromag.mp4",
desc: "视觉 + 单片机测电磁转换效率 + 未来 BO/RL 迭代脉冲参数,提高加速/制动能量效率。"
},
{
key: "optics",
title: "光学 · 分光计智能辅助",
src: "/videos/optics.mp4",
desc: "十字像追踪 + 分步引导 + PID 调节。"
}
];
interface VideoStateRef {
currentTime: number;
wasPlaying: boolean;
}
export default function ExpoPage() {
const [active, setActive] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const timeRefs = useRef<Record<string, VideoStateRef>>({});
const containerRefs = useRef<Record<string, HTMLVideoElement | null>>({});
const mainRef = useRef<HTMLElement | null>(null);
// 记录时间用于在放大时继续播放(非严格同步,够展示用)
const handleBeforeExpand = (key: string) => {
const vid = containerRefs.current[key];
if (vid) {
timeRefs.current[key] = {
currentTime: vid.currentTime,
wasPlaying: !vid.paused
};
}
setActive(key);
};
const handleRestore = () => {
setActive(null);
};
// 在放大的 video 元素上恢复播放进度
const expandedVideoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
if (active && expandedVideoRef.current) {
const state = timeRefs.current[active];
if (state) {
try {
expandedVideoRef.current.currentTime = state.currentTime;
} catch (_) {
/* Safari 某些情况下可能阻止精确跳转,忽略 */
}
if (state.wasPlaying) expandedVideoRef.current.play().catch(() => {});
}
}
}, [active]);
// ESC 关闭
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && active) setActive(null);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [active]);
// Fullscreen 监听
useEffect(() => {
const handler = () => {
const fsEl = document.fullscreenElement || (document as any).webkitFullscreenElement;
setIsFullscreen(!!fsEl);
};
document.addEventListener("fullscreenchange", handler);
document.addEventListener("webkitfullscreenchange", handler as any);
return () => {
document.removeEventListener("fullscreenchange", handler);
document.removeEventListener("webkitfullscreenchange", handler as any);
};
}, []);
const enterFs = async () => {
const el = mainRef.current;
if (!el) return;
try {
if (el.requestFullscreen) await el.requestFullscreen();
else if ((el as any).webkitRequestFullscreen) (el as any).webkitRequestFullscreen();
} catch (_) {
/* ignore */
}
};
const exitFs = async () => {
try {
if (document.exitFullscreen) await document.exitFullscreen();
else if ((document as any).webkitExitFullscreen) (document as any).webkitExitFullscreen();
} catch (_) {
/* ignore */
}
};
const toggleFs = () => {
if (isFullscreen) exitFs(); else enterFs();
};
return (
<main ref={mainRef} className="relative min-h-screen w-full bg-[#05080b] text-slate-100 overflow-hidden select-none">
{/* 全屏按钮 */}
<button
onClick={toggleFs}
className="fixed z-[60] bottom-2 right-2 md:bottom-3 md:right-3 w-9 h-9 md:w-10 md:h-10 flex items-center justify-center rounded-full bg-black/60 hover:bg-black/80 border border-white/10 text-slate-200 backdrop-blur-sm transition"
aria-label={isFullscreen ? "退出全屏" : "进入全屏"}
>
{isFullscreen ? <Minimize2 className="w-4 h-4 md:w-5 md:h-5" /> : <Maximize2 className="w-4 h-4 md:w-5 md:h-5" />}
</button>
{/* 2x2 全屏栅格 */}
<div className="grid grid-cols-1 grid-rows-4 md:grid-cols-2 md:grid-rows-2 w-full h-screen">
{videos.map(v => (
<motion.div
key={v.key}
layoutId={`card-${v.key}`}
className={clsx(
"relative group overflow-hidden flex cursor-pointer bg-black",
active && active !== v.key && "opacity-25 scale-[0.99] transition"
)}
onClick={() => handleBeforeExpand(v.key)}
>
<video
ref={el => { containerRefs.current[v.key] = el; }}
className="w-full h-full object-contain"
src={v.src}
playsInline
muted
autoPlay
loop
preload="auto"
/>
{/* 角标信息,默认半透明,悬停/触摸更亮 */}
<div className="absolute left-0 top-0 p-2 md:p-3 bg-black/35 backdrop-blur-sm rounded-br-xl md:rounded-br-2xl text-[12px] md:text-xs leading-snug transition group-hover:bg-black/55">
<p className="font-semibold text-slate-100/95 mb-0.5 md:mb-1 truncate max-w-[10rem] md:max-w-[14rem]">{v.title}</p>
<p className="text-[10px] md:text-[10px] text-slate-300/80 line-clamp-2 md:line-clamp-2 max-w-[12rem] md:max-w-[16rem]">{v.desc}</p>
</div>
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition bg-black/25 flex items-center justify-center text-[11px] tracking-wide font-medium"></div>
</motion.div>
))}
</div>
<AnimatePresence>
{active && (
<>
<motion.div
key="backdrop"
className="fixed inset-0 bg-black/70 backdrop-blur-sm z-40"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
{videos.filter(v => v.key === active).map(v => (
<motion.div
key={v.key}
layoutId={`card-${v.key}`}
className="fixed z-50 inset-0 flex flex-col overflow-hidden bg-black"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ type: "spring", stiffness: 180, damping: 22 }}
>
<div className="relative flex-1 bg-black flex items-center justify-center min-h-0">
{/* 居中容器:使用 max-* 避免视频固有尺寸撑大父容器 */}
<video
ref={expandedVideoRef}
className="max-w-full max-h-full w-auto h-auto object-contain bg-black select-none"
src={v.src}
playsInline
muted
autoPlay
loop
controls
/>
{/* 叠加标题描述层,置于上方 */}
<div className="absolute top-0 left-0 right-0 z-10 p-3 md:p-5 flex flex-col gap-2 bg-gradient-to-b from-black/70 to-transparent pointer-events-none">
<h2 className="text-base md:text-2xl font-semibold tracking-tight drop-shadow">{v.title}</h2>
<p className="text-[10px] md:text-sm text-slate-300 max-w-4xl leading-relaxed">{v.desc}</p>
</div>
<button
onClick={handleRestore}
className="absolute z-10 top-2 right-2 md:top-4 md:right-4 w-9 h-9 rounded-full bg-black/60 hover:bg-black/80 text-slate-200 flex items-center justify-center border border-white/10 backdrop-blur-sm transition"
aria-label="关闭放大"
>
<X className="w-5 h-5" />
</button>
</div>
</motion.div>
))}
</>
)}
</AnimatePresence>
</main>
);
}