diff --git a/packages/studio/src/player/components/timelineLayout.test.ts b/packages/studio/src/player/components/timelineLayout.test.ts index 3642dea95..dde3a5cfc 100644 --- a/packages/studio/src/player/components/timelineLayout.test.ts +++ b/packages/studio/src/player/components/timelineLayout.test.ts @@ -7,6 +7,7 @@ import { GUTTER, TRACKS_LEFT_PAD, getTimelineRowTop, + getTimelineScrubTime, getTimelineRowFromY, getTimelineCanvasHeight, resolveTimelineAssetDrop, @@ -105,3 +106,46 @@ describe("track-area breathing pad y-math", () => { }); }); }); + +describe("getTimelineScrubTime", () => { + const at = (clientX: number, duration = 10) => + getTimelineScrubTime({ + clientX, + viewportLeft: 0, + scrollLeft: 0, + pixelsPerSecond: 100, + duration, + }); + const origin = GUTTER + TRACKS_LEFT_PAD; + + it("maps the content origin to t=0", () => { + expect(at(origin)).toBe(0); + expect(at(origin + 250)).toBe(2.5); + }); + + // The bug: a pointer left of the origin used to abort the scrub instead of + // clamping, so dragging the playhead to the start only worked if a sample + // happened to land in the few px before t=0. + it("clamps a pointer left of the origin to 0 instead of dropping the scrub", () => { + expect(at(origin - 1)).toBe(0); + expect(at(origin - 500)).toBe(0); + expect(at(0)).toBe(0); + }); + + it("clamps past the end to the duration", () => { + expect(at(origin + 5000)).toBe(10); + }); + + it("returns 0 for a degenerate zoom or duration", () => { + expect( + getTimelineScrubTime({ + clientX: 500, + viewportLeft: 0, + scrollLeft: 0, + pixelsPerSecond: 0, + duration: 10, + }), + ).toBe(0); + expect(at(origin + 250, Number.NaN)).toBe(0); + }); +}); diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index c2326734f..9a1b51108 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -321,6 +321,29 @@ export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number): ); } +/** + * Inverse of {@link getTimelinePlayheadLeft}: the scrub time under a viewport + * clientX. Clamped to [0, duration], NOT rejected — the scrub surface starts + * `GUTTER + TRACKS_LEFT_PAD` px right of the viewport edge, so any pointer left + * of t=0 maps to a negative offset. Callers used to bail on that instead of + * clamping, which made the last 80px of the drag to zero silently do nothing: + * the playhead stuck wherever the last in-range sample landed, and only a very + * slow drag that happened to sample inside the sliver before the origin reached + * 0. Every scrub path shares this so they cannot diverge on the edge again. + */ +export function getTimelineScrubTime(input: { + clientX: number; + viewportLeft: number; + scrollLeft: number; + pixelsPerSecond: number; + duration: number; +}): number { + const { clientX, viewportLeft, scrollLeft, pixelsPerSecond, duration } = input; + if (!(pixelsPerSecond > 0) || !Number.isFinite(duration)) return 0; + const x = clientX - viewportLeft + scrollLeft - GUTTER - TRACKS_LEFT_PAD; + return Math.max(0, Math.min(duration, x / pixelsPerSecond)); +} + export function getTimelineCanvasHeight(trackCount: number): number { // RULER_H + top pad + lanes + bottom pad. The old TIMELINE_SCROLL_BUFFER is // subsumed by TRACKS_BOTTOM_PAD (which is larger), so the drag-into-void space diff --git a/packages/studio/src/player/components/useTimelinePlayhead.ts b/packages/studio/src/player/components/useTimelinePlayhead.ts index 6706c0f7b..f7c3ab6b3 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.ts +++ b/packages/studio/src/player/components/useTimelinePlayhead.ts @@ -6,6 +6,7 @@ import { GUTTER, TRACKS_LEFT_PAD, getTimelinePlayheadLeft, + getTimelineScrubTime, getTimelineScrollLeftForZoomTransition, getTimelineScrollLeftForZoomAnchor, shouldAutoScrollTimeline, @@ -130,9 +131,13 @@ export function useTimelinePlayhead({ const el = scrollRef.current; if (!el || effectiveDuration <= 0) return; const rect = el.getBoundingClientRect(); - const x = clientX - rect.left + el.scrollLeft - GUTTER - TRACKS_LEFT_PAD; - if (x < 0) return; - const time = Math.max(0, Math.min(effectiveDuration, x / pps)); + const time = getTimelineScrubTime({ + clientX, + viewportLeft: rect.left, + scrollLeft: el.scrollLeft, + pixelsPerSecond: pps, + duration: effectiveDuration, + }); liveTime.notify(time); onSeek?.(time); }, diff --git a/packages/studio/src/player/components/useTimelineRangeSelection.ts b/packages/studio/src/player/components/useTimelineRangeSelection.ts index 1df2a74fe..6edc0ff44 100644 --- a/packages/studio/src/player/components/useTimelineRangeSelection.ts +++ b/packages/studio/src/player/components/useTimelineRangeSelection.ts @@ -7,7 +7,7 @@ import { } from "./timelineEditing"; import type { TimelineElement } from "../store/playerStore"; import { liveTime, usePlayerStore } from "../store/playerStore"; -import { GUTTER, TRACKS_LEFT_PAD } from "./timelineLayout"; +import { GUTTER, TRACKS_LEFT_PAD, getTimelineScrubTime } from "./timelineLayout"; import { computeMarqueeSelection, getMarqueeRect, @@ -286,11 +286,15 @@ export function useTimelineRangeSelection({ const el = scrollRef.current; if (el) { const rect = el.getBoundingClientRect(); - const x = clientX - rect.left + el.scrollLeft - GUTTER - TRACKS_LEFT_PAD; - if (x >= 0) { - const dur = el.scrollWidth / pps; - liveTime.notify(Math.max(0, Math.min(dur, x / pps))); - } + liveTime.notify( + getTimelineScrubTime({ + clientX, + viewportLeft: rect.left, + scrollLeft: el.scrollLeft, + pixelsPerSecond: pps, + duration: el.scrollWidth / pps, + }), + ); } if (!seekRafRef.current) { seekRafRef.current = requestAnimationFrame(() => {