mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
feat(studio): export player handle for hosts (#4227)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { buildProjectApiPath } from "../../utils/projectRouting";
|
||||
import { useContext, useState, useCallback, useRef, useEffect, type ReactNode } from "react";
|
||||
import { useTimelinePlayer, usePlayerStore } from "../../player";
|
||||
import type { TimelineElement } from "../../player";
|
||||
import { useTimelinePlayer } from "../../player/hooks/useTimelinePlayer";
|
||||
import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
|
||||
import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
|
||||
import type { CompositionLevel } from "./CompositionBreadcrumb";
|
||||
import { useCompositionStack } from "./useCompositionStack";
|
||||
@@ -18,6 +18,8 @@ export interface NLEContextValue {
|
||||
projectId: string;
|
||||
// player (from useTimelinePlayer — single instance for the whole shell)
|
||||
iframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlay: () => void;
|
||||
seek: (time: number, options?: { keepPlaying?: boolean }) => boolean;
|
||||
refreshPlayer: () => void;
|
||||
@@ -84,6 +86,8 @@ export function NLEProvider({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
iframeRef,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
seek,
|
||||
onIframeLoad: baseOnIframeLoad,
|
||||
@@ -298,6 +302,8 @@ export function NLEProvider({
|
||||
const value: NLEContextValue = {
|
||||
projectId,
|
||||
iframeRef,
|
||||
play,
|
||||
pause,
|
||||
containerRef,
|
||||
togglePlay,
|
||||
seek,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { buildProjectApiPath } from "../../utils/projectRouting";
|
||||
// Composition drill-down stack management for NLEContext/EditorShell
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { usePlayerStore } from "../../player";
|
||||
import { usePlayerStore } from "../../player/store/playerStore";
|
||||
import type { CompositionLevel } from "./CompositionBreadcrumb";
|
||||
import { encodePreviewPath } from "../../player/components/thumbnailUtils";
|
||||
|
||||
|
||||
@@ -46,12 +46,20 @@ export {
|
||||
VideoThumbnail,
|
||||
CompositionThumbnail,
|
||||
useTimelinePlayer,
|
||||
usePlayerHandle,
|
||||
resolveIframe,
|
||||
usePlayerStore,
|
||||
liveTime,
|
||||
formatTime,
|
||||
} from "./player";
|
||||
export type { TimelineElement, TimelineTimeRange } from "./player";
|
||||
export type {
|
||||
PlayerHandle,
|
||||
PlayerHandleElement,
|
||||
PlayerHandleListener,
|
||||
PlayerHandleTimeListener,
|
||||
TimelineElement,
|
||||
TimelineTimeRange,
|
||||
} from "./player";
|
||||
|
||||
// Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
|
||||
export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { NLEProvider, useNLEContext, type NLEContextValue } from "../../components/nle/NLEContext";
|
||||
import { liveTime, usePlayerStore } from "../store/playerStore";
|
||||
import { makeAdapterWindow, makeFakeIframe } from "./timelinePlayerTestHarness";
|
||||
import { usePlayerHandle, type PlayerHandle } from "./usePlayerHandle";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
function renderHandle() {
|
||||
let latest: PlayerHandle | null = null;
|
||||
let context: NLEContextValue | null = null;
|
||||
let renders = 0;
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<NLEProvider projectId="player-handle-test">
|
||||
<HandleHarness
|
||||
onContext={(value) => (context = value)}
|
||||
onValue={(value) => (latest = value)}
|
||||
onRender={() => renders++}
|
||||
/>
|
||||
</NLEProvider>,
|
||||
);
|
||||
});
|
||||
if (!latest || !context) throw new Error("usePlayerHandle did not mount");
|
||||
return {
|
||||
root,
|
||||
getHandle: () => latest as PlayerHandle,
|
||||
getContext: () => context as NLEContextValue,
|
||||
getRenderCount: () => renders,
|
||||
};
|
||||
}
|
||||
|
||||
function HandleHarness({
|
||||
onContext,
|
||||
onValue,
|
||||
onRender,
|
||||
}: {
|
||||
onContext: (value: NLEContextValue) => void;
|
||||
onValue: (value: PlayerHandle) => void;
|
||||
onRender: () => void;
|
||||
}) {
|
||||
onRender();
|
||||
const context = useNLEContext();
|
||||
const handle = usePlayerHandle();
|
||||
useEffect(() => {
|
||||
onContext(context);
|
||||
onValue(handle);
|
||||
}, [context, handle, onContext, onValue]);
|
||||
return null;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("usePlayerHandle", () => {
|
||||
it("refuses seek before readiness and notifies structural changes independently", () => {
|
||||
const { root, getHandle } = renderHandle();
|
||||
const handle = getHandle();
|
||||
|
||||
expect(handle.ready).toBe(false);
|
||||
expect(handle.seek(4)).toBe(false);
|
||||
|
||||
const structuralListener = vi.fn();
|
||||
const unsubscribe = handle.subscribe(structuralListener);
|
||||
act(() => usePlayerStore.setState({ timelineReady: true }));
|
||||
expect(structuralListener).toHaveBeenCalledTimes(1);
|
||||
act(() => usePlayerStore.setState({ duration: 8 }));
|
||||
expect(structuralListener).toHaveBeenCalledTimes(2);
|
||||
act(() =>
|
||||
usePlayerStore.setState({
|
||||
elements: [
|
||||
{
|
||||
id: "clip",
|
||||
tag: "div",
|
||||
start: 2,
|
||||
duration: 3,
|
||||
track: 0,
|
||||
selector: "#clip",
|
||||
sourceFile: "index.html",
|
||||
compositionSrc: "compositions/intro.html",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(structuralListener).toHaveBeenCalledTimes(3);
|
||||
expect(getHandle().elements).toEqual([
|
||||
{
|
||||
id: "clip",
|
||||
start: 2,
|
||||
end: 5,
|
||||
selector: "#clip",
|
||||
sourceFile: "index.html",
|
||||
compositionSrc: "compositions/intro.html",
|
||||
},
|
||||
]);
|
||||
|
||||
unsubscribe();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("uses the shell player to seek a real preview adapter after readiness", async () => {
|
||||
const { root, getContext, getHandle } = renderHandle();
|
||||
const { adapter, win } = makeAdapterWindow();
|
||||
await act(async () => {
|
||||
getContext().iframeRef.current = makeFakeIframe(win);
|
||||
getContext().onIframeLoad();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(usePlayerStore.getState().timelineReady).toBe(true);
|
||||
expect(getHandle().seek(4)).toBe(true);
|
||||
expect(adapter.getTime()).toBe(4);
|
||||
expect(usePlayerStore.getState().currentTime).toBe(4);
|
||||
act(() => getHandle().play());
|
||||
expect(getHandle().playing).toBe(true);
|
||||
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps playback time out of React renders and forwards live time notifications", () => {
|
||||
const { root, getHandle, getRenderCount } = renderHandle();
|
||||
const initialRenderCount = getRenderCount();
|
||||
const timeListener = vi.fn();
|
||||
const structuralListener = vi.fn();
|
||||
const unsubscribeTime = getHandle().subscribeTime(timeListener);
|
||||
const unsubscribe = getHandle().subscribe(structuralListener);
|
||||
|
||||
act(() => {
|
||||
usePlayerStore.setState({ currentTime: 2.5 });
|
||||
liveTime.notify(7.25);
|
||||
});
|
||||
|
||||
expect(getRenderCount()).toBe(initialRenderCount);
|
||||
expect(getHandle().currentTime).toBe(2.5);
|
||||
expect(timeListener).toHaveBeenCalledWith(7.25);
|
||||
expect(structuralListener).not.toHaveBeenCalled();
|
||||
|
||||
unsubscribeTime();
|
||||
unsubscribe();
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { liveTime, usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import { useNLEContext } from "../../components/nle/NLEContext";
|
||||
|
||||
export interface PlayerHandleElement {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
selector?: string;
|
||||
sourceFile?: string;
|
||||
compositionSrc?: string;
|
||||
}
|
||||
|
||||
export type PlayerHandleListener = () => void;
|
||||
export type PlayerHandleTimeListener = (time: number) => void;
|
||||
|
||||
export interface PlayerHandle {
|
||||
ready: boolean;
|
||||
playing: boolean;
|
||||
seek: (seconds: number) => boolean;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlay: () => void;
|
||||
readonly currentTime: number;
|
||||
duration: number;
|
||||
elements: PlayerHandleElement[];
|
||||
subscribe: (listener: PlayerHandleListener) => () => void;
|
||||
subscribeTime: (listener: PlayerHandleTimeListener) => () => void;
|
||||
}
|
||||
|
||||
function toHandleElement(element: TimelineElement): PlayerHandleElement {
|
||||
return {
|
||||
id: element.id,
|
||||
start: element.start,
|
||||
end: element.start + element.duration,
|
||||
selector: element.selector,
|
||||
sourceFile: element.sourceFile,
|
||||
compositionSrc: element.compositionSrc,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and controls the shell player. Call this under EditorShell/NLEProvider;
|
||||
* the provider owns the mounted preview iframe and the single player instance.
|
||||
*/
|
||||
export function usePlayerHandle(): PlayerHandle {
|
||||
const { play, pause, togglePlay, seek: seekPlayer } = useNLEContext();
|
||||
const ready = usePlayerStore((state) => state.timelineReady);
|
||||
const playing = usePlayerStore((state) => state.isPlaying);
|
||||
const duration = usePlayerStore((state) => state.duration);
|
||||
const elements = usePlayerStore((state) => state.elements);
|
||||
|
||||
const seek = useCallback(
|
||||
(seconds: number) => {
|
||||
if (!ready) return false;
|
||||
return seekPlayer(seconds);
|
||||
},
|
||||
[ready, seekPlayer],
|
||||
);
|
||||
|
||||
const subscribe = useCallback((listener: PlayerHandleListener) => {
|
||||
let previous = usePlayerStore.getState();
|
||||
return usePlayerStore.subscribe((state) => {
|
||||
const structuralChange =
|
||||
state.timelineReady !== previous.timelineReady ||
|
||||
state.isPlaying !== previous.isPlaying ||
|
||||
state.duration !== previous.duration ||
|
||||
state.elements !== previous.elements;
|
||||
previous = state;
|
||||
if (structuralChange) listener();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const subscribeTime = useCallback((listener: PlayerHandleTimeListener) => {
|
||||
return liveTime.subscribe(listener);
|
||||
}, []);
|
||||
const handleElements = useMemo(() => elements.map(toHandleElement), [elements]);
|
||||
|
||||
return {
|
||||
ready,
|
||||
playing,
|
||||
seek,
|
||||
play,
|
||||
pause,
|
||||
togglePlay,
|
||||
get currentTime() {
|
||||
return usePlayerStore.getState().currentTime;
|
||||
},
|
||||
duration,
|
||||
elements: handleElements,
|
||||
subscribe,
|
||||
subscribeTime,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,13 @@ export { CompositionThumbnail } from "./components/CompositionThumbnail";
|
||||
|
||||
// Hooks
|
||||
export { useTimelinePlayer } from "./hooks/useTimelinePlayer";
|
||||
export { usePlayerHandle } from "./hooks/usePlayerHandle";
|
||||
export type {
|
||||
PlayerHandle,
|
||||
PlayerHandleElement,
|
||||
PlayerHandleListener,
|
||||
PlayerHandleTimeListener,
|
||||
} from "./hooks/usePlayerHandle";
|
||||
export { resolveIframe } from "./lib/timelineDOM";
|
||||
|
||||
// Store
|
||||
|
||||
Reference in New Issue
Block a user