@hyperframes/studio 0.6.47 → 0.6.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,6 +25,20 @@ function TimelinePlayerHarness({
25
25
  return null;
26
26
  }
27
27
 
28
+ function renderTimelinePlayerHarness() {
29
+ let api: ReturnType<typeof useTimelinePlayer> | null = null;
30
+ const host = document.createElement("div");
31
+ document.body.append(host);
32
+ const root = createRoot(host);
33
+
34
+ act(() => {
35
+ root.render(React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }));
36
+ });
37
+
38
+ if (!api) throw new Error("useTimelinePlayer did not mount");
39
+ return { api, root };
40
+ }
41
+
28
42
  afterEach(() => {
29
43
  document.body.innerHTML = "";
30
44
  resetPlayerStore();
@@ -35,19 +49,25 @@ function attachIframeAdapter(
35
49
  options: {
36
50
  postMessage?: (message: unknown, targetOrigin: string) => void;
37
51
  timelines?: Record<string, unknown>;
52
+ duration?: number;
38
53
  } = {},
39
54
  ) {
40
55
  const iframe = document.createElement("iframe");
41
56
  let currentTime = 0;
57
+ let playing = false;
42
58
  const adapter = {
43
- play: () => {},
44
- pause: () => {},
59
+ play: vi.fn(() => {
60
+ playing = true;
61
+ }),
62
+ pause: vi.fn(() => {
63
+ playing = false;
64
+ }),
45
65
  seek: (time: number) => {
46
66
  currentTime = time;
47
67
  },
48
68
  getTime: () => currentTime,
49
- getDuration: () => 30,
50
- isPlaying: () => false,
69
+ getDuration: () => options.duration ?? 30,
70
+ isPlaying: () => playing,
51
71
  };
52
72
  Object.defineProperty(iframe, "contentWindow", {
53
73
  value: {
@@ -71,90 +91,77 @@ function attachIframeAdapter(
71
91
  return adapter;
72
92
  }
73
93
 
94
+ function renderAttachedTimelinePlayer() {
95
+ const { api, root } = renderTimelinePlayerHarness();
96
+ const adapter = attachIframeAdapter(api);
97
+ return { api, root, adapter };
98
+ }
99
+
100
+ function setStorePlaying() {
101
+ act(() => {
102
+ usePlayerStore.setState({ isPlaying: true });
103
+ });
104
+ }
105
+
106
+ function seekWithAct(
107
+ api: ReturnType<typeof useTimelinePlayer>,
108
+ time: number,
109
+ options?: { keepPlaying?: boolean },
110
+ ) {
111
+ act(() => {
112
+ api.seek(time, options);
113
+ });
114
+ }
115
+
116
+ function unmountWithAct(root: ReturnType<typeof createRoot>) {
117
+ act(() => {
118
+ root.unmount();
119
+ });
120
+ }
121
+
122
+ function expectStorePlaybackState(
123
+ root: ReturnType<typeof createRoot>,
124
+ expected: { isPlaying: boolean; currentTime: number },
125
+ ) {
126
+ expect(usePlayerStore.getState().isPlaying).toBe(expected.isPlaying);
127
+ expect(usePlayerStore.getState().currentTime).toBe(expected.currentTime);
128
+ unmountWithAct(root);
129
+ }
130
+
74
131
  describe("useTimelinePlayer seek hydration", () => {
75
132
  it("keeps an external seek request until the iframe adapter is ready", () => {
76
- let api: ReturnType<typeof useTimelinePlayer> | null = null;
77
133
  const observedTimes: number[] = [];
78
134
  const unsubscribe = liveTime.subscribe((time) => {
79
135
  observedTimes.push(time);
80
136
  });
81
- const host = document.createElement("div");
82
- document.body.append(host);
83
- const root = createRoot(host);
84
-
85
- act(() => {
86
- root.render(
87
- React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
88
- );
89
- });
137
+ const { api, root } = renderTimelinePlayerHarness();
90
138
 
91
139
  act(() => {
92
140
  usePlayerStore.getState().requestSeek(4.2);
93
141
  });
94
142
 
95
- expect(api).not.toBeNull();
96
143
  expect(usePlayerStore.getState().currentTime).toBe(0);
97
144
  expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
98
145
 
99
- const iframe = document.createElement("iframe");
100
- let currentTime = 0;
101
- const adapter = {
102
- play: () => {},
103
- pause: () => {},
104
- seek: (time: number) => {
105
- currentTime = time;
106
- },
107
- getTime: () => currentTime,
108
- getDuration: () => 30,
109
- isPlaying: () => false,
110
- };
111
- Object.defineProperty(iframe, "contentWindow", {
112
- value: {
113
- __player: adapter,
114
- postMessage: () => {},
115
- scrollTo: () => {},
116
- addEventListener: () => {},
117
- removeEventListener: () => {},
118
- },
119
- configurable: true,
120
- });
121
- Object.defineProperty(iframe, "contentDocument", {
122
- value: document.implementation.createHTMLDocument("preview"),
123
- configurable: true,
124
- });
125
-
126
- act(() => {
127
- api!.iframeRef.current = iframe;
128
- api!.onIframeLoad();
129
- });
146
+ const adapter = attachIframeAdapter(api);
130
147
 
131
- expect(currentTime).toBe(4.2);
148
+ expect(adapter.getTime()).toBe(4.2);
132
149
  expect(usePlayerStore.getState().currentTime).toBe(4.2);
133
150
  expect(usePlayerStore.getState().timelineReady).toBe(true);
134
151
  expect(observedTimes).toContain(4.2);
135
152
 
136
- act(() => {
137
- root.unmount();
138
- });
153
+ unmountWithAct(root);
139
154
  unsubscribe();
140
155
  });
141
156
  });
142
157
 
143
158
  describe("useTimelinePlayer audio controls (#835)", () => {
144
159
  it("applies playback-rate changes immediately and auto-mutes audio above 1x", () => {
145
- let api: ReturnType<typeof useTimelinePlayer> | null = null;
146
- const host = document.createElement("div");
147
- document.body.append(host);
148
- const root = createRoot(host);
160
+ const { api, root } = renderTimelinePlayerHarness();
149
161
  const postMessage = vi.fn();
150
162
  const timeScale = vi.fn();
151
163
 
152
- act(() => {
153
- root.render(
154
- React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
155
- );
156
- });
157
- attachIframeAdapter(api!, {
164
+ attachIframeAdapter(api, {
158
165
  postMessage,
159
166
  timelines: {
160
167
  root: { timeScale },
@@ -202,24 +209,14 @@ describe("useTimelinePlayer audio controls (#835)", () => {
202
209
  "*",
203
210
  );
204
211
 
205
- act(() => {
206
- root.unmount();
207
- });
212
+ unmountWithAct(root);
208
213
  });
209
214
 
210
215
  it("keeps explicit Studio mute active at 1x", () => {
211
- let api: ReturnType<typeof useTimelinePlayer> | null = null;
212
- const host = document.createElement("div");
213
- document.body.append(host);
214
- const root = createRoot(host);
216
+ const { api, root } = renderTimelinePlayerHarness();
215
217
  const postMessage = vi.fn();
216
218
 
217
- act(() => {
218
- root.render(
219
- React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
220
- );
221
- });
222
- attachIframeAdapter(api!, { postMessage });
219
+ attachIframeAdapter(api, { postMessage });
223
220
  postMessage.mockClear();
224
221
 
225
222
  act(() => {
@@ -235,95 +232,50 @@ describe("useTimelinePlayer audio controls (#835)", () => {
235
232
  "*",
236
233
  );
237
234
 
238
- act(() => {
239
- root.unmount();
240
- });
235
+ unmountWithAct(root);
241
236
  });
242
237
  });
243
238
 
244
239
  describe("useTimelinePlayer seek keepPlaying option (#834)", () => {
245
240
  it("default seek() clears isPlaying when the store reports playing", () => {
246
- let api: ReturnType<typeof useTimelinePlayer> | null = null;
247
- const host = document.createElement("div");
248
- document.body.append(host);
249
- const root = createRoot(host);
250
-
251
- act(() => {
252
- root.render(
253
- React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
254
- );
255
- });
256
- attachIframeAdapter(api!);
257
-
258
- act(() => {
259
- usePlayerStore.setState({ isPlaying: true });
260
- });
241
+ const { api, root } = renderAttachedTimelinePlayer();
242
+ setStorePlaying();
261
243
 
262
- act(() => {
263
- api!.seek(5);
264
- });
265
-
266
- expect(usePlayerStore.getState().isPlaying).toBe(false);
267
- expect(usePlayerStore.getState().currentTime).toBe(5);
244
+ seekWithAct(api, 5);
268
245
 
269
- act(() => {
270
- root.unmount();
271
- });
246
+ expectStorePlaybackState(root, { isPlaying: false, currentTime: 5 });
272
247
  });
273
248
 
274
249
  it("seek(time, { keepPlaying: true }) preserves isPlaying=true so A/E shortcuts don't pause the timeline", () => {
275
- let api: ReturnType<typeof useTimelinePlayer> | null = null;
276
- const host = document.createElement("div");
277
- document.body.append(host);
278
- const root = createRoot(host);
279
-
280
- act(() => {
281
- root.render(
282
- React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
283
- );
284
- });
285
- attachIframeAdapter(api!);
250
+ const { api, root, adapter } = renderAttachedTimelinePlayer();
251
+ setStorePlaying();
286
252
 
287
- act(() => {
288
- usePlayerStore.setState({ isPlaying: true });
289
- });
290
-
291
- act(() => {
292
- api!.seek(5, { keepPlaying: true });
293
- });
253
+ seekWithAct(api, 5, { keepPlaying: true });
294
254
 
295
- expect(usePlayerStore.getState().isPlaying).toBe(true);
296
- expect(usePlayerStore.getState().currentTime).toBe(5);
297
-
298
- act(() => {
299
- root.unmount();
300
- });
255
+ expect(adapter.play).toHaveBeenCalledTimes(1);
256
+ expectStorePlaybackState(root, { isPlaying: true, currentTime: 5 });
301
257
  });
302
258
 
303
259
  it("seek(time, { keepPlaying: true }) from paused state stays paused (no spurious resume)", () => {
304
- let api: ReturnType<typeof useTimelinePlayer> | null = null;
305
- const host = document.createElement("div");
306
- document.body.append(host);
307
- const root = createRoot(host);
308
-
309
- act(() => {
310
- root.render(
311
- React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
312
- );
313
- });
314
- attachIframeAdapter(api!);
260
+ const { api, root } = renderAttachedTimelinePlayer();
315
261
 
316
262
  expect(usePlayerStore.getState().isPlaying).toBe(false);
317
263
 
318
- act(() => {
319
- api!.seek(5, { keepPlaying: true });
320
- });
264
+ seekWithAct(api, 5, { keepPlaying: true });
321
265
 
322
- expect(usePlayerStore.getState().isPlaying).toBe(false);
323
- expect(usePlayerStore.getState().currentTime).toBe(5);
266
+ expectStorePlaybackState(root, { isPlaying: false, currentTime: 5 });
267
+ });
324
268
 
325
- act(() => {
326
- root.unmount();
327
- });
269
+ it("seek(time, { keepPlaying: true }) restarts playback when the iframe adapter was paused", () => {
270
+ const { api, root, adapter } = renderAttachedTimelinePlayer();
271
+ setStorePlaying();
272
+
273
+ expect(adapter.isPlaying()).toBe(false);
274
+
275
+ seekWithAct(api, 0, { keepPlaying: true });
276
+
277
+ expect(adapter.play).toHaveBeenCalledTimes(1);
278
+ expect(adapter.isPlaying()).toBe(true);
279
+ expectStorePlaybackState(root, { isPlaying: true, currentTime: 0 });
328
280
  });
329
281
  });
@@ -4,19 +4,16 @@ import { useMountEffect } from "../../hooks/useMountEffect";
4
4
  import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
5
5
  import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
6
6
 
7
- // Re-export public API consumed by tests and external modules.
8
- // All of these were previously defined in this file; they now live in focused
9
- // sub-modules but are re-exported here so existing import sites don't change.
10
7
  export type { ClipManifestClip } from "../lib/playbackTypes";
11
8
  export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
12
9
  export {
13
- getTimelineElementSelector,
14
- readTimelineDurationFromDocument,
15
- parseTimelineFromDOM,
10
+ buildStandaloneRootTimelineElement,
16
11
  createTimelineElementFromManifestClip,
17
12
  findTimelineDomNodeForClip,
18
- buildStandaloneRootTimelineElement,
13
+ getTimelineElementSelector,
19
14
  mergeTimelineElementsPreservingDowngrades,
15
+ parseTimelineFromDOM,
16
+ readTimelineDurationFromDocument,
20
17
  resolveStandaloneRootCompositionSrc,
21
18
  resolveIframe,
22
19
  } from "../lib/timelineDOM";
@@ -43,10 +40,7 @@ import {
43
40
  shouldMutePreviewAudio,
44
41
  } from "../lib/timelineIframeHelpers";
45
42
  import { probeMediaUrl, getCachedProbe } from "../lib/mediaProbe";
46
-
47
- // ---------------------------------------------------------------------------
48
- // Hook
49
- // ---------------------------------------------------------------------------
43
+ import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
50
44
 
51
45
  export function useTimelinePlayer() {
52
46
  const iframeRef = useRef<HTMLIFrameElement | null>(null);
@@ -65,8 +59,6 @@ export function useTimelinePlayer() {
65
59
  adapter: PlaybackAdapter;
66
60
  } | null>(null);
67
61
 
68
- // ZERO store subscriptions — this hook never causes re-renders.
69
- // All reads use getState() (point-in-time), all writes use the stable setters.
70
62
  const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
71
63
  usePlayerStore.getState();
72
64
 
@@ -383,8 +375,6 @@ export function useTimelinePlayer() {
383
375
  }, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
384
376
  const seek = useCallback(
385
377
  (time: number, options?: { keepPlaying?: boolean }) => {
386
- // Reverse shuttle is always stopped: the RAF reverse tick can't survive
387
- // a seek anyway, so `keepPlaying` only preserves forward playback.
388
378
  const wasReverseShuttle = shuttleDirectionRef.current === "backward";
389
379
  stopReverseLoop();
390
380
  const adapter = getAdapter();
@@ -394,10 +384,27 @@ export function useTimelinePlayer() {
394
384
  }
395
385
  const duration = Math.max(0, adapter.getDuration());
396
386
  const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
387
+ const keepPlaying = options?.keepPlaying === true;
388
+ const shouldResumeAfterSeek = shouldResumeForwardPlaybackAfterSeek({
389
+ keepPlaying,
390
+ wasReverseShuttle,
391
+ storeWasPlaying: usePlayerStore.getState().isPlaying,
392
+ duration,
393
+ nextTime,
394
+ });
397
395
  adapter.seek(nextTime, options);
398
396
  liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
399
397
  setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
400
- if (!options?.keepPlaying || wasReverseShuttle) {
398
+ if (shouldResumeAfterSeek) {
399
+ stopRAFLoop();
400
+ applyPlaybackRate(usePlayerStore.getState().playbackRate);
401
+ applyPreviewAudioState();
402
+ adapter.play();
403
+ setIsPlaying(true);
404
+ shuttleDirectionRef.current = "forward";
405
+ shuttleSpeedIndexRef.current = 0;
406
+ startRAFLoop();
407
+ } else if (shouldStopAfterSeek({ keepPlaying, wasReverseShuttle })) {
401
408
  stopRAFLoop();
402
409
  if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
403
410
  shuttleDirectionRef.current = null;
@@ -410,14 +417,16 @@ export function useTimelinePlayer() {
410
417
  pendingSeekRef,
411
418
  setCurrentTime,
412
419
  setIsPlaying,
420
+ startRAFLoop,
413
421
  stopRAFLoop,
414
422
  stopReverseLoop,
423
+ applyPlaybackRate,
424
+ applyPreviewAudioState,
415
425
  shuttleDirectionRef,
416
426
  shuttleSpeedIndexRef,
417
427
  ],
418
428
  );
419
429
 
420
- // Handle seek requests from outside the player loop (e.g. LayersPanel).
421
430
  useEffect(() => {
422
431
  return usePlayerStore.subscribe((state, prev) => {
423
432
  if (state.requestedSeekTime !== null && state.requestedSeekTime !== prev.requestedSeekTime) {
@@ -480,12 +489,8 @@ export function useTimelinePlayer() {
480
489
  const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
481
490
  const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
482
491
 
483
- // Listen for timeline messages from the iframe runtime.
484
- // The runtime sends this AFTER all external compositions load,
485
- // so we get the complete clip list (not just the first few).
486
492
  const handleMessage = (e: MessageEvent) => {
487
493
  const data = e.data;
488
- // Only process messages from the main preview iframe — ignore MediaPanel/ClipThumbnail iframes
489
494
  const ourIframe = iframeRef.current;
490
495
  if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
491
496
  return;
@@ -499,10 +504,6 @@ export function useTimelinePlayer() {
499
504
  processTimelineMessageRef.current(manifest);
500
505
  }
501
506
  }
502
- // Enrich only when the timeline has settled — skip during the window
503
- // right after a "timeline" message to avoid the enrichment adding
504
- // elements that fight with the manifest's authoritative element list,
505
- // causing duration oscillation.
506
507
  const msSinceTimeline = Date.now() - lastTimelineMessageRef.current;
507
508
  if (msSinceTimeline > 500) {
508
509
  enrichMissingCompositionsRef.current();
@@ -535,7 +536,6 @@ export function useTimelinePlayer() {
535
536
  }
536
537
  };
537
538
 
538
- // Pause video when tab loses focus
539
539
  const handleVisibilityChange = () => {
540
540
  if (document.hidden && usePlayerStore.getState().isPlaying) {
541
541
  const adapter = getAdapterRef.current?.();
@@ -564,7 +564,6 @@ export function useTimelinePlayer() {
564
564
  };
565
565
  });
566
566
 
567
- /** Reset the player store (elements, duration, etc.) — call when switching sessions. */
568
567
  const resetPlayer = useCallback(() => {
569
568
  stopRAFLoop();
570
569
  stopReverseLoop();
@@ -1,6 +1,10 @@
1
1
  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";
4
8
 
5
9
  describe("wrapTimeline seek keepPlaying option (#834)", () => {
6
10
  function mockTimeline(): TimelineLike & {
@@ -48,3 +52,162 @@ describe("wrapTimeline seek keepPlaying option (#834)", () => {
48
52
  expect(tl.seek).toHaveBeenCalledWith(5);
49
53
  });
50
54
  });
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
+ });
@@ -113,12 +113,20 @@ export function createStaticSeekPlaybackAdapter(
113
113
  playing = false;
114
114
  stopTicker();
115
115
  },
116
- seek: (time) => {
116
+ seek: (time, options) => {
117
117
  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;
121
124
  }
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();
122
130
  },
123
131
  getTime: () => currentTime,
124
132
  getDuration: () => safeDuration,
@@ -0,0 +1,21 @@
1
+ export function shouldResumeForwardPlaybackAfterSeek(input: {
2
+ keepPlaying: boolean;
3
+ wasReverseShuttle: boolean;
4
+ storeWasPlaying: boolean;
5
+ duration: number;
6
+ nextTime: number;
7
+ }): boolean {
8
+ return (
9
+ input.keepPlaying &&
10
+ !input.wasReverseShuttle &&
11
+ input.storeWasPlaying &&
12
+ (input.duration <= 0 || input.nextTime < input.duration)
13
+ );
14
+ }
15
+
16
+ export function shouldStopAfterSeek(input: {
17
+ keepPlaying: boolean;
18
+ wasReverseShuttle: boolean;
19
+ }): boolean {
20
+ return !input.keepPlaying || input.wasReverseShuttle;
21
+ }