@hyperframes/studio 0.6.6 → 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-T-ME1rqL.js → hyperframes-player-D0Yi3xMP.js} +2 -2
- package/dist/assets/{index-Bne9FFeo.css → index-Ckqo37Co.css} +1 -1
- 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/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 +61 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +174 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +18 -15
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +329 -0
- package/src/player/hooks/useTimelinePlayer.ts +76 -18
- 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 +12 -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-DYqqzECY.js +0 -117
|
@@ -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
|
|
@@ -218,11 +222,7 @@ export function useTimelinePlayer() {
|
|
|
218
222
|
const applyPlaybackRate = useCallback((rate: number) => {
|
|
219
223
|
const iframe = iframeRef.current;
|
|
220
224
|
if (!iframe) return;
|
|
221
|
-
|
|
222
|
-
iframe.contentWindow?.postMessage(
|
|
223
|
-
{ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate },
|
|
224
|
-
"*",
|
|
225
|
-
);
|
|
225
|
+
setPreviewPlaybackRate(iframe, rate);
|
|
226
226
|
// Also set directly on GSAP timeline if accessible
|
|
227
227
|
try {
|
|
228
228
|
const win = iframe.contentWindow as IframeWindow | null;
|
|
@@ -241,6 +241,15 @@ export function useTimelinePlayer() {
|
|
|
241
241
|
}
|
|
242
242
|
}, []);
|
|
243
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
|
+
|
|
244
253
|
const play = useCallback(() => {
|
|
245
254
|
stopRAFLoop();
|
|
246
255
|
stopReverseLoop();
|
|
@@ -249,13 +258,21 @@ export function useTimelinePlayer() {
|
|
|
249
258
|
if (adapter.getTime() >= adapter.getDuration()) {
|
|
250
259
|
adapter.seek(usePlayerStore.getState().inPoint ?? 0);
|
|
251
260
|
}
|
|
252
|
-
unmutePreviewMedia(iframeRef.current);
|
|
253
261
|
applyPlaybackRate(usePlayerStore.getState().playbackRate);
|
|
262
|
+
applyPreviewAudioState();
|
|
254
263
|
adapter.play();
|
|
255
264
|
shuttleDirectionRef.current = "forward";
|
|
256
265
|
setIsPlaying(true);
|
|
257
266
|
startRAFLoop();
|
|
258
|
-
}, [
|
|
267
|
+
}, [
|
|
268
|
+
getAdapter,
|
|
269
|
+
setIsPlaying,
|
|
270
|
+
startRAFLoop,
|
|
271
|
+
applyPlaybackRate,
|
|
272
|
+
applyPreviewAudioState,
|
|
273
|
+
stopRAFLoop,
|
|
274
|
+
stopReverseLoop,
|
|
275
|
+
]);
|
|
259
276
|
|
|
260
277
|
const playBackward = useCallback(
|
|
261
278
|
(rate: number) => {
|
|
@@ -267,8 +284,9 @@ export function useTimelinePlayer() {
|
|
|
267
284
|
const initialTime = adapter.getTime() <= 0 && duration > 0 ? duration : adapter.getTime();
|
|
268
285
|
adapter.pause();
|
|
269
286
|
if (initialTime !== adapter.getTime()) adapter.seek(initialTime);
|
|
270
|
-
unmutePreviewMedia(iframeRef.current);
|
|
271
287
|
const speed = Math.max(0.1, Math.min(4, rate));
|
|
288
|
+
applyPlaybackRate(speed);
|
|
289
|
+
applyPreviewAudioState(speed);
|
|
272
290
|
let startTime = initialTime;
|
|
273
291
|
let startedAt = performance.now();
|
|
274
292
|
|
|
@@ -305,7 +323,15 @@ export function useTimelinePlayer() {
|
|
|
305
323
|
shuttleDirectionRef.current = "backward";
|
|
306
324
|
reverseRafRef.current = requestAnimationFrame(tick);
|
|
307
325
|
},
|
|
308
|
-
[
|
|
326
|
+
[
|
|
327
|
+
getAdapter,
|
|
328
|
+
setCurrentTime,
|
|
329
|
+
setIsPlaying,
|
|
330
|
+
applyPlaybackRate,
|
|
331
|
+
applyPreviewAudioState,
|
|
332
|
+
stopRAFLoop,
|
|
333
|
+
stopReverseLoop,
|
|
334
|
+
],
|
|
309
335
|
);
|
|
310
336
|
|
|
311
337
|
const pause = useCallback(() => {
|
|
@@ -321,21 +347,39 @@ export function useTimelinePlayer() {
|
|
|
321
347
|
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
|
|
322
348
|
|
|
323
349
|
const seek = useCallback(
|
|
324
|
-
(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";
|
|
325
354
|
stopReverseLoop();
|
|
326
355
|
const adapter = getAdapter();
|
|
327
|
-
if (!adapter)
|
|
356
|
+
if (!adapter) {
|
|
357
|
+
pendingSeekRef.current = Math.max(0, time);
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
328
360
|
const duration = Math.max(0, adapter.getDuration());
|
|
329
361
|
const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
|
|
330
|
-
adapter.seek(nextTime);
|
|
362
|
+
adapter.seek(nextTime, options);
|
|
331
363
|
liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
|
|
332
364
|
setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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;
|
|
337
372
|
},
|
|
338
|
-
[
|
|
373
|
+
[
|
|
374
|
+
getAdapter,
|
|
375
|
+
pendingSeekRef,
|
|
376
|
+
setCurrentTime,
|
|
377
|
+
setIsPlaying,
|
|
378
|
+
stopRAFLoop,
|
|
379
|
+
stopReverseLoop,
|
|
380
|
+
shuttleDirectionRef,
|
|
381
|
+
shuttleSpeedIndexRef,
|
|
382
|
+
],
|
|
339
383
|
);
|
|
340
384
|
|
|
341
385
|
// Handle seek requests from outside the player loop (e.g. LayersPanel).
|
|
@@ -374,6 +418,7 @@ export function useTimelinePlayer() {
|
|
|
374
418
|
setTimelineReady,
|
|
375
419
|
setIsPlaying,
|
|
376
420
|
attachIframeShortcutListeners,
|
|
421
|
+
applyPreviewAudioState,
|
|
377
422
|
});
|
|
378
423
|
|
|
379
424
|
const saveSeekPosition = useCallback(() => {
|
|
@@ -498,6 +543,19 @@ export function useTimelinePlayer() {
|
|
|
498
543
|
usePlayerStore.getState().reset();
|
|
499
544
|
}, [stopRAFLoop, stopReverseLoop]);
|
|
500
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
|
+
|
|
501
559
|
return {
|
|
502
560
|
iframeRef,
|
|
503
561
|
play,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { useCallback } from "react";
|
|
12
|
-
import { usePlayerStore } from "../store/playerStore";
|
|
12
|
+
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
13
13
|
import type { TimelineElement } from "../store/playerStore";
|
|
14
14
|
import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
|
|
15
15
|
import {
|
|
@@ -24,7 +24,6 @@ import {
|
|
|
24
24
|
import {
|
|
25
25
|
normalizePreviewViewport,
|
|
26
26
|
autoHealMissingCompositionIds,
|
|
27
|
-
unmutePreviewMedia,
|
|
28
27
|
buildMissingCompositionElements,
|
|
29
28
|
} from "../lib/timelineIframeHelpers";
|
|
30
29
|
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
@@ -41,6 +40,7 @@ interface UseTimelineSyncCallbacksParams {
|
|
|
41
40
|
setTimelineReady: (v: boolean) => void;
|
|
42
41
|
setIsPlaying: (v: boolean) => void;
|
|
43
42
|
attachIframeShortcutListeners: () => void;
|
|
43
|
+
applyPreviewAudioState: () => void;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export function useTimelineSyncCallbacks({
|
|
@@ -55,6 +55,7 @@ export function useTimelineSyncCallbacks({
|
|
|
55
55
|
setTimelineReady,
|
|
56
56
|
setIsPlaying,
|
|
57
57
|
attachIframeShortcutListeners,
|
|
58
|
+
applyPreviewAudioState,
|
|
58
59
|
}: UseTimelineSyncCallbacksParams) {
|
|
59
60
|
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
|
|
60
61
|
const processTimelineMessage = useCallback(
|
|
@@ -158,6 +159,9 @@ export function useTimelineSyncCallbacks({
|
|
|
158
159
|
const startTime = seekTo != null ? Math.min(seekTo, adapter.getDuration()) : 0;
|
|
159
160
|
|
|
160
161
|
adapter.seek(startTime);
|
|
162
|
+
// Keep non-React listeners such as the capture link and time display in sync
|
|
163
|
+
// with the initial adapter seek on iframe load.
|
|
164
|
+
liveTime.notify(startTime);
|
|
161
165
|
const adapterDur = adapter.getDuration();
|
|
162
166
|
if (
|
|
163
167
|
Number.isFinite(adapterDur) &&
|
|
@@ -189,6 +193,7 @@ export function useTimelineSyncCallbacks({
|
|
|
189
193
|
processTimelineMessage(manifest);
|
|
190
194
|
}
|
|
191
195
|
enrichMissingCompositions();
|
|
196
|
+
applyPreviewAudioState();
|
|
192
197
|
|
|
193
198
|
if (usePlayerStore.getState().elements.length === 0 && doc) {
|
|
194
199
|
const els = parseTimelineFromDOM(doc, adapter.getDuration());
|
|
@@ -222,13 +227,14 @@ export function useTimelineSyncCallbacks({
|
|
|
222
227
|
enrichMissingCompositions,
|
|
223
228
|
syncTimelineElements,
|
|
224
229
|
attachIframeShortcutListeners,
|
|
230
|
+
applyPreviewAudioState,
|
|
225
231
|
iframeRef,
|
|
226
232
|
isRefreshingRef,
|
|
227
233
|
pendingSeekRef,
|
|
228
234
|
]);
|
|
229
235
|
|
|
230
236
|
const onIframeLoad = useCallback(() => {
|
|
231
|
-
|
|
237
|
+
applyPreviewAudioState();
|
|
232
238
|
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
|
|
233
239
|
|
|
234
240
|
// Fast path: adapter already available (in-place reloads, cached compositions)
|
|
@@ -267,7 +273,7 @@ export function useTimelineSyncCallbacks({
|
|
|
267
273
|
}
|
|
268
274
|
window.removeEventListener("message", onMessage);
|
|
269
275
|
}, 5000) as unknown as ReturnType<typeof setInterval>;
|
|
270
|
-
}, [initializeAdapter, iframeRef, probeIntervalRef]);
|
|
276
|
+
}, [initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState]);
|
|
271
277
|
|
|
272
278
|
// Stable refs so mount-effect closures always call the latest version
|
|
273
279
|
const processTimelineMessageRef = { current: processTimelineMessage };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { wrapTimeline } from "./playbackAdapter";
|
|
3
|
+
import type { TimelineLike } from "./playbackTypes";
|
|
4
|
+
|
|
5
|
+
describe("wrapTimeline seek keepPlaying option (#834)", () => {
|
|
6
|
+
function mockTimeline(): TimelineLike & {
|
|
7
|
+
play: ReturnType<typeof vi.fn>;
|
|
8
|
+
pause: ReturnType<typeof vi.fn>;
|
|
9
|
+
seek: ReturnType<typeof vi.fn>;
|
|
10
|
+
} {
|
|
11
|
+
return {
|
|
12
|
+
play: vi.fn(),
|
|
13
|
+
pause: vi.fn(),
|
|
14
|
+
seek: vi.fn(),
|
|
15
|
+
time: () => 0,
|
|
16
|
+
duration: () => 10,
|
|
17
|
+
isActive: () => false,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
it("default seek pauses the GSAP timeline before seeking", () => {
|
|
22
|
+
const tl = mockTimeline();
|
|
23
|
+
const adapter = wrapTimeline(tl);
|
|
24
|
+
|
|
25
|
+
adapter.seek(5);
|
|
26
|
+
|
|
27
|
+
expect(tl.pause).toHaveBeenCalledTimes(1);
|
|
28
|
+
expect(tl.seek).toHaveBeenCalledWith(5);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("seek with { keepPlaying: true } skips the implicit pause", () => {
|
|
32
|
+
const tl = mockTimeline();
|
|
33
|
+
const adapter = wrapTimeline(tl);
|
|
34
|
+
|
|
35
|
+
adapter.seek(5, { keepPlaying: true });
|
|
36
|
+
|
|
37
|
+
expect(tl.pause).not.toHaveBeenCalled();
|
|
38
|
+
expect(tl.seek).toHaveBeenCalledWith(5);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("seek with { keepPlaying: false } still pauses (explicit default)", () => {
|
|
42
|
+
const tl = mockTimeline();
|
|
43
|
+
const adapter = wrapTimeline(tl);
|
|
44
|
+
|
|
45
|
+
adapter.seek(5, { keepPlaying: false });
|
|
46
|
+
|
|
47
|
+
expect(tl.pause).toHaveBeenCalledTimes(1);
|
|
48
|
+
expect(tl.seek).toHaveBeenCalledWith(5);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -134,8 +134,8 @@ export function wrapTimeline(tl: TimelineLike): PlaybackAdapter {
|
|
|
134
134
|
return {
|
|
135
135
|
play: () => tl.play(),
|
|
136
136
|
pause: () => tl.pause(),
|
|
137
|
-
seek: (t) => {
|
|
138
|
-
tl.pause();
|
|
137
|
+
seek: (t, options) => {
|
|
138
|
+
if (!options?.keepPlaying) tl.pause();
|
|
139
139
|
tl.seek(t);
|
|
140
140
|
},
|
|
141
141
|
getTime: () => tl.time(),
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
export interface PlaybackAdapter {
|
|
8
8
|
play: () => void;
|
|
9
9
|
pause: () => void;
|
|
10
|
-
seek: (time: number) => void;
|
|
10
|
+
seek: (time: number, options?: { keepPlaying?: boolean }) => void;
|
|
11
11
|
getTime: () => number;
|
|
12
12
|
getDuration: () => number;
|
|
13
13
|
isPlaying: () => boolean;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Higher-level timeline DOM operations: element factories, DOM-to-element
|
|
3
3
|
* parsing, timeline merging, and standalone composition helpers.
|
|
4
4
|
*
|
|
5
|
-
* Preview iframe utilities (normaliseViewport, autoHeal,
|
|
5
|
+
* Preview iframe utilities (normaliseViewport, autoHeal, audio controls, resolveIframe,
|
|
6
6
|
* buildMissingCompositionElements) live in timelineIframeHelpers.ts.
|
|
7
7
|
*
|
|
8
8
|
* Pure functions (no React, no store reads) — testable in isolation.
|
|
@@ -42,7 +42,9 @@ export {
|
|
|
42
42
|
export {
|
|
43
43
|
normalizePreviewViewport,
|
|
44
44
|
autoHealMissingCompositionIds,
|
|
45
|
-
|
|
45
|
+
setPreviewMediaMuted,
|
|
46
|
+
setPreviewPlaybackRate,
|
|
47
|
+
shouldMutePreviewAudio,
|
|
46
48
|
resolveIframe,
|
|
47
49
|
buildMissingCompositionElements,
|
|
48
50
|
} from "./timelineIframeHelpers";
|
|
@@ -73,18 +73,74 @@ export function autoHealMissingCompositionIds(doc: Document): void {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
// ---------------------------------------------------------------------------
|
|
76
|
-
//
|
|
76
|
+
// Audio / iframe resolution
|
|
77
77
|
// ---------------------------------------------------------------------------
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
type PreviewPlayerHost = HTMLElement & {
|
|
80
|
+
muted?: boolean;
|
|
81
|
+
playbackRate?: number;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function isPreviewPlayerHost(value: unknown): value is PreviewPlayerHost {
|
|
85
|
+
return value instanceof HTMLElement;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolvePreviewPlayerHost(iframe: HTMLIFrameElement): PreviewPlayerHost | null {
|
|
89
|
+
const root = iframe.getRootNode();
|
|
90
|
+
if (
|
|
91
|
+
typeof ShadowRoot !== "undefined" &&
|
|
92
|
+
root instanceof ShadowRoot &&
|
|
93
|
+
isPreviewPlayerHost(root.host)
|
|
94
|
+
) {
|
|
95
|
+
return root.host;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function postPreviewControl(
|
|
101
|
+
iframe: HTMLIFrameElement,
|
|
102
|
+
action: string,
|
|
103
|
+
payload: Record<string, unknown>,
|
|
104
|
+
): void {
|
|
105
|
+
iframe.contentWindow?.postMessage(
|
|
106
|
+
{ source: "hf-parent", type: "control", action, ...payload },
|
|
107
|
+
"*",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function shouldMutePreviewAudio(audioMuted: boolean, playbackRate: number): boolean {
|
|
112
|
+
return audioMuted || playbackRate > 1;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function setPreviewMediaMuted(iframe: HTMLIFrameElement | null, muted: boolean): void {
|
|
80
116
|
if (!iframe) return;
|
|
81
117
|
try {
|
|
82
|
-
iframe
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
118
|
+
const host = resolvePreviewPlayerHost(iframe);
|
|
119
|
+
if (host && typeof host.muted === "boolean") {
|
|
120
|
+
host.muted = muted;
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
postPreviewControl(iframe, "set-muted", { muted });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.warn("[useTimelinePlayer] Failed to set preview media mute state", err);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function setPreviewPlaybackRate(
|
|
130
|
+
iframe: HTMLIFrameElement | null,
|
|
131
|
+
playbackRate: number,
|
|
132
|
+
): void {
|
|
133
|
+
if (!iframe) return;
|
|
134
|
+
const rate = Number.isFinite(playbackRate) && playbackRate > 0 ? playbackRate : 1;
|
|
135
|
+
try {
|
|
136
|
+
const host = resolvePreviewPlayerHost(iframe);
|
|
137
|
+
if (host && typeof host.playbackRate === "number") {
|
|
138
|
+
host.playbackRate = rate;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
postPreviewControl(iframe, "set-playback-rate", { playbackRate: rate });
|
|
86
142
|
} catch (err) {
|
|
87
|
-
console.warn("[useTimelinePlayer] Failed to
|
|
143
|
+
console.warn("[useTimelinePlayer] Failed to set preview playback rate", err);
|
|
88
144
|
}
|
|
89
145
|
}
|
|
90
146
|
|
|
@@ -16,6 +16,7 @@ describe("usePlayerStore", () => {
|
|
|
16
16
|
expect(state.elements).toEqual([]);
|
|
17
17
|
expect(state.selectedElementId).toBeNull();
|
|
18
18
|
expect(state.playbackRate).toBe(1);
|
|
19
|
+
expect(state.audioMuted).toBe(false);
|
|
19
20
|
expect(state.loopEnabled).toBe(false);
|
|
20
21
|
expect(state.zoomMode).toBe("fit");
|
|
21
22
|
expect(state.manualZoomPercent).toBe(100);
|
|
@@ -62,6 +63,13 @@ describe("usePlayerStore", () => {
|
|
|
62
63
|
});
|
|
63
64
|
});
|
|
64
65
|
|
|
66
|
+
describe("setAudioMuted", () => {
|
|
67
|
+
it("updates audioMuted", () => {
|
|
68
|
+
usePlayerStore.getState().setAudioMuted(true);
|
|
69
|
+
expect(usePlayerStore.getState().audioMuted).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
65
73
|
describe("setLoopEnabled", () => {
|
|
66
74
|
it("updates loopEnabled", () => {
|
|
67
75
|
usePlayerStore.getState().setLoopEnabled(true);
|
|
@@ -69,6 +77,100 @@ describe("usePlayerStore", () => {
|
|
|
69
77
|
});
|
|
70
78
|
});
|
|
71
79
|
|
|
80
|
+
describe("setInPoint", () => {
|
|
81
|
+
it("updates inPoint", () => {
|
|
82
|
+
usePlayerStore.getState().setInPoint(1.5);
|
|
83
|
+
expect(usePlayerStore.getState().inPoint).toBe(1.5);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("clears inPoint when given null", () => {
|
|
87
|
+
usePlayerStore.getState().setInPoint(1.5);
|
|
88
|
+
usePlayerStore.getState().setInPoint(null);
|
|
89
|
+
expect(usePlayerStore.getState().inPoint).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("rejects non-finite values", () => {
|
|
93
|
+
usePlayerStore.getState().setInPoint(Number.NaN);
|
|
94
|
+
expect(usePlayerStore.getState().inPoint).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("nullifies outPoint when new inPoint is at or past existing outPoint", () => {
|
|
98
|
+
usePlayerStore.getState().setOutPoint(2);
|
|
99
|
+
usePlayerStore.getState().setInPoint(3);
|
|
100
|
+
expect(usePlayerStore.getState().outPoint).toBeNull();
|
|
101
|
+
expect(usePlayerStore.getState().inPoint).toBe(3);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("preserves outPoint when new inPoint is before it", () => {
|
|
105
|
+
usePlayerStore.getState().setOutPoint(5);
|
|
106
|
+
usePlayerStore.getState().setInPoint(2);
|
|
107
|
+
expect(usePlayerStore.getState().outPoint).toBe(5);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("auto-enables loopEnabled when set to a non-null value", () => {
|
|
111
|
+
usePlayerStore.getState().setLoopEnabled(false);
|
|
112
|
+
usePlayerStore.getState().setInPoint(1.5);
|
|
113
|
+
expect(usePlayerStore.getState().loopEnabled).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("preserves loopEnabled when cleared with null", () => {
|
|
117
|
+
usePlayerStore.getState().setLoopEnabled(true);
|
|
118
|
+
usePlayerStore.getState().setInPoint(null);
|
|
119
|
+
expect(usePlayerStore.getState().loopEnabled).toBe(true);
|
|
120
|
+
|
|
121
|
+
usePlayerStore.getState().setLoopEnabled(false);
|
|
122
|
+
usePlayerStore.getState().setInPoint(null);
|
|
123
|
+
expect(usePlayerStore.getState().loopEnabled).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("setOutPoint", () => {
|
|
128
|
+
it("updates outPoint", () => {
|
|
129
|
+
usePlayerStore.getState().setOutPoint(4.2);
|
|
130
|
+
expect(usePlayerStore.getState().outPoint).toBe(4.2);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("clears outPoint when given null", () => {
|
|
134
|
+
usePlayerStore.getState().setOutPoint(4.2);
|
|
135
|
+
usePlayerStore.getState().setOutPoint(null);
|
|
136
|
+
expect(usePlayerStore.getState().outPoint).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rejects non-finite values", () => {
|
|
140
|
+
usePlayerStore.getState().setOutPoint(Number.POSITIVE_INFINITY);
|
|
141
|
+
expect(usePlayerStore.getState().outPoint).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("nullifies inPoint when new outPoint is at or before existing inPoint", () => {
|
|
145
|
+
usePlayerStore.getState().setInPoint(5);
|
|
146
|
+
usePlayerStore.getState().setOutPoint(3);
|
|
147
|
+
expect(usePlayerStore.getState().inPoint).toBeNull();
|
|
148
|
+
expect(usePlayerStore.getState().outPoint).toBe(3);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("preserves inPoint when new outPoint is after it", () => {
|
|
152
|
+
usePlayerStore.getState().setInPoint(2);
|
|
153
|
+
usePlayerStore.getState().setOutPoint(5);
|
|
154
|
+
expect(usePlayerStore.getState().inPoint).toBe(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("auto-enables loopEnabled when set to a non-null value", () => {
|
|
158
|
+
usePlayerStore.getState().setLoopEnabled(false);
|
|
159
|
+
usePlayerStore.getState().setOutPoint(4.2);
|
|
160
|
+
expect(usePlayerStore.getState().loopEnabled).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("preserves loopEnabled when cleared with null", () => {
|
|
164
|
+
usePlayerStore.getState().setLoopEnabled(true);
|
|
165
|
+
usePlayerStore.getState().setOutPoint(null);
|
|
166
|
+
expect(usePlayerStore.getState().loopEnabled).toBe(true);
|
|
167
|
+
|
|
168
|
+
usePlayerStore.getState().setLoopEnabled(false);
|
|
169
|
+
usePlayerStore.getState().setOutPoint(null);
|
|
170
|
+
expect(usePlayerStore.getState().loopEnabled).toBe(false);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
72
174
|
describe("setTimelineReady", () => {
|
|
73
175
|
it("updates timelineReady", () => {
|
|
74
176
|
usePlayerStore.getState().setTimelineReady(true);
|
|
@@ -213,9 +315,10 @@ describe("usePlayerStore", () => {
|
|
|
213
315
|
expect(state.selectedElementId).toBeNull();
|
|
214
316
|
});
|
|
215
317
|
|
|
216
|
-
it("does not reset playbackRate, loopEnabled, zoomMode, or manualZoomPercent", () => {
|
|
318
|
+
it("does not reset playbackRate, audioMuted, loopEnabled, zoomMode, or manualZoomPercent", () => {
|
|
217
319
|
const store = usePlayerStore.getState();
|
|
218
320
|
store.setPlaybackRate(2);
|
|
321
|
+
store.setAudioMuted(true);
|
|
219
322
|
store.setLoopEnabled(true);
|
|
220
323
|
store.setZoomMode("manual");
|
|
221
324
|
store.setManualZoomPercent(200);
|
|
@@ -225,6 +328,7 @@ describe("usePlayerStore", () => {
|
|
|
225
328
|
const state = usePlayerStore.getState();
|
|
226
329
|
// reset() only resets the fields explicitly listed in the reset function
|
|
227
330
|
expect(state.playbackRate).toBe(2);
|
|
331
|
+
expect(state.audioMuted).toBe(true);
|
|
228
332
|
expect(state.loopEnabled).toBe(true);
|
|
229
333
|
expect(state.zoomMode).toBe("manual");
|
|
230
334
|
expect(state.manualZoomPercent).toBe(200);
|
|
@@ -38,6 +38,7 @@ interface PlayerState {
|
|
|
38
38
|
elements: TimelineElement[];
|
|
39
39
|
selectedElementId: string | null;
|
|
40
40
|
playbackRate: number;
|
|
41
|
+
audioMuted: boolean;
|
|
41
42
|
loopEnabled: boolean;
|
|
42
43
|
/** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */
|
|
43
44
|
zoomMode: ZoomMode;
|
|
@@ -52,6 +53,7 @@ interface PlayerState {
|
|
|
52
53
|
setCurrentTime: (time: number) => void;
|
|
53
54
|
setDuration: (duration: number) => void;
|
|
54
55
|
setPlaybackRate: (rate: number) => void;
|
|
56
|
+
setAudioMuted: (muted: boolean) => void;
|
|
55
57
|
setLoopEnabled: (enabled: boolean) => void;
|
|
56
58
|
setTimelineReady: (ready: boolean) => void;
|
|
57
59
|
setElements: (elements: TimelineElement[]) => void;
|
|
@@ -96,6 +98,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
96
98
|
elements: [],
|
|
97
99
|
selectedElementId: null,
|
|
98
100
|
playbackRate: readStudioUiPreferences().playbackRate ?? 1,
|
|
101
|
+
audioMuted: readStudioUiPreferences().audioMuted ?? false,
|
|
99
102
|
loopEnabled: false,
|
|
100
103
|
zoomMode: "fit",
|
|
101
104
|
manualZoomPercent: 100,
|
|
@@ -111,6 +114,10 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
111
114
|
writeStudioUiPreferences({ playbackRate: rate });
|
|
112
115
|
set({ playbackRate: rate });
|
|
113
116
|
},
|
|
117
|
+
setAudioMuted: (muted) => {
|
|
118
|
+
writeStudioUiPreferences({ audioMuted: muted });
|
|
119
|
+
set({ audioMuted: muted });
|
|
120
|
+
},
|
|
114
121
|
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
|
|
115
122
|
setZoomMode: (mode) => set({ zoomMode: mode }),
|
|
116
123
|
setInPoint: (time) =>
|
|
@@ -120,6 +127,9 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
120
127
|
inPoint: t,
|
|
121
128
|
outPoint:
|
|
122
129
|
t !== null && state.outPoint !== null && t >= state.outPoint ? null : state.outPoint,
|
|
130
|
+
// Setting a work-area marker implies the user wants playback bounded by it.
|
|
131
|
+
// Auto-enable loop so the playhead respects the marker instead of running past.
|
|
132
|
+
loopEnabled: t !== null ? true : state.loopEnabled,
|
|
123
133
|
};
|
|
124
134
|
}),
|
|
125
135
|
setOutPoint: (time) =>
|
|
@@ -128,6 +138,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
128
138
|
return {
|
|
129
139
|
outPoint: t,
|
|
130
140
|
inPoint: t !== null && state.inPoint !== null && t <= state.inPoint ? null : state.inPoint,
|
|
141
|
+
loopEnabled: t !== null ? true : state.loopEnabled,
|
|
131
142
|
};
|
|
132
143
|
}),
|
|
133
144
|
setManualZoomPercent: (percent) =>
|
|
@@ -144,7 +155,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
144
155
|
),
|
|
145
156
|
})),
|
|
146
157
|
// Resets project-specific state when switching compositions.
|
|
147
|
-
// playbackRate, loopEnabled, zoomMode, and manualZoomPercent are intentionally preserved
|
|
158
|
+
// playbackRate, audioMuted, loopEnabled, zoomMode, and manualZoomPercent are intentionally preserved
|
|
148
159
|
// because they are user preferences that should survive project switches.
|
|
149
160
|
reset: () =>
|
|
150
161
|
set({
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
buildProjectApiPath,
|
|
5
5
|
buildProjectHash,
|
|
6
6
|
encodeProjectId,
|
|
7
|
+
parseProjectHashRoute,
|
|
7
8
|
parseProjectIdFromHash,
|
|
8
9
|
} from "./projectRouting";
|
|
9
10
|
|
|
@@ -61,6 +62,20 @@ describe("project routing utilities", () => {
|
|
|
61
62
|
expect(parseProjectIdFromHash(hash)).toBe("Mañana demo");
|
|
62
63
|
});
|
|
63
64
|
|
|
65
|
+
it("parses project hash routes with query params", () => {
|
|
66
|
+
const route = parseProjectHashRoute("#project/Notion%20Showcase?tab=design&t=4.2");
|
|
67
|
+
|
|
68
|
+
expect(route?.projectId).toBe("Notion Showcase");
|
|
69
|
+
expect(route?.params.get("tab")).toBe("design");
|
|
70
|
+
expect(route?.params.get("t")).toBe("4.2");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("builds hash routes with query params", () => {
|
|
74
|
+
expect(buildProjectHash("Notion Showcase", { tab: "design", t: "4.2" })).toBe(
|
|
75
|
+
"#project/Notion%20Showcase?tab=design&t=4.2",
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
64
79
|
it("encodes project ids as one API path segment", () => {
|
|
65
80
|
expect(encodeProjectId("Notion Showcase")).toBe("Notion%20Showcase");
|
|
66
81
|
expect(encodeProjectId("Notion%20Showcase")).toBe("Notion%2520Showcase");
|