@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.
- package/dist/assets/{hyperframes-player-T-ME1rqL.js → hyperframes-player-D0Yi3xMP.js} +2 -2
- package/dist/assets/{index-Bne9FFeo.css → index-Ckqo37Co.css} +1 -1
- package/dist/assets/index-Yvtxngdi.js +116 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +54 -31
- package/src/components/StudioGlobalDragOverlay.tsx +26 -0
- package/src/components/StudioRightPanel.tsx +0 -2
- package/src/components/editor/DomEditOverlay.test.ts +1 -0
- package/src/components/editor/DomEditOverlay.tsx +2 -1
- package/src/components/editor/PropertyPanel.tsx +27 -36
- package/src/components/editor/domEditingElement.ts +1 -0
- package/src/components/editor/manualEdits.test.ts +39 -466
- package/src/components/editor/manualEdits.ts +6 -168
- package/src/components/editor/manualEditsDom.ts +361 -1
- package/src/components/editor/manualEditsParsing.ts +2 -240
- package/src/components/editor/manualEditsTypes.ts +1 -40
- package/src/components/editor/useDomEditOverlayGestures.ts +25 -8
- package/src/components/nle/NLEPreview.tsx +1 -1
- package/src/components/sidebar/CompositionsTab.tsx +9 -3
- package/src/contexts/DomEditContext.tsx +3 -0
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/hooks/useAppHotkeys.ts +1 -4
- package/src/hooks/useDomEditCommits.ts +82 -77
- package/src/hooks/useDomEditSession.ts +4 -16
- package/src/hooks/useFileManager.ts +10 -1
- package/src/hooks/useManifestPersistence.ts +51 -187
- package/src/hooks/usePanelLayout.ts +10 -3
- package/src/hooks/usePreviewInteraction.ts +0 -1
- package/src/hooks/useStudioUrlState.ts +188 -0
- package/src/player/components/Player.tsx +15 -1
- package/src/player/components/PlayerControls.test.ts +17 -0
- package/src/player/components/PlayerControls.tsx +61 -0
- package/src/player/hooks/usePlaybackKeyboard.test.ts +174 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +18 -15
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +329 -0
- package/src/player/hooks/useTimelinePlayer.ts +76 -18
- package/src/player/hooks/useTimelineSyncCallbacks.ts +10 -4
- package/src/player/lib/playbackAdapter.test.ts +50 -0
- package/src/player/lib/playbackAdapter.ts +2 -2
- package/src/player/lib/playbackTypes.ts +1 -1
- package/src/player/lib/timelineDOM.ts +4 -2
- package/src/player/lib/timelineIframeHelpers.ts +63 -7
- package/src/player/store/playerStore.test.ts +105 -1
- package/src/player/store/playerStore.ts +12 -1
- package/src/utils/projectRouting.test.ts +15 -0
- package/src/utils/projectRouting.ts +46 -9
- package/src/utils/sourcePatcher.ts +50 -14
- package/src/utils/studioPreviewHelpers.test.ts +56 -0
- package/src/utils/studioPreviewHelpers.ts +51 -13
- package/src/utils/studioUiPreferences.test.ts +3 -0
- package/src/utils/studioUiPreferences.ts +4 -0
- package/src/utils/studioUrlState.test.ts +249 -0
- package/src/utils/studioUrlState.ts +135 -0
- package/dist/assets/index-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
|
|
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
|
-
|
|
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 (
|
|
111
|
+
if (key === "k") {
|
|
111
112
|
e.preventDefault();
|
|
112
113
|
pause();
|
|
113
114
|
return;
|
|
114
115
|
}
|
|
115
|
-
if (
|
|
116
|
+
if (key === "j") {
|
|
116
117
|
e.preventDefault();
|
|
117
|
-
if (
|
|
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 (
|
|
125
|
+
if (key === "l") {
|
|
125
126
|
e.preventDefault();
|
|
126
|
-
if (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
+
});
|