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", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.1", "vaul": "^1.1.1",
"zod": "^3.25.67",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {

View File

@ -27,7 +27,7 @@ import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
import { processMediaFiles } from "@/lib/media-processing"; import { processMediaFiles } from "@/lib/media-processing";
import { toast } from "sonner"; import { toast } from "sonner";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -69,6 +69,10 @@ export function Timeline() {
additive: boolean; additive: boolean;
} | null>(null); } | null>(null);
// Playhead scrubbing state
const [isScrubbing, setIsScrubbing] = useState(false);
const [scrubTime, setScrubTime] = useState<number | null>(null);
// Update timeline duration when tracks change // Update timeline duration when tracks change
useEffect(() => { useEffect(() => {
const totalDuration = getTotalDuration(); const totalDuration = getTotalDuration();
@ -304,6 +308,41 @@ export function Timeline() {
setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); 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 = { const dragProps = {
onDragEnter: handleDragEnter, onDragEnter: handleDragEnter,
onDragOver: handleDragOver, onDragOver: handleDragOver,
@ -555,10 +594,11 @@ export function Timeline() {
}).filter(Boolean); }).filter(Boolean);
})()} })()}
{/* Playhead in ruler */} {/* Playhead in ruler (scrubbable) */}
<div <div
className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-none z-10" className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-auto z-10 cursor-ew-resize"
style={{ left: `${currentTime * 50 * zoomLevel}px` }} 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 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> </div>
@ -668,14 +708,17 @@ export function Timeline() {
</div> </div>
))} ))}
{/* Playhead for tracks area */} {/* Playhead for tracks area (scrubbable) */}
<div {tracks.length > 0 && (
className="absolute top-0 w-0.5 bg-red-500 pointer-events-none z-20" <div
style={{ className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-20 cursor-ew-resize"
left: `${currentTime * 50 * zoomLevel}px`, style={{
height: `${tracks.length * 60}px`, left: `${playheadPosition * 50 * zoomLevel}px`,
}} height: `${tracks.length * 60}px`,
/> }}
onMouseDown={handlePlayheadMouseDown}
/>
)}
</> </>
)} )}
</div> </div>

View File

@ -42,7 +42,7 @@ export function VideoPlayer({
if (!video) return; if (!video) return;
const handleSeekEvent = (e: CustomEvent) => { const handleSeekEvent = (e: CustomEvent) => {
if (!isInClipRange) return; // Always update video time, even if outside clip range
const timelineTime = e.detail.time; const timelineTime = e.detail.time;
const newVideoTime = Math.max(trimStart, Math.min( const newVideoTime = Math.max(trimStart, Math.min(
clipDuration - trimEnd, clipDuration - trimEnd,
@ -52,7 +52,7 @@ export function VideoPlayer({
}; };
const handleUpdateEvent = (e: CustomEvent) => { const handleUpdateEvent = (e: CustomEvent) => {
if (!isInClipRange) return; // Always update video time, even if outside clip range
const timelineTime = e.detail.time; const timelineTime = e.detail.time;
const targetVideoTime = Math.max(trimStart, Math.min( const targetVideoTime = Math.max(trimStart, Math.min(
clipDuration - trimEnd, clipDuration - trimEnd,

6
package-lock.json generated Normal file
View File

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