@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.
Files changed (55) hide show
  1. package/dist/assets/{hyperframes-player-T-ME1rqL.js → hyperframes-player-D0Yi3xMP.js} +2 -2
  2. package/dist/assets/{index-Bne9FFeo.css → index-Ckqo37Co.css} +1 -1
  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/StudioRightPanel.tsx +0 -2
  9. package/src/components/editor/DomEditOverlay.test.ts +1 -0
  10. package/src/components/editor/DomEditOverlay.tsx +2 -1
  11. package/src/components/editor/PropertyPanel.tsx +27 -36
  12. package/src/components/editor/domEditingElement.ts +1 -0
  13. package/src/components/editor/manualEdits.test.ts +39 -466
  14. package/src/components/editor/manualEdits.ts +6 -168
  15. package/src/components/editor/manualEditsDom.ts +361 -1
  16. package/src/components/editor/manualEditsParsing.ts +2 -240
  17. package/src/components/editor/manualEditsTypes.ts +1 -40
  18. package/src/components/editor/useDomEditOverlayGestures.ts +25 -8
  19. package/src/components/nle/NLEPreview.tsx +1 -1
  20. package/src/components/sidebar/CompositionsTab.tsx +9 -3
  21. package/src/contexts/DomEditContext.tsx +3 -0
  22. package/src/contexts/FileManagerContext.tsx +3 -0
  23. package/src/hooks/useAppHotkeys.ts +1 -4
  24. package/src/hooks/useDomEditCommits.ts +82 -77
  25. package/src/hooks/useDomEditSession.ts +4 -16
  26. package/src/hooks/useFileManager.ts +10 -1
  27. package/src/hooks/useManifestPersistence.ts +51 -187
  28. package/src/hooks/usePanelLayout.ts +10 -3
  29. package/src/hooks/usePreviewInteraction.ts +0 -1
  30. package/src/hooks/useStudioUrlState.ts +188 -0
  31. package/src/player/components/Player.tsx +15 -1
  32. package/src/player/components/PlayerControls.test.ts +17 -0
  33. package/src/player/components/PlayerControls.tsx +61 -0
  34. package/src/player/hooks/usePlaybackKeyboard.test.ts +174 -0
  35. package/src/player/hooks/usePlaybackKeyboard.ts +18 -15
  36. package/src/player/hooks/useTimelinePlayer.seek.test.ts +329 -0
  37. package/src/player/hooks/useTimelinePlayer.ts +76 -18
  38. package/src/player/hooks/useTimelineSyncCallbacks.ts +10 -4
  39. package/src/player/lib/playbackAdapter.test.ts +50 -0
  40. package/src/player/lib/playbackAdapter.ts +2 -2
  41. package/src/player/lib/playbackTypes.ts +1 -1
  42. package/src/player/lib/timelineDOM.ts +4 -2
  43. package/src/player/lib/timelineIframeHelpers.ts +63 -7
  44. package/src/player/store/playerStore.test.ts +105 -1
  45. package/src/player/store/playerStore.ts +12 -1
  46. package/src/utils/projectRouting.test.ts +15 -0
  47. package/src/utils/projectRouting.ts +46 -9
  48. package/src/utils/sourcePatcher.ts +50 -14
  49. package/src/utils/studioPreviewHelpers.test.ts +56 -0
  50. package/src/utils/studioPreviewHelpers.ts +51 -13
  51. package/src/utils/studioUiPreferences.test.ts +3 -0
  52. package/src/utils/studioUiPreferences.ts +4 -0
  53. package/src/utils/studioUrlState.test.ts +249 -0
  54. package/src/utils/studioUrlState.ts +135 -0
  55. 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 { unmutePreviewMedia } from "../lib/timelineIframeHelpers";
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
- // Send to runtime via bridge (works with both new and CDN runtime)
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
- }, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate, stopRAFLoop, stopReverseLoop]);
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
- [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
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) return;
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
- stopRAFLoop();
334
- if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
335
- shuttleDirectionRef.current = null;
336
- shuttleSpeedIndexRef.current = 0;
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
- [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
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
- 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,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");