mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(studio): add a preview volume control (#3282)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<PlayerState>((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<PlayerState>((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() }),
|
||||
|
||||
@@ -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;
|
||||
/*
|
||||
|
||||
@@ -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" },
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user