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
@@ -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 {