From e664ea0271a9c34bb7290447823367ba6fffac7d Mon Sep 17 00:00:00 2001 From: Pulkit Garg Date: Tue, 24 Jun 2025 10:28:58 +0530 Subject: [PATCH] feat: Enable Playhead Dragging for Video Navigation --- apps/web/package-lock.json | 1 + apps/web/src/components/editor/timeline.tsx | 67 +++++++++++++++++---- apps/web/src/components/ui/video-player.tsx | 4 +- package-lock.json | 6 ++ 4 files changed, 64 insertions(+), 14 deletions(-) create mode 100644 package-lock.json 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 675b398..e97b9ce 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(); @@ -304,6 +308,41 @@ export function Timeline() { setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); }; + // --- 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, @@ -555,10 +594,11 @@ export function Timeline() { }).filter(Boolean); })()} - {/* Playhead in ruler */} + {/* Playhead in ruler (scrubbable) */}
@@ -668,14 +708,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, diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e1f6b63 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "Opencut", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}