| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390 |
- 'use client';
- import { useRef, useState, useEffect, useCallback } from 'react';
- import Hls from 'hls.js';
- import { AnnotationCanvas, COLORS } from './AnnotationCanvas';
- import { Timeline } from './Timeline';
- import { AnnotationData, Comment } from '@/lib/api';
- type Tool = 'pen' | 'arrow' | 'rect' | 'ellipse';
- interface Props {
- src: string;
- mimeType: string;
- fps?: number;
- comments: Comment[];
- // Draw mode — controlled externally by parent
- drawMode: boolean;
- drawTool: Tool;
- drawColor: string;
- onDrawModeChange: (active: boolean) => void;
- onDrawToolChange: (tool: Tool) => void;
- onDrawColorChange: (color: string) => void;
- // Called after each completed stroke (mouseUp)
- onStrokeComplete: (stroke: AnnotationData) => void;
- // Called when video time updates
- onTimeUpdate: (time: number) => void;
- // Called when user clicks a comment marker on timeline
- onCommentClick: (comment: Comment) => void;
- }
- export function VideoPlayer({
- src,
- mimeType,
- fps = 30,
- comments,
- drawMode,
- drawTool,
- drawColor,
- onDrawModeChange,
- onDrawToolChange,
- onDrawColorChange,
- onStrokeComplete,
- onTimeUpdate,
- onCommentClick,
- }: Props) {
- const videoRef = useRef<HTMLVideoElement>(null);
- const containerRef = useRef<HTMLDivElement>(null);
- const [playing, setPlaying] = useState(false);
- const [currentTime, setCurrentTime] = useState(0);
- const [duration, setDuration] = useState(0);
- const [volume, setVolume] = useState(1);
- const [muted, setMuted] = useState(false);
- const [playbackRate, setPlaybackRate] = useState(1);
- const [fullscreen, setFullscreen] = useState(false);
- const [showControls, setShowControls] = useState(true);
- const [dims, setDims] = useState({ width: 0, height: 0 });
- const hideTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
- // HLS setup
- useEffect(() => {
- const video = videoRef.current;
- if (!video || !src) return;
- if (mimeType === 'application/x-mpegURL' || src.endsWith('.m3u8')) {
- if (Hls.isSupported()) {
- const hls = new Hls();
- hls.loadSource(src);
- hls.attachMedia(video);
- return () => hls.destroy();
- }
- } else {
- video.src = src;
- }
- }, [src, mimeType]);
- // Measure container
- useEffect(() => {
- const obs = new ResizeObserver(entries => {
- for (const entry of entries) {
- setDims({ width: entry.contentRect.width, height: entry.contentRect.height });
- }
- });
- if (containerRef.current) obs.observe(containerRef.current);
- return () => obs.disconnect();
- }, []);
- // Keyboard shortcuts
- useEffect(() => {
- const handleKey = (e: KeyboardEvent) => {
- const video = videoRef.current;
- if (!video || e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
- if (e.code === 'Space') {
- e.preventDefault();
- video.paused ? video.play() : video.pause();
- }
- if (e.code === 'ArrowLeft') {
- e.preventDefault();
- video.currentTime = Math.max(0, video.currentTime - 5);
- }
- if (e.code === 'ArrowRight') {
- e.preventDefault();
- video.currentTime = Math.min(duration, video.currentTime + 5);
- }
- if (e.code === 'KeyC') {
- e.preventDefault();
- // Entering draw mode → auto-pause; exiting → no auto-play
- if (!drawMode) {
- video.pause();
- onDrawModeChange(true);
- } else {
- onDrawModeChange(false);
- }
- }
- if (e.code === 'KeyF') { e.preventDefault(); toggleFullscreen(); }
- if (e.code === 'KeyM') { e.preventDefault(); video.muted = !video.muted; }
- if (e.code === 'KeyU') { e.preventDefault(); stepFrame(-1); }
- if (e.code === 'KeyI') { e.preventDefault(); stepFrame(1); }
- if (e.code === 'Escape' && drawMode) {
- e.preventDefault();
- onDrawModeChange(false);
- }
- };
- window.addEventListener('keydown', handleKey);
- return () => window.removeEventListener('keydown', handleKey);
- }, [duration, drawMode, onDrawModeChange]);
- // Auto-hide controls
- const resetHideTimer = useCallback(() => {
- setShowControls(true);
- clearTimeout(hideTimer.current);
- if (playing) {
- hideTimer.current = setTimeout(() => setShowControls(false), 3000);
- }
- }, [playing]);
- function stepFrame(dir: 1 | -1) {
- const video = videoRef.current;
- if (!video) return;
- const frameTime = 1 / (fps || 30);
- video.pause();
- video.currentTime = Math.max(0, Math.min(duration, video.currentTime + dir * frameTime));
- }
- function toggleFullscreen() {
- if (!containerRef.current) return;
- if (!document.fullscreenElement) {
- containerRef.current.requestFullscreen();
- setFullscreen(true);
- } else {
- document.exitFullscreen();
- setFullscreen(false);
- }
- }
- function togglePlay() {
- const video = videoRef.current;
- if (!video) return;
- video.paused ? video.play() : video.pause();
- }
- const handleVolume = (v: number) => {
- const video = videoRef.current;
- if (!video) return;
- video.volume = v;
- setVolume(v);
- setMuted(v === 0);
- };
- const handleSpeed = (rate: number) => {
- const video = videoRef.current;
- if (!video) return;
- video.playbackRate = rate;
- setPlaybackRate(rate);
- };
- const handleSeek = (time: number) => {
- const video = videoRef.current;
- if (!video) return;
- video.currentTime = time;
- setCurrentTime(time);
- onTimeUpdate(time);
- };
- return (
- <div
- ref={containerRef}
- className="relative bg-black rounded-xl overflow-hidden select-none group"
- onMouseMove={resetHideTimer}
- onMouseLeave={() => playing && setShowControls(false)}
- >
- {/* Video */}
- <video
- ref={videoRef}
- className="w-full block"
- onClick={() => { if (!drawMode) togglePlay(); }}
- onPlay={() => setPlaying(true)}
- onPause={() => setPlaying(false)}
- onTimeUpdate={() => {
- const t = videoRef.current?.currentTime ?? 0;
- setCurrentTime(t);
- onTimeUpdate(t);
- }}
- onLoadedMetadata={() => setDuration(videoRef.current?.duration ?? 0)}
- playsInline
- />
- {/* Annotation drawing layer — only active when drawMode */}
- <AnnotationCanvas
- isActive={drawMode}
- tool={drawTool}
- color={drawColor}
- width={dims.width}
- height={dims.height}
- onStrokeComplete={onStrokeComplete}
- />
- {/* Big play button overlay */}
- {!playing && !drawMode && (
- <button
- className="absolute inset-0 flex items-center justify-center bg-black/30 z-20"
- onClick={togglePlay}
- >
- <div className="w-16 h-16 bg-white/90 rounded-full flex items-center justify-center shadow-2xl hover:scale-110 transition-transform">
- <svg className="w-8 h-8 text-gray-900 ml-1" fill="currentColor" viewBox="0 0 24 24">
- <path d="M8 5v14l11-7z" />
- </svg>
- </div>
- </button>
- )}
- {/* Controls overlay */}
- <div
- 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 ${
- showControls || !playing || drawMode ? 'opacity-100' : 'opacity-0 pointer-events-none'
- }`}
- >
- {/* Draw toolbar */}
- {drawMode && (
- <div className="flex items-center gap-2 mb-2">
- <span className="text-xs text-white/60">Draw:</span>
- {(['pen', 'arrow', 'rect', 'ellipse'] as Tool[]).map(t => (
- <button
- key={t}
- onClick={() => onDrawToolChange(t)}
- className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
- drawTool === t ? 'bg-blue-600 text-white' : 'bg-white/20 text-white hover:bg-white/30'
- }`}
- >
- {t.charAt(0).toUpperCase() + t.slice(1)}
- </button>
- ))}
- <div className="w-px h-5 bg-white/30 mx-1" />
- {COLORS.map(c => (
- <button
- key={c.value}
- className={`w-5 h-5 rounded-full border-2 transition-transform hover:scale-125 ${
- drawColor === c.value ? 'border-white scale-125' : 'border-transparent'
- }`}
- style={{ backgroundColor: c.value }}
- onClick={() => onDrawColorChange(c.value)}
- title={c.name}
- />
- ))}
- <div className="w-px h-5 bg-white/30 mx-1" />
- <button
- onClick={() => onDrawModeChange(false)}
- 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"
- >
- Done
- </button>
- </div>
- )}
- {/* Timeline */}
- <Timeline
- duration={duration}
- currentTime={currentTime}
- fps={fps}
- comments={comments}
- onSeek={handleSeek}
- onCommentClick={onCommentClick}
- />
- {/* Bottom controls */}
- <div className="flex items-center gap-2 mt-1">
- {/* Play/Pause */}
- <button
- onClick={togglePlay}
- className="text-white hover:text-blue-300 transition-colors"
- disabled={drawMode}
- >
- {playing ? (
- <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
- <path d="M6 4h4v16H6zM14 4h4v16h-4z" />
- </svg>
- ) : (
- <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
- <path d="M8 5v14l11-7z" />
- </svg>
- )}
- </button>
- {/* Frame step */}
- <button onClick={() => stepFrame(-1)} className="text-white/60 hover:text-white text-xs" title="Previous frame (U)">⏮</button>
- <button onClick={() => stepFrame(1)} className="text-white/60 hover:text-white text-xs" title="Next frame (I)">⏭</button>
- {/* Volume */}
- <button onClick={() => handleVolume(muted || volume === 0 ? 1 : 0)} className="text-white/80 hover:text-white">
- {muted || volume === 0 ? (
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <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" />
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" />
- </svg>
- ) : (
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <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" />
- </svg>
- )}
- </button>
- <input
- type="range" min={0} max={1} step={0.05}
- value={muted ? 0 : volume}
- onChange={e => handleVolume(parseFloat(e.target.value))}
- className="w-16 h-1 accent-blue-500"
- />
- {/* Timecode */}
- <span className="text-xs text-white/60 ml-1 font-mono">
- {formatTimecode(currentTime, fps)} / {formatTimecode(duration, fps)}
- </span>
- <div className="flex-1" />
- {/* Speed */}
- <select
- value={playbackRate}
- onChange={e => handleSpeed(parseFloat(e.target.value))}
- 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"
- >
- {[0.25, 0.5, 0.75, 1, 1.25, 1.5, 2].map(r => (
- <option key={r} value={r} className="text-black">{r}x</option>
- ))}
- </select>
- {/* Draw mode toggle */}
- <button
- onClick={() => {
- if (!drawMode) {
- videoRef.current?.pause();
- onDrawModeChange(true);
- } else {
- onDrawModeChange(false);
- }
- }}
- className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
- drawMode ? 'bg-blue-600 text-white' : 'bg-white/20 text-white hover:bg-white/30'
- }`}
- title="Toggle draw mode (C)"
- >
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <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" />
- </svg>
- </button>
- {/* Fullscreen */}
- <button onClick={toggleFullscreen} className="text-white/80 hover:text-white" title="Fullscreen (F)">
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- {fullscreen ? (
- <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" />
- ) : (
- <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" />
- )}
- </svg>
- </button>
- </div>
- </div>
- </div>
- );
- }
- function formatTimecode(s: number, fps: number = 30): string {
- if (!s || isNaN(s)) return '00:00:00:00';
- const h = Math.floor(s / 3600);
- const m = Math.floor((s % 3600) / 60);
- const sec = Math.floor(s % 60);
- const f = Math.round(s * fps) % fps;
- return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}:${String(f).padStart(2,'0')}`;
- }
|