@hyperframes/studio 0.6.47 → 0.6.48
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-B2QGnquo.js +138 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/components/nle/NLEPreview.test.ts +17 -1
- package/src/components/nle/NLEPreview.tsx +58 -8
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +95 -143
- package/src/player/hooks/useTimelinePlayer.ts +26 -27
- package/src/player/lib/playbackSeek.ts +21 -0
- package/dist/assets/hyperframes-player-CWb0VPYD.js +0 -418
- package/dist/assets/index-DpbZouXZ.js +0 -138
package/dist/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>HyperFrames Studio</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-B2QGnquo.js"></script>
|
|
9
9
|
<link rel="stylesheet" crossorigin href="/assets/index-SKRp8mGz.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.48",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"@codemirror/view": "6.40.0",
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"mediabunny": "^1.45.3",
|
|
34
|
-
"@hyperframes/
|
|
35
|
-
"@hyperframes/
|
|
34
|
+
"@hyperframes/player": "0.6.48",
|
|
35
|
+
"@hyperframes/core": "0.6.48"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/react": "19",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"vite": "^6.4.2",
|
|
47
47
|
"vitest": "^3.2.4",
|
|
48
48
|
"zustand": "^5.0.0",
|
|
49
|
-
"@hyperframes/producer": "0.6.
|
|
49
|
+
"@hyperframes/producer": "0.6.48"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"react": "19",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React, { act, createRef } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
-
import { NLEPreview, getPreviewPlayerKey } from "./NLEPreview";
|
|
6
|
+
import { NLEPreview, getPreviewPlayerKey, resolvePreviewStageSize } from "./NLEPreview";
|
|
7
7
|
|
|
8
8
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
9
|
|
|
@@ -133,6 +133,22 @@ describe("getPreviewPlayerKey", () => {
|
|
|
133
133
|
});
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
describe("resolvePreviewStageSize", () => {
|
|
137
|
+
it("fits portrait composition dimensions by height in a narrow viewport", () => {
|
|
138
|
+
expect(resolvePreviewStageSize(512, 402, { width: 1080, height: 1920 }, undefined)).toEqual({
|
|
139
|
+
width: 217.125,
|
|
140
|
+
height: 386,
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("uses composition dimensions ahead of the legacy portrait fallback", () => {
|
|
145
|
+
expect(resolvePreviewStageSize(512, 402, { width: 1920, height: 1080 }, true)).toEqual({
|
|
146
|
+
width: 496,
|
|
147
|
+
height: 279,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
136
152
|
describe("NLEPreview", () => {
|
|
137
153
|
beforeEach(() => {
|
|
138
154
|
globalThis.ResizeObserver = MockResizeObserver as typeof ResizeObserver;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, useCallback, useEffect, useRef, useState, type
|
|
1
|
+
import { memo, useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
|
2
2
|
import { Player } from "../../player";
|
|
3
3
|
import {
|
|
4
4
|
DEFAULT_PREVIEW_ZOOM,
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
15
15
|
interface NLEPreviewProps {
|
|
16
16
|
projectId: string;
|
|
17
|
-
iframeRef:
|
|
17
|
+
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
18
18
|
onIframeLoad: () => void;
|
|
19
19
|
onCompositionLoadingChange?: (loading: boolean) => void;
|
|
20
20
|
portrait?: boolean;
|
|
@@ -37,6 +37,11 @@ const ZOOM_HUD_TIMEOUT_MS = 1200;
|
|
|
37
37
|
const ZOOM_SETTLE_MS = 200;
|
|
38
38
|
const PREVIEW_STAGE_INSET_PX = 16;
|
|
39
39
|
|
|
40
|
+
interface PreviewCompositionSize {
|
|
41
|
+
width: number;
|
|
42
|
+
height: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
40
45
|
function isPreviewAtFit(state: PreviewZoomState): boolean {
|
|
41
46
|
return (
|
|
42
47
|
Math.abs(state.zoomPercent - 100) < 0.5 &&
|
|
@@ -56,14 +61,41 @@ function loadInitialZoom(): PreviewZoomState {
|
|
|
56
61
|
: DEFAULT_PREVIEW_ZOOM;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
|
-
|
|
64
|
+
// fallow-ignore-next-line complexity
|
|
65
|
+
function readPreviewCompositionSize(
|
|
66
|
+
iframe: HTMLIFrameElement | null,
|
|
67
|
+
): PreviewCompositionSize | null {
|
|
68
|
+
try {
|
|
69
|
+
const doc = iframe?.contentDocument;
|
|
70
|
+
const root =
|
|
71
|
+
doc?.querySelector("[data-composition-id][data-width][data-height]") ??
|
|
72
|
+
doc?.querySelector("[data-width][data-height]");
|
|
73
|
+
if (!root) return null;
|
|
74
|
+
const width = Number.parseInt(root.getAttribute("data-width") ?? "", 10);
|
|
75
|
+
const height = Number.parseInt(root.getAttribute("data-height") ?? "", 10);
|
|
76
|
+
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return { width, height };
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function resolvePreviewStageSize(
|
|
60
86
|
viewportWidth: number,
|
|
61
87
|
viewportHeight: number,
|
|
88
|
+
compositionSize: PreviewCompositionSize | null,
|
|
62
89
|
portrait: boolean | undefined,
|
|
63
90
|
): { width: number; height: number } {
|
|
64
91
|
const availableWidth = Math.max(0, viewportWidth - PREVIEW_STAGE_INSET_PX);
|
|
65
92
|
const availableHeight = Math.max(0, viewportHeight - PREVIEW_STAGE_INSET_PX);
|
|
66
|
-
const aspectRatio =
|
|
93
|
+
const aspectRatio =
|
|
94
|
+
compositionSize && compositionSize.width > 0 && compositionSize.height > 0
|
|
95
|
+
? compositionSize.width / compositionSize.height
|
|
96
|
+
: portrait
|
|
97
|
+
? 9 / 16
|
|
98
|
+
: 16 / 9;
|
|
67
99
|
|
|
68
100
|
if (availableWidth === 0 || availableHeight === 0) {
|
|
69
101
|
return { width: 0, height: 0 };
|
|
@@ -95,10 +127,12 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
95
127
|
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
|
|
96
128
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
97
129
|
const stageRef = useRef<HTMLDivElement>(null);
|
|
130
|
+
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
98
131
|
useEffect(() => {
|
|
99
132
|
onStageRef?.(stageRef);
|
|
100
133
|
}, [onStageRef]);
|
|
101
|
-
const [
|
|
134
|
+
const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
|
|
135
|
+
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
|
|
102
136
|
|
|
103
137
|
const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
|
|
104
138
|
const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(() => zoomRef.current);
|
|
@@ -127,14 +161,29 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
127
161
|
|
|
128
162
|
const updateStageSize = () => {
|
|
129
163
|
const rect = viewport.getBoundingClientRect();
|
|
130
|
-
setStageSize(resolvePreviewStageSize(rect.width, rect.height, portrait));
|
|
164
|
+
setStageSize(resolvePreviewStageSize(rect.width, rect.height, compositionSize, portrait));
|
|
131
165
|
};
|
|
132
166
|
|
|
133
167
|
updateStageSize();
|
|
134
168
|
const observer = new ResizeObserver(updateStageSize);
|
|
135
169
|
observer.observe(viewport);
|
|
136
170
|
return () => observer.disconnect();
|
|
137
|
-
}, [portrait]);
|
|
171
|
+
}, [compositionSize, portrait]);
|
|
172
|
+
|
|
173
|
+
const updateCompositionSizeFromPreview = useCallback(() => {
|
|
174
|
+
const next = readPreviewCompositionSize(previewIframeRef.current);
|
|
175
|
+
setCompositionSize((prev) =>
|
|
176
|
+
prev?.width === next?.width && prev?.height === next?.height ? prev : next,
|
|
177
|
+
);
|
|
178
|
+
}, []);
|
|
179
|
+
|
|
180
|
+
const setPreviewIframeRef = useCallback(
|
|
181
|
+
(node: HTMLIFrameElement | null) => {
|
|
182
|
+
previewIframeRef.current = node;
|
|
183
|
+
iframeRef.current = node;
|
|
184
|
+
},
|
|
185
|
+
[iframeRef],
|
|
186
|
+
);
|
|
138
187
|
|
|
139
188
|
const stageSizeRef = useRef(stageSize);
|
|
140
189
|
stageSizeRef.current = stageSize;
|
|
@@ -403,10 +452,11 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
403
452
|
)}
|
|
404
453
|
<Player
|
|
405
454
|
key={activeKey}
|
|
406
|
-
ref={
|
|
455
|
+
ref={setPreviewIframeRef}
|
|
407
456
|
projectId={directUrl ? undefined : projectId}
|
|
408
457
|
directUrl={directUrl}
|
|
409
458
|
onLoad={() => {
|
|
459
|
+
updateCompositionSizeFromPreview();
|
|
410
460
|
onIframeLoad();
|
|
411
461
|
applyInitialZoom();
|
|
412
462
|
}}
|
|
@@ -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();
|
|
@@ -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
|
+
}
|