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;