diff options
| author | Kha Nguyen <hi@khanguyen.com> | 2025-07-24 16:50:14 -0500 |
|---|---|---|
| committer | Kha Nguyen <hi@khanguyen.com> | 2025-07-24 16:50:14 -0500 |
| commit | 262a7d53d63cc9997f730c7f64b7f8ec29723926 (patch) | |
| tree | 894d6e3986b3ca339dc3ff7ec62fb31b3690be82 /apps/web/src/hooks/use-timeline-playhead.ts | |
| parent | 5a7745f806edf4a386b8cfd079861f96c3b92baf (diff) | |
| parent | 5931ddb77aac23ffe1b82d9d7c03afde3e671109 (diff) | |
| download | opencut-262a7d53d63cc9997f730c7f64b7f8ec29723926.tar.gz | |
Merge branch 'main' into fix/improve-shortcuts
Diffstat (limited to 'apps/web/src/hooks/use-timeline-playhead.ts')
| -rw-r--r-- | apps/web/src/hooks/use-timeline-playhead.ts | 314 |
1 files changed, 157 insertions, 157 deletions
diff --git a/apps/web/src/hooks/use-timeline-playhead.ts b/apps/web/src/hooks/use-timeline-playhead.ts index 9a82c23f..008f61a4 100644 --- a/apps/web/src/hooks/use-timeline-playhead.ts +++ b/apps/web/src/hooks/use-timeline-playhead.ts @@ -1,157 +1,157 @@ -import { snapTimeToFrame } from "@/constants/timeline-constants";
-import { useProjectStore } from "@/stores/project-store";
-import { useState, useEffect, useCallback } from "react";
-
-interface UseTimelinePlayheadProps {
- currentTime: number;
- duration: number;
- zoomLevel: number;
- seek: (time: number) => void;
- rulerRef: React.RefObject<HTMLDivElement>;
- rulerScrollRef: React.RefObject<HTMLDivElement>;
- tracksScrollRef: React.RefObject<HTMLDivElement>;
- playheadRef?: React.RefObject<HTMLDivElement>;
-}
-
-export function useTimelinePlayhead({
- currentTime,
- duration,
- zoomLevel,
- seek,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
-}: UseTimelinePlayheadProps) {
- // Playhead scrubbing state
- const [isScrubbing, setIsScrubbing] = useState(false);
- const [scrubTime, setScrubTime] = useState<number | null>(null);
-
- // Ruler drag detection state
- const [isDraggingRuler, setIsDraggingRuler] = useState(false);
- const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
-
- const playheadPosition =
- isScrubbing && scrubTime !== null ? scrubTime : currentTime;
-
- // --- Playhead Scrubbing Handlers ---
- const handlePlayheadMouseDown = useCallback(
- (e: React.MouseEvent) => {
- e.preventDefault();
- e.stopPropagation(); // Prevent ruler drag from triggering
- setIsScrubbing(true);
- handleScrub(e);
- },
- [duration, zoomLevel]
- );
-
- // Ruler mouse down handler
- const handleRulerMouseDown = useCallback(
- (e: React.MouseEvent) => {
- // Only handle left mouse button
- if (e.button !== 0) return;
-
- // Don't interfere if clicking on the playhead itself
- if (playheadRef?.current?.contains(e.target as Node)) return;
-
- e.preventDefault();
- setIsDraggingRuler(true);
- setHasDraggedRuler(false);
-
- // Start scrubbing immediately
- setIsScrubbing(true);
- handleScrub(e);
- },
- [duration, zoomLevel]
- );
-
- const handleScrub = useCallback(
- (e: MouseEvent | React.MouseEvent) => {
- const ruler = rulerRef.current;
- if (!ruler) return;
- const rect = ruler.getBoundingClientRect();
- const x = e.clientX - rect.left;
- const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel)));
- // Use frame snapping for playhead scrubbing
- const projectStore = useProjectStore.getState();
- const projectFps = projectStore.activeProject?.fps || 30;
- const time = snapTimeToFrame(rawTime, projectFps);
- setScrubTime(time);
- seek(time); // update video preview in real time
- },
- [duration, zoomLevel, seek, rulerRef]
- );
-
- // Mouse move/up event handlers
- useEffect(() => {
- if (!isScrubbing) return;
- const onMouseMove = (e: MouseEvent) => {
- handleScrub(e);
- // Mark that we've dragged if ruler drag is active
- if (isDraggingRuler) {
- setHasDraggedRuler(true);
- }
- };
- const onMouseUp = (e: MouseEvent) => {
- setIsScrubbing(false);
- if (scrubTime !== null) seek(scrubTime); // finalize seek
- setScrubTime(null);
-
- // Handle ruler click vs drag
- if (isDraggingRuler) {
- setIsDraggingRuler(false);
- // If we didn't drag, treat it as a click-to-seek
- if (!hasDraggedRuler) {
- handleScrub(e);
- }
- setHasDraggedRuler(false);
- }
- };
- window.addEventListener("mousemove", onMouseMove);
- window.addEventListener("mouseup", onMouseUp);
- return () => {
- window.removeEventListener("mousemove", onMouseMove);
- window.removeEventListener("mouseup", onMouseUp);
- };
- }, [
- isScrubbing,
- scrubTime,
- seek,
- handleScrub,
- isDraggingRuler,
- hasDraggedRuler,
- ]);
-
- // --- Playhead auto-scroll effect ---
- useEffect(() => {
- const rulerViewport = rulerScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- const tracksViewport = tracksScrollRef.current?.querySelector(
- "[data-radix-scroll-area-viewport]"
- ) as HTMLElement;
- if (!rulerViewport || !tracksViewport) return;
- const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50
- const viewportWidth = rulerViewport.clientWidth;
- const scrollMin = 0;
- const scrollMax = rulerViewport.scrollWidth - viewportWidth;
- // Center the playhead if it's not visible (100px buffer)
- const desiredScroll = Math.max(
- scrollMin,
- Math.min(scrollMax, playheadPx - viewportWidth / 2)
- );
- if (
- playheadPx < rulerViewport.scrollLeft + 100 ||
- playheadPx > rulerViewport.scrollLeft + viewportWidth - 100
- ) {
- rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
- }
- }, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]);
-
- return {
- playheadPosition,
- handlePlayheadMouseDown,
- handleRulerMouseDown,
- isDraggingRuler,
- };
-}
+import { snapTimeToFrame } from "@/constants/timeline-constants"; +import { useProjectStore } from "@/stores/project-store"; +import { useState, useEffect, useCallback } from "react"; + +interface UseTimelinePlayheadProps { + currentTime: number; + duration: number; + zoomLevel: number; + seek: (time: number) => void; + rulerRef: React.RefObject<HTMLDivElement>; + rulerScrollRef: React.RefObject<HTMLDivElement>; + tracksScrollRef: React.RefObject<HTMLDivElement>; + playheadRef?: React.RefObject<HTMLDivElement>; +} + +export function useTimelinePlayhead({ + currentTime, + duration, + zoomLevel, + seek, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, +}: UseTimelinePlayheadProps) { + // Playhead scrubbing state + const [isScrubbing, setIsScrubbing] = useState(false); + const [scrubTime, setScrubTime] = useState<number | null>(null); + + // Ruler drag detection state + const [isDraggingRuler, setIsDraggingRuler] = useState(false); + const [hasDraggedRuler, setHasDraggedRuler] = useState(false); + + const playheadPosition = + isScrubbing && scrubTime !== null ? scrubTime : currentTime; + + // --- Playhead Scrubbing Handlers --- + const handlePlayheadMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); // Prevent ruler drag from triggering + setIsScrubbing(true); + handleScrub(e); + }, + [duration, zoomLevel] + ); + + // Ruler mouse down handler + const handleRulerMouseDown = useCallback( + (e: React.MouseEvent) => { + // Only handle left mouse button + if (e.button !== 0) return; + + // Don't interfere if clicking on the playhead itself + if (playheadRef?.current?.contains(e.target as Node)) return; + + e.preventDefault(); + setIsDraggingRuler(true); + setHasDraggedRuler(false); + + // Start scrubbing immediately + setIsScrubbing(true); + handleScrub(e); + }, + [duration, zoomLevel] + ); + + const handleScrub = useCallback( + (e: MouseEvent | React.MouseEvent) => { + const ruler = rulerRef.current; + if (!ruler) return; + const rect = ruler.getBoundingClientRect(); + const x = e.clientX - rect.left; + const rawTime = Math.max(0, Math.min(duration, x / (50 * zoomLevel))); + // Use frame snapping for playhead scrubbing + const projectStore = useProjectStore.getState(); + const projectFps = projectStore.activeProject?.fps || 30; + const time = snapTimeToFrame(rawTime, projectFps); + setScrubTime(time); + seek(time); // update video preview in real time + }, + [duration, zoomLevel, seek, rulerRef] + ); + + // Mouse move/up event handlers + useEffect(() => { + if (!isScrubbing) return; + const onMouseMove = (e: MouseEvent) => { + handleScrub(e); + // Mark that we've dragged if ruler drag is active + if (isDraggingRuler) { + setHasDraggedRuler(true); + } + }; + const onMouseUp = (e: MouseEvent) => { + setIsScrubbing(false); + if (scrubTime !== null) seek(scrubTime); // finalize seek + setScrubTime(null); + + // Handle ruler click vs drag + if (isDraggingRuler) { + setIsDraggingRuler(false); + // If we didn't drag, treat it as a click-to-seek + if (!hasDraggedRuler) { + handleScrub(e); + } + setHasDraggedRuler(false); + } + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [ + isScrubbing, + scrubTime, + seek, + handleScrub, + isDraggingRuler, + hasDraggedRuler, + ]); + + // --- Playhead auto-scroll effect --- + useEffect(() => { + const rulerViewport = rulerScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + const tracksViewport = tracksScrollRef.current?.querySelector( + "[data-radix-scroll-area-viewport]" + ) as HTMLElement; + if (!rulerViewport || !tracksViewport) return; + const playheadPx = playheadPosition * 50 * zoomLevel; // TIMELINE_CONSTANTS.PIXELS_PER_SECOND = 50 + const viewportWidth = rulerViewport.clientWidth; + const scrollMin = 0; + const scrollMax = rulerViewport.scrollWidth - viewportWidth; + // Center the playhead if it's not visible (100px buffer) + const desiredScroll = Math.max( + scrollMin, + Math.min(scrollMax, playheadPx - viewportWidth / 2) + ); + if ( + playheadPx < rulerViewport.scrollLeft + 100 || + playheadPx > rulerViewport.scrollLeft + viewportWidth - 100 + ) { + rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll; + } + }, [playheadPosition, duration, zoomLevel, rulerScrollRef, tracksScrollRef]); + + return { + playheadPosition, + handlePlayheadMouseDown, + handleRulerMouseDown, + isDraggingRuler, + }; +} |
