fix(player): treat runtime timeline as cross-origin ready (#1690)

This commit is contained in:
Miguel Ángel
2026-06-24 00:39:53 -04:00
committed by GitHub
parent 546b2d770b
commit ea23e6309f
5 changed files with 89 additions and 0 deletions
@@ -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", () => {
+18
View File
@@ -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;
@@ -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();
});
});
@@ -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;
@@ -466,6 +466,7 @@ describe("handleRuntimeMessage scenes seam", () => {
sendControl: () => {},
getIframeDoc: () => null,
onRuntimeReady: () => {},
onRuntimeTimelineReady: () => {},
setScenes,
updateControlsTime: () => {},
updateControlsPlaying: () => {},