@hyperframes/studio 0.8.57 → 0.8.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.57",
3
+ "version": "0.8.58",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,11 +51,11 @@
51
51
  "dockview-react": "8.3.1",
52
52
  "gsap": "^3.13.0",
53
53
  "mediabunny": "^1.45.3",
54
- "@hyperframes/core": "0.8.57",
55
- "@hyperframes/player": "0.8.57",
56
- "@hyperframes/parsers": "0.8.57",
57
- "@hyperframes/sdk": "0.8.57",
58
- "@hyperframes/studio-server": "0.8.57"
54
+ "@hyperframes/core": "0.8.58",
55
+ "@hyperframes/parsers": "0.8.58",
56
+ "@hyperframes/studio-server": "0.8.58",
57
+ "@hyperframes/sdk": "0.8.58",
58
+ "@hyperframes/player": "0.8.58"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@tailwindcss/vite": "^4.3.3",
@@ -71,7 +71,7 @@
71
71
  "vite": "^6.4.2",
72
72
  "vitest": "^4.1.11",
73
73
  "zustand": "^5.0.0",
74
- "@hyperframes/producer": "0.8.57"
74
+ "@hyperframes/producer": "0.8.58"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "react": "19",
@@ -52,7 +52,6 @@ it("re-evaluates set-destination availability when a reload swaps the live ifram
52
52
  expect(usePlayerStore.getState().motionPathCreateAvailable).toBe(false);
53
53
 
54
54
  act(() => {
55
- ref.current = after;
56
55
  usePreviewIframeStore.getState().setIframe(after);
57
56
  });
58
57
  expect(usePlayerStore.getState().motionPathCreateAvailable).toBe(true);
@@ -1,7 +1,7 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
2
  import { useContext, useState, useCallback, useRef, useEffect, type ReactNode } from "react";
3
- import { useTimelinePlayer, usePlayerStore } from "../../player";
4
- import type { TimelineElement } from "../../player";
3
+ import { useTimelinePlayer } from "../../player/hooks/useTimelinePlayer";
4
+ import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
5
5
  import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
6
6
  import type { CompositionLevel } from "./CompositionBreadcrumb";
7
7
  import { useCompositionStack } from "./useCompositionStack";
@@ -18,6 +18,8 @@ export interface NLEContextValue {
18
18
  projectId: string;
19
19
  // player (from useTimelinePlayer — single instance for the whole shell)
20
20
  iframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
21
+ play: () => void;
22
+ pause: () => void;
21
23
  togglePlay: () => void;
22
24
  seek: (time: number, options?: { keepPlaying?: boolean }) => boolean;
23
25
  refreshPlayer: () => void;
@@ -84,6 +86,8 @@ export function NLEProvider({
84
86
  const containerRef = useRef<HTMLDivElement>(null);
85
87
  const {
86
88
  iframeRef,
89
+ play,
90
+ pause,
87
91
  togglePlay,
88
92
  seek,
89
93
  onIframeLoad: baseOnIframeLoad,
@@ -298,6 +302,8 @@ export function NLEProvider({
298
302
  const value: NLEContextValue = {
299
303
  projectId,
300
304
  iframeRef,
305
+ play,
306
+ pause,
301
307
  containerRef,
302
308
  togglePlay,
303
309
  seek,
@@ -261,6 +261,7 @@ describe("NLEPreview", () => {
261
261
  expect(players[0].style.clipPath).toBe("");
262
262
  expect(players[1].style.clipPath).toBe("inset(100%)");
263
263
  expect(players[1].style.visibility).toBe("hidden");
264
+ expect(players[1].style.pointerEvents).toBe("none");
264
265
  view.cleanup();
265
266
  });
266
267
 
@@ -1,7 +1,7 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
2
  // Composition drill-down stack management for NLEContext/EditorShell
3
3
  import { useState, useCallback, useRef, useEffect } from "react";
4
- import { usePlayerStore } from "../../player";
4
+ import { usePlayerStore } from "../../player/store/playerStore";
5
5
  import type { CompositionLevel } from "./CompositionBreadcrumb";
6
6
  import { encodePreviewPath } from "../../player/components/thumbnailUtils";
7
7
 
@@ -71,7 +71,6 @@ it("re-reads the size from the promoted iframe's document", () => {
71
71
  act(() => root.render(<Harness />));
72
72
  expect(host.textContent).toBe('{"width":1920,"height":1080}');
73
73
  act(() => {
74
- ref.current = b;
75
74
  usePreviewIframeStore.getState().setIframe(b);
76
75
  });
77
76
  expect(host.textContent).toBe('{"width":1080,"height":1920}');
@@ -267,6 +267,34 @@ describe("useSdkSession unavailable telemetry", () => {
267
267
  await act(async () => root.unmount());
268
268
  });
269
269
 
270
+ // A fetch that REJECTS produces no response at all. It used to escape this
271
+ // function and be caught by the effect's outer `.catch`, which reported it as
272
+ // `stage: "open"` — a label that means openComposition threw. Every `stage:
273
+ // open` event on 0.8.56/0.8.57 carries a fetch-rejection message, so the
274
+ // largest failure class was a network problem reported as a parser one and
275
+ // was unaddressable in that bucket.
276
+ it("reports a rejected request as a read failure, not an open failure", async () => {
277
+ vi.stubGlobal(
278
+ "fetch",
279
+ vi.fn(async () => {
280
+ throw new TypeError("Failed to fetch");
281
+ }),
282
+ );
283
+ const root = createRoot(document.createElement("div"));
284
+ await act(async () => root.render(<Probe projectId="project-a" />));
285
+ await flushAsyncEffects();
286
+
287
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
288
+ stage: "read",
289
+ reason: "network",
290
+ });
291
+ expect(trackMock).not.toHaveBeenCalledWith("sdk_session_unavailable", {
292
+ stage: "open",
293
+ error: expect.anything(),
294
+ });
295
+ await act(async () => root.unmount());
296
+ });
297
+
270
298
  it("separates an unexpected response shape from a failed request", async () => {
271
299
  vi.stubGlobal(
272
300
  "fetch",
@@ -16,12 +16,23 @@ import { addExternalFileReloadListener } from "./externalFileReloadBus";
16
16
  *
17
17
  * Every reason lives in this union so the full surface is readable from one
18
18
  * place — `absent_or_empty` included, even though it is a 2xx.
19
+ *
20
+ * `network` is a fetch that REJECTED — the request never produced a response.
21
+ * It was not in this union until 2026-09-21, so it escaped `readProjectFileOptional`
22
+ * and was caught by the effect's outer `.catch`, landing in `stage: "open"`.
23
+ * That label means `openComposition` threw (unparseable composition, OOM), and
24
+ * it is what the largest failure class was reported as. Every `stage: open`
25
+ * event measured on 0.8.56 and 0.8.57 carries a fetch-rejection message —
26
+ * "Failed to fetch", "Load failed", "NetworkError when attempting to fetch
27
+ * resource." — so the class was a network problem filed under a parser one, and
28
+ * unaddressable in that bucket.
19
29
  */
20
30
  type ProjectFileReadFailure =
21
31
  | { ok: false; reason: "unsafe_path" }
22
32
  | { ok: false; reason: "http_error"; status: number }
23
33
  | { ok: false; reason: "missing_content" }
24
- | { ok: false; reason: "absent_or_empty" };
34
+ | { ok: false; reason: "absent_or_empty" }
35
+ | { ok: false; reason: "network" };
25
36
 
26
37
  type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
27
38
 
@@ -68,9 +79,20 @@ async function readProjectFileOptional(
68
79
  // and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
69
80
  // already confines both values to single segments of this same-origin URL.
70
81
  if (path.includes("\0") || path.includes("..")) return { ok: false, reason: "unsafe_path" };
71
- const res = await fetch(
72
- `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
73
- );
82
+ let res: Response;
83
+ try {
84
+ res = await fetch(
85
+ `/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
86
+ );
87
+ } catch {
88
+ // The request never produced a response: offline, the dev server gone, a
89
+ // CSP/Private-Network-Access block, or an extension rewriting fetch. Caught
90
+ // here so it is reported as a READ failure — left to propagate, the effect's
91
+ // outer catch reported it as `stage: "open"`, which claims the composition
92
+ // failed to parse. Every `stage: open` event before this change was this
93
+ // branch, so the label made the largest class unaddressable.
94
+ return { ok: false, reason: "network" };
95
+ }
74
96
  if (!res.ok) return { ok: false, reason: "http_error", status: res.status };
75
97
  const data = (await res.json()) as { content?: string };
76
98
  // `optional=1` answers a missing file with 200 + `content: ""`, so a
@@ -299,6 +321,11 @@ export function useSdkSession(
299
321
  // openComposition threw (unparseable composition, OOM) — same total
300
322
  // bypass as the read failure above, but this one is a real defect
301
323
  // rather than a missing file. Carry the message; it is the only clue.
324
+ //
325
+ // A rejected read no longer reaches here: `readProjectFileOptional`
326
+ // catches its own fetch rejection and answers `reason: "network"`, so
327
+ // this stage now means what it says. Before that, every event in this
328
+ // bucket was a network error wearing a parser's label.
302
329
  trackStudioEvent("sdk_session_unavailable", {
303
330
  stage: "open",
304
331
  error: error instanceof Error ? error.message : String(error),
package/src/index.ts CHANGED
@@ -46,12 +46,20 @@ export {
46
46
  VideoThumbnail,
47
47
  CompositionThumbnail,
48
48
  useTimelinePlayer,
49
+ usePlayerHandle,
49
50
  resolveIframe,
50
51
  usePlayerStore,
51
52
  liveTime,
52
53
  formatTime,
53
54
  } from "./player";
54
- export type { TimelineElement, TimelineTimeRange } from "./player";
55
+ export type {
56
+ PlayerHandle,
57
+ PlayerHandleElement,
58
+ PlayerHandleListener,
59
+ PlayerHandleTimeListener,
60
+ TimelineElement,
61
+ TimelineTimeRange,
62
+ } from "./player";
55
63
 
56
64
  // Host overlays: draw over the preview in composition coordinates (see EditorShellProps.gestureOverlay)
57
65
  export { usePreviewCompositionRect } from "./components/editor/usePreviewCompositionRect";
@@ -95,9 +95,9 @@ export function LayerDisclosureRow({
95
95
  <span aria-hidden="true" className="shrink-0 text-[13px] leading-none text-white/40">
96
96
  ◇
97
97
  </span>
98
- {/* Wraps rather than truncating: a truncated name needs a hover to be
99
- read, which a scanned column cannot rely on. */}
100
- <span className="min-w-0 flex-1 wrap-break-word font-medium leading-tight">{name}</span>
98
+ <span title={name} className="min-w-0 flex-1 truncate font-medium leading-tight">
99
+ {name}
100
+ </span>
101
101
  <TrackClipCount clipCount={clipCount} />
102
102
  {children}
103
103
  {/* Anchored right, on every header that has one: the lane toggle is the
@@ -85,6 +85,8 @@ async function mountPlayer(props: Partial<PlayerProps> = {}) {
85
85
 
86
86
  const twoFrames = () => act(async () => void (await new Promise((r) => setTimeout(r, 80))));
87
87
 
88
+ const flushEffects = () => act(async () => await Promise.resolve());
89
+
88
90
  function createAudioIframe() {
89
91
  const iframe = document.createElement("iframe");
90
92
  document.body.appendChild(iframe);
@@ -216,6 +218,38 @@ describe("ready to show", () => {
216
218
  const painted = (player: TestHyperframesPlayer) =>
217
219
  act(() => void player.dispatchEvent(new Event("painted")));
218
220
 
221
+ it("waits two animation frames before notifying that the preview can show", async () => {
222
+ const callbacks: FrameRequestCallback[] = [];
223
+ const requestAnimationFrame = vi
224
+ .spyOn(window, "requestAnimationFrame")
225
+ .mockImplementation((callback) => {
226
+ callbacks.push(callback);
227
+ return callbacks.length;
228
+ });
229
+ try {
230
+ const onReadyToShowChange = vi.fn();
231
+ const { player } = await mountPlayer({ onReadyToShowChange });
232
+ const el = player as TestHyperframesPlayer;
233
+
234
+ loadAndReady(el);
235
+ painted(el);
236
+ await flushEffects();
237
+ expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
238
+ expect(callbacks).toHaveLength(1);
239
+
240
+ act(() => callbacks.shift()?.(0));
241
+ await flushEffects();
242
+ expect(onReadyToShowChange).not.toHaveBeenCalledWith(true);
243
+ expect(callbacks).toHaveLength(1);
244
+
245
+ act(() => callbacks.shift()?.(16));
246
+ await flushEffects();
247
+ expect(onReadyToShowChange).toHaveBeenLastCalledWith(true);
248
+ } finally {
249
+ requestAnimationFrame.mockRestore();
250
+ }
251
+ });
252
+
219
253
  it("promotes only once the player reports painted, not at ready or assetsready", async () => {
220
254
  const onReadyToShowChange = vi.fn();
221
255
  const { player } = await mountPlayer({ onReadyToShowChange });
@@ -65,9 +65,9 @@ function GroupNameButton({
65
65
  <span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
66
66
  ▤
67
67
  </span>
68
- {/* Wraps rather than truncating — a name that needs a hover to be read
69
- is no use in a column you scan. */}
70
- <span className="min-w-0 wrap-break-word text-left font-medium leading-tight">{label}</span>
68
+ <span title={label} className="min-w-0 truncate text-left font-medium leading-tight">
69
+ {label}
70
+ </span>
71
71
  <span
72
72
  className="shrink-0 rounded-full bg-white/10 px-1 text-[9px] leading-[14px] tabular-nums text-white/55"
73
73
  aria-hidden="true"
@@ -109,9 +109,7 @@ export function TimelineGroupHeader({
109
109
  }}
110
110
  >
111
111
  {/* One line, like a track header's: caret and name, then every control
112
- anchored to the right edge. The name wraps and the controls are
113
- `shrink-0`, so they hold the edge and the name gives way — no second
114
- line needed to keep five controls off the label. */}
112
+ anchored to the right edge. */}
115
113
  <button
116
114
  type="button"
117
115
  tabIndex={-1}
@@ -136,4 +136,14 @@ describe("PlainTrackHeader", () => {
136
136
  expect(labelOf(view.host)).toBe("Hide track 1");
137
137
  view.unmount();
138
138
  });
139
+
140
+ it("truncates long names and keeps the full name in the tooltip", () => {
141
+ const name = "A-very-long-unbroken-clip-name-for-the-timeline";
142
+ const view = renderHeader({ trackLabel: name });
143
+ const label = view.host.querySelector("span[title]");
144
+ expect(label?.className).toContain("truncate");
145
+ expect(label?.className).not.toContain("wrap-break-word");
146
+ expect(label?.getAttribute("title")).toBe(name);
147
+ view.unmount();
148
+ });
139
149
  });
@@ -93,14 +93,11 @@ export function PlainTrackHeader({
93
93
  )}
94
94
  {/* No `flex-1`: the name takes only the width it needs, so the clip
95
95
  count sits against it rather than being pushed out to meet the
96
- controls. The slack goes to the `ml-auto` group below instead.
97
-
98
- Wraps rather than truncating: a truncated name needs a hover to be
99
- read at all, and a tooltip is no use to a name you are scanning a
100
- column of. `wrap-break-word` so a long single token breaks instead of
101
- forcing the column wider. */}
96
+ controls. The slack goes to the `ml-auto` group below instead. */}
102
97
  {showTrackLabel && (
103
- <span className="min-w-0 wrap-break-word text-[11px] leading-tight">{trackLabel}</span>
98
+ <span title={trackLabel} className="min-w-0 truncate text-[11px] leading-tight">
99
+ {trackLabel}
100
+ </span>
104
101
  )}
105
102
  {showTrackLabel && <TrackClipCount clipCount={clipCount} />}
106
103
  {/* `ml-auto` is what anchors the group right: it absorbs the slack the
@@ -0,0 +1,41 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { resolvePlaybackAdapter } from "./playbackAdapterResolution";
5
+
6
+ function timeline(duration: number) {
7
+ return {
8
+ duration: vi.fn(() => duration),
9
+ time: vi.fn(() => 0),
10
+ seek: vi.fn(),
11
+ play: vi.fn(),
12
+ pause: vi.fn(),
13
+ isActive: vi.fn(() => false),
14
+ };
15
+ }
16
+
17
+ describe("resolvePlaybackAdapter", () => {
18
+ it("uses the __timelines key matching the document root, not the first key", () => {
19
+ const first = timeline(12);
20
+ const second = timeline(12);
21
+ const third = timeline(12);
22
+ const iframe = document.createElement("iframe");
23
+ const doc = document.implementation.createHTMLDocument("preview");
24
+ doc.body.innerHTML = '<div data-composition-id="second" data-duration="10"></div>';
25
+ Object.defineProperty(iframe, "contentDocument", { configurable: true, value: doc });
26
+ const win = {
27
+ __timelines: { first, second, third },
28
+ } as never;
29
+
30
+ const adapter = resolvePlaybackAdapter(iframe, win, {
31
+ cache: { current: null },
32
+ warned: { current: false },
33
+ });
34
+
35
+ expect(adapter).not.toBeNull();
36
+ adapter?.seek(7);
37
+ expect(second.seek).toHaveBeenCalledWith(7);
38
+ expect(first.seek).not.toHaveBeenCalled();
39
+ expect(third.seek).not.toHaveBeenCalled();
40
+ });
41
+ });
@@ -0,0 +1,152 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { useEffect } from "react";
4
+ import { act } from "react";
5
+ import { createRoot } from "react-dom/client";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { NLEProvider, useNLEContext, type NLEContextValue } from "../../components/nle/NLEContext";
8
+ import { liveTime, usePlayerStore } from "../store/playerStore";
9
+ import { makeAdapterWindow, makeFakeIframe } from "./timelinePlayerTestHarness";
10
+ import { usePlayerHandle, type PlayerHandle } from "./usePlayerHandle";
11
+
12
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
13
+
14
+ function renderHandle() {
15
+ let latest: PlayerHandle | null = null;
16
+ let context: NLEContextValue | null = null;
17
+ let renders = 0;
18
+ const host = document.createElement("div");
19
+ document.body.append(host);
20
+ const root = createRoot(host);
21
+ act(() => {
22
+ root.render(
23
+ <NLEProvider projectId="player-handle-test">
24
+ <HandleHarness
25
+ onContext={(value) => (context = value)}
26
+ onValue={(value) => (latest = value)}
27
+ onRender={() => renders++}
28
+ />
29
+ </NLEProvider>,
30
+ );
31
+ });
32
+ if (!latest || !context) throw new Error("usePlayerHandle did not mount");
33
+ return {
34
+ root,
35
+ getHandle: () => latest as PlayerHandle,
36
+ getContext: () => context as NLEContextValue,
37
+ getRenderCount: () => renders,
38
+ };
39
+ }
40
+
41
+ function HandleHarness({
42
+ onContext,
43
+ onValue,
44
+ onRender,
45
+ }: {
46
+ onContext: (value: NLEContextValue) => void;
47
+ onValue: (value: PlayerHandle) => void;
48
+ onRender: () => void;
49
+ }) {
50
+ onRender();
51
+ const context = useNLEContext();
52
+ const handle = usePlayerHandle();
53
+ useEffect(() => {
54
+ onContext(context);
55
+ onValue(handle);
56
+ }, [context, handle, onContext, onValue]);
57
+ return null;
58
+ }
59
+
60
+ afterEach(() => {
61
+ usePlayerStore.getState().reset();
62
+ document.body.innerHTML = "";
63
+ });
64
+
65
+ describe("usePlayerHandle", () => {
66
+ it("refuses seek before readiness and notifies structural changes independently", () => {
67
+ const { root, getHandle } = renderHandle();
68
+ const handle = getHandle();
69
+
70
+ expect(handle.ready).toBe(false);
71
+ expect(handle.seek(4)).toBe(false);
72
+
73
+ const structuralListener = vi.fn();
74
+ const unsubscribe = handle.subscribe(structuralListener);
75
+ act(() => usePlayerStore.setState({ timelineReady: true }));
76
+ expect(structuralListener).toHaveBeenCalledTimes(1);
77
+ act(() => usePlayerStore.setState({ duration: 8 }));
78
+ expect(structuralListener).toHaveBeenCalledTimes(2);
79
+ act(() =>
80
+ usePlayerStore.setState({
81
+ elements: [
82
+ {
83
+ id: "clip",
84
+ tag: "div",
85
+ start: 2,
86
+ duration: 3,
87
+ track: 0,
88
+ selector: "#clip",
89
+ sourceFile: "index.html",
90
+ compositionSrc: "compositions/intro.html",
91
+ },
92
+ ],
93
+ }),
94
+ );
95
+ expect(structuralListener).toHaveBeenCalledTimes(3);
96
+ expect(getHandle().elements).toEqual([
97
+ {
98
+ id: "clip",
99
+ start: 2,
100
+ end: 5,
101
+ selector: "#clip",
102
+ sourceFile: "index.html",
103
+ compositionSrc: "compositions/intro.html",
104
+ },
105
+ ]);
106
+
107
+ unsubscribe();
108
+ act(() => root.unmount());
109
+ });
110
+
111
+ it("uses the shell player to seek a real preview adapter after readiness", async () => {
112
+ const { root, getContext, getHandle } = renderHandle();
113
+ const { adapter, win } = makeAdapterWindow();
114
+ await act(async () => {
115
+ getContext().iframeRef.current = makeFakeIframe(win);
116
+ getContext().onIframeLoad();
117
+ await Promise.resolve();
118
+ });
119
+
120
+ expect(usePlayerStore.getState().timelineReady).toBe(true);
121
+ expect(getHandle().seek(4)).toBe(true);
122
+ expect(adapter.getTime()).toBe(4);
123
+ expect(usePlayerStore.getState().currentTime).toBe(4);
124
+ act(() => getHandle().play());
125
+ expect(getHandle().playing).toBe(true);
126
+
127
+ act(() => root.unmount());
128
+ });
129
+
130
+ it("keeps playback time out of React renders and forwards live time notifications", () => {
131
+ const { root, getHandle, getRenderCount } = renderHandle();
132
+ const initialRenderCount = getRenderCount();
133
+ const timeListener = vi.fn();
134
+ const structuralListener = vi.fn();
135
+ const unsubscribeTime = getHandle().subscribeTime(timeListener);
136
+ const unsubscribe = getHandle().subscribe(structuralListener);
137
+
138
+ act(() => {
139
+ usePlayerStore.setState({ currentTime: 2.5 });
140
+ liveTime.notify(7.25);
141
+ });
142
+
143
+ expect(getRenderCount()).toBe(initialRenderCount);
144
+ expect(getHandle().currentTime).toBe(2.5);
145
+ expect(timeListener).toHaveBeenCalledWith(7.25);
146
+ expect(structuralListener).not.toHaveBeenCalled();
147
+
148
+ unsubscribeTime();
149
+ unsubscribe();
150
+ act(() => root.unmount());
151
+ });
152
+ });
@@ -0,0 +1,94 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import { liveTime, usePlayerStore, type TimelineElement } from "../store/playerStore";
3
+ import { useNLEContext } from "../../components/nle/NLEContext";
4
+
5
+ export interface PlayerHandleElement {
6
+ id: string;
7
+ start: number;
8
+ end: number;
9
+ selector?: string;
10
+ sourceFile?: string;
11
+ compositionSrc?: string;
12
+ }
13
+
14
+ export type PlayerHandleListener = () => void;
15
+ export type PlayerHandleTimeListener = (time: number) => void;
16
+
17
+ export interface PlayerHandle {
18
+ ready: boolean;
19
+ playing: boolean;
20
+ seek: (seconds: number) => boolean;
21
+ play: () => void;
22
+ pause: () => void;
23
+ togglePlay: () => void;
24
+ readonly currentTime: number;
25
+ duration: number;
26
+ elements: PlayerHandleElement[];
27
+ subscribe: (listener: PlayerHandleListener) => () => void;
28
+ subscribeTime: (listener: PlayerHandleTimeListener) => () => void;
29
+ }
30
+
31
+ function toHandleElement(element: TimelineElement): PlayerHandleElement {
32
+ return {
33
+ id: element.id,
34
+ start: element.start,
35
+ end: element.start + element.duration,
36
+ selector: element.selector,
37
+ sourceFile: element.sourceFile,
38
+ compositionSrc: element.compositionSrc,
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Reads and controls the shell player. Call this under EditorShell/NLEProvider;
44
+ * the provider owns the mounted preview iframe and the single player instance.
45
+ */
46
+ export function usePlayerHandle(): PlayerHandle {
47
+ const { play, pause, togglePlay, seek: seekPlayer } = useNLEContext();
48
+ const ready = usePlayerStore((state) => state.timelineReady);
49
+ const playing = usePlayerStore((state) => state.isPlaying);
50
+ const duration = usePlayerStore((state) => state.duration);
51
+ const elements = usePlayerStore((state) => state.elements);
52
+
53
+ const seek = useCallback(
54
+ (seconds: number) => {
55
+ if (!ready) return false;
56
+ return seekPlayer(seconds);
57
+ },
58
+ [ready, seekPlayer],
59
+ );
60
+
61
+ const subscribe = useCallback((listener: PlayerHandleListener) => {
62
+ let previous = usePlayerStore.getState();
63
+ return usePlayerStore.subscribe((state) => {
64
+ const structuralChange =
65
+ state.timelineReady !== previous.timelineReady ||
66
+ state.isPlaying !== previous.isPlaying ||
67
+ state.duration !== previous.duration ||
68
+ state.elements !== previous.elements;
69
+ previous = state;
70
+ if (structuralChange) listener();
71
+ });
72
+ }, []);
73
+
74
+ const subscribeTime = useCallback((listener: PlayerHandleTimeListener) => {
75
+ return liveTime.subscribe(listener);
76
+ }, []);
77
+ const handleElements = useMemo(() => elements.map(toHandleElement), [elements]);
78
+
79
+ return {
80
+ ready,
81
+ playing,
82
+ seek,
83
+ play,
84
+ pause,
85
+ togglePlay,
86
+ get currentTime() {
87
+ return usePlayerStore.getState().currentTime;
88
+ },
89
+ duration,
90
+ elements: handleElements,
91
+ subscribe,
92
+ subscribeTime,
93
+ };
94
+ }
@@ -7,6 +7,13 @@ export { CompositionThumbnail } from "./components/CompositionThumbnail";
7
7
 
8
8
  // Hooks
9
9
  export { useTimelinePlayer } from "./hooks/useTimelinePlayer";
10
+ export { usePlayerHandle } from "./hooks/usePlayerHandle";
11
+ export type {
12
+ PlayerHandle,
13
+ PlayerHandleElement,
14
+ PlayerHandleListener,
15
+ PlayerHandleTimeListener,
16
+ } from "./hooks/usePlayerHandle";
10
17
  export { resolveIframe } from "./lib/timelineDOM";
11
18
 
12
19
  // Store