@hyperframes/studio 0.6.5 → 0.6.7
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-CzwFysqv.js → hyperframes-player-D0Yi3xMP.js} +2 -2
- package/dist/assets/index-Ckqo37Co.css +1 -0
- package/dist/assets/index-Yvtxngdi.js +116 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +54 -31
- package/src/components/StudioGlobalDragOverlay.tsx +26 -0
- package/src/components/StudioHeader.tsx +128 -3
- package/src/components/StudioRightPanel.tsx +0 -2
- package/src/components/editor/DomEditOverlay.test.ts +1 -0
- package/src/components/editor/DomEditOverlay.tsx +2 -1
- package/src/components/editor/PropertyPanel.tsx +27 -36
- package/src/components/editor/domEditingElement.ts +1 -0
- package/src/components/editor/manualEdits.test.ts +39 -466
- package/src/components/editor/manualEdits.ts +6 -168
- package/src/components/editor/manualEditsDom.ts +361 -1
- package/src/components/editor/manualEditsParsing.ts +2 -240
- package/src/components/editor/manualEditsTypes.ts +1 -40
- package/src/components/editor/useDomEditOverlayGestures.ts +25 -8
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/sidebar/CompositionsTab.tsx +9 -3
- package/src/contexts/DomEditContext.tsx +3 -0
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/hooks/useAppHotkeys.ts +1 -4
- package/src/hooks/useDomEditCommits.ts +82 -77
- package/src/hooks/useDomEditSession.ts +4 -16
- package/src/hooks/useFileManager.ts +10 -1
- package/src/hooks/useManifestPersistence.ts +51 -187
- package/src/hooks/usePanelLayout.ts +10 -3
- package/src/hooks/usePreviewInteraction.ts +0 -1
- package/src/hooks/useStudioUrlState.ts +188 -0
- package/src/player/components/Player.tsx +15 -1
- package/src/player/components/PlayerControls.test.ts +17 -0
- package/src/player/components/PlayerControls.tsx +347 -56
- package/src/player/hooks/usePlaybackKeyboard.test.ts +174 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +37 -10
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +329 -0
- package/src/player/hooks/useTimelinePlayer.ts +97 -28
- package/src/player/hooks/useTimelineSyncCallbacks.ts +10 -4
- package/src/player/lib/playbackAdapter.test.ts +50 -0
- package/src/player/lib/playbackAdapter.ts +2 -2
- package/src/player/lib/playbackTypes.ts +1 -1
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineIframeHelpers.ts +63 -7
- package/src/player/store/playerStore.test.ts +105 -1
- package/src/player/store/playerStore.ts +39 -1
- package/src/utils/projectRouting.test.ts +15 -0
- package/src/utils/projectRouting.ts +46 -9
- package/src/utils/sourcePatcher.ts +50 -14
- package/src/utils/studioPreviewHelpers.test.ts +56 -0
- package/src/utils/studioPreviewHelpers.ts +51 -13
- package/src/utils/studioUiPreferences.test.ts +3 -0
- package/src/utils/studioUiPreferences.ts +4 -0
- package/src/utils/studioUrlState.test.ts +249 -0
- package/src/utils/studioUrlState.ts +135 -0
- package/dist/assets/index-Bs6NmE0o.js +0 -117
- package/dist/assets/index-Dswa2GJ2.css +0 -1
|
@@ -22,7 +22,7 @@ interface UsePlaybackKeyboardParams {
|
|
|
22
22
|
play: () => void;
|
|
23
23
|
playBackward: (rate: number) => void;
|
|
24
24
|
pause: () => void;
|
|
25
|
-
seek: (time: number) => void;
|
|
25
|
+
seek: (time: number, options?: { keepPlaying?: boolean }) => void;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
export function usePlaybackKeyboard({
|
|
@@ -36,7 +36,7 @@ export function usePlaybackKeyboard({
|
|
|
36
36
|
pause,
|
|
37
37
|
seek,
|
|
38
38
|
}: UsePlaybackKeyboardParams) {
|
|
39
|
-
const
|
|
39
|
+
const pressedKeysRef = useRef(new Set<string>());
|
|
40
40
|
const playbackKeyDownRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
|
41
41
|
const playbackKeyUpRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
|
42
42
|
|
|
@@ -90,7 +90,8 @@ export function usePlaybackKeyboard({
|
|
|
90
90
|
) {
|
|
91
91
|
return;
|
|
92
92
|
}
|
|
93
|
-
|
|
93
|
+
const key = e.key.toLowerCase();
|
|
94
|
+
pressedKeysRef.current.add(key);
|
|
94
95
|
if (e.code === "Space") {
|
|
95
96
|
e.preventDefault();
|
|
96
97
|
togglePlay();
|
|
@@ -107,34 +108,60 @@ export function usePlaybackKeyboard({
|
|
|
107
108
|
return;
|
|
108
109
|
}
|
|
109
110
|
if (e.repeat) return;
|
|
110
|
-
if (
|
|
111
|
+
if (key === "k") {
|
|
111
112
|
e.preventDefault();
|
|
112
113
|
pause();
|
|
113
114
|
return;
|
|
114
115
|
}
|
|
115
|
-
if (
|
|
116
|
+
if (key === "j") {
|
|
116
117
|
e.preventDefault();
|
|
117
|
-
if (
|
|
118
|
+
if (pressedKeysRef.current.has("k")) {
|
|
118
119
|
stepFrames(-1);
|
|
119
120
|
return;
|
|
120
121
|
}
|
|
121
122
|
shuttle("backward");
|
|
122
123
|
return;
|
|
123
124
|
}
|
|
124
|
-
if (
|
|
125
|
+
if (key === "l") {
|
|
125
126
|
e.preventDefault();
|
|
126
|
-
if (
|
|
127
|
+
if (pressedKeysRef.current.has("k")) {
|
|
127
128
|
stepFrames(1);
|
|
128
129
|
return;
|
|
129
130
|
}
|
|
130
131
|
shuttle("forward");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (key === "i") {
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
const t = getAdapter()?.getTime() ?? usePlayerStore.getState().currentTime;
|
|
137
|
+
usePlayerStore.getState().setInPoint(e.shiftKey ? null : t);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (key === "o") {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
const t = getAdapter()?.getTime() ?? usePlayerStore.getState().currentTime;
|
|
143
|
+
usePlayerStore.getState().setOutPoint(e.shiftKey ? null : t);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (key === "a") {
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
seek(usePlayerStore.getState().inPoint ?? 0, { keepPlaying: true });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (key === "e") {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
const { outPoint } = usePlayerStore.getState();
|
|
154
|
+
seek(outPoint ?? getAdapter()?.getDuration() ?? usePlayerStore.getState().duration, {
|
|
155
|
+
keepPlaying: true,
|
|
156
|
+
});
|
|
157
|
+
return;
|
|
131
158
|
}
|
|
132
159
|
},
|
|
133
|
-
[pause, shuttle, stepFrames, togglePlay],
|
|
160
|
+
[pause, shuttle, stepFrames, togglePlay, getAdapter, seek],
|
|
134
161
|
);
|
|
135
162
|
|
|
136
163
|
const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
|
|
137
|
-
|
|
164
|
+
pressedKeysRef.current.delete(e.key.toLowerCase());
|
|
138
165
|
}, []);
|
|
139
166
|
|
|
140
167
|
playbackKeyDownRef.current = handlePlaybackKeyDown;
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act, useEffect } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { useTimelinePlayer } from "./useTimelinePlayer";
|
|
7
|
+
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
8
|
+
|
|
9
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
function resetPlayerStore() {
|
|
12
|
+
usePlayerStore.getState().reset();
|
|
13
|
+
usePlayerStore.setState({ requestedSeekTime: null });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function TimelinePlayerHarness({
|
|
17
|
+
onValue,
|
|
18
|
+
}: {
|
|
19
|
+
onValue: (value: ReturnType<typeof useTimelinePlayer>) => void;
|
|
20
|
+
}) {
|
|
21
|
+
const value = useTimelinePlayer();
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
onValue(value);
|
|
24
|
+
}, [onValue, value]);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
document.body.innerHTML = "";
|
|
30
|
+
resetPlayerStore();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function attachIframeAdapter(
|
|
34
|
+
api: ReturnType<typeof useTimelinePlayer>,
|
|
35
|
+
options: {
|
|
36
|
+
postMessage?: (message: unknown, targetOrigin: string) => void;
|
|
37
|
+
timelines?: Record<string, unknown>;
|
|
38
|
+
} = {},
|
|
39
|
+
) {
|
|
40
|
+
const iframe = document.createElement("iframe");
|
|
41
|
+
let currentTime = 0;
|
|
42
|
+
const adapter = {
|
|
43
|
+
play: () => {},
|
|
44
|
+
pause: () => {},
|
|
45
|
+
seek: (time: number) => {
|
|
46
|
+
currentTime = time;
|
|
47
|
+
},
|
|
48
|
+
getTime: () => currentTime,
|
|
49
|
+
getDuration: () => 30,
|
|
50
|
+
isPlaying: () => false,
|
|
51
|
+
};
|
|
52
|
+
Object.defineProperty(iframe, "contentWindow", {
|
|
53
|
+
value: {
|
|
54
|
+
__player: adapter,
|
|
55
|
+
__timelines: options.timelines,
|
|
56
|
+
postMessage: options.postMessage ?? (() => {}),
|
|
57
|
+
scrollTo: () => {},
|
|
58
|
+
addEventListener: () => {},
|
|
59
|
+
removeEventListener: () => {},
|
|
60
|
+
},
|
|
61
|
+
configurable: true,
|
|
62
|
+
});
|
|
63
|
+
Object.defineProperty(iframe, "contentDocument", {
|
|
64
|
+
value: document.implementation.createHTMLDocument("preview"),
|
|
65
|
+
configurable: true,
|
|
66
|
+
});
|
|
67
|
+
act(() => {
|
|
68
|
+
api.iframeRef.current = iframe;
|
|
69
|
+
api.onIframeLoad();
|
|
70
|
+
});
|
|
71
|
+
return adapter;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe("useTimelinePlayer seek hydration", () => {
|
|
75
|
+
it("keeps an external seek request until the iframe adapter is ready", () => {
|
|
76
|
+
let api: ReturnType<typeof useTimelinePlayer> | null = null;
|
|
77
|
+
const observedTimes: number[] = [];
|
|
78
|
+
const unsubscribe = liveTime.subscribe((time) => {
|
|
79
|
+
observedTimes.push(time);
|
|
80
|
+
});
|
|
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
|
+
});
|
|
90
|
+
|
|
91
|
+
act(() => {
|
|
92
|
+
usePlayerStore.getState().requestSeek(4.2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(api).not.toBeNull();
|
|
96
|
+
expect(usePlayerStore.getState().currentTime).toBe(0);
|
|
97
|
+
expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
|
|
98
|
+
|
|
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
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(currentTime).toBe(4.2);
|
|
132
|
+
expect(usePlayerStore.getState().currentTime).toBe(4.2);
|
|
133
|
+
expect(usePlayerStore.getState().timelineReady).toBe(true);
|
|
134
|
+
expect(observedTimes).toContain(4.2);
|
|
135
|
+
|
|
136
|
+
act(() => {
|
|
137
|
+
root.unmount();
|
|
138
|
+
});
|
|
139
|
+
unsubscribe();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("useTimelinePlayer audio controls (#835)", () => {
|
|
144
|
+
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);
|
|
149
|
+
const postMessage = vi.fn();
|
|
150
|
+
const timeScale = vi.fn();
|
|
151
|
+
|
|
152
|
+
act(() => {
|
|
153
|
+
root.render(
|
|
154
|
+
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
attachIframeAdapter(api!, {
|
|
158
|
+
postMessage,
|
|
159
|
+
timelines: {
|
|
160
|
+
root: { timeScale },
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
postMessage.mockClear();
|
|
164
|
+
timeScale.mockClear();
|
|
165
|
+
|
|
166
|
+
act(() => {
|
|
167
|
+
usePlayerStore.getState().setAudioMuted(false);
|
|
168
|
+
usePlayerStore.getState().setPlaybackRate(2);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(postMessage).toHaveBeenCalledWith(
|
|
172
|
+
expect.objectContaining({
|
|
173
|
+
source: "hf-parent",
|
|
174
|
+
type: "control",
|
|
175
|
+
action: "set-playback-rate",
|
|
176
|
+
playbackRate: 2,
|
|
177
|
+
}),
|
|
178
|
+
"*",
|
|
179
|
+
);
|
|
180
|
+
expect(postMessage).toHaveBeenCalledWith(
|
|
181
|
+
expect.objectContaining({
|
|
182
|
+
source: "hf-parent",
|
|
183
|
+
type: "control",
|
|
184
|
+
action: "set-muted",
|
|
185
|
+
muted: true,
|
|
186
|
+
}),
|
|
187
|
+
"*",
|
|
188
|
+
);
|
|
189
|
+
expect(timeScale).toHaveBeenCalledWith(2);
|
|
190
|
+
|
|
191
|
+
postMessage.mockClear();
|
|
192
|
+
|
|
193
|
+
act(() => {
|
|
194
|
+
usePlayerStore.getState().setPlaybackRate(1);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(postMessage).toHaveBeenCalledWith(
|
|
198
|
+
expect.objectContaining({
|
|
199
|
+
action: "set-muted",
|
|
200
|
+
muted: false,
|
|
201
|
+
}),
|
|
202
|
+
"*",
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
act(() => {
|
|
206
|
+
root.unmount();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
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);
|
|
215
|
+
const postMessage = vi.fn();
|
|
216
|
+
|
|
217
|
+
act(() => {
|
|
218
|
+
root.render(
|
|
219
|
+
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
attachIframeAdapter(api!, { postMessage });
|
|
223
|
+
postMessage.mockClear();
|
|
224
|
+
|
|
225
|
+
act(() => {
|
|
226
|
+
usePlayerStore.getState().setPlaybackRate(1);
|
|
227
|
+
usePlayerStore.getState().setAudioMuted(true);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(postMessage).toHaveBeenCalledWith(
|
|
231
|
+
expect.objectContaining({
|
|
232
|
+
action: "set-muted",
|
|
233
|
+
muted: true,
|
|
234
|
+
}),
|
|
235
|
+
"*",
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
act(() => {
|
|
239
|
+
root.unmount();
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("useTimelinePlayer seek keepPlaying option (#834)", () => {
|
|
245
|
+
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
|
+
});
|
|
261
|
+
|
|
262
|
+
act(() => {
|
|
263
|
+
api!.seek(5);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
267
|
+
expect(usePlayerStore.getState().currentTime).toBe(5);
|
|
268
|
+
|
|
269
|
+
act(() => {
|
|
270
|
+
root.unmount();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
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!);
|
|
286
|
+
|
|
287
|
+
act(() => {
|
|
288
|
+
usePlayerStore.setState({ isPlaying: true });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
act(() => {
|
|
292
|
+
api!.seek(5, { keepPlaying: true });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
296
|
+
expect(usePlayerStore.getState().currentTime).toBe(5);
|
|
297
|
+
|
|
298
|
+
act(() => {
|
|
299
|
+
root.unmount();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
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!);
|
|
315
|
+
|
|
316
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
317
|
+
|
|
318
|
+
act(() => {
|
|
319
|
+
api!.seek(5, { keepPlaying: true });
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
323
|
+
expect(usePlayerStore.getState().currentTime).toBe(5);
|
|
324
|
+
|
|
325
|
+
act(() => {
|
|
326
|
+
root.unmount();
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -37,7 +37,11 @@ import {
|
|
|
37
37
|
mergeTimelineElementsPreservingDowngrades,
|
|
38
38
|
parseTimelineFromDOM,
|
|
39
39
|
} from "../lib/timelineDOM";
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
setPreviewMediaMuted,
|
|
42
|
+
setPreviewPlaybackRate,
|
|
43
|
+
shouldMutePreviewAudio,
|
|
44
|
+
} from "../lib/timelineIframeHelpers";
|
|
41
45
|
|
|
42
46
|
// ---------------------------------------------------------------------------
|
|
43
47
|
// Hook
|
|
@@ -185,15 +189,21 @@ export function useTimelinePlayer() {
|
|
|
185
189
|
const time = adapter.getTime();
|
|
186
190
|
const dur = adapter.getDuration();
|
|
187
191
|
liveTime.notify(time); // direct DOM updates, no React re-render
|
|
188
|
-
|
|
192
|
+
const { inPoint, outPoint } = usePlayerStore.getState();
|
|
193
|
+
const rawLoopEnd = outPoint !== null ? outPoint : dur;
|
|
194
|
+
const rawLoopStart = inPoint !== null ? inPoint : 0;
|
|
195
|
+
const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : dur;
|
|
196
|
+
const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
|
|
197
|
+
if (time >= loopEnd) {
|
|
189
198
|
if (usePlayerStore.getState().loopEnabled && dur > 0) {
|
|
190
|
-
adapter.seek(
|
|
191
|
-
liveTime.notify(
|
|
199
|
+
adapter.seek(loopStart);
|
|
200
|
+
liveTime.notify(loopStart);
|
|
192
201
|
adapter.play();
|
|
193
202
|
setIsPlaying(true);
|
|
194
203
|
rafRef.current = requestAnimationFrame(tick);
|
|
195
204
|
return;
|
|
196
205
|
}
|
|
206
|
+
if (adapter.isPlaying()) adapter.pause();
|
|
197
207
|
setCurrentTime(time); // sync Zustand once at end
|
|
198
208
|
setIsPlaying(false);
|
|
199
209
|
cancelAnimationFrame(rafRef.current);
|
|
@@ -212,11 +222,7 @@ export function useTimelinePlayer() {
|
|
|
212
222
|
const applyPlaybackRate = useCallback((rate: number) => {
|
|
213
223
|
const iframe = iframeRef.current;
|
|
214
224
|
if (!iframe) return;
|
|
215
|
-
|
|
216
|
-
iframe.contentWindow?.postMessage(
|
|
217
|
-
{ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate },
|
|
218
|
-
"*",
|
|
219
|
-
);
|
|
225
|
+
setPreviewPlaybackRate(iframe, rate);
|
|
220
226
|
// Also set directly on GSAP timeline if accessible
|
|
221
227
|
try {
|
|
222
228
|
const win = iframe.contentWindow as IframeWindow | null;
|
|
@@ -235,21 +241,38 @@ export function useTimelinePlayer() {
|
|
|
235
241
|
}
|
|
236
242
|
}, []);
|
|
237
243
|
|
|
244
|
+
const applyPreviewAudioState = useCallback((playbackRateOverride?: number) => {
|
|
245
|
+
const { audioMuted, playbackRate } = usePlayerStore.getState();
|
|
246
|
+
const effectivePlaybackRate = playbackRateOverride ?? playbackRate;
|
|
247
|
+
setPreviewMediaMuted(
|
|
248
|
+
iframeRef.current,
|
|
249
|
+
shouldMutePreviewAudio(audioMuted, effectivePlaybackRate),
|
|
250
|
+
);
|
|
251
|
+
}, []);
|
|
252
|
+
|
|
238
253
|
const play = useCallback(() => {
|
|
239
254
|
stopRAFLoop();
|
|
240
255
|
stopReverseLoop();
|
|
241
256
|
const adapter = getAdapter();
|
|
242
257
|
if (!adapter) return;
|
|
243
258
|
if (adapter.getTime() >= adapter.getDuration()) {
|
|
244
|
-
adapter.seek(0);
|
|
259
|
+
adapter.seek(usePlayerStore.getState().inPoint ?? 0);
|
|
245
260
|
}
|
|
246
|
-
unmutePreviewMedia(iframeRef.current);
|
|
247
261
|
applyPlaybackRate(usePlayerStore.getState().playbackRate);
|
|
262
|
+
applyPreviewAudioState();
|
|
248
263
|
adapter.play();
|
|
249
264
|
shuttleDirectionRef.current = "forward";
|
|
250
265
|
setIsPlaying(true);
|
|
251
266
|
startRAFLoop();
|
|
252
|
-
}, [
|
|
267
|
+
}, [
|
|
268
|
+
getAdapter,
|
|
269
|
+
setIsPlaying,
|
|
270
|
+
startRAFLoop,
|
|
271
|
+
applyPlaybackRate,
|
|
272
|
+
applyPreviewAudioState,
|
|
273
|
+
stopRAFLoop,
|
|
274
|
+
stopReverseLoop,
|
|
275
|
+
]);
|
|
253
276
|
|
|
254
277
|
const playBackward = useCallback(
|
|
255
278
|
(rate: number) => {
|
|
@@ -261,23 +284,29 @@ export function useTimelinePlayer() {
|
|
|
261
284
|
const initialTime = adapter.getTime() <= 0 && duration > 0 ? duration : adapter.getTime();
|
|
262
285
|
adapter.pause();
|
|
263
286
|
if (initialTime !== adapter.getTime()) adapter.seek(initialTime);
|
|
264
|
-
unmutePreviewMedia(iframeRef.current);
|
|
265
287
|
const speed = Math.max(0.1, Math.min(4, rate));
|
|
288
|
+
applyPlaybackRate(speed);
|
|
289
|
+
applyPreviewAudioState(speed);
|
|
266
290
|
let startTime = initialTime;
|
|
267
291
|
let startedAt = performance.now();
|
|
268
292
|
|
|
269
293
|
const tick = (now: number) => {
|
|
270
294
|
const elapsed = ((now - startedAt) / 1000) * speed;
|
|
271
295
|
let nextTime = startTime - elapsed;
|
|
272
|
-
|
|
296
|
+
const { inPoint, outPoint } = usePlayerStore.getState();
|
|
297
|
+
const rawLoopEnd = outPoint !== null ? outPoint : duration;
|
|
298
|
+
const rawLoopStart = inPoint !== null ? inPoint : 0;
|
|
299
|
+
const loopEnd = rawLoopStart < rawLoopEnd ? rawLoopEnd : duration;
|
|
300
|
+
const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
|
|
301
|
+
if (nextTime <= loopStart) {
|
|
273
302
|
if (usePlayerStore.getState().loopEnabled && duration > 0) {
|
|
274
|
-
startTime =
|
|
303
|
+
startTime = loopEnd;
|
|
275
304
|
startedAt = now;
|
|
276
|
-
nextTime =
|
|
305
|
+
nextTime = loopEnd;
|
|
277
306
|
} else {
|
|
278
|
-
adapter.seek(
|
|
279
|
-
liveTime.notify(
|
|
280
|
-
setCurrentTime(
|
|
307
|
+
adapter.seek(loopStart);
|
|
308
|
+
liveTime.notify(loopStart);
|
|
309
|
+
setCurrentTime(loopStart);
|
|
281
310
|
setIsPlaying(false);
|
|
282
311
|
shuttleDirectionRef.current = null;
|
|
283
312
|
reverseRafRef.current = 0;
|
|
@@ -294,7 +323,15 @@ export function useTimelinePlayer() {
|
|
|
294
323
|
shuttleDirectionRef.current = "backward";
|
|
295
324
|
reverseRafRef.current = requestAnimationFrame(tick);
|
|
296
325
|
},
|
|
297
|
-
[
|
|
326
|
+
[
|
|
327
|
+
getAdapter,
|
|
328
|
+
setCurrentTime,
|
|
329
|
+
setIsPlaying,
|
|
330
|
+
applyPlaybackRate,
|
|
331
|
+
applyPreviewAudioState,
|
|
332
|
+
stopRAFLoop,
|
|
333
|
+
stopReverseLoop,
|
|
334
|
+
],
|
|
298
335
|
);
|
|
299
336
|
|
|
300
337
|
const pause = useCallback(() => {
|
|
@@ -310,21 +347,39 @@ export function useTimelinePlayer() {
|
|
|
310
347
|
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
|
|
311
348
|
|
|
312
349
|
const seek = useCallback(
|
|
313
|
-
(time: number) => {
|
|
350
|
+
(time: number, options?: { keepPlaying?: boolean }) => {
|
|
351
|
+
// Reverse shuttle is always stopped: the RAF reverse tick can't survive
|
|
352
|
+
// a seek anyway, so `keepPlaying` only preserves forward playback.
|
|
353
|
+
const wasReverseShuttle = shuttleDirectionRef.current === "backward";
|
|
314
354
|
stopReverseLoop();
|
|
315
355
|
const adapter = getAdapter();
|
|
316
|
-
if (!adapter)
|
|
356
|
+
if (!adapter) {
|
|
357
|
+
pendingSeekRef.current = Math.max(0, time);
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
317
360
|
const duration = Math.max(0, adapter.getDuration());
|
|
318
361
|
const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
|
|
319
|
-
adapter.seek(nextTime);
|
|
362
|
+
adapter.seek(nextTime, options);
|
|
320
363
|
liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
|
|
321
364
|
setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
365
|
+
if (!options?.keepPlaying || wasReverseShuttle) {
|
|
366
|
+
stopRAFLoop();
|
|
367
|
+
if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
|
|
368
|
+
shuttleDirectionRef.current = null;
|
|
369
|
+
shuttleSpeedIndexRef.current = 0;
|
|
370
|
+
}
|
|
371
|
+
return true;
|
|
326
372
|
},
|
|
327
|
-
[
|
|
373
|
+
[
|
|
374
|
+
getAdapter,
|
|
375
|
+
pendingSeekRef,
|
|
376
|
+
setCurrentTime,
|
|
377
|
+
setIsPlaying,
|
|
378
|
+
stopRAFLoop,
|
|
379
|
+
stopReverseLoop,
|
|
380
|
+
shuttleDirectionRef,
|
|
381
|
+
shuttleSpeedIndexRef,
|
|
382
|
+
],
|
|
328
383
|
);
|
|
329
384
|
|
|
330
385
|
// Handle seek requests from outside the player loop (e.g. LayersPanel).
|
|
@@ -363,6 +418,7 @@ export function useTimelinePlayer() {
|
|
|
363
418
|
setTimelineReady,
|
|
364
419
|
setIsPlaying,
|
|
365
420
|
attachIframeShortcutListeners,
|
|
421
|
+
applyPreviewAudioState,
|
|
366
422
|
});
|
|
367
423
|
|
|
368
424
|
const saveSeekPosition = useCallback(() => {
|
|
@@ -487,6 +543,19 @@ export function useTimelinePlayer() {
|
|
|
487
543
|
usePlayerStore.getState().reset();
|
|
488
544
|
}, [stopRAFLoop, stopReverseLoop]);
|
|
489
545
|
|
|
546
|
+
useEffect(() => {
|
|
547
|
+
return usePlayerStore.subscribe((state, prev) => {
|
|
548
|
+
const playbackRateChanged = state.playbackRate !== prev.playbackRate;
|
|
549
|
+
const audioMutedChanged = state.audioMuted !== prev.audioMuted;
|
|
550
|
+
if (!playbackRateChanged && !audioMutedChanged) return;
|
|
551
|
+
|
|
552
|
+
if (playbackRateChanged) {
|
|
553
|
+
applyPlaybackRate(state.playbackRate);
|
|
554
|
+
}
|
|
555
|
+
applyPreviewAudioState();
|
|
556
|
+
});
|
|
557
|
+
}, [applyPlaybackRate, applyPreviewAudioState]);
|
|
558
|
+
|
|
490
559
|
return {
|
|
491
560
|
iframeRef,
|
|
492
561
|
play,
|