mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readCompositionSizeFromDocument } from "./composition-probe.js";
|
||||
|
||||
describe("readCompositionSizeFromDocument", () => {
|
||||
it("reads dimensions from the composition root", () => {
|
||||
const doc = document.implementation.createHTMLDocument();
|
||||
doc.body.innerHTML =
|
||||
'<div data-composition-id="main" data-width="1080" data-height="1920"></div>';
|
||||
|
||||
expect(readCompositionSizeFromDocument(doc)).toEqual({ width: 1080, height: 1920 });
|
||||
});
|
||||
|
||||
it("falls back to plain data-width/data-height compositions", () => {
|
||||
const doc = document.implementation.createHTMLDocument();
|
||||
doc.body.innerHTML = '<div class="clip" data-width="1080" data-height="1920"></div>';
|
||||
|
||||
expect(readCompositionSizeFromDocument(doc)).toEqual({ width: 1080, height: 1920 });
|
||||
});
|
||||
|
||||
it("ignores invalid dimensions", () => {
|
||||
const doc = document.implementation.createHTMLDocument();
|
||||
doc.body.innerHTML = '<div data-width="0" data-height="1920"></div>';
|
||||
|
||||
expect(readCompositionSizeFromDocument(doc)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,24 @@ export interface ProbeCallbacks {
|
||||
onRuntimeInjected?: () => void;
|
||||
}
|
||||
|
||||
function readPositiveDimension(value: string | null): number | null {
|
||||
if (value === null) return null;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
export function readCompositionSizeFromDocument(
|
||||
doc: Document | null | undefined,
|
||||
): { width: number; height: number } | null {
|
||||
const root =
|
||||
doc?.querySelector("[data-composition-id][data-width][data-height]") ??
|
||||
doc?.querySelector("[data-width][data-height]");
|
||||
if (!root) return null;
|
||||
const width = readPositiveDimension(root.getAttribute("data-width"));
|
||||
const height = readPositiveDimension(root.getAttribute("data-height"));
|
||||
return width !== null && height !== null ? { width, height } : null;
|
||||
}
|
||||
|
||||
export class CompositionProbe {
|
||||
private _interval: ReturnType<typeof setInterval> | null = null;
|
||||
private _runtimeInjected = false;
|
||||
@@ -45,6 +63,7 @@ export class CompositionProbe {
|
||||
private readonly _callbacks: ProbeCallbacks,
|
||||
) {}
|
||||
|
||||
// fallow-ignore-next-line unused-class-member
|
||||
get runtimeInjected(): boolean {
|
||||
return this._runtimeInjected;
|
||||
}
|
||||
@@ -55,6 +74,7 @@ export class CompositionProbe {
|
||||
this._runtimeInjected = false;
|
||||
let attempts = 0;
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
this._interval = setInterval(() => {
|
||||
attempts++;
|
||||
try {
|
||||
@@ -89,14 +109,7 @@ export class CompositionProbe {
|
||||
if (adapter && adapter.getDuration() > 0) {
|
||||
this.stop();
|
||||
|
||||
const doc = this._iframe.contentDocument;
|
||||
let compositionSize: { width: number; height: number } | null = null;
|
||||
const root = doc?.querySelector("[data-composition-id]");
|
||||
if (root) {
|
||||
const w = parseInt(root.getAttribute("data-width") || "0", 10);
|
||||
const h = parseInt(root.getAttribute("data-height") || "0", 10);
|
||||
if (w > 0 && h > 0) compositionSize = { width: w, height: h };
|
||||
}
|
||||
const compositionSize = readCompositionSizeFromDocument(this._iframe.contentDocument);
|
||||
|
||||
this._callbacks.onReady({
|
||||
duration: adapter.getDuration(),
|
||||
@@ -135,6 +148,7 @@ export class CompositionProbe {
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line unused-class-member
|
||||
resolveDirectTimelineAdapterFromWindow(win: Window): DirectTimelineAdapter | null {
|
||||
return this._resolveDirectTimelineAdapterFromWindow(win);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { act, createRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NLEPreview, getPreviewPlayerKey } from "./NLEPreview";
|
||||
import { NLEPreview, getPreviewPlayerKey, resolvePreviewStageSize } from "./NLEPreview";
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -133,6 +133,22 @@ describe("getPreviewPlayerKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePreviewStageSize", () => {
|
||||
it("fits portrait composition dimensions by height in a narrow viewport", () => {
|
||||
expect(resolvePreviewStageSize(512, 402, { width: 1080, height: 1920 }, undefined)).toEqual({
|
||||
width: 217.125,
|
||||
height: 386,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses composition dimensions ahead of the legacy portrait fallback", () => {
|
||||
expect(resolvePreviewStageSize(512, 402, { width: 1920, height: 1080 }, true)).toEqual({
|
||||
width: 496,
|
||||
height: 279,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("NLEPreview", () => {
|
||||
beforeEach(() => {
|
||||
globalThis.ResizeObserver = MockResizeObserver as typeof ResizeObserver;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState, type Ref } from "react";
|
||||
import { memo, useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { Player } from "../../player";
|
||||
import {
|
||||
DEFAULT_PREVIEW_ZOOM,
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
||||
interface NLEPreviewProps {
|
||||
projectId: string;
|
||||
iframeRef: Ref<HTMLIFrameElement>;
|
||||
iframeRef: RefObject<HTMLIFrameElement | null>;
|
||||
onIframeLoad: () => void;
|
||||
onCompositionLoadingChange?: (loading: boolean) => void;
|
||||
portrait?: boolean;
|
||||
@@ -37,6 +37,11 @@ const ZOOM_HUD_TIMEOUT_MS = 1200;
|
||||
const ZOOM_SETTLE_MS = 200;
|
||||
const PREVIEW_STAGE_INSET_PX = 16;
|
||||
|
||||
interface PreviewCompositionSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function isPreviewAtFit(state: PreviewZoomState): boolean {
|
||||
return (
|
||||
Math.abs(state.zoomPercent - 100) < 0.5 &&
|
||||
@@ -56,14 +61,41 @@ function loadInitialZoom(): PreviewZoomState {
|
||||
: DEFAULT_PREVIEW_ZOOM;
|
||||
}
|
||||
|
||||
function resolvePreviewStageSize(
|
||||
// fallow-ignore-next-line complexity
|
||||
function readPreviewCompositionSize(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
): PreviewCompositionSize | null {
|
||||
try {
|
||||
const doc = iframe?.contentDocument;
|
||||
const root =
|
||||
doc?.querySelector("[data-composition-id][data-width][data-height]") ??
|
||||
doc?.querySelector("[data-width][data-height]");
|
||||
if (!root) return null;
|
||||
const width = Number.parseInt(root.getAttribute("data-width") ?? "", 10);
|
||||
const height = Number.parseInt(root.getAttribute("data-height") ?? "", 10);
|
||||
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
return { width, height };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolvePreviewStageSize(
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
compositionSize: PreviewCompositionSize | null,
|
||||
portrait: boolean | undefined,
|
||||
): { width: number; height: number } {
|
||||
const availableWidth = Math.max(0, viewportWidth - PREVIEW_STAGE_INSET_PX);
|
||||
const availableHeight = Math.max(0, viewportHeight - PREVIEW_STAGE_INSET_PX);
|
||||
const aspectRatio = portrait ? 9 / 16 : 16 / 9;
|
||||
const aspectRatio =
|
||||
compositionSize && compositionSize.width > 0 && compositionSize.height > 0
|
||||
? compositionSize.width / compositionSize.height
|
||||
: portrait
|
||||
? 9 / 16
|
||||
: 16 / 9;
|
||||
|
||||
if (availableWidth === 0 || availableHeight === 0) {
|
||||
return { width: 0, height: 0 };
|
||||
@@ -95,10 +127,12 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
useEffect(() => {
|
||||
onStageRef?.(stageRef);
|
||||
}, [onStageRef]);
|
||||
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, portrait));
|
||||
const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
|
||||
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
|
||||
|
||||
const zoomRef = useRef<PreviewZoomState>(loadInitialZoom());
|
||||
const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(() => zoomRef.current);
|
||||
@@ -127,14 +161,29 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
|
||||
const updateStageSize = () => {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
setStageSize(resolvePreviewStageSize(rect.width, rect.height, portrait));
|
||||
setStageSize(resolvePreviewStageSize(rect.width, rect.height, compositionSize, portrait));
|
||||
};
|
||||
|
||||
updateStageSize();
|
||||
const observer = new ResizeObserver(updateStageSize);
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, [portrait]);
|
||||
}, [compositionSize, portrait]);
|
||||
|
||||
const updateCompositionSizeFromPreview = useCallback(() => {
|
||||
const next = readPreviewCompositionSize(previewIframeRef.current);
|
||||
setCompositionSize((prev) =>
|
||||
prev?.width === next?.width && prev?.height === next?.height ? prev : next,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const setPreviewIframeRef = useCallback(
|
||||
(node: HTMLIFrameElement | null) => {
|
||||
previewIframeRef.current = node;
|
||||
iframeRef.current = node;
|
||||
},
|
||||
[iframeRef],
|
||||
);
|
||||
|
||||
const stageSizeRef = useRef(stageSize);
|
||||
stageSizeRef.current = stageSize;
|
||||
@@ -403,10 +452,11 @@ export const NLEPreview = memo(function NLEPreview({
|
||||
)}
|
||||
<Player
|
||||
key={activeKey}
|
||||
ref={iframeRef}
|
||||
ref={setPreviewIframeRef}
|
||||
projectId={directUrl ? undefined : projectId}
|
||||
directUrl={directUrl}
|
||||
onLoad={() => {
|
||||
updateCompositionSizeFromPreview();
|
||||
onIframeLoad();
|
||||
applyInitialZoom();
|
||||
}}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export function shouldResumeForwardPlaybackAfterSeek(input: {
|
||||
keepPlaying: boolean;
|
||||
wasReverseShuttle: boolean;
|
||||
storeWasPlaying: boolean;
|
||||
duration: number;
|
||||
nextTime: number;
|
||||
}): boolean {
|
||||
return (
|
||||
input.keepPlaying &&
|
||||
!input.wasReverseShuttle &&
|
||||
input.storeWasPlaying &&
|
||||
(input.duration <= 0 || input.nextTime < input.duration)
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldStopAfterSeek(input: {
|
||||
keepPlaying: boolean;
|
||||
wasReverseShuttle: boolean;
|
||||
}): boolean {
|
||||
return !input.keepPlaying || input.wasReverseShuttle;
|
||||
}
|
||||
Reference in New Issue
Block a user