fix(studio): make static-seek adapter honor keepPlaying option (#1089)

createStaticSeekPlaybackAdapter.seek now accepts the same options as the
PlaybackAdapter contract and aligns the default-pause semantics with
wrapTimeline (hardened in 3e7b464b). Without keepPlaying the adapter
clears its `playing` flag and cancels the RAF ticker, so on non-GSAP
compositions a scrub during playback no longer leaves the iframe
silently advancing while the public seek wrapper marks isPlaying=false.

Follow-up to #863 review: jrusso called out the type drift and invited
a separate PR; this also closes the asymmetry with wrapTimeline.

Co-authored-by: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com>
This commit is contained in:
Carlos Alcaraz Gregor
2026-05-27 00:50:30 -04:00
committed by GitHub
co-authored by Carlos Alcaraz
parent 7cde0d9554
commit 8ecef4b939
2 changed files with 177 additions and 6 deletions
@@ -1,6 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { wrapTimeline } from "./playbackAdapter";
import type { TimelineLike } from "./playbackTypes";
import { createStaticSeekPlaybackAdapter, wrapTimeline } from "./playbackAdapter";
import type {
RuntimePlaybackAdapter,
StaticSeekPlaybackClock,
TimelineLike,
} from "./playbackTypes";
describe("wrapTimeline seek keepPlaying option (#834)", () => {
function mockTimeline(): TimelineLike & {
@@ -48,3 +52,162 @@ describe("wrapTimeline seek keepPlaying option (#834)", () => {
expect(tl.seek).toHaveBeenCalledWith(5);
});
});
describe("createStaticSeekPlaybackAdapter seek keepPlaying option", () => {
type StaticSeekPlayer = Pick<RuntimePlaybackAdapter, "getTime"> &
Partial<Pick<RuntimePlaybackAdapter, "renderSeek" | "seek">>;
function makeFakeClock(): StaticSeekPlaybackClock & {
runNextFrame: () => boolean;
cancelled: number[];
scheduled: number;
setNow: (ms: number) => void;
} {
let now = 0;
let nextHandle = 0;
const pending = new Map<number, FrameRequestCallback>();
const cancelled: number[] = [];
let scheduled = 0;
return {
now: () => now,
requestAnimationFrame: (cb) => {
nextHandle += 1;
pending.set(nextHandle, cb);
scheduled += 1;
return nextHandle;
},
cancelAnimationFrame: (handle) => {
if (pending.delete(handle)) cancelled.push(handle);
},
runNextFrame: () => {
const next = pending.entries().next();
if (next.done) return false;
const [handle, cb] = next.value;
pending.delete(handle);
cb(now);
return true;
},
cancelled,
get scheduled() {
return scheduled;
},
setNow: (ms) => {
now = ms;
},
};
}
function makePlayer(): StaticSeekPlayer & {
renderSeek: ReturnType<typeof vi.fn>;
} {
return {
getTime: () => 0,
renderSeek: vi.fn(),
};
}
it("default seek stops the RAF ticker so the adapter reports paused", () => {
const clock = makeFakeClock();
const player = makePlayer();
const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
adapter.play();
expect(adapter.isPlaying()).toBe(true);
adapter.seek(5);
expect(adapter.isPlaying()).toBe(false);
expect(adapter.getTime()).toBe(5);
expect(player.renderSeek).toHaveBeenLastCalledWith(5);
expect(clock.cancelled.length).toBeGreaterThan(0);
});
it("default seek prevents the ticker from advancing further", () => {
const clock = makeFakeClock();
const player = makePlayer();
const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
adapter.play();
player.renderSeek.mockClear();
adapter.seek(5);
// Any frame the RAF callback already had queued before cancel should be a no-op.
clock.setNow(1000);
clock.runNextFrame();
expect(player.renderSeek).toHaveBeenCalledTimes(1); // only the seek itself
expect(player.renderSeek).toHaveBeenLastCalledWith(5);
expect(adapter.getTime()).toBe(5);
});
it("seek with { keepPlaying: true } preserves playback and rebases the ticker", () => {
const clock = makeFakeClock();
const player = makePlayer();
const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
adapter.play();
clock.setNow(500);
expect(adapter.isPlaying()).toBe(true);
adapter.seek(3, { keepPlaying: true });
expect(adapter.isPlaying()).toBe(true);
expect(adapter.getTime()).toBe(3);
// Advance 1s of wall-clock time. With playStartTime rebased to 3 and
// playStartNow rebased to 500, the next tick should render around t=4.
clock.setNow(1500);
clock.runNextFrame();
expect(player.renderSeek).toHaveBeenLastCalledWith(4);
});
it("seek with { keepPlaying: false } pauses (matches default)", () => {
const clock = makeFakeClock();
const player = makePlayer();
const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
adapter.play();
adapter.seek(5, { keepPlaying: false });
expect(adapter.isPlaying()).toBe(false);
expect(player.renderSeek).toHaveBeenLastCalledWith(5);
});
it("seek with { keepPlaying: true } does not force playback when adapter is paused", () => {
const clock = makeFakeClock();
const player = makePlayer();
const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
adapter.seek(2, { keepPlaying: true });
expect(adapter.isPlaying()).toBe(false);
expect(adapter.getTime()).toBe(2);
expect(player.renderSeek).toHaveBeenLastCalledWith(2);
});
it("seek without options stays back-compatible with the previous signature", () => {
const clock = makeFakeClock();
const player = makePlayer();
const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
// Caller written before the options parameter existed.
adapter.seek(4);
expect(player.renderSeek).toHaveBeenLastCalledWith(4);
expect(adapter.getTime()).toBe(4);
expect(adapter.isPlaying()).toBe(false);
});
it("default seek clamps to duration and still pauses", () => {
const clock = makeFakeClock();
const player = makePlayer();
const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
adapter.play();
adapter.seek(99);
expect(adapter.getTime()).toBe(10);
expect(player.renderSeek).toHaveBeenLastCalledWith(10);
expect(adapter.isPlaying()).toBe(false);
});
});
@@ -113,12 +113,20 @@ export function createStaticSeekPlaybackAdapter(
playing = false;
stopTicker();
},
seek: (time) => {
seek: (time, options) => {
renderSeek(time);
if (playing) {
playStartTime = currentTime;
playStartNow = clock.now();
if (options?.keepPlaying) {
if (playing) {
playStartTime = currentTime;
playStartNow = clock.now();
}
return;
}
// Default seek aligns with wrapTimeline: stop the RAF ticker so the
// adapter's `playing` flag matches the public seek contract instead of
// silently driving renderSeek in the background.
playing = false;
stopTicker();
},
getTime: () => currentTime,
getDuration: () => safeDuration,