summaryrefslogtreecommitdiff
path: root/apps/web/src/timeline/controllers/playhead-controller.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-25 03:56:22 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-25 03:56:22 +0200
commit6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692 (patch)
tree976d60dbe1ac8e44d6a9259f9d6e20fe6530c698 /apps/web/src/timeline/controllers/playhead-controller.ts
parent0dffefdd59d8c70fece63436e8556f8ec1e43a49 (diff)
downloadopencut-6a22a3ecbdff2d0e6a4c24c8db40bb86bc01b692.tar.gz
refactor: centralize editor interaction state
Move timeline and preview gestures into dedicated controllers so hooks stay thin around editor state and DOM events. This also routes drag and playback synchronization through manager APIs and reuses persistent wasm surfaces so scrubbing, seeking, and preview rendering stay in sync. Made-with: Cursor
Diffstat (limited to 'apps/web/src/timeline/controllers/playhead-controller.ts')
-rw-r--r--apps/web/src/timeline/controllers/playhead-controller.ts314
1 files changed, 314 insertions, 0 deletions
diff --git a/apps/web/src/timeline/controllers/playhead-controller.ts b/apps/web/src/timeline/controllers/playhead-controller.ts
new file mode 100644
index 00000000..5260c046
--- /dev/null
+++ b/apps/web/src/timeline/controllers/playhead-controller.ts
@@ -0,0 +1,314 @@
+import type { MouseEvent as ReactMouseEvent } from "react";
+import { snappedSeekTime, type FrameRate } from "opencut-wasm";
+import { TICKS_PER_SECOND } from "@/wasm";
+import {
+ buildTimelineSnapPoints,
+ getTimelineSnapThresholdInTicks,
+ resolveTimelineSnap,
+} from "@/timeline/snapping";
+import { getBookmarkSnapPoints } from "@/timeline/bookmarks/index";
+import { getElementEdgeSnapPoints } from "@/timeline/element-snap-source";
+import { getAnimationKeyframeSnapPointsForTimeline } from "@/animation/timeline-snap-points";
+import {
+ getCenteredLineLeft,
+ timelineTimeToPixels,
+ timelineTimeToSnappedPixels,
+} from "@/timeline";
+import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/timeline/scale";
+import type { Bookmark, SceneTracks } from "@/timeline";
+
+// --- Session ---
+
+interface ScrubSession {
+ kind: "scrubbing";
+ /** True when scrub started from a ruler click (not the playhead handle). */
+ didStartFromRuler: boolean;
+ /** True once the mouse has moved during a ruler drag. */
+ hasMoved: boolean;
+ /** Most recent frame-snapped time set by scrub(). */
+ currentTime: number | null;
+}
+
+type Session = { kind: "idle" } | ScrubSession;
+
+// --- Config ---
+
+export interface PlayheadConfig {
+ zoomLevel: number;
+ duration: number;
+ getActiveProjectFps: () => FrameRate | null;
+ isShiftHeld: () => boolean;
+ getIsPlaying: () => boolean;
+ getRulerEl: () => HTMLDivElement | null;
+ getRulerScrollEl: () => HTMLDivElement | null;
+ getTracksScrollEl: () => HTMLDivElement | null;
+ getPlayheadEl: () => HTMLDivElement | null;
+ getSceneTracks: () => SceneTracks;
+ getSceneBookmarks: () => Bookmark[];
+ seek: (time: number) => void;
+ setScrubbing: (isScrubbing: boolean) => void;
+ setTimelineViewState: (viewState: {
+ zoomLevel: number;
+ scrollLeft: number;
+ playheadTime: number;
+ }) => void;
+}
+
+export interface PlayheadConfigRef {
+ readonly current: PlayheadConfig;
+}
+
+// --- Pure helpers (px → logical) ---
+
+function pixelToTime({
+ clientX,
+ rulerEl,
+ zoomLevel,
+ duration,
+}: {
+ clientX: number;
+ rulerEl: HTMLDivElement;
+ zoomLevel: number;
+ duration: number;
+}): number {
+ const rulerRect = rulerEl.getBoundingClientRect();
+ const contentWidth = timelineTimeToPixels({ time: duration, zoomLevel });
+ const clampedX = Math.max(
+ 0,
+ Math.min(contentWidth, clientX - rulerRect.left),
+ );
+ const seconds = Math.max(
+ 0,
+ Math.min(
+ duration / TICKS_PER_SECOND,
+ clampedX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel),
+ ),
+ );
+ return Math.round(seconds * TICKS_PER_SECOND);
+}
+
+// --- Controller ---
+
+export class PlayheadController {
+ private lastMouseClientX = 0;
+
+ private session: Session = { kind: "idle" };
+ private readonly configRef: PlayheadConfigRef;
+
+ constructor(deps: { configRef: PlayheadConfigRef }) {
+ this.configRef = deps.configRef;
+ this.onPlayheadMouseDown = this.onPlayheadMouseDown.bind(this);
+ this.onRulerMouseDown = this.onRulerMouseDown.bind(this);
+ this.handleMouseMove = this.handleMouseMove.bind(this);
+ this.handleMouseUp = this.handleMouseUp.bind(this);
+ }
+
+ private get config(): PlayheadConfig {
+ return this.configRef.current;
+ }
+
+ get isActive(): boolean {
+ return this.session.kind !== "idle";
+ }
+
+ getLastMouseClientX(): number {
+ return this.lastMouseClientX;
+ }
+
+ destroy(): void {
+ this.deactivate();
+ }
+
+ // --- Public event handlers (bound, stable references) ---
+
+ onPlayheadMouseDown(event: ReactMouseEvent): void {
+ event.preventDefault();
+ event.stopPropagation();
+ this.session = {
+ kind: "scrubbing",
+ didStartFromRuler: false,
+ hasMoved: false,
+ currentTime: null,
+ };
+ this.config.setScrubbing(true);
+ this.scrub({ event, isElementSnappingEnabled: true });
+ this.activate();
+ }
+
+ onRulerMouseDown(event: ReactMouseEvent): void {
+ if (event.button !== 0) return;
+ if (this.config.getPlayheadEl()?.contains(event.target as Node)) return;
+
+ event.preventDefault();
+ this.session = {
+ kind: "scrubbing",
+ didStartFromRuler: true,
+ hasMoved: false,
+ currentTime: null,
+ };
+ this.config.setScrubbing(true);
+ // No element-edge snapping on initial ruler click — avoids a jarring jump.
+ this.scrub({ event, isElementSnappingEnabled: false });
+ this.activate();
+ }
+
+ // --- Public non-session methods ---
+
+ /**
+ * Imperatively updates the playhead DOM element's `left` style.
+ * Called on scroll and playback events to avoid React re-renders
+ * during animation frame updates.
+ */
+ updatePlayheadLeft(time: number): void {
+ const playheadEl = this.config.getPlayheadEl();
+ if (!playheadEl) return;
+
+ const centerPixel = timelineTimeToSnappedPixels({
+ time,
+ zoomLevel: this.config.zoomLevel,
+ });
+ const scrollLeft = this.config.getRulerScrollEl()?.scrollLeft ?? 0;
+ playheadEl.style.left = `${getCenteredLineLeft({ centerPixel }) - scrollLeft}px`;
+ }
+
+ /**
+ * Updates the playhead position and auto-scrolls to keep the playhead
+ * visible during playback.
+ */
+ handlePlaybackUpdate(time: number): void {
+ this.updatePlayheadLeft(time);
+
+ // Auto-scroll only during playback, not while scrubbing.
+ if (!this.config.getIsPlaying() || this.session.kind === "scrubbing")
+ return;
+
+ const rulerViewport = this.config.getRulerScrollEl();
+ const tracksViewport = this.config.getTracksScrollEl();
+ if (!rulerViewport || !tracksViewport) return;
+
+ const playheadPixels = timelineTimeToPixels({
+ time,
+ zoomLevel: this.config.zoomLevel,
+ });
+ const viewportWidth = rulerViewport.clientWidth;
+ const isOutOfView =
+ playheadPixels < rulerViewport.scrollLeft ||
+ playheadPixels > rulerViewport.scrollLeft + viewportWidth;
+
+ if (isOutOfView) {
+ const desiredScroll = Math.max(
+ 0,
+ Math.min(
+ rulerViewport.scrollWidth - viewportWidth,
+ playheadPixels - viewportWidth / 2,
+ ),
+ );
+ rulerViewport.scrollLeft = tracksViewport.scrollLeft = desiredScroll;
+ }
+ }
+
+ // --- Private ---
+
+ private activate(): void {
+ window.addEventListener("mousemove", this.handleMouseMove);
+ window.addEventListener("mouseup", this.handleMouseUp);
+ }
+
+ private deactivate(): void {
+ window.removeEventListener("mousemove", this.handleMouseMove);
+ window.removeEventListener("mouseup", this.handleMouseUp);
+ }
+
+ /**
+ * Converts pointer position to a frame-snapped timeline time and seeks.
+ * `isElementSnappingEnabled` controls element-edge snapping; frame-level snapping
+ * is always applied.
+ */
+ private scrub({
+ event,
+ isElementSnappingEnabled,
+ }: {
+ event: MouseEvent | ReactMouseEvent;
+ isElementSnappingEnabled: boolean;
+ }): void {
+ const ruler = this.config.getRulerEl();
+ if (!ruler) return;
+
+ const fps = this.config.getActiveProjectFps();
+ if (!fps) return;
+
+ const { zoomLevel, duration } = this.config;
+ const rawTime = pixelToTime({
+ clientX: event.clientX,
+ rulerEl: ruler,
+ zoomLevel,
+ duration,
+ });
+ const frameTime =
+ snappedSeekTime({ time: rawTime, duration, rate: fps }) ?? rawTime;
+
+ const time = (() => {
+ if (!isElementSnappingEnabled || this.config.isShiftHeld())
+ return frameTime;
+
+ const snapPoints = buildTimelineSnapPoints({
+ sources: [
+ () =>
+ getElementEdgeSnapPoints({ tracks: this.config.getSceneTracks() }),
+ () =>
+ getBookmarkSnapPoints({
+ bookmarks: this.config.getSceneBookmarks(),
+ }),
+ () =>
+ getAnimationKeyframeSnapPointsForTimeline({
+ tracks: this.config.getSceneTracks(),
+ }),
+ ],
+ });
+ const result = resolveTimelineSnap({
+ targetTime: frameTime,
+ snapPoints,
+ maxSnapDistance: getTimelineSnapThresholdInTicks({ zoomLevel }),
+ });
+ return result.snapPoint ? result.snappedTime : frameTime;
+ })();
+
+ if (this.session.kind === "scrubbing") {
+ this.session.currentTime = time;
+ }
+ this.config.seek(time);
+ this.lastMouseClientX = event.clientX;
+ }
+
+ private handleMouseMove(event: MouseEvent): void {
+ if (this.session.kind !== "scrubbing") return;
+ this.scrub({ event, isElementSnappingEnabled: true });
+ if (this.session.didStartFromRuler) {
+ this.session.hasMoved = true;
+ }
+ }
+
+ private handleMouseUp(event: MouseEvent): void {
+ if (this.session.kind !== "scrubbing") return;
+
+ const session = this.session;
+ this.config.setScrubbing(false);
+
+ if (session.currentTime !== null) {
+ this.config.seek(session.currentTime);
+ this.config.setTimelineViewState({
+ zoomLevel: this.config.zoomLevel,
+ scrollLeft: this.config.getTracksScrollEl()?.scrollLeft ?? 0,
+ playheadTime: session.currentTime,
+ });
+ }
+
+ // Ruler click without drag: snap to clicked position on mouseup.
+ if (session.didStartFromRuler && !session.hasMoved) {
+ this.scrub({ event, isElementSnappingEnabled: false });
+ }
+
+ this.session = { kind: "idle" };
+ this.deactivate();
+ }
+}