mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
co-authored by
Carlos Alcaraz
parent
7cde0d9554
commit
8ecef4b939
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user