Skip to content

Commit 0c0ccce

Browse files
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>
1 parent 7be4f92 commit 0c0ccce

2 files changed

Lines changed: 144 additions & 1 deletion

File tree

packages/studio/src/player/hooks/useTimelinePlayer.seek.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,145 @@ describe("useTimelinePlayer seek keepPlaying option (#834)", () => {
279279
expectStorePlaybackState(root, { isPlaying: true, currentTime: 0 });
280280
});
281281
});
282+
283+
describe("useTimelinePlayer RAF loop wrap-around", () => {
284+
type SeekCall = { time: number; options?: { keepPlaying?: boolean } };
285+
286+
function attachInstrumentedAdapter(api: ReturnType<typeof useTimelinePlayer>, duration = 30) {
287+
const iframe = document.createElement("iframe");
288+
let currentTime = 0;
289+
let playing = false;
290+
const seekCalls: SeekCall[] = [];
291+
const adapter = {
292+
play: vi.fn(() => {
293+
playing = true;
294+
}),
295+
pause: vi.fn(() => {
296+
playing = false;
297+
}),
298+
seek: vi.fn((time: number, options?: { keepPlaying?: boolean }) => {
299+
currentTime = time;
300+
seekCalls.push({ time, options });
301+
}),
302+
getTime: () => currentTime,
303+
getDuration: () => duration,
304+
isPlaying: () => playing,
305+
setTime: (t: number) => {
306+
currentTime = t;
307+
},
308+
};
309+
Object.defineProperty(iframe, "contentWindow", {
310+
value: {
311+
__player: adapter,
312+
postMessage: () => {},
313+
scrollTo: () => {},
314+
addEventListener: () => {},
315+
removeEventListener: () => {},
316+
},
317+
configurable: true,
318+
});
319+
Object.defineProperty(iframe, "contentDocument", {
320+
value: document.implementation.createHTMLDocument("preview"),
321+
configurable: true,
322+
});
323+
act(() => {
324+
api.iframeRef.current = iframe;
325+
api.onIframeLoad();
326+
});
327+
return { adapter, seekCalls };
328+
}
329+
330+
function installRafCapture(): {
331+
flushOne: () => boolean;
332+
restore: () => void;
333+
} {
334+
const callbacks: FrameRequestCallback[] = [];
335+
const originalRAF = globalThis.requestAnimationFrame;
336+
const originalCancel = globalThis.cancelAnimationFrame;
337+
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
338+
callbacks.push(cb);
339+
return callbacks.length;
340+
}) as typeof requestAnimationFrame;
341+
globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame;
342+
return {
343+
flushOne: () => {
344+
const next = callbacks.shift();
345+
if (!next) return false;
346+
next(performance.now());
347+
return true;
348+
},
349+
restore: () => {
350+
globalThis.requestAnimationFrame = originalRAF;
351+
globalThis.cancelAnimationFrame = originalCancel;
352+
},
353+
};
354+
}
355+
356+
it("passes { keepPlaying: true } when forward playback wraps around loopEnd", () => {
357+
const raf = installRafCapture();
358+
try {
359+
const { api, root } = renderTimelinePlayerHarness();
360+
const { adapter, seekCalls } = attachInstrumentedAdapter(api);
361+
362+
act(() => {
363+
usePlayerStore.getState().setInPoint(2);
364+
usePlayerStore.getState().setOutPoint(5);
365+
});
366+
expect(usePlayerStore.getState().loopEnabled).toBe(true);
367+
368+
act(() => {
369+
api.play();
370+
});
371+
adapter.seek.mockClear();
372+
seekCalls.length = 0;
373+
374+
adapter.setTime(6); // past outPoint=5
375+
act(() => {
376+
raf.flushOne();
377+
});
378+
379+
const wrapSeek = seekCalls.find((call) => call.time === 2);
380+
expect(wrapSeek).toBeDefined();
381+
expect(wrapSeek?.options).toEqual({ keepPlaying: true });
382+
expect(adapter.play).toHaveBeenCalled();
383+
expect(usePlayerStore.getState().isPlaying).toBe(true);
384+
385+
unmountWithAct(root);
386+
} finally {
387+
raf.restore();
388+
}
389+
});
390+
391+
it("does not seek and pauses cleanly when forward playback reaches the end without loop", () => {
392+
const raf = installRafCapture();
393+
try {
394+
const { api, root } = renderTimelinePlayerHarness();
395+
const { adapter, seekCalls } = attachInstrumentedAdapter(api);
396+
397+
act(() => {
398+
usePlayerStore.getState().setLoopEnabled(false);
399+
});
400+
401+
act(() => {
402+
api.play();
403+
});
404+
adapter.seek.mockClear();
405+
seekCalls.length = 0;
406+
adapter.play.mockClear();
407+
adapter.pause.mockClear();
408+
409+
adapter.setTime(adapter.getDuration() + 1); // past end
410+
act(() => {
411+
raf.flushOne();
412+
});
413+
414+
expect(seekCalls).toHaveLength(0);
415+
expect(adapter.pause).toHaveBeenCalled();
416+
expect(usePlayerStore.getState().isPlaying).toBe(false);
417+
418+
unmountWithAct(root);
419+
} finally {
420+
raf.restore();
421+
}
422+
});
423+
});

packages/studio/src/player/hooks/useTimelinePlayer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,8 @@ export function useTimelinePlayer() {
229229
const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
230230
if (time >= loopEnd) {
231231
if (usePlayerStore.getState().loopEnabled && dur > 0) {
232-
adapter.seek(loopStart);
232+
// keepPlaying skips the adapter's implicit pause; play() below is then a no-op.
233+
adapter.seek(loopStart, { keepPlaying: true });
233234
liveTime.notify(loopStart);
234235
adapter.play();
235236
setIsPlaying(true);

0 commit comments

Comments
 (0)