mobile support for client review player
Deploy / deploy (push) Failing after 5m17s

This commit is contained in:
twotalesanimation
2026-06-12 09:01:28 +02:00
parent 3248b8596a
commit 289e0c367a
5 changed files with 176 additions and 70 deletions
@@ -278,7 +278,7 @@ export default function ClientReviewPage({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-8 text-xs gap-1 text-orange-400 border-orange-500/30 hover:bg-orange-500/10" className="h-10 sm:h-8 text-xs gap-1 text-orange-400 border-orange-500/30 hover:bg-orange-500/10"
onClick={() => setApprovalDialog({ open: true, status: "NEEDS_CHANGES" })} onClick={() => setApprovalDialog({ open: true, status: "NEEDS_CHANGES" })}
> >
<AlertCircle className="h-3.5 w-3.5" /> <AlertCircle className="h-3.5 w-3.5" />
@@ -286,7 +286,7 @@ export default function ClientReviewPage({
</Button> </Button>
<Button <Button
size="sm" size="sm"
className="h-8 text-xs gap-1 bg-emerald-600 hover:bg-emerald-500 text-white" className="h-10 sm:h-8 text-xs gap-1 bg-emerald-600 hover:bg-emerald-500 text-white"
onClick={() => setApprovalDialog({ open: true, status: "APPROVED" })} onClick={() => setApprovalDialog({ open: true, status: "APPROVED" })}
> >
<CheckCircle2 className="h-3.5 w-3.5" /> <CheckCircle2 className="h-3.5 w-3.5" />
@@ -295,8 +295,8 @@ export default function ClientReviewPage({
</div> </div>
</header> </header>
{/* Main: player + comments */} {/* Main: player + comments — stacked on mobile, side-by-side on md+ */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-col md:flex-row flex-1 overflow-hidden">
{/* Player */} {/* Player */}
<div className="flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col bg-black"> <div className="flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col bg-black">
<ReviewPlayer <ReviewPlayer
@@ -310,7 +310,7 @@ export default function ClientReviewPage({
</div> </div>
{/* Comment panel */} {/* Comment panel */}
<div className="w-72 xl:w-80 shrink-0 flex flex-col border-l border-zinc-800 bg-zinc-900"> <div className="h-64 md:h-auto md:w-72 xl:w-80 shrink-0 flex flex-col border-t md:border-t-0 md:border-l border-zinc-800 bg-zinc-900">
<div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between"> <div className="px-4 py-3 border-b border-zinc-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2"> <h3 className="text-sm font-semibold text-white flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-zinc-400" /> <MessageSquare className="h-4 w-4 text-zinc-400" />
@@ -362,17 +362,73 @@ export function AnnotationCanvas({
} }
}, [versionId, frameNumber, fps, selectedColor, annotationCommentedFrames, onAnnotationSaved, toast]); }, [versionId, frameNumber, fps, selectedColor, annotationCommentedFrames, onAnnotationSaved, toast]);
// ── Touch events (mirrors mouse events for mobile drawing) ──────────────
const handleTouchStart = useCallback(
(e: React.TouchEvent<HTMLCanvasElement>) => {
if (!isAnnotating) return;
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const touch = e.touches[0];
if (!touch) return;
const point: AnnotationPoint = {
x: (touch.clientX - rect.left) / rect.width,
y: (touch.clientY - rect.top) / rect.height,
};
const newShape: AnnotationShape = {
id: uuidv4(),
tool: selectedTool,
points: [point],
color: selectedColor,
strokeWidth,
frameNumber,
};
setDrawingState((prev) => ({ ...prev, isDrawing: true, currentShape: newShape }));
},
[isAnnotating, selectedTool, selectedColor, strokeWidth, frameNumber]
);
const handleTouchMove = useCallback(
(e: React.TouchEvent<HTMLCanvasElement>) => {
if (!drawingStateRef.current.isDrawing || !drawingStateRef.current.currentShape) return;
e.preventDefault();
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const touch = e.touches[0];
if (!touch) return;
const point: AnnotationPoint = {
x: (touch.clientX - rect.left) / rect.width,
y: (touch.clientY - rect.top) / rect.height,
};
setDrawingState((prev) => ({
...prev,
currentShape: prev.currentShape
? { ...prev.currentShape, points: [...prev.currentShape.points, point] }
: null,
}));
redraw();
},
[redraw]
);
return ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className={`annotation-canvas-overlay ${isAnnotating ? "is-annotating" : ""}`} className={`annotation-canvas-overlay ${isAnnotating ? "is-annotating" : ""}`}
style={{ style={{
cursor: isAnnotating ? "crosshair" : "default", cursor: isAnnotating ? "crosshair" : "default",
touchAction: isAnnotating ? "none" : "auto",
}} }}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp} onMouseLeave={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={() => void handleMouseUp()}
onTouchCancel={() => void handleMouseUp()}
/> />
); );
} }
+41 -2
View File
@@ -170,21 +170,60 @@ export function FrameTimeline({ fps, comments, annotations = [], videoRef, onSee
isDragging.current = false; 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(() => { useEffect(() => {
window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp); window.addEventListener("mouseup", handleMouseUp);
window.addEventListener("touchmove", handleTouchMove, { passive: false });
window.addEventListener("touchend", handleMouseUp);
return () => { return () => {
window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp); window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("touchmove", handleTouchMove);
window.removeEventListener("touchend", handleMouseUp);
}; };
}, [handleMouseMove, handleMouseUp]); }, [handleMouseMove, handleMouseUp, handleTouchMove]);
return ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className="frame-timeline w-full cursor-ew-resize" className="frame-timeline w-full cursor-ew-resize"
style={{ height: 48 }} style={{ height: 48, touchAction: "none" }}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
/> />
); );
} }
+10 -2
View File
@@ -72,7 +72,7 @@ export function PlaybackControls({
const timecode = frameToTimecode(currentFrame, fps); const timecode = frameToTimecode(currentFrame, fps);
return ( 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 */} {/* Frame info */}
<div className="flex items-center gap-3 font-mono text-xs text-zinc-300 min-w-0"> <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"> <span className="hidden sm:block text-zinc-500">
@@ -91,6 +91,8 @@ export function PlaybackControls({
{/* Transport Controls */} {/* Transport Controls */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* Go to start + Reverse — hidden on mobile */}
<div className="hidden sm:contents">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@@ -123,6 +125,7 @@ export function PlaybackControls({
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Reverse (J)</TooltipContent> <TooltipContent>Reverse (J)</TooltipContent>
</Tooltip> </Tooltip>
</div>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -171,6 +174,8 @@ export function PlaybackControls({
<TooltipContent>Step forward ()</TooltipContent> <TooltipContent>Step forward ()</TooltipContent>
</Tooltip> </Tooltip>
{/* Go to end — hidden on mobile */}
<div className="hidden sm:contents">
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
@@ -188,13 +193,15 @@ export function PlaybackControls({
<TooltipContent>Go to end</TooltipContent> <TooltipContent>Go to end</TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
</div>
{/* Spacer */} {/* Spacer */}
<div className="flex-1" /> <div className="flex-1" />
{/* Right Controls */} {/* Right Controls */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Playback speed */} {/* Playback speed — hidden on mobile */}
<div className="hidden sm:contents">
<Select value={String(playbackRate)} onValueChange={handleRateChange}> <Select value={String(playbackRate)} onValueChange={handleRateChange}>
<SelectTrigger className="h-7 w-16 text-xs border-0 bg-white/5 text-zinc-300 px-2"> <SelectTrigger className="h-7 w-16 text-xs border-0 bg-white/5 text-zinc-300 px-2">
<SelectValue /> <SelectValue />
@@ -207,6 +214,7 @@ export function PlaybackControls({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
{/* Annotation toggle */} {/* Annotation toggle */}
<Tooltip> <Tooltip>
+5 -2
View File
@@ -250,8 +250,11 @@ export const ReviewPlayer = forwardRef<ReviewPlayerRef, ReviewPlayerProps>(
className className
)} )}
> >
{/* Video */} {/* Video — tap anywhere to play/pause (pointer-events pass through canvas when not annotating) */}
<div className="relative flex-1 min-h-0 overflow-hidden"> <div
className="relative flex-1 min-h-0 overflow-hidden"
onClick={!isAnnotating ? togglePlayback : undefined}
>
<video <video
ref={videoRef} ref={videoRef}
src={videoUrl} src={videoUrl}