@hyperframes/studio 0.8.58 → 0.8.60
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-zGyOcwwh.js → hyperframes-player-Di7GyCOP.js} +3 -3
- package/dist/assets/{index-DKJpHYYd.css → index-CBKb_0TI.css} +1 -1
- package/dist/assets/{index-d8ferjRc.js → index-Dpq2lkOm.js} +190 -190
- package/dist/assets/{index-ZJaRCDYV.js → index-Dx_pt9m4.js} +1 -1
- package/dist/assets/{index-BcE2OSVA.js → index-jAjhQTrB.js} +1 -1
- package/dist/index.d.ts +549 -13
- package/dist/index.html +2 -2
- package/dist/index.js +23133 -21941
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/components/StudioHeader.tsx +2 -13
- package/src/components/TimelineToolbar.test.tsx +42 -0
- package/src/components/TimelineToolbar.tsx +22 -8
- package/src/components/editor/audioInspectorInput.test.ts +55 -0
- package/src/components/editor/audioInspectorInput.ts +55 -0
- package/src/components/editor/propertyPanelFill.tsx +3 -2
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +172 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +87 -1
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +92 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +13 -9
- package/src/components/editor/propertyPanelFlatSliderReadout.tsx +106 -0
- package/src/components/editor/useColorGradingController.ts +2 -6
- package/src/components/icons/AudioMetersIcon.tsx +16 -0
- package/src/components/icons/InspectorIcon.tsx +18 -0
- package/src/components/icons/ReverseGradientIcon.tsx +23 -0
- package/src/components/icons/RippleEditIcon.tsx +19 -0
- package/src/components/nle/AudioMeterStrip.test.tsx +395 -0
- package/src/components/nle/AudioMeterStrip.tsx +430 -0
- package/src/components/nle/TimelinePane.tsx +29 -23
- package/src/components/ui/Menu.test.tsx +3 -4
- package/src/components/ui/Menu.tsx +18 -5
- package/src/components/ui/Select.tsx +7 -1
- package/src/components/ui/Tooltip.tsx +6 -1
- package/src/contexts/TimelineEditContext.tsx +4 -0
- package/src/hooks/useTimelineAssetDropOps.ts +9 -1
- package/src/index.ts +16 -0
- package/src/player/components/AudioWaveform.tsx +16 -7
- package/src/player/components/ImageThumbnail.tsx +3 -5
- package/src/player/components/LayerDisclosureRow.tsx +9 -4
- package/src/player/components/PlayheadIndicator.tsx +3 -3
- package/src/player/components/Timeline.test.ts +22 -31
- package/src/player/components/Timeline.tsx +34 -7
- package/src/player/components/TimelineAutomationLane.test.tsx +1 -1
- package/src/player/components/TimelineAutomationLane.tsx +5 -5
- package/src/player/components/TimelineCanvas.tsx +28 -71
- package/src/player/components/TimelineClip.test.tsx +41 -4
- package/src/player/components/TimelineClip.tsx +25 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +3 -1
- package/src/player/components/TimelineClipDiamonds.tsx +2 -2
- package/src/player/components/TimelineClipFades.test.tsx +171 -0
- package/src/player/components/TimelineClipFades.tsx +302 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +2 -2
- package/src/player/components/TimelineEmptyState.tsx +1 -1
- package/src/player/components/TimelineFxButton.tsx +10 -8
- package/src/player/components/TimelineGroupHeader.tsx +18 -9
- package/src/player/components/TimelineGroupLaneLabels.tsx +12 -5
- package/src/player/components/TimelineGroupRow.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +14 -0
- package/src/player/components/TimelineOverlays.tsx +164 -165
- package/src/player/components/TimelineParts.test.tsx +101 -0
- package/src/player/components/TimelineParts.tsx +74 -0
- package/src/player/components/TimelinePartsParity.test.tsx +58 -0
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineProvider.tsx +25 -0
- package/src/player/components/TimelineRazorInteraction.tsx +2 -2
- package/src/player/components/TimelineRuler.tsx +3 -2
- package/src/player/components/TimelineRulerPart.tsx +22 -0
- package/src/player/components/TimelineShortcutHint.tsx +2 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.tsx +4 -6
- package/src/player/components/TimelineTrackPlainHeader.tsx +10 -3
- package/src/player/components/VideoThumbnail.tsx +3 -5
- package/src/player/components/timelineMotionStyles.test.ts +96 -30
- package/src/player/components/timelineProviderStateBuilders.ts +20 -0
- package/src/player/components/timelineTheme.test.ts +14 -0
- package/src/player/components/timelineTheme.ts +12 -11
- package/src/player/components/useTimelineProviderState.tsx +19 -12
- package/src/player/lib/timelineElementHelpers.ts +20 -0
- package/src/player/store/timelineElement.ts +5 -0
- package/src/styles/hex-baseline.json +4 -23
- package/src/styles/studio.css +43 -47
- package/src/styles/theme.css +109 -1
- package/src/styles/theme.test.ts +9 -0
- package/src/utils/audioMeterMath.test.ts +44 -0
- package/src/utils/audioMeterMath.ts +79 -0
- package/src/utils/audioMeterVisibility.ts +14 -0
- package/src/utils/studioHelpers.ts +21 -0
- package/src/utils/studioUiPreferences.test.ts +10 -0
- package/src/utils/studioUiPreferences.ts +5 -0
- package/src/utils/timelineAssetDrop.test.ts +32 -0
- package/src/utils/timelineAssetDrop.ts +6 -1
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// fallow-ignore-file code-duplication
|
|
3
|
+
|
|
4
|
+
import { act } from "react";
|
|
5
|
+
import { MAX_AUDIO_GAIN } from "@hyperframes/core/audio-gain";
|
|
6
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
9
|
+
import type { TimelineElement } from "../../player/store/timelineElement";
|
|
10
|
+
import { useAudioMetersVisible } from "../../utils/audioMeterVisibility";
|
|
11
|
+
import { SILENT_CHANNEL } from "../../utils/audioMeterMath";
|
|
12
|
+
import {
|
|
13
|
+
AudioMeterStrip,
|
|
14
|
+
evictGoneMeterState,
|
|
15
|
+
followMeterHook,
|
|
16
|
+
stepAndPaintStrips,
|
|
17
|
+
} from "./AudioMeterStrip";
|
|
18
|
+
|
|
19
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
20
|
+
|
|
21
|
+
const iframe = { contentWindow: null as unknown };
|
|
22
|
+
const previewIframeRef = { current: iframe };
|
|
23
|
+
vi.mock("../../contexts/StudioContext", () => ({
|
|
24
|
+
useStudioShellContext: () => ({ previewIframeRef }),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const onSetAudioGroupAttributeLive = vi.fn();
|
|
28
|
+
const onSetAudioGroupAttributeQuiet = vi.fn();
|
|
29
|
+
vi.mock("../../contexts/TimelineEditContext", () => ({
|
|
30
|
+
useTimelineEditContextOptional: () => ({
|
|
31
|
+
onSetAudioGroupAttributeLive,
|
|
32
|
+
onSetAudioGroupAttributeQuiet,
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
function stubTrackRect(): () => void {
|
|
37
|
+
const original = Element.prototype.getBoundingClientRect;
|
|
38
|
+
Element.prototype.getBoundingClientRect = function (): DOMRect {
|
|
39
|
+
return {
|
|
40
|
+
left: 0,
|
|
41
|
+
top: 0,
|
|
42
|
+
right: 8,
|
|
43
|
+
bottom: 100,
|
|
44
|
+
width: 8,
|
|
45
|
+
height: 100,
|
|
46
|
+
x: 0,
|
|
47
|
+
y: 0,
|
|
48
|
+
toJSON: () => ({}),
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
return () => {
|
|
52
|
+
Element.prototype.getBoundingClientRect = original;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function drag(fader: HTMLElement, clientY: number) {
|
|
57
|
+
const restoreRect = stubTrackRect();
|
|
58
|
+
const original = Element.prototype.setPointerCapture;
|
|
59
|
+
Element.prototype.setPointerCapture = vi.fn();
|
|
60
|
+
try {
|
|
61
|
+
for (const type of ["pointerdown", "pointermove", "pointerup"]) {
|
|
62
|
+
act(() =>
|
|
63
|
+
fader.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 1, clientY })),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
} finally {
|
|
67
|
+
Element.prototype.setPointerCapture = original;
|
|
68
|
+
restoreRect();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const makeHook = (groups: Record<string, { l: number; r: number }> = {}) => ({
|
|
73
|
+
start: vi.fn(),
|
|
74
|
+
stop: vi.fn(),
|
|
75
|
+
read: vi.fn(() => ({ master: { l: 1, r: 0.5 }, groups })),
|
|
76
|
+
});
|
|
77
|
+
const setHook = (hook: ReturnType<typeof makeHook> | null) => {
|
|
78
|
+
iframe.contentWindow = hook ? { __hf: { audioMeter: hook } } : null;
|
|
79
|
+
};
|
|
80
|
+
const clip = (extra: Partial<TimelineElement>) =>
|
|
81
|
+
({ id: "a", tag: "audio", ...extra }) as TimelineElement;
|
|
82
|
+
|
|
83
|
+
let frames: FrameRequestCallback[] = [];
|
|
84
|
+
const tick = () => {
|
|
85
|
+
const run = frames;
|
|
86
|
+
frames = [];
|
|
87
|
+
act(() => run.forEach((cb) => cb(performance.now() + 16)));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
frames = [];
|
|
92
|
+
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => frames.push(cb));
|
|
93
|
+
vi.stubGlobal("cancelAnimationFrame", () => {});
|
|
94
|
+
usePlayerStore.setState({ elements: [], audioVolume: 1 });
|
|
95
|
+
useAudioMetersVisible.setState({ visible: true });
|
|
96
|
+
onSetAudioGroupAttributeLive.mockClear();
|
|
97
|
+
onSetAudioGroupAttributeQuiet.mockClear();
|
|
98
|
+
});
|
|
99
|
+
const roots: Root[] = [];
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
act(() => roots.splice(0).forEach((root) => root.unmount()));
|
|
102
|
+
document.body.innerHTML = "";
|
|
103
|
+
vi.unstubAllGlobals();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function mount() {
|
|
107
|
+
const host = document.createElement("div");
|
|
108
|
+
document.body.append(host);
|
|
109
|
+
const root = createRoot(host);
|
|
110
|
+
act(() => root.render(<AudioMeterStrip />));
|
|
111
|
+
roots.push(root);
|
|
112
|
+
return { host, root };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe("AudioMeterStrip", () => {
|
|
116
|
+
it("is absent when the project has no audio", () => {
|
|
117
|
+
usePlayerStore.setState({ elements: [clip({ tag: "div" })] });
|
|
118
|
+
expect(mount().host.querySelector("[data-testid=audio-meter-strip]")).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("shows one strip per group plus Monitor, and hides when toggled off", () => {
|
|
122
|
+
usePlayerStore.setState({
|
|
123
|
+
elements: [
|
|
124
|
+
clip({ id: "1", audioGroup: "music", audioGroupLabel: "Music" }),
|
|
125
|
+
clip({ id: "2", audioGroup: "vo" }),
|
|
126
|
+
],
|
|
127
|
+
});
|
|
128
|
+
const { host } = mount();
|
|
129
|
+
expect(host.textContent).toContain("Music");
|
|
130
|
+
expect(host.textContent).toContain("vo");
|
|
131
|
+
expect(host.textContent).toContain("Monitor");
|
|
132
|
+
expect(host.textContent).not.toContain("Master");
|
|
133
|
+
act(() => useAudioMetersVisible.getState().setVisible(false));
|
|
134
|
+
expect(host.querySelector("[data-testid=audio-meter-strip]")).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("starts the taps once, drives the bars, follows a swapped preview window, and stops on unmount", () => {
|
|
138
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
|
|
139
|
+
const first = makeHook({ vo: { l: 1, r: 1 } });
|
|
140
|
+
setHook(first);
|
|
141
|
+
const { host, root } = mount();
|
|
142
|
+
const mask = host.querySelector<HTMLElement>("[data-testid=meter-mask]")!;
|
|
143
|
+
expect(mask.style.height).toBe("100%");
|
|
144
|
+
tick();
|
|
145
|
+
tick();
|
|
146
|
+
expect(first.start).toHaveBeenCalledTimes(1);
|
|
147
|
+
expect(mask.style.height).toBe("0%");
|
|
148
|
+
const peak = host.querySelector<HTMLElement>("[data-testid=meter-peak]")!;
|
|
149
|
+
expect(peak.style.bottom).toBe("100%");
|
|
150
|
+
expect(peak.style.transform).toBe("translateY(1px)");
|
|
151
|
+
|
|
152
|
+
const second = makeHook();
|
|
153
|
+
setHook(second);
|
|
154
|
+
tick();
|
|
155
|
+
expect(first.stop).toHaveBeenCalledTimes(1);
|
|
156
|
+
expect(second.start).toHaveBeenCalledTimes(1);
|
|
157
|
+
|
|
158
|
+
act(() => root.unmount());
|
|
159
|
+
roots.length = 0;
|
|
160
|
+
expect(second.stop).toHaveBeenCalledTimes(1);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("follows a new preview hook even when the old stop() throws", () => {
|
|
164
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
|
|
165
|
+
const first = makeHook({ vo: { l: 1, r: 1 } });
|
|
166
|
+
first.stop.mockImplementation(() => {
|
|
167
|
+
throw new Error("dead realm");
|
|
168
|
+
});
|
|
169
|
+
setHook(first);
|
|
170
|
+
mount();
|
|
171
|
+
tick();
|
|
172
|
+
const second = makeHook();
|
|
173
|
+
setHook(second);
|
|
174
|
+
tick();
|
|
175
|
+
expect(second.start).toHaveBeenCalledTimes(1);
|
|
176
|
+
tick();
|
|
177
|
+
expect(second.read).toHaveBeenCalled();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("keeps authored gain 2 above unity and writes exactly 1 at the midpoint", () => {
|
|
181
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo", audioGroupVolume: 2 })] });
|
|
182
|
+
const { host } = mount();
|
|
183
|
+
const fader = host.querySelector<HTMLElement>('[aria-label="vo volume"]')!;
|
|
184
|
+
expect(Number(fader.getAttribute("aria-valuenow"))).toBeGreaterThan(0);
|
|
185
|
+
expect(Number(fader.getAttribute("aria-valuenow"))).toBeLessThan(100);
|
|
186
|
+
expect(fader.getAttribute("aria-valuetext")).toBe("+6.0 dB");
|
|
187
|
+
expect(parseFloat(fader.querySelector<HTMLElement>("div")!.style.bottom)).toBeGreaterThan(50);
|
|
188
|
+
drag(fader, 50);
|
|
189
|
+
expect(onSetAudioGroupAttributeLive).toHaveBeenCalledWith("vo", "data-volume", "1");
|
|
190
|
+
expect(onSetAudioGroupAttributeQuiet).toHaveBeenCalledExactlyOnceWith(
|
|
191
|
+
"vo",
|
|
192
|
+
"data-volume",
|
|
193
|
+
"1",
|
|
194
|
+
"Set volume",
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("drags a group to the shared +12 dB ceiling", () => {
|
|
199
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
|
|
200
|
+
const fader = mount().host.querySelector<HTMLElement>('[aria-label="vo volume"]')!;
|
|
201
|
+
drag(fader, 0);
|
|
202
|
+
expect(Number(onSetAudioGroupAttributeQuiet.mock.calls[0]![2])).toBeCloseTo(MAX_AUDIO_GAIN, 6);
|
|
203
|
+
expect(onSetAudioGroupAttributeLive).toHaveBeenCalledWith("vo", "data-volume", "3.981072");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it.each([0.5, 1, 2, 3.98])(
|
|
207
|
+
"round-trips authored group gain %s through its displayed thumb",
|
|
208
|
+
(gain) => {
|
|
209
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo", audioGroupVolume: gain })] });
|
|
210
|
+
const fader = mount().host.querySelector<HTMLElement>('[aria-label="vo volume"]')!;
|
|
211
|
+
const thumb = fader.firstElementChild as HTMLElement;
|
|
212
|
+
drag(fader, 100 - parseFloat(thumb.style.bottom));
|
|
213
|
+
expect(Number(onSetAudioGroupAttributeQuiet.mock.calls[0]![2])).toBeCloseTo(gain, 6);
|
|
214
|
+
},
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
it("preserves low authored gain instead of rounding it to mute", () => {
|
|
218
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
|
|
219
|
+
const fader = mount().host.querySelector<HTMLElement>('[aria-label="vo volume"]')!;
|
|
220
|
+
drag(fader, 85);
|
|
221
|
+
expect(onSetAudioGroupAttributeQuiet).toHaveBeenCalledExactlyOnceWith(
|
|
222
|
+
"vo",
|
|
223
|
+
"data-volume",
|
|
224
|
+
"0.007943",
|
|
225
|
+
"Set volume",
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("keeps the Monitor fader within the player store range", () => {
|
|
230
|
+
usePlayerStore.setState({ elements: [clip({})] });
|
|
231
|
+
const fader = mount().host.querySelector<HTMLElement>('[aria-label="Monitor volume"]')!;
|
|
232
|
+
expect(fader.getAttribute("aria-valuemin")).toBe("-100");
|
|
233
|
+
expect(fader.getAttribute("aria-valuemax")).toBe("0");
|
|
234
|
+
drag(fader, 100);
|
|
235
|
+
expect(usePlayerStore.getState().audioVolume).toBe(0);
|
|
236
|
+
drag(fader, 0);
|
|
237
|
+
expect(usePlayerStore.getState().audioVolume).toBe(1);
|
|
238
|
+
act(() => fader.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })));
|
|
239
|
+
expect(usePlayerStore.getState().audioVolume).toBe(1);
|
|
240
|
+
expect(fader.getAttribute("aria-valuetext")).toBe("0.0 dB");
|
|
241
|
+
expect(onSetAudioGroupAttributeQuiet).not.toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it.each(["pointercancel", "lostpointercapture"])(
|
|
245
|
+
"finishes %s once and ignores subsequent hovering",
|
|
246
|
+
(event) => {
|
|
247
|
+
const restoreRect = stubTrackRect();
|
|
248
|
+
const original = Element.prototype.setPointerCapture;
|
|
249
|
+
Element.prototype.setPointerCapture = vi.fn();
|
|
250
|
+
try {
|
|
251
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
|
|
252
|
+
const fader = mount().host.querySelector<HTMLElement>('[aria-label="vo volume"]')!;
|
|
253
|
+
for (const [type, clientY] of [
|
|
254
|
+
["pointerdown", 50],
|
|
255
|
+
[event, 0],
|
|
256
|
+
["pointermove", 0],
|
|
257
|
+
["pointerup", 0],
|
|
258
|
+
] as const) {
|
|
259
|
+
act(() =>
|
|
260
|
+
fader.dispatchEvent(new PointerEvent(type, { bubbles: true, pointerId: 1, clientY })),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
expect(onSetAudioGroupAttributeLive).toHaveBeenCalledExactlyOnceWith(
|
|
264
|
+
"vo",
|
|
265
|
+
"data-volume",
|
|
266
|
+
"1",
|
|
267
|
+
);
|
|
268
|
+
expect(onSetAudioGroupAttributeQuiet).toHaveBeenCalledExactlyOnceWith(
|
|
269
|
+
"vo",
|
|
270
|
+
"data-volume",
|
|
271
|
+
"1",
|
|
272
|
+
"Set volume",
|
|
273
|
+
);
|
|
274
|
+
} finally {
|
|
275
|
+
Element.prototype.setPointerCapture = original;
|
|
276
|
+
restoreRect();
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
it.each([
|
|
282
|
+
["Home", "0"],
|
|
283
|
+
["End", "3.981072"],
|
|
284
|
+
["PageUp", "1.318257"],
|
|
285
|
+
["PageDown", "0.251189"],
|
|
286
|
+
])("supports the %s slider key", (key, gain) => {
|
|
287
|
+
usePlayerStore.setState({ elements: [clip({ audioGroup: "vo" })] });
|
|
288
|
+
const fader = mount().host.querySelector<HTMLElement>('[aria-label="vo volume"]')!;
|
|
289
|
+
act(() => fader.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true })));
|
|
290
|
+
expect(onSetAudioGroupAttributeQuiet).toHaveBeenCalledExactlyOnceWith(
|
|
291
|
+
"vo",
|
|
292
|
+
"data-volume",
|
|
293
|
+
gain,
|
|
294
|
+
"Set volume",
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("nudges by an equal visual step and exposes the gain readout", () => {
|
|
299
|
+
usePlayerStore.setState({ elements: [clip({})], audioVolume: 1 });
|
|
300
|
+
const fader = mount().host.querySelector<HTMLElement>('[aria-label="Monitor volume"]')!;
|
|
301
|
+
act(() =>
|
|
302
|
+
fader.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })),
|
|
303
|
+
);
|
|
304
|
+
expect(fader.getAttribute("aria-valuenow")).toBe("-2");
|
|
305
|
+
expect(fader.getAttribute("aria-valuetext")).toBe("-1.2 dB");
|
|
306
|
+
act(() => fader.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })));
|
|
307
|
+
expect(usePlayerStore.getState().audioVolume).toBeCloseTo(1, 12);
|
|
308
|
+
expect(fader.getAttribute("aria-valuenow")).toBe("0");
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("followMeterHook", () => {
|
|
313
|
+
it("leaves the same hook attached", () => {
|
|
314
|
+
const hook = makeHook();
|
|
315
|
+
expect(followMeterHook(hook, hook)).toBe(hook);
|
|
316
|
+
expect(hook.stop).not.toHaveBeenCalled();
|
|
317
|
+
expect(hook.start).not.toHaveBeenCalled();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("stops the old hook and starts the new one", () => {
|
|
321
|
+
const first = makeHook();
|
|
322
|
+
const second = makeHook();
|
|
323
|
+
expect(followMeterHook(first, second)).toBe(second);
|
|
324
|
+
expect(first.stop).toHaveBeenCalledTimes(1);
|
|
325
|
+
expect(second.start).toHaveBeenCalledTimes(1);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("still attaches the new hook when stop() throws", () => {
|
|
329
|
+
const first = makeHook();
|
|
330
|
+
first.stop.mockImplementation(() => {
|
|
331
|
+
throw new Error("dead realm");
|
|
332
|
+
});
|
|
333
|
+
const second = makeHook();
|
|
334
|
+
expect(followMeterHook(first, second)).toBe(second);
|
|
335
|
+
expect(second.start).toHaveBeenCalledTimes(1);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("returns the live hook even when start() throws", () => {
|
|
339
|
+
const live = makeHook();
|
|
340
|
+
live.start.mockImplementation(() => {
|
|
341
|
+
throw new Error("not ready");
|
|
342
|
+
});
|
|
343
|
+
expect(followMeterHook(null, live)).toBe(live);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("evictGoneMeterState", () => {
|
|
348
|
+
it("drops ids that are no longer in the strip list", () => {
|
|
349
|
+
const rest: [typeof SILENT_CHANNEL, typeof SILENT_CHANNEL] = [SILENT_CHANNEL, SILENT_CHANNEL];
|
|
350
|
+
const state = new Map<string | null, typeof rest>([
|
|
351
|
+
["gone", rest],
|
|
352
|
+
["vo", rest],
|
|
353
|
+
[null, rest],
|
|
354
|
+
]);
|
|
355
|
+
evictGoneMeterState(state, new Set(["vo", null]));
|
|
356
|
+
expect([...state.keys()]).toEqual(["vo", null]);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe("stepAndPaintStrips", () => {
|
|
361
|
+
it("paints a loud group and skips a silent strip already at rest", () => {
|
|
362
|
+
const loudMask = document.createElement("div");
|
|
363
|
+
const restMask = document.createElement("div");
|
|
364
|
+
restMask.style.height = "50%";
|
|
365
|
+
const rest: [typeof SILENT_CHANNEL, typeof SILENT_CHANNEL] = [SILENT_CHANNEL, SILENT_CHANNEL];
|
|
366
|
+
const state = new Map<string | null, typeof rest>([["rest", rest]]);
|
|
367
|
+
const bars = new Map([
|
|
368
|
+
[
|
|
369
|
+
"loud",
|
|
370
|
+
[
|
|
371
|
+
{ mask: loudMask, peak: null },
|
|
372
|
+
{ mask: null, peak: null },
|
|
373
|
+
] as const,
|
|
374
|
+
],
|
|
375
|
+
[
|
|
376
|
+
"rest",
|
|
377
|
+
[
|
|
378
|
+
{ mask: restMask, peak: null },
|
|
379
|
+
{ mask: null, peak: null },
|
|
380
|
+
] as const,
|
|
381
|
+
],
|
|
382
|
+
]);
|
|
383
|
+
stepAndPaintStrips(
|
|
384
|
+
[{ id: "loud" }, { id: "rest" }],
|
|
385
|
+
state,
|
|
386
|
+
bars as never,
|
|
387
|
+
{ master: { l: 0, r: 0 }, groups: { loud: { l: 1, r: 1 } } },
|
|
388
|
+
0,
|
|
389
|
+
16,
|
|
390
|
+
);
|
|
391
|
+
expect(loudMask.style.height).toBe("0%");
|
|
392
|
+
expect(restMask.style.height).toBe("50%");
|
|
393
|
+
expect(state.has("loud")).toBe(true);
|
|
394
|
+
});
|
|
395
|
+
});
|