feat(player): expose picture size, durationchange and a click-to-play opt-out (#4505)

* feat(player): expose picture size, durationchange and a click-to-play opt-out

- compositionWidth/compositionHeight are public, ready carries them, and a
  resize event fires when the picture size changes.
- durationchange fires when the duration changes after ready, on both the
  runtime and the direct-timeline paths.
- disable-click-to-play leaves clicks on the picture to the host.

Nothing changes for a player that does not use the new attribute; ready's
detail only gains two fields.

* docs(player): say composition size, one doc line per size getter

* fix(player): apply scenes before ready fires, keep the zero-size warning, document the new API

* fix(player): fire a message's events after the whole message is applied

* fix(player): queue every player event behind the update that raised it

* fix(player): decide autoplay after ready listeners run, and restart a loop at time 0

* fix(player): keep raising events after a listener throws

* refactor(player): drop a comment the ordering test already pins

* fix(player): fire the rest of an update's events when a listener throws
This commit is contained in:
Miguel Ángel
2026-09-26 00:58:43 +00:00
committed by GitHub
parent 0f528ff2e8
commit 4ef2fc3f1e
6 changed files with 513 additions and 86 deletions
+5 -1
View File
@@ -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 |
+32 -26
View File
@@ -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) => {
+337 -5
View File
@@ -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<string, unknown> = {}) {
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;
+129 -44
View File
@@ -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<string, unknown>();
private _runtimeDataRequestId = 0;
private _pendingRuntimeData = new Map<string, PendingRuntimeDataDelivery>();
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,
+1 -1
View File
@@ -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 };
@@ -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;
}