diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx
index 1ac690617b..c341c19a11 100644
--- a/packages/studio/src/player/components/PlayerControls.tsx
+++ b/packages/studio/src/player/components/PlayerControls.tsx
@@ -8,6 +8,7 @@ import { Tooltip } from "../../components/ui";
import { useMountEffect } from "../../hooks/useMountEffect";
import { ShortcutsPanel } from "./ShortcutsPanel";
import { SpeedMenu } from "./SpeedMenu";
+import { VolumeControl } from "./VolumeControl";
/* ── Icon sub-components ─────────────────────────────────────────── */
@@ -55,60 +56,6 @@ function PlayPauseMorphIcon({ playing }: { playing: boolean }) {
/* ── Button sub-components ───────────────────────────────────────── */
-const MuteButton = memo(function MuteButton({
- audioMuted,
- controlsDisabled,
- setAudioMuted,
-}: {
- audioMuted: boolean;
- controlsDisabled: boolean;
- setAudioMuted: (v: boolean) => void;
-}) {
- const label = audioMuted ? "Unmute audio" : "Mute audio";
- return (
-
-
-
- );
-});
-
const LoopButton = memo(function LoopButton({
loopEnabled,
disabled,
@@ -228,9 +175,11 @@ export const PlayerControls = memo(function PlayerControls({
const timelineReady = usePlayerStore((s) => s.timelineReady);
const playbackRate = usePlayerStore((s) => s.playbackRate);
const audioMuted = usePlayerStore((s) => s.audioMuted);
+ const audioVolume = usePlayerStore((s) => s.audioVolume);
const loopEnabled = usePlayerStore((s) => s.loopEnabled);
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
const setAudioMuted = usePlayerStore.getState().setAudioMuted;
+ const setAudioVolume = usePlayerStore.getState().setAudioVolume;
const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
const inPoint = usePlayerStore((s) => s.inPoint);
const outPoint = usePlayerStore((s) => s.outPoint);
@@ -313,10 +262,12 @@ export const PlayerControls = memo(function PlayerControls({
-
{
+ it("uses the same compact thumb in Chromium and Firefox", () => {
+ const selectors = [
+ ".hf-preview-volume-range::-webkit-slider-thumb",
+ ".hf-preview-volume-range::-moz-range-thumb",
+ ];
+
+ for (const selector of selectors) {
+ const start = studioCss.indexOf(`${selector} {`);
+ const rule = studioCss.slice(start, studioCss.indexOf("}", start));
+
+ expect(start).toBeGreaterThanOrEqual(0);
+ expect(rule).toContain("width: 0.5rem");
+ expect(rule).toContain("height: 0.5rem");
+ }
+ });
+});
diff --git a/packages/studio/src/player/components/VolumeControl.test.tsx b/packages/studio/src/player/components/VolumeControl.test.tsx
new file mode 100644
index 0000000000..8b37191ff4
--- /dev/null
+++ b/packages/studio/src/player/components/VolumeControl.test.tsx
@@ -0,0 +1,86 @@
+// @vitest-environment happy-dom
+
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { VolumeControl } from "./VolumeControl";
+
+vi.mock("../../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+let host: HTMLDivElement;
+let root: Root;
+
+beforeEach(() => {
+ host = document.createElement("div");
+ document.body.append(host);
+ root = createRoot(host);
+});
+
+afterEach(() => {
+ act(() => root.unmount());
+ document.body.innerHTML = "";
+});
+
+function renderVolumeControl(overrides: Partial> = {}) {
+ const props = {
+ audioMuted: false,
+ audioVolume: 1,
+ disabled: false,
+ setAudioMuted: vi.fn(),
+ setAudioVolume: vi.fn(),
+ ...overrides,
+ };
+ act(() => root.render());
+ return { host, props };
+}
+
+function setRangeValue(input: HTMLInputElement, value: string): void {
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
+ if (!setter) throw new Error("expected native range value setter");
+ setter.call(input, value);
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+}
+
+describe("VolumeControl", () => {
+ it("exposes the current preview volume as an accessible range", () => {
+ const { host } = renderVolumeControl({ audioVolume: 0.42 });
+ const slider = host.querySelector('input[aria-label="Preview volume"]');
+
+ expect(slider?.value).toBe("42");
+ expect(slider?.getAttribute("aria-valuetext")).toBe("42%");
+ });
+
+ it("updates the preview volume while dragging", () => {
+ const { host, props } = renderVolumeControl();
+ const slider = host.querySelector('input[aria-label="Preview volume"]');
+ if (!slider) throw new Error("preview volume slider did not render");
+
+ act(() => setRangeValue(slider, "35"));
+
+ expect(props.setAudioVolume).toHaveBeenCalledWith(0.35);
+ });
+
+ it("unmutes when a muted preview is given a positive volume", () => {
+ const { host, props } = renderVolumeControl({ audioMuted: true });
+ const slider = host.querySelector('input[aria-label="Preview volume"]');
+ if (!slider) throw new Error("preview volume slider did not render");
+
+ act(() => setRangeValue(slider, "60"));
+
+ expect(props.setAudioVolume).toHaveBeenCalledWith(0.6);
+ expect(props.setAudioMuted).toHaveBeenCalledWith(false);
+ });
+
+ it("restores an audible level when unmuting from zero", () => {
+ const { host, props } = renderVolumeControl({ audioVolume: 0 });
+ const button = host.querySelector('button[aria-label="Unmute audio"]');
+ if (!button) throw new Error("unmute button did not render");
+
+ act(() => button.click());
+
+ expect(props.setAudioVolume).toHaveBeenCalledWith(1);
+ expect(props.setAudioMuted).toHaveBeenCalledWith(false);
+ });
+});
diff --git a/packages/studio/src/player/components/VolumeControl.tsx b/packages/studio/src/player/components/VolumeControl.tsx
new file mode 100644
index 0000000000..dec425869e
--- /dev/null
+++ b/packages/studio/src/player/components/VolumeControl.tsx
@@ -0,0 +1,104 @@
+import { memo } from "react";
+import { Tooltip } from "../../components/ui";
+import { trackStudioEvent } from "../../utils/studioTelemetry";
+
+interface VolumeControlProps {
+ audioMuted: boolean;
+ audioVolume: number;
+ disabled: boolean;
+ setAudioMuted: (muted: boolean) => void;
+ setAudioVolume: (volume: number) => void;
+}
+
+function VolumeIcon({ muted, volume }: { muted: boolean; volume: number }) {
+ const silent = muted || volume === 0;
+ return (
+
+ );
+}
+
+export const VolumeControl = memo(function VolumeControl({
+ audioMuted,
+ audioVolume,
+ disabled,
+ setAudioMuted,
+ setAudioVolume,
+}: VolumeControlProps) {
+ const percentage = Math.round(audioVolume * 100);
+ const silent = audioMuted || audioVolume === 0;
+ const muteLabel = silent ? "Unmute audio" : "Mute audio";
+
+ return (
+
+
+
+
+
{
+ const volume = Number(event.currentTarget.value) / 100;
+ setAudioVolume(volume);
+ if (audioMuted && volume > 0) setAudioMuted(false);
+ }}
+ className="hf-preview-volume-range absolute inset-0 w-full disabled:pointer-events-none"
+ />
+
+
+
+
+
+
+
+ );
+});
diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts
index 2faff57a2b..be2c239820 100644
--- a/packages/studio/src/player/hooks/useTimelinePlayer.ts
+++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts
@@ -38,7 +38,11 @@ import {
parseTimelineFromDOM,
} from "../lib/timelineDOM";
import { normalizeToZones } from "../components/timelineZones";
-import { setPreviewMediaMuted, setPreviewPlaybackRate } from "../lib/timelineIframeHelpers";
+import {
+ setPreviewMediaMuted,
+ setPreviewMediaVolume,
+ setPreviewPlaybackRate,
+} from "../lib/timelineIframeHelpers";
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
@@ -231,8 +235,9 @@ export function useTimelinePlayer() {
} catch {}
}, []);
const applyPreviewAudioState = useCallback(() => {
- const { audioMuted } = usePlayerStore.getState();
+ const { audioMuted, audioVolume } = usePlayerStore.getState();
setPreviewMediaMuted(iframeRef.current, audioMuted);
+ setPreviewMediaVolume(iframeRef.current, audioVolume);
}, []);
const play = useCallback(() => {
stopRAFLoop();
@@ -570,7 +575,8 @@ export function useTimelinePlayer() {
return usePlayerStore.subscribe((state, prev) => {
const playbackRateChanged = state.playbackRate !== prev.playbackRate;
const audioMutedChanged = state.audioMuted !== prev.audioMuted;
- if (!playbackRateChanged && !audioMutedChanged) return;
+ const audioVolumeChanged = state.audioVolume !== prev.audioVolume;
+ if (!playbackRateChanged && !audioMutedChanged && !audioVolumeChanged) return;
if (playbackRateChanged) {
applyPlaybackRate(state.playbackRate);
diff --git a/packages/studio/src/player/lib/playbackScrub.ts b/packages/studio/src/player/lib/playbackScrub.ts
index ae789e410e..5119f50d13 100644
--- a/packages/studio/src/player/lib/playbackScrub.ts
+++ b/packages/studio/src/player/lib/playbackScrub.ts
@@ -12,5 +12,5 @@ export function scrubMusicAtSeek(iframe: HTMLIFrameElement | null, nextTime: num
if (!music || s.audioMuted) return;
const rel = nextTime - music.start;
const audioFileTime = rel >= 0 && rel <= music.duration ? (music.playbackStart ?? 0) + rel : null;
- scrubPreviewAudio(iframe, audioFileTime, music.domId ?? music.id);
+ scrubPreviewAudio(iframe, audioFileTime, music.domId ?? music.id, s.audioVolume);
}
diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.test.ts b/packages/studio/src/player/lib/timelineIframeHelpers.test.ts
index b97bf498ed..354d47d76a 100644
--- a/packages/studio/src/player/lib/timelineIframeHelpers.test.ts
+++ b/packages/studio/src/player/lib/timelineIframeHelpers.test.ts
@@ -1,6 +1,11 @@
// @vitest-environment jsdom
-import { describe, expect, it } from "vitest";
-import { buildMissingCompositionElements } from "./timelineIframeHelpers";
+import { describe, expect, it, vi } from "vitest";
+import {
+ buildMissingCompositionElements,
+ scrubPreviewAudio,
+ setPreviewMediaVolume,
+ stopScrubPreviewAudio,
+} from "./timelineIframeHelpers";
import type { IframeWindow } from "./playbackTypes";
function makeDoc(html: string): Document {
@@ -49,3 +54,36 @@ describe("buildMissingCompositionElements — hfId (R7)", () => {
expect(entry?.hfId).toBeUndefined();
});
});
+
+describe("setPreviewMediaVolume", () => {
+ it("sends a clamped runtime volume to a direct preview iframe", () => {
+ const iframe = document.createElement("iframe");
+ document.body.append(iframe);
+ const postMessage = vi.spyOn(iframe.contentWindow!, "postMessage");
+
+ setPreviewMediaVolume(iframe, 1.5);
+
+ expect(postMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ action: "set-volume", volume: 1 }),
+ "*",
+ );
+ });
+});
+
+describe("scrubPreviewAudio", () => {
+ it("scales scrub feedback by the Studio preview volume", () => {
+ const iframe = document.createElement("iframe");
+ document.body.append(iframe);
+ const audio = iframe.contentDocument?.createElement("audio");
+ if (!audio || !iframe.contentDocument?.body) throw new Error("expected iframe audio document");
+ audio.id = "music";
+ audio.play = vi.fn(async () => {});
+ audio.pause = vi.fn();
+ iframe.contentDocument.body.append(audio);
+
+ scrubPreviewAudio(iframe, 0.5, "music", 0.4);
+
+ expect(audio.volume).toBeCloseTo(0.1);
+ stopScrubPreviewAudio();
+ });
+});
diff --git a/packages/studio/src/player/lib/timelineIframeHelpers.ts b/packages/studio/src/player/lib/timelineIframeHelpers.ts
index 9be165f1de..209acf2a6a 100644
--- a/packages/studio/src/player/lib/timelineIframeHelpers.ts
+++ b/packages/studio/src/player/lib/timelineIframeHelpers.ts
@@ -84,9 +84,15 @@ export function autoHealMissingCompositionIds(doc: Document): void {
type PreviewPlayerHost = HTMLElement & {
muted?: boolean;
+ volume?: number;
playbackRate?: number;
};
+function normalizePreviewVolume(volume: number): number {
+ if (!Number.isFinite(volume)) return 1;
+ return Math.max(0, Math.min(1, volume));
+}
+
function isPreviewPlayerHost(value: unknown): value is PreviewPlayerHost {
return value instanceof HTMLElement;
}
@@ -123,6 +129,19 @@ export function setPreviewMediaMuted(iframe: HTMLIFrameElement | null, muted: bo
} catch {}
}
+export function setPreviewMediaVolume(iframe: HTMLIFrameElement | null, volume: number): void {
+ if (!iframe) return;
+ const nextVolume = normalizePreviewVolume(volume);
+ try {
+ const host = resolvePreviewPlayerHost(iframe);
+ if (host && typeof host.volume === "number") {
+ host.volume = nextVolume;
+ return;
+ }
+ postPreviewControl(iframe, "set-volume", { volume: nextVolume });
+ } catch {}
+}
+
export function setPreviewPlaybackRate(
iframe: HTMLIFrameElement | null,
playbackRate: number,
@@ -193,14 +212,14 @@ function resolveScrubAudioEl(doc: Document, musicId?: string | null): HTMLAudioE
);
}
-function applyScrub(el: HTMLAudioElement, audioFileTime: number): void {
+function applyScrub(el: HTMLAudioElement, audioFileTime: number, previewVolume: number): void {
if (scrubAudioEl && scrubAudioEl !== el) stopScrubPreviewAudio();
if (scrubPrevMuted === null) scrubPrevMuted = el.muted;
if (scrubPrevVolume === null) scrubPrevVolume = el.volume;
scrubAudioEl = el;
try {
el.muted = false;
- el.volume = SCRUB_VOLUME;
+ el.volume = SCRUB_VOLUME * normalizePreviewVolume(previewVolume);
if (Math.abs(el.currentTime - audioFileTime) > 0.04) el.currentTime = audioFileTime;
if (el.paused) void el.play().catch(() => {});
} catch {
@@ -218,6 +237,7 @@ export function scrubPreviewAudio(
iframe: HTMLIFrameElement | null,
audioFileTime: number | null,
musicId?: string | null,
+ previewVolume = 1,
): void {
if (!iframe) return;
if (audioFileTime === null) {
@@ -232,7 +252,7 @@ export function scrubPreviewAudio(
}
if (!doc) return;
const el = resolveScrubAudioEl(doc, musicId);
- if (el) applyScrub(el, audioFileTime);
+ if (el) applyScrub(el, audioFileTime, previewVolume);
}
export function stopScrubPreviewAudio(): void {
diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts
index 6d0d9d24b1..7f471e9089 100644
--- a/packages/studio/src/player/store/playerStore.test.ts
+++ b/packages/studio/src/player/store/playerStore.test.ts
@@ -22,6 +22,7 @@ describe("usePlayerStore", () => {
expectResettableDefaults(state);
expect(state.playbackRate).toBe(1);
expect(state.audioMuted).toBe(false);
+ expect(state.audioVolume).toBe(1);
expect(state.loopEnabled).toBe(false);
expect(state.zoomMode).toBe("fit");
expect(state.manualZoomPercent).toBe(100);
@@ -176,6 +177,19 @@ describe("usePlayerStore", () => {
});
});
+ describe("setAudioVolume", () => {
+ it("updates and clamps audioVolume", () => {
+ usePlayerStore.getState().setAudioVolume(0.35);
+ expect(usePlayerStore.getState().audioVolume).toBe(0.35);
+
+ usePlayerStore.getState().setAudioVolume(2);
+ expect(usePlayerStore.getState().audioVolume).toBe(1);
+
+ usePlayerStore.getState().setAudioVolume(-1);
+ expect(usePlayerStore.getState().audioVolume).toBe(0);
+ });
+ });
+
describe("setLoopEnabled", () => {
it("updates loopEnabled", () => {
usePlayerStore.getState().setLoopEnabled(true);
@@ -572,10 +586,11 @@ describe("usePlayerStore", () => {
expect(usePlayerStore.getState().automationSelection).toBeNull();
});
- it("does not reset playbackRate, audioMuted, loopEnabled, zoomMode, or manualZoomPercent", () => {
+ it("does not reset playbackRate, audioMuted, audioVolume, loopEnabled, zoomMode, or manualZoomPercent", () => {
const store = usePlayerStore.getState();
store.setPlaybackRate(2);
store.setAudioMuted(true);
+ store.setAudioVolume(0.4);
store.setLoopEnabled(true);
store.setZoomMode("manual");
store.setManualZoomPercent(200);
@@ -586,6 +601,7 @@ describe("usePlayerStore", () => {
// reset() only resets the fields explicitly listed in the reset function
expect(state.playbackRate).toBe(2);
expect(state.audioMuted).toBe(true);
+ expect(state.audioVolume).toBe(0.4);
expect(state.loopEnabled).toBe(true);
expect(state.zoomMode).toBe("manual");
expect(state.manualZoomPercent).toBe(200);
diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts
index 966a031fbc..6f7bca0bf8 100644
--- a/packages/studio/src/player/store/playerStore.ts
+++ b/packages/studio/src/player/store/playerStore.ts
@@ -62,6 +62,7 @@ interface PlayerState extends KeyframeSlice, AutomationSelectionSlice, Thumbnail
selectedElementId: string | null;
playbackRate: number;
audioMuted: boolean;
+ audioVolume: number;
loopEnabled: boolean;
/** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */
zoomMode: ZoomMode;
@@ -132,6 +133,7 @@ interface PlayerState extends KeyframeSlice, AutomationSelectionSlice, Thumbnail
setDuration: (duration: number) => void;
setPlaybackRate: (rate: number) => void;
setAudioMuted: (muted: boolean) => void;
+ setAudioVolume: (volume: number) => void;
setLoopEnabled: (enabled: boolean) => void;
setTimelineReady: (ready: boolean) => void;
setBeatDragging: (dragging: boolean) => void;
@@ -298,6 +300,7 @@ export const usePlayerStore = create((set, get) => ({
selectedElementId: null,
playbackRate: readStudioUiPreferences().playbackRate ?? 1,
audioMuted: readStudioUiPreferences().audioMuted ?? false,
+ audioVolume: readStudioUiPreferences().audioVolume ?? 1,
loopEnabled: false,
zoomMode: "fit",
manualZoomPercent: 100,
@@ -446,6 +449,11 @@ export const usePlayerStore = create((set, get) => ({
writeStudioUiPreferences({ audioMuted: muted });
set({ audioMuted: muted });
},
+ setAudioVolume: (volume) => {
+ const nextVolume = Number.isFinite(volume) ? Math.max(0, Math.min(1, volume)) : 1;
+ writeStudioUiPreferences({ audioVolume: nextVolume });
+ set({ audioVolume: nextVolume });
+ },
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
setZoomMode: (mode) => set({ zoomMode: mode }),
clearSelectedElementIds: () => set({ selectedElementIds: new Set() }),
diff --git a/packages/studio/src/styles/studio.css b/packages/studio/src/styles/studio.css
index ed97c1eb6e..7e0fe46e14 100644
--- a/packages/studio/src/styles/studio.css
+++ b/packages/studio/src/styles/studio.css
@@ -101,6 +101,75 @@ body {
0 1px 4px rgba(0, 0, 0, 0.55);
}
+.hf-preview-volume-range {
+ height: 1.5rem;
+ cursor: pointer;
+ appearance: none;
+ background: transparent;
+}
+
+.hf-preview-volume-range:disabled {
+ opacity: 0.3;
+}
+
+.hf-preview-volume-range::-webkit-slider-runnable-track {
+ height: 2px;
+ background: transparent;
+}
+
+.hf-preview-volume-range::-webkit-slider-thumb {
+ width: 0.5rem;
+ height: 0.5rem;
+ margin-top: -3px;
+ border: 0;
+ border-radius: 999px;
+ appearance: none;
+ background: #ffffff;
+ box-shadow:
+ 0 0 0 2px #0a0a0a,
+ 0 1px 3px rgba(0, 0, 0, 0.5);
+ cursor: grab;
+}
+
+.hf-preview-volume-range::-webkit-slider-thumb:active {
+ cursor: grabbing;
+}
+
+.hf-preview-volume-range::-moz-range-track,
+.hf-preview-volume-range::-moz-range-progress {
+ height: 2px;
+ border: 0;
+ background: transparent;
+}
+
+.hf-preview-volume-range::-moz-range-thumb {
+ width: 0.5rem;
+ height: 0.5rem;
+ border: 0;
+ border-radius: 999px;
+ background: #ffffff;
+ box-shadow:
+ 0 0 0 2px #0a0a0a,
+ 0 1px 3px rgba(0, 0, 0, 0.5);
+ cursor: grab;
+}
+
+.hf-preview-volume-range::-moz-range-thumb:active {
+ cursor: grabbing;
+}
+
+.hf-preview-volume-range:focus-visible {
+ outline: none;
+}
+
+.hf-preview-volume-range:focus-visible::-webkit-slider-thumb,
+.hf-preview-volume-range:focus-visible::-moz-range-thumb {
+ box-shadow:
+ 0 0 0 2px #0a0a0a,
+ 0 0 0 4px rgba(60, 230, 172, 0.4),
+ 0 1px 3px rgba(0, 0, 0, 0.5);
+}
+
#root {
width: 100vw;
/*
diff --git a/packages/studio/src/utils/studioUiPreferences.test.ts b/packages/studio/src/utils/studioUiPreferences.test.ts
index 39b4c5ec3d..012ccf67e7 100644
--- a/packages/studio/src/utils/studioUiPreferences.test.ts
+++ b/packages/studio/src/utils/studioUiPreferences.test.ts
@@ -23,6 +23,7 @@ describe("studio UI preferences", () => {
writeStudioUiPreferences({ leftWidth: 384, rightWidth: 424 }, storage);
writeStudioUiPreferences({ playbackRate: 1.5 }, storage);
writeStudioUiPreferences({ audioMuted: true }, storage);
+ writeStudioUiPreferences({ audioVolume: 0.4 }, storage);
writeStudioUiPreferences({ previewZoom: { zoomPercent: 160, panX: -20, panY: 12 } }, storage);
expect(readStudioUiPreferences(storage)).toEqual({
@@ -31,6 +32,7 @@ describe("studio UI preferences", () => {
rightWidth: 424,
playbackRate: 1.5,
audioMuted: true,
+ audioVolume: 0.4,
previewZoom: { zoomPercent: 160, panX: -20, panY: 12 },
});
});
@@ -46,6 +48,7 @@ describe("studio UI preferences", () => {
timelineVisible: true,
playbackRate: Number.NaN,
audioMuted: "false",
+ audioVolume: 2,
previewZoom: { zoomPercent: 150, panX: 0, panY: "bad" },
}),
);
diff --git a/packages/studio/src/utils/studioUiPreferences.ts b/packages/studio/src/utils/studioUiPreferences.ts
index 7994fe7b73..801a584858 100644
--- a/packages/studio/src/utils/studioUiPreferences.ts
+++ b/packages/studio/src/utils/studioUiPreferences.ts
@@ -14,6 +14,7 @@ export interface StudioUiPreferences {
timelineHeight?: number;
playbackRate?: number;
audioMuted?: boolean;
+ audioVolume?: number;
thumbnailMode?: "adaptive" | "hidden";
previewZoom?: StoredPreviewZoomState;
recentBlocks?: string[];
@@ -81,6 +82,14 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
if (typeof parsed.audioMuted === "boolean") {
preferences.audioMuted = parsed.audioMuted;
}
+ if (
+ typeof parsed.audioVolume === "number" &&
+ Number.isFinite(parsed.audioVolume) &&
+ parsed.audioVolume >= 0 &&
+ parsed.audioVolume <= 1
+ ) {
+ preferences.audioVolume = parsed.audioVolume;
+ }
if (parsed.thumbnailMode === "adaptive" || parsed.thumbnailMode === "hidden") {
preferences.thumbnailMode = parsed.thumbnailMode;
} else if (typeof parsed.thumbnailsEnabled === "boolean") {