feat: Enable Playhead Dragging for Video Navigation

This commit is contained in:
Pulkit Garg
2025-06-24 10:28:58 +05:30
parent 4260d4be08
commit e664ea0271
4 changed files with 64 additions and 14 deletions

View File

@ -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": {

View File

@ -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<number | null>(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) */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-none z-10"
style={{ left: `${currentTime * 50 * zoomLevel}px` }}
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}
>
<div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" />
</div>
@ -668,14 +708,17 @@ export function Timeline() {
</div>
))}
{/* Playhead for tracks area */}
<div
className="absolute top-0 w-0.5 bg-red-500 pointer-events-none z-20"
style={{
left: `${currentTime * 50 * zoomLevel}px`,
height: `${tracks.length * 60}px`,
}}
/>
{/* Playhead for tracks area (scrubbable) */}
{tracks.length > 0 && (
<div
className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-20 cursor-ew-resize"
style={{
left: `${playheadPosition * 50 * zoomLevel}px`,
height: `${tracks.length * 60}px`,
}}
onMouseDown={handlePlayheadMouseDown}
/>
)}
</>
)}
</div>

View File

@ -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,

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "Opencut",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}