From 6373e154c6315183c1bea4c63361f26b548bcd5f Mon Sep 17 00:00:00 2001 From: Carlos Alcaraz <193642530+calcarazgre646@users.noreply.github.com> Date: Fri, 15 May 2026 13:23:40 -0300 Subject: [PATCH] fix(runtime): respect keepPlaying option in player seek The runtime player's seek unconditionally pauses on every invocation, so A/E Jump-to-in/out shortcuts (which pass { keepPlaying: true } per PR #842) pause playback in compositions backed by the __player runtime adapter. Only the wrapTimeline path honoured the option. Extend RuntimePlayer.seek and the PlayerAPI contract to accept { keepPlaying?: boolean }. When keepPlaying is set and playback was active, resume the master plus rearmed sibling timelines and emit the play-state events so media and analytics stay in sync. Default behaviour is unchanged when no options are passed. --- packages/core/src/core.types.ts | 2 +- packages/core/src/runtime/init.ts | 2 +- packages/core/src/runtime/player.test.ts | 85 ++++++++++++++++++++++++ packages/core/src/runtime/player.ts | 23 ++++++- packages/core/src/runtime/types.ts | 2 +- 5 files changed, 108 insertions(+), 6 deletions(-) diff --git a/packages/core/src/core.types.ts b/packages/core/src/core.types.ts index fd3cce545..68cae93df 100644 --- a/packages/core/src/core.types.ts +++ b/packages/core/src/core.types.ts @@ -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; diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 7ecc5a594..31d445b0f 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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; diff --git a/packages/core/src/runtime/player.test.ts b/packages/core/src/runtime/player.test.ts index 9ade8ced4..d066b51d3 100644 --- a/packages/core/src/runtime/player.test.ts +++ b/packages/core/src/runtime/player.test.ts @@ -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; + const pauseMock = timeline.pause as ReturnType; + 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", () => { diff --git a/packages/core/src/runtime/player.ts b/packages/core/src/runtime/player.ts index 8da664d5d..9d1ef263b 100644 --- a/packages/core/src/runtime/player.ts +++ b/packages/core/src/runtime/player.ts @@ -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); }, diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index f1c1c3872..2ff901fab 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -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;