From b64584244638a278546894fbb1856a0b3ee55f68 Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Fri, 14 Aug 2026 23:05:03 -0400 Subject: [PATCH] feat(studio): add a preview volume control --- .../src/player/components/PlayerControls.tsx | 63 ++--------- .../components/VolumeControl.styles.test.ts | 22 ++++ .../player/components/VolumeControl.test.tsx | 86 +++++++++++++++ .../src/player/components/VolumeControl.tsx | 104 ++++++++++++++++++ .../src/player/hooks/useTimelinePlayer.ts | 12 +- .../studio/src/player/lib/playbackScrub.ts | 2 +- .../player/lib/timelineIframeHelpers.test.ts | 42 ++++++- .../src/player/lib/timelineIframeHelpers.ts | 26 ++++- .../src/player/store/playerStore.test.ts | 18 ++- .../studio/src/player/store/playerStore.ts | 8 ++ packages/studio/src/styles/studio.css | 69 ++++++++++++ .../src/utils/studioUiPreferences.test.ts | 3 + .../studio/src/utils/studioUiPreferences.ts | 9 ++ 13 files changed, 398 insertions(+), 66 deletions(-) create mode 100644 packages/studio/src/player/components/VolumeControl.styles.test.ts create mode 100644 packages/studio/src/player/components/VolumeControl.test.tsx create mode 100644 packages/studio/src/player/components/VolumeControl.tsx 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") {