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