TimelapseViewer.tsx 7.8 KB

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