From bd43d213ceb0185e8bbe7626a6ea0f37d44fdb8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 15:50:39 -0400 Subject: [PATCH 1/6] fix: smooth scrubber end seeking --- packages/core/src/runtime/player.test.ts | 12 +++++++++++ .../player/components/PlayerControls.test.ts | 20 +++++++++++++++++++ .../src/player/components/PlayerControls.tsx | 13 +++++++++++- 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 packages/studio/src/player/components/PlayerControls.test.ts diff --git a/packages/core/src/runtime/player.test.ts b/packages/core/src/runtime/player.test.ts index 9a71bf46..bed968cb 100644 --- a/packages/core/src/runtime/player.test.ts +++ b/packages/core/src/runtime/player.test.ts @@ -262,6 +262,18 @@ describe("createRuntimePlayer", () => { player.seek(NaN); expect(deps.onDeterministicSeek).toHaveBeenCalledWith(0); }); + + it("seeks to the exact safe duration without snapping back a frame", () => { + const timeline = createMockTimeline({ duration: 8 }); + const deps = createMockDeps(timeline); + deps.getSafeDuration.mockReturnValue(8); + const player = createRuntimePlayer(deps); + player.seek(8); + expect(timeline.pause).toHaveBeenCalled(); + expect(timeline.totalTime).toHaveBeenCalledWith(8, false); + expect(deps.onDeterministicSeek).toHaveBeenCalledWith(8); + expect(deps.onSyncMedia).toHaveBeenCalledWith(8, false); + }); }); describe("renderSeek", () => { diff --git a/packages/studio/src/player/components/PlayerControls.test.ts b/packages/studio/src/player/components/PlayerControls.test.ts new file mode 100644 index 00000000..84f23ac5 --- /dev/null +++ b/packages/studio/src/player/components/PlayerControls.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { resolveSeekPercent } from "./PlayerControls"; + +describe("resolveSeekPercent", () => { + it("returns 0 when the track width is invalid", () => { + expect(resolveSeekPercent(100, 0, 0)).toBe(0); + }); + + it("snaps to the start within the edge threshold", () => { + expect(resolveSeekPercent(105, 100, 200)).toBe(0); + }); + + it("snaps to the end within the edge threshold", () => { + expect(resolveSeekPercent(298, 100, 200)).toBe(1); + }); + + it("preserves the true percent away from the edges", () => { + expect(resolveSeekPercent(150, 100, 200)).toBe(0.25); + }); +}); diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index b5d59e84..909da925 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -4,6 +4,17 @@ import { formatTime } from "../lib/time"; import { usePlayerStore, liveTime } from "../store/playerStore"; const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const; +const SEEK_EDGE_SNAP_PX = 8; + +export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number { + if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0; + const rawPercent = (clientX - rectLeft) / rectWidth; + const clamped = Math.max(0, Math.min(1, rawPercent)); + const snapThreshold = Math.min(0.5, SEEK_EDGE_SNAP_PX / rectWidth); + if (clamped <= snapThreshold) return 0; + if (clamped >= 1 - snapThreshold) return 1; + return clamped; +} interface PlayerControlsProps { onTogglePlay: () => void; @@ -88,7 +99,7 @@ export const PlayerControls = memo(function PlayerControls({ const bar = seekBarRef.current; if (!bar || duration <= 0) return; const rect = bar.getBoundingClientRect(); - const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const percent = resolveSeekPercent(clientX, rect.left, rect.width); // Immediately update progress bar visuals (don't wait for liveTime round-trip) const pct = percent * 100; if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`; From f017b64a153e9dd2c11a6b49927a906e46c07818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 16:49:40 -0400 Subject: [PATCH 2/6] fix: stop timeline auto-scroll in fit mode --- .../src/player/components/Timeline.test.ts | 17 +++++++++- .../studio/src/player/components/Timeline.tsx | 33 +++++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 3e9d6170..9c1a67a5 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { generateTicks } from "./Timeline"; +import { generateTicks, shouldAutoScrollTimeline } from "./Timeline"; import { formatTime } from "../lib/time"; describe("generateTicks", () => { @@ -108,3 +108,18 @@ describe("formatTime", () => { expect(formatTime(61)).toBe("1:01"); }); }); + +describe("shouldAutoScrollTimeline", () => { + it("never auto-scrolls in fit mode", () => { + expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false); + }); + + it("does not auto-scroll when there is no horizontal overflow", () => { + expect(shouldAutoScrollTimeline("manual", 800, 800)).toBe(false); + expect(shouldAutoScrollTimeline("manual", 800.5, 800)).toBe(false); + }); + + it("auto-scrolls in manual mode when horizontal overflow exists", () => { + expect(shouldAutoScrollTimeline("manual", 1200, 800)).toBe(true); + }); +}); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 124e6a3b..2cbe0ead 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -1,5 +1,10 @@ import { useRef, useMemo, useCallback, useState, memo, type ReactNode } from "react"; -import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore"; +import { + usePlayerStore, + liveTime, + type TimelineElement, + type ZoomMode, +} from "../store/playerStore"; import { useMountEffect } from "../../hooks/useMountEffect"; import { formatTime } from "../lib/time"; import { TimelineClip } from "./TimelineClip"; @@ -99,6 +104,16 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe return { major, minor }; } +export function shouldAutoScrollTimeline( + zoomMode: ZoomMode, + scrollWidth: number, + clientWidth: number, +): boolean { + if (zoomMode === "fit") return false; + if (!Number.isFinite(scrollWidth) || !Number.isFinite(clientWidth)) return false; + return scrollWidth - clientWidth > 1; +} + /* ── Component ──────────────────────────────────────────────────── */ interface TimelineProps { /** Called when user seeks via ruler/track click or playhead drag */ @@ -290,6 +305,8 @@ export const Timeline = memo(function Timeline({ : 100; const pps = zoomMode === "fit" ? fitPps : manualPps; const trackContentWidth = Math.max(0, effectiveDuration * pps); + const zoomModeRef = useRef(zoomMode); + zoomModeRef.current = zoomMode; const durationRef = useRef(effectiveDuration); durationRef.current = effectiveDuration; @@ -304,7 +321,11 @@ export const Timeline = memo(function Timeline({ // Auto-scroll to follow playhead during playback or seeking const scroll = scrollRef.current; - if (scroll && !isDragging.current) { + if ( + scroll && + !isDragging.current && + shouldAutoScrollTimeline(zoomModeRef.current, scroll.scrollWidth, scroll.clientWidth) + ) { const playheadX = GUTTER + px; const visibleRight = scroll.scrollLeft + scroll.clientWidth; const visibleLeft = scroll.scrollLeft; @@ -444,7 +465,13 @@ export const Timeline = memo(function Timeline({ (clientX: number) => { cancelAnimationFrame(dragScrollRaf.current); const el = scrollRef.current; - if (!el || !isDragging.current) return; + if ( + !el || + !isDragging.current || + !shouldAutoScrollTimeline(zoomModeRef.current, el.scrollWidth, el.clientWidth) + ) { + return; + } const rect = el.getBoundingClientRect(); const edgeZone = 40; const maxSpeed = 12; From a0b22f57d58886075e65501f1dc539ffa38543a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 21 Apr 2026 18:00:51 -0400 Subject: [PATCH 3/6] feat: use percentage-based timeline zoom --- packages/studio/src/App.tsx | 18 ++++-- .../studio/src/player/components/Timeline.tsx | 5 +- .../player/components/timelineZoom.test.ts | 62 +++++++++++++++++++ .../src/player/components/timelineZoom.ts | 38 ++++++++++++ .../src/player/store/playerStore.test.ts | 29 +++++---- .../studio/src/player/store/playerStore.ts | 15 ++--- 6 files changed, 141 insertions(+), 26 deletions(-) create mode 100644 packages/studio/src/player/components/timelineZoom.test.ts create mode 100644 packages/studio/src/player/components/timelineZoom.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 3f4b71a5..e3b036b4 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -23,6 +23,10 @@ import { buildTrackZIndexMap, formatTimelineAttributeNumber, } from "./player/components/timelineEditing"; +import { + getNextTimelineZoomPercent, + getTimelineZoomPercent, +} from "./player/components/timelineZoom"; interface EditingFile { path: string; @@ -204,9 +208,9 @@ export function StudioApp() { ? `/api/projects/${projectId}/preview/comp/${activeCompPath}` : null; const zoomMode = usePlayerStore((s) => s.zoomMode); - const pixelsPerSecond = usePlayerStore((s) => s.pixelsPerSecond); + const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent); const setZoomMode = usePlayerStore((s) => s.setZoomMode); - const setPixelsPerSecond = usePlayerStore((s) => s.setPixelsPerSecond); + const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent); const timelineElements = usePlayerStore((s) => s.elements); const timelineDuration = usePlayerStore((s) => s.duration); const effectiveTimelineDuration = useMemo(() => { @@ -216,6 +220,10 @@ export function StudioApp() { : 0; return Math.max(timelineDuration, maxEnd); }, [timelineDuration, timelineElements]); + const displayedTimelineZoomPercent = useMemo( + () => getTimelineZoomPercent(zoomMode, manualZoomPercent), + [zoomMode, manualZoomPercent], + ); const renderClipContent = useCallback( (el: TimelineElement, style: { clip: string; label: string }): ReactNode => { @@ -336,7 +344,7 @@ export function StudioApp() { type="button" onClick={() => { setZoomMode("manual"); - setPixelsPerSecond(Math.max(20, Math.round(pixelsPerSecond * 0.8))); + setManualZoomPercent(getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent)); }} className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200" title="Zoom out" @@ -344,13 +352,13 @@ export function StudioApp() { -
- {zoomMode === "fit" ? "Auto" : `${Math.round(pixelsPerSecond)} px/s`} + {`${displayedTimelineZoomPercent}%`}