Files
app/wei-ai-demo/pages/Player.tsx
2026-01-28 19:10:19 +08:00

285 lines
13 KiB
TypeScript

import React, { useEffect, useState, useMemo, useRef } from 'react';
import { X, Pause, Play, AlertTriangle } from 'lucide-react';
import { AreaChart, Area, ResponsiveContainer, YAxis, XAxis, ReferenceDot } from 'recharts';
import { Scenario } from '../types';
interface PlayerProps {
scenario: Scenario | null;
onClose: () => void;
}
// Mock script synced with progress (0-100)
const DIALOGUE_SCRIPT = [
{ time: 0, text: "正在建立神经连接..." },
{ time: 5, text: "(检测到心率轻微上升)" },
{ time: 12, text: "“放松,把控制权交给我。”" },
{ time: 20, text: "“很好,保持呼吸频率...”" },
{ time: 28, text: "正在启动触觉反馈模块" },
{ time: 35, text: "“感觉到那个节奏了吗?”" },
{ time: 45, text: "强度正在逐渐增加..." },
{ time: 55, text: "“不要抵抗,顺从它。”" },
{ time: 65, text: "“我会稍微加快一点速度。”" },
{ time: 75, text: "(设备输出功率提升至 80%)" },
{ time: 85, text: "“就是现在...”" },
{ time: 92, text: "“做得很好,指挥官。”" },
{ time: 100, text: "连接结束。" },
];
const Player: React.FC<PlayerProps> = ({ scenario, onClose }) => {
const [isPlaying, setIsPlaying] = useState(true);
const [progress, setProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false); // Track dragging state
const [showAlert, setShowAlert] = useState(false);
// Generate 101 data points (0 to 100) to map perfectly to progress
const data = useMemo(() => {
return Array.from({ length: 101 }, (_, i) => {
// Create a realistic wave: Warmup -> Build up -> Climax -> Cooldown
let hz = 10;
if (i < 20) hz = 10 + (i * 1.5) + (Math.random() * 5); // Warmup
else if (i < 50) hz = 40 + Math.sin(i * 0.5) * 10 + (Math.random() * 10); // Plateau/Tease
else if (i < 85) hz = 70 + (i - 50) * 0.8 + (Math.random() * 15); // Build up to climax
else hz = 90 - ((i - 85) * 5) + (Math.random() * 5); // Cooldown
return {
time: i,
hz: Math.max(0, Math.min(100, hz))
};
});
}, []);
useEffect(() => {
let interval: any;
if (isPlaying && !isDragging) {
interval = setInterval(() => {
setProgress(p => {
if (p >= 100) {
setIsPlaying(false);
return 100;
}
return p + 0.1;
});
}, 50);
}
return () => {
if (interval) clearInterval(interval);
};
}, [isPlaying, isDragging]);
const handleEmergencyStop = () => {
setIsPlaying(false);
setShowAlert(true);
if (window.navigator && window.navigator.vibrate) {
window.navigator.vibrate([100, 50, 100]);
}
setTimeout(() => {
onClose();
}, 1500);
};
// Handle Seek / Drag
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVal = parseFloat(e.target.value);
setProgress(newVal);
};
const handleDragStart = () => {
setIsDragging(true);
};
const handleDragEnd = () => {
setIsDragging(false);
};
// Calculate active line based on progress
const activeLineIndex = DIALOGUE_SCRIPT.findIndex((line, index) => {
const nextLine = DIALOGUE_SCRIPT[index + 1];
return progress >= line.time && (!nextLine || progress < nextLine.time);
});
// Get current HZ for the dot
const currentHz = data[Math.min(100, Math.floor(progress))]?.hz || 0;
if (!scenario) return null;
return (
<div className="fixed inset-0 z-[100] bg-[#2e1065] flex flex-col" onDoubleClick={handleEmergencyStop}>
{/* Background Visual */}
<div className="absolute inset-0 opacity-40">
<img src={scenario.cover} className="w-full h-full object-cover blur-lg scale-110" alt="bg" />
<div className="absolute inset-0 bg-gradient-to-t from-[#2e1065] via-[#2e1065]/80 to-[#4c1d95]/60"></div>
</div>
{/* Emergency Overlay */}
{showAlert && (
<div className="absolute inset-0 z-[110] bg-[#2e1065]/95 flex flex-col items-center justify-center animate-pulse">
<AlertTriangle size={64} className="text-[#F43F5E] mb-4" />
<h2 className="text-2xl font-bold text-[#F43F5E] tracking-widest">EMERGENCY STOP</h2>
<p className="text-[#E2E8F0] mt-2"></p>
</div>
)}
{/* Header - Seamless Gradient */}
<div className="relative z-10 flex justify-between items-center p-6 bg-gradient-to-b from-[#2e1065] via-[#2e1065]/60 to-transparent">
<div className="flex flex-col">
<h2 className="text-lg font-bold text-white tracking-wide drop-shadow-md">{scenario.title}</h2>
<span className="text-[10px] text-[#C084FC] uppercase tracking-wider font-medium">{scenario.category} SCENARIO</span>
</div>
<button onClick={onClose} className="p-2 rounded-full bg-white/10 backdrop-blur-md active:bg-white/20 transition-colors border border-white/10 hover:border-white/30">
<X size={20} className="text-white/90" />
</button>
</div>
{/* Central Content (Rolling Lyrics) */}
<div className="flex-1 relative z-10 w-full overflow-hidden">
{/* Top Gradient Mask */}
<div className="absolute top-0 left-0 w-full h-24 bg-gradient-to-b from-[#2e1065]/0 to-transparent z-20 pointer-events-none"></div>
<div
className="flex flex-col items-center w-full transition-transform duration-700 ease-[cubic-bezier(0.25,1,0.5,1)]"
style={{
marginTop: 'calc(50vh - 64px)',
transform: `translateY(-${activeLineIndex * 64}px)`
}}
>
{DIALOGUE_SCRIPT.map((line, i) => {
const isActive = i === activeLineIndex;
const distance = Math.abs(activeLineIndex - i);
const isFar = distance > 2;
return (
<div
key={i}
className={`h-16 w-full px-8 flex items-center justify-center text-center transition-all duration-500 ${
isActive
? 'scale-110 opacity-100 blur-0'
: isFar
? 'opacity-10 scale-90 blur-[2px]'
: 'opacity-40 scale-95 blur-[0.5px]'
}`}
>
<p className={`font-medium leading-tight ${isActive ? 'text-white text-lg drop-shadow-[0_0_15px_rgba(192,132,252,0.6)]' : 'text-[#CBD5E1] text-base'}`}>
{line.text}
</p>
</div>
);
})}
</div>
</div>
{/* Bottom Controls & Viz - Unified Gradient */}
<div className="relative z-20 pb-12 px-0 bg-gradient-to-t from-[#2e1065] via-[#2e1065] to-transparent pt-32 -mt-32 pointer-events-none">
{/* Chart Visualizer */}
<div className="h-28 w-full mb-2 px-0 relative opacity-90">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 10, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorHz" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#C084FC" stopOpacity={0.6}/>
<stop offset="95%" stopColor="#C084FC" stopOpacity={0}/>
</linearGradient>
<linearGradient id="strokeGradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor="#C084FC" stopOpacity={0.6} />
<stop offset="50%" stopColor="#F472B6" stopOpacity={1} />
<stop offset="100%" stopColor="#C084FC" stopOpacity={0.6} />
</linearGradient>
</defs>
<XAxis dataKey="time" type="number" domain={[0, 100]} hide />
<YAxis hide domain={[0, 100]} />
<Area
type="monotone"
dataKey="hz"
stroke="url(#strokeGradient)"
strokeWidth={3}
fillOpacity={1}
fill="url(#colorHz)"
isAnimationActive={false}
/>
<ReferenceDot
x={progress}
y={currentHz}
r={6}
fill="#F8FAFC"
stroke="#F472B6"
strokeWidth={3}
/>
</AreaChart>
</ResponsiveContainer>
</div>
{/* Controls (Pointer events enabled) */}
<div className="px-8 flex items-center gap-6 pointer-events-auto">
<button
onClick={() => setIsPlaying(!isPlaying)}
className="shrink-0 w-12 h-12 rounded-full bg-white text-[#2e1065] flex items-center justify-center shadow-[0_0_25px_rgba(255,255,255,0.3)] active:scale-95 transition-all hover:scale-105"
>
{isPlaying ? <Pause size={20} fill="currentColor" /> : <Play size={20} fill="currentColor" className="ml-0.5" />}
</button>
<div className="flex-1">
<div className="flex justify-between items-end mb-2">
<div className="flex flex-col">
<span className="text-[10px] text-[#CBD5E1] font-bold uppercase tracking-wider mb-0.5">Device Intensity</span>
<span className="text-sm text-[#F472B6] font-mono font-bold drop-shadow-sm">
: {Math.max(1, Math.ceil(progress / 10))}
</span>
</div>
<span className="text-[10px] text-[#CBD5E1] font-mono">{(progress * 1.5).toFixed(0)}s / {scenario.duration}</span>
</div>
{/* Draggable Progress Bar Container */}
<div className="relative h-6 flex items-center group">
{/* 1. Visual Track (Background) */}
<div className="absolute w-full h-1 bg-white/20 rounded-full overflow-hidden pointer-events-none backdrop-blur-sm">
{/* Visual Progress Fill */}
<div
className="h-full bg-gradient-to-r from-[#C084FC] to-[#F472B6] shadow-[0_0_15px_#F472B6]"
style={{ width: `${progress}%` }}
></div>
</div>
{/* 2. Visual Thumb (Knob) - Moves with progress */}
<div
className="absolute h-4 w-4 bg-white rounded-full shadow-[0_0_15px_rgba(255,255,255,0.8)] pointer-events-none transition-transform duration-75 border-2 border-[#F472B6]"
style={{
left: `${progress}%`,
transform: `translateX(-50%) scale(${isDragging ? 1.2 : 1})`
}}
></div>
{/* 3. Invisible Input Range (The actual interaction layer) */}
<input
type="range"
min="0"
max="100"
step="0.1"
value={progress}
onChange={handleSeek}
onMouseDown={handleDragStart}
onMouseUp={handleDragEnd}
onTouchStart={handleDragStart}
onTouchEnd={handleDragEnd}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
/>
</div>
</div>
</div>
<div className="text-center mt-8 pointer-events-auto">
<p className="text-[10px] text-[#CBD5E1]/60 font-medium tracking-widest uppercase"></p>
</div>
</div>
</div>
);
};
export default Player;