VideoPlayer.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  1. 'use client';
  2. import { useRef, useState, useEffect, useCallback } from 'react';
  3. import Hls from 'hls.js';
  4. import { AnnotationCanvas, COLORS } from './AnnotationCanvas';
  5. import { Timeline } from './Timeline';
  6. import { AnnotationData, Comment } from '@/lib/api';
  7. type Tool = 'pen' | 'arrow' | 'rect' | 'ellipse';
  8. interface Props {
  9. src: string;
  10. mimeType: string;
  11. fps?: number;
  12. comments: Comment[];
  13. // Draw mode — controlled externally by parent
  14. drawMode: boolean;
  15. drawTool: Tool;
  16. drawColor: string;
  17. onDrawModeChange: (active: boolean) => void;
  18. onDrawToolChange: (tool: Tool) => void;
  19. onDrawColorChange: (color: string) => void;
  20. // Called after each completed stroke (mouseUp)
  21. onStrokeComplete: (stroke: AnnotationData) => void;
  22. // Called when video time updates
  23. onTimeUpdate: (time: number) => void;
  24. // Called when user clicks a comment marker on timeline
  25. onCommentClick: (comment: Comment) => void;
  26. }
  27. export function VideoPlayer({
  28. src,
  29. mimeType,
  30. fps = 30,
  31. comments,
  32. drawMode,
  33. drawTool,
  34. drawColor,
  35. onDrawModeChange,
  36. onDrawToolChange,
  37. onDrawColorChange,
  38. onStrokeComplete,
  39. onTimeUpdate,
  40. onCommentClick,
  41. }: Props) {
  42. const videoRef = useRef<HTMLVideoElement>(null);
  43. const containerRef = useRef<HTMLDivElement>(null);
  44. const [playing, setPlaying] = useState(false);
  45. const [currentTime, setCurrentTime] = useState(0);
  46. const [duration, setDuration] = useState(0);
  47. const [volume, setVolume] = useState(1);
  48. const [muted, setMuted] = useState(false);
  49. const [playbackRate, setPlaybackRate] = useState(1);
  50. const [fullscreen, setFullscreen] = useState(false);
  51. const [showControls, setShowControls] = useState(true);
  52. const [dims, setDims] = useState({ width: 0, height: 0 });
  53. const hideTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
  54. // HLS setup
  55. useEffect(() => {
  56. const video = videoRef.current;
  57. if (!video || !src) return;
  58. if (mimeType === 'application/x-mpegURL' || src.endsWith('.m3u8')) {
  59. if (Hls.isSupported()) {
  60. const hls = new Hls();
  61. hls.loadSource(src);
  62. hls.attachMedia(video);
  63. return () => hls.destroy();
  64. }
  65. } else {
  66. video.src = src;
  67. }
  68. }, [src, mimeType]);
  69. // Measure container
  70. useEffect(() => {
  71. const obs = new ResizeObserver(entries => {
  72. for (const entry of entries) {
  73. setDims({ width: entry.contentRect.width, height: entry.contentRect.height });
  74. }
  75. });
  76. if (containerRef.current) obs.observe(containerRef.current);
  77. return () => obs.disconnect();
  78. }, []);
  79. // Keyboard shortcuts
  80. useEffect(() => {
  81. const handleKey = (e: KeyboardEvent) => {
  82. const video = videoRef.current;
  83. if (!video || e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
  84. if (e.code === 'Space') {
  85. e.preventDefault();
  86. video.paused ? video.play() : video.pause();
  87. }
  88. if (e.code === 'ArrowLeft') {
  89. e.preventDefault();
  90. video.currentTime = Math.max(0, video.currentTime - 5);
  91. }
  92. if (e.code === 'ArrowRight') {
  93. e.preventDefault();
  94. video.currentTime = Math.min(duration, video.currentTime + 5);
  95. }
  96. if (e.code === 'KeyC') {
  97. e.preventDefault();
  98. // Entering draw mode → auto-pause; exiting → no auto-play
  99. if (!drawMode) {
  100. video.pause();
  101. onDrawModeChange(true);
  102. } else {
  103. onDrawModeChange(false);
  104. }
  105. }
  106. if (e.code === 'KeyF') { e.preventDefault(); toggleFullscreen(); }
  107. if (e.code === 'KeyM') { e.preventDefault(); video.muted = !video.muted; }
  108. if (e.code === 'KeyU') { e.preventDefault(); stepFrame(-1); }
  109. if (e.code === 'KeyI') { e.preventDefault(); stepFrame(1); }
  110. if (e.code === 'Escape' && drawMode) {
  111. e.preventDefault();
  112. onDrawModeChange(false);
  113. }
  114. };
  115. window.addEventListener('keydown', handleKey);
  116. return () => window.removeEventListener('keydown', handleKey);
  117. }, [duration, drawMode, onDrawModeChange]);
  118. // Auto-hide controls
  119. const resetHideTimer = useCallback(() => {
  120. setShowControls(true);
  121. clearTimeout(hideTimer.current);
  122. if (playing) {
  123. hideTimer.current = setTimeout(() => setShowControls(false), 3000);
  124. }
  125. }, [playing]);
  126. function stepFrame(dir: 1 | -1) {
  127. const video = videoRef.current;
  128. if (!video) return;
  129. const frameTime = 1 / (fps || 30);
  130. video.pause();
  131. video.currentTime = Math.max(0, Math.min(duration, video.currentTime + dir * frameTime));
  132. }
  133. function toggleFullscreen() {
  134. if (!containerRef.current) return;
  135. if (!document.fullscreenElement) {
  136. containerRef.current.requestFullscreen();
  137. setFullscreen(true);
  138. } else {
  139. document.exitFullscreen();
  140. setFullscreen(false);
  141. }
  142. }
  143. function togglePlay() {
  144. const video = videoRef.current;
  145. if (!video) return;
  146. video.paused ? video.play() : video.pause();
  147. }
  148. const handleVolume = (v: number) => {
  149. const video = videoRef.current;
  150. if (!video) return;
  151. video.volume = v;
  152. setVolume(v);
  153. setMuted(v === 0);
  154. };
  155. const handleSpeed = (rate: number) => {
  156. const video = videoRef.current;
  157. if (!video) return;
  158. video.playbackRate = rate;
  159. setPlaybackRate(rate);
  160. };
  161. const handleSeek = (time: number) => {
  162. const video = videoRef.current;
  163. if (!video) return;
  164. video.currentTime = time;
  165. setCurrentTime(time);
  166. onTimeUpdate(time);
  167. };
  168. return (
  169. <div
  170. ref={containerRef}
  171. className="relative bg-black rounded-xl overflow-hidden select-none group"
  172. onMouseMove={resetHideTimer}
  173. onMouseLeave={() => playing && setShowControls(false)}
  174. >
  175. {/* Video */}
  176. <video
  177. ref={videoRef}
  178. className="w-full block"
  179. onClick={() => { if (!drawMode) togglePlay(); }}
  180. onPlay={() => setPlaying(true)}
  181. onPause={() => setPlaying(false)}
  182. onTimeUpdate={() => {
  183. const t = videoRef.current?.currentTime ?? 0;
  184. setCurrentTime(t);
  185. onTimeUpdate(t);
  186. }}
  187. onLoadedMetadata={() => setDuration(videoRef.current?.duration ?? 0)}
  188. playsInline
  189. />
  190. {/* Annotation drawing layer — only active when drawMode */}
  191. <AnnotationCanvas
  192. isActive={drawMode}
  193. tool={drawTool}
  194. color={drawColor}
  195. width={dims.width}
  196. height={dims.height}
  197. onStrokeComplete={onStrokeComplete}
  198. />
  199. {/* Big play button overlay */}
  200. {!playing && !drawMode && (
  201. <button
  202. className="absolute inset-0 flex items-center justify-center bg-black/30 z-20"
  203. onClick={togglePlay}
  204. >
  205. <div className="w-16 h-16 bg-white/90 rounded-full flex items-center justify-center shadow-2xl hover:scale-110 transition-transform">
  206. <svg className="w-8 h-8 text-gray-900 ml-1" fill="currentColor" viewBox="0 0 24 24">
  207. <path d="M8 5v14l11-7z" />
  208. </svg>
  209. </div>
  210. </button>
  211. )}
  212. {/* Controls overlay */}
  213. <div
  214. className={`absolute bottom-0 left-0 right-0 z-30 bg-gradient-to-t from-black/80 to-transparent pt-12 pb-3 px-4 transition-opacity duration-300 ${
  215. showControls || !playing || drawMode ? 'opacity-100' : 'opacity-0 pointer-events-none'
  216. }`}
  217. >
  218. {/* Draw toolbar */}
  219. {drawMode && (
  220. <div className="flex items-center gap-2 mb-2">
  221. <span className="text-xs text-white/60">Draw:</span>
  222. {(['pen', 'arrow', 'rect', 'ellipse'] as Tool[]).map(t => (
  223. <button
  224. key={t}
  225. onClick={() => onDrawToolChange(t)}
  226. className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
  227. drawTool === t ? 'bg-blue-600 text-white' : 'bg-white/20 text-white hover:bg-white/30'
  228. }`}
  229. >
  230. {t.charAt(0).toUpperCase() + t.slice(1)}
  231. </button>
  232. ))}
  233. <div className="w-px h-5 bg-white/30 mx-1" />
  234. {COLORS.map(c => (
  235. <button
  236. key={c.value}
  237. className={`w-5 h-5 rounded-full border-2 transition-transform hover:scale-125 ${
  238. drawColor === c.value ? 'border-white scale-125' : 'border-transparent'
  239. }`}
  240. style={{ backgroundColor: c.value }}
  241. onClick={() => onDrawColorChange(c.value)}
  242. title={c.name}
  243. />
  244. ))}
  245. <div className="w-px h-5 bg-white/30 mx-1" />
  246. <button
  247. onClick={() => onDrawModeChange(false)}
  248. className="ml-1 text-xs text-white/60 hover:text-white px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
  249. >
  250. Done
  251. </button>
  252. </div>
  253. )}
  254. {/* Timeline */}
  255. <Timeline
  256. duration={duration}
  257. currentTime={currentTime}
  258. fps={fps}
  259. comments={comments}
  260. onSeek={handleSeek}
  261. onCommentClick={onCommentClick}
  262. />
  263. {/* Bottom controls */}
  264. <div className="flex items-center gap-2 mt-1">
  265. {/* Play/Pause */}
  266. <button
  267. onClick={togglePlay}
  268. className="text-white hover:text-blue-300 transition-colors"
  269. disabled={drawMode}
  270. >
  271. {playing ? (
  272. <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
  273. <path d="M6 4h4v16H6zM14 4h4v16h-4z" />
  274. </svg>
  275. ) : (
  276. <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
  277. <path d="M8 5v14l11-7z" />
  278. </svg>
  279. )}
  280. </button>
  281. {/* Frame step */}
  282. <button onClick={() => stepFrame(-1)} className="text-white/60 hover:text-white text-xs" title="Previous frame (U)">⏮</button>
  283. <button onClick={() => stepFrame(1)} className="text-white/60 hover:text-white text-xs" title="Next frame (I)">⏭</button>
  284. {/* Volume */}
  285. <button onClick={() => handleVolume(muted || volume === 0 ? 1 : 0)} className="text-white/80 hover:text-white">
  286. {muted || volume === 0 ? (
  287. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  288. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707A1 1 0 0112 5v14a1 1 0 01-1.707.707L5.586 15z" />
  289. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
  290. </svg>
  291. ) : (
  292. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  293. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707A1 1 0 0112 5v14a1 1 0 01-1.707.707L5.586 15z" />
  294. </svg>
  295. )}
  296. </button>
  297. <input
  298. type="range" min={0} max={1} step={0.05}
  299. value={muted ? 0 : volume}
  300. onChange={e => handleVolume(parseFloat(e.target.value))}
  301. className="w-16 h-1 accent-blue-500"
  302. />
  303. {/* Timecode */}
  304. <span className="text-xs text-white/60 ml-1 font-mono">
  305. {formatTimecode(currentTime, fps)} / {formatTimecode(duration, fps)}
  306. </span>
  307. <div className="flex-1" />
  308. {/* Speed */}
  309. <select
  310. value={playbackRate}
  311. onChange={e => handleSpeed(parseFloat(e.target.value))}
  312. className="bg-transparent text-xs text-white/80 border border-white/30 rounded px-1.5 py-0.5 cursor-pointer hover:border-white/60"
  313. >
  314. {[0.25, 0.5, 0.75, 1, 1.25, 1.5, 2].map(r => (
  315. <option key={r} value={r} className="text-black">{r}x</option>
  316. ))}
  317. </select>
  318. {/* Draw mode toggle */}
  319. <button
  320. onClick={() => {
  321. if (!drawMode) {
  322. videoRef.current?.pause();
  323. onDrawModeChange(true);
  324. } else {
  325. onDrawModeChange(false);
  326. }
  327. }}
  328. className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
  329. drawMode ? 'bg-blue-600 text-white' : 'bg-white/20 text-white hover:bg-white/30'
  330. }`}
  331. title="Toggle draw mode (C)"
  332. >
  333. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  334. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
  335. </svg>
  336. </button>
  337. {/* Fullscreen */}
  338. <button onClick={toggleFullscreen} className="text-white/80 hover:text-white" title="Fullscreen (F)">
  339. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  340. {fullscreen ? (
  341. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
  342. ) : (
  343. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
  344. )}
  345. </svg>
  346. </button>
  347. </div>
  348. </div>
  349. </div>
  350. );
  351. }
  352. function formatTimecode(s: number, fps: number = 30): string {
  353. if (!s || isNaN(s)) return '00:00:00:00';
  354. const h = Math.floor(s / 3600);
  355. const m = Math.floor((s % 3600) / 60);
  356. const sec = Math.floor(s % 60);
  357. const f = Math.round(s * fps) % fps;
  358. return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
  359. }