fix(studio): preserve playback across forward RAF loop wrap-around (#1103)

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.

Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
This commit is contained in:
Carlos Alcaraz Gregor
2026-05-27 23:48:07 -04:00
committed by GitHub
co-authored by Carlos Alcaraz
parent 7be4f92f18
commit 0c0cccec96
2 changed files with 144 additions and 1 deletions
@@ -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<typeof useTimelinePlayer>, 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();
}
});
});
@@ -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);