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/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/components/useTimelinePlayhead.test.tsx b/packages/studio/src/player/components/useTimelinePlayhead.test.tsx index a6096a3b25..33dcde8973 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, 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"; 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,34 @@ 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) { + const scrollRef = useRef(scroll); + const durationRef = useRef(60); useTimelinePlayhead({ - playheadRef: createRef(), - scrollRef: { current: scroll }, + playheadRef: { current: document.createElement("div") }, + scrollRef, ppsRef: { current: pps }, - durationRef: { current: 60 }, - isDragging: { current: false }, + durationRef, + 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 +57,198 @@ 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, byPerson = false) => + act(() => { + if (byPerson) usePlayerStore.setState((s) => ({ userZoomCount: s.userZoomCount + 1 })); + 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, beatDragging: 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; +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", () => { + 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 }, 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 }, true); + 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); + mount({ pps: 100, scroll, percent: 100 })({ pps: 200, percent: 200 }, true); + expectVisible(scroll, 30, 200); + }); +}); + +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); + expectVisible(scroll, 30, 200); + }); +}); + +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.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.notifySeek(7)); + expect(scroll.scrollLeft).toBe(0); + }); + + 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.notifySeek(30)); + scroll.scrollLeft = 0; + 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); + }); + + 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("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 }); + act(() => liveTime.notifySeek(30)); + 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" }); + 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 8f8bbdb087..1770400112 100644 --- a/packages/studio/src/player/components/useTimelinePlayhead.ts +++ b/packages/studio/src/player/components/useTimelinePlayhead.ts @@ -12,6 +12,22 @@ import { import { getTimelinePlayheadTransform } from "./timelinePlayheadTransform"; import { applyTimelineHorizontalAutoScrollStep } from "./timelineEditing"; +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 +74,13 @@ 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 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 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; @@ -70,15 +89,22 @@ export function useTimelinePlayhead({ const scroll = scrollRef.current; const prevPps = previousAnchorPpsRef.current; previousAnchorPpsRef.current = pps; + 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; - // 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 = userZoomCount !== prevZoomCount; + 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 +112,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, userZoomCount, scrollRef, durationRef, contentOrigin]); const syncPlayheadPosition = useCallback( (time: number) => { @@ -127,7 +156,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( @@ -138,29 +166,30 @@ export function useTimelinePlayhead({ ); return true; }; + const dragging = () => isDragging.current || usePlayerStore.getState().beatDragging; const unsubPlaying = usePlayerStore.subscribe((state, prev) => { - if (prev.isPlaying && !state.isPlaying) place(lastLiveTime, true); + if (prev.isPlaying && !state.isPlaying) place(lastLiveTimeRef.current, true); }); + lastSeekCountRef.current = liveTime.seekCount(); const unsub = liveTime.subscribe((t) => { - lastLiveTime = t; - if (!place(t, !usePlayerStore.getState().isPlaying)) return; + 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; - if ( - !scroll || - !usePlayerStore.getState().isPlaying || - isDragging.current || - zoomModeRef.current === "fit" - ) { - return; - } - const nextScrollLeft = getTimelinePlaybackFollowScrollLeft({ - playheadX, - currentScrollLeft: scroll.scrollLeft, - viewportWidth: scroll.clientWidth, - contentOrigin: contentOriginRef.current, - maxScrollLeft: scroll.scrollWidth - scroll.clientWidth, - }); + // Paused, only a seek scrolls: a reload's republish, frame-rounded, must not undo a person's scroll. + if (!scroll || dragging() || zoomModeRef.current === "fit" || (!playing && !sought)) return; + 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; } diff --git a/packages/studio/src/player/components/useTimelineZoom.ts b/packages/studio/src/player/components/useTimelineZoom.ts index f4b3e74399..f452e5af9f 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,13 @@ 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); + 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/hooks/useTimelinePlayer.seek.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts index 5d0fa43899..473065bc31 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"; @@ -263,6 +265,48 @@ 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("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("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 421ad84607..b470feae2e 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?: { follow?: boolean }) => + options?.follow === false ? 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; follow?: boolean }) => { const wasReverseShuttle = shuttleDirectionRef.current === "backward"; stopReverseLoop(); const adapter = getAdapter(); @@ -295,7 +298,7 @@ export function useTimelinePlayer({ nextTime, }); adapter.seek(nextTime, options); - liveTime.notify(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) { @@ -344,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, { follow: false }); } usePlayerStore.getState().clearPlaybackRequest(); } @@ -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, 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(); 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); 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,