fix(studio): add preview audio mute controls (#853)

This commit is contained in:
Miguel Ángel
2026-05-15 03:37:53 +02:00
committed by GitHub
parent 9cc09fca83
commit d1e5ac2939
11 changed files with 338 additions and 27 deletions
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { resolveSeekPercent } from "./PlayerControls";
import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
describe("resolveSeekPercent", () => {
it("returns 0 when the track width is invalid", () => {
@@ -18,3 +19,19 @@ describe("resolveSeekPercent", () => {
expect(resolveSeekPercent(150, 100, 200)).toBe(0.25);
});
});
describe("shouldMutePreviewAudio", () => {
it("mutes when the user toggled audio off", () => {
expect(shouldMutePreviewAudio(true, 1)).toBe(true);
});
it("auto-mutes above 1x playback", () => {
expect(shouldMutePreviewAudio(false, 1.5)).toBe(true);
expect(shouldMutePreviewAudio(false, 2)).toBe(true);
});
it("keeps audio on at 1x or slower when the user has not muted it", () => {
expect(shouldMutePreviewAudio(false, 1)).toBe(false);
expect(shouldMutePreviewAudio(false, 0.5)).toBe(false);
});
});
@@ -1,6 +1,7 @@
import { useRef, useState, useCallback, useEffect, memo } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { formatFrameTime, frameToSeconds, stepFrameTime, formatTime } from "../lib/time";
import { shouldMutePreviewAudio } from "../lib/timelineIframeHelpers";
import { usePlayerStore, liveTime } from "../store/playerStore";
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
@@ -57,8 +58,10 @@ export const PlayerControls = memo(function PlayerControls({
const duration = usePlayerStore((s) => s.duration);
const timelineReady = usePlayerStore((s) => s.timelineReady);
const playbackRate = usePlayerStore((s) => s.playbackRate);
const audioMuted = usePlayerStore((s) => s.audioMuted);
const loopEnabled = usePlayerStore((s) => s.loopEnabled);
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
const setAudioMuted = usePlayerStore.getState().setAudioMuted;
const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
const inPoint = usePlayerStore((s) => s.inPoint);
const outPoint = usePlayerStore((s) => s.outPoint);
@@ -84,6 +87,13 @@ export const PlayerControls = memo(function PlayerControls({
const durationRef = useRef(duration);
durationRef.current = duration;
const controlsDisabled = disabled || !timelineReady;
const audioAutoMuted = playbackRate > 1;
const effectiveAudioMuted = shouldMutePreviewAudio(audioMuted, playbackRate);
const muteButtonLabel = audioAutoMuted
? "Audio muted above 1x speed"
: audioMuted
? "Unmute audio"
: "Mute audio";
useMountEffect(() => {
const updateProgress = (t: number) => {
currentTimeRef.current = t;
@@ -420,6 +430,57 @@ export const PlayerControls = memo(function PlayerControls({
</div>
</div>
{/* Mute toggle */}
<button
type="button"
onClick={() => {
if (!audioAutoMuted) setAudioMuted(!audioMuted);
}}
disabled={controlsDisabled || audioAutoMuted}
title={muteButtonLabel}
aria-label={muteButtonLabel}
aria-pressed={effectiveAudioMuted}
className={`h-7 w-7 flex-shrink-0 flex items-center justify-center rounded-md border transition-colors disabled:pointer-events-none ${
effectiveAudioMuted
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
} ${audioAutoMuted ? "opacity-70" : ""}`}
>
{effectiveAudioMuted ? (
<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" />
<path d="m19 9-6 6" />
<path d="m13 9 6 6" />
</svg>
) : (
<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" />
<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>
{/* Speed control */}
<div ref={speedMenuContainerRef} className="relative flex-shrink-0">
<button
@@ -2,7 +2,7 @@
import React, { act, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useTimelinePlayer } from "./useTimelinePlayer";
import { liveTime, usePlayerStore } from "../store/playerStore";
@@ -30,7 +30,13 @@ afterEach(() => {
resetPlayerStore();
});
function attachIframeAdapter(api: ReturnType<typeof useTimelinePlayer>) {
function attachIframeAdapter(
api: ReturnType<typeof useTimelinePlayer>,
options: {
postMessage?: (message: unknown, targetOrigin: string) => void;
timelines?: Record<string, unknown>;
} = {},
) {
const iframe = document.createElement("iframe");
let currentTime = 0;
const adapter = {
@@ -46,7 +52,8 @@ function attachIframeAdapter(api: ReturnType<typeof useTimelinePlayer>) {
Object.defineProperty(iframe, "contentWindow", {
value: {
__player: adapter,
postMessage: () => {},
__timelines: options.timelines,
postMessage: options.postMessage ?? (() => {}),
scrollTo: () => {},
addEventListener: () => {},
removeEventListener: () => {},
@@ -133,6 +140,107 @@ describe("useTimelinePlayer seek hydration", () => {
});
});
describe("useTimelinePlayer audio controls (#835)", () => {
it("applies playback-rate changes immediately and auto-mutes audio above 1x", () => {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const postMessage = vi.fn();
const timeScale = vi.fn();
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
);
});
attachIframeAdapter(api!, {
postMessage,
timelines: {
root: { timeScale },
},
});
postMessage.mockClear();
timeScale.mockClear();
act(() => {
usePlayerStore.getState().setAudioMuted(false);
usePlayerStore.getState().setPlaybackRate(2);
});
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
source: "hf-parent",
type: "control",
action: "set-playback-rate",
playbackRate: 2,
}),
"*",
);
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
source: "hf-parent",
type: "control",
action: "set-muted",
muted: true,
}),
"*",
);
expect(timeScale).toHaveBeenCalledWith(2);
postMessage.mockClear();
act(() => {
usePlayerStore.getState().setPlaybackRate(1);
});
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
action: "set-muted",
muted: false,
}),
"*",
);
act(() => {
root.unmount();
});
});
it("keeps explicit Studio mute active at 1x", () => {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const postMessage = vi.fn();
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
);
});
attachIframeAdapter(api!, { postMessage });
postMessage.mockClear();
act(() => {
usePlayerStore.getState().setPlaybackRate(1);
usePlayerStore.getState().setAudioMuted(true);
});
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
action: "set-muted",
muted: true,
}),
"*",
);
act(() => {
root.unmount();
});
});
});
describe("useTimelinePlayer seek keepPlaying option (#834)", () => {
it("default seek() clears isPlaying when the store reports playing", () => {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
@@ -37,7 +37,11 @@ import {
mergeTimelineElementsPreservingDowngrades,
parseTimelineFromDOM,
} from "../lib/timelineDOM";
import { unmutePreviewMedia } from "../lib/timelineIframeHelpers";
import {
setPreviewMediaMuted,
setPreviewPlaybackRate,
shouldMutePreviewAudio,
} from "../lib/timelineIframeHelpers";
// ---------------------------------------------------------------------------
// Hook
@@ -218,11 +222,7 @@ export function useTimelinePlayer() {
const applyPlaybackRate = useCallback((rate: number) => {
const iframe = iframeRef.current;
if (!iframe) return;
// Send to runtime via bridge (works with both new and CDN runtime)
iframe.contentWindow?.postMessage(
{ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate },
"*",
);
setPreviewPlaybackRate(iframe, rate);
// Also set directly on GSAP timeline if accessible
try {
const win = iframe.contentWindow as IframeWindow | null;
@@ -241,6 +241,15 @@ export function useTimelinePlayer() {
}
}, []);
const applyPreviewAudioState = useCallback((playbackRateOverride?: number) => {
const { audioMuted, playbackRate } = usePlayerStore.getState();
const effectivePlaybackRate = playbackRateOverride ?? playbackRate;
setPreviewMediaMuted(
iframeRef.current,
shouldMutePreviewAudio(audioMuted, effectivePlaybackRate),
);
}, []);
const play = useCallback(() => {
stopRAFLoop();
stopReverseLoop();
@@ -249,13 +258,21 @@ export function useTimelinePlayer() {
if (adapter.getTime() >= adapter.getDuration()) {
adapter.seek(usePlayerStore.getState().inPoint ?? 0);
}
unmutePreviewMedia(iframeRef.current);
applyPlaybackRate(usePlayerStore.getState().playbackRate);
applyPreviewAudioState();
adapter.play();
shuttleDirectionRef.current = "forward";
setIsPlaying(true);
startRAFLoop();
}, [getAdapter, setIsPlaying, startRAFLoop, applyPlaybackRate, stopRAFLoop, stopReverseLoop]);
}, [
getAdapter,
setIsPlaying,
startRAFLoop,
applyPlaybackRate,
applyPreviewAudioState,
stopRAFLoop,
stopReverseLoop,
]);
const playBackward = useCallback(
(rate: number) => {
@@ -267,8 +284,9 @@ export function useTimelinePlayer() {
const initialTime = adapter.getTime() <= 0 && duration > 0 ? duration : adapter.getTime();
adapter.pause();
if (initialTime !== adapter.getTime()) adapter.seek(initialTime);
unmutePreviewMedia(iframeRef.current);
const speed = Math.max(0.1, Math.min(4, rate));
applyPlaybackRate(speed);
applyPreviewAudioState(speed);
let startTime = initialTime;
let startedAt = performance.now();
@@ -305,7 +323,15 @@ export function useTimelinePlayer() {
shuttleDirectionRef.current = "backward";
reverseRafRef.current = requestAnimationFrame(tick);
},
[getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop],
[
getAdapter,
setCurrentTime,
setIsPlaying,
applyPlaybackRate,
applyPreviewAudioState,
stopRAFLoop,
stopReverseLoop,
],
);
const pause = useCallback(() => {
@@ -392,6 +418,7 @@ export function useTimelinePlayer() {
setTimelineReady,
setIsPlaying,
attachIframeShortcutListeners,
applyPreviewAudioState,
});
const saveSeekPosition = useCallback(() => {
@@ -516,6 +543,19 @@ export function useTimelinePlayer() {
usePlayerStore.getState().reset();
}, [stopRAFLoop, stopReverseLoop]);
useEffect(() => {
return usePlayerStore.subscribe((state, prev) => {
const playbackRateChanged = state.playbackRate !== prev.playbackRate;
const audioMutedChanged = state.audioMuted !== prev.audioMuted;
if (!playbackRateChanged && !audioMutedChanged) return;
if (playbackRateChanged) {
applyPlaybackRate(state.playbackRate);
}
applyPreviewAudioState();
});
}, [applyPlaybackRate, applyPreviewAudioState]);
return {
iframeRef,
play,
@@ -24,7 +24,6 @@ import {
import {
normalizePreviewViewport,
autoHealMissingCompositionIds,
unmutePreviewMedia,
buildMissingCompositionElements,
} from "../lib/timelineIframeHelpers";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
@@ -41,6 +40,7 @@ interface UseTimelineSyncCallbacksParams {
setTimelineReady: (v: boolean) => void;
setIsPlaying: (v: boolean) => void;
attachIframeShortcutListeners: () => void;
applyPreviewAudioState: () => void;
}
export function useTimelineSyncCallbacks({
@@ -55,6 +55,7 @@ export function useTimelineSyncCallbacks({
setTimelineReady,
setIsPlaying,
attachIframeShortcutListeners,
applyPreviewAudioState,
}: UseTimelineSyncCallbacksParams) {
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
const processTimelineMessage = useCallback(
@@ -192,6 +193,7 @@ export function useTimelineSyncCallbacks({
processTimelineMessage(manifest);
}
enrichMissingCompositions();
applyPreviewAudioState();
if (usePlayerStore.getState().elements.length === 0 && doc) {
const els = parseTimelineFromDOM(doc, adapter.getDuration());
@@ -225,13 +227,14 @@ export function useTimelineSyncCallbacks({
enrichMissingCompositions,
syncTimelineElements,
attachIframeShortcutListeners,
applyPreviewAudioState,
iframeRef,
isRefreshingRef,
pendingSeekRef,
]);
const onIframeLoad = useCallback(() => {
unmutePreviewMedia(iframeRef.current);
applyPreviewAudioState();
if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
// Fast path: adapter already available (in-place reloads, cached compositions)
@@ -270,7 +273,7 @@ export function useTimelineSyncCallbacks({
}
window.removeEventListener("message", onMessage);
}, 5000) as unknown as ReturnType<typeof setInterval>;
}, [initializeAdapter, iframeRef, probeIntervalRef]);
}, [initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState]);
// Stable refs so mount-effect closures always call the latest version
const processTimelineMessageRef = { current: processTimelineMessage };
@@ -2,7 +2,7 @@
* Higher-level timeline DOM operations: element factories, DOM-to-element
* parsing, timeline merging, and standalone composition helpers.
*
* Preview iframe utilities (normaliseViewport, autoHeal, unmute, resolveIframe,
* Preview iframe utilities (normaliseViewport, autoHeal, audio controls, resolveIframe,
* buildMissingCompositionElements) live in timelineIframeHelpers.ts.
*
* Pure functions (no React, no store reads) — testable in isolation.
@@ -42,7 +42,9 @@ export {
export {
normalizePreviewViewport,
autoHealMissingCompositionIds,
unmutePreviewMedia,
setPreviewMediaMuted,
setPreviewPlaybackRate,
shouldMutePreviewAudio,
resolveIframe,
buildMissingCompositionElements,
} from "./timelineIframeHelpers";
@@ -73,18 +73,74 @@ export function autoHealMissingCompositionIds(doc: Document): void {
}
// ---------------------------------------------------------------------------
// Muting / iframe resolution
// Audio / iframe resolution
// ---------------------------------------------------------------------------
export function unmutePreviewMedia(iframe: HTMLIFrameElement | null): void {
type PreviewPlayerHost = HTMLElement & {
muted?: boolean;
playbackRate?: number;
};
function isPreviewPlayerHost(value: unknown): value is PreviewPlayerHost {
return value instanceof HTMLElement;
}
function resolvePreviewPlayerHost(iframe: HTMLIFrameElement): PreviewPlayerHost | null {
const root = iframe.getRootNode();
if (
typeof ShadowRoot !== "undefined" &&
root instanceof ShadowRoot &&
isPreviewPlayerHost(root.host)
) {
return root.host;
}
return null;
}
function postPreviewControl(
iframe: HTMLIFrameElement,
action: string,
payload: Record<string, unknown>,
): void {
iframe.contentWindow?.postMessage(
{ source: "hf-parent", type: "control", action, ...payload },
"*",
);
}
export function shouldMutePreviewAudio(audioMuted: boolean, playbackRate: number): boolean {
return audioMuted || playbackRate > 1;
}
export function setPreviewMediaMuted(iframe: HTMLIFrameElement | null, muted: boolean): void {
if (!iframe) return;
try {
iframe.contentWindow?.postMessage(
{ source: "hf-parent", type: "control", action: "set-muted", muted: false },
"*",
);
const host = resolvePreviewPlayerHost(iframe);
if (host && typeof host.muted === "boolean") {
host.muted = muted;
return;
}
postPreviewControl(iframe, "set-muted", { muted });
} catch (err) {
console.warn("[useTimelinePlayer] Failed to unmute preview media", err);
console.warn("[useTimelinePlayer] Failed to set preview media mute state", err);
}
}
export function setPreviewPlaybackRate(
iframe: HTMLIFrameElement | null,
playbackRate: number,
): void {
if (!iframe) return;
const rate = Number.isFinite(playbackRate) && playbackRate > 0 ? playbackRate : 1;
try {
const host = resolvePreviewPlayerHost(iframe);
if (host && typeof host.playbackRate === "number") {
host.playbackRate = rate;
return;
}
postPreviewControl(iframe, "set-playback-rate", { playbackRate: rate });
} catch (err) {
console.warn("[useTimelinePlayer] Failed to set preview playback rate", err);
}
}
@@ -16,6 +16,7 @@ describe("usePlayerStore", () => {
expect(state.elements).toEqual([]);
expect(state.selectedElementId).toBeNull();
expect(state.playbackRate).toBe(1);
expect(state.audioMuted).toBe(false);
expect(state.loopEnabled).toBe(false);
expect(state.zoomMode).toBe("fit");
expect(state.manualZoomPercent).toBe(100);
@@ -62,6 +63,13 @@ describe("usePlayerStore", () => {
});
});
describe("setAudioMuted", () => {
it("updates audioMuted", () => {
usePlayerStore.getState().setAudioMuted(true);
expect(usePlayerStore.getState().audioMuted).toBe(true);
});
});
describe("setLoopEnabled", () => {
it("updates loopEnabled", () => {
usePlayerStore.getState().setLoopEnabled(true);
@@ -213,9 +221,10 @@ describe("usePlayerStore", () => {
expect(state.selectedElementId).toBeNull();
});
it("does not reset playbackRate, loopEnabled, zoomMode, or manualZoomPercent", () => {
it("does not reset playbackRate, audioMuted, loopEnabled, zoomMode, or manualZoomPercent", () => {
const store = usePlayerStore.getState();
store.setPlaybackRate(2);
store.setAudioMuted(true);
store.setLoopEnabled(true);
store.setZoomMode("manual");
store.setManualZoomPercent(200);
@@ -225,6 +234,7 @@ describe("usePlayerStore", () => {
const state = usePlayerStore.getState();
// reset() only resets the fields explicitly listed in the reset function
expect(state.playbackRate).toBe(2);
expect(state.audioMuted).toBe(true);
expect(state.loopEnabled).toBe(true);
expect(state.zoomMode).toBe("manual");
expect(state.manualZoomPercent).toBe(200);
@@ -38,6 +38,7 @@ interface PlayerState {
elements: TimelineElement[];
selectedElementId: string | null;
playbackRate: number;
audioMuted: boolean;
loopEnabled: boolean;
/** Timeline zoom: 'fit' auto-scales to viewport, 'manual' uses manualZoomPercent */
zoomMode: ZoomMode;
@@ -52,6 +53,7 @@ interface PlayerState {
setCurrentTime: (time: number) => void;
setDuration: (duration: number) => void;
setPlaybackRate: (rate: number) => void;
setAudioMuted: (muted: boolean) => void;
setLoopEnabled: (enabled: boolean) => void;
setTimelineReady: (ready: boolean) => void;
setElements: (elements: TimelineElement[]) => void;
@@ -96,6 +98,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
elements: [],
selectedElementId: null,
playbackRate: readStudioUiPreferences().playbackRate ?? 1,
audioMuted: readStudioUiPreferences().audioMuted ?? false,
loopEnabled: false,
zoomMode: "fit",
manualZoomPercent: 100,
@@ -111,6 +114,10 @@ export const usePlayerStore = create<PlayerState>((set) => ({
writeStudioUiPreferences({ playbackRate: rate });
set({ playbackRate: rate });
},
setAudioMuted: (muted) => {
writeStudioUiPreferences({ audioMuted: muted });
set({ audioMuted: muted });
},
setLoopEnabled: (enabled) => set({ loopEnabled: enabled }),
setZoomMode: (mode) => set({ zoomMode: mode }),
setInPoint: (time) =>
@@ -144,7 +151,7 @@ export const usePlayerStore = create<PlayerState>((set) => ({
),
})),
// Resets project-specific state when switching compositions.
// playbackRate, loopEnabled, zoomMode, and manualZoomPercent are intentionally preserved
// playbackRate, audioMuted, loopEnabled, zoomMode, and manualZoomPercent are intentionally preserved
// because they are user preferences that should survive project switches.
reset: () =>
set({
@@ -21,11 +21,13 @@ describe("studio UI preferences", () => {
writeStudioUiPreferences({ timelineVisible: false }, storage);
writeStudioUiPreferences({ playbackRate: 1.5 }, storage);
writeStudioUiPreferences({ audioMuted: true }, storage);
writeStudioUiPreferences({ previewZoom: { zoomPercent: 160, panX: -20, panY: 12 } }, storage);
expect(readStudioUiPreferences(storage)).toEqual({
timelineVisible: false,
playbackRate: 1.5,
audioMuted: true,
previewZoom: { zoomPercent: 160, panX: -20, panY: 12 },
});
});
@@ -38,6 +40,7 @@ describe("studio UI preferences", () => {
leftCollapsed: "yes",
timelineVisible: true,
playbackRate: Number.NaN,
audioMuted: "false",
previewZoom: { zoomPercent: 150, panX: 0, panY: "bad" },
}),
);
@@ -8,6 +8,7 @@ export interface StudioUiPreferences {
leftCollapsed?: boolean;
timelineVisible?: boolean;
playbackRate?: number;
audioMuted?: boolean;
previewZoom?: StoredPreviewZoomState;
}
@@ -44,6 +45,9 @@ function readStorage(storage: Storage | null): StudioUiPreferences {
if (typeof parsed.playbackRate === "number" && Number.isFinite(parsed.playbackRate)) {
preferences.playbackRate = parsed.playbackRate;
}
if (typeof parsed.audioMuted === "boolean") {
preferences.audioMuted = parsed.audioMuted;
}
if (isRecord(parsed.previewZoom)) {
const { zoomPercent, panX, panY } = parsed.previewZoom;
if (