"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 { AspectOverlay } from "./AspectOverlay"; import { FrameTimeline } from "./FrameTimeline"; import { PlaybackControls } from "./PlaybackControls"; import { HandleOverlay } from "./HandleOverlay"; import { useZoomPan } from "@/hooks/use-zoom-pan"; import { Minus, Plus } from "lucide-react"; import { timeToFrame, frameToTime, durationToFrameCount } from "@/lib/frame-utils"; import { cn } from "@/lib/utils"; import { computeEditRange, frameRegion, type VersionHandles } from "@/lib/handles"; 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; /** Override the default server action used to persist annotations (e.g. for client-token routes) */ saveAnnotationFn?: (data: { versionId: string; frameNumber: number; drawingData: unknown; color: string }) => Promise; /** Override the default server action used to create the companion comment */ addCommentFn?: (data: { versionId: string; frameNumber: number; timestamp: number; text: string }) => Promise; /** The version's handle metadata — frames past the cut get flagged. */ handles?: VersionHandles | null; /** Start with playback kept to the edit (the client portal's default). */ defaultEditRangeOnly?: boolean; /** High quality source (full res, high bitrate); enables the quality switch. */ hqVideoUrl?: string | null; } export type PlaybackQuality = "standard" | "high"; const QUALITY_KEY = "review:quality"; export const ReviewPlayer = forwardRef( function ReviewPlayer( { videoUrl, versionId, fps = 24, comments = [], annotations, className, onAddComment, onAnnotationSaved, saveAnnotationFn, addCommentFn, handles, defaultEditRangeOnly = false, hqVideoUrl, }, 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 stageRef = useRef(null); const zoomLayerRef = useRef(null); const reverseIntervalRef = useRef | null>(null); const openedAtCut = useRef(false); // ── Quality ────────────────────────────────────────────────────────────── // Standard is the review MP4; High is the full-res, high-bitrate encode of // the delivery MOV. The choice is a per-browser preference, read after // mount so the server render and the first client render agree. const [quality, setQuality] = useState("standard"); const [switchingQuality, setSwitchingQuality] = useState(false); // Where to pick up once the other source has loaded. const resumeRef = useRef<{ time: number; playing: boolean } | null>(null); useEffect(() => { try { if (localStorage.getItem(QUALITY_KEY) === "high") setQuality("high"); } catch { // storage blocked — Standard it is } }, []); const activeSrc = quality === "high" && hqVideoUrl ? hqVideoUrl : videoUrl; const changeQuality = useCallback( (next: PlaybackQuality) => { if (next === quality) return; const video = videoRef.current; if (video && video.readyState > 0 && hqVideoUrl) { resumeRef.current = { time: video.currentTime, playing: !video.paused }; setSwitchingQuality(true); } setQuality(next); try { localStorage.setItem(QUALITY_KEY, next); } catch { // preference just isn't remembered } }, [quality, hqVideoUrl] ); const [isReversing, setIsReversing] = useState(false); const { isPlaying, currentFrame, isAnnotating, showAnnotations, aspectPresetId, blankingMode, setPlaying, setCurrentFrame, setCurrentTime, setDuration, setFps, setTotalFrames, totalFrames, editRange, editRangeOnly, setEditRange, setEditRangeOnly, } = useReviewStore(); // ── Handles ────────────────────────────────────────────────────────────── // Where the cut sits in this media. Recomputed from primitives so a parent // re-render with a fresh `handles` object does not churn the store. const handleHead = handles?.handleHead ?? null; const handleTail = handles?.handleTail ?? null; const pictureStartFrame = handles?.pictureStartFrame ?? null; const handleFrameCount = handles?.frameCount ?? null; useEffect(() => { setEditRange( computeEditRange( { handleHead, handleTail, pictureStartFrame, frameCount: handleFrameCount }, totalFrames ) ); }, [handleHead, handleTail, pictureStartFrame, handleFrameCount, totalFrames, setEditRange]); useEffect(() => () => setEditRange(null), [setEditRange]); useEffect(() => { setEditRangeOnly(defaultEditRangeOnly); }, [defaultEditRangeOnly, setEditRangeOnly]); // Read inside event handlers / intervals without re-binding them. const keepToEdit = useRef<{ range: typeof editRange; on: boolean }>({ range: null, on: false }); keepToEdit.current = { range: editRange, on: editRangeOnly && !!editRange }; const zoom = useZoomPan(stageRef, zoomLayerRef, { gesturesEnabled: !isAnnotating, }); const { reset: resetZoom, zoomBy } = zoom; // Initialize player config from props useEffect(() => { setFps(fps); }, [fps, setFps]); // Reset playback state when the source changes so the timeline and // timecode don't briefly show the previous clip's values while the new // video is loading its metadata. useEffect(() => { setCurrentFrame(0); setCurrentTime(0); setDuration(0); setTotalFrames(0); resetZoom(); openedAtCut.current = false; resumeRef.current = null; setSwitchingQuality(false); }, [videoUrl, setCurrentFrame, setCurrentTime, setDuration, setTotalFrames, resetZoom]); // ── 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); // A new source starts at 1x; keep the speed the reviewer chose. video.playbackRate = useReviewStore.getState().playbackRate; // Quality switch: carry on from the same frame, playing or not. const resume = resumeRef.current; resumeRef.current = null; if (resume) { video.currentTime = resume.time; if (resume.playing) video.play().catch(() => {}); } }, [fps, setDuration, setTotalFrames]); // High quality failed to load (pruned, or a network hiccup): fall back to // Standard rather than leave a black player. Not remembered as a choice. const handleVideoError = useCallback(() => { if (quality !== "high" || !hqVideoUrl) return; setQuality("standard"); }, [quality, hqVideoUrl]); /** * Play — when keeping to the edit, from the cut in-point if the playhead * is outside the cut (slate, a handle, or parked on the last cut frame). */ const startPlayback = useCallback(() => { const video = videoRef.current; if (!video) return; const { range, on } = keepToEdit.current; if (on && range) { const frame = timeToFrame(video.currentTime, fps); if (frame < range.cutIn || frame >= range.tailStart - 1) { video.currentTime = frameToTime(range.cutIn, fps); } } // A pause() or power-saving pause before playback starts rejects this; // that's expected, not an error. video.play().catch(() => {}); }, [fps]); // Keeping to the edit: stop on the last frame of the cut rather than // playing into the tail handle. Checked every animation frame — the // video's own timeupdate only fires ~4x a second. useEffect(() => { if (!isPlaying || !editRangeOnly || !editRange) return; const lastCutFrame = editRange.tailStart - 1; let raf = 0; const tick = () => { const video = videoRef.current; if (!video || video.paused) return; if (timeToFrame(video.currentTime, fps) >= editRange.tailStart) { video.pause(); video.currentTime = frameToTime(lastCutFrame, fps); return; } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [isPlaying, editRangeOnly, editRange, fps]); // Keeping to the edit: open on the first frame of the cut rather than on // the slate. Once per video, so it never moves the playhead afterwards. useEffect(() => { const video = videoRef.current; if (!video || openedAtCut.current || !editRangeOnly || !editRange) return; openedAtCut.current = true; if (video.paused && timeToFrame(video.currentTime, fps) < editRange.cutIn) { video.currentTime = frameToTime(editRange.cutIn, fps); } }, [editRange, editRangeOnly, fps]); 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: () => startPlayback(), 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(() => { // Keeping to the edit, reverse stops at the cut in-point. const { range, on } = keepToEdit.current; const floor = on && range ? frameToTime(range.cutIn, fps) : 0; if (!videoRef.current || videoRef.current.currentTime <= floor) { stopReverse(); return; } videoRef.current.currentTime = Math.max(floor, videoRef.current.currentTime - 1 / fps); }, 1000 / fps); }, [fps, stopReverse]); const togglePlayback = useCallback(() => { const video = videoRef.current; if (!video) return; stopReverse(); if (video.paused) { startPlayback(); } else { video.pause(); } }, [stopReverse, startPlayback]); // ── 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) startPlayback(); 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; case "=": case "+": e.preventDefault(); zoomBy(1.25); break; case "-": case "_": e.preventDefault(); zoomBy(0.8); break; case "0": e.preventDefault(); resetZoom(); break; } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isReversing, stepFrame, startReverse, stopReverse, togglePlayback, startPlayback, zoomBy, resetZoom]); // Stop a running reverse only on unmount. This used to live in the // cleanup above, which re-runs whenever isReversing changes — so starting // reverse immediately stopped it, and J never did anything. useEffect(() => stopReverse, [stopReverse]); // ── 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 — tap anywhere to play/pause (pointer-events pass through canvas when not annotating). Wheel / pinch zooms, drag pans when zoomed; a drag never counts as a tap. */}
{ if (!zoom.consumeDrag()) togglePlayback(); } : undefined } > {/* Zoomed as one layer so blanking and annotations stay pinned to the picture — annotation coords are normalised from the canvas's transformed bounding rect, so drawing while zoomed lands correctly. */}
{/* Handle flag — drawn over the player, never into the media */} {/* Zoom badge — only while zoomed */} {zoom.zoomed && (
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} > {Math.round(zoom.scale * 100)}%
)} {switchingQuality && (
Loading {quality === "high" ? "high" : "standard"} quality…
)} {/* JKL hint overlay — shown briefly when reversing */} {isReversing && (
◄◄ REVERSE
)}
{/* 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} quality={hqVideoUrl ? quality : null} onQualityChange={changeQuality} />
); } );