@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
|
@@ -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,16 +38,22 @@ 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;
|
|
44
45
|
/** Timeline zoom percent relative to the fit width when in manual mode */
|
|
45
46
|
manualZoomPercent: number;
|
|
47
|
+
/** Work-area in-point (seconds). When set, loop starts here and A jumps here. */
|
|
48
|
+
inPoint: number | null;
|
|
49
|
+
/** Work-area out-point (seconds). When set, loop ends here and E jumps here. */
|
|
50
|
+
outPoint: number | null;
|
|
46
51
|
|
|
47
52
|
setIsPlaying: (playing: boolean) => void;
|
|
48
53
|
setCurrentTime: (time: number) => void;
|
|
49
54
|
setDuration: (duration: number) => void;
|
|
50
55
|
setPlaybackRate: (rate: number) => void;
|
|
56
|
+
setAudioMuted: (muted: boolean) => void;
|
|
51
57
|
setLoopEnabled: (enabled: boolean) => void;
|
|
52
58
|
setTimelineReady: (ready: boolean) => void;
|
|
53
59
|
setElements: (elements: TimelineElement[]) => void;
|
|
@@ -58,6 +64,8 @@ interface PlayerState {
|
|
|
58
64
|
) => void;
|
|
59
65
|
setZoomMode: (mode: ZoomMode) => void;
|
|
60
66
|
setManualZoomPercent: (percent: number) => void;
|
|
67
|
+
setInPoint: (time: number | null) => void;
|
|
68
|
+
setOutPoint: (time: number | null) => void;
|
|
61
69
|
reset: () => void;
|
|
62
70
|
|
|
63
71
|
/**
|
|
@@ -90,9 +98,12 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
90
98
|
elements: [],
|
|
91
99
|
selectedElementId: null,
|
|
92
100
|
playbackRate: readStudioUiPreferences().playbackRate ?? 1,
|
|
101
|
+
audioMuted: readStudioUiPreferences().audioMuted ?? false,
|
|
93
102
|
loopEnabled: false,
|
|
94
103
|
zoomMode: "fit",
|
|
95
104
|
manualZoomPercent: 100,
|
|
105
|
+
inPoint: null,
|
|
106
|
+
outPoint: null,
|
|
96
107
|
|
|
97
108
|
requestedSeekTime: null,
|
|
98
109
|
requestSeek: (time) => set({ requestedSeekTime: time }),
|
|
@@ -103,8 +114,33 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
103
114
|
writeStudioUiPreferences({ playbackRate: rate });
|
|
104
115
|
set({ playbackRate: rate });
|
|
105
116
|
},
|
|
117
|
+
setAudioMuted: (muted) => {
|
|
118
|
+
writeStudioUiPreferences({ audioMuted: muted });
|
|
119
|
+
set({ audioMuted: muted });
|
|
120
|
+
},
|
|
106
121
|
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
|
|
107
122
|
setZoomMode: (mode) => set({ zoomMode: mode }),
|
|
123
|
+
setInPoint: (time) =>
|
|
124
|
+
set((state) => {
|
|
125
|
+
const t = time !== null && Number.isFinite(time) ? time : null;
|
|
126
|
+
return {
|
|
127
|
+
inPoint: t,
|
|
128
|
+
outPoint:
|
|
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,
|
|
133
|
+
};
|
|
134
|
+
}),
|
|
135
|
+
setOutPoint: (time) =>
|
|
136
|
+
set((state) => {
|
|
137
|
+
const t = time !== null && Number.isFinite(time) ? time : null;
|
|
138
|
+
return {
|
|
139
|
+
outPoint: t,
|
|
140
|
+
inPoint: t !== null && state.inPoint !== null && t <= state.inPoint ? null : state.inPoint,
|
|
141
|
+
loopEnabled: t !== null ? true : state.loopEnabled,
|
|
142
|
+
};
|
|
143
|
+
}),
|
|
108
144
|
setManualZoomPercent: (percent) =>
|
|
109
145
|
set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
|
|
110
146
|
setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
|
|
@@ -119,7 +155,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
119
155
|
),
|
|
120
156
|
})),
|
|
121
157
|
// Resets project-specific state when switching compositions.
|
|
122
|
-
// playbackRate, loopEnabled, zoomMode, and manualZoomPercent are intentionally preserved
|
|
158
|
+
// playbackRate, audioMuted, loopEnabled, zoomMode, and manualZoomPercent are intentionally preserved
|
|
123
159
|
// because they are user preferences that should survive project switches.
|
|
124
160
|
reset: () =>
|
|
125
161
|
set({
|
|
@@ -129,5 +165,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|
|
129
165
|
timelineReady: false,
|
|
130
166
|
elements: [],
|
|
131
167
|
selectedElementId: null,
|
|
168
|
+
inPoint: null,
|
|
169
|
+
outPoint: null,
|
|
132
170
|
}),
|
|
133
171
|
}));
|
|
@@ -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");
|
|
@@ -1,24 +1,61 @@
|
|
|
1
1
|
const PROJECT_HASH_PREFIX = "#project/";
|
|
2
2
|
|
|
3
|
+
export interface ProjectHashRoute {
|
|
4
|
+
projectId: string;
|
|
5
|
+
params: URLSearchParams;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function decodeHashProjectId(value: string): string {
|
|
9
|
+
try {
|
|
10
|
+
return decodeURIComponent(value);
|
|
11
|
+
} catch {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeHashParams(
|
|
17
|
+
params?: URLSearchParams | Record<string, string | null | undefined>,
|
|
18
|
+
): URLSearchParams {
|
|
19
|
+
if (!params) return new URLSearchParams();
|
|
20
|
+
if (params instanceof URLSearchParams) return params;
|
|
21
|
+
|
|
22
|
+
const next = new URLSearchParams();
|
|
23
|
+
for (const [key, value] of Object.entries(params)) {
|
|
24
|
+
if (!key || value == null || value === "") continue;
|
|
25
|
+
next.set(key, value);
|
|
26
|
+
}
|
|
27
|
+
return next;
|
|
28
|
+
}
|
|
29
|
+
|
|
3
30
|
export function encodeProjectId(projectId: string): string {
|
|
4
31
|
return encodeURIComponent(projectId);
|
|
5
32
|
}
|
|
6
33
|
|
|
7
|
-
export function buildProjectHash(
|
|
8
|
-
|
|
34
|
+
export function buildProjectHash(
|
|
35
|
+
projectId: string,
|
|
36
|
+
params?: URLSearchParams | Record<string, string | null | undefined>,
|
|
37
|
+
): string {
|
|
38
|
+
const search = normalizeHashParams(params).toString();
|
|
39
|
+
return `${PROJECT_HASH_PREFIX}${encodeProjectId(projectId)}${search ? `?${search}` : ""}`;
|
|
9
40
|
}
|
|
10
41
|
|
|
11
|
-
export function
|
|
42
|
+
export function parseProjectHashRoute(hash: string): ProjectHashRoute | null {
|
|
12
43
|
if (!hash.startsWith(PROJECT_HASH_PREFIX)) return null;
|
|
13
44
|
|
|
14
|
-
const
|
|
45
|
+
const route = hash.slice(PROJECT_HASH_PREFIX.length);
|
|
46
|
+
const queryIndex = route.indexOf("?");
|
|
47
|
+
const encodedProjectId = queryIndex >= 0 ? route.slice(0, queryIndex) : route;
|
|
15
48
|
if (!encodedProjectId || encodedProjectId.includes("/")) return null;
|
|
16
49
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
50
|
+
const rawParams = queryIndex >= 0 ? route.slice(queryIndex + 1) : "";
|
|
51
|
+
return {
|
|
52
|
+
projectId: decodeHashProjectId(encodedProjectId),
|
|
53
|
+
params: new URLSearchParams(rawParams),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseProjectIdFromHash(hash: string): string | null {
|
|
58
|
+
return parseProjectHashRoute(hash)?.projectId ?? null;
|
|
22
59
|
}
|
|
23
60
|
|
|
24
61
|
export function buildProjectApiPath(projectId: string, suffix = ""): string {
|