feat: Enable Playhead Dragging for Video Navigation
This commit is contained in:
1
apps/web/package-lock.json
generated
1
apps/web/package-lock.json
generated
@ -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": {
|
||||||
|
@ -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) */}
|
||||||
|
{tracks.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 w-0.5 bg-red-500 pointer-events-none z-20"
|
className="absolute top-0 w-0.5 bg-red-500 pointer-events-auto z-20 cursor-ew-resize"
|
||||||
style={{
|
style={{
|
||||||
left: `${currentTime * 50 * zoomLevel}px`,
|
left: `${playheadPosition * 50 * zoomLevel}px`,
|
||||||
height: `${tracks.length * 60}px`,
|
height: `${tracks.length * 60}px`,
|
||||||
}}
|
}}
|
||||||
|
onMouseDown={handlePlayheadMouseDown}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -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
6
package-lock.json
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "Opencut",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
Reference in New Issue
Block a user