fix(runtime): respect keepPlaying option in player seek (#863)

This commit is contained in:
Carlos Alcaraz Gregor
2026-05-17 17:10:23 +02:00
committed by GitHub
parent 551607efd6
commit cff1e76246
5 changed files with 108 additions and 6 deletions
+1 -1
View File
@@ -399,7 +399,7 @@ export interface CompositionAPI {
export interface PlayerAPI {
play(): void;
pause(): void;
seek(time: number): void;
seek(time: number, options?: { keepPlaying?: boolean }): void;
getTime(): number;
getDuration(): number;
isPlaying(): boolean;
+1 -1
View File
@@ -84,7 +84,7 @@ export function initSandboxRuntimeModular(): void {
_timeline: RuntimeTimelineLike | null;
play: () => void;
pause: () => void;
seek: (timeSeconds: number) => void;
seek: (timeSeconds: number, options?: { keepPlaying?: boolean }) => void;
getTime: () => number;
getDuration: () => number;
isPlaying: () => boolean;
+85
View File
@@ -368,6 +368,91 @@ describe("createRuntimePlayer", () => {
expect(deps.onDeterministicSeek).toHaveBeenCalledWith(8);
expect(deps.onSyncMedia).toHaveBeenCalledWith(8, false);
});
// Regression: A/E Jump-to-in/out shortcuts (PR #842) send
// `{ keepPlaying: true }` so playback survives the seek. Before this fix the
// runtime always called setIsPlaying(false), so the shortcut paused playback
// on every press in compositions backed by the `__player` runtime adapter.
describe("keepPlaying option", () => {
it("preserves play state when keepPlaying is true and playback was active", () => {
const timeline = createMockTimeline({ duration: 10 });
const deps = createMockDeps(timeline);
deps.getIsPlaying.mockReturnValue(true);
const player = createRuntimePlayer(deps);
player.seek(3, { keepPlaying: true });
expect(deps.setIsPlaying).not.toHaveBeenCalledWith(false);
expect(deps.onDeterministicPlay).toHaveBeenCalled();
expect(deps.onShowNativeVideos).toHaveBeenCalled();
expect(deps.onSyncMedia).toHaveBeenCalledWith(expect.any(Number), true);
});
it("resumes the master timeline after the deterministic seek pauses it", () => {
const timeline = createMockTimeline({ duration: 10 });
const deps = createMockDeps(timeline);
deps.getIsPlaying.mockReturnValue(true);
const player = createRuntimePlayer(deps);
player.seek(3, { keepPlaying: true });
// The helper pauses then seeks; the keep-playing branch must call
// play() afterwards so the timeline is left running.
const playMock = timeline.play as ReturnType<typeof vi.fn>;
const pauseMock = timeline.pause as ReturnType<typeof vi.fn>;
expect(playMock).toHaveBeenCalledTimes(1);
expect(pauseMock).toHaveBeenCalled();
expect(playMock.mock.invocationCallOrder[0]).toBeGreaterThan(
pauseMock.mock.invocationCallOrder[pauseMock.mock.invocationCallOrder.length - 1],
);
});
it("applies playbackRate to master and siblings on resume", () => {
const master = createMockTimeline({ duration: 10 });
const scene1 = createMockTimeline();
const deps = createMockDeps(master);
deps.getIsPlaying.mockReturnValue(true);
deps.getPlaybackRate.mockReturnValue(2);
const player = createRuntimePlayer({
...deps,
getTimelineRegistry: () => ({ main: master, scene1 }),
});
player.seek(3, { keepPlaying: true });
expect(master.timeScale).toHaveBeenCalledWith(2);
expect(scene1.timeScale).toHaveBeenCalledWith(2);
expect(scene1.play).toHaveBeenCalled();
});
it("stays paused when keepPlaying is true but playback was not active", () => {
const timeline = createMockTimeline({ duration: 10 });
const deps = createMockDeps(timeline);
deps.getIsPlaying.mockReturnValue(false);
const player = createRuntimePlayer(deps);
player.seek(3, { keepPlaying: true });
expect(deps.setIsPlaying).toHaveBeenCalledWith(false);
expect(deps.onSyncMedia).toHaveBeenCalledWith(expect.any(Number), false);
expect(deps.onDeterministicPlay).not.toHaveBeenCalled();
expect(deps.onShowNativeVideos).not.toHaveBeenCalled();
});
it("pauses on seek when keepPlaying is false (explicit)", () => {
const timeline = createMockTimeline({ duration: 10 });
const deps = createMockDeps(timeline);
deps.getIsPlaying.mockReturnValue(true);
const player = createRuntimePlayer(deps);
player.seek(3, { keepPlaying: false });
expect(deps.setIsPlaying).toHaveBeenCalledWith(false);
expect(deps.onSyncMedia).toHaveBeenCalledWith(expect.any(Number), false);
expect(deps.onDeterministicPlay).not.toHaveBeenCalled();
});
it("pauses on seek when no options are passed (default behavior unchanged)", () => {
const timeline = createMockTimeline({ duration: 10 });
const deps = createMockDeps(timeline);
deps.getIsPlaying.mockReturnValue(true);
const player = createRuntimePlayer(deps);
player.seek(3);
expect(deps.setIsPlaying).toHaveBeenCalledWith(false);
expect(deps.onSyncMedia).toHaveBeenCalledWith(expect.any(Number), false);
expect(deps.onDeterministicPlay).not.toHaveBeenCalled();
});
});
});
describe("renderSeek", () => {
+20 -3
View File
@@ -144,10 +144,11 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
deps.onRenderFrameSeek(time);
deps.onStatePost(true);
},
seek: (timeSeconds: number) => {
seek: (timeSeconds: number, options?: { keepPlaying?: boolean }) => {
const timeline = deps.getTimeline();
if (!timeline) return;
const safeTime = Math.max(0, Number(timeSeconds) || 0);
const wasPlaying = deps.getIsPlaying();
const quantized = seekMasterAndSiblingTimelinesDeterministically(
deps.getTimelineRegistry?.(),
timeline,
@@ -155,8 +156,24 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
deps.getCanonicalFps(),
);
deps.onDeterministicSeek(quantized);
deps.setIsPlaying(false);
deps.onSyncMedia(quantized, false);
if (options?.keepPlaying && wasPlaying) {
// The deterministic seek helper pauses the master and rearmed siblings.
// Resume them so the caller's playback state survives the seek.
if (typeof timeline.timeScale === "function") {
timeline.timeScale(deps.getPlaybackRate());
}
timeline.play();
forEachSiblingTimeline(deps.getTimelineRegistry?.(), timeline, (tl) => {
if (typeof tl.timeScale === "function") tl.timeScale(deps.getPlaybackRate());
tl.play();
});
deps.onDeterministicPlay();
deps.onShowNativeVideos();
deps.onSyncMedia(quantized, true);
} else {
deps.setIsPlaying(false);
deps.onSyncMedia(quantized, false);
}
deps.onRenderFrameSeek(quantized);
deps.onStatePost(true);
},
+1 -1
View File
@@ -206,7 +206,7 @@ export type RuntimePlayer = {
_timeline: RuntimeTimelineLike | null;
play: () => void;
pause: () => void;
seek: (timeSeconds: number) => void;
seek: (timeSeconds: number, options?: { keepPlaying?: boolean }) => void;
renderSeek: (timeSeconds: number) => void;
getTime: () => number;
getDuration: () => number;