From 9b9b0e786d3f14f534fd15af85eaa5b935b2d31d Mon Sep 17 00:00:00 2001 From: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com> Date: Thu, 28 May 2026 00:35:19 -0300 Subject: [PATCH] fix(studio): preserve playback across forward RAF loop wrap-around MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When forward playback reaches loopEnd and the loop wraps back to loopStart, the RAF tick was calling `adapter.seek(loopStart)` without keepPlaying, then immediately `adapter.play()` to resume. With the post-3e7b464b wrapTimeline contract (default seek pauses), this means every loop boundary executes pause→seek→pause→play for GSAP and a stop/start RAF ticker cycle for the static-seek adapter — purely unnecessary churn. Pass { keepPlaying: true } so seek skips the implicit pause; the follow-up adapter.play() is then a no-op because the underlying adapter never paused. Adds two tests covering the wrap-around branch (previously uncovered) and the no-loop terminal path as a regression guard. Completes the keepPlaying rollout: #842 introduced the option for A/E shortcuts, #863 extended it to the runtime player, #1089 aligned the static-seek adapter, and this applies it to the last internal caller that explicitly resumes after seek. --- .../hooks/useTimelinePlayer.seek.test.ts | 142 ++++++++++++++++++ .../src/player/hooks/useTimelinePlayer.ts | 3 +- 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts index e4db5e0b5..303638053 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts @@ -279,3 +279,145 @@ describe("useTimelinePlayer seek keepPlaying option (#834)", () => { expectStorePlaybackState(root, { isPlaying: true, currentTime: 0 }); }); }); + +describe("useTimelinePlayer RAF loop wrap-around", () => { + type SeekCall = { time: number; options?: { keepPlaying?: boolean } }; + + function attachInstrumentedAdapter(api: ReturnType, duration = 30) { + const iframe = document.createElement("iframe"); + let currentTime = 0; + let playing = false; + const seekCalls: SeekCall[] = []; + const adapter = { + play: vi.fn(() => { + playing = true; + }), + pause: vi.fn(() => { + playing = false; + }), + seek: vi.fn((time: number, options?: { keepPlaying?: boolean }) => { + currentTime = time; + seekCalls.push({ time, options }); + }), + getTime: () => currentTime, + getDuration: () => duration, + isPlaying: () => playing, + setTime: (t: number) => { + currentTime = t; + }, + }; + Object.defineProperty(iframe, "contentWindow", { + value: { + __player: adapter, + postMessage: () => {}, + scrollTo: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + }, + configurable: true, + }); + Object.defineProperty(iframe, "contentDocument", { + value: document.implementation.createHTMLDocument("preview"), + configurable: true, + }); + act(() => { + api.iframeRef.current = iframe; + api.onIframeLoad(); + }); + return { adapter, seekCalls }; + } + + function installRafCapture(): { + flushOne: () => boolean; + restore: () => void; + } { + const callbacks: FrameRequestCallback[] = []; + const originalRAF = globalThis.requestAnimationFrame; + const originalCancel = globalThis.cancelAnimationFrame; + globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => { + callbacks.push(cb); + return callbacks.length; + }) as typeof requestAnimationFrame; + globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame; + return { + flushOne: () => { + const next = callbacks.shift(); + if (!next) return false; + next(performance.now()); + return true; + }, + restore: () => { + globalThis.requestAnimationFrame = originalRAF; + globalThis.cancelAnimationFrame = originalCancel; + }, + }; + } + + it("passes { keepPlaying: true } when forward playback wraps around loopEnd", () => { + const raf = installRafCapture(); + try { + const { api, root } = renderTimelinePlayerHarness(); + const { adapter, seekCalls } = attachInstrumentedAdapter(api); + + act(() => { + usePlayerStore.getState().setInPoint(2); + usePlayerStore.getState().setOutPoint(5); + }); + expect(usePlayerStore.getState().loopEnabled).toBe(true); + + act(() => { + api.play(); + }); + adapter.seek.mockClear(); + seekCalls.length = 0; + + adapter.setTime(6); // past outPoint=5 + act(() => { + raf.flushOne(); + }); + + const wrapSeek = seekCalls.find((call) => call.time === 2); + expect(wrapSeek).toBeDefined(); + expect(wrapSeek?.options).toEqual({ keepPlaying: true }); + expect(adapter.play).toHaveBeenCalled(); + expect(usePlayerStore.getState().isPlaying).toBe(true); + + unmountWithAct(root); + } finally { + raf.restore(); + } + }); + + it("does not seek and pauses cleanly when forward playback reaches the end without loop", () => { + const raf = installRafCapture(); + try { + const { api, root } = renderTimelinePlayerHarness(); + const { adapter, seekCalls } = attachInstrumentedAdapter(api); + + act(() => { + usePlayerStore.getState().setLoopEnabled(false); + }); + + act(() => { + api.play(); + }); + adapter.seek.mockClear(); + seekCalls.length = 0; + adapter.play.mockClear(); + adapter.pause.mockClear(); + + adapter.setTime(adapter.getDuration() + 1); // past end + act(() => { + raf.flushOne(); + }); + + expect(seekCalls).toHaveLength(0); + expect(adapter.pause).toHaveBeenCalled(); + expect(usePlayerStore.getState().isPlaying).toBe(false); + + unmountWithAct(root); + } finally { + raf.restore(); + } + }); +}); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 77e38836c..943aa6c68 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -229,7 +229,8 @@ export function useTimelinePlayer() { const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0; if (time >= loopEnd) { if (usePlayerStore.getState().loopEnabled && dur > 0) { - adapter.seek(loopStart); + // keepPlaying skips the adapter's implicit pause; play() below is then a no-op. + adapter.seek(loopStart, { keepPlaying: true }); liveTime.notify(loopStart); adapter.play(); setIsPlaying(true);