201 lines
5.9 KiB
TypeScript
201 lines
5.9 KiB
TypeScript
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||
|
|
import { Play, Pause, SkipBack, SkipForward } from 'lucide-react';
|
||
|
|
|
||
|
|
interface TimelinePlayerProps {
|
||
|
|
startDate: string;
|
||
|
|
endDate: string;
|
||
|
|
currentDate: string;
|
||
|
|
onDateChange: (date: string) => void;
|
||
|
|
isPlaying?: boolean;
|
||
|
|
speed?: number;
|
||
|
|
onSpeedChange?: (speed: number) => void;
|
||
|
|
onPlayPause?: (playing: boolean) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
const SPEEDS = [0.5, 1, 2, 5, 10];
|
||
|
|
|
||
|
|
export function TimelinePlayer({
|
||
|
|
startDate,
|
||
|
|
endDate,
|
||
|
|
currentDate,
|
||
|
|
onDateChange,
|
||
|
|
isPlaying = false,
|
||
|
|
speed = 1,
|
||
|
|
onSpeedChange,
|
||
|
|
onPlayPause,
|
||
|
|
}: TimelinePlayerProps) {
|
||
|
|
const [playing, setPlaying] = useState(isPlaying);
|
||
|
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
|
|
|
||
|
|
const generateDateRange = useCallback((start: string, end: string) => {
|
||
|
|
const dates: string[] = [];
|
||
|
|
const current = new Date(start);
|
||
|
|
const final = new Date(end);
|
||
|
|
|
||
|
|
while (current <= final) {
|
||
|
|
dates.push(current.toISOString().split('T')[0]);
|
||
|
|
current.setDate(current.getDate() + 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
return dates;
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const dateRange = generateDateRange(startDate, endDate);
|
||
|
|
const currentIndex = dateRange.indexOf(currentDate);
|
||
|
|
const progress = ((currentIndex + 1) / dateRange.length) * 100;
|
||
|
|
|
||
|
|
const play = useCallback(() => {
|
||
|
|
setPlaying(true);
|
||
|
|
onPlayPause?.(true);
|
||
|
|
}, [onPlayPause]);
|
||
|
|
|
||
|
|
const pause = useCallback(() => {
|
||
|
|
setPlaying(false);
|
||
|
|
onPlayPause?.(false);
|
||
|
|
}, [onPlayPause]);
|
||
|
|
|
||
|
|
const togglePlay = () => {
|
||
|
|
if (playing) {
|
||
|
|
pause();
|
||
|
|
} else {
|
||
|
|
play();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const goToNext = useCallback(() => {
|
||
|
|
const nextIndex = Math.min(currentIndex + 1, dateRange.length - 1);
|
||
|
|
onDateChange(dateRange[nextIndex]);
|
||
|
|
}, [currentIndex, dateRange, onDateChange]);
|
||
|
|
|
||
|
|
const goToStart = () => {
|
||
|
|
onDateChange(dateRange[0]);
|
||
|
|
};
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (playing) {
|
||
|
|
const interval = 1000 / speed;
|
||
|
|
|
||
|
|
timerRef.current = setInterval(() => {
|
||
|
|
goToNext();
|
||
|
|
}, interval);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
if (timerRef.current) {
|
||
|
|
clearInterval(timerRef.current);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}, [playing, speed, goToNext]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (currentIndex >= dateRange.length - 1) {
|
||
|
|
pause();
|
||
|
|
}
|
||
|
|
}, [currentIndex, dateRange.length, pause]);
|
||
|
|
|
||
|
|
const handleSliderChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
|
|
const index = Math.round((Number(e.target.value) / 100) * (dateRange.length - 1));
|
||
|
|
onDateChange(dateRange[index]);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSpeedChange = () => {
|
||
|
|
const currentIndex = SPEEDS.indexOf(speed);
|
||
|
|
const nextIndex = (currentIndex + 1) % SPEEDS.length;
|
||
|
|
onSpeedChange?.(SPEEDS[nextIndex]);
|
||
|
|
};
|
||
|
|
|
||
|
|
const formatSpeed = (s: number) => {
|
||
|
|
return s >= 1 ? `${s}x` : `${s.toFixed(1)}x`;
|
||
|
|
};
|
||
|
|
|
||
|
|
const formatDate = (dateStr: string) => {
|
||
|
|
const date = new Date(dateStr);
|
||
|
|
const today = new Date();
|
||
|
|
const isToday = date.toDateString() === today.toDateString();
|
||
|
|
|
||
|
|
if (isToday) {
|
||
|
|
return `今天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
return date.toLocaleDateString('zh-CN', {
|
||
|
|
year: 'numeric',
|
||
|
|
month: '2-digit',
|
||
|
|
day: '2-digit',
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="fixed right-4 top-1/2 -translate-y-1/2 z-[9999] w-64">
|
||
|
|
<div className="bg-white/95 backdrop-blur-xl border border-gray-200/80 rounded-2xl shadow-[0_8px_32px_rgba(0,0,0,0.12)] px-4 py-3">
|
||
|
|
{/* Date display */}
|
||
|
|
<div className="text-center mb-3">
|
||
|
|
<div className="font-medium text-gray-900 text-sm">{formatDate(currentDate)}</div>
|
||
|
|
<div className="text-xs text-gray-400 mt-0.5">
|
||
|
|
第 {currentIndex + 1} / {dateRange.length} 天
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Vertical slider */}
|
||
|
|
<div className="flex justify-center mb-3">
|
||
|
|
<input
|
||
|
|
type="range"
|
||
|
|
min="0"
|
||
|
|
max="100"
|
||
|
|
value={progress}
|
||
|
|
onChange={handleSliderChange}
|
||
|
|
className="h-1.5 w-full bg-gray-200 rounded-full appearance-none cursor-pointer accent-blue-600"
|
||
|
|
style={{
|
||
|
|
background: `linear-gradient(to right, #2563eb 0%, #2563eb ${progress}%, #e5e7eb ${progress}%, #e5e7eb 100%)`,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="flex justify-between text-[10px] text-gray-400 mb-3">
|
||
|
|
<span>{new Date(startDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
|
||
|
|
<span>{new Date(endDate).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Transport controls */}
|
||
|
|
<div className="flex items-center justify-center gap-2">
|
||
|
|
<button
|
||
|
|
onClick={goToStart}
|
||
|
|
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
|
||
|
|
title="跳到开始"
|
||
|
|
>
|
||
|
|
<SkipBack className="w-4 h-4" />
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<button
|
||
|
|
onClick={togglePlay}
|
||
|
|
className="p-2.5 bg-blue-600 text-white rounded-full hover:bg-blue-700 transition-colors shadow-md"
|
||
|
|
>
|
||
|
|
{playing ? (
|
||
|
|
<Pause className="w-5 h-5" />
|
||
|
|
) : (
|
||
|
|
<Play className="w-5 h-5 ml-0.5" />
|
||
|
|
)}
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<button
|
||
|
|
onClick={goToNext}
|
||
|
|
className="p-1.5 text-gray-400 hover:text-gray-700 hover:bg-gray-100 rounded-full transition-colors"
|
||
|
|
title="跳到下一天"
|
||
|
|
>
|
||
|
|
<SkipForward className="w-4 h-4" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Speed */}
|
||
|
|
<div className="flex items-center justify-center gap-2 mt-2">
|
||
|
|
<button
|
||
|
|
onClick={handleSpeedChange}
|
||
|
|
className="px-2 py-0.5 text-xs font-medium text-gray-600 bg-gray-100/80 rounded-full hover:bg-gray-200 transition-colors"
|
||
|
|
title="调整播放速度"
|
||
|
|
>
|
||
|
|
{formatSpeed(speed)}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|