From 068b61574d2c7e174512fe9411490fd15db39de2 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 28 Sep 2026 17:12:51 -0700 Subject: [PATCH 01/11] fix(studio): the timeline follows a paused playhead and zoom keeps the playhead in place --- .../components/useTimelinePlayhead.test.tsx | 143 +++++++++++++++--- .../player/components/useTimelinePlayhead.ts | 77 +++++++--- 2 files changed, 171 insertions(+), 49 deletions(-) diff --git a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx index a6096a3b25..c25773630a 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx +++ b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx @@ -1,12 +1,15 @@ // @vitest-environment happy-dom -import { act, createRef } from "react"; -import { createRoot } from "react-dom/client"; -import { describe, expect, it } from "vitest"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore"; import { useTimelinePlayhead } from "./useTimelinePlayhead"; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +const ORIGIN = 32; + function scrollBox(scrollLeft: number) { const el = document.createElement("div"); let left = scrollLeft; @@ -18,18 +21,26 @@ function scrollBox(scrollLeft: number) { return el; } -function Harness({ pps, scroll }: { pps: number; scroll: HTMLDivElement }) { +interface HarnessProps { + pps: number; + scroll: HTMLDivElement; + percent?: number; + dragging?: boolean; + zoomMode?: ZoomMode; +} + +function Harness({ pps, scroll, percent = 100, dragging = false, zoomMode = "manual" }: HarnessProps) { useTimelinePlayhead({ - playheadRef: createRef(), + playheadRef: { current: document.createElement("div") }, scrollRef: { current: scroll }, ppsRef: { current: pps }, durationRef: { current: 60 }, - isDragging: { current: false }, + isDragging: { current: dragging }, currentTime: 0, - zoomMode: "manual", - manualZoomPercent: 100, - zoomModeRef: { current: "manual" }, - manualZoomPercentRef: { current: 100 }, + zoomMode, + manualZoomPercent: percent, + zoomModeRef: { current: zoomMode }, + manualZoomPercentRef: { current: percent }, fitPps: pps, fitPpsRef: { current: pps }, effectiveDuration: 60, @@ -38,28 +49,112 @@ function Harness({ pps, scroll }: { pps: number; scroll: HTMLDivElement }) { elementsLength: 1, setZoomMode: () => {}, setManualZoomPercent: () => {}, - contentOrigin: 32, + contentOrigin: ORIGIN, }); return null; } -function zoom(scrollLeft: number, fromPps: number, toPps: number) { - const scroll = scrollBox(scrollLeft); - const host = document.createElement("div"); - const root = createRoot(host); - act(() => root.render()); - act(() => root.render()); - act(() => root.unmount()); - return scroll.scrollLeft; +const roots: Root[] = []; +function mount(props: HarnessProps) { + const root = createRoot(document.createElement("div")); + roots.push(root); + act(() => root.render()); + return (next: Partial) => act(() => root.render()); } -describe("useTimelinePlayhead centre anchor", () => { - it("keeps a view at the start at the start when the scale changes", () => { - expect(zoom(0, 100, 114)).toBe(0); +beforeEach(() => { + usePlayerStore.setState({ currentTime: 0, isPlaying: false }); +}); +afterEach(() => { + for (const root of roots.splice(0)) act(() => root.unmount()); +}); + +/** Where the playhead at `time` sits inside the 800px viewport. */ +const onScreenX = (scroll: HTMLDivElement, time: number, pps: number) => + ORIGIN + time * pps - scroll.scrollLeft; + +describe("useTimelinePlayhead zoom anchor", () => { + it("keeps a view at the start at the start when the window resizes", () => { + const scroll = scrollBox(0); + mount({ pps: 100, scroll })({ pps: 114 }); + expect(scroll.scrollLeft).toBe(0); }); - it("keeps the time at the viewport centre when the view is scrolled", () => { + it("keeps the time at the viewport centre when a scrolled window resizes", () => { + const scroll = scrollBox(400); // Centre time (400 + 400 - 32) / 100 = 7.68s lands at 32 + 7.68 * 200 - 400. - expect(zoom(400, 100, 200)).toBe(1168); + mount({ pps: 100, scroll })({ pps: 200 }); + expect(scroll.scrollLeft).toBe(1168); + }); + + it("keeps a view at 00:00 on a resize even when the playhead is mid-film", () => { + usePlayerStore.setState({ currentTime: 6 }); + const scroll = scrollBox(0); + mount({ pps: 100, scroll })({ pps: 150 }); + expect(scroll.scrollLeft).toBe(0); + }); + + it("keeps the playhead where it is on screen when the toolbar zooms", () => { + usePlayerStore.setState({ currentTime: 6 }); + const scroll = scrollBox(400); + const before = onScreenX(scroll, 6, 100); + mount({ pps: 100, scroll, percent: 100 })({ pps: 200, percent: 200 }); + expect(onScreenX(scroll, 6, 200)).toBeCloseTo(before); + }); + + it("stays at 00:00 when a zoom is set with the playhead at 0, as a zoom restored on open is", () => { + const scroll = scrollBox(0); + mount({ pps: 100, scroll, percent: 100 })({ pps: 250, percent: 250 }); + expect(scroll.scrollLeft).toBe(0); + }); + + it("brings an off-screen playhead into view when the toolbar zooms", () => { + usePlayerStore.setState({ currentTime: 30 }); + const scroll = scrollBox(0); + mount({ pps: 100, scroll, percent: 100 })({ pps: 200, percent: 200 }); + const x = onScreenX(scroll, 30, 200); + expect(x).toBeGreaterThanOrEqual(ORIGIN); + expect(x).toBeLessThanOrEqual(800); + }); +}); + +describe("useTimelinePlayhead follow while paused", () => { + it("scrolls a paused seek that lands off screen into view", () => { + const scroll = scrollBox(0); + mount({ pps: 100, scroll }); + act(() => liveTime.notify(30)); + const x = onScreenX(scroll, 30, 100); + expect(x).toBeGreaterThanOrEqual(ORIGIN); + expect(x).toBeLessThanOrEqual(800); + }); + + it("leaves the view alone when a paused seek lands on screen", () => { + const scroll = scrollBox(0); + mount({ pps: 100, scroll }); + act(() => liveTime.notify(5)); + expect(scroll.scrollLeft).toBe(0); + }); + + it("does not undo a person's own scroll when the paused time is published again", () => { + const scroll = scrollBox(0); + mount({ pps: 100, scroll }); + act(() => liveTime.notify(30)); + scroll.scrollLeft = 0; + act(() => liveTime.notify(30)); + expect(scroll.scrollLeft).toBe(0); + }); + + it("does not scroll while the playhead is being dragged", () => { + const scroll = scrollBox(0); + mount({ pps: 100, scroll, dragging: true }); + act(() => liveTime.notify(30)); + expect(scroll.scrollLeft).toBe(0); + }); + + it("does not scroll in Fit", () => { + const scroll = scrollBox(0); + mount({ pps: 100, scroll, zoomMode: "fit" }); + act(() => liveTime.notify(30)); + expect(scroll.scrollLeft).toBe(0); }); }); diff --git a/packages/studio/src/player/components/useTimelinePlayhead.ts b/packages/studio/src/player/components/useTimelinePlayhead.ts index 8f8bbdb087..155418ba6d 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.ts +++ b/packages/studio/src/player/components/useTimelinePlayhead.ts @@ -12,6 +12,23 @@ import { import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform"; import { applyTimelineHorizontalAutoScrollStep } from "./timelineEditing"; +/** The scroll that brings an off-screen playhead into view; `from` when it is already visible. */ +function revealPlayheadScrollLeft( + scroll: HTMLDivElement, + playheadX: number, + contentOrigin: number, + from = scroll.scrollLeft, +): number { + if (playheadX >= from + contentOrigin && playheadX <= from + scroll.clientWidth) return from; + return getTimelinePlaybackFollowScrollLeft({ + playheadX, + currentScrollLeft: from, + viewportWidth: scroll.clientWidth, + contentOrigin, + maxScrollLeft: scroll.scrollWidth - scroll.clientWidth, + }); +} + interface UseTimelinePlayheadInput { playheadRef: React.RefObject; scrollRef: React.RefObject; @@ -58,10 +75,12 @@ export function useTimelinePlayhead({ }: UseTimelinePlayheadInput) { const dragScrollRaf = useRef(0); const previousZoomModeRef = useRef(zoomMode); - // Center-anchored magnify: keep the time at the viewport center fixed when - // the zoom level (pps) changes via the toolbar / slider. The pinch handler - // anchors at the cursor instead, so it opts out via `skipCenterAnchorRef`. + // A toolbar / slider zoom keeps the playhead where it is on screen; a resize keeps the + // viewport centre, or 00:00 when the view is at the start. The pinch handler anchors at + // the cursor instead, so it opts out via `skipCenterAnchorRef`. const previousAnchorPpsRef = useRef(pps); + const previousAnchorPercentRef = useRef(manualZoomPercentRef.current); + const lastLiveTimeRef = useRef(usePlayerStore.getState().currentTime); const skipCenterAnchorRef = useRef(false); const contentOriginRef = useRef(contentOrigin); contentOriginRef.current = contentOrigin; @@ -70,15 +89,21 @@ export function useTimelinePlayhead({ const scroll = scrollRef.current; const prevPps = previousAnchorPpsRef.current; previousAnchorPpsRef.current = pps; + const prevPercent = previousAnchorPercentRef.current; + previousAnchorPercentRef.current = manualZoomPercentRef.current; // Always consume the skip flag, even when pps didn't change — otherwise a // pinch that produced no pps change (already at the zoom clamp) would strand // it true and the next toolbar zoom would wrongly skip center-anchoring. const skip = skipCenterAnchorRef.current; skipCenterAnchorRef.current = false; - // A view at the start stays there: a resize or a host zoom must not hide 00:00. - if (!scroll || pps === prevPps || skip || scroll.scrollLeft < 1) return; + if (!scroll || pps === prevPps || skip) return; + const zoomed = manualZoomPercentRef.current !== prevPercent; + if (!zoomed && scroll.scrollLeft < 1) return; + const time = Math.max(0, lastLiveTimeRef.current); + const playheadX = contentOrigin + time * prevPps; + const onScreen = revealPlayheadScrollLeft(scroll, playheadX, contentOrigin) === scroll.scrollLeft; const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({ - pointerX: scroll.clientWidth / 2, + pointerX: zoomed && onScreen ? playheadX - scroll.scrollLeft : scroll.clientWidth / 2, currentScrollLeft: scroll.scrollLeft, contentOrigin, currentPixelsPerSecond: prevPps, @@ -86,8 +111,11 @@ export function useTimelinePlayhead({ duration: durationRef.current, }); const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth); - scroll.scrollLeft = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft)); - }, [pps, scrollRef, durationRef, contentOrigin]); + const anchored = Math.max(0, Math.min(maxScrollLeft, nextScrollLeft)); + scroll.scrollLeft = zoomed + ? revealPlayheadScrollLeft(scroll, contentOrigin + time * pps, contentOrigin, anchored) + : anchored; + }, [pps, scrollRef, durationRef, contentOrigin, manualZoomPercentRef]); const syncPlayheadPosition = useCallback( (time: number) => { @@ -127,7 +155,6 @@ export function useTimelinePlayhead({ }, [zoomMode, scrollRef]); useMountEffect(() => { - let lastLiveTime = usePlayerStore.getState().currentTime; const place = (t: number, atRest: boolean) => { if (!playheadRef.current || durationRef.current <= 0) return false; playheadRef.current.style.transform = getTimelinePlayheadTransform( @@ -139,28 +166,28 @@ export function useTimelinePlayhead({ return true; }; const unsubPlaying = usePlayerStore.subscribe((state, prev) => { - if (prev.isPlaying && !state.isPlaying) place(lastLiveTime, true); + if (prev.isPlaying && !state.isPlaying) place(lastLiveTimeRef.current, true); }); const unsub = liveTime.subscribe((t) => { - lastLiveTime = t; - if (!place(t, !usePlayerStore.getState().isPlaying)) return; + const moved = t !== lastLiveTimeRef.current; + lastLiveTimeRef.current = t; + const playing = usePlayerStore.getState().isPlaying; + if (!place(t, !playing)) return; const playheadX = contentOriginRef.current + Math.max(0, t) * ppsRef.current; const scroll = scrollRef.current; - if ( - !scroll || - !usePlayerStore.getState().isPlaying || - isDragging.current || - zoomModeRef.current === "fit" - ) { + // Paused, only a new playhead time scrolls, so a person's own scroll stays where they put it. + if (!scroll || isDragging.current || zoomModeRef.current === "fit" || (!playing && !moved)) { return; } - const nextScrollLeft = getTimelinePlaybackFollowScrollLeft({ - playheadX, - currentScrollLeft: scroll.scrollLeft, - viewportWidth: scroll.clientWidth, - contentOrigin: contentOriginRef.current, - maxScrollLeft: scroll.scrollWidth - scroll.clientWidth, - }); + const nextScrollLeft = playing + ? getTimelinePlaybackFollowScrollLeft({ + playheadX, + currentScrollLeft: scroll.scrollLeft, + viewportWidth: scroll.clientWidth, + contentOrigin: contentOriginRef.current, + maxScrollLeft: scroll.scrollWidth - scroll.clientWidth, + }) + : revealPlayheadScrollLeft(scroll, playheadX, contentOriginRef.current); if (Math.abs(nextScrollLeft - scroll.scrollLeft) >= 0.5) { scroll.scrollLeft = nextScrollLeft; } From 084cf71c2e699a333d05b23252d728e059cf47d7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 28 Sep 2026 17:14:21 -0700 Subject: [PATCH 02/11] test(studio): pin a paused on-screen seek past the playback follow line --- .../studio/src/player/components/useTimelinePlayhead.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx index c25773630a..586ce55f6d 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx +++ b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx @@ -128,10 +128,10 @@ describe("useTimelinePlayhead follow while paused", () => { expect(x).toBeLessThanOrEqual(800); }); - it("leaves the view alone when a paused seek lands on screen", () => { + it("leaves the view alone when a paused seek lands on screen, even past the follow line", () => { const scroll = scrollBox(0); mount({ pps: 100, scroll }); - act(() => liveTime.notify(5)); + act(() => liveTime.notify(7)); expect(scroll.scrollLeft).toBe(0); }); From 8d7a1d7a468642a6d1e82c617cbedaeeb1f55f1b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 28 Sep 2026 17:20:33 -0700 Subject: [PATCH 03/11] style(studio): format the follow change and trim its comments --- .../src/player/components/useTimelinePlayhead.test.tsx | 8 +++++++- .../studio/src/player/components/useTimelinePlayhead.ts | 9 ++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx index 586ce55f6d..7b2dcb319f 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx +++ b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx @@ -29,7 +29,13 @@ interface HarnessProps { zoomMode?: ZoomMode; } -function Harness({ pps, scroll, percent = 100, dragging = false, zoomMode = "manual" }: HarnessProps) { +function Harness({ + pps, + scroll, + percent = 100, + dragging = false, + zoomMode = "manual", +}: HarnessProps) { useTimelinePlayhead({ playheadRef: { current: document.createElement("div") }, scrollRef: { current: scroll }, diff --git a/packages/studio/src/player/components/useTimelinePlayhead.ts b/packages/studio/src/player/components/useTimelinePlayhead.ts index 155418ba6d..a86ef7eb62 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.ts +++ b/packages/studio/src/player/components/useTimelinePlayhead.ts @@ -12,7 +12,6 @@ import { import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform"; import { applyTimelineHorizontalAutoScrollStep } from "./timelineEditing"; -/** The scroll that brings an off-screen playhead into view; `from` when it is already visible. */ function revealPlayheadScrollLeft( scroll: HTMLDivElement, playheadX: number, @@ -75,9 +74,8 @@ export function useTimelinePlayhead({ }: UseTimelinePlayheadInput) { const dragScrollRaf = useRef(0); const previousZoomModeRef = useRef(zoomMode); - // A toolbar / slider zoom keeps the playhead where it is on screen; a resize keeps the - // viewport centre, or 00:00 when the view is at the start. The pinch handler anchors at - // the cursor instead, so it opts out via `skipCenterAnchorRef`. + // A toolbar zoom keeps the playhead in place; a resize keeps the centre, or 00:00 at the start. + // The pinch handler anchors at the cursor instead, so it opts out via `skipCenterAnchorRef`. const previousAnchorPpsRef = useRef(pps); const previousAnchorPercentRef = useRef(manualZoomPercentRef.current); const lastLiveTimeRef = useRef(usePlayerStore.getState().currentTime); @@ -101,7 +99,8 @@ export function useTimelinePlayhead({ if (!zoomed && scroll.scrollLeft < 1) return; const time = Math.max(0, lastLiveTimeRef.current); const playheadX = contentOrigin + time * prevPps; - const onScreen = revealPlayheadScrollLeft(scroll, playheadX, contentOrigin) === scroll.scrollLeft; + const onScreen = + revealPlayheadScrollLeft(scroll, playheadX, contentOrigin) === scroll.scrollLeft; const nextScrollLeft = getTimelineScrollLeftForZoomAnchor({ pointerX: zoomed && onScreen ? playheadX - scroll.scrollLeft : scroll.clientWidth / 2, currentScrollLeft: scroll.scrollLeft, From 45e8af264d6ba64097b480c843c4c92103a9d21b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 28 Sep 2026 19:01:52 -0700 Subject: [PATCH 04/11] fix(studio): only a person's zoom anchors on the playhead, not a pin or a resize --- .../components/useTimelinePlayhead.test.tsx | 42 +++++++++++++++++-- .../player/components/useTimelinePlayhead.ts | 11 ++--- .../src/player/components/useTimelineZoom.ts | 11 ++++- .../studio/src/player/store/playerStore.ts | 2 + 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx index 7b2dcb319f..e26c4744aa 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx +++ b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx @@ -65,7 +65,11 @@ function mount(props: HarnessProps) { const root = createRoot(document.createElement("div")); roots.push(root); act(() => root.render()); - return (next: Partial) => act(() => root.render()); + return (next: Partial, byPerson = false) => + act(() => { + if (byPerson) usePlayerStore.setState((s) => ({ userZoomCount: s.userZoomCount + 1 })); + root.render(); + }); } beforeEach(() => { @@ -104,20 +108,50 @@ describe("useTimelinePlayhead zoom anchor", () => { usePlayerStore.setState({ currentTime: 6 }); const scroll = scrollBox(400); const before = onScreenX(scroll, 6, 100); - mount({ pps: 100, scroll, percent: 100 })({ pps: 200, percent: 200 }); + mount({ pps: 100, scroll, percent: 100 })({ pps: 200, percent: 200 }, true); expect(onScreenX(scroll, 6, 200)).toBeCloseTo(before); }); it("stays at 00:00 when a zoom is set with the playhead at 0, as a zoom restored on open is", () => { const scroll = scrollBox(0); - mount({ pps: 100, scroll, percent: 100 })({ pps: 250, percent: 250 }); + mount({ pps: 100, scroll, percent: 100 })({ pps: 250, percent: 250 }, true); expect(scroll.scrollLeft).toBe(0); }); it("brings an off-screen playhead into view when the toolbar zooms", () => { usePlayerStore.setState({ currentTime: 30 }); const scroll = scrollBox(0); - mount({ pps: 100, scroll, percent: 100 })({ pps: 200, percent: 200 }); + mount({ pps: 100, scroll, percent: 100 })({ pps: 200, percent: 200 }, true); + const x = onScreenX(scroll, 30, 200); + expect(x).toBeGreaterThanOrEqual(ORIGIN); + expect(x).toBeLessThanOrEqual(800); + }); +}); + +describe("useTimelinePlayhead zoom anchor, percent written by Studio itself", () => { + it("keeps 00:00 when the window resizes after an edit pinned the zoom", () => { + usePlayerStore.setState({ currentTime: 6 }); + const scroll = scrollBox(0); + const update = mount({ pps: 100, scroll, percent: 200 }); + update({ percent: 100 }); + update({ pps: 130, percent: 100 }); + expect(scroll.scrollLeft).toBe(0); + }); + + it("leaves an off-screen playhead alone when a length change re-pins the zoom", () => { + usePlayerStore.setState({ currentTime: 30 }); + const scroll = scrollBox(400); + const update = mount({ pps: 100, scroll, percent: 150 }); + update({ pps: 90, percent: 150 }); + update({ pps: 101, percent: 168 }); + const x = onScreenX(scroll, 30, 101); + expect(x > 800 || x < ORIGIN).toBe(true); + }); + + it("anchors a person's zoom that lands on the percent already stored", () => { + usePlayerStore.setState({ currentTime: 30 }); + const scroll = scrollBox(0); + mount({ pps: 100, scroll, percent: 200 })({ pps: 200, percent: 200 }, true); const x = onScreenX(scroll, 30, 200); expect(x).toBeGreaterThanOrEqual(ORIGIN); expect(x).toBeLessThanOrEqual(800); diff --git a/packages/studio/src/player/components/useTimelinePlayhead.ts b/packages/studio/src/player/components/useTimelinePlayhead.ts index a86ef7eb62..1406a2ecf9 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.ts +++ b/packages/studio/src/player/components/useTimelinePlayhead.ts @@ -77,7 +77,8 @@ export function useTimelinePlayhead({ // A toolbar zoom keeps the playhead in place; a resize keeps the centre, or 00:00 at the start. // The pinch handler anchors at the cursor instead, so it opts out via `skipCenterAnchorRef`. const previousAnchorPpsRef = useRef(pps); - const previousAnchorPercentRef = useRef(manualZoomPercentRef.current); + const userZoomCount = usePlayerStore((s) => s.userZoomCount); + const previousZoomCountRef = useRef(userZoomCount); const lastLiveTimeRef = useRef(usePlayerStore.getState().currentTime); const skipCenterAnchorRef = useRef(false); const contentOriginRef = useRef(contentOrigin); @@ -87,15 +88,15 @@ export function useTimelinePlayhead({ const scroll = scrollRef.current; const prevPps = previousAnchorPpsRef.current; previousAnchorPpsRef.current = pps; - const prevPercent = previousAnchorPercentRef.current; - previousAnchorPercentRef.current = manualZoomPercentRef.current; + const prevZoomCount = previousZoomCountRef.current; + previousZoomCountRef.current = userZoomCount; // Always consume the skip flag, even when pps didn't change — otherwise a // pinch that produced no pps change (already at the zoom clamp) would strand // it true and the next toolbar zoom would wrongly skip center-anchoring. const skip = skipCenterAnchorRef.current; skipCenterAnchorRef.current = false; if (!scroll || pps === prevPps || skip) return; - const zoomed = manualZoomPercentRef.current !== prevPercent; + const zoomed = userZoomCount !== prevZoomCount; if (!zoomed && scroll.scrollLeft < 1) return; const time = Math.max(0, lastLiveTimeRef.current); const playheadX = contentOrigin + time * prevPps; @@ -114,7 +115,7 @@ export function useTimelinePlayhead({ scroll.scrollLeft = zoomed ? revealPlayheadScrollLeft(scroll, contentOrigin + time * pps, contentOrigin, anchored) : anchored; - }, [pps, scrollRef, durationRef, contentOrigin, manualZoomPercentRef]); + }, [pps, userZoomCount, scrollRef, durationRef, contentOrigin]); const syncPlayheadPosition = useCallback( (time: number) => { diff --git a/packages/studio/src/player/components/useTimelineZoom.ts b/packages/studio/src/player/components/useTimelineZoom.ts index f4b3e74399..59e5c7583a 100644 --- a/packages/studio/src/player/components/useTimelineZoom.ts +++ b/packages/studio/src/player/components/useTimelineZoom.ts @@ -1,4 +1,5 @@ // fallow-ignore-file dead-code +import { useCallback } from "react"; import { usePlayerStore, type ZoomMode } from "../store/playerStore"; export interface TimelineZoomState { @@ -13,6 +14,14 @@ export function useTimelineZoom(): TimelineZoomState { const zoomMode = usePlayerStore((s) => s.zoomMode); const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent); const setZoomMode = usePlayerStore((s) => s.setZoomMode); - const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent); + const setStorePercent = usePlayerStore((s) => s.setManualZoomPercent); + // Counted so the timeline tells a person's zoom from a pin or re-pin, which write the percent directly. + const setManualZoomPercent = useCallback( + (percent: number) => { + usePlayerStore.setState((s) => ({ userZoomCount: s.userZoomCount + 1 })); + setStorePercent(percent); + }, + [setStorePercent], + ); return { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent }; } diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index f513f7b28d..5296c96f6b 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -83,6 +83,7 @@ interface PlayerState extends PlayerStoreSlices { zoomMode: ZoomMode; /** Timeline zoom percent relative to the fit width when in manual mode */ manualZoomPercent: number; + userZoomCount: number; /** * Bumped on every live z-index edit (handleDomZIndexReorderCommit apply AND * rollback). Flashless z commits (skipReload) never reload the iframe or @@ -279,6 +280,7 @@ export const usePlayerStore = create((set, get) => ({ loopEnabled: false, zoomMode: "fit", manualZoomPercent: 100, + userZoomCount: 0, zEditVersion: 0, timelinePps: 100, timelineFitPps: 100, From bcaa4311d587721901dee7f89922f95a9ebce53d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 28 Sep 2026 19:08:02 -0700 Subject: [PATCH 05/11] test(studio): pin that a toolbar zoom is counted as a person's zoom --- .../src/components/TimelineToolbar.test.tsx | 12 ++++++++++++ .../components/useTimelinePlayhead.test.tsx | 17 ++++++++--------- .../src/player/components/useTimelineZoom.ts | 1 - 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/studio/src/components/TimelineToolbar.test.tsx b/packages/studio/src/components/TimelineToolbar.test.tsx index bc6132cc43..e309d5b977 100644 --- a/packages/studio/src/components/TimelineToolbar.test.tsx +++ b/packages/studio/src/components/TimelineToolbar.test.tsx @@ -226,6 +226,18 @@ describe("TimelineToolbar Fit", () => { expect(fit()?.getAttribute("aria-pressed")).toBe("false"); act(() => root.unmount()); }); + + it("counts a zoom-in click as a person's zoom, so the timeline anchors it on the playhead", () => { + const { host, root } = renderToolbar(); + const before = usePlayerStore.getState().userZoomCount; + act(() => + host + .querySelector('button[aria-label="Zoom in"]') + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })), + ); + expect(usePlayerStore.getState().userZoomCount).toBe(before + 1); + act(() => root.unmount()); + }); }); describe("TimelineToolbar audio meters", () => { diff --git a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx index e26c4744aa..a738657361 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx +++ b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx @@ -82,6 +82,11 @@ afterEach(() => { /** Where the playhead at `time` sits inside the 800px viewport. */ const onScreenX = (scroll: HTMLDivElement, time: number, pps: number) => ORIGIN + time * pps - scroll.scrollLeft; +function expectVisible(scroll: HTMLDivElement, time: number, pps: number) { + const x = onScreenX(scroll, time, pps); + expect(x).toBeGreaterThanOrEqual(ORIGIN); + expect(x).toBeLessThanOrEqual(800); +} describe("useTimelinePlayhead zoom anchor", () => { it("keeps a view at the start at the start when the window resizes", () => { @@ -122,9 +127,7 @@ describe("useTimelinePlayhead zoom anchor", () => { usePlayerStore.setState({ currentTime: 30 }); const scroll = scrollBox(0); mount({ pps: 100, scroll, percent: 100 })({ pps: 200, percent: 200 }, true); - const x = onScreenX(scroll, 30, 200); - expect(x).toBeGreaterThanOrEqual(ORIGIN); - expect(x).toBeLessThanOrEqual(800); + expectVisible(scroll, 30, 200); }); }); @@ -152,9 +155,7 @@ describe("useTimelinePlayhead zoom anchor, percent written by Studio itself", () usePlayerStore.setState({ currentTime: 30 }); const scroll = scrollBox(0); mount({ pps: 100, scroll, percent: 200 })({ pps: 200, percent: 200 }, true); - const x = onScreenX(scroll, 30, 200); - expect(x).toBeGreaterThanOrEqual(ORIGIN); - expect(x).toBeLessThanOrEqual(800); + expectVisible(scroll, 30, 200); }); }); @@ -163,9 +164,7 @@ describe("useTimelinePlayhead follow while paused", () => { const scroll = scrollBox(0); mount({ pps: 100, scroll }); act(() => liveTime.notify(30)); - const x = onScreenX(scroll, 30, 100); - expect(x).toBeGreaterThanOrEqual(ORIGIN); - expect(x).toBeLessThanOrEqual(800); + expectVisible(scroll, 30, 100); }); it("leaves the view alone when a paused seek lands on screen, even past the follow line", () => { diff --git a/packages/studio/src/player/components/useTimelineZoom.ts b/packages/studio/src/player/components/useTimelineZoom.ts index 59e5c7583a..f452e5af9f 100644 --- a/packages/studio/src/player/components/useTimelineZoom.ts +++ b/packages/studio/src/player/components/useTimelineZoom.ts @@ -15,7 +15,6 @@ export function useTimelineZoom(): TimelineZoomState { const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent); const setZoomMode = usePlayerStore((s) => s.setZoomMode); const setStorePercent = usePlayerStore((s) => s.setManualZoomPercent); - // Counted so the timeline tells a person's zoom from a pin or re-pin, which write the percent directly. const setManualZoomPercent = useCallback( (percent: number) => { usePlayerStore.setState((s) => ({ userZoomCount: s.userZoomCount + 1 })); From a6b1cea42cf0aed0c98a0ba637d8ee4d77bd3935 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 28 Sep 2026 20:03:54 -0700 Subject: [PATCH 06/11] fix(studio): keep a paused timeline scroll when the preview reloads after a keyboard pause --- .../components/useTimelinePlayhead.test.tsx | 31 +++++++++++++++++-- .../player/components/useTimelinePlayhead.ts | 5 ++- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx index a738657361..df8faa5044 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx +++ b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act } from "react"; +import { act, useRef } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { liveTime, usePlayerStore, type ZoomMode } from "../store/playerStore"; @@ -36,11 +36,13 @@ function Harness({ dragging = false, zoomMode = "manual", }: HarnessProps) { + const scrollRef = useRef(scroll); + const durationRef = useRef(60); useTimelinePlayhead({ playheadRef: { current: document.createElement("div") }, - scrollRef: { current: scroll }, + scrollRef, ppsRef: { current: pps }, - durationRef: { current: 60 }, + durationRef, isDragging: { current: dragging }, currentTime: 0, zoomMode, @@ -123,6 +125,15 @@ describe("useTimelinePlayhead zoom anchor", () => { expect(scroll.scrollLeft).toBe(0); }); + it("keeps the centre on a resize after a toolbar zoom that hit the zoom limit", () => { + usePlayerStore.setState({ currentTime: 6 }); + const scroll = scrollBox(400); + const update = mount({ pps: 100, scroll }); + update({}, true); + update({ pps: 200 }); + expect(scroll.scrollLeft).toBe(1168); + }); + it("brings an off-screen playhead into view when the toolbar zooms", () => { usePlayerStore.setState({ currentTime: 30 }); const scroll = scrollBox(0); @@ -183,6 +194,20 @@ describe("useTimelinePlayhead follow while paused", () => { expect(scroll.scrollLeft).toBe(0); }); + it("keeps a person's scroll when a reload follows a keyboard pause", () => { + const scroll = scrollBox(0); + mount({ pps: 100, scroll }); + act(() => usePlayerStore.setState({ isPlaying: true })); + act(() => liveTime.notify(30)); + act(() => { + usePlayerStore.getState().setCurrentTime(30.012); + usePlayerStore.setState({ isPlaying: false }); + }); + scroll.scrollLeft = 0; + act(() => liveTime.notify(30.012)); + expect(scroll.scrollLeft).toBe(0); + }); + it("does not scroll while the playhead is being dragged", () => { const scroll = scrollBox(0); mount({ pps: 100, scroll, dragging: true }); diff --git a/packages/studio/src/player/components/useTimelinePlayhead.ts b/packages/studio/src/player/components/useTimelinePlayhead.ts index 1406a2ecf9..fcb287f81d 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.ts +++ b/packages/studio/src/player/components/useTimelinePlayhead.ts @@ -166,7 +166,10 @@ export function useTimelinePlayhead({ return true; }; const unsubPlaying = usePlayerStore.subscribe((state, prev) => { - if (prev.isPlaying && !state.isPlaying) place(lastLiveTimeRef.current, true); + if (!prev.isPlaying || state.isPlaying) return; + // pause() stores the adapter's time without publishing it, so a reload's republish is no move. + lastLiveTimeRef.current = state.currentTime; + place(state.currentTime, true); }); const unsub = liveTime.subscribe((t) => { const moved = t !== lastLiveTimeRef.current; From 6b3139f94ec5a028f6f411ada82782925438d7ab Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 28 Sep 2026 20:39:40 -0700 Subject: [PATCH 07/11] fix(studio): publish the paused time so a reverse shuttle stop keeps the timeline scroll --- .../components/useTimelinePlayhead.test.tsx | 14 ++++++++++++++ .../src/player/components/useTimelinePlayhead.ts | 5 +---- .../hooks/useTimelinePlayer.hiddenPause.test.ts | 15 ++++++++++++++- .../studio/src/player/hooks/useTimelinePlayer.ts | 4 +++- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx index df8faa5044..b5701c3c9b 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx +++ b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx @@ -200,6 +200,7 @@ describe("useTimelinePlayhead follow while paused", () => { act(() => usePlayerStore.setState({ isPlaying: true })); act(() => liveTime.notify(30)); act(() => { + liveTime.notify(30.012); usePlayerStore.getState().setCurrentTime(30.012); usePlayerStore.setState({ isPlaying: false }); }); @@ -208,6 +209,19 @@ describe("useTimelinePlayhead follow while paused", () => { expect(scroll.scrollLeft).toBe(0); }); + it("keeps a person's scroll when a reload stops a reverse shuttle", () => { + usePlayerStore.setState({ currentTime: 30 }); + const scroll = scrollBox(0); + mount({ pps: 100, scroll }); + act(() => usePlayerStore.setState({ isPlaying: true })); + act(() => liveTime.notify(29)); + act(() => liveTime.notify(20)); + act(() => usePlayerStore.setState({ isPlaying: false })); + scroll.scrollLeft = 0; + act(() => liveTime.notify(20)); + expect(scroll.scrollLeft).toBe(0); + }); + it("does not scroll while the playhead is being dragged", () => { const scroll = scrollBox(0); mount({ pps: 100, scroll, dragging: true }); diff --git a/packages/studio/src/player/components/useTimelinePlayhead.ts b/packages/studio/src/player/components/useTimelinePlayhead.ts index fcb287f81d..1406a2ecf9 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.ts +++ b/packages/studio/src/player/components/useTimelinePlayhead.ts @@ -166,10 +166,7 @@ export function useTimelinePlayhead({ return true; }; const unsubPlaying = usePlayerStore.subscribe((state, prev) => { - if (!prev.isPlaying || state.isPlaying) return; - // pause() stores the adapter's time without publishing it, so a reload's republish is no move. - lastLiveTimeRef.current = state.currentTime; - place(state.currentTime, true); + if (prev.isPlaying && !state.isPlaying) place(lastLiveTimeRef.current, true); }); const unsub = liveTime.subscribe((t) => { const moved = t !== lastLiveTimeRef.current; diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts index 3008333440..07c5fb73d7 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts @@ -2,7 +2,7 @@ import { act } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { usePlayerStore } from "../store/playerStore"; +import { liveTime, usePlayerStore } from "../store/playerStore"; import { attachIframeWindow, makeAdapterWindow, @@ -18,6 +18,19 @@ afterEach(() => { resetPlayerStore(); }); +describe("useTimelinePlayer pause", () => { + it("publishes the paused time, as a seek does", () => { + const { api, root } = renderTimelinePlayerHarness(); + const { adapter, win } = makeAdapterWindow(); + attachIframeWindow(api, win); + act(() => api.play()); + adapter.seek(6); + act(() => api.pause()); + expect(liveTime.latest()).toBe(6); + act(() => root.unmount()); + }); +}); + describe("useTimelinePlayer tab hidden while playing", () => { it("pauses on the adapter's time, like any other pause", () => { const { api, root } = renderTimelinePlayerHarness(); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 421ad84607..ba11b29e5b 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -269,7 +269,9 @@ export function useTimelinePlayer({ const adapter = getAdapter(); if (!adapter) return; adapter.pause(); - setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time + const time = adapter.getTime(); + liveTime.notify(time); + setCurrentTime(time); // sync store so Split/Delete have accurate time setIsPlaying(false); shuttleDirectionRef.current = null; shuttleSpeedIndexRef.current = 0; From 260bd4d162a24be0ba4c02a7fef356251e3cdbbe Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 28 Sep 2026 21:12:24 -0700 Subject: [PATCH 08/11] fix(studio): follow a paused playhead only on a seek, not when a reload republishes the time --- .../components/useTimelinePlayhead.test.tsx | 24 ++++++++++++------- .../player/components/useTimelinePlayhead.ts | 8 ++++--- .../useTimelinePlayer.hiddenPause.test.ts | 15 +----------- .../hooks/useTimelinePlayer.seek.test.ts | 9 +++++++ .../src/player/hooks/useTimelinePlayer.ts | 6 ++--- packages/studio/src/player/store/liveTime.ts | 6 +++++ 6 files changed, 39 insertions(+), 29 deletions(-) diff --git a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx index b5701c3c9b..536829032e 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx +++ b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx @@ -174,23 +174,32 @@ describe("useTimelinePlayhead follow while paused", () => { it("scrolls a paused seek that lands off screen into view", () => { const scroll = scrollBox(0); mount({ pps: 100, scroll }); - act(() => liveTime.notify(30)); + act(() => liveTime.notifySeek(30)); expectVisible(scroll, 30, 100); }); it("leaves the view alone when a paused seek lands on screen, even past the follow line", () => { const scroll = scrollBox(0); mount({ pps: 100, scroll }); - act(() => liveTime.notify(7)); + act(() => liveTime.notifySeek(7)); expect(scroll.scrollLeft).toBe(0); }); - it("does not undo a person's own scroll when the paused time is published again", () => { + it("scrolls back to the playhead when a person seeks to the time it already has", () => { const scroll = scrollBox(0); mount({ pps: 100, scroll }); - act(() => liveTime.notify(30)); + act(() => liveTime.notifySeek(30)); scroll.scrollLeft = 0; - act(() => liveTime.notify(30)); + act(() => liveTime.notifySeek(30)); + expectVisible(scroll, 30, 100); + }); + + it("keeps a person's scroll when a reload republishes the seek rounded to a frame", () => { + const scroll = scrollBox(0); + mount({ pps: 100, scroll }); + act(() => liveTime.notifySeek(12.3456)); + scroll.scrollLeft = 0; + act(() => liveTime.notify(Math.floor(12.3456 * 30) / 30)); expect(scroll.scrollLeft).toBe(0); }); @@ -200,7 +209,6 @@ describe("useTimelinePlayhead follow while paused", () => { act(() => usePlayerStore.setState({ isPlaying: true })); act(() => liveTime.notify(30)); act(() => { - liveTime.notify(30.012); usePlayerStore.getState().setCurrentTime(30.012); usePlayerStore.setState({ isPlaying: false }); }); @@ -225,14 +233,14 @@ describe("useTimelinePlayhead follow while paused", () => { it("does not scroll while the playhead is being dragged", () => { const scroll = scrollBox(0); mount({ pps: 100, scroll, dragging: true }); - act(() => liveTime.notify(30)); + act(() => liveTime.notifySeek(30)); expect(scroll.scrollLeft).toBe(0); }); it("does not scroll in Fit", () => { const scroll = scrollBox(0); mount({ pps: 100, scroll, zoomMode: "fit" }); - act(() => liveTime.notify(30)); + act(() => liveTime.notifySeek(30)); expect(scroll.scrollLeft).toBe(0); }); }); diff --git a/packages/studio/src/player/components/useTimelinePlayhead.ts b/packages/studio/src/player/components/useTimelinePlayhead.ts index 1406a2ecf9..28734663d2 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.ts +++ b/packages/studio/src/player/components/useTimelinePlayhead.ts @@ -80,6 +80,7 @@ export function useTimelinePlayhead({ const userZoomCount = usePlayerStore((s) => s.userZoomCount); const previousZoomCountRef = useRef(userZoomCount); const lastLiveTimeRef = useRef(usePlayerStore.getState().currentTime); + const lastSeekCountRef = useRef(liveTime.seekCount()); const skipCenterAnchorRef = useRef(false); const contentOriginRef = useRef(contentOrigin); contentOriginRef.current = contentOrigin; @@ -169,14 +170,15 @@ export function useTimelinePlayhead({ if (prev.isPlaying && !state.isPlaying) place(lastLiveTimeRef.current, true); }); const unsub = liveTime.subscribe((t) => { - const moved = t !== lastLiveTimeRef.current; + const sought = liveTime.seekCount() !== lastSeekCountRef.current; + lastSeekCountRef.current = liveTime.seekCount(); lastLiveTimeRef.current = t; const playing = usePlayerStore.getState().isPlaying; if (!place(t, !playing)) return; const playheadX = contentOriginRef.current + Math.max(0, t) * ppsRef.current; const scroll = scrollRef.current; - // Paused, only a new playhead time scrolls, so a person's own scroll stays where they put it. - if (!scroll || isDragging.current || zoomModeRef.current === "fit" || (!playing && !moved)) { + // Paused, only a seek scrolls: a reload's republish, frame-rounded, must not undo a person's scroll. + if (!scroll || isDragging.current || zoomModeRef.current === "fit" || (!playing && !sought)) { return; } const nextScrollLeft = playing diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts index 07c5fb73d7..3008333440 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.hiddenPause.test.ts @@ -2,7 +2,7 @@ import { act } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { liveTime, usePlayerStore } from "../store/playerStore"; +import { usePlayerStore } from "../store/playerStore"; import { attachIframeWindow, makeAdapterWindow, @@ -18,19 +18,6 @@ afterEach(() => { resetPlayerStore(); }); -describe("useTimelinePlayer pause", () => { - it("publishes the paused time, as a seek does", () => { - const { api, root } = renderTimelinePlayerHarness(); - const { adapter, win } = makeAdapterWindow(); - attachIframeWindow(api, win); - act(() => api.play()); - adapter.seek(6); - act(() => api.pause()); - expect(liveTime.latest()).toBe(6); - act(() => root.unmount()); - }); -}); - describe("useTimelinePlayer tab hidden while playing", () => { it("pauses on the adapter's time, like any other pause", () => { const { api, root } = renderTimelinePlayerHarness(); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts index 5d0fa43899..a07541a2d1 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts @@ -263,6 +263,15 @@ describe("useTimelinePlayer audio controls (#835)", () => { }); describe("useTimelinePlayer seek keepPlaying option (#834)", () => { + it("publishes a seek as a person's seek, so a paused timeline follows it", () => { + const { root, api } = renderAttachedTimelinePlayer(); + const before = liveTime.seekCount(); + seekWithAct(api, 5); + expect(liveTime.seekCount()).toBe(before + 1); + expect(liveTime.latest()).toBe(5); + unmountWithAct(root); + }); + it("default seek() clears isPlaying when the store reports playing", () => { const { api, root } = renderAttachedTimelinePlayer(); setStorePlaying(); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index ba11b29e5b..625cb0ea2c 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -269,9 +269,7 @@ export function useTimelinePlayer({ const adapter = getAdapter(); if (!adapter) return; adapter.pause(); - const time = adapter.getTime(); - liveTime.notify(time); - setCurrentTime(time); // sync store so Split/Delete have accurate time + setCurrentTime(adapter.getTime()); // sync store so Split/Delete have accurate time setIsPlaying(false); shuttleDirectionRef.current = null; shuttleSpeedIndexRef.current = 0; @@ -297,7 +295,7 @@ export function useTimelinePlayer({ nextTime, }); adapter.seek(nextTime, options); - liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render + liveTime.notifySeek(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render setCurrentTime(nextTime); // sync store so Split/Delete have accurate time if (!shouldResumeAfterSeek && !keepPlaying) scrubMusicAtSeek(iframeRef.current, nextTime); if (shouldResumeAfterSeek) { diff --git a/packages/studio/src/player/store/liveTime.ts b/packages/studio/src/player/store/liveTime.ts index ed2050efe9..087bf5cc48 100644 --- a/packages/studio/src/player/store/liveTime.ts +++ b/packages/studio/src/player/store/liveTime.ts @@ -2,12 +2,18 @@ type TimeListener = (time: number) => void; const timeListeners = new Set(); let latestTime = 0; +let seekCount = 0; export const liveTime = { notify: (time: number) => { latestTime = time; timeListeners.forEach((listener) => listener(time)); }, + notifySeek: (time: number) => { + seekCount += 1; + liveTime.notify(time); + }, + seekCount: () => seekCount, latest: () => latestTime, subscribe: (listener: TimeListener) => { timeListeners.add(listener); From e16d51b84eb6e2f88bd58f368aedb97610811eea Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 28 Sep 2026 22:14:01 -0700 Subject: [PATCH 09/11] fix(studio): keep a reloaded preview's hand-over from counting as a person's seek --- .../player/components/useTimelinePlayhead.ts | 1 + .../hooks/useTimelinePlayer.seek.test.ts | 25 +++++++++++++++++++ .../src/player/hooks/useTimelinePlayer.ts | 9 ++++--- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/player/components/useTimelinePlayhead.ts b/packages/studio/src/player/components/useTimelinePlayhead.ts index 28734663d2..1ef7cba88a 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.ts +++ b/packages/studio/src/player/components/useTimelinePlayhead.ts @@ -169,6 +169,7 @@ export function useTimelinePlayhead({ const unsubPlaying = usePlayerStore.subscribe((state, prev) => { if (prev.isPlaying && !state.isPlaying) place(lastLiveTimeRef.current, true); }); + lastSeekCountRef.current = liveTime.seekCount(); const unsub = liveTime.subscribe((t) => { const sought = liveTime.seekCount() !== lastSeekCountRef.current; lastSeekCountRef.current = liveTime.seekCount(); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts index a07541a2d1..9fa02ef0b2 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts @@ -6,6 +6,8 @@ import type { useTimelinePlayer } from "./useTimelinePlayer"; import { attachIframeAdapter, attachIframeWindow, + makeAdapterWindow, + makeFakeIframe, renderTimelinePlayerHarness, resetPlayerStore, } from "./timelinePlayerTestHarness"; @@ -272,6 +274,29 @@ describe("useTimelinePlayer seek keepPlaying option (#834)", () => { unmountWithAct(root); }); + it("does not count a reload's hand-over as a person's seek", () => { + const { getApi, root } = renderTimelinePlayerHarness(); + act(() => { + getApi().iframeRef.current = makeFakeIframe(makeAdapterWindow().win); + getApi().onIframeLoad(); + }); + seekWithAct(getApi(), 7); + act(() => getApi().refreshPlayer()); + const seeks = liveTime.seekCount(); + const gen = getApi().previewSlots.find((s) => s.role === "shadow")!.gen; + const shadow = makeFakeIframe(makeAdapterWindow().win); + shadow.src = "http://localhost/api/projects/demo/preview?_t=1"; + act(() => { + getApi().setShadowIframeNode(shadow); + getApi().onShadowIframeLoad(gen); + getApi().onShadowReadyChange(gen, true); + }); + expect(getApi().iframeRef.current).toBe(shadow); + expect(liveTime.latest()).toBe(7); + expect(liveTime.seekCount()).toBe(seeks); + unmountWithAct(root); + }); + it("default seek() clears isPlaying when the store reports playing", () => { const { api, root } = renderAttachedTimelinePlayer(); setStorePlaying(); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 625cb0ea2c..438e7aa756 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -47,6 +47,9 @@ export interface UseTimelinePlayerOptions { onPreviewReloadFailed?: (message: string) => void; } +const publishSeek = (time: number, options?: { restore?: boolean }) => + options?.restore ? liveTime.notify(time) : liveTime.notifySeek(time); + export function useTimelinePlayer({ onShadowPromoted, onPreviewReloadFailed, @@ -276,7 +279,7 @@ export function useTimelinePlayer({ stopRAFLoop(); }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]); const seek = useCallback( - (time: number, options?: { keepPlaying?: boolean }) => { + (time: number, options?: { keepPlaying?: boolean; restore?: boolean }) => { const wasReverseShuttle = shuttleDirectionRef.current === "backward"; stopReverseLoop(); const adapter = getAdapter(); @@ -295,7 +298,7 @@ export function useTimelinePlayer({ nextTime, }); adapter.seek(nextTime, options); - liveTime.notifySeek(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render + publishSeek(nextTime, options); // Direct DOM updates (playhead, timecode, progress) — no re-render setCurrentTime(nextTime); // sync store so Split/Delete have accurate time if (!shouldResumeAfterSeek && !keepPlaying) scrubMusicAtSeek(iframeRef.current, nextTime); if (shouldResumeAfterSeek) { @@ -409,7 +412,7 @@ export function useTimelinePlayer({ onReloadFailed: onPreviewReloadFailed, handOverPlayback: (time, playing) => { // keepPlaying: move the playhead without the paused-seek audio scrub. - seek(time, { keepPlaying: true }); + seek(time, { keepPlaying: true, restore: true }); const adapter = getAdapter(); // An edit that cut the film short of the live time stops it at the new end, as playback does. if (playing && adapter && adapter.getTime() < adapter.getDuration()) play(); From 2fabd6be41b93710d4490bfaaff86d9a66c9b5ab Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 28 Sep 2026 22:44:21 -0700 Subject: [PATCH 10/11] fix(studio): keep a paused timeline still during a beat drag and after a preset audition --- .../src/player/components/useTimelinePlayhead.test.tsx | 10 +++++++++- .../src/player/components/useTimelinePlayhead.ts | 5 ++--- .../src/player/hooks/useTimelinePlayer.seek.test.ts | 10 ++++++++++ packages/studio/src/player/hooks/useTimelinePlayer.ts | 2 +- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx index 536829032e..33dcde8973 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.test.tsx +++ b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx @@ -75,7 +75,7 @@ function mount(props: HarnessProps) { } beforeEach(() => { - usePlayerStore.setState({ currentTime: 0, isPlaying: false }); + usePlayerStore.setState({ currentTime: 0, isPlaying: false, beatDragging: false }); }); afterEach(() => { for (const root of roots.splice(0)) act(() => root.unmount()); @@ -237,6 +237,14 @@ describe("useTimelinePlayhead follow while paused", () => { expect(scroll.scrollLeft).toBe(0); }); + it("does not scroll while a beat is being dragged past the edge", () => { + usePlayerStore.setState({ beatDragging: true }); + const scroll = scrollBox(0); + mount({ pps: 100, scroll }); + act(() => liveTime.notifySeek(30)); + expect(scroll.scrollLeft).toBe(0); + }); + it("does not scroll in Fit", () => { const scroll = scrollBox(0); mount({ pps: 100, scroll, zoomMode: "fit" }); diff --git a/packages/studio/src/player/components/useTimelinePlayhead.ts b/packages/studio/src/player/components/useTimelinePlayhead.ts index 1ef7cba88a..1770400112 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.ts +++ b/packages/studio/src/player/components/useTimelinePlayhead.ts @@ -166,6 +166,7 @@ export function useTimelinePlayhead({ ); return true; }; + const dragging = () => isDragging.current || usePlayerStore.getState().beatDragging; const unsubPlaying = usePlayerStore.subscribe((state, prev) => { if (prev.isPlaying && !state.isPlaying) place(lastLiveTimeRef.current, true); }); @@ -179,9 +180,7 @@ export function useTimelinePlayhead({ const playheadX = contentOriginRef.current + Math.max(0, t) * ppsRef.current; const scroll = scrollRef.current; // Paused, only a seek scrolls: a reload's republish, frame-rounded, must not undo a person's scroll. - if (!scroll || isDragging.current || zoomModeRef.current === "fit" || (!playing && !sought)) { - return; - } + if (!scroll || dragging() || zoomModeRef.current === "fit" || (!playing && !sought)) return; const nextScrollLeft = playing ? getTimelinePlaybackFollowScrollLeft({ playheadX, diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts index 9fa02ef0b2..473065bc31 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts @@ -297,6 +297,16 @@ describe("useTimelinePlayer seek keepPlaying option (#834)", () => { unmountWithAct(root); }); + it("does not count the audition's return to the paused time as a person's seek", () => { + const { api, root } = renderAttachedTimelinePlayer(); + seekWithAct(api, 4); + const seeks = liveTime.seekCount(); + act(() => usePlayerStore.getState().requestPlayback(false, 2)); + expect(liveTime.latest()).toBe(2); + expect(liveTime.seekCount()).toBe(seeks); + unmountWithAct(root); + }); + it("default seek() clears isPlaying when the store reports playing", () => { const { api, root } = renderAttachedTimelinePlayer(); setStorePlaying(); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 438e7aa756..c7bfc65c8d 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -347,7 +347,7 @@ export function useTimelinePlayer({ if (request.playing) play(); else { pause(); - if (request.returnTo !== null) seek(request.returnTo); + if (request.returnTo !== null) seek(request.returnTo, { restore: true }); } usePlayerStore.getState().clearPlaybackRequest(); } From c38b00b421e886cfc85645368f84b119929c03d1 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 28 Sep 2026 23:06:52 -0700 Subject: [PATCH 11/11] fix(studio): keep a paused timeline where a person put it while they trim a clip --- packages/studio/src/components/nle/NLEContext.tsx | 2 +- .../studio/src/player/components/TimelineTypes.ts | 2 +- .../components/useTimelineClipDrag.resize.test.tsx | 13 ++++++++----- .../src/player/components/useTimelineClipDrag.ts | 5 +++-- .../studio/src/player/hooks/useTimelinePlayer.ts | 10 +++++----- 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/studio/src/components/nle/NLEContext.tsx b/packages/studio/src/components/nle/NLEContext.tsx index ab85f68caf..c09ed2735d 100644 --- a/packages/studio/src/components/nle/NLEContext.tsx +++ b/packages/studio/src/components/nle/NLEContext.tsx @@ -21,7 +21,7 @@ export interface NLEContextValue { play: () => void; pause: () => void; togglePlay: () => void; - seek: (time: number, options?: { keepPlaying?: boolean }) => boolean; + seek: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => boolean; refreshPlayer: () => void; onIframeLoad: () => void; // The hidden reload iframe NLEPreview renders next to the live one during a full reload. diff --git a/packages/studio/src/player/components/TimelineTypes.ts b/packages/studio/src/player/components/TimelineTypes.ts index f5aa1e03d0..460fef28c9 100644 --- a/packages/studio/src/player/components/TimelineTypes.ts +++ b/packages/studio/src/player/components/TimelineTypes.ts @@ -15,7 +15,7 @@ export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides, TimelineStackingSyncProps { /** Project-scoped reset boundary; soft source refreshes retain the same epoch. */ sessionEpoch?: number; - onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void; + onSeek?: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => void; onDrillDown?: (element: TimelineElement) => void; /** Picture only: takes no pointer input. Interactive content goes in renderClipOverlay. */ renderClipContent?: ( diff --git a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx index fd5cb0fadd..2aa3718dd1 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx +++ b/packages/studio/src/player/components/useTimelineClipDrag.resize.test.tsx @@ -513,9 +513,9 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => { const h = renderResizeHarness([a], [], { onSeek }); h.startResize(a, "end"); h.movePointer(50); - expect(onSeek).toHaveBeenLastCalledWith(3.5 - 1 / 30, { keepPlaying: true }); + expect(onSeek).toHaveBeenLastCalledWith(3.5 - 1 / 30, { keepPlaying: true, follow: false }); await h.dropPointer(); - expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true }); + expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true, follow: false }); h.unmount(); }); @@ -525,7 +525,7 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => { const h = renderResizeHarness([a], [], { onSeek }); h.startResize(a, "start"); h.movePointer(50); - expect(onSeek).toHaveBeenLastCalledWith(1.5, { keepPlaying: true }); + expect(onSeek).toHaveBeenLastCalledWith(1.5, { keepPlaying: true, follow: false }); h.unmount(); }); @@ -545,7 +545,7 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => { h.movePointer(80); h.movePointer(120); await h.dropPointer(); - expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true }); + expect(onSeek).toHaveBeenLastCalledWith(1.25, { keepPlaying: true, follow: false }); h.unmount(); }); @@ -571,7 +571,10 @@ describe("useTimelineClipDrag — trim guide and preview frame", () => { const clip = h.getResizingClip()!; expect(clip.previewStart + clip.previewDuration).toBeCloseTo(3.1, 3); expect(clip).toMatchObject({ snapTime: null, snapType: null }); - expect(onSeek).toHaveBeenLastCalledWith(expect.closeTo(3.1 - 1 / 30, 3), { keepPlaying: true }); + expect(onSeek).toHaveBeenLastCalledWith(expect.closeTo(3.1 - 1 / 30, 3), { + keepPlaying: true, + follow: false, + }); h.unmount(); }); }); diff --git a/packages/studio/src/player/components/useTimelineClipDrag.ts b/packages/studio/src/player/components/useTimelineClipDrag.ts index 7c49eda117..18e588d747 100644 --- a/packages/studio/src/player/components/useTimelineClipDrag.ts +++ b/packages/studio/src/player/components/useTimelineClipDrag.ts @@ -66,7 +66,7 @@ interface UseTimelineClipDragInput { onResizeElements?: NonNullable; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedClipState["intent"]) => void; /** Seeks the preview; a trim shows the frame at its dragged edge. */ - onSeek?: (time: number, options?: { keepPlaying?: boolean }) => void; + onSeek?: (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => void; setShowPopover: (show: boolean) => void; /** Stable ref to the range selection setter — wired after mount to break circular dependency. */ setRangeSelectionRef: React.RefObject<((sel: null) => void) | null>; @@ -311,6 +311,7 @@ export function useTimelineClipDrag({ // and it only resumes playback if it was already playing. onSeekRef.current?.(trimPreviewTime(resize.edge, v.previewStart, v.previewDuration), { keepPlaying: true, + follow: false, }); publishResizingClip( resizingClipRef.current ? { ...resizingClipRef.current, started: true, ...v } : null, @@ -361,7 +362,7 @@ export function useTimelineClipDrag({ if (trimSeekOriginRef.current != null) { // Paused: put the playhead back. Playing: leave it, a backward jump would rewind live playback. if (!usePlayerStore.getState().isPlaying) { - onSeekRef.current?.(trimSeekOriginRef.current, { keepPlaying: true }); + onSeekRef.current?.(trimSeekOriginRef.current, { keepPlaying: true, follow: false }); } trimSeekOriginRef.current = null; } diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index c7bfc65c8d..b470feae2e 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -47,8 +47,8 @@ export interface UseTimelinePlayerOptions { onPreviewReloadFailed?: (message: string) => void; } -const publishSeek = (time: number, options?: { restore?: boolean }) => - options?.restore ? liveTime.notify(time) : liveTime.notifySeek(time); +const publishSeek = (time: number, options?: { follow?: boolean }) => + options?.follow === false ? liveTime.notify(time) : liveTime.notifySeek(time); export function useTimelinePlayer({ onShadowPromoted, @@ -279,7 +279,7 @@ export function useTimelinePlayer({ stopRAFLoop(); }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]); const seek = useCallback( - (time: number, options?: { keepPlaying?: boolean; restore?: boolean }) => { + (time: number, options?: { keepPlaying?: boolean; follow?: boolean }) => { const wasReverseShuttle = shuttleDirectionRef.current === "backward"; stopReverseLoop(); const adapter = getAdapter(); @@ -347,7 +347,7 @@ export function useTimelinePlayer({ if (request.playing) play(); else { pause(); - if (request.returnTo !== null) seek(request.returnTo, { restore: true }); + if (request.returnTo !== null) seek(request.returnTo, { follow: false }); } usePlayerStore.getState().clearPlaybackRequest(); } @@ -412,7 +412,7 @@ export function useTimelinePlayer({ onReloadFailed: onPreviewReloadFailed, handOverPlayback: (time, playing) => { // keepPlaying: move the playhead without the paused-seek audio scrub. - seek(time, { keepPlaying: true, restore: true }); + seek(time, { keepPlaying: true, follow: false }); const adapter = getAdapter(); // An edit that cut the film short of the live time stops it at the new end, as playback does. if (playing && adapter && adapter.getTime() < adapter.getDuration()) play();