summaryrefslogtreecommitdiff
path: root/apps/web/src/hooks/timeline/use-timeline-playhead.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/hooks/timeline/use-timeline-playhead.ts')
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-playhead.ts178
1 files changed, 104 insertions, 74 deletions
diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
index 1426a003..e6204f23 100644
--- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
@@ -1,12 +1,13 @@
import { getSnappedSeekTime } from "@/lib/time";
-import { useState, useEffect, useCallback, useRef } from "react";
+import { useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
import { useShiftKey } from "@/hooks/use-shift-key";
+import { findSnapPoints, snapToNearestPoint } from "@/lib/timeline/snap-utils";
import {
- findSnapPoints,
- snapToNearestPoint,
-} from "@/lib/timeline/snap-utils";
+ getCenteredLineLeft,
+ timelineTimeToSnappedPixels,
+} from "@/lib/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
interface UseTimelinePlayheadProps {
@@ -25,27 +26,33 @@ export function useTimelinePlayhead({
playheadRef,
}: UseTimelinePlayheadProps) {
const editor = useEditor();
+ const isScrubbing = useEditor((e) => e.playback.getIsScrubbing());
const activeProject = editor.project.getActive();
- const currentTime = editor.playback.getCurrentTime();
const duration = editor.timeline.getTotalDuration();
- const isPlaying = editor.playback.getIsPlaying();
- const isScrubbing = editor.playback.getIsScrubbing();
const isShiftHeldRef = useShiftKey();
+ const zoomLevelRef = useRef(zoomLevel);
+ const durationRef = useRef(duration);
+ const isScrubbingRef = useRef(isScrubbing);
+ const isPlayingRef = useRef(false);
+
+ useEffect(() => {
+ zoomLevelRef.current = zoomLevel;
+ durationRef.current = duration;
+ isScrubbingRef.current = isScrubbing;
+ isPlayingRef.current = editor.playback.getIsPlaying();
+ }, [zoomLevel, duration, isScrubbing, editor.playback]);
+
const seek = useCallback(
({ time }: { time: number }) => editor.playback.seek({ time }),
[editor.playback],
);
- const [scrubTime, setScrubTime] = useState<number | null>(null);
-
- const [isDraggingRuler, setIsDraggingRuler] = useState(false);
- const [hasDraggedRuler, setHasDraggedRuler] = useState(false);
+ const scrubTimeRef = useRef<number | null>(null);
+ const isDraggingRulerRef = useRef(false);
+ const hasDraggedRulerRef = useRef(false);
const lastMouseXRef = useRef<number>(0);
- const playheadPosition =
- isScrubbing && scrubTime !== null ? scrubTime : currentTime;
-
const handleScrub = useCallback(
({
event,
@@ -86,8 +93,7 @@ export function useTimelinePlayhead({
const time = (() => {
if (!shouldSnap) return frameTime;
const tracks = editor.timeline.getTracks();
- const bookmarks =
- editor.scenes.getActiveScene()?.bookmarks ?? [];
+ const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
const snapPoints = findSnapPoints({
tracks,
playheadTime: frameTime,
@@ -102,7 +108,7 @@ export function useTimelinePlayhead({
return snapResult.snapPoint ? snapResult.snappedTime : frameTime;
})();
- setScrubTime(time);
+ scrubTimeRef.current = time;
seek({ time });
lastMouseXRef.current = event.clientX;
@@ -132,17 +138,16 @@ export function useTimelinePlayhead({
const handleRulerMouseDown = useCallback(
({ event }: { event: React.MouseEvent }) => {
if (event.button !== 0) return;
-
if (playheadRef?.current?.contains(event.target as Node)) return;
event.preventDefault();
- setIsDraggingRuler(true);
- setHasDraggedRuler(false);
+ isDraggingRulerRef.current = true;
+ hasDraggedRulerRef.current = false;
- editor.playback.setScrubbing({ isScrubbing: true });
- handleScrub({ event, snappingEnabled: false });
- },
- [handleScrub, playheadRef, editor.playback],
+ editor.playback.setScrubbing({ isScrubbing: true });
+ handleScrub({ event, snappingEnabled: false });
+ },
+ [handleScrub, playheadRef, editor.playback],
);
const handlePlayheadMouseDownEvent = useCallback(
@@ -168,31 +173,32 @@ export function useTimelinePlayhead({
const handleMouseMove = ({ event }: { event: MouseEvent }) => {
handleScrub({ event });
- if (isDraggingRuler) {
- setHasDraggedRuler(true);
+ if (isDraggingRulerRef.current) {
+ hasDraggedRulerRef.current = true;
}
};
const handleMouseUp = ({ event }: { event: MouseEvent }) => {
editor.playback.setScrubbing({ isScrubbing: false });
- if (scrubTime !== null) {
- seek({ time: scrubTime });
+ const finalTime = scrubTimeRef.current;
+ if (finalTime !== null) {
+ seek({ time: finalTime });
editor.project.setTimelineViewState({
viewState: {
zoomLevel,
scrollLeft: tracksScrollRef.current?.scrollLeft ?? 0,
- playheadTime: scrubTime,
+ playheadTime: finalTime,
},
});
}
- setScrubTime(null);
+ scrubTimeRef.current = null;
- if (isDraggingRuler) {
- setIsDraggingRuler(false);
- if (!hasDraggedRuler) {
+ if (isDraggingRulerRef.current) {
+ isDraggingRulerRef.current = false;
+ if (!hasDraggedRulerRef.current) {
handleScrub({ event, snappingEnabled: false });
}
- setHasDraggedRuler(false);
+ hasDraggedRulerRef.current = false;
}
};
@@ -206,55 +212,79 @@ export function useTimelinePlayhead({
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
- }, [
- isScrubbing,
- scrubTime,
- seek,
- handleScrub,
- isDraggingRuler,
- hasDraggedRuler,
- editor,
- tracksScrollRef,
- zoomLevel,
- ]);
+ }, [isScrubbing, seek, handleScrub, editor, tracksScrollRef, zoomLevel]);
+
+ const updatePlayheadLeft = useCallback(
+ (time: number) => {
+ const playheadEl = playheadRef?.current;
+ if (!playheadEl) return;
+ const centerPosition = timelineTimeToSnappedPixels({
+ time,
+ zoomLevel: zoomLevelRef.current,
+ });
+ const leftPosition = getCenteredLineLeft({ centerPixel: centerPosition });
+ const scrollLeft = rulerScrollRef.current?.scrollLeft ?? 0;
+ playheadEl.style.left = `${leftPosition - scrollLeft}px`;
+ },
+ [playheadRef, rulerScrollRef],
+ );
useEffect(() => {
- if (!isPlaying || isScrubbing) return;
+ const scrollEl = rulerScrollRef.current;
+ if (!scrollEl) return;
- const rulerViewport = rulerScrollRef.current;
- const tracksViewport = tracksScrollRef.current;
- if (!rulerViewport || !tracksViewport) return;
+ const handleScroll = () => {
+ updatePlayheadLeft(editor.playback.getCurrentTime());
+ };
- const playheadPixels =
- playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
- const viewportWidth = rulerViewport.clientWidth;
- const scrollMinimum = 0;
- const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
+ scrollEl.addEventListener("scroll", handleScroll, { passive: true });
+ return () => scrollEl.removeEventListener("scroll", handleScroll);
+ }, [editor.playback, rulerScrollRef, updatePlayheadLeft]);
- const needsScroll =
- playheadPixels < rulerViewport.scrollLeft ||
- playheadPixels > rulerViewport.scrollLeft + viewportWidth;
+ useEffect(() => {
+ const handlePlaybackUpdate = (e: Event) => {
+ const time = (e as CustomEvent<{ time: number }>).detail.time;
+ updatePlayheadLeft(time);
- if (needsScroll) {
- const desiredScroll = Math.max(
- scrollMinimum,
- Math.min(scrollMaximum, playheadPixels - viewportWidth / 2),
- );
- rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
- }
- }, [
- playheadPosition,
- zoomLevel,
- rulerScrollRef,
- tracksScrollRef,
- isScrubbing,
- isPlaying,
- ]);
+ if (!isPlayingRef.current || isScrubbingRef.current) return;
+ const rulerViewport = rulerScrollRef.current;
+ const tracksViewport = tracksScrollRef.current;
+ if (!rulerViewport || !tracksViewport) return;
+
+ const playheadPixels =
+ time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevelRef.current;
+ const viewportWidth = rulerViewport.clientWidth;
+ const scrollMinimum = 0;
+ const scrollMaximum = rulerViewport.scrollWidth - viewportWidth;
+
+ const needsScroll =
+ playheadPixels < rulerViewport.scrollLeft ||
+ playheadPixels > rulerViewport.scrollLeft + viewportWidth;
+
+ if (needsScroll) {
+ const desiredScroll = Math.max(
+ scrollMinimum,
+ Math.min(scrollMaximum, playheadPixels - viewportWidth / 2),
+ );
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
+ }
+ };
+
+ const initialTime = editor.playback.getCurrentTime();
+ handlePlaybackUpdate({
+ detail: { time: initialTime },
+ } as CustomEvent<{ time: number }>);
+
+ window.addEventListener("playback-update", handlePlaybackUpdate);
+ window.addEventListener("playback-seek", handlePlaybackUpdate);
+ return () => {
+ window.removeEventListener("playback-update", handlePlaybackUpdate);
+ window.removeEventListener("playback-seek", handlePlaybackUpdate);
+ };
+ }, [editor.playback, rulerScrollRef, tracksScrollRef, updatePlayheadLeft]);
return {
- playheadPosition,
handlePlayheadMouseDown: handlePlayheadMouseDownEvent,
handleRulerMouseDown: handleRulerMouseDownEvent,
- isDraggingRuler,
};
}