This commit is contained in:
@@ -170,21 +170,60 @@ export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSee
|
||||
isDragging.current = false;
|
||||
}, []);
|
||||
|
||||
// ── Touch scrubbing ──────────────────────────────────────────────────────
|
||||
const getFrameFromTouch = useCallback(
|
||||
(touch: Touch): number => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || totalFrames === 0) return 0;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(touch.clientX - rect.left, rect.width));
|
||||
return Math.round((x / rect.width) * totalFrames);
|
||||
},
|
||||
[totalFrames]
|
||||
);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
isDragging.current = true;
|
||||
onSeek(getFrameFromTouch(touch));
|
||||
},
|
||||
[getFrameFromTouch, onSeek]
|
||||
);
|
||||
|
||||
const handleTouchMove = useCallback(
|
||||
(e: TouchEvent) => {
|
||||
if (!isDragging.current) return;
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
if (!touch) return;
|
||||
onSeek(getFrameFromTouch(touch));
|
||||
},
|
||||
[getFrameFromTouch, onSeek]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("mouseup", handleMouseUp);
|
||||
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
window.addEventListener("touchend", handleMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("mouseup", handleMouseUp);
|
||||
window.removeEventListener("touchmove", handleTouchMove);
|
||||
window.removeEventListener("touchend", handleMouseUp);
|
||||
};
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
}, [handleMouseMove, handleMouseUp, handleTouchMove]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="frame-timeline w-full cursor-ew-resize"
|
||||
style={{ height: 48 }}
|
||||
style={{ height: 48, touchAction: "none" }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleTouchStart}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export function PlaybackControls({
|
||||
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">
|
||||
<div className="flex items-center gap-2 bg-black/90 px-3 py-2 border-t border-white/5" style={{ touchAction: "manipulation" }}>
|
||||
{/* 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">
|
||||
@@ -91,38 +91,41 @@ export function PlaybackControls({
|
||||
|
||||
{/* 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>
|
||||
{/* Go to start + Reverse — hidden on mobile */}
|
||||
<div className="hidden sm:contents">
|
||||
<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={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>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -171,22 +174,25 @@ export function PlaybackControls({
|
||||
<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>
|
||||
{/* Go to end — hidden on mobile */}
|
||||
<div className="hidden sm:contents">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
@@ -194,19 +200,21 @@ export function PlaybackControls({
|
||||
|
||||
{/* 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>
|
||||
{/* Playback speed — hidden on mobile */}
|
||||
<div className="hidden sm:contents">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Annotation toggle */}
|
||||
<Tooltip>
|
||||
|
||||
@@ -250,8 +250,11 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Video */}
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
{/* Video — tap anywhere to play/pause (pointer-events pass through canvas when not annotating) */}
|
||||
<div
|
||||
className="relative flex-1 min-h-0 overflow-hidden"
|
||||
onClick={!isAnnotating ? togglePlayback : undefined}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
|
||||
Reference in New Issue
Block a user