summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks/use-timeline-playhead.ts
diff options
context:
space:
mode:
authorHayden Bleasel <hello@haydenbleasel.com>2025-07-24 14:41:34 -0700
committerGitHub <noreply@github.com>2025-07-25 00:41:34 +0300
commit5931ddb77aac23ffe1b82d9d7c03afde3e671109 (patch)
tree3e2ead9cc6e13c01132a78c21b64f2be7b00e215 /apps/web/src/hooks/use-timeline-playhead.ts
parent7b840e9b4e198212eb34861c21469b2215157914 (diff)
downloadopencut-5931ddb77aac23ffe1b82d9d7c03afde3e671109.tar.gz
feat: add ultracite (#452)
* Init ultracite * Update scripts and biome.jsonc * Update biome.jsonc * Update biome.jsonc * Update biome.jsonc * Run format
Diffstat (limited to 'apps/web/src/hooks/use-timeline-playhead.ts')
-rw-r--r--apps/web/src/hooks/use-timeline-playhead.ts314
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,
+ };
+}