| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349 |
- '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;
- comments: Comment[];
- pendingAnnotation: AnnotationData | null;
- onAnnotationCreated: (annotation: AnnotationData) => void;
- onTimeUpdate: (time: number) => void;
- onCommentClick: (comment: Comment) => void;
- }
- export function VideoPlayer({
- src,
- mimeType,
- comments,
- pendingAnnotation,
- onAnnotationCreated,
- 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 [drawMode, setDrawMode] = useState(false);
- const [tool, setTool] = useState<Tool>('pen');
- const [color, setColor] = useState(COLORS[0].value);
- 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(); setDrawMode(v => !v); }
- 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); }
- };
- window.addEventListener('keydown', handleKey);
- return () => window.removeEventListener('keydown', handleKey);
- }, [duration]);
- // 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;
- // Approximate frame step: 1/fps (assume 30fps)
- const frameTime = 1 / 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);
- }
- }
- const 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);
- };
- // Annotations visible at current time (±0.5s)
- const visibleAnnotations = comments
- .filter(c => c.annotation && c.timestamp != null && Math.abs(c.timestamp - currentTime) < 0.5)
- .map(c => c.annotation as AnnotationData);
- 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={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 Canvas */}
- <AnnotationCanvas
- isDrawingMode={drawMode}
- tool={tool}
- color={color}
- annotations={[...visibleAnnotations, ...(pendingAnnotation ? [pendingAnnotation] : [])]}
- width={dims.width}
- height={dims.height}
- onAnnotationCreated={onAnnotationCreated}
- />
- {/* Big play button overlay */}
- {!playing && (
- <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 ? '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 mr-1">Draw:</span>
- {(['pen', 'arrow', 'rect', 'ellipse'] as Tool[]).map(t => (
- <button
- key={t}
- onClick={() => setTool(t)}
- className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
- tool === 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 ${
- color === c.value ? 'border-white scale-125' : 'border-transparent'
- }`}
- style={{ backgroundColor: c.value }}
- onClick={() => setColor(c.value)}
- title={c.name}
- />
- ))}
- <button
- onClick={() => setDrawMode(false)}
- className="ml-2 text-xs text-white/60 hover:text-white"
- >
- Done
- </button>
- </div>
- )}
- {/* Timeline */}
- <Timeline
- duration={duration}
- currentTime={currentTime}
- 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">
- {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"
- />
- <span className="text-xs text-white/60 ml-1">
- {formatTime(currentTime)} / {formatTime(duration)}
- </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 */}
- <button
- onClick={() => setDrawMode(v => !v)}
- 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 formatTime(s: number): string {
- if (!s || isNaN(s)) return '0:00';
- const m = Math.floor(s / 60);
- const sec = Math.floor(s % 60);
- return `${m}:${sec.toString().padStart(2, '0')}`;
- }
|