@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.
- package/dist/assets/hyperframes-player-BP6jGdt0.js +418 -0
- package/dist/assets/index-B4Cr7MVx.js +138 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/components/editor/DomEditOverlay.test.ts +3 -2
- package/src/components/editor/DomEditOverlay.tsx +4 -7
- package/src/components/editor/LayersPanel.tsx +8 -7
- package/src/components/editor/domEditing.test.ts +58 -43
- package/src/components/editor/domEditingLayers.ts +56 -5
- package/src/components/editor/useDomEditOverlayGestures.ts +1 -1
- package/src/components/nle/NLEPreview.test.ts +17 -1
- package/src/components/nle/NLEPreview.tsx +58 -8
- package/src/hooks/useDomEditCommits.ts +10 -1
- package/src/hooks/useDomEditSession.ts +4 -4
- package/src/hooks/useDomEditTextCommits.ts +3 -3
- package/src/hooks/useDomSelection.ts +28 -16
- package/src/hooks/usePreviewInteraction.ts +7 -6
- package/src/hooks/useStudioUrlState.ts +4 -3
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +95 -143
- package/src/player/hooks/useTimelinePlayer.ts +26 -27
- package/src/player/lib/playbackAdapter.test.ts +165 -2
- package/src/player/lib/playbackAdapter.ts +12 -4
- package/src/player/lib/playbackSeek.ts +21 -0
- package/src/utils/studioUrlState.test.ts +6 -4
- package/dist/assets/hyperframes-player-CWb0VPYD.js +0 -418
- package/dist/assets/index-DpbZouXZ.js +0 -138
|
@@ -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
|
-
|
|
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: () =>
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
root.unmount();
|
|
207
|
-
});
|
|
212
|
+
unmountWithAct(root);
|
|
208
213
|
});
|
|
209
214
|
|
|
210
215
|
it("keeps explicit Studio mute active at 1x", () => {
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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(
|
|
296
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
api!.seek(5, { keepPlaying: true });
|
|
320
|
-
});
|
|
264
|
+
seekWithAct(api, 5, { keepPlaying: true });
|
|
321
265
|
|
|
322
|
-
|
|
323
|
-
|
|
266
|
+
expectStorePlaybackState(root, { isPlaying: false, currentTime: 5 });
|
|
267
|
+
});
|
|
324
268
|
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
14
|
-
readTimelineDurationFromDocument,
|
|
15
|
-
parseTimelineFromDOM,
|
|
10
|
+
buildStandaloneRootTimelineElement,
|
|
16
11
|
createTimelineElementFromManifestClip,
|
|
17
12
|
findTimelineDomNodeForClip,
|
|
18
|
-
|
|
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 (
|
|
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 {
|
|
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 (
|
|
119
|
-
|
|
120
|
-
|
|
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
|
+
}
|