@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
@@ -1,6 +1,7 @@
1
1
  import { useRef, useState, useCallback, useEffect, memo } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
4
+ import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
4
5
  import { usePlayerStore, liveTime } from "../store/playerStore";
5
6
 
6
7
  const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
@@ -57,8 +58,10 @@ export const PlayerControls = memo(function PlayerControls({
57
58
  const duration = usePlayerStore((s) => s.duration);
58
59
  const timelineReady = usePlayerStore((s) => s.timelineReady);
59
60
  const playbackRate = usePlayerStore((s) => s.playbackRate);
61
+ const audioMuted = usePlayerStore((s) => s.audioMuted);
60
62
  const loopEnabled = usePlayerStore((s) => s.loopEnabled);
61
63
  const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
64
+ const setAudioMuted = usePlayerStore.getState().setAudioMuted;
62
65
  const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
63
66
  const inPoint = usePlayerStore((s) => s.inPoint);
64
67
  const outPoint = usePlayerStore((s) => s.outPoint);
@@ -84,6 +87,13 @@ export const PlayerControls = memo(function PlayerControls({
84
87
  const durationRef = useRef(duration);
85
88
  durationRef.current = duration;
86
89
  const controlsDisabled = disabled || !timelineReady;
90
+ const audioAutoMuted = playbackRate > 1;
91
+ const effectiveAudioMuted = shouldMutePreviewAudio(audioMuted, playbackRate);
92
+ const muteButtonLabel = audioAutoMuted
93
+ ? "Audio muted above 1x speed"
94
+ : audioMuted
95
+ ? "Unmute audio"
96
+ : "Mute audio";
87
97
  useMountEffect(() => {
88
98
  const updateProgress = (t: number) => {
89
99
  currentTimeRef.current = t;
@@ -420,6 +430,57 @@ export const PlayerControls = memo(function PlayerControls({
420
430
  </div>
421
431
  </div>
422
432
 
433
+ {/* Mute toggle */}
434
+ <button
435
+ type="button"
436
+ onClick={() => {
437
+ if (!audioAutoMuted) setAudioMuted(!audioMuted);
438
+ }}
439
+ disabled={controlsDisabled || audioAutoMuted}
440
+ title={muteButtonLabel}
441
+ aria-label={muteButtonLabel}
442
+ aria-pressed={effectiveAudioMuted}
443
+ className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors disabled:pointer-events-none ${
444
+ effectiveAudioMuted
445
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
446
+ : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
447
+ } ${audioAutoMuted ? "opacity-70" : ""}`}
448
+ >
449
+ {effectiveAudioMuted ? (
450
+ <svg
451
+ width="13"
452
+ height="13"
453
+ viewBox="0 0 24 24"
454
+ fill="none"
455
+ stroke="currentColor"
456
+ strokeWidth="2"
457
+ strokeLinecap="round"
458
+ strokeLinejoin="round"
459
+ aria-hidden="true"
460
+ >
461
+ <path d="M11 5 6 9H3v6h3l5 4V5Z" />
462
+ <path d="m19 9-6 6" />
463
+ <path d="m13 9 6 6" />
464
+ </svg>
465
+ ) : (
466
+ <svg
467
+ width="13"
468
+ height="13"
469
+ viewBox="0 0 24 24"
470
+ fill="none"
471
+ stroke="currentColor"
472
+ strokeWidth="2"
473
+ strokeLinecap="round"
474
+ strokeLinejoin="round"
475
+ aria-hidden="true"
476
+ >
477
+ <path d="M11 5 6 9H3v6h3l5 4V5Z" />
478
+ <path d="M15.5 8.5a5 5 0 0 1 0 7" />
479
+ <path d="M18.5 5.5a9 9 0 0 1 0 13" />
480
+ </svg>
481
+ )}
482
+ </button>
483
+
423
484
  {/* Speed control */}
424
485
  <div ref={speedMenuContainerRef} className="relative flex-shrink-0">
425
486
  <button
@@ -0,0 +1,174 @@
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 { usePlaybackKeyboard } from "./usePlaybackKeyboard";
7
+ import { usePlayerStore } from "../store/playerStore";
8
+
9
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ usePlayerStore.getState().reset();
14
+ });
15
+
16
+ interface Spies {
17
+ seek: ReturnType<typeof vi.fn>;
18
+ play: ReturnType<typeof vi.fn>;
19
+ playBackward: ReturnType<typeof vi.fn>;
20
+ pause: ReturnType<typeof vi.fn>;
21
+ }
22
+
23
+ interface HookHandle {
24
+ dispatch: (event: KeyboardEvent) => void;
25
+ release: (event: KeyboardEvent) => void;
26
+ spies: Spies;
27
+ }
28
+
29
+ function setupHook(): HookHandle {
30
+ const spies: Spies = {
31
+ seek: vi.fn(),
32
+ play: vi.fn(),
33
+ playBackward: vi.fn(),
34
+ pause: vi.fn(),
35
+ };
36
+
37
+ let captured: ReturnType<typeof usePlaybackKeyboard> | null = null;
38
+
39
+ function Harness() {
40
+ const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
41
+ const shuttleDirectionRef = React.useRef<"forward" | "backward" | null>(null);
42
+ const shuttleSpeedIndexRef = React.useRef(0);
43
+ const iframeShortcutCleanupRef = React.useRef<(() => void) | null>(null);
44
+ const result = usePlaybackKeyboard({
45
+ iframeRef,
46
+ shuttleDirectionRef,
47
+ shuttleSpeedIndexRef,
48
+ iframeShortcutCleanupRef,
49
+ getAdapter: () => null,
50
+ ...spies,
51
+ });
52
+ useEffect(() => {
53
+ captured = result;
54
+ });
55
+ return null;
56
+ }
57
+
58
+ const host = document.createElement("div");
59
+ document.body.append(host);
60
+ const root = createRoot(host);
61
+ act(() => {
62
+ root.render(React.createElement(Harness));
63
+ });
64
+
65
+ if (!captured) throw new Error("usePlaybackKeyboard harness did not capture handlers");
66
+
67
+ return {
68
+ dispatch: (event) => captured!.playbackKeyDownRef.current(event),
69
+ release: (event) => captured!.playbackKeyUpRef.current(event),
70
+ spies,
71
+ };
72
+ }
73
+
74
+ function keydown(init: { code: string; key: string; shiftKey?: boolean }): KeyboardEvent {
75
+ return new KeyboardEvent("keydown", {
76
+ code: init.code,
77
+ key: init.key,
78
+ shiftKey: init.shiftKey ?? false,
79
+ cancelable: true,
80
+ });
81
+ }
82
+
83
+ function keyup(init: { code: string; key: string }): KeyboardEvent {
84
+ return new KeyboardEvent("keyup", { code: init.code, key: init.key });
85
+ }
86
+
87
+ describe("usePlaybackKeyboard — keyboard layout independence (#834)", () => {
88
+ it("'Jump to in-point' fires on physical KeyA in a QWERTY layout", () => {
89
+ const { dispatch, spies } = setupHook();
90
+ usePlayerStore.setState({ inPoint: 1.5 });
91
+
92
+ act(() => {
93
+ dispatch(keydown({ code: "KeyA", key: "a" }));
94
+ });
95
+
96
+ expect(spies.seek).toHaveBeenCalledWith(1.5, { keepPlaying: true });
97
+ });
98
+
99
+ it("'Jump to in-point' fires on AZERTY (physical KeyQ produces e.key='a')", () => {
100
+ const { dispatch, spies } = setupHook();
101
+ usePlayerStore.setState({ inPoint: 2.5 });
102
+
103
+ act(() => {
104
+ dispatch(keydown({ code: "KeyQ", key: "a" }));
105
+ });
106
+
107
+ expect(spies.seek).toHaveBeenCalledWith(2.5, { keepPlaying: true });
108
+ });
109
+
110
+ it("AZERTY 'A' physical key (e.key='q') no longer triggers in-point seek", () => {
111
+ const { dispatch, spies } = setupHook();
112
+ usePlayerStore.setState({ inPoint: 4.0 });
113
+
114
+ act(() => {
115
+ dispatch(keydown({ code: "KeyA", key: "q" }));
116
+ });
117
+
118
+ expect(spies.seek).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it("Shift+I clears the in-point (e.key='I' is matched after lowercasing)", () => {
122
+ const { dispatch } = setupHook();
123
+ usePlayerStore.setState({ inPoint: 3.0 });
124
+
125
+ act(() => {
126
+ dispatch(keydown({ code: "KeyI", key: "I", shiftKey: true }));
127
+ });
128
+
129
+ expect(usePlayerStore.getState().inPoint).toBeNull();
130
+ });
131
+
132
+ it("K-held + L steps forward one frame (combo uses character, not physical position)", () => {
133
+ const { dispatch, spies } = setupHook();
134
+ usePlayerStore.setState({ currentTime: 0 });
135
+
136
+ act(() => {
137
+ dispatch(keydown({ code: "KeyK", key: "k" }));
138
+ });
139
+ act(() => {
140
+ dispatch(keydown({ code: "KeyL", key: "l" }));
141
+ });
142
+
143
+ expect(spies.seek).toHaveBeenCalledTimes(1);
144
+ expect(spies.play).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it("releasing K removes it from the pressed set so subsequent L resumes forward shuttle", () => {
148
+ const { dispatch, release, spies } = setupHook();
149
+
150
+ act(() => {
151
+ dispatch(keydown({ code: "KeyK", key: "k" }));
152
+ });
153
+ act(() => {
154
+ release(keyup({ code: "KeyK", key: "k" }));
155
+ });
156
+ act(() => {
157
+ dispatch(keydown({ code: "KeyL", key: "l" }));
158
+ });
159
+
160
+ expect(spies.play).toHaveBeenCalledTimes(1);
161
+ expect(spies.seek).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it("Space (universal e.code) still toggles play", () => {
165
+ const { dispatch, spies } = setupHook();
166
+ usePlayerStore.setState({ isPlaying: false });
167
+
168
+ act(() => {
169
+ dispatch(keydown({ code: "Space", key: " " }));
170
+ });
171
+
172
+ expect(spies.play).toHaveBeenCalledTimes(1);
173
+ });
174
+ });
@@ -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,50 +108,52 @@ 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");
131
132
  return;
132
133
  }
133
- if (e.code === "KeyI") {
134
+ if (key === "i") {
134
135
  e.preventDefault();
135
136
  const t = getAdapter()?.getTime() ?? usePlayerStore.getState().currentTime;
136
137
  usePlayerStore.getState().setInPoint(e.shiftKey ? null : t);
137
138
  return;
138
139
  }
139
- if (e.code === "KeyO") {
140
+ if (key === "o") {
140
141
  e.preventDefault();
141
142
  const t = getAdapter()?.getTime() ?? usePlayerStore.getState().currentTime;
142
143
  usePlayerStore.getState().setOutPoint(e.shiftKey ? null : t);
143
144
  return;
144
145
  }
145
- if (e.code === "KeyA") {
146
+ if (key === "a") {
146
147
  e.preventDefault();
147
- seek(usePlayerStore.getState().inPoint ?? 0);
148
+ seek(usePlayerStore.getState().inPoint ?? 0, { keepPlaying: true });
148
149
  return;
149
150
  }
150
- if (e.code === "KeyE") {
151
+ if (key === "e") {
151
152
  e.preventDefault();
152
153
  const { outPoint } = usePlayerStore.getState();
153
- seek(outPoint ?? getAdapter()?.getDuration() ?? usePlayerStore.getState().duration);
154
+ seek(outPoint ?? getAdapter()?.getDuration() ?? usePlayerStore.getState().duration, {
155
+ keepPlaying: true,
156
+ });
154
157
  return;
155
158
  }
156
159
  },
@@ -158,7 +161,7 @@ export function usePlaybackKeyboard({
158
161
  );
159
162
 
160
163
  const handlePlaybackKeyUp = useCallback((e: KeyboardEvent) => {
161
- pressedCodesRef.current.delete(e.code);
164
+ pressedKeysRef.current.delete(e.key.toLowerCase());
162
165
  }, []);
163
166
 
164
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
+ });