feat(studio): export player handle for hosts (#4227)

This commit is contained in:
Miguel Ángel
2026-09-20 17:47:35 -04:00
committed by GitHub
parent 2fb6f6987e
commit 4ff4435e1d
6 changed files with 271 additions and 4 deletions
@@ -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";
+9 -1
View File
@@ -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
View File
@@ -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