diff --git a/packages/studio/src/components/nle/NLEContext.tsx b/packages/studio/src/components/nle/NLEContext.tsx index 1c921277f2..ab85f68caf 100644 --- a/packages/studio/src/components/nle/NLEContext.tsx +++ b/packages/studio/src/components/nle/NLEContext.tsx @@ -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; + 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(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, diff --git a/packages/studio/src/components/nle/useCompositionStack.ts b/packages/studio/src/components/nle/useCompositionStack.ts index 47e23350f3..44a0164acf 100644 --- a/packages/studio/src/components/nle/useCompositionStack.ts +++ b/packages/studio/src/components/nle/useCompositionStack.ts @@ -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"; diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts index 815ebea193..61941d01a7 100644 --- a/packages/studio/src/index.ts +++ b/packages/studio/src/index.ts @@ -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"; diff --git a/packages/studio/src/player/hooks/usePlayerHandle.test.tsx b/packages/studio/src/player/hooks/usePlayerHandle.test.tsx new file mode 100644 index 0000000000..652945227c --- /dev/null +++ b/packages/studio/src/player/hooks/usePlayerHandle.test.tsx @@ -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( + + (context = value)} + onValue={(value) => (latest = value)} + onRender={() => renders++} + /> + , + ); + }); + 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()); + }); +}); diff --git a/packages/studio/src/player/hooks/usePlayerHandle.ts b/packages/studio/src/player/hooks/usePlayerHandle.ts new file mode 100644 index 0000000000..e9b76498b4 --- /dev/null +++ b/packages/studio/src/player/hooks/usePlayerHandle.ts @@ -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, + }; +} diff --git a/packages/studio/src/player/index.ts b/packages/studio/src/player/index.ts index b8852fe5ba..3f8d5de402 100644 --- a/packages/studio/src/player/index.ts +++ b/packages/studio/src/player/index.ts @@ -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