diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 7718a97..4017882 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -45,6 +45,7 @@ "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "vaul": "^1.1.1", + "zod": "^3.25.67", "zustand": "^5.0.2" }, "devDependencies": { diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 41e842b..ce81f9e 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -27,7 +27,7 @@ import { useMediaStore } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; import { processMediaFiles } from "@/lib/media-processing"; import { toast } from "sonner"; -import { useState, useRef, useEffect } from "react"; +import { useState, useRef, useEffect, useCallback } from "react"; import { Select, SelectContent, @@ -69,6 +69,10 @@ export function Timeline() { additive: boolean; } | null>(null); + // Playhead scrubbing state + const [isScrubbing, setIsScrubbing] = useState(false); + const [scrubTime, setScrubTime] = useState(null); + // Update timeline duration when tracks change useEffect(() => { const totalDuration = getTotalDuration(); @@ -345,6 +349,41 @@ export function Timeline() { // Otherwise, allow normal scrolling }; + // --- Playhead Scrubbing Handlers --- + const handlePlayheadMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setIsScrubbing(true); + handleScrub(e); + }, [duration, zoomLevel]); + + const handleScrub = useCallback((e: MouseEvent | React.MouseEvent) => { + const timeline = timelineRef.current; + if (!timeline) return; + const rect = timeline.getBoundingClientRect(); + const x = e.clientX - rect.left; + const time = Math.max(0, Math.min(duration, x / (50 * zoomLevel))); + setScrubTime(time); + seek(time); // update video preview in real time + }, [duration, zoomLevel, seek]); + + useEffect(() => { + if (!isScrubbing) return; + const onMouseMove = (e: MouseEvent) => handleScrub(e); + const onMouseUp = (e: MouseEvent) => { + setIsScrubbing(false); + if (scrubTime !== null) seek(scrubTime); // finalize seek + setScrubTime(null); + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [isScrubbing, scrubTime, seek, handleScrub]); + + const playheadPosition = isScrubbing && scrubTime !== null ? scrubTime : currentTime; + const dragProps = { onDragEnter: handleDragEnter, onDragOver: handleDragOver, @@ -686,28 +725,11 @@ export function Timeline() { }).filter(Boolean); })()} - {/* Playhead in ruler */} + {/* Playhead in ruler (scrubbable) */}
{ - e.preventDefault(); - e.stopPropagation(); - const handleMouseMove = (e: MouseEvent) => { - const timeline = timelineRef.current; // Get timeline element ref to track the position - if (!timeline) return; // If no timeline element, exit - const rect = timeline.getBoundingClientRect(); // Get the bounding rect of the timeline element - const mouseX = Math.max(0, e.clientX - rect.left); // Calculate the mouse position relative to the timeline element - const newTime = mouseX / (50 * zoomLevel); // Calculate the time based on the mouse position - seek(newTime); // Set the current time - }; - const handleMouseUp = () => { - window.removeEventListener("mousemove", handleMouseMove); // Remove the mousemove event listener - window.removeEventListener("mouseup", handleMouseUp); // Remove the mouseup event listener - }; - window.addEventListener("mousemove", handleMouseMove); // Add the mousemove event listener - window.addEventListener("mouseup", handleMouseUp); // Add the mouseup event listener - }} + className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-auto z-10 cursor-ew-resize" + style={{ left: `${playheadPosition * 50 * zoomLevel}px` }} + onMouseDown={handlePlayheadMouseDown} >
@@ -815,14 +837,17 @@ export function Timeline() {
))} - {/* Playhead for tracks area */} -
+ {/* Playhead for tracks area (scrubbable) */} + {tracks.length > 0 && ( +
+ )} )}
diff --git a/apps/web/src/components/ui/video-player.tsx b/apps/web/src/components/ui/video-player.tsx index 8731e1a..fdc2ed0 100644 --- a/apps/web/src/components/ui/video-player.tsx +++ b/apps/web/src/components/ui/video-player.tsx @@ -42,7 +42,7 @@ export function VideoPlayer({ if (!video) return; const handleSeekEvent = (e: CustomEvent) => { - if (!isInClipRange) return; + // Always update video time, even if outside clip range const timelineTime = e.detail.time; const newVideoTime = Math.max(trimStart, Math.min( clipDuration - trimEnd, @@ -52,7 +52,7 @@ export function VideoPlayer({ }; const handleUpdateEvent = (e: CustomEvent) => { - if (!isInClipRange) return; + // Always update video time, even if outside clip range const timelineTime = e.detail.time; const targetVideoTime = Math.max(trimStart, Math.min( clipDuration - trimEnd,