285 lines
8.1 KiB
TypeScript
285 lines
8.1 KiB
TypeScript
"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>
|
|
);
|
|
}
|