Skip to content

Commit 8ecef4b

Browse files
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 3e7b464). 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>
1 parent 7cde0d9 commit 8ecef4b

2 files changed

Lines changed: 177 additions & 6 deletions

File tree

packages/studio/src/player/lib/playbackAdapter.test.ts

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it, vi } from "vitest";
2-
import { wrapTimeline } from "./playbackAdapter";
3-
import type { TimelineLike } from "./playbackTypes";
2+
import { createStaticSeekPlaybackAdapter, wrapTimeline } from "./playbackAdapter";
3+
import type {
4+
RuntimePlaybackAdapter,
5+
StaticSeekPlaybackClock,
6+
TimelineLike,
7+
} from "./playbackTypes";
48

59
describe("wrapTimeline seek keepPlaying option (#834)", () => {
610
function mockTimeline(): TimelineLike & {
@@ -48,3 +52,162 @@ describe("wrapTimeline seek keepPlaying option (#834)", () => {
4852
expect(tl.seek).toHaveBeenCalledWith(5);
4953
});
5054
});
55+
56+
describe("createStaticSeekPlaybackAdapter seek keepPlaying option", () => {
57+
type StaticSeekPlayer = Pick<RuntimePlaybackAdapter, "getTime"> &
58+
Partial<Pick<RuntimePlaybackAdapter, "renderSeek" | "seek">>;
59+
60+
function makeFakeClock(): StaticSeekPlaybackClock & {
61+
runNextFrame: () => boolean;
62+
cancelled: number[];
63+
scheduled: number;
64+
setNow: (ms: number) => void;
65+
} {
66+
let now = 0;
67+
let nextHandle = 0;
68+
const pending = new Map<number, FrameRequestCallback>();
69+
const cancelled: number[] = [];
70+
let scheduled = 0;
71+
return {
72+
now: () => now,
73+
requestAnimationFrame: (cb) => {
74+
nextHandle += 1;
75+
pending.set(nextHandle, cb);
76+
scheduled += 1;
77+
return nextHandle;
78+
},
79+
cancelAnimationFrame: (handle) => {
80+
if (pending.delete(handle)) cancelled.push(handle);
81+
},
82+
runNextFrame: () => {
83+
const next = pending.entries().next();
84+
if (next.done) return false;
85+
const [handle, cb] = next.value;
86+
pending.delete(handle);
87+
cb(now);
88+
return true;
89+
},
90+
cancelled,
91+
get scheduled() {
92+
return scheduled;
93+
},
94+
setNow: (ms) => {
95+
now = ms;
96+
},
97+
};
98+
}
99+
100+
function makePlayer(): StaticSeekPlayer & {
101+
renderSeek: ReturnType<typeof vi.fn>;
102+
} {
103+
return {
104+
getTime: () => 0,
105+
renderSeek: vi.fn(),
106+
};
107+
}
108+
109+
it("default seek stops the RAF ticker so the adapter reports paused", () => {
110+
const clock = makeFakeClock();
111+
const player = makePlayer();
112+
const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
113+
114+
adapter.play();
115+
expect(adapter.isPlaying()).toBe(true);
116+
117+
adapter.seek(5);
118+
119+
expect(adapter.isPlaying()).toBe(false);
120+
expect(adapter.getTime()).toBe(5);
121+
expect(player.renderSeek).toHaveBeenLastCalledWith(5);
122+
expect(clock.cancelled.length).toBeGreaterThan(0);
123+
});
124+
125+
it("default seek prevents the ticker from advancing further", () => {
126+
const clock = makeFakeClock();
127+
const player = makePlayer();
128+
const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
129+
130+
adapter.play();
131+
player.renderSeek.mockClear();
132+
133+
adapter.seek(5);
134+
135+
// Any frame the RAF callback already had queued before cancel should be a no-op.
136+
clock.setNow(1000);
137+
clock.runNextFrame();
138+
expect(player.renderSeek).toHaveBeenCalledTimes(1); // only the seek itself
139+
expect(player.renderSeek).toHaveBeenLastCalledWith(5);
140+
expect(adapter.getTime()).toBe(5);
141+
});
142+
143+
it("seek with { keepPlaying: true } preserves playback and rebases the ticker", () => {
144+
const clock = makeFakeClock();
145+
const player = makePlayer();
146+
const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
147+
148+
adapter.play();
149+
clock.setNow(500);
150+
expect(adapter.isPlaying()).toBe(true);
151+
152+
adapter.seek(3, { keepPlaying: true });
153+
154+
expect(adapter.isPlaying()).toBe(true);
155+
expect(adapter.getTime()).toBe(3);
156+
157+
// Advance 1s of wall-clock time. With playStartTime rebased to 3 and
158+
// playStartNow rebased to 500, the next tick should render around t=4.
159+
clock.setNow(1500);
160+
clock.runNextFrame();
161+
expect(player.renderSeek).toHaveBeenLastCalledWith(4);
162+
});
163+
164+
it("seek with { keepPlaying: false } pauses (matches default)", () => {
165+
const clock = makeFakeClock();
166+
const player = makePlayer();
167+
const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
168+
169+
adapter.play();
170+
adapter.seek(5, { keepPlaying: false });
171+
172+
expect(adapter.isPlaying()).toBe(false);
173+
expect(player.renderSeek).toHaveBeenLastCalledWith(5);
174+
});
175+
176+
it("seek with { keepPlaying: true } does not force playback when adapter is paused", () => {
177+
const clock = makeFakeClock();
178+
const player = makePlayer();
179+
const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
180+
181+
adapter.seek(2, { keepPlaying: true });
182+
183+
expect(adapter.isPlaying()).toBe(false);
184+
expect(adapter.getTime()).toBe(2);
185+
expect(player.renderSeek).toHaveBeenLastCalledWith(2);
186+
});
187+
188+
it("seek without options stays back-compatible with the previous signature", () => {
189+
const clock = makeFakeClock();
190+
const player = makePlayer();
191+
const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
192+
193+
// Caller written before the options parameter existed.
194+
adapter.seek(4);
195+
196+
expect(player.renderSeek).toHaveBeenLastCalledWith(4);
197+
expect(adapter.getTime()).toBe(4);
198+
expect(adapter.isPlaying()).toBe(false);
199+
});
200+
201+
it("default seek clamps to duration and still pauses", () => {
202+
const clock = makeFakeClock();
203+
const player = makePlayer();
204+
const adapter = createStaticSeekPlaybackAdapter(player, 10, clock);
205+
206+
adapter.play();
207+
adapter.seek(99);
208+
209+
expect(adapter.getTime()).toBe(10);
210+
expect(player.renderSeek).toHaveBeenLastCalledWith(10);
211+
expect(adapter.isPlaying()).toBe(false);
212+
});
213+
});

packages/studio/src/player/lib/playbackAdapter.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,20 @@ export function createStaticSeekPlaybackAdapter(
113113
playing = false;
114114
stopTicker();
115115
},
116-
seek: (time) => {
116+
seek: (time, options) => {
117117
renderSeek(time);
118-
if (playing) {
119-
playStartTime = currentTime;
120-
playStartNow = clock.now();
118+
if (options?.keepPlaying) {
119+
if (playing) {
120+
playStartTime = currentTime;
121+
playStartNow = clock.now();
122+
}
123+
return;
121124
}
125+
// Default seek aligns with wrapTimeline: stop the RAF ticker so the
126+
// adapter's `playing` flag matches the public seek contract instead of
127+
// silently driving renderSeek in the background.
128+
playing = false;
129+
stopTicker();
122130
},
123131
getTime: () => currentTime,
124132
getDuration: () => safeDuration,

0 commit comments

Comments
 (0)