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