@hyperframes/studio 0.7.109 → 0.7.111
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-BYgqAPib.js → hyperframes-player-DDIlvdLL.js} +1 -1
- package/dist/assets/index-8lDGbNjx.css +1 -0
- package/dist/assets/{index-CfBGQdrX.js → index-B6B3bUGG.js} +132 -132
- package/dist/assets/{index-CIRxodzK.js → index-COUW8ONf.js} +1 -1
- package/dist/assets/{index-Blxia-Lt.js → index-DB9iM545.js} +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.html +2 -2
- package/dist/index.js +3916 -3834
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioRightPanel.tsx +8 -50
- package/src/components/StudioRightPanel.types.ts +55 -0
- package/src/player/components/PlayerControls.tsx +7 -56
- package/src/player/components/TimelineAutomationLane.tsx +0 -175
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +1 -1
- package/src/player/components/TimelineAutomationLaneSlot.tsx +187 -0
- package/src/player/components/TimelineLanes.tsx +1 -1
- package/src/player/components/VolumeControl.styles.test.ts +22 -0
- package/src/player/components/VolumeControl.test.tsx +86 -0
- package/src/player/components/VolumeControl.tsx +104 -0
- package/src/player/hooks/useTimelinePlayer.ts +9 -3
- package/src/player/lib/playbackScrub.ts +1 -1
- package/src/player/lib/timelineIframeHelpers.test.ts +40 -2
- package/src/player/lib/timelineIframeHelpers.ts +23 -3
- package/src/player/store/playerStore.test.ts +17 -1
- package/src/player/store/playerStore.ts +8 -0
- package/src/styles/studio.css +69 -0
- package/src/utils/studioUiPreferences.test.ts +3 -0
- package/src/utils/studioUiPreferences.ts +9 -0
- package/dist/assets/index-DNkh9mbV.css +0 -1
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
const studioCss = readFileSync(new URL("../../styles/studio.css", import.meta.url), "utf8");
|
|
5
|
+
|
|
6
|
+
describe("preview volume range styles", () => {
|
|
7
|
+
it("uses the same compact thumb in Chromium and Firefox", () => {
|
|
8
|
+
const selectors = [
|
|
9
|
+
".hf-preview-volume-range::-webkit-slider-thumb",
|
|
10
|
+
".hf-preview-volume-range::-moz-range-thumb",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
for (const selector of selectors) {
|
|
14
|
+
const start = studioCss.indexOf(`${selector} {`);
|
|
15
|
+
const rule = studioCss.slice(start, studioCss.indexOf("}", start));
|
|
16
|
+
|
|
17
|
+
expect(start).toBeGreaterThanOrEqual(0);
|
|
18
|
+
expect(rule).toContain("width: 0.5rem");
|
|
19
|
+
expect(rule).toContain("height: 0.5rem");
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { VolumeControl } from "./VolumeControl";
|
|
7
|
+
|
|
8
|
+
vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
|
|
9
|
+
|
|
10
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
11
|
+
|
|
12
|
+
let host: HTMLDivElement;
|
|
13
|
+
let root: Root;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
host = document.createElement("div");
|
|
17
|
+
document.body.append(host);
|
|
18
|
+
root = createRoot(host);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
act(() => root.unmount());
|
|
23
|
+
document.body.innerHTML = "";
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function renderVolumeControl(overrides: Partial<React.ComponentProps<typeof VolumeControl>> = {}) {
|
|
27
|
+
const props = {
|
|
28
|
+
audioMuted: false,
|
|
29
|
+
audioVolume: 1,
|
|
30
|
+
disabled: false,
|
|
31
|
+
setAudioMuted: vi.fn(),
|
|
32
|
+
setAudioVolume: vi.fn(),
|
|
33
|
+
...overrides,
|
|
34
|
+
};
|
|
35
|
+
act(() => root.render(<VolumeControl {...props} />));
|
|
36
|
+
return { host, props };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setRangeValue(input: HTMLInputElement, value: string): void {
|
|
40
|
+
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
|
41
|
+
if (!setter) throw new Error("expected native range value setter");
|
|
42
|
+
setter.call(input, value);
|
|
43
|
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("VolumeControl", () => {
|
|
47
|
+
it("exposes the current preview volume as an accessible range", () => {
|
|
48
|
+
const { host } = renderVolumeControl({ audioVolume: 0.42 });
|
|
49
|
+
const slider = host.querySelector<HTMLInputElement>('input[aria-label="Preview volume"]');
|
|
50
|
+
|
|
51
|
+
expect(slider?.value).toBe("42");
|
|
52
|
+
expect(slider?.getAttribute("aria-valuetext")).toBe("42%");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("updates the preview volume while dragging", () => {
|
|
56
|
+
const { host, props } = renderVolumeControl();
|
|
57
|
+
const slider = host.querySelector<HTMLInputElement>('input[aria-label="Preview volume"]');
|
|
58
|
+
if (!slider) throw new Error("preview volume slider did not render");
|
|
59
|
+
|
|
60
|
+
act(() => setRangeValue(slider, "35"));
|
|
61
|
+
|
|
62
|
+
expect(props.setAudioVolume).toHaveBeenCalledWith(0.35);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("unmutes when a muted preview is given a positive volume", () => {
|
|
66
|
+
const { host, props } = renderVolumeControl({ audioMuted: true });
|
|
67
|
+
const slider = host.querySelector<HTMLInputElement>('input[aria-label="Preview volume"]');
|
|
68
|
+
if (!slider) throw new Error("preview volume slider did not render");
|
|
69
|
+
|
|
70
|
+
act(() => setRangeValue(slider, "60"));
|
|
71
|
+
|
|
72
|
+
expect(props.setAudioVolume).toHaveBeenCalledWith(0.6);
|
|
73
|
+
expect(props.setAudioMuted).toHaveBeenCalledWith(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("restores an audible level when unmuting from zero", () => {
|
|
77
|
+
const { host, props } = renderVolumeControl({ audioVolume: 0 });
|
|
78
|
+
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Unmute audio"]');
|
|
79
|
+
if (!button) throw new Error("unmute button did not render");
|
|
80
|
+
|
|
81
|
+
act(() => button.click());
|
|
82
|
+
|
|
83
|
+
expect(props.setAudioVolume).toHaveBeenCalledWith(1);
|
|
84
|
+
expect(props.setAudioMuted).toHaveBeenCalledWith(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { memo } from "react";
|
|
2
|
+
import { Tooltip } from "../../components/ui";
|
|
3
|
+
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
4
|
+
|
|
5
|
+
interface VolumeControlProps {
|
|
6
|
+
audioMuted: boolean;
|
|
7
|
+
audioVolume: number;
|
|
8
|
+
disabled: boolean;
|
|
9
|
+
setAudioMuted: (muted: boolean) => void;
|
|
10
|
+
setAudioVolume: (volume: number) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function VolumeIcon({ muted, volume }: { muted: boolean; volume: number }) {
|
|
14
|
+
const silent = muted || volume === 0;
|
|
15
|
+
return (
|
|
16
|
+
<svg
|
|
17
|
+
width="13"
|
|
18
|
+
height="13"
|
|
19
|
+
viewBox="0 0 24 24"
|
|
20
|
+
fill="none"
|
|
21
|
+
stroke="currentColor"
|
|
22
|
+
strokeWidth="2"
|
|
23
|
+
strokeLinecap="round"
|
|
24
|
+
strokeLinejoin="round"
|
|
25
|
+
aria-hidden="true"
|
|
26
|
+
>
|
|
27
|
+
<path d="M11 5 6 9H3v6h3l5 4V5Z" />
|
|
28
|
+
{silent ? (
|
|
29
|
+
<>
|
|
30
|
+
<path d="m19 9-6 6" />
|
|
31
|
+
<path d="m13 9 6 6" />
|
|
32
|
+
</>
|
|
33
|
+
) : (
|
|
34
|
+
<>
|
|
35
|
+
<path d="M15.5 8.5a5 5 0 0 1 0 7" />
|
|
36
|
+
{volume >= 0.5 ? <path d="M18.5 5.5a9 9 0 0 1 0 13" /> : null}
|
|
37
|
+
</>
|
|
38
|
+
)}
|
|
39
|
+
</svg>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const VolumeControl = memo(function VolumeControl({
|
|
44
|
+
audioMuted,
|
|
45
|
+
audioVolume,
|
|
46
|
+
disabled,
|
|
47
|
+
setAudioMuted,
|
|
48
|
+
setAudioVolume,
|
|
49
|
+
}: VolumeControlProps) {
|
|
50
|
+
const percentage = Math.round(audioVolume * 100);
|
|
51
|
+
const silent = audioMuted || audioVolume === 0;
|
|
52
|
+
const muteLabel = silent ? "Unmute audio" : "Mute audio";
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="group flex flex-shrink-0 items-center">
|
|
56
|
+
<div className="w-0 overflow-hidden opacity-0 transition-[width,opacity] duration-150 ease-out group-hover:w-14 group-hover:opacity-100 group-focus-within:w-14 group-focus-within:opacity-100">
|
|
57
|
+
<div className="relative mx-1 flex h-6 w-12 items-center">
|
|
58
|
+
<div className="absolute inset-x-0 h-0.5 overflow-hidden rounded-full bg-neutral-700">
|
|
59
|
+
<div
|
|
60
|
+
className="h-full rounded-full bg-neutral-300"
|
|
61
|
+
style={{ width: `${percentage}%` }}
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
<input
|
|
65
|
+
type="range"
|
|
66
|
+
min="0"
|
|
67
|
+
max="100"
|
|
68
|
+
step="1"
|
|
69
|
+
value={percentage}
|
|
70
|
+
disabled={disabled}
|
|
71
|
+
aria-label="Preview volume"
|
|
72
|
+
aria-valuetext={`${percentage}%`}
|
|
73
|
+
title={`Preview volume: ${percentage}%`}
|
|
74
|
+
onChange={(event) => {
|
|
75
|
+
const volume = Number(event.currentTarget.value) / 100;
|
|
76
|
+
setAudioVolume(volume);
|
|
77
|
+
if (audioMuted && volume > 0) setAudioMuted(false);
|
|
78
|
+
}}
|
|
79
|
+
className="hf-preview-volume-range absolute inset-0 w-full disabled:pointer-events-none"
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<Tooltip label={muteLabel}>
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={() => {
|
|
88
|
+
trackStudioEvent("playback", { action: "mute_toggle", muted: !silent });
|
|
89
|
+
if (silent && audioVolume === 0) setAudioVolume(1);
|
|
90
|
+
setAudioMuted(!silent);
|
|
91
|
+
}}
|
|
92
|
+
disabled={disabled}
|
|
93
|
+
aria-label={muteLabel}
|
|
94
|
+
aria-pressed={silent}
|
|
95
|
+
className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-30 ${
|
|
96
|
+
silent ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
|
|
97
|
+
}`}
|
|
98
|
+
>
|
|
99
|
+
<VolumeIcon muted={audioMuted} volume={audioVolume} />
|
|
100
|
+
</button>
|
|
101
|
+
</Tooltip>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
});
|
|
@@ -38,7 +38,11 @@ import {
|
|
|
38
38
|
parseTimelineFromDOM,
|
|
39
39
|
} from "../lib/timelineDOM";
|
|
40
40
|
import { normalizeToZones } from "../components/timelineZones";
|
|
41
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
setPreviewMediaMuted,
|
|
43
|
+
setPreviewMediaVolume,
|
|
44
|
+
setPreviewPlaybackRate,
|
|
45
|
+
} from "../lib/timelineIframeHelpers";
|
|
42
46
|
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
|
|
43
47
|
import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
|
|
44
48
|
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
|
|
@@ -231,8 +235,9 @@ export function useTimelinePlayer() {
|
|
|
231
235
|
} catch {}
|
|
232
236
|
}, []);
|
|
233
237
|
const applyPreviewAudioState = useCallback(() => {
|
|
234
|
-
const { audioMuted } = usePlayerStore.getState();
|
|
238
|
+
const { audioMuted, audioVolume } = usePlayerStore.getState();
|
|
235
239
|
setPreviewMediaMuted(iframeRef.current, audioMuted);
|
|
240
|
+
setPreviewMediaVolume(iframeRef.current, audioVolume);
|
|
236
241
|
}, []);
|
|
237
242
|
const play = useCallback(() => {
|
|
238
243
|
stopRAFLoop();
|
|
@@ -570,7 +575,8 @@ export function useTimelinePlayer() {
|
|
|
570
575
|
return usePlayerStore.subscribe((state, prev) => {
|
|
571
576
|
const playbackRateChanged = state.playbackRate !== prev.playbackRate;
|
|
572
577
|
const audioMutedChanged = state.audioMuted !== prev.audioMuted;
|
|
573
|
-
|
|
578
|
+
const audioVolumeChanged = state.audioVolume !== prev.audioVolume;
|
|
579
|
+
if (!playbackRateChanged && !audioMutedChanged && !audioVolumeChanged) return;
|
|
574
580
|
|
|
575
581
|
if (playbackRateChanged) {
|
|
576
582
|
applyPlaybackRate(state.playbackRate);
|
|
@@ -12,5 +12,5 @@ export function scrubMusicAtSeek(iframe: HTMLIFrameElement | null, nextTime: num
|
|
|
12
12
|
if (!music || s.audioMuted) return;
|
|
13
13
|
const rel = nextTime - music.start;
|
|
14
14
|
const audioFileTime = rel >= 0 && rel <= music.duration ? (music.playbackStart ?? 0) + rel : null;
|
|
15
|
-
scrubPreviewAudio(iframe, audioFileTime, music.domId ?? music.id);
|
|
15
|
+
scrubPreviewAudio(iframe, audioFileTime, music.domId ?? music.id, s.audioVolume);
|
|
16
16
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
-
import {
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
buildMissingCompositionElements,
|
|
5
|
+
scrubPreviewAudio,
|
|
6
|
+
setPreviewMediaVolume,
|
|
7
|
+
stopScrubPreviewAudio,
|
|
8
|
+
} from "./timelineIframeHelpers";
|
|
4
9
|
import type { IframeWindow } from "./playbackTypes";
|
|
5
10
|
|
|
6
11
|
function makeDoc(html: string): Document {
|
|
@@ -49,3 +54,36 @@ describe("buildMissingCompositionElements — hfId (R7)", () => {
|
|
|
49
54
|
expect(entry?.hfId).toBeUndefined();
|
|
50
55
|
});
|
|
51
56
|
});
|
|
57
|
+
|
|
58
|
+
describe("setPreviewMediaVolume", () => {
|
|
59
|
+
it("sends a clamped runtime volume to a direct preview iframe", () => {
|
|
60
|
+
const iframe = document.createElement("iframe");
|
|
61
|
+
document.body.append(iframe);
|
|
62
|
+
const postMessage = vi.spyOn(iframe.contentWindow!, "postMessage");
|
|
63
|
+
|
|
64
|
+
setPreviewMediaVolume(iframe, 1.5);
|
|
65
|
+
|
|
66
|
+
expect(postMessage).toHaveBeenCalledWith(
|
|
67
|
+
expect.objectContaining({ action: "set-volume", volume: 1 }),
|
|
68
|
+
"*",
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("scrubPreviewAudio", () => {
|
|
74
|
+
it("scales scrub feedback by the Studio preview volume", () => {
|
|
75
|
+
const iframe = document.createElement("iframe");
|
|
76
|
+
document.body.append(iframe);
|
|
77
|
+
const audio = iframe.contentDocument?.createElement("audio");
|
|
78
|
+
if (!audio || !iframe.contentDocument?.body) throw new Error("expected iframe audio document");
|
|
79
|
+
audio.id = "music";
|
|
80
|
+
audio.play = vi.fn(async () => {});
|
|
81
|
+
audio.pause = vi.fn();
|
|
82
|
+
iframe.contentDocument.body.append(audio);
|
|
83
|
+
|
|
84
|
+
scrubPreviewAudio(iframe, 0.5, "music", 0.4);
|
|
85
|
+
|
|
86
|
+
expect(audio.volume).toBeCloseTo(0.1);
|
|
87
|
+
stopScrubPreviewAudio();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -84,9 +84,15 @@ export function autoHealMissingCompositionIds(doc: Document): void {
|
|
|
84
84
|
|
|
85
85
|
type PreviewPlayerHost = HTMLElement & {
|
|
86
86
|
muted?: boolean;
|
|
87
|
+
volume?: number;
|
|
87
88
|
playbackRate?: number;
|
|
88
89
|
};
|
|
89
90
|
|
|
91
|
+
function normalizePreviewVolume(volume: number): number {
|
|
92
|
+
if (!Number.isFinite(volume)) return 1;
|
|
93
|
+
return Math.max(0, Math.min(1, volume));
|
|
94
|
+
}
|
|
95
|
+
|
|
90
96
|
function isPreviewPlayerHost(value: unknown): value is PreviewPlayerHost {
|
|
91
97
|
return value instanceof HTMLElement;
|
|
92
98
|
}
|
|
@@ -123,6 +129,19 @@ export function setPreviewMediaMuted(iframe: HTMLIFrameElement | null, muted: bo
|
|
|
123
129
|
} catch {}
|
|
124
130
|
}
|
|
125
131
|
|
|
132
|
+
export function setPreviewMediaVolume(iframe: HTMLIFrameElement | null, volume: number): void {
|
|
133
|
+
if (!iframe) return;
|
|
134
|
+
const nextVolume = normalizePreviewVolume(volume);
|
|
135
|
+
try {
|
|
136
|
+
const host = resolvePreviewPlayerHost(iframe);
|
|
137
|
+
if (host && typeof host.volume === "number") {
|
|
138
|
+
host.volume = nextVolume;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
postPreviewControl(iframe, "set-volume", { volume: nextVolume });
|
|
142
|
+
} catch {}
|
|
143
|
+
}
|
|
144
|
+
|
|
126
145
|
export function setPreviewPlaybackRate(
|
|
127
146
|
iframe: HTMLIFrameElement | null,
|
|
128
147
|
playbackRate: number,
|
|
@@ -193,14 +212,14 @@ function resolveScrubAudioEl(doc: Document, musicId?: string | null): HTMLAudioE
|
|
|
193
212
|
);
|
|
194
213
|
}
|
|
195
214
|
|
|
196
|
-
function applyScrub(el: HTMLAudioElement, audioFileTime: number): void {
|
|
215
|
+
function applyScrub(el: HTMLAudioElement, audioFileTime: number, previewVolume: number): void {
|
|
197
216
|
if (scrubAudioEl && scrubAudioEl !== el) stopScrubPreviewAudio();
|
|
198
217
|
if (scrubPrevMuted === null) scrubPrevMuted = el.muted;
|
|
199
218
|
if (scrubPrevVolume === null) scrubPrevVolume = el.volume;
|
|
200
219
|
scrubAudioEl = el;
|
|
201
220
|
try {
|
|
202
221
|
el.muted = false;
|
|
203
|
-
el.volume = SCRUB_VOLUME;
|
|
222
|
+
el.volume = SCRUB_VOLUME * normalizePreviewVolume(previewVolume);
|
|
204
223
|
if (Math.abs(el.currentTime - audioFileTime) > 0.04) el.currentTime = audioFileTime;
|
|
205
224
|
if (el.paused) void el.play().catch(() => {});
|
|
206
225
|
} catch {
|
|
@@ -218,6 +237,7 @@ export function scrubPreviewAudio(
|
|
|
218
237
|
iframe: HTMLIFrameElement | null,
|
|
219
238
|
audioFileTime: number | null,
|
|
220
239
|
musicId?: string | null,
|
|
240
|
+
previewVolume = 1,
|
|
221
241
|
): void {
|
|
222
242
|
if (!iframe) return;
|
|
223
243
|
if (audioFileTime === null) {
|
|
@@ -232,7 +252,7 @@ export function scrubPreviewAudio(
|
|
|
232
252
|
}
|
|
233
253
|
if (!doc) return;
|
|
234
254
|
const el = resolveScrubAudioEl(doc, musicId);
|
|
235
|
-
if (el) applyScrub(el, audioFileTime);
|
|
255
|
+
if (el) applyScrub(el, audioFileTime, previewVolume);
|
|
236
256
|
}
|
|
237
257
|
|
|
238
258
|
export function stopScrubPreviewAudio(): void {
|
|
@@ -22,6 +22,7 @@ describe("usePlayerStore", () => {
|
|
|
22
22
|
expectResettableDefaults(state);
|
|
23
23
|
expect(state.playbackRate).toBe(1);
|
|
24
24
|
expect(state.audioMuted).toBe(false);
|
|
25
|
+
expect(state.audioVolume).toBe(1);
|
|
25
26
|
expect(state.loopEnabled).toBe(false);
|
|
26
27
|
expect(state.zoomMode).toBe("fit");
|
|
27
28
|
expect(state.manualZoomPercent).toBe(100);
|
|
@@ -176,6 +177,19 @@ describe("usePlayerStore", () => {
|
|
|
176
177
|
});
|
|
177
178
|
});
|
|
178
179
|
|
|
180
|
+
describe("setAudioVolume", () => {
|
|
181
|
+
it("updates and clamps audioVolume", () => {
|
|
182
|
+
usePlayerStore.getState().setAudioVolume(0.35);
|
|
183
|
+
expect(usePlayerStore.getState().audioVolume).toBe(0.35);
|
|
184
|
+
|
|
185
|
+
usePlayerStore.getState().setAudioVolume(2);
|
|
186
|
+
expect(usePlayerStore.getState().audioVolume).toBe(1);
|
|
187
|
+
|
|
188
|
+
usePlayerStore.getState().setAudioVolume(-1);
|
|
189
|
+
expect(usePlayerStore.getState().audioVolume).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
179
193
|
describe("setLoopEnabled", () => {
|
|
180
194
|
it("updates loopEnabled", () => {
|
|
181
195
|
usePlayerStore.getState().setLoopEnabled(true);
|
|
@@ -572,10 +586,11 @@ describe("usePlayerStore", () => {
|
|
|
572
586
|
expect(usePlayerStore.getState().automationSelection).toBeNull();
|
|
573
587
|
});
|
|
574
588
|
|
|
575
|
-
it("does not reset playbackRate, audioMuted, loopEnabled, zoomMode, or manualZoomPercent", () => {
|
|
589
|
+
it("does not reset playbackRate, audioMuted, audioVolume, loopEnabled, zoomMode, or manualZoomPercent", () => {
|
|
576
590
|
const store = usePlayerStore.getState();
|
|
577
591
|
store.setPlaybackRate(2);
|
|
578
592
|
store.setAudioMuted(true);
|
|
593
|
+
store.setAudioVolume(0.4);
|
|
579
594
|
store.setLoopEnabled(true);
|
|
580
595
|
store.setZoomMode("manual");
|
|
581
596
|
store.setManualZoomPercent(200);
|
|
@@ -586,6 +601,7 @@ describe("usePlayerStore", () => {
|
|
|
586
601
|
// reset() only resets the fields explicitly listed in the reset function
|
|
587
602
|
expect(state.playbackRate).toBe(2);
|
|
588
603
|
expect(state.audioMuted).toBe(true);
|
|
604
|
+
expect(state.audioVolume).toBe(0.4);
|
|
589
605
|
expect(state.loopEnabled).toBe(true);
|
|
590
606
|
expect(state.zoomMode).toBe("manual");
|
|
591
607
|
expect(state.manualZoomPercent).toBe(200);
|
|
@@ -62,6 +62,7 @@ interface PlayerState extends KeyframeSlice, AutomationSelectionSlice, Thumbnail
|
|
|
62
62
|
selectedElementId: string | null;
|
|
63
63
|
playbackRate: number;
|
|
64
64
|
audioMuted: boolean;
|
|
65
|
+
audioVolume: number;
|
|
65
66
|
loopEnabled: boolean;
|
|
66
67
|
/** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */
|
|
67
68
|
zoomMode: ZoomMode;
|
|
@@ -132,6 +133,7 @@ interface PlayerState extends KeyframeSlice, AutomationSelectionSlice, Thumbnail
|
|
|
132
133
|
setDuration: (duration: number) => void;
|
|
133
134
|
setPlaybackRate: (rate: number) => void;
|
|
134
135
|
setAudioMuted: (muted: boolean) => void;
|
|
136
|
+
setAudioVolume: (volume: number) => void;
|
|
135
137
|
setLoopEnabled: (enabled: boolean) => void;
|
|
136
138
|
setTimelineReady: (ready: boolean) => void;
|
|
137
139
|
setBeatDragging: (dragging: boolean) => void;
|
|
@@ -298,6 +300,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
298
300
|
selectedElementId: null,
|
|
299
301
|
playbackRate: readStudioUiPreferences().playbackRate ?? 1,
|
|
300
302
|
audioMuted: readStudioUiPreferences().audioMuted ?? false,
|
|
303
|
+
audioVolume: readStudioUiPreferences().audioVolume ?? 1,
|
|
301
304
|
loopEnabled: false,
|
|
302
305
|
zoomMode: "fit",
|
|
303
306
|
manualZoomPercent: 100,
|
|
@@ -446,6 +449,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
446
449
|
writeStudioUiPreferences({ audioMuted: muted });
|
|
447
450
|
set({ audioMuted: muted });
|
|
448
451
|
},
|
|
452
|
+
setAudioVolume: (volume) => {
|
|
453
|
+
const nextVolume = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
|
|
454
|
+
writeStudioUiPreferences({ audioVolume: nextVolume });
|
|
455
|
+
set({ audioVolume: nextVolume });
|
|
456
|
+
},
|
|
449
457
|
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
|
|
450
458
|
setZoomMode: (mode) => set({ zoomMode: mode }),
|
|
451
459
|
clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
|
package/src/styles/studio.css
CHANGED
|
@@ -101,6 +101,75 @@ body {
|
|
|
101
101
|
0 1px 4px rgba(0, 0, 0, 0.55);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
.hf-preview-volume-range {
|
|
105
|
+
height: 1.5rem;
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
appearance: none;
|
|
108
|
+
background: transparent;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.hf-preview-volume-range:disabled {
|
|
112
|
+
opacity: 0.3;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.hf-preview-volume-range::-webkit-slider-runnable-track {
|
|
116
|
+
height: 2px;
|
|
117
|
+
background: transparent;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.hf-preview-volume-range::-webkit-slider-thumb {
|
|
121
|
+
width: 0.5rem;
|
|
122
|
+
height: 0.5rem;
|
|
123
|
+
margin-top: -3px;
|
|
124
|
+
border: 0;
|
|
125
|
+
border-radius: 999px;
|
|
126
|
+
appearance: none;
|
|
127
|
+
background: #ffffff;
|
|
128
|
+
box-shadow:
|
|
129
|
+
0 0 0 2px #0a0a0a,
|
|
130
|
+
0 1px 3px rgba(0, 0, 0, 0.5);
|
|
131
|
+
cursor: grab;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.hf-preview-volume-range::-webkit-slider-thumb:active {
|
|
135
|
+
cursor: grabbing;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.hf-preview-volume-range::-moz-range-track,
|
|
139
|
+
.hf-preview-volume-range::-moz-range-progress {
|
|
140
|
+
height: 2px;
|
|
141
|
+
border: 0;
|
|
142
|
+
background: transparent;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.hf-preview-volume-range::-moz-range-thumb {
|
|
146
|
+
width: 0.5rem;
|
|
147
|
+
height: 0.5rem;
|
|
148
|
+
border: 0;
|
|
149
|
+
border-radius: 999px;
|
|
150
|
+
background: #ffffff;
|
|
151
|
+
box-shadow:
|
|
152
|
+
0 0 0 2px #0a0a0a,
|
|
153
|
+
0 1px 3px rgba(0, 0, 0, 0.5);
|
|
154
|
+
cursor: grab;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.hf-preview-volume-range::-moz-range-thumb:active {
|
|
158
|
+
cursor: grabbing;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.hf-preview-volume-range:focus-visible {
|
|
162
|
+
outline: none;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.hf-preview-volume-range:focus-visible::-webkit-slider-thumb,
|
|
166
|
+
.hf-preview-volume-range:focus-visible::-moz-range-thumb {
|
|
167
|
+
box-shadow:
|
|
168
|
+
0 0 0 2px #0a0a0a,
|
|
169
|
+
0 0 0 4px rgba(60, 230, 172, 0.4),
|
|
170
|
+
0 1px 3px rgba(0, 0, 0, 0.5);
|
|
171
|
+
}
|
|
172
|
+
|
|
104
173
|
#root {
|
|
105
174
|
width: 100vw;
|
|
106
175
|
/*
|
|
@@ -23,6 +23,7 @@ describe("studio UI preferences", () => {
|
|
|
23
23
|
writeStudioUiPreferences({ leftWidth: 384, rightWidth: 424 }, storage);
|
|
24
24
|
writeStudioUiPreferences({ playbackRate: 1.5 }, storage);
|
|
25
25
|
writeStudioUiPreferences({ audioMuted: true }, storage);
|
|
26
|
+
writeStudioUiPreferences({ audioVolume: 0.4 }, storage);
|
|
26
27
|
writeStudioUiPreferences({ previewZoom: { zoomPercent: 160, panX: -20, panY: 12 } }, storage);
|
|
27
28
|
|
|
28
29
|
expect(readStudioUiPreferences(storage)).toEqual({
|
|
@@ -31,6 +32,7 @@ describe("studio UI preferences", () => {
|
|
|
31
32
|
rightWidth: 424,
|
|
32
33
|
playbackRate: 1.5,
|
|
33
34
|
audioMuted: true,
|
|
35
|
+
audioVolume: 0.4,
|
|
34
36
|
previewZoom: { zoomPercent: 160, panX: -20, panY: 12 },
|
|
35
37
|
});
|
|
36
38
|
});
|
|
@@ -46,6 +48,7 @@ describe("studio UI preferences", () => {
|
|
|
46
48
|
timelineVisible: true,
|
|
47
49
|
playbackRate: Number.NaN,
|
|
48
50
|
audioMuted: "false",
|
|
51
|
+
audioVolume: 2,
|
|
49
52
|
previewZoom: { zoomPercent: 150, panX: 0, panY: "bad" },
|
|
50
53
|
}),
|
|
51
54
|
);
|
|
@@ -14,6 +14,7 @@ export interface StudioUiPreferences {
|
|
|
14
14
|
timelineHeight?: number;
|
|
15
15
|
playbackRate?: number;
|
|
16
16
|
audioMuted?: boolean;
|
|
17
|
+
audioVolume?: number;
|
|
17
18
|
thumbnailMode?: "adaptive" | "hidden";
|
|
18
19
|
previewZoom?: StoredPreviewZoomState;
|
|
19
20
|
recentBlocks?: string[];
|
|
@@ -81,6 +82,14 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
|
|
|
81
82
|
if (typeof parsed.audioMuted === "boolean") {
|
|
82
83
|
preferences.audioMuted = parsed.audioMuted;
|
|
83
84
|
}
|
|
85
|
+
if (
|
|
86
|
+
typeof parsed.audioVolume === "number" &&
|
|
87
|
+
Number.isFinite(parsed.audioVolume) &&
|
|
88
|
+
parsed.audioVolume >= 0 &&
|
|
89
|
+
parsed.audioVolume <= 1
|
|
90
|
+
) {
|
|
91
|
+
preferences.audioVolume = parsed.audioVolume;
|
|
92
|
+
}
|
|
84
93
|
if (parsed.thumbnailMode === "adaptive" || parsed.thumbnailMode === "hidden") {
|
|
85
94
|
preferences.thumbnailMode = parsed.thumbnailMode;
|
|
86
95
|
} else if (typeof parsed.thumbnailsEnabled === "boolean") {
|