@hyperframes/studio 0.7.109 → 0.7.110

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.109",
3
+ "version": "0.7.110",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/core": "0.7.109",
51
- "@hyperframes/parsers": "0.7.109",
52
- "@hyperframes/studio-server": "0.7.109",
53
- "@hyperframes/sdk": "0.7.109",
54
- "@hyperframes/player": "0.7.109"
50
+ "@hyperframes/player": "0.7.110",
51
+ "@hyperframes/core": "0.7.110",
52
+ "@hyperframes/sdk": "0.7.110",
53
+ "@hyperframes/parsers": "0.7.110",
54
+ "@hyperframes/studio-server": "0.7.110"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -68,7 +68,7 @@
68
68
  "vite": "^6.4.2",
69
69
  "vitest": "^3.2.4",
70
70
  "zustand": "^5.0.0",
71
- "@hyperframes/producer": "0.7.109"
71
+ "@hyperframes/producer": "0.7.110"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "react": "19",
@@ -8,6 +8,7 @@ import { Tooltip } from "../../components/ui";
8
8
  import { useMountEffect } from "../../hooks/useMountEffect";
9
9
  import { ShortcutsPanel } from "./ShortcutsPanel";
10
10
  import { SpeedMenu } from "./SpeedMenu";
11
+ import { VolumeControl } from "./VolumeControl";
11
12
 
12
13
  /* ── Icon sub-components ─────────────────────────────────────────── */
13
14
 
@@ -55,60 +56,6 @@ function PlayPauseMorphIcon({ playing }: { playing: boolean }) {
55
56
 
56
57
  /* ── Button sub-components ───────────────────────────────────────── */
57
58
 
58
- const MuteButton = memo(function MuteButton({
59
- audioMuted,
60
- controlsDisabled,
61
- setAudioMuted,
62
- }: {
63
- audioMuted: boolean;
64
- controlsDisabled: boolean;
65
- setAudioMuted: (v: boolean) => void;
66
- }) {
67
- const label = audioMuted ? "Unmute audio" : "Mute audio";
68
- return (
69
- <Tooltip label={label}>
70
- <button
71
- type="button"
72
- onClick={() => {
73
- trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
74
- setAudioMuted(!audioMuted);
75
- }}
76
- disabled={controlsDisabled}
77
- aria-label={label}
78
- aria-pressed={audioMuted}
79
- className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-30 ${
80
- audioMuted ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
81
- }`}
82
- >
83
- <svg
84
- width="13"
85
- height="13"
86
- viewBox="0 0 24 24"
87
- fill="none"
88
- stroke="currentColor"
89
- strokeWidth="2"
90
- strokeLinecap="round"
91
- strokeLinejoin="round"
92
- aria-hidden="true"
93
- >
94
- <path d="M11 5 6 9H3v6h3l5 4V5Z" />
95
- {audioMuted ? (
96
- <>
97
- <path d="m19 9-6 6" />
98
- <path d="m13 9 6 6" />
99
- </>
100
- ) : (
101
- <>
102
- <path d="M15.5 8.5a5 5 0 0 1 0 7" />
103
- <path d="M18.5 5.5a9 9 0 0 1 0 13" />
104
- </>
105
- )}
106
- </svg>
107
- </button>
108
- </Tooltip>
109
- );
110
- });
111
-
112
59
  const LoopButton = memo(function LoopButton({
113
60
  loopEnabled,
114
61
  disabled,
@@ -228,9 +175,11 @@ export const PlayerControls = memo(function PlayerControls({
228
175
  const timelineReady = usePlayerStore((s) => s.timelineReady);
229
176
  const playbackRate = usePlayerStore((s) => s.playbackRate);
230
177
  const audioMuted = usePlayerStore((s) => s.audioMuted);
178
+ const audioVolume = usePlayerStore((s) => s.audioVolume);
231
179
  const loopEnabled = usePlayerStore((s) => s.loopEnabled);
232
180
  const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
233
181
  const setAudioMuted = usePlayerStore.getState().setAudioMuted;
182
+ const setAudioVolume = usePlayerStore.getState().setAudioVolume;
234
183
  const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
235
184
  const inPoint = usePlayerStore((s) => s.inPoint);
236
185
  const outPoint = usePlayerStore((s) => s.outPoint);
@@ -313,10 +262,12 @@ export const PlayerControls = memo(function PlayerControls({
313
262
  </Tooltip>
314
263
 
315
264
  <div className="flex min-w-0 items-center justify-self-end">
316
- <MuteButton
265
+ <VolumeControl
317
266
  audioMuted={audioMuted}
318
- controlsDisabled={controlsDisabled}
267
+ audioVolume={audioVolume}
268
+ disabled={controlsDisabled}
319
269
  setAudioMuted={setAudioMuted}
270
+ setAudioVolume={setAudioVolume}
320
271
  />
321
272
  <SpeedMenu
322
273
  playbackRate={playbackRate}
@@ -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 { setPreviewMediaMuted, setPreviewPlaybackRate } from "../lib/timelineIframeHelpers";
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
- if (!playbackRateChanged && !audioMutedChanged) return;
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 { buildMissingCompositionElements } from "./timelineIframeHelpers";
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() }),
@@ -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
  /*