Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 7 additions & 56 deletions packages/studio/src/player/components/PlayerControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────── */

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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}
Expand Down
22 changes: 22 additions & 0 deletions packages/studio/src/player/components/VolumeControl.styles.test.ts
Original file line number Diff line number Diff line change
@@ -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");
}
});
});
86 changes: 86 additions & 0 deletions packages/studio/src/player/components/VolumeControl.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
104 changes: 104 additions & 0 deletions packages/studio/src/player/components/VolumeControl.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
});
12 changes: 9 additions & 3 deletions packages/studio/src/player/hooks/useTimelinePlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/studio/src/player/lib/playbackScrub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading
Loading