mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user