fix(studio): fit preview reset to composition dimensions (#1085)

* fix(studio): fit preview reset to composition dimensions

* fix(core): keep runtime root resolution explicit

* fix(studio): resume playback after keep-playing seek
This commit is contained in:
Miguel Ángel
2026-05-26 23:44:39 -04:00
committed by GitHub
parent 3cd6cd6a1c
commit 3a24aed9bc
7 changed files with 265 additions and 187 deletions
@@ -25,6 +25,20 @@ function TimelinePlayerHarness({
return null;
}
function renderTimelinePlayerHarness() {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }));
});
if (!api) throw new Error("useTimelinePlayer did not mount");
return { api, root };
}
afterEach(() => {
document.body.innerHTML = "";
resetPlayerStore();
@@ -35,19 +49,25 @@ function attachIframeAdapter(
options: {
postMessage?: (message: unknown, targetOrigin: string) => void;
timelines?: Record<string, unknown>;
duration?: number;
} = {},
) {
const iframe = document.createElement("iframe");
let currentTime = 0;
let playing = false;
const adapter = {
play: () => {},
pause: () => {},
play: vi.fn(() => {
playing = true;
}),
pause: vi.fn(() => {
playing = false;
}),
seek: (time: number) => {
currentTime = time;
},
getTime: () => currentTime,
getDuration: () => 30,
isPlaying: () => false,
getDuration: () => options.duration ?? 30,
isPlaying: () => playing,
};
Object.defineProperty(iframe, "contentWindow", {
value: {
@@ -71,90 +91,77 @@ function attachIframeAdapter(
return adapter;
}
function renderAttachedTimelinePlayer() {
const { api, root } = renderTimelinePlayerHarness();
const adapter = attachIframeAdapter(api);
return { api, root, adapter };
}
function setStorePlaying() {
act(() => {
usePlayerStore.setState({ isPlaying: true });
});
}
function seekWithAct(
api: ReturnType<typeof useTimelinePlayer>,
time: number,
options?: { keepPlaying?: boolean },
) {
act(() => {
api.seek(time, options);
});
}
function unmountWithAct(root: ReturnType<typeof createRoot>) {
act(() => {
root.unmount();
});
}
function expectStorePlaybackState(
root: ReturnType<typeof createRoot>,
expected: { isPlaying: boolean; currentTime: number },
) {
expect(usePlayerStore.getState().isPlaying).toBe(expected.isPlaying);
expect(usePlayerStore.getState().currentTime).toBe(expected.currentTime);
unmountWithAct(root);
}
describe("useTimelinePlayer seek hydration", () => {
it("keeps an external seek request until the iframe adapter is ready", () => {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
const observedTimes: number[] = [];
const unsubscribe = liveTime.subscribe((time) => {
observedTimes.push(time);
});
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
);
});
const { api, root } = renderTimelinePlayerHarness();
act(() => {
usePlayerStore.getState().requestSeek(4.2);
});
expect(api).not.toBeNull();
expect(usePlayerStore.getState().currentTime).toBe(0);
expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
const iframe = document.createElement("iframe");
let currentTime = 0;
const adapter = {
play: () => {},
pause: () => {},
seek: (time: number) => {
currentTime = time;
},
getTime: () => currentTime,
getDuration: () => 30,
isPlaying: () => false,
};
Object.defineProperty(iframe, "contentWindow", {
value: {
__player: adapter,
postMessage: () => {},
scrollTo: () => {},
addEventListener: () => {},
removeEventListener: () => {},
},
configurable: true,
});
Object.defineProperty(iframe, "contentDocument", {
value: document.implementation.createHTMLDocument("preview"),
configurable: true,
});
const adapter = attachIframeAdapter(api);
act(() => {
api!.iframeRef.current = iframe;
api!.onIframeLoad();
});
expect(currentTime).toBe(4.2);
expect(adapter.getTime()).toBe(4.2);
expect(usePlayerStore.getState().currentTime).toBe(4.2);
expect(usePlayerStore.getState().timelineReady).toBe(true);
expect(observedTimes).toContain(4.2);
act(() => {
root.unmount();
});
unmountWithAct(root);
unsubscribe();
});
});
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 { api, root } = renderTimelinePlayerHarness();
const postMessage = vi.fn();
const timeScale = vi.fn();
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
);
});
attachIframeAdapter(api!, {
attachIframeAdapter(api, {
postMessage,
timelines: {
root: { timeScale },
@@ -202,24 +209,14 @@ describe("useTimelinePlayer audio controls (#835)", () => {
"*",
);
act(() => {
root.unmount();
});
unmountWithAct(root);
});
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 { api, root } = renderTimelinePlayerHarness();
const postMessage = vi.fn();
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
);
});
attachIframeAdapter(api!, { postMessage });
attachIframeAdapter(api, { postMessage });
postMessage.mockClear();
act(() => {
@@ -235,95 +232,50 @@ describe("useTimelinePlayer audio controls (#835)", () => {
"*",
);
act(() => {
root.unmount();
});
unmountWithAct(root);
});
});
describe("useTimelinePlayer seek keepPlaying option (#834)", () => {
it("default seek() clears isPlaying when the store reports playing", () => {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const { api, root } = renderAttachedTimelinePlayer();
setStorePlaying();
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
);
});
attachIframeAdapter(api!);
seekWithAct(api, 5);
act(() => {
usePlayerStore.setState({ isPlaying: true });
});
act(() => {
api!.seek(5);
});
expect(usePlayerStore.getState().isPlaying).toBe(false);
expect(usePlayerStore.getState().currentTime).toBe(5);
act(() => {
root.unmount();
});
expectStorePlaybackState(root, { isPlaying: false, currentTime: 5 });
});
it("seek(time, { keepPlaying: true }) preserves isPlaying=true so A/E shortcuts don't pause the timeline", () => {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const { api, root, adapter } = renderAttachedTimelinePlayer();
setStorePlaying();
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
);
});
attachIframeAdapter(api!);
seekWithAct(api, 5, { keepPlaying: true });
act(() => {
usePlayerStore.setState({ isPlaying: true });
});
act(() => {
api!.seek(5, { keepPlaying: true });
});
expect(usePlayerStore.getState().isPlaying).toBe(true);
expect(usePlayerStore.getState().currentTime).toBe(5);
act(() => {
root.unmount();
});
expect(adapter.play).toHaveBeenCalledTimes(1);
expectStorePlaybackState(root, { isPlaying: true, currentTime: 5 });
});
it("seek(time, { keepPlaying: true }) from paused state stays paused (no spurious resume)", () => {
let api: ReturnType<typeof useTimelinePlayer> | null = null;
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(
React.createElement(TimelinePlayerHarness, { onValue: (value) => (api = value) }),
);
});
attachIframeAdapter(api!);
const { api, root } = renderAttachedTimelinePlayer();
expect(usePlayerStore.getState().isPlaying).toBe(false);
act(() => {
api!.seek(5, { keepPlaying: true });
});
seekWithAct(api, 5, { keepPlaying: true });
expect(usePlayerStore.getState().isPlaying).toBe(false);
expect(usePlayerStore.getState().currentTime).toBe(5);
expectStorePlaybackState(root, { isPlaying: false, currentTime: 5 });
});
act(() => {
root.unmount();
});
it("seek(time, { keepPlaying: true }) restarts playback when the iframe adapter was paused", () => {
const { api, root, adapter } = renderAttachedTimelinePlayer();
setStorePlaying();
expect(adapter.isPlaying()).toBe(false);
seekWithAct(api, 0, { keepPlaying: true });
expect(adapter.play).toHaveBeenCalledTimes(1);
expect(adapter.isPlaying()).toBe(true);
expectStorePlaybackState(root, { isPlaying: true, currentTime: 0 });
});
});
@@ -4,19 +4,16 @@ import { useMountEffect } from "../../hooks/useMountEffect";
import { usePlaybackKeyboard } from "./usePlaybackKeyboard";
import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
// Re-export public API consumed by tests and external modules.
// All of these were previously defined in this file; they now live in focused
// sub-modules but are re-exported here so existing import sites don't change.
export type { ClipManifestClip } from "../lib/playbackTypes";
export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
export {
getTimelineElementSelector,
readTimelineDurationFromDocument,
parseTimelineFromDOM,
buildStandaloneRootTimelineElement,
createTimelineElementFromManifestClip,
findTimelineDomNodeForClip,
buildStandaloneRootTimelineElement,
getTimelineElementSelector,
mergeTimelineElementsPreservingDowngrades,
parseTimelineFromDOM,
readTimelineDurationFromDocument,
resolveStandaloneRootCompositionSrc,
resolveIframe,
} from "../lib/timelineDOM";
@@ -43,10 +40,7 @@ import {
shouldMutePreviewAudio,
} from "../lib/timelineIframeHelpers";
import { probeMediaUrl, getCachedProbe } from "../lib/mediaProbe";
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
export function useTimelinePlayer() {
const iframeRef = useRef<HTMLIFrameElement | null>(null);
@@ -65,8 +59,6 @@ export function useTimelinePlayer() {
adapter: PlaybackAdapter;
} | null>(null);
// ZERO store subscriptions — this hook never causes re-renders.
// All reads use getState() (point-in-time), all writes use the stable setters.
const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
usePlayerStore.getState();
@@ -383,8 +375,6 @@ export function useTimelinePlayer() {
}, [getAdapter, setCurrentTime, setIsPlaying, stopRAFLoop, stopReverseLoop]);
const seek = useCallback(
(time: number, options?: { keepPlaying?: boolean }) => {
// Reverse shuttle is always stopped: the RAF reverse tick can't survive
// a seek anyway, so `keepPlaying` only preserves forward playback.
const wasReverseShuttle = shuttleDirectionRef.current === "backward";
stopReverseLoop();
const adapter = getAdapter();
@@ -394,10 +384,27 @@ export function useTimelinePlayer() {
}
const duration = Math.max(0, adapter.getDuration());
const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time);
const keepPlaying = options?.keepPlaying === true;
const shouldResumeAfterSeek = shouldResumeForwardPlaybackAfterSeek({
keepPlaying,
wasReverseShuttle,
storeWasPlaying: usePlayerStore.getState().isPlaying,
duration,
nextTime,
});
adapter.seek(nextTime, options);
liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render
setCurrentTime(nextTime); // sync store so Split/Delete have accurate time
if (!options?.keepPlaying || wasReverseShuttle) {
if (shouldResumeAfterSeek) {
stopRAFLoop();
applyPlaybackRate(usePlayerStore.getState().playbackRate);
applyPreviewAudioState();
adapter.play();
setIsPlaying(true);
shuttleDirectionRef.current = "forward";
shuttleSpeedIndexRef.current = 0;
startRAFLoop();
} else if (shouldStopAfterSeek({ keepPlaying, wasReverseShuttle })) {
stopRAFLoop();
if (usePlayerStore.getState().isPlaying) setIsPlaying(false);
shuttleDirectionRef.current = null;
@@ -410,14 +417,16 @@ export function useTimelinePlayer() {
pendingSeekRef,
setCurrentTime,
setIsPlaying,
startRAFLoop,
stopRAFLoop,
stopReverseLoop,
applyPlaybackRate,
applyPreviewAudioState,
shuttleDirectionRef,
shuttleSpeedIndexRef,
],
);
// Handle seek requests from outside the player loop (e.g. LayersPanel).
useEffect(() => {
return usePlayerStore.subscribe((state, prev) => {
if (state.requestedSeekTime !== null && state.requestedSeekTime !== prev.requestedSeekTime) {
@@ -480,12 +489,8 @@ export function useTimelinePlayer() {
const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
// Listen for timeline messages from the iframe runtime.
// The runtime sends this AFTER all external compositions load,
// so we get the complete clip list (not just the first few).
const handleMessage = (e: MessageEvent) => {
const data = e.data;
// Only process messages from the main preview iframe — ignore MediaPanel/ClipThumbnail iframes
const ourIframe = iframeRef.current;
if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
return;
@@ -499,10 +504,6 @@ export function useTimelinePlayer() {
processTimelineMessageRef.current(manifest);
}
}
// Enrich only when the timeline has settled — skip during the window
// right after a "timeline" message to avoid the enrichment adding
// elements that fight with the manifest's authoritative element list,
// causing duration oscillation.
const msSinceTimeline = Date.now() - lastTimelineMessageRef.current;
if (msSinceTimeline > 500) {
enrichMissingCompositionsRef.current();
@@ -535,7 +536,6 @@ export function useTimelinePlayer() {
}
};
// Pause video when tab loses focus
const handleVisibilityChange = () => {
if (document.hidden && usePlayerStore.getState().isPlaying) {
const adapter = getAdapterRef.current?.();
@@ -564,7 +564,6 @@ export function useTimelinePlayer() {
};
});
/** Reset the player store (elements, duration, etc.) — call when switching sessions. */
const resetPlayer = useCallback(() => {
stopRAFLoop();
stopReverseLoop();