Initial commit
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useCallback, RefObject } from "react";
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
import { frameToPosition } from "@/lib/frame-utils";
|
||||
import type { CommentWithReplies } from "@/types";
|
||||
|
||||
interface FrameTimelineProps {
|
||||
fps: number;
|
||||
comments: CommentWithReplies[];
|
||||
annotations?: { frameNumber: number }[];
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
onSeek: (frame: number) => void;
|
||||
}
|
||||
|
||||
export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSeek }: FrameTimelineProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const isDragging = useRef(false);
|
||||
const { currentFrame, totalFrames } = useReviewStore();
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const W = canvas.width;
|
||||
const H = canvas.height;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = "hsl(0 0% 6%)";
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Frame ruler ticks
|
||||
if (totalFrames > 0) {
|
||||
const tickInterval = Math.max(1, Math.floor(totalFrames / (W / 40)));
|
||||
|
||||
ctx.strokeStyle = "hsl(0 0% 20%)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.fillStyle = "hsl(0 0% 35%)";
|
||||
ctx.font = "9px monospace";
|
||||
ctx.textAlign = "center";
|
||||
|
||||
for (let f = 0; f <= totalFrames; f += tickInterval) {
|
||||
const x = Math.round((f / totalFrames) * W);
|
||||
const isMajor = f % (tickInterval * 5) === 0 || tickInterval > 20;
|
||||
|
||||
ctx.strokeStyle = isMajor ? "hsl(0 0% 22%)" : "hsl(0 0% 16%)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, isMajor ? 0 : H / 2);
|
||||
ctx.lineTo(x, H);
|
||||
ctx.stroke();
|
||||
|
||||
if (isMajor && f > 0) {
|
||||
ctx.fillStyle = "hsl(0 0% 35%)";
|
||||
ctx.fillText(String(f), x, 11);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Annotation markers — amber diamonds at top
|
||||
const seenAnnotationFrames = new Set<number>();
|
||||
annotations.forEach((ann) => {
|
||||
if (totalFrames === 0) return;
|
||||
if (seenAnnotationFrames.has(ann.frameNumber)) return;
|
||||
seenAnnotationFrames.add(ann.frameNumber);
|
||||
const x = Math.round(frameToPosition(ann.frameNumber, totalFrames) * W);
|
||||
// Amber diamond
|
||||
ctx.fillStyle = "hsl(38 92% 50%)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 2);
|
||||
ctx.lineTo(x + 4, 7);
|
||||
ctx.lineTo(x, 12);
|
||||
ctx.lineTo(x - 4, 7);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
// Comment markers — blue triangles pointing down from top
|
||||
comments.forEach((comment) => {
|
||||
if (totalFrames === 0) return;
|
||||
const x = Math.round(frameToPosition(comment.frameNumber, totalFrames) * W);
|
||||
|
||||
if (comment.isResolved) {
|
||||
ctx.fillStyle = "hsl(142 71% 45% / 0.7)";
|
||||
} else {
|
||||
ctx.fillStyle = "hsl(213 94% 68%)";
|
||||
}
|
||||
|
||||
// Triangle marker pointing down from top
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - 4, 0);
|
||||
ctx.lineTo(x + 4, 0);
|
||||
ctx.lineTo(x, 8);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
// Playhead
|
||||
if (totalFrames > 0) {
|
||||
const playheadX = Math.round(
|
||||
frameToPosition(currentFrame, totalFrames) * W
|
||||
);
|
||||
|
||||
// Red line
|
||||
ctx.strokeStyle = "hsl(0 72% 51%)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(playheadX, 0);
|
||||
ctx.lineTo(playheadX, H);
|
||||
ctx.stroke();
|
||||
|
||||
// Red diamond at top
|
||||
ctx.fillStyle = "hsl(0 72% 51%)";
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(playheadX, H - 4);
|
||||
ctx.lineTo(playheadX - 5, H - 10);
|
||||
ctx.lineTo(playheadX, H - 16);
|
||||
ctx.lineTo(playheadX + 5, H - 10);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
}, [currentFrame, totalFrames, comments, annotations]);
|
||||
|
||||
// Resize observer to match canvas to container
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
canvas.width = canvas.offsetWidth;
|
||||
canvas.height = canvas.offsetHeight;
|
||||
draw();
|
||||
});
|
||||
observer.observe(canvas);
|
||||
return () => observer.disconnect();
|
||||
}, [draw]);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
}, [draw]);
|
||||
|
||||
// Seek on click/drag
|
||||
const getFrameFromEvent = useCallback(
|
||||
(e: React.MouseEvent | MouseEvent): number => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || totalFrames === 0) return 0;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
return Math.round((x / rect.width) * totalFrames);
|
||||
},
|
||||
[totalFrames]
|
||||
);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
isDragging.current = true;
|
||||
onSeek(getFrameFromEvent(e));
|
||||
};
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
onSeek(getFrameFromEvent(e));
|
||||
},
|
||||
[getFrameFromEvent, onSeek]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
isDragging.current = false;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="frame-timeline w-full cursor-ew-resize"
|
||||
style={{ height: 48 }}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { RefObject } from "react";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
ChevronFirst,
|
||||
ChevronLast,
|
||||
Maximize2,
|
||||
MessageSquarePlus,
|
||||
Pencil,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useReviewStore } from "@/hooks/use-review-player";
|
||||
import { frameToTimecode } from "@/lib/frame-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PLAYBACK_RATES = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
|
||||
interface PlaybackControlsProps {
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
fps: number;
|
||||
isReversing: boolean;
|
||||
onStepBackward: () => void;
|
||||
onStepForward: () => void;
|
||||
onReverse: () => void;
|
||||
onTogglePlay: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
onAddComment: () => void;
|
||||
}
|
||||
|
||||
export function PlaybackControls({
|
||||
videoRef,
|
||||
fps,
|
||||
isReversing,
|
||||
onStepBackward,
|
||||
onStepForward,
|
||||
onReverse,
|
||||
onTogglePlay,
|
||||
onToggleFullscreen,
|
||||
onAddComment,
|
||||
}: PlaybackControlsProps) {
|
||||
const {
|
||||
isPlaying,
|
||||
currentFrame,
|
||||
currentTime,
|
||||
totalFrames,
|
||||
playbackRate,
|
||||
isAnnotating,
|
||||
showAnnotations,
|
||||
setPlaybackRate,
|
||||
setAnnotating,
|
||||
setShowAnnotations,
|
||||
} = useReviewStore();
|
||||
|
||||
const handleRateChange = (val: string) => {
|
||||
const rate = parseFloat(val);
|
||||
if (videoRef.current) videoRef.current.playbackRate = rate;
|
||||
setPlaybackRate(rate);
|
||||
};
|
||||
|
||||
const timecode = frameToTimecode(currentFrame, fps);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 bg-black/90 px-3 py-2 border-t border-white/5">
|
||||
{/* Frame info */}
|
||||
<div className="flex items-center gap-3 font-mono text-xs text-zinc-300 min-w-0">
|
||||
<span className="hidden sm:block text-zinc-500">
|
||||
{timecode}
|
||||
</span>
|
||||
<span className="text-white font-semibold">
|
||||
F{String(currentFrame).padStart(4, "0")}
|
||||
</span>
|
||||
<span className="text-zinc-600 hidden md:block">
|
||||
/ {totalFrames}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-5 w-px bg-white/10 mx-1" />
|
||||
|
||||
{/* Transport Controls */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white"
|
||||
onClick={() => {
|
||||
if (videoRef.current) videoRef.current.currentTime = 0;
|
||||
}}
|
||||
>
|
||||
<ChevronFirst className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Go to start</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"text-zinc-400 hover:text-white",
|
||||
isReversing && "text-amber-400 bg-amber-400/10"
|
||||
)}
|
||||
onClick={onReverse}
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reverse (J)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white"
|
||||
onClick={onStepBackward}
|
||||
>
|
||||
<SkipBack className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Step back (←)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Play/Pause — slightly larger */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-white hover:text-white hover:bg-white/10 h-9 w-9"
|
||||
onClick={onTogglePlay}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause className="h-5 w-5 fill-current" />
|
||||
) : (
|
||||
<Play className="h-5 w-5 fill-current" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Play / Pause (K or Space)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white"
|
||||
onClick={onStepForward}
|
||||
>
|
||||
<SkipForward className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Step forward (→)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white"
|
||||
onClick={() => {
|
||||
if (videoRef.current)
|
||||
videoRef.current.currentTime = videoRef.current.duration;
|
||||
}}
|
||||
>
|
||||
<ChevronLast className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Go to end</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Playback speed */}
|
||||
<Select value={String(playbackRate)} onValueChange={handleRateChange}>
|
||||
<SelectTrigger className="h-7 w-16 text-xs border-0 bg-white/5 text-zinc-300 px-2">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PLAYBACK_RATES.map((r) => (
|
||||
<SelectItem key={r} value={String(r)} className="text-xs">
|
||||
{r}x
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Annotation toggle */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"text-zinc-400 hover:text-white",
|
||||
isAnnotating && "text-amber-400 bg-amber-400/10"
|
||||
)}
|
||||
onClick={() => setAnnotating(!isAnnotating)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isAnnotating ? "Stop drawing" : "Draw annotation"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Show/hide annotations */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white"
|
||||
onClick={() => setShowAnnotations(!showAnnotations)}
|
||||
>
|
||||
{showAnnotations ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{showAnnotations ? "Hide annotations" : "Show annotations"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Add Comment */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-primary"
|
||||
onClick={onAddComment}
|
||||
>
|
||||
<MessageSquarePlus className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add comment at frame {currentFrame}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Fullscreen */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-zinc-400 hover:text-white"
|
||||
onClick={onToggleFullscreen}
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Fullscreen (F)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user