TimelapseViewer.tsx 8.5 KB

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