diff --git a/docs/packages/player.mdx b/docs/packages/player.mdx index ca078db4ac..a94a587f87 100644 --- a/docs/packages/player.mdx +++ b/docs/packages/player.mdx @@ -62,6 +62,7 @@ Set `autoplay muted` only when playback should start without a user gesture. | `audio-locked` | boolean | false | Force muted playback and hide volume controls | | `assets-loading-ui` | `player` or `none` | `player` | `none` hides the loading-assets card; asset events still fire | | `low-power-idle` | boolean | false | While paused, check in once a second, not every 80 ms (many-player pages) | +| `disable-click-to-play` | boolean | false | A click on the player no longer plays or pauses, so the page can handle it | Player also accepts `shader-capture-scale` and `shader-loading` for previewing projects that use shader transitions. These are preview controls, not composition @@ -83,6 +84,7 @@ player.playbackRate = 1.5; player.muted = true; console.log(player.duration, player.paused, player.ready); +console.log(player.compositionWidth, player.compositionHeight); ``` ## Events @@ -101,7 +103,9 @@ player.addEventListener("timeupdate", (event) => { | Event | Detail | Description | | -------------- | ----------------- | ------------------------------------------------------------ | -| `ready` | `{ duration }` | Composition loaded and timeline discovered | +| `ready` | `{ duration, compositionWidth, compositionHeight }` | Composition loaded and timeline discovered | +| `durationchange` | `{ duration }` | The duration changed after `ready` | +| `resize` | `{ compositionWidth, compositionHeight }` | The composition's size changed | | `timeupdate` | `{ currentTime }` | Playback position changed, approximately 10 times per second | | `play` | — | Playback started | | `pause` | — | Playback paused | diff --git a/packages/player/README.md b/packages/player/README.md index fe06e4d1b5..96659e39fb 100644 --- a/packages/player/README.md +++ b/packages/player/README.md @@ -52,23 +52,24 @@ Show a static image before playback starts: ## Attributes -| Attribute | Type | Default | Description | -| ---------------------- | ------------------------------- | ------------- | --------------------------------------------------------------------------- | -| `src` | string | — | URL to the composition HTML file | -| `audio-src` | string | — | Audio URL for parent-frame playback (mobile) | -| `width` | number | 1920 | Composition width in pixels (aspect ratio) | -| `height` | number | 1080 | Composition height in pixels (aspect ratio) | -| `controls` | boolean | false | Show play/pause, scrubber, and time display | -| `muted` | boolean | false | Mute audio playback | -| `audio-locked` | boolean | false | Force-mute and hide the volume controls so the viewer cannot turn sound on | -| `poster` | string | — | Image URL shown before playback starts | -| `playback-rate` | number | 1 | Speed multiplier (0.5 = half, 2 = double) | -| `autoplay` | boolean | false | Start playing when ready | -| `loop` | boolean | false | Restart when the composition ends | -| `shader-capture-scale` | number | — | Shader transition snapshot scale forwarded to browser previews (`0.25`-`1`) | -| `shader-loading` | `composition \| player \| none` | `composition` | Controls shader transition prep loading UI ownership | -| `assets-loading-ui` | `player \| none` | `player` | `none` never shows the loading-assets card; asset events still fire | -| `low-power-idle` | boolean | false | While paused, check in once a second, not every 80 ms (many-player pages) | +| Attribute | Type | Default | Description | +| ----------------------- | ------------------------------- | ------------- | --------------------------------------------------------------------------- | +| `src` | string | — | URL to the composition HTML file | +| `audio-src` | string | — | Audio URL for parent-frame playback (mobile) | +| `width` | number | 1920 | Composition width in pixels (aspect ratio) | +| `height` | number | 1080 | Composition height in pixels (aspect ratio) | +| `controls` | boolean | false | Show play/pause, scrubber, and time display | +| `muted` | boolean | false | Mute audio playback | +| `audio-locked` | boolean | false | Force-mute and hide the volume controls so the viewer cannot turn sound on | +| `poster` | string | — | Image URL shown before playback starts | +| `playback-rate` | number | 1 | Speed multiplier (0.5 = half, 2 = double) | +| `autoplay` | boolean | false | Start playing when ready | +| `loop` | boolean | false | Restart when the composition ends | +| `shader-capture-scale` | number | — | Shader transition snapshot scale forwarded to browser previews (`0.25`-`1`) | +| `shader-loading` | `composition \| player \| none` | `composition` | Controls shader transition prep loading UI ownership | +| `assets-loading-ui` | `player \| none` | `player` | `none` never shows the loading-assets card; asset events still fire | +| `low-power-idle` | boolean | false | While paused, check in once a second, not every 80 ms (many-player pages) | +| `disable-click-to-play` | boolean | false | A click on the player no longer plays or pauses (host overlays own clicks) | ### Shader transition previews @@ -127,6 +128,9 @@ player.currentTime; // number (read/write) player.duration; // number (read-only) player.paused; // boolean (read-only) player.ready; // boolean (read-only) +player.compositionWidth; // number (read-only), the composition's width +player.compositionHeight; // number (read-only), the composition's height +player.disableClickToPlay; // boolean (read/write) player.playbackRate; // number (read/write) player.muted; // boolean (read/write) player.audioLocked; // boolean (read/write) — force-mute + hide volume controls @@ -223,15 +227,17 @@ function StudioPreview({ src }: { src: string }) { ## Events -| Event | Detail | Fired when | -| ----------------------- | -------------------------- | ------------------------------------------ | -| `ready` | `{ duration }` | Composition loaded and duration determined | -| `play` | — | Playback started | -| `pause` | — | Playback paused | -| `timeupdate` | `{ currentTime }` | Playback position changed (~10 fps) | -| `ended` | — | Reached the end (when not looping) | -| `error` | `{ message }` | Composition failed to load | -| `shadertransitionstate` | `{ compositionId, state }` | Shader transition cache/capture progress | +| Event | Detail | Fired when | +| ----------------------- | --------------------------------------------------- | ------------------------------------------ | +| `ready` | `{ duration, compositionWidth, compositionHeight }` | Composition loaded and duration determined | +| `durationchange` | `{ duration }` | The duration changed after `ready` | +| `resize` | `{ compositionWidth, compositionHeight }` | The composition's size changed | +| `play` | — | Playback started | +| `pause` | — | Playback paused | +| `timeupdate` | `{ currentTime }` | Playback position changed (~10 fps) | +| `ended` | — | Reached the end (when not looping) | +| `error` | `{ message }` | Composition failed to load | +| `shadertransitionstate` | `{ compositionId, state }` | Shader transition cache/capture progress | ```js player.addEventListener("ready", (e) => { diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 5c2d606913..a90a900da6 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -5,7 +5,7 @@ import { formatTime, formatSpeed, SPEED_PRESETS } from "./controls.js"; // new stopMedia / muted tests repeat this `Object.defineProperty(... { get })` // shape; routing through a named helper keeps the per-test bodies focused on // the actual assertion. -function stubIframeContentDocument(iframe: HTMLIFrameElement, doc: Document): void { +function stubIframeContentDocument(iframe: HTMLIFrameElement, doc: Document | null): void { Object.defineProperty(iframe, "contentDocument", { configurable: true, get: () => doc, @@ -1146,6 +1146,29 @@ describe("HyperframesPlayer seek() sync path", () => { expect(post).not.toHaveBeenCalled(); }); + it("fires durationchange when a ready composition replaces its timeline", () => { + const makeTimeline = (duration: number): TimelineStub => ({ + duration: vi.fn(() => duration), + time: vi.fn(() => 0), + seek: vi.fn(), + play: vi.fn(), + pause: vi.fn(), + }); + const timelines = { main: makeTimeline(5) }; + stubContentWindow({ __timelines: timelines, postMessage: vi.fn() }); + const durations: number[] = []; + player.addEventListener("durationchange", (event) => { + durations.push((event as CustomEvent<{ duration: number }>).detail.duration); + }); + + player.seek(1); + (player as unknown as { _ready: boolean })._ready = true; + timelines.main = makeTimeline(8); + player.seek(6); + + expect(durations).toEqual([8]); + }); + it("plays and pauses same-origin __timelines when no runtime bridge exists", () => { const timeline: TimelineStub = { duration: vi.fn(() => 5), @@ -1336,6 +1359,8 @@ describe("HyperframesPlayer loop end-state handling", () => { _duration: number; _currentTime: number; _paused: boolean; + _ready: boolean; + _assetsReady: boolean; _onMessage: (event: MessageEvent) => void; }; @@ -1381,6 +1406,7 @@ describe("HyperframesPlayer loop end-state handling", () => { expect(seek).toHaveBeenCalledWith(0); expect(play).toHaveBeenCalled(); expect(player._paused).toBe(false); + expect(player._currentTime).toBe(0); }); it("fires ended and stays paused when a non-looping composition posts its final paused state", () => { @@ -1460,6 +1486,26 @@ describe("HyperframesPlayer loop end-state handling", () => { expect(player._paused).toBe(false); }); + it("rewinds and keeps playing when an ended listener calls play", () => { + const seek = vi.spyOn(player, "seek"); + player._ready = true; + player._assetsReady = true; + player._duration = 4; + player._paused = false; + player.addEventListener("ended", () => player.play(), { once: true }); + + player._onMessage( + new MessageEvent("message", { + source: frameWindow, + data: { source: "hf-preview", type: "state", frame: 120, isPlaying: false }, + }), + ); + + expect(seek).toHaveBeenCalledWith(0); + expect(player._currentTime).toBe(0); + expect(player._paused).toBe(false); + }); + it("clamps _currentTime to _duration when a state message reports a frame past the end", () => { // Regression test: the postMessage state path previously set _currentTime // without clamping, while the direct timeline path already clamped. A frame @@ -2013,8 +2059,19 @@ describe("HyperframesPlayer runtime ready handshake", () => { ready: boolean; duration: number; paused: boolean; + compositionWidth: number; + compositionHeight: number; + scenes: Array<{ id: string; start: number; duration: number }>; iframe: HTMLIFrameElement; + play: () => void; + pause: () => void; + seek: (t: number) => void; _onMessage: (event: MessageEvent) => void; + _onProbeReady: (r: { + duration: number; + adapter: { kind: string; getDuration: () => number }; + compositionSize: { width: number; height: number } | null; + }) => void; _onIframeLoad: () => void; _runtimeBridgeReady: boolean; } @@ -2030,7 +2087,7 @@ describe("HyperframesPlayer runtime ready handshake", () => { }); } - function timelineMessage(durationInFrames = 120) { + function timelineMessage(durationInFrames = 120, extra: Record = {}) { return new MessageEvent("message", { source: frameWindow, data: { @@ -2038,10 +2095,18 @@ describe("HyperframesPlayer runtime ready handshake", () => { type: "timeline", durationInFrames, scenes: [], + ...extra, }, }); } + function stageSizeMessage(width: number, height: number) { + return new MessageEvent("message", { + source: frameWindow, + data: { source: "hf-preview", type: "stage-size", width, height }, + }); + } + function findControlCalls(action: string) { return postSpy.mock.calls.filter((call) => { const data = call[0] as { type?: string; action?: string }; @@ -2218,16 +2283,222 @@ describe("HyperframesPlayer runtime ready handshake", () => { }); it("treats a cross-origin runtime timeline message as player ready", () => { - const readyEvents: Array<{ duration: number }> = []; + const readyEvents: unknown[] = []; player.addEventListener("ready", (event) => { - readyEvents.push((event as CustomEvent<{ duration: number }>).detail); + readyEvents.push((event as CustomEvent).detail); }); player._onMessage(timelineMessage(120)); expect(player.ready).toBe(true); expect(player.duration).toBe(4); - expect(readyEvents).toEqual([{ duration: 4 }]); + expect(readyEvents).toEqual([{ duration: 4, compositionWidth: 1920, compositionHeight: 1080 }]); + }); + + it("reports the picture size in ready and as public properties", () => { + const readyEvents: unknown[] = []; + player.addEventListener("ready", (event) => { + readyEvents.push((event as CustomEvent).detail); + }); + + player._onMessage(timelineMessage(120, { compositionWidth: 1080, compositionHeight: 1920 })); + + expect(readyEvents).toEqual([{ duration: 4, compositionWidth: 1080, compositionHeight: 1920 }]); + expect(player.compositionWidth).toBe(1080); + expect(player.compositionHeight).toBe(1920); + }); + + it("reports the picture size in ready on the same-origin probe path", () => { + const readyEvents: unknown[] = []; + player.addEventListener("ready", (event) => { + readyEvents.push((event as CustomEvent).detail); + }); + + player._onProbeReady({ + duration: 5, + adapter: { kind: "runtime", getDuration: () => 5 }, + compositionSize: { width: 1080, height: 1350 }, + }); + + expect(readyEvents).toEqual([{ duration: 5, compositionWidth: 1080, compositionHeight: 1350 }]); + }); + + it("fires a timeline message's events only after the whole message is applied", () => { + const seen: string[] = []; + const state = () => + `ready=${player.ready} d=${player.duration} ` + + `${player.compositionWidth}x${player.compositionHeight} scenes=${player.scenes.length}`; + for (const type of ["resize", "scenes", "durationchange", "ready"]) { + player.addEventListener(type, () => seen.push(`${type}: ${state()}`)); + } + + player._onMessage( + timelineMessage(120, { + compositionWidth: 1080, + compositionHeight: 1920, + scenes: [{ id: "a", start: 0, duration: 4 }], + }), + ); + seen.push("--"); + player._onMessage( + timelineMessage(180, { + compositionWidth: 1280, + compositionHeight: 720, + scenes: [ + { id: "a", start: 0, duration: 4 }, + { id: "b", start: 4, duration: 2 }, + ], + }), + ); + + expect(seen).toEqual([ + "resize: ready=true d=4 1080x1920 scenes=1", + "ready: ready=true d=4 1080x1920 scenes=1", + "scenes: ready=true d=4 1080x1920 scenes=1", + "--", + "resize: ready=true d=6 1280x720 scenes=2", + "durationchange: ready=true d=6 1280x720 scenes=2", + "scenes: ready=true d=6 1280x720 scenes=2", + ]); + }); + + it("keeps ready ahead of the events it causes on an opaque-origin timeline", () => { + stubIframeContentDocument(player.iframe, null); + player.setAttribute("autoplay", ""); + const seen: string[] = []; + for (const type of ["ready", "assetsready", "play"]) { + player.addEventListener(type, () => seen.push(type)); + } + + player._onMessage(timelineMessage(120)); + + expect(seen).toEqual(["ready", "assetsready", "play"]); + }); + + it("fires an event a ready listener raises after the rest of the update", () => { + stubIframeContentDocument(player.iframe, null); + player.setAttribute("autoplay", ""); + const seen: string[] = []; + for (const type of ["ready", "assetsready", "play", "pause"]) { + player.addEventListener(type, () => seen.push(type)); + } + player.addEventListener("ready", () => player.pause(), { once: true }); + + player._onMessage(timelineMessage(120)); + + // Autoplay is decided after `ready`'s listeners. + expect(seen).toEqual(["ready", "assetsready", "pause", "play"]); + expect(player.paused).toBe(false); + }); + + it("keeps autoplay when a ready listener seeks", () => { + stubIframeContentDocument(player.iframe, null); + player.setAttribute("autoplay", ""); + player.addEventListener("ready", () => player.seek(1), { once: true }); + + player._onMessage(timelineMessage(120)); + + expect(player.paused).toBe(false); + expect(findControlCalls("play")).toHaveLength(1); + }); + + it("applies a message a listener delivers inside the update after the first one's events", () => { + const seen: string[] = []; + for (const type of ["ready", "durationchange", "scenes"]) { + player.addEventListener(type, () => seen.push(`${type} d=${player.duration}`)); + } + player.addEventListener("ready", () => player._onMessage(timelineMessage(180)), { once: true }); + + player._onMessage(timelineMessage(120)); + + expect(seen).toEqual(["ready d=4", "scenes d=6", "durationchange d=6", "scenes d=6"]); + }); + + it("runs the rest of an update's events when a listener throws", () => { + stubIframeContentDocument(player.iframe, null); + const seen: string[] = []; + player.addEventListener("ready", () => { + throw new Error("listener failed"); + }); + player.addEventListener("assetsready", () => seen.push("assetsready")); + + expect(() => player._onMessage(timelineMessage(120))).toThrow("listener failed"); + expect(seen).toEqual(["assetsready"]); + }); + + it("keeps raising events after an action throws inside an update", () => { + stubIframeContentDocument(player.iframe, null); + player.setAttribute("autoplay", ""); + vi.spyOn(player, "play").mockImplementationOnce(() => { + throw new Error("play failed"); + }); + expect(() => player._onMessage(timelineMessage(120))).toThrow("play failed"); + const durations: number[] = []; + player.addEventListener("durationchange", (event) => { + durations.push((event as CustomEvent<{ duration: number }>).detail.duration); + }); + + player._onMessage(timelineMessage(180)); + + expect(durations).toEqual([6]); + }); + + it("fires the probe path's resize only once ready is set", () => { + const seen: string[] = []; + player.addEventListener("resize", () => seen.push(`resize ready=${player.ready}`)); + + player._onProbeReady({ + duration: 5, + adapter: { kind: "runtime", getDuration: () => 5 }, + compositionSize: { width: 1080, height: 1350 }, + }); + + expect(seen).toEqual(["resize ready=true"]); + }); + + it("warns when the same-origin probe readies a zero-size player", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + player._onProbeReady({ + duration: 5, + adapter: { kind: "runtime", getDuration: () => 5 }, + compositionSize: { width: 1080, height: 1920 }, + }); + + const rescaleWarnings = warnSpy.mock.calls.filter((call) => + String(call[0]).includes("rescale no-op after ready"), + ); + expect(rescaleWarnings).toHaveLength(1); + }); + + it("fires resize only when the picture size changes", () => { + const sizes: unknown[] = []; + player.addEventListener("resize", (event) => { + sizes.push((event as unknown as CustomEvent).detail); + }); + + player._onMessage(timelineMessage(120, { compositionWidth: 1080, compositionHeight: 1920 })); + player._onMessage(stageSizeMessage(1080, 1920)); + player._onMessage(stageSizeMessage(1280, 720)); + + expect(sizes).toEqual([ + { compositionWidth: 1080, compositionHeight: 1920 }, + { compositionWidth: 1280, compositionHeight: 720 }, + ]); + }); + + it("fires durationchange when the duration changes after ready, not at ready", () => { + const durations: number[] = []; + player.addEventListener("durationchange", (event) => { + durations.push((event as CustomEvent<{ duration: number }>).detail.duration); + }); + + player._onMessage(timelineMessage(120)); + player._onMessage(timelineMessage(120)); + player._onMessage(timelineMessage(180)); + + expect(durations).toEqual([6]); + expect(player.duration).toBe(6); }); it("honors autoplay after cross-origin runtime timeline readiness", async () => { @@ -2562,6 +2833,47 @@ describe("HyperframesPlayer composition dimension attributes", () => { }); }); +describe("HyperframesPlayer click-to-play", () => { + type ClickPlayer = HTMLElement & { + play: () => void; + pause: () => void; + disableClickToPlay: boolean; + }; + + let player: ClickPlayer; + + beforeEach(async () => { + await import("./hyperframes-player.js"); + player = document.createElement("hyperframes-player") as ClickPlayer; + document.body.appendChild(player); + }); + + afterEach(() => { + player.remove(); + vi.restoreAllMocks(); + }); + + it("plays on a click by default", () => { + const play = vi.spyOn(player, "play").mockImplementation(() => undefined); + + player.click(); + + expect(play).toHaveBeenCalledTimes(1); + }); + + it("leaves clicks to the host with disable-click-to-play", () => { + const play = vi.spyOn(player, "play").mockImplementation(() => undefined); + const pause = vi.spyOn(player, "pause").mockImplementation(() => undefined); + + player.disableClickToPlay = true; + player.click(); + + expect(player.hasAttribute("disable-click-to-play")).toBe(true); + expect(play).not.toHaveBeenCalled(); + expect(pause).not.toHaveBeenCalled(); + }); +}); + describe("HyperframesPlayer retained runtime data", () => { interface RuntimeDataPlayer extends HTMLElement { iframeElement: HTMLIFrameElement; @@ -2893,6 +3205,26 @@ describe("HyperframesPlayer asset-ready gate", () => { player.remove(); }); + it("drops a queued play when an assetsready listener pauses", async () => { + const player = await createConnectedPlayer(); + player._ready = false; + Object.defineProperty(player.iframe, "contentDocument", { get: () => null }); + post(player, { type: "timeline", durationInFrames: 60, assetsReady: false }); + player.play(); + const seen: string[] = []; + for (const type of ["assetsready", "play", "pause"]) { + player.addEventListener(type, () => seen.push(type)); + } + player.addEventListener("assetsready", () => player.pause(), { once: true }); + + post(player, { type: "assets-ready", timedOut: false }); + + expect(seen).toEqual(["assetsready", "pause"]); + expect(player._paused).toBe(true); + + player.remove(); + }); + it("stays ready when a late iframe load follows a settled opaque-origin wait", async () => { const player = await createConnectedPlayer(); player._ready = false; diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 548fcfa03a..0136881e16 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -45,6 +45,7 @@ const ASSETS_LOADING_ATTR = "assets-loading"; // "player" (default) draws the loading-assets card; "none" never does, like shader-loading="none". const ASSETS_LOADING_UI_ATTR = "assets-loading-ui"; const LOW_POWER_IDLE_ATTR = "low-power-idle"; +const DISABLE_CLICK_TO_PLAY_ATTR = "disable-click-to-play"; // paint-and-idle now always has a frame to wait on, so the overlay would // flash on every single Play without this debounce. ponytail: 150ms is // unmeasured, retune once there's production data on paint-and-idle timing. @@ -141,6 +142,7 @@ class HyperframesPlayer extends HTMLElement { private _runtimeData = new Map(); private _runtimeDataRequestId = 0; private _pendingRuntimeData = new Map(); + private _afterUpdate: Array<() => void> | null = null; constructor() { super(); @@ -155,7 +157,7 @@ class HyperframesPlayer extends HTMLElement { this.shaderLoader = new ShaderLoaderState(loaderElements); this._media = new ParentMediaManager({ - dispatchEvent: (e) => this.dispatchEvent(e), + dispatchEvent: (e) => this._emit(e), getMuted: () => this.muted, getVolume: () => this._volume, getPlaybackRate: () => this.playbackRate, @@ -167,7 +169,7 @@ class HyperframesPlayer extends HTMLElement { onTimeUpdate: (currentTime, duration) => { this._currentTime = currentTime; this.controlsApi?.updateTime(currentTime, duration); - this.dispatchEvent(new CustomEvent("timeupdate", { detail: { currentTime } })); + this._emit(new CustomEvent("timeupdate", { detail: { currentTime } })); }, getLoop: () => this.loop, restart: () => { @@ -178,18 +180,18 @@ class HyperframesPlayer extends HTMLElement { if (this._media.audioOwner === "parent") this._media.pauseAll(); this._paused = true; this.controlsApi?.updatePlaying(false); - this.dispatchEvent(new Event("ended")); + this._emit(new Event("ended")); }, onEnded: () => this.loop, }); this.probe = new CompositionProbe(this.iframe, { onReady: (result) => this._onProbeReady(result), - onError: (message) => this.dispatchEvent(new CustomEvent("error", { detail: { message } })), + onError: (message) => this._emit(new CustomEvent("error", { detail: { message } })), }); this.addEventListener("click", (event) => { - if (isControlsClick(event)) return; + if (this.disableClickToPlay || isControlsClick(event)) return; if (this._paused) this.play(); else this.pause(); }); @@ -271,12 +273,10 @@ class HyperframesPlayer extends HTMLElement { // reach scaleIframeToFit as scale(NaN) or a division by zero and // blank the player); fall back to the defaults instead. case "width": - this._compositionWidth = readPositiveDimension(val) ?? 1920; - this._rescale(); + this._setCompositionSize(readPositiveDimension(val) ?? 1920, this._compositionHeight); break; case "height": - this._compositionHeight = readPositiveDimension(val) ?? 1080; - this._rescale(); + this._setCompositionSize(this._compositionWidth, readPositiveDimension(val) ?? 1080); break; case "controls": if (val !== null) this._setupControls(); @@ -294,7 +294,7 @@ class HyperframesPlayer extends HTMLElement { this._sendControl("set-playback-rate", { playbackRate: rate }); this._directTimelineAdapter?.timeScale?.(rate); this.controlsApi?.updateSpeed(rate); - this.dispatchEvent(new Event("ratechange")); + this._emit(new Event("ratechange")); break; } case "muted": @@ -309,7 +309,7 @@ class HyperframesPlayer extends HTMLElement { this._media.updateVolume(v); this._sendControl("set-volume", { volume: v }); this.controlsApi?.updateVolume(v); - this.dispatchEvent(new Event("volumechange")); + this._emit(new Event("volumechange")); break; } case "audio-src": @@ -398,7 +398,7 @@ class HyperframesPlayer extends HTMLElement { } if (this._media.audioOwner === "parent") this._media.playAll(); this.controlsApi?.updatePlaying(true); - if (!queuedForReady) this.dispatchEvent(new Event("play")); + if (!queuedForReady) this._emit(new Event("play")); if (directTimelineStarted && this._directTimelineAdapter) { this._directTimelineClock.start( this._directTimelineAdapter, @@ -420,7 +420,7 @@ class HyperframesPlayer extends HTMLElement { if (this._media.audioOwner === "parent") this._media.pauseAll(); this._paused = true; this.controlsApi?.updatePlaying(false); - this.dispatchEvent(new Event("pause")); + this._emit(new Event("pause")); } stopMedia() { @@ -519,6 +519,15 @@ class HyperframesPlayer extends HTMLElement { get duration() { return this._duration; } + + /** The composition's width, from the runtime or the `width` attribute. */ + get compositionWidth() { + return this._compositionWidth; + } + /** The composition's height, from the runtime or the `height` attribute. */ + get compositionHeight() { + return this._compositionHeight; + } get paused() { return this._paused; } @@ -622,7 +631,7 @@ class HyperframesPlayer extends HTMLElement { this._setIframeMediaMuted(val !== null); this._sendControl("set-muted", { muted: val !== null }); this.controlsApi?.updateMuted(val !== null); - this.dispatchEvent(new Event("volumechange")); + this._emit(new Event("volumechange")); } /** @@ -643,6 +652,13 @@ class HyperframesPlayer extends HTMLElement { this.setAttribute("volume", String(Math.max(0, Math.min(1, v)))); } + get disableClickToPlay() { + return this.hasAttribute(DISABLE_CLICK_TO_PLAY_ATTR); + } + set disableClickToPlay(disabled: boolean) { + this.toggleAttribute(DISABLE_CLICK_TO_PLAY_ATTR, disabled); + } + get loop() { return this.hasAttribute("loop"); } @@ -752,7 +768,7 @@ class HyperframesPlayer extends HTMLElement { private _resolveRuntimeDataDelivery(channel: unknown, requestId: unknown): void { const pending = this._takeRuntimeDataDelivery(channel, requestId); if (!pending) return; - this.dispatchEvent( + this._emit( new CustomEvent("runtimedataapplied", { detail: { channel, requestId: pending.requestId }, }), @@ -762,7 +778,7 @@ class HyperframesPlayer extends HTMLElement { private _rejectRuntimeDataDelivery(channel: unknown, requestId: unknown, message: unknown): void { const pending = this._takeRuntimeDataDelivery(channel, requestId); if (!pending) return; - this.dispatchEvent( + this._emit( new CustomEvent("runtimedataerror", { detail: { channel, @@ -886,7 +902,7 @@ class HyperframesPlayer extends HTMLElement { if (resolved && resolved !== this._directTimelineAdapter) { const duration = resolved.duration(); if (Number.isFinite(duration) && duration > 0) { - this._duration = duration; + this._setDuration(duration); this.controlsApi?.updateTime(this._currentTime, duration); } } @@ -942,6 +958,10 @@ class HyperframesPlayer extends HTMLElement { } private _onMessage(e: MessageEvent) { + this._applyThenEmit(() => this._handleRuntimeMessage(e)); + } + + private _handleRuntimeMessage(e: MessageEvent) { handleRuntimeMessage(e, this.iframe.contentWindow, { getPlaybackState: () => ({ currentTime: this._currentTime, @@ -951,17 +971,13 @@ class HyperframesPlayer extends HTMLElement { }), setPlaybackState: ({ currentTime, duration, paused, lastUpdateMs }) => { this._currentTime = currentTime; - this._duration = duration; + this._setDuration(duration); this._paused = paused; this._lastUpdateMs = lastUpdateMs; }, getShaderLoadingMode: () => getShaderModeFromElement(this), shaderLoader: this.shaderLoader, - setCompositionSize: (w, h) => { - this._compositionWidth = w; - this._compositionHeight = h; - this._rescale(); - }, + setCompositionSize: (w, h) => this._setCompositionSize(w, h), sendControl: (action, extra) => this._sendControl(action, extra), getIframeDoc: () => this.iframe.contentDocument, onRuntimeReady: () => { @@ -986,11 +1002,11 @@ class HyperframesPlayer extends HTMLElement { shouldPromoteMediaAutoplayFallback: () => !this._isSlideshowPlayer(), setScenes: (scenes) => { this._scenes = scenes; - this.dispatchEvent(new CustomEvent("scenes", { detail: { scenes } })); + this._emit(new CustomEvent("scenes", { detail: { scenes } })); }, updateControlsTime: (t, d) => this.controlsApi?.updateTime(t, d), updateControlsPlaying: (p) => this.controlsApi?.updatePlaying(p), - dispatchEvent: (ev) => this.dispatchEvent(ev), + dispatchEvent: (ev) => this._emit(ev), seek: (t) => this.seek(t), play: () => this.play(), getLoop: () => this.loop, @@ -1001,15 +1017,11 @@ class HyperframesPlayer extends HTMLElement { private _onRuntimeTimelineReady(duration: number, assetsReady: boolean | undefined) { if (this._ready) return; this.probe.stop(); - this._duration = duration; + this._setDuration(duration); this._directTimelineAdapter = null; this._ready = true; this.controlsApi?.updateTime(this._currentTime, duration); - this.dispatchEvent(new CustomEvent("ready", { detail: { duration } })); - // stage-size may not have arrived yet (race in the runtime's postTimeline - // resolving the root's data-width/data-height on first paint) — rescale - // here too so cross-origin compositions never stay unscaled/untransformed. - this._rescale(); + this._dispatchReady(); const doc = this._getSameOriginIframeDocument(); if (doc) this._media.setupFromIframe(doc); @@ -1017,25 +1029,25 @@ class HyperframesPlayer extends HTMLElement { this._replayBridgeState(); this._setIframeMediaMuted(this.muted); this._waitForAssetsReady(doc, assetsReady); - if (this.hasAttribute("autoplay") || this._pendingPlay) this.play(); + this._playWhenWanted(); } - private _onProbeReady({ duration, adapter, compositionSize }: ProbeResult) { - this._duration = duration; + private _onProbeReady(result: ProbeResult) { + this._applyThenEmit(() => this._applyProbeResult(result)); + } + + private _applyProbeResult({ duration, adapter, compositionSize }: ProbeResult) { + this._setDuration(duration); this._directTimelineAdapter = adapter.kind === "direct-timeline" ? adapter.timeline : null; + if (compositionSize) this._setCompositionSize(compositionSize.width, compositionSize.height); this._ready = true; this.controlsApi?.updateTime(0, duration); - this.dispatchEvent(new CustomEvent("ready", { detail: { duration } })); - if (compositionSize) { - this._compositionWidth = compositionSize.width; - this._compositionHeight = compositionSize.height; - this._rescale(); - } + this._dispatchReady(); const doc = this._getSameOriginIframeDocument(); if (doc) this._media.setupFromIframe(doc); this._setIframeMediaMuted(this.muted); this._waitForAssetsReady(doc); - if (this.hasAttribute("autoplay") || this._pendingPlay) this.play(); + this._playWhenWanted(); } /** Gates play() on composition readiness (media, compute, paint-and-idle), @@ -1114,13 +1126,15 @@ class HyperframesPlayer extends HTMLElement { this._assetsReady = true; this.removeAttribute(ASSETS_LOADING_ATTR); this.shaderLoader.hideAssetsLoading(); - this.dispatchEvent(new Event("assetsready")); + this._emit(new Event("assetsready")); this.shaderLoader.whenHidden(() => { if (generation !== this._assetsGeneration) return; this._painted = true; - this.dispatchEvent(new Event("painted")); + this._emit(new Event("painted")); + }); + this._afterEvents(() => { + if (this._pendingPlay) this.play(); }); - if (this._pendingPlay) this.play(); } /** Every host-driven navigation or teardown: the old document's handshake, asset wait and @@ -1149,6 +1163,77 @@ class HyperframesPlayer extends HTMLElement { this._assetsLoadingShowTimer = null; } + private _dispatchReady(): void { + const detail = { + duration: this._duration, + compositionWidth: this._compositionWidth, + compositionHeight: this._compositionHeight, + }; + this._emit(new CustomEvent("ready", { detail })); + // Once ready: covers a size message that never came, and lets a zero-size player warn. + this._rescale(); + } + + /** `ready` carries the first duration; later changes fire `durationchange`. */ + private _setDuration(duration: number): void { + if (duration === this._duration) return; + this._duration = duration; + if (this._ready) this._emit(new CustomEvent("durationchange", { detail: { duration } })); + } + + private _setCompositionSize(width: number, height: number): void { + const changed = width !== this._compositionWidth || height !== this._compositionHeight; + this._compositionWidth = width; + this._compositionHeight = height; + this._rescale(); + if (!changed) return; + const detail = { compositionWidth: width, compositionHeight: height }; + this._emit(new CustomEvent("resize", { detail })); + } + + /** Runs one update (a runtime message, a probe result), then the events and actions it raised, + * so every listener sees the whole update applied. */ + private _applyThenEmit(apply: () => void): void { + // A nested update joins the outer one's queue. + if (this._afterUpdate) { + apply(); + return; + } + const queue: Array<() => void> = []; + this._afterUpdate = queue; + const errors: unknown[] = []; + const run = (step: () => void) => { + try { + step(); + } catch (error) { + errors.push(error); + } + }; + run(apply); + // Still open while flushing: whatever a listener raises goes behind the rest. + for (const action of queue) run(action); + this._afterUpdate = null; + if (errors.length > 0) throw errors[0]; + } + + /** Runs `action` once the current update's events have fired (at once outside an update). */ + private _afterEvents(action: () => void): void { + if (this._afterUpdate) this._afterUpdate.push(action); + else action(); + } + + /** Autoplay, or a play() made before ready, decided after `ready`'s listeners had their turn. */ + private _playWhenWanted(): void { + this._afterEvents(() => { + if (this.hasAttribute("autoplay") || this._pendingPlay) this.play(); + }); + } + + /** Every event the player raises goes through here, so an update's events keep their order. */ + private _emit(event: Event): void { + this._afterEvents(() => this.dispatchEvent(event)); + } + private _rescale() { const applied = scaleIframeToFit( this, diff --git a/packages/player/src/playback-state.ts b/packages/player/src/playback-state.ts index 02a3f26aaf..018a0cb927 100644 --- a/packages/player/src/playback-state.ts +++ b/packages/player/src/playback-state.ts @@ -51,7 +51,7 @@ export function applyRuntimeStateMessage( callbacks.play(); // play() sets paused=false; reflect that in the returned state so the // caller's destructure doesn't overwrite it with the stale nextPaused value. - return { ...current, currentTime, paused: false }; + return { ...current, currentTime: 0, paused: false }; } const next: PlaybackState = { ...current, currentTime, paused: nextPaused }; diff --git a/packages/player/src/runtime-message-handler.ts b/packages/player/src/runtime-message-handler.ts index d3507ba54e..118f45b363 100644 --- a/packages/player/src/runtime-message-handler.ts +++ b/packages/player/src/runtime-message-handler.ts @@ -145,15 +145,6 @@ export function handleRuntimeMessage( Number.isFinite(declaredDuration) && declaredDuration > 0 ? declaredDuration : frameDuration / protocol.fps; - if (Number.isFinite(duration) && duration > 0) { - const pb = callbacks.getPlaybackState(); - callbacks.setPlaybackState({ ...pb, duration }); - callbacks.updateControlsTime(pb.currentTime, duration); - callbacks.onRuntimeTimelineReady( - duration, - typeof data["assetsReady"] === "boolean" ? data["assetsReady"] : undefined, - ); - } if ( Number.isFinite(data["compositionWidth"]) && (data["compositionWidth"] as number) > 0 && @@ -165,6 +156,15 @@ export function handleRuntimeMessage( data["compositionHeight"] as number, ); } + if (Number.isFinite(duration) && duration > 0) { + const pb = callbacks.getPlaybackState(); + callbacks.setPlaybackState({ ...pb, duration }); + callbacks.updateControlsTime(pb.currentTime, duration); + callbacks.onRuntimeTimelineReady( + duration, + typeof data["assetsReady"] === "boolean" ? data["assetsReady"] : undefined, + ); + } callbacks.setScenes(extractScenes(data["scenes"])); return; }