TimelapseViewer.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import { useState, useRef, useEffect } from 'react';
  2. import { X, Download, Film, Play, Pause, SkipBack, SkipForward, Pencil } from 'lucide-react';
  3. import { Button } from './Button';
  4. import { TimelapseEditorModal } from './TimelapseEditorModal';
  5. interface TimelapseViewerProps {
  6. src: string;
  7. title: string;
  8. downloadFilename: string;
  9. archiveId?: number;
  10. onClose: () => void;
  11. onEdit?: () => void;
  12. }
  13. const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4];
  14. export function TimelapseViewer({
  15. src,
  16. title,
  17. downloadFilename,
  18. archiveId,
  19. onClose,
  20. onEdit,
  21. }: TimelapseViewerProps) {
  22. const videoRef = useRef<HTMLVideoElement>(null);
  23. const [isPlaying, setIsPlaying] = useState(true);
  24. const [playbackRate, setPlaybackRate] = useState(1); // Default to 1x
  25. const [currentTime, setCurrentTime] = useState(0);
  26. const [duration, setDuration] = useState(0);
  27. const [showEditor, setShowEditor] = useState(false);
  28. useEffect(() => {
  29. const video = videoRef.current;
  30. if (video) {
  31. video.playbackRate = playbackRate;
  32. }
  33. }, [playbackRate]);
  34. // Close on Escape key
  35. useEffect(() => {
  36. const handleKeyDown = (e: KeyboardEvent) => {
  37. if (e.key === 'Escape') {
  38. onClose();
  39. }
  40. };
  41. window.addEventListener('keydown', handleKeyDown);
  42. return () => window.removeEventListener('keydown', handleKeyDown);
  43. }, [onClose]);
  44. useEffect(() => {
  45. const video = videoRef.current;
  46. if (!video) return;
  47. const handleTimeUpdate = () => setCurrentTime(video.currentTime);
  48. const handleDurationChange = () => setDuration(video.duration);
  49. const handlePlay = () => setIsPlaying(true);
  50. const handlePause = () => setIsPlaying(false);
  51. video.addEventListener('timeupdate', handleTimeUpdate);
  52. video.addEventListener('durationchange', handleDurationChange);
  53. video.addEventListener('play', handlePlay);
  54. video.addEventListener('pause', handlePause);
  55. return () => {
  56. video.removeEventListener('timeupdate', handleTimeUpdate);
  57. video.removeEventListener('durationchange', handleDurationChange);
  58. video.removeEventListener('play', handlePlay);
  59. video.removeEventListener('pause', handlePause);
  60. };
  61. }, []);
  62. const togglePlay = () => {
  63. const video = videoRef.current;
  64. if (!video) return;
  65. if (isPlaying) {
  66. video.pause();
  67. } else {
  68. video.play();
  69. }
  70. };
  71. const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
  72. const video = videoRef.current;
  73. if (!video) return;
  74. video.currentTime = parseFloat(e.target.value);
  75. };
  76. const skipBackward = () => {
  77. const video = videoRef.current;
  78. if (!video) return;
  79. video.currentTime = Math.max(0, video.currentTime - 5);
  80. };
  81. const skipForward = () => {
  82. const video = videoRef.current;
  83. if (!video) return;
  84. video.currentTime = Math.min(duration, video.currentTime + 5);
  85. };
  86. const formatTime = (time: number) => {
  87. const minutes = Math.floor(time / 60);
  88. const seconds = Math.floor(time % 60);
  89. return `${minutes}:${seconds.toString().padStart(2, '0')}`;
  90. };
  91. const handleDownload = () => {
  92. const link = document.createElement('a');
  93. link.href = src;
  94. link.download = downloadFilename;
  95. link.click();
  96. };
  97. return (
  98. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
  99. <div className="relative bg-bambu-dark-secondary rounded-xl max-w-4xl w-full mx-4 overflow-hidden">
  100. {/* Header */}
  101. <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
  102. <h3 className="text-lg font-semibold text-white flex items-center gap-2">
  103. <Film className="w-5 h-5 text-bambu-green" />
  104. {title}
  105. </h3>
  106. <div className="flex items-center gap-2">
  107. {archiveId && (
  108. <Button variant="secondary" size="sm" onClick={() => setShowEditor(true)}>
  109. <Pencil className="w-4 h-4" />
  110. Edit
  111. </Button>
  112. )}
  113. <Button variant="secondary" size="sm" onClick={handleDownload}>
  114. <Download className="w-4 h-4" />
  115. Download
  116. </Button>
  117. <button
  118. onClick={onClose}
  119. className="p-1 hover:bg-bambu-dark-tertiary rounded transition-colors"
  120. >
  121. <X className="w-5 h-5 text-bambu-gray" />
  122. </button>
  123. </div>
  124. </div>
  125. {/* Video */}
  126. <div className="p-4">
  127. <video
  128. ref={videoRef}
  129. src={src}
  130. autoPlay
  131. className="w-full rounded-lg"
  132. onClick={togglePlay}
  133. />
  134. {/* Custom Controls */}
  135. <div className="mt-4 space-y-3">
  136. {/* Progress bar */}
  137. <div className="flex items-center gap-3">
  138. <span className="text-xs text-bambu-gray w-12 text-right">
  139. {formatTime(currentTime)}
  140. </span>
  141. <input
  142. type="range"
  143. min={0}
  144. max={duration || 100}
  145. value={currentTime}
  146. onChange={handleSeek}
  147. className="flex-1 h-1 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer
  148. [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
  149. [&::-webkit-slider-thumb]:bg-bambu-green [&::-webkit-slider-thumb]:rounded-full
  150. [&::-webkit-slider-thumb]:cursor-pointer"
  151. />
  152. <span className="text-xs text-bambu-gray w-12">
  153. {formatTime(duration)}
  154. </span>
  155. </div>
  156. {/* Playback controls */}
  157. <div className="flex items-center justify-between">
  158. {/* Left: Play controls */}
  159. <div className="flex items-center gap-2">
  160. <button
  161. onClick={skipBackward}
  162. className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
  163. title="Skip back 5s"
  164. >
  165. <SkipBack className="w-5 h-5 text-bambu-gray" />
  166. </button>
  167. <button
  168. onClick={togglePlay}
  169. className="p-2 bg-bambu-green hover:bg-bambu-green-dark rounded-lg transition-colors"
  170. >
  171. {isPlaying ? (
  172. <Pause className="w-5 h-5 text-white" />
  173. ) : (
  174. <Play className="w-5 h-5 text-white" />
  175. )}
  176. </button>
  177. <button
  178. onClick={skipForward}
  179. className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"
  180. title="Skip forward 5s"
  181. >
  182. <SkipForward className="w-5 h-5 text-bambu-gray" />
  183. </button>
  184. </div>
  185. {/* Right: Speed control */}
  186. <div className="flex items-center gap-2">
  187. <span className="text-sm text-bambu-gray">Speed:</span>
  188. <div className="flex gap-1">
  189. {SPEED_OPTIONS.map((speed) => (
  190. <button
  191. key={speed}
  192. onClick={() => setPlaybackRate(speed)}
  193. className={`px-2 py-1 text-xs rounded transition-colors ${
  194. playbackRate === speed
  195. ? 'bg-bambu-green text-white'
  196. : 'bg-bambu-dark-tertiary text-bambu-gray hover:bg-bambu-dark-tertiary/80'
  197. }`}
  198. >
  199. {speed}x
  200. </button>
  201. ))}
  202. </div>
  203. </div>
  204. </div>
  205. </div>
  206. </div>
  207. </div>
  208. {/* Timelapse Editor Modal */}
  209. {showEditor && archiveId && (
  210. <TimelapseEditorModal
  211. archiveId={archiveId}
  212. timelapseSrc={src}
  213. onClose={() => setShowEditor(false)}
  214. onSave={onEdit}
  215. />
  216. )}
  217. </div>
  218. );
  219. }