feat: app 端 ui 设计完成
This commit is contained in:
285
wei-ai-demo/pages/Player.tsx
Normal file
285
wei-ai-demo/pages/Player.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user