|
|
@@ -0,0 +1,202 @@
|
|
|
+import { useState, useRef, useEffect } from 'react';
|
|
|
+import { X, Download, Film, Play, Pause, SkipBack, SkipForward } from 'lucide-react';
|
|
|
+import { Button } from './Button';
|
|
|
+
|
|
|
+interface TimelapseViewerProps {
|
|
|
+ src: string;
|
|
|
+ title: string;
|
|
|
+ downloadFilename: string;
|
|
|
+ onClose: () => void;
|
|
|
+}
|
|
|
+
|
|
|
+const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
|
|
|
+
|
|
|
+export function TimelapseViewer({ src, title, downloadFilename, onClose }: TimelapseViewerProps) {
|
|
|
+ const videoRef = useRef<HTMLVideoElement>(null);
|
|
|
+ const [isPlaying, setIsPlaying] = useState(true);
|
|
|
+ const [playbackRate, setPlaybackRate] = useState(0.5); // Default to 0.5x for timelapse
|
|
|
+ const [currentTime, setCurrentTime] = useState(0);
|
|
|
+ const [duration, setDuration] = useState(0);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const video = videoRef.current;
|
|
|
+ if (video) {
|
|
|
+ video.playbackRate = playbackRate;
|
|
|
+ }
|
|
|
+ }, [playbackRate]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const video = videoRef.current;
|
|
|
+ if (!video) return;
|
|
|
+
|
|
|
+ const handleTimeUpdate = () => setCurrentTime(video.currentTime);
|
|
|
+ const handleDurationChange = () => setDuration(video.duration);
|
|
|
+ const handlePlay = () => setIsPlaying(true);
|
|
|
+ const handlePause = () => setIsPlaying(false);
|
|
|
+
|
|
|
+ video.addEventListener('timeupdate', handleTimeUpdate);
|
|
|
+ video.addEventListener('durationchange', handleDurationChange);
|
|
|
+ video.addEventListener('play', handlePlay);
|
|
|
+ video.addEventListener('pause', handlePause);
|
|
|
+
|
|
|
+ return () => {
|
|
|
+ video.removeEventListener('timeupdate', handleTimeUpdate);
|
|
|
+ video.removeEventListener('durationchange', handleDurationChange);
|
|
|
+ video.removeEventListener('play', handlePlay);
|
|
|
+ video.removeEventListener('pause', handlePause);
|
|
|
+ };
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const togglePlay = () => {
|
|
|
+ const video = videoRef.current;
|
|
|
+ if (!video) return;
|
|
|
+ if (isPlaying) {
|
|
|
+ video.pause();
|
|
|
+ } else {
|
|
|
+ video.play();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
+ const video = videoRef.current;
|
|
|
+ if (!video) return;
|
|
|
+ video.currentTime = parseFloat(e.target.value);
|
|
|
+ };
|
|
|
+
|
|
|
+ const skipBackward = () => {
|
|
|
+ const video = videoRef.current;
|
|
|
+ if (!video) return;
|
|
|
+ video.currentTime = Math.max(0, video.currentTime - 5);
|
|
|
+ };
|
|
|
+
|
|
|
+ const skipForward = () => {
|
|
|
+ const video = videoRef.current;
|
|
|
+ if (!video) return;
|
|
|
+ video.currentTime = Math.min(duration, video.currentTime + 5);
|
|
|
+ };
|
|
|
+
|
|
|
+ const formatTime = (time: number) => {
|
|
|
+ const minutes = Math.floor(time / 60);
|
|
|
+ const seconds = Math.floor(time % 60);
|
|
|
+ return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleDownload = () => {
|
|
|
+ const link = document.createElement('a');
|
|
|
+ link.href = src;
|
|
|
+ link.download = downloadFilename;
|
|
|
+ link.click();
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
|
|
|
+ <div className="relative bg-bambu-dark-secondary rounded-xl max-w-4xl w-full mx-4 overflow-hidden">
|
|
|
+ {/* Header */}
|
|
|
+ <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
|
|
+ <h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
|
|
+ <Film className="w-5 h-5 text-bambu-green" />
|
|
|
+ {title}
|
|
|
+ </h3>
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <Button variant="secondary" size="sm" onClick={handleDownload}>
|
|
|
+ <Download className="w-4 h-4" />
|
|
|
+ Download
|
|
|
+ </Button>
|
|
|
+ <button
|
|
|
+ onClick={onClose}
|
|
|
+ className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
|
|
|
+ >
|
|
|
+ <X className="w-5 h-5 text-bambu-gray" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Video */}
|
|
|
+ <div className="p-4">
|
|
|
+ <video
|
|
|
+ ref={videoRef}
|
|
|
+ src={src}
|
|
|
+ autoPlay
|
|
|
+ className="w-full rounded-lg"
|
|
|
+ onClick={togglePlay}
|
|
|
+ />
|
|
|
+
|
|
|
+ {/* Custom Controls */}
|
|
|
+ <div className="mt-4 space-y-3">
|
|
|
+ {/* Progress bar */}
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
+ <span className="text-xs text-bambu-gray w-12 text-right">
|
|
|
+ {formatTime(currentTime)}
|
|
|
+ </span>
|
|
|
+ <input
|
|
|
+ type="range"
|
|
|
+ min={0}
|
|
|
+ max={duration || 100}
|
|
|
+ value={currentTime}
|
|
|
+ onChange={handleSeek}
|
|
|
+ className="flex-1 h-1 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer
|
|
|
+ [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
|
|
|
+ [&::-webkit-slider-thumb]:bg-bambu-green [&::-webkit-slider-thumb]:rounded-full
|
|
|
+ [&::-webkit-slider-thumb]:cursor-pointer"
|
|
|
+ />
|
|
|
+ <span className="text-xs text-bambu-gray w-12">
|
|
|
+ {formatTime(duration)}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Playback controls */}
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ {/* Left: Play controls */}
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <button
|
|
|
+ onClick={skipBackward}
|
|
|
+ className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
|
|
|
+ title="Skip back 5s"
|
|
|
+ >
|
|
|
+ <SkipBack className="w-5 h-5 text-bambu-gray" />
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={togglePlay}
|
|
|
+ className="p-2 bg-bambu-green hover:bg-bambu-green-dark rounded-lg transition-colors"
|
|
|
+ >
|
|
|
+ {isPlaying ? (
|
|
|
+ <Pause className="w-5 h-5 text-white" />
|
|
|
+ ) : (
|
|
|
+ <Play className="w-5 h-5 text-white" />
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={skipForward}
|
|
|
+ className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
|
|
|
+ title="Skip forward 5s"
|
|
|
+ >
|
|
|
+ <SkipForward className="w-5 h-5 text-bambu-gray" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Right: Speed control */}
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <span className="text-sm text-bambu-gray">Speed:</span>
|
|
|
+ <div className="flex gap-1">
|
|
|
+ {SPEED_OPTIONS.map((speed) => (
|
|
|
+ <button
|
|
|
+ key={speed}
|
|
|
+ onClick={() => setPlaybackRate(speed)}
|
|
|
+ className={`px-2 py-1 text-xs rounded transition-colors ${
|
|
|
+ playbackRate === speed
|
|
|
+ ? 'bg-bambu-green text-white'
|
|
|
+ : 'bg-bambu-dark-tertiary text-bambu-gray hover:bg-bambu-dark-tertiary/80'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ {speed}x
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|