VideoPlayer.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  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. comments: Comment[];
  12. pendingAnnotation: AnnotationData | null;
  13. onAnnotationCreated: (annotation: AnnotationData) => void;
  14. onTimeUpdate: (time: number) => void;
  15. onCommentClick: (comment: Comment) => void;
  16. }
  17. export function VideoPlayer({
  18. src,
  19. mimeType,
  20. comments,
  21. pendingAnnotation,
  22. onAnnotationCreated,
  23. onTimeUpdate,
  24. onCommentClick,
  25. }: Props) {
  26. const videoRef = useRef<HTMLVideoElement>(null);
  27. const containerRef = useRef<HTMLDivElement>(null);
  28. const [playing, setPlaying] = useState(false);
  29. const [currentTime, setCurrentTime] = useState(0);
  30. const [duration, setDuration] = useState(0);
  31. const [volume, setVolume] = useState(1);
  32. const [muted, setMuted] = useState(false);
  33. const [playbackRate, setPlaybackRate] = useState(1);
  34. const [fullscreen, setFullscreen] = useState(false);
  35. const [drawMode, setDrawMode] = useState(false);
  36. const [tool, setTool] = useState<Tool>('pen');
  37. const [color, setColor] = useState(COLORS[0].value);
  38. const [showControls, setShowControls] = useState(true);
  39. const [dims, setDims] = useState({ width: 0, height: 0 });
  40. const hideTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
  41. // HLS setup
  42. useEffect(() => {
  43. const video = videoRef.current;
  44. if (!video || !src) return;
  45. if (mimeType === 'application/x-mpegURL' || src.endsWith('.m3u8')) {
  46. if (Hls.isSupported()) {
  47. const hls = new Hls();
  48. hls.loadSource(src);
  49. hls.attachMedia(video);
  50. return () => hls.destroy();
  51. }
  52. } else {
  53. video.src = src;
  54. }
  55. }, [src, mimeType]);
  56. // Measure container
  57. useEffect(() => {
  58. const obs = new ResizeObserver(entries => {
  59. for (const entry of entries) {
  60. setDims({ width: entry.contentRect.width, height: entry.contentRect.height });
  61. }
  62. });
  63. if (containerRef.current) obs.observe(containerRef.current);
  64. return () => obs.disconnect();
  65. }, []);
  66. // Keyboard shortcuts
  67. useEffect(() => {
  68. const handleKey = (e: KeyboardEvent) => {
  69. const video = videoRef.current;
  70. if (!video || e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
  71. if (e.code === 'Space') { e.preventDefault(); video.paused ? video.play() : video.pause(); }
  72. if (e.code === 'ArrowLeft') { e.preventDefault(); video.currentTime = Math.max(0, video.currentTime - 5); }
  73. if (e.code === 'ArrowRight') { e.preventDefault(); video.currentTime = Math.min(duration, video.currentTime + 5); }
  74. if (e.code === 'KeyC') { e.preventDefault(); setDrawMode(v => !v); }
  75. if (e.code === 'KeyF') { e.preventDefault(); toggleFullscreen(); }
  76. if (e.code === 'KeyM') { e.preventDefault(); video.muted = !video.muted; }
  77. if (e.code === 'KeyU') { e.preventDefault(); stepFrame(-1); }
  78. if (e.code === 'KeyI') { e.preventDefault(); stepFrame(1); }
  79. };
  80. window.addEventListener('keydown', handleKey);
  81. return () => window.removeEventListener('keydown', handleKey);
  82. }, [duration]);
  83. // Auto-hide controls
  84. const resetHideTimer = useCallback(() => {
  85. setShowControls(true);
  86. clearTimeout(hideTimer.current);
  87. if (playing) {
  88. hideTimer.current = setTimeout(() => setShowControls(false), 3000);
  89. }
  90. }, [playing]);
  91. function stepFrame(dir: 1 | -1) {
  92. const video = videoRef.current;
  93. if (!video) return;
  94. // Approximate frame step: 1/fps (assume 30fps)
  95. const frameTime = 1 / 30;
  96. video.pause();
  97. video.currentTime = Math.max(0, Math.min(duration, video.currentTime + dir * frameTime));
  98. }
  99. function toggleFullscreen() {
  100. if (!containerRef.current) return;
  101. if (!document.fullscreenElement) {
  102. containerRef.current.requestFullscreen();
  103. setFullscreen(true);
  104. } else {
  105. document.exitFullscreen();
  106. setFullscreen(false);
  107. }
  108. }
  109. const togglePlay = () => {
  110. const video = videoRef.current;
  111. if (!video) return;
  112. video.paused ? video.play() : video.pause();
  113. };
  114. const handleVolume = (v: number) => {
  115. const video = videoRef.current;
  116. if (!video) return;
  117. video.volume = v;
  118. setVolume(v);
  119. setMuted(v === 0);
  120. };
  121. const handleSpeed = (rate: number) => {
  122. const video = videoRef.current;
  123. if (!video) return;
  124. video.playbackRate = rate;
  125. setPlaybackRate(rate);
  126. };
  127. const handleSeek = (time: number) => {
  128. const video = videoRef.current;
  129. if (!video) return;
  130. video.currentTime = time;
  131. setCurrentTime(time);
  132. onTimeUpdate(time);
  133. };
  134. // Annotations visible at current time (±0.5s)
  135. const visibleAnnotations = comments
  136. .filter(c => c.annotation && c.timestamp != null && Math.abs(c.timestamp - currentTime) < 0.5)
  137. .map(c => c.annotation as AnnotationData);
  138. return (
  139. <div
  140. ref={containerRef}
  141. className="relative bg-black rounded-xl overflow-hidden select-none group"
  142. onMouseMove={resetHideTimer}
  143. onMouseLeave={() => playing && setShowControls(false)}
  144. >
  145. {/* Video */}
  146. <video
  147. ref={videoRef}
  148. className="w-full block"
  149. onClick={togglePlay}
  150. onPlay={() => setPlaying(true)}
  151. onPause={() => setPlaying(false)}
  152. onTimeUpdate={() => {
  153. const t = videoRef.current?.currentTime ?? 0;
  154. setCurrentTime(t);
  155. onTimeUpdate(t);
  156. }}
  157. onLoadedMetadata={() => setDuration(videoRef.current?.duration ?? 0)}
  158. playsInline
  159. />
  160. {/* Annotation Canvas */}
  161. <AnnotationCanvas
  162. isDrawingMode={drawMode}
  163. tool={tool}
  164. color={color}
  165. annotations={[...visibleAnnotations, ...(pendingAnnotation ? [pendingAnnotation] : [])]}
  166. width={dims.width}
  167. height={dims.height}
  168. onAnnotationCreated={onAnnotationCreated}
  169. />
  170. {/* Big play button overlay */}
  171. {!playing && (
  172. <button
  173. className="absolute inset-0 flex items-center justify-center bg-black/30 z-20"
  174. onClick={togglePlay}
  175. >
  176. <div className="w-16 h-16 bg-white/90 rounded-full flex items-center justify-center shadow-2xl hover:scale-110 transition-transform">
  177. <svg className="w-8 h-8 text-gray-900 ml-1" fill="currentColor" viewBox="0 0 24 24">
  178. <path d="M8 5v14l11-7z" />
  179. </svg>
  180. </div>
  181. </button>
  182. )}
  183. {/* Controls overlay */}
  184. <div
  185. 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 ${
  186. showControls || !playing ? 'opacity-100' : 'opacity-0 pointer-events-none'
  187. }`}
  188. >
  189. {/* Draw toolbar */}
  190. {drawMode && (
  191. <div className="flex items-center gap-2 mb-2">
  192. <span className="text-xs text-white/60 mr-1">Draw:</span>
  193. {(['pen', 'arrow', 'rect', 'ellipse'] as Tool[]).map(t => (
  194. <button
  195. key={t}
  196. onClick={() => setTool(t)}
  197. className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
  198. tool === t ? 'bg-blue-600 text-white' : 'bg-white/20 text-white hover:bg-white/30'
  199. }`}
  200. >
  201. {t.charAt(0).toUpperCase() + t.slice(1)}
  202. </button>
  203. ))}
  204. <div className="w-px h-5 bg-white/30 mx-1" />
  205. {COLORS.map(c => (
  206. <button
  207. key={c.value}
  208. className={`w-5 h-5 rounded-full border-2 transition-transform hover:scale-125 ${
  209. color === c.value ? 'border-white scale-125' : 'border-transparent'
  210. }`}
  211. style={{ backgroundColor: c.value }}
  212. onClick={() => setColor(c.value)}
  213. title={c.name}
  214. />
  215. ))}
  216. <button
  217. onClick={() => setDrawMode(false)}
  218. className="ml-2 text-xs text-white/60 hover:text-white"
  219. >
  220. Done
  221. </button>
  222. </div>
  223. )}
  224. {/* Timeline */}
  225. <Timeline
  226. duration={duration}
  227. currentTime={currentTime}
  228. comments={comments}
  229. onSeek={handleSeek}
  230. onCommentClick={onCommentClick}
  231. />
  232. {/* Bottom controls */}
  233. <div className="flex items-center gap-2 mt-1">
  234. {/* Play/Pause */}
  235. <button onClick={togglePlay} className="text-white hover:text-blue-300 transition-colors">
  236. {playing ? (
  237. <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
  238. <path d="M6 4h4v16H6zM14 4h4v16h-4z" />
  239. </svg>
  240. ) : (
  241. <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
  242. <path d="M8 5v14l11-7z" />
  243. </svg>
  244. )}
  245. </button>
  246. {/* Frame step */}
  247. <button onClick={() => stepFrame(-1)} className="text-white/60 hover:text-white text-xs" title="Previous frame (U)">⏮</button>
  248. <button onClick={() => stepFrame(1)} className="text-white/60 hover:text-white text-xs" title="Next frame (I)">⏭</button>
  249. {/* Volume */}
  250. <button onClick={() => handleVolume(muted || volume === 0 ? 1 : 0)} className="text-white/80 hover:text-white">
  251. {muted || volume === 0 ? (
  252. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  253. <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" />
  254. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
  255. </svg>
  256. ) : (
  257. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  258. <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" />
  259. </svg>
  260. )}
  261. </button>
  262. <input
  263. type="range"
  264. min={0}
  265. max={1}
  266. step={0.05}
  267. value={muted ? 0 : volume}
  268. onChange={e => handleVolume(parseFloat(e.target.value))}
  269. className="w-16 h-1 accent-blue-500"
  270. />
  271. <span className="text-xs text-white/60 ml-1">
  272. {formatTime(currentTime)} / {formatTime(duration)}
  273. </span>
  274. <div className="flex-1" />
  275. {/* Speed */}
  276. <select
  277. value={playbackRate}
  278. onChange={e => handleSpeed(parseFloat(e.target.value))}
  279. 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"
  280. >
  281. {[0.25, 0.5, 0.75, 1, 1.25, 1.5, 2].map(r => (
  282. <option key={r} value={r} className="text-black">{r}x</option>
  283. ))}
  284. </select>
  285. {/* Draw mode */}
  286. <button
  287. onClick={() => setDrawMode(v => !v)}
  288. className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
  289. drawMode ? 'bg-blue-600 text-white' : 'bg-white/20 text-white hover:bg-white/30'
  290. }`}
  291. title="Toggle draw mode (C)"
  292. >
  293. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  294. <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" />
  295. </svg>
  296. </button>
  297. {/* Fullscreen */}
  298. <button onClick={toggleFullscreen} className="text-white/80 hover:text-white" title="Fullscreen (F)">
  299. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  300. {fullscreen ? (
  301. <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" />
  302. ) : (
  303. <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" />
  304. )}
  305. </svg>
  306. </button>
  307. </div>
  308. </div>
  309. </div>
  310. );
  311. }
  312. function formatTime(s: number): string {
  313. if (!s || isNaN(s)) return '0:00';
  314. const m = Math.floor(s / 60);
  315. const sec = Math.floor(s % 60);
  316. return `${m}:${sec.toString().padStart(2, '0')}`;
  317. }