From 57e179aa0bcbf8ad52f2d632884cee69b2c6a635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 24 Jun 2026 04:34:20 +0000 Subject: [PATCH] fix(player): treat runtime timeline as cross-origin ready --- .../player/src/hyperframes-player.test.ts | 38 +++++++++++++++++++ packages/player/src/hyperframes-player.ts | 18 +++++++++ .../src/runtime-message-handler.test.ts | 27 +++++++++++++ .../player/src/runtime-message-handler.ts | 5 +++ .../slideshow/hyperframes-slideshow.test.ts | 1 + 5 files changed, 89 insertions(+) diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 5bc1c6ab15..94929851bc 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -1807,6 +1807,9 @@ describe("HyperframesPlayer runtime ready handshake", () => { volume: number; audioLocked: boolean; playbackRate: number; + ready: boolean; + duration: number; + paused: boolean; iframe: HTMLIFrameElement; _onMessage: (event: MessageEvent) => void; } @@ -1822,6 +1825,18 @@ describe("HyperframesPlayer runtime ready handshake", () => { }); } + function timelineMessage(durationInFrames = 120) { + return new MessageEvent("message", { + source: frameWindow, + data: { + source: "hf-preview", + type: "timeline", + durationInFrames, + scenes: [], + }, + }); + } + function findControlCalls(action: string) { return postSpy.mock.calls.filter((call) => { const data = call[0] as { type?: string; action?: string }; @@ -1951,6 +1966,29 @@ describe("HyperframesPlayer runtime ready handshake", () => { expect(findControlCalls("set-muted")).toHaveLength(0); }); + + it("treats a cross-origin runtime timeline message as player ready", () => { + const readyEvents: Array<{ duration: number }> = []; + player.addEventListener("ready", (event) => { + readyEvents.push((event as CustomEvent<{ duration: number }>).detail); + }); + + player._onMessage(timelineMessage(120)); + + expect(player.ready).toBe(true); + expect(player.duration).toBe(4); + expect(readyEvents).toEqual([{ duration: 4 }]); + }); + + it("honors autoplay after cross-origin runtime timeline readiness", () => { + player.setAttribute("autoplay", ""); + postSpy.mockClear(); + + player._onMessage(timelineMessage(120)); + + expect(player.paused).toBe(false); + expect(findControlCalls("play")).toHaveLength(1); + }); }); describe("HyperframesPlayer audio lock — Claude desktop UA fallback", () => { diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 48cebce9b5..20f6fd6f56 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -643,6 +643,7 @@ class HyperframesPlayer extends HTMLElement { sendControl: (action, extra) => this._sendControl(action, extra), getIframeDoc: () => this.iframe.contentDocument, onRuntimeReady: () => this._replayBridgeState(), + onRuntimeTimelineReady: (duration) => this._onRuntimeTimelineReady(duration), shouldPromoteMediaAutoplayFallback: () => !this._isSlideshowPlayer(), setScenes: (scenes) => { this._scenes = scenes; @@ -658,6 +659,23 @@ class HyperframesPlayer extends HTMLElement { }); } + private _onRuntimeTimelineReady(duration: number) { + if (this._ready) return; + this.probe.stop(); + this._duration = duration; + this._directTimelineAdapter = null; + this._ready = true; + this.controlsApi?.updateTime(this._currentTime, duration); + this.dispatchEvent(new CustomEvent("ready", { detail: { duration } })); + + const doc = this._getSameOriginIframeDocument(); + if (doc) this._media.setupFromIframe(doc); + + this._replayBridgeState(); + this._setIframeMediaMuted(this.muted); + if (this.hasAttribute("autoplay")) this.play(); + } + private _onProbeReady({ duration, adapter, compositionSize }: ProbeResult) { this._duration = duration; this._directTimelineAdapter = adapter.kind === "direct-timeline" ? adapter.timeline : null; diff --git a/packages/player/src/runtime-message-handler.test.ts b/packages/player/src/runtime-message-handler.test.ts index 18f87cefef..6a1beba13a 100644 --- a/packages/player/src/runtime-message-handler.test.ts +++ b/packages/player/src/runtime-message-handler.test.ts @@ -12,6 +12,7 @@ const makeCallbacks = (): MessageHandlerCallbacks => ({ updateControlsPlaying: vi.fn(), dispatchEvent: vi.fn(), onRuntimeReady: vi.fn(), + onRuntimeTimelineReady: vi.fn(), seek: vi.fn(), play: vi.fn(), getLoop: vi.fn(() => false), @@ -101,3 +102,29 @@ describe("handleRuntimeMessage media autoplay fallback", () => { expect(callbacks.sendControl).not.toHaveBeenCalled(); }); }); + +describe("handleRuntimeMessage timeline ready", () => { + const timelineEvent = (durationInFrames: unknown, source: object): MessageEvent => + ({ + source, + data: { source: "hf-preview", type: "timeline", durationInFrames, scenes: [] }, + }) as unknown as MessageEvent; + + it("reports a finite positive timeline duration in seconds", () => { + const frameWindow = {} as Window; + const callbacks = makeCallbacks(); + + handleRuntimeMessage(timelineEvent(120, frameWindow), frameWindow, callbacks); + + expect(callbacks.onRuntimeTimelineReady).toHaveBeenCalledWith(4); + }); + + it("does not report invalid timeline durations as ready", () => { + const frameWindow = {} as Window; + const callbacks = makeCallbacks(); + + handleRuntimeMessage(timelineEvent(Infinity, frameWindow), frameWindow, callbacks); + + expect(callbacks.onRuntimeTimelineReady).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/player/src/runtime-message-handler.ts b/packages/player/src/runtime-message-handler.ts index ee07fa9d5d..a28a30d58e 100644 --- a/packages/player/src/runtime-message-handler.ts +++ b/packages/player/src/runtime-message-handler.ts @@ -41,6 +41,10 @@ export interface MessageHandlerCallbacks extends PlaybackStateCallbacks { * uses it to replay current bridge state (mute, volume, playback rate) so * control messages sent before the iframe's listener registered aren't lost. */ onRuntimeReady: () => void; + /** Invoked when the runtime posts a finite positive timeline duration. The + * player uses this as the cross-origin readiness signal because the + * same-origin composition probe cannot inspect CDN iframes. */ + onRuntimeTimelineReady: (duration: number) => void; /** Called with the scene list whenever a "timeline" message is received. */ setScenes: (scenes: SceneRecord[]) => void; /** Return false to ignore the iframe runtime's audible-media autoplay fallback. @@ -111,6 +115,7 @@ export function handleRuntimeMessage( const duration = (data["durationInFrames"] as number) / FPS; callbacks.setPlaybackState({ ...pb, duration }); callbacks.updateControlsTime(pb.currentTime, duration); + callbacks.onRuntimeTimelineReady(duration); } callbacks.setScenes(extractScenes(data["scenes"])); return; diff --git a/packages/player/src/slideshow/hyperframes-slideshow.test.ts b/packages/player/src/slideshow/hyperframes-slideshow.test.ts index b91f08ae4b..f86b862e28 100644 --- a/packages/player/src/slideshow/hyperframes-slideshow.test.ts +++ b/packages/player/src/slideshow/hyperframes-slideshow.test.ts @@ -466,6 +466,7 @@ describe("handleRuntimeMessage scenes seam", () => { sendControl: () => {}, getIframeDoc: () => null, onRuntimeReady: () => {}, + onRuntimeTimelineReady: () => {}, setScenes, updateControlsTime: () => {}, updateControlsPlaying: () => {},