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
size="sm"
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" })}
>
<AlertCircle className="h-3.5 w-3.5" />
@@ -286,7 +286,7 @@ export default function ClientReviewPage({
</Button>
<Button
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" })}
>
<CheckCircle2 className="h-3.5 w-3.5" />
@@ -295,8 +295,8 @@ export default function ClientReviewPage({
</div>
</header>
{/* Main: player + comments */}
<div className="flex flex-1 overflow-hidden">
{/* Main: player + comments — stacked on mobile, side-by-side on md+ */}
<div className="flex flex-col md:flex-row flex-1 overflow-hidden">
{/* Player */}
<div className="flex-1 min-w-0 min-h-0 overflow-hidden flex flex-col bg-black">
<ReviewPlayer
@@ -310,7 +310,7 @@ export default function ClientReviewPage({
</div>
{/* 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">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-zinc-400" />
@@ -362,17 +362,73 @@ export function AnnotationCanvas({
}
}, [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 (
<canvas
ref={canvasRef}
className={`annotation-canvas-overlay ${isAnnotating ? "is-annotating" : ""}`}
style={{
cursor: isAnnotating ? "crosshair" : "default",
touchAction: isAnnotating ? "none" : "auto",
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={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;
}, []);
// ── 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}
/>
);
}
+69 -61
View File
@@ -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>
+5 -2
View File
@@ -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}