From f43154c5a354aa73d1c5509feba23b63175151dd Mon Sep 17 00:00:00 2001 From: SatyaViswas Date: Thu, 21 May 2026 19:58:46 +0530 Subject: [PATCH] feat: add interactive dual-thumb timeline range slider with live video scrubbing --- src/components/TrimControl.tsx | 220 +++++++++++++++++++-------------- src/components/VideoEditor.tsx | 3 +- src/hooks/useVideoEditor.ts | 49 ++++---- 3 files changed, 155 insertions(+), 117 deletions(-) diff --git a/src/components/TrimControl.tsx b/src/components/TrimControl.tsx index 577dc1fa..3e8732cb 100644 --- a/src/components/TrimControl.tsx +++ b/src/components/TrimControl.tsx @@ -1,7 +1,7 @@ "use client"; import { EditRecipe } from "@/lib/types"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, type PointerEvent } from "react"; import { AlertCircle } from "lucide-react"; import { formatDuration } from "@/lib/utils"; import { useAudioWaveform } from "@/hooks/useAudioWaveform"; @@ -14,16 +14,16 @@ interface Props { onChange: (patch: Partial) => void; duration: number; file: File | null; + seekTo?: (time: number) => void; } -export default function TrimControl({ recipe, onChange, duration, file }: Props) { +export default function TrimControl({ recipe, onChange, duration, file, seekTo}: Props) { const [invalidStart, setStart] = useState(false); const [invalidEnd, setEnd] = useState(false); const [startErrorMsg, setStartErrorMsg] = useState(""); const [endErrorMsg, setEndErrorMsg] = useState(""); - const [startInput, setStartInput] = useState( - recipe.trimStart.toString() - ); + const [startInput, setStartInput] = useState(recipe.trimStart.toString()); + const [draggingThumb, setDraggingThumb] = useState<"start" | "end" | null>(null); const { waveform, isLoading: waveformLoading } = useAudioWaveform(file); const hasAudio = waveform.length > 0; @@ -32,8 +32,60 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) setStartInput(recipe.trimStart.toString()); }, [recipe.trimStart]); - const clipLength = - (recipe.trimEnd ?? duration) - recipe.trimStart; + const clipLength = (recipe.trimEnd ?? duration) - recipe.trimStart; + const trimEndValue = recipe.trimEnd ?? duration; + const startPercent = duration > 0 ? Math.min(100, Math.max(0, (recipe.trimStart / duration) * 100)) : 0; + const endPercent = duration > 0 ? Math.min(100, Math.max(0, (trimEndValue / duration) * 100)) : 100; + + const updateTrimFromPointer = ( + event: PointerEvent, + thumb: "start" | "end", + ) => { + if (duration <= 0) { + return; + } + + const rect = event.currentTarget.getBoundingClientRect(); + const percent = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)); + const newValue = percent * duration; + + if (thumb === "start") { + handleStart(newValue.toString()); + seekTo?.(newValue); + } else { + handleEnd(newValue.toString()); + seekTo?.(newValue); + } + }; + + const handleTrackPointerDown = (event: PointerEvent) => { + const thumb = (event.target as HTMLElement).closest("[data-thumb]")?.getAttribute("data-thumb"); + + if (thumb !== "start" && thumb !== "end") { + return; + } + + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + setDraggingThumb(thumb); + updateTrimFromPointer(event, thumb); + }; + + const handleTrackPointerMove = (event: PointerEvent) => { + if (!draggingThumb) { + return; + } + + updateTrimFromPointer(event, draggingThumb); + }; + + const handleTrackPointerUp = (event: PointerEvent) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + setDraggingThumb(null); + }; const trackRef = useRef(null); const dragging = useRef<"start" | "end" | null>(null); @@ -57,39 +109,38 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) } }, [xToSeconds, duration, recipe.trimStart, recipe.trimEnd, onChange]); - useEffect(() => { - const onMove = (e: MouseEvent | TouchEvent) => { - let clientX: number; - - if ("touches" in e) { - const touch = e.touches[0]; - - if (!touch) return; - - clientX = touch.clientX; - } else { - clientX = e.clientX; - } - - applyDrag(clientX); - }; - - const onUp = () => { - dragging.current = null; - }; - - document.addEventListener("mousemove", onMove); - document.addEventListener("mouseup", onUp); - document.addEventListener("touchmove", onMove); - document.addEventListener("touchend", onUp); + useEffect(() => { + const onMove = (e: MouseEvent | TouchEvent) => { + let clientX: number; + + if ("touches" in e) { + const touch = e.touches[0]; + if (!touch) return; + clientX = touch.clientX; + } else { + clientX = e.clientX; + } + + applyDrag(clientX); + }; + + const onUp = () => { + dragging.current = null; + }; + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + document.addEventListener("touchmove", onMove); + document.addEventListener("touchend", onUp); + + return () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.removeEventListener("touchmove", onMove); + document.removeEventListener("touchend", onUp); + }; + }, [applyDrag]); - return () => { - document.removeEventListener("mousemove", onMove); - document.removeEventListener("mouseup", onUp); - document.removeEventListener("touchmove", onMove); - document.removeEventListener("touchend", onUp); - }; -}, [applyDrag]); const handleStart = (val: string) => { setStartInput(val); @@ -177,62 +228,43 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) return (
- {duration > 0 && ( + {/* Waveform — shown while loading or when file is present */} + {(file && (waveformLoading || hasAudio)) && (
{ - if (dragging.current) return; - const s = xToSeconds(e.clientX); - onChange({ trimStart: s }); - }} - onKeyDown={(e) => { - if (e.key === "ArrowLeft") onChange({ trimStart: Math.max(0, recipe.trimStart - 0.1) }); - if (e.key === "ArrowRight") onChange({ trimStart: Math.min((recipe.trimEnd ?? duration) - 0.1, recipe.trimStart + 0.1) }); - }} + className="relative w-full rounded-md overflow-hidden bg-[var(--surface)] touch-none" + onPointerDown={handleTrackPointerDown} + onPointerMove={handleTrackPointerMove} + onPointerUp={handleTrackPointerUp} + onPointerCancel={handleTrackPointerUp} > -
-
-
{ dragging.current = "start"; }} - onTouchStart={() => { dragging.current = "start"; }} - onKeyDown={(e) => { - if (e.key === "ArrowLeft") onChange({ trimStart: Math.max(0, recipe.trimStart - 0.1) }); - if (e.key === "ArrowRight") onChange({ trimStart: Math.min((recipe.trimEnd ?? duration) - 0.1, recipe.trimStart + 0.1) }); - }} - /> -
{ dragging.current = "end"; }} - onTouchStart={() => { dragging.current = "end"; }} - onKeyDown={(e) => { - if (e.key === "ArrowLeft") onChange({ trimEnd: Math.max(recipe.trimStart + 0.1, (recipe.trimEnd ?? duration) - 0.1) }); - if (e.key === "ArrowRight") onChange({ trimEnd: Math.min(duration, (recipe.trimEnd ?? duration) + 0.1) }); - }} + + {duration > 0 && ( +
+
+
+
+ )}
)}
@@ -319,6 +351,4 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) )}
); -} - - +} \ No newline at end of file diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index ef9ad3f7..e87da01a 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -361,7 +361,8 @@ export default function VideoEditor() { recipe={recipe} onChange={updateRecipe} duration={duration} - file={file} + file={file} + seekTo={seekTo} /> diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index 77bf9ab4..29aedf68 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -267,33 +267,40 @@ export function useVideoEditor() { } }, []); - useEffect(() => { +useEffect(() => { if (typeof window === "undefined") return; - try { - const params = new URLSearchParams(); - const recipeKeys = Object.keys(DEFAULT_RECIPE) as Array; - recipeKeys.forEach((key) => { - const currentVal = recipe[key]; - const defaultVal = DEFAULT_RECIPE[key]; + // Debounce the URL update to prevent history.replaceState security errors + // when dragging sliders rapidly (max 100 calls per 10s browser limit) + const timeoutId = setTimeout(() => { + try { + const params = new URLSearchParams(); + const recipeKeys = Object.keys(DEFAULT_RECIPE) as Array; + + recipeKeys.forEach((key) => { + const currentVal = recipe[key]; + const defaultVal = DEFAULT_RECIPE[key]; - if (currentVal !== defaultVal) { - params.set(key, currentVal === null ? "null" : String(currentVal)); - } - }); + if (currentVal !== defaultVal) { + params.set(key, currentVal === null ? "null" : String(currentVal)); + } + }); - const newQuery = params.toString(); - const currentQuery = window.location.search.replace(/^\?/, ""); + const newQuery = params.toString(); + const currentQuery = window.location.search.replace(/^\?/, ""); - if (newQuery !== currentQuery) { - const newUrl = newQuery - ? `${window.location.pathname}?${newQuery}` - : window.location.pathname; - window.history.replaceState(null, "", newUrl); + if (newQuery !== currentQuery) { + const newUrl = newQuery + ? `${window.location.pathname}?${newQuery}` + : window.location.pathname; + window.history.replaceState(null, "", newUrl); + } + } catch (e) { + // ignore } - } catch (e) { - // ignore - } + }, 500); // 500ms delay + + return () => clearTimeout(timeoutId); }, [recipe]); useEffect(() => {