feat(studio): add a preview volume control (#3282)

This commit is contained in:
Akshar Patel
2026-08-15 11:53:56 -04:00
committed by GitHub
parent b9a4dfcbe5
commit fecaf72d1f
13 changed files with 398 additions and 66 deletions
@@ -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 (
<Tooltip label={label}>
<button
type="button"
onClick={() => {
trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
setAudioMuted(!audioMuted);
}}
disabled={controlsDisabled}
aria-label={label}
aria-pressed={audioMuted}
className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-30 ${
audioMuted ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
}`}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M11 5 6 9H3v6h3l5 4V5Z" />
{audioMuted ? (
<>
<path d="m19 9-6 6" />
<path d="m13 9 6 6" />
</>
) : (
<>
<path d="M15.5 8.5a5 5 0 0 1 0 7" />
<path d="M18.5 5.5a9 9 0 0 1 0 13" />
</>
)}
</svg>
</button>
</Tooltip>
);
});
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({
</Tooltip>
<div className="flex min-w-0 items-center justify-self-end">
<MuteButton
<VolumeControl
audioMuted={audioMuted}
controlsDisabled={controlsDisabled}
audioVolume={audioVolume}
disabled={controlsDisabled}
setAudioMuted={setAudioMuted}
setAudioVolume={setAudioVolume}
/>
<SpeedMenu
playbackRate={playbackRate}
@@ -0,0 +1,22 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const studioCss = readFileSync(new URL("../../styles/studio.css", import.meta.url), "utf8");
describe("preview volume range styles", () => {
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");
}
});
});
@@ -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<React.ComponentProps<typeof VolumeControl>> = {}) {
const props = {
audioMuted: false,
audioVolume: 1,
disabled: false,
setAudioMuted: vi.fn(),
setAudioVolume: vi.fn(),
...overrides,
};
act(() => root.render(<VolumeControl {...props} />));
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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLButtonElement>('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);
});
});
@@ -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 (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M11 5 6 9H3v6h3l5 4V5Z" />
{silent ? (
<>
<path d="m19 9-6 6" />
<path d="m13 9 6 6" />
</>
) : (
<>
<path d="M15.5 8.5a5 5 0 0 1 0 7" />
{volume >= 0.5 ? <path d="M18.5 5.5a9 9 0 0 1 0 13" /> : null}
</>
)}
</svg>
);
}
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 (
<div className="group flex flex-shrink-0 items-center">
<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">
<div className="relative mx-1 flex h-6 w-12 items-center">
<div className="absolute inset-x-0 h-0.5 overflow-hidden rounded-full bg-neutral-700">
<div
className="h-full rounded-full bg-neutral-300"
style={{ width: `${percentage}%` }}
/>
</div>
<input
type="range"
min="0"
max="100"
step="1"
value={percentage}
disabled={disabled}
aria-label="Preview volume"
aria-valuetext={`${percentage}%`}
title={`Preview volume: ${percentage}%`}
onChange={(event) => {
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"
/>
</div>
</div>
<Tooltip label={muteLabel}>
<button
type="button"
onClick={() => {
trackStudioEvent("playback", { action: "mute_toggle", muted: !silent });
if (silent && audioVolume === 0) setAudioVolume(1);
setAudioMuted(!silent);
}}
disabled={disabled}
aria-label={muteLabel}
aria-pressed={silent}
className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-30 ${
silent ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
}`}
>
<VolumeIcon muted={audioMuted} volume={audioVolume} />
</button>
</Tooltip>
</div>
);
});