"use client"; import { useRef, useEffect, useState, useCallback, useMemo, forwardRef, useImperativeHandle, } from "react"; import { useReviewStore } from "@/hooks/use-review-player"; import { AnnotationCanvas } from "@/components/annotations/AnnotationCanvas"; import { AnnotationTools } from "@/components/annotations/AnnotationTools"; import { FrameTimeline } from "./FrameTimeline"; import { PlaybackControls } from "./PlaybackControls"; import { timeToFrame, frameToTime, durationToFrameCount } from "@/lib/frame-utils"; import { cn } from "@/lib/utils"; import type { CommentWithReplies } from "@/types"; export interface ReviewPlayerRef { seekToFrame: (frame: number) => void; play: () => void; pause: () => void; } interface ReviewPlayerProps { videoUrl: string; versionId: string; fps?: number; comments?: CommentWithReplies[]; annotations?: unknown[]; className?: string; onAddComment?: (frameNumber: number, timestamp: number) => void; onAnnotationSaved?: (frameNumber: number) => void; } export const ReviewPlayer = forwardRef( function ReviewPlayer( { videoUrl, versionId, fps = 24, comments = [], annotations, className, onAddComment, onAnnotationSaved, }, ref ) { // Stabilise the annotations reference so AnnotationCanvas's sync effect // only fires when the actual content changes, not on every parent re-render. const stableAnnotations = useMemo(() => annotations ?? [], [annotations]); const videoRef = useRef(null); const containerRef = useRef(null); const reverseIntervalRef = useRef | null>(null); const [isReversing, setIsReversing] = useState(false); const { isPlaying, currentFrame, isAnnotating, showAnnotations, setPlaying, setCurrentFrame, setCurrentTime, setDuration, setFps, setTotalFrames, } = useReviewStore(); // Initialize player config from props useEffect(() => { setFps(fps); }, [fps, setFps]); // ── Playback state sync ────────────────────────────────────────────────── const handleTimeUpdate = useCallback(() => { const video = videoRef.current; if (!video) return; const frame = timeToFrame(video.currentTime, fps); setCurrentFrame(frame); setCurrentTime(video.currentTime); }, [fps, setCurrentFrame, setCurrentTime]); const handleLoadedMetadata = useCallback(() => { const video = videoRef.current; if (!video) return; const frames = durationToFrameCount(video.duration, fps); setDuration(video.duration); setTotalFrames(frames); }, [fps, setDuration, setTotalFrames]); const handlePlay = useCallback(() => setPlaying(true), [setPlaying]); const handlePause = useCallback(() => setPlaying(false), [setPlaying]); // ── Exposed ref API ────────────────────────────────────────────────────── useImperativeHandle(ref, () => ({ seekToFrame: (frame: number) => { const video = videoRef.current; if (!video) return; video.currentTime = frameToTime(frame, fps); if (!video.paused) video.pause(); }, play: () => videoRef.current?.play(), pause: () => videoRef.current?.pause(), })); // ── Frame step functions ───────────────────────────────────────────────── const stepFrame = useCallback( (delta: number) => { const video = videoRef.current; if (!video) return; video.pause(); stopReverse(); const newTime = Math.max(0, Math.min(video.duration, video.currentTime + delta / fps)); video.currentTime = newTime; }, [fps] ); const stopReverse = useCallback(() => { if (reverseIntervalRef.current) { clearInterval(reverseIntervalRef.current); reverseIntervalRef.current = null; } setIsReversing(false); }, []); const startReverse = useCallback(() => { const video = videoRef.current; if (!video) return; video.pause(); setIsReversing(true); reverseIntervalRef.current = setInterval(() => { if (!videoRef.current || videoRef.current.currentTime <= 0) { stopReverse(); return; } videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 1 / fps); }, 1000 / fps); }, [fps, stopReverse]); const togglePlayback = useCallback(() => { const video = videoRef.current; if (!video) return; stopReverse(); if (video.paused) { video.play(); } else { video.pause(); } }, [stopReverse]); // ── Keyboard shortcuts (JKL + arrows + space + F) ─────────────────────── useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if ( target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable ) return; switch (e.key.toLowerCase()) { case "j": e.preventDefault(); if (isReversing) { stopReverse(); } else { startReverse(); } break; case "k": e.preventDefault(); if (isReversing) { stopReverse(); } else { togglePlayback(); } break; case "l": e.preventDefault(); stopReverse(); if (videoRef.current?.paused) videoRef.current.play(); break; case "arrowleft": e.preventDefault(); stepFrame(-1); break; case "arrowright": e.preventDefault(); stepFrame(1); break; case " ": e.preventDefault(); stopReverse(); togglePlayback(); break; case "f": e.preventDefault(); toggleFullscreen(); break; } }; window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("keydown", handleKeyDown); stopReverse(); }; }, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback]); // ── Fullscreen ─────────────────────────────────────────────────────────── const toggleFullscreen = () => { const el = containerRef.current; if (!el) return; if (!document.fullscreenElement) { el.requestFullscreen().catch(() => {}); } else { document.exitFullscreen(); } }; const handleSeek = useCallback( (frame: number) => { const video = videoRef.current; if (!video) return; stopReverse(); video.currentTime = frameToTime(frame, fps); video.pause(); }, [fps, stopReverse] ); const handleAddComment = useCallback(() => { const video = videoRef.current; if (!video) return; video.pause(); onAddComment?.(currentFrame, video.currentTime); }, [currentFrame, onAddComment]); return (
{/* Video */}
{/* Annotation Tools (visible when annotation mode is active) */} {/* Frame Timeline */} {/* Playback Controls */} stepFrame(-1)} onStepForward={() => stepFrame(1)} onReverse={isReversing ? stopReverse : startReverse} onTogglePlay={togglePlayback} onToggleFullscreen={toggleFullscreen} onAddComment={handleAddComment} />
); } );