314 lines
9.7 KiB
TypeScript
314 lines
9.7 KiB
TypeScript
"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<ReviewPlayerRef, ReviewPlayerProps>(
|
|
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<HTMLVideoElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const reverseIntervalRef = useRef<ReturnType<typeof setInterval> | 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 (
|
|
<div
|
|
ref={containerRef}
|
|
className={cn(
|
|
"review-player-container flex flex-col bg-black select-none h-full",
|
|
className
|
|
)}
|
|
>
|
|
{/* Video */}
|
|
<div className="relative flex-1 min-h-0 overflow-hidden">
|
|
<video
|
|
ref={videoRef}
|
|
src={videoUrl}
|
|
className="block w-full h-full object-contain"
|
|
onTimeUpdate={handleTimeUpdate}
|
|
onLoadedMetadata={handleLoadedMetadata}
|
|
onPlay={handlePlay}
|
|
onPause={handlePause}
|
|
preload="metadata"
|
|
playsInline
|
|
/>
|
|
|
|
{/* Annotation canvas overlay */}
|
|
<AnnotationCanvas
|
|
versionId={versionId}
|
|
frameNumber={currentFrame}
|
|
fps={fps}
|
|
isAnnotating={isAnnotating && !isPlaying}
|
|
showAnnotations={showAnnotations}
|
|
existingAnnotations={stableAnnotations}
|
|
onAnnotationSaved={onAnnotationSaved}
|
|
/>
|
|
|
|
{/* JKL hint overlay — shown briefly when reversing */}
|
|
{isReversing && (
|
|
<div className="absolute top-3 left-3 rounded bg-black/60 px-2 py-1 text-xs font-mono text-white">
|
|
◄◄ REVERSE
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Annotation Tools (visible when annotation mode is active) */}
|
|
<AnnotationTools />
|
|
|
|
{/* Frame Timeline */}
|
|
<FrameTimeline
|
|
fps={fps}
|
|
comments={comments}
|
|
annotations={annotations as { frameNumber: number }[]}
|
|
videoRef={videoRef}
|
|
onSeek={handleSeek}
|
|
/>
|
|
|
|
{/* Playback Controls */}
|
|
<PlaybackControls
|
|
videoRef={videoRef}
|
|
fps={fps}
|
|
isReversing={isReversing}
|
|
onStepBackward={() => stepFrame(-1)}
|
|
onStepForward={() => stepFrame(1)}
|
|
onReverse={isReversing ? stopReverse : startReverse}
|
|
onTogglePlay={togglePlayback}
|
|
onToggleFullscreen={toggleFullscreen}
|
|
onAddComment={handleAddComment}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
);
|