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
@@ -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 };