mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix(studio): add preview audio mute controls (#853)
This commit is contained in:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user