@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.
Files changed (57) hide show
  1. package/dist/assets/{hyperframes-player-CzwFysqv.js → hyperframes-player-D0Yi3xMP.js} +2 -2
  2. package/dist/assets/index-Ckqo37Co.css +1 -0
  3. package/dist/assets/index-Yvtxngdi.js +116 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/App.tsx +54 -31
  7. package/src/components/StudioGlobalDragOverlay.tsx +26 -0
  8. package/src/components/StudioHeader.tsx +128 -3
  9. package/src/components/StudioRightPanel.tsx +0 -2
  10. package/src/components/editor/DomEditOverlay.test.ts +1 -0
  11. package/src/components/editor/DomEditOverlay.tsx +2 -1
  12. package/src/components/editor/PropertyPanel.tsx +27 -36
  13. package/src/components/editor/domEditingElement.ts +1 -0
  14. package/src/components/editor/manualEdits.test.ts +39 -466
  15. package/src/components/editor/manualEdits.ts +6 -168
  16. package/src/components/editor/manualEditsDom.ts +361 -1
  17. package/src/components/editor/manualEditsParsing.ts +2 -240
  18. package/src/components/editor/manualEditsTypes.ts +1 -40
  19. package/src/components/editor/useDomEditOverlayGestures.ts +25 -8
  20. package/src/components/nle/NLEPreview.tsx +1 -1
  21. package/src/components/sidebar/CompositionsTab.tsx +9 -3
  22. package/src/contexts/DomEditContext.tsx +3 -0
  23. package/src/contexts/FileManagerContext.tsx +3 -0
  24. package/src/hooks/useAppHotkeys.ts +1 -4
  25. package/src/hooks/useDomEditCommits.ts +82 -77
  26. package/src/hooks/useDomEditSession.ts +4 -16
  27. package/src/hooks/useFileManager.ts +10 -1
  28. package/src/hooks/useManifestPersistence.ts +51 -187
  29. package/src/hooks/usePanelLayout.ts +10 -3
  30. package/src/hooks/usePreviewInteraction.ts +0 -1
  31. package/src/hooks/useStudioUrlState.ts +188 -0
  32. package/src/player/components/Player.tsx +15 -1
  33. package/src/player/components/PlayerControls.test.ts +17 -0
  34. package/src/player/components/PlayerControls.tsx +347 -56
  35. package/src/player/hooks/usePlaybackKeyboard.test.ts +174 -0
  36. package/src/player/hooks/usePlaybackKeyboard.ts +37 -10
  37. package/src/player/hooks/useTimelinePlayer.seek.test.ts +329 -0
  38. package/src/player/hooks/useTimelinePlayer.ts +97 -28
  39. package/src/player/hooks/useTimelineSyncCallbacks.ts +10 -4
  40. package/src/player/lib/playbackAdapter.test.ts +50 -0
  41. package/src/player/lib/playbackAdapter.ts +2 -2
  42. package/src/player/lib/playbackTypes.ts +1 -1
  43. package/src/player/lib/timelineDOM.ts +4 -2
  44. package/src/player/lib/timelineIframeHelpers.ts +63 -7
  45. package/src/player/store/playerStore.test.ts +105 -1
  46. package/src/player/store/playerStore.ts +39 -1
  47. package/src/utils/projectRouting.test.ts +15 -0
  48. package/src/utils/projectRouting.ts +46 -9
  49. package/src/utils/sourcePatcher.ts +50 -14
  50. package/src/utils/studioPreviewHelpers.test.ts +56 -0
  51. package/src/utils/studioPreviewHelpers.ts +51 -13
  52. package/src/utils/studioUiPreferences.test.ts +3 -0
  53. package/src/utils/studioUiPreferences.ts +4 -0
  54. package/src/utils/studioUrlState.test.ts +249 -0
  55. package/src/utils/studioUrlState.ts +135 -0
  56. package/dist/assets/index-Bs6NmE0o.js +0 -117
  57. 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
- unmutePreviewMedia(iframeRef.current);
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, unmute, resolveIframe,
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
- unmutePreviewMedia,
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
- // Muting / iframe resolution
76
+ // Audio / iframe resolution
77
77
  // ---------------------------------------------------------------------------
78
78
 
79
- export function unmutePreviewMedia(iframe: HTMLIFrameElement | null): void {
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.contentWindow?.postMessage(
83
- { source: "hf-parent", type: "control", action: "set-muted", muted: false },
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 unmute preview media", err);
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(projectId: string): string {
8
- return `${PROJECT_HASH_PREFIX}${encodeProjectId(projectId)}`;
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 parseProjectIdFromHash(hash: string): string | null {
42
+ export function parseProjectHashRoute(hash: string): ProjectHashRoute | null {
12
43
  if (!hash.startsWith(PROJECT_HASH_PREFIX)) return null;
13
44
 
14
- const encodedProjectId = hash.slice(PROJECT_HASH_PREFIX.length);
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
- try {
18
- return decodeURIComponent(encodedProjectId);
19
- } catch {
20
- return encodedProjectId;
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 {