fix(player): a play() made while the composition loads still plays (#4429)

* fix(player): a play() made while the composition loads still plays

The iframe's own load event reset the asset wait, and that reset also cleared the queued play,
so a play() between the first ready and load was dropped with no error. Play intent now leaves
the asset-wait reset: only pause, seek, disconnect and a new src or srcdoc clear it.

Also adds assets-loading-ui (player | none): none never shows the loading-assets card, while
the assets-loading attribute and the assetsready and painted events still fire.

* fix(player): switching the loading card off keeps a shader load on screen

* fix(player): the loading card and a shader load no longer hide each other
This commit is contained in:
Miguel Ángel
2026-09-24 12:48:55 -04:00
committed by GitHub
parent 8399a1d904
commit b9e5a16fcf
6 changed files with 217 additions and 29 deletions
+1
View File
@@ -60,6 +60,7 @@ Set `autoplay muted` only when playback should start without a user gesture.
| `playback-rate` | number | 1 | Playback speed multiplier |
| `audio-src` | string | — | Optional primary audio URL to preload in the parent frame |
| `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 |
Player also accepts `shader-capture-scale` and `shader-loading` for previewing
projects that use shader transitions. These are preview controls, not composition
+5
View File
@@ -67,6 +67,7 @@ Show a static image before playback starts:
| `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 |
### Shader transition previews
@@ -83,6 +84,10 @@ When a composition uses `@hyperframes/shader-transitions`, the player can own pr
`shader-loading="player"` shows the player-owned transition-prep overlay from shader progress messages. `composition` leaves direct composition fallback behavior alone, and `none` suppresses the loader.
### Loading-assets card
While images, video or fonts are still loading after `ready`, the player shows a loading card over the frame and sets the `assets-loading` attribute on itself. A host that draws its own loading state can turn the card off with `assets-loading-ui="none"` (or `player.assetsLoadingUi = "none"`). The `assets-loading` attribute and the `assetsready` and `painted` events behave the same either way, so the host still knows when the frame is ready.
### Audio lock (host-mandated silent playback)
`audio-locked` forces `muted` on and hides the volume controls, with no UI path for the viewer to turn sound back on. Use it when embedding in a chat host (Claude.ai, ChatGPT, etc.) where audio must stay off regardless of viewer intent. Setting `muted` directly is _not_ enough — viewers can flip it back via the controls bar.
+142 -1
View File
@@ -2795,8 +2795,13 @@ describe("HyperframesPlayer asset-ready gate", () => {
play(): void;
pause(): void;
seek(timeInSeconds: number): void;
shaderLoader: { showAssetsLoading(): void };
shaderLoader: {
showAssetsLoading(): void;
hide(): void;
update(status: { loading: boolean; ready: boolean }, mode: string): void;
};
_settleAssetsReady(generation: number): void;
assetsLoadingUi: "player" | "none";
};
beforeEach(async () => {
@@ -3028,6 +3033,142 @@ describe("HyperframesPlayer asset-ready gate", () => {
player.remove();
});
it("keeps a play queued before a same-origin iframe load and starts it once the wait settles", async () => {
const player = await createConnectedPlayer();
const { doc, video } = createStalledVideoDoc();
stubIframeContentDocument(player.iframe, doc);
const playSpy = vi.fn();
player.addEventListener("play", playSpy);
// First ready's asset wait, a play() while it is pending, then the document's own load event.
player._waitForAssetsReady(doc);
player.play();
expect(player._pendingPlay).toBe(true);
player._onIframeLoad();
post(player, { type: "timeline", durationInFrames: 60 });
video.dispatchEvent(new Event("canplay"));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(player.assetsReady).toBe(true);
expect(player._paused).toBe(false);
expect(playSpy).toHaveBeenCalledTimes(1);
player.remove();
});
it("drops a queued play when the host points the player at a different composition", async () => {
const player = await createConnectedPlayer();
const { doc } = createStalledVideoDoc();
stubIframeContentDocument(player.iframe, doc);
player._waitForAssetsReady(doc);
player.play();
expect(player._pendingPlay).toBe(true);
player.setAttribute("srcdoc", "<p>another composition</p>");
expect(player._pendingPlay).toBe(false);
player.remove();
});
it("keeps a queued play through a shader-options reload of the same composition", async () => {
const player = await createConnectedPlayer();
const { doc } = createStalledVideoDoc();
stubIframeContentDocument(player.iframe, doc);
player._waitForAssetsReady(doc);
player.play();
player.setAttribute("shader-loading", "player");
expect(player._pendingPlay).toBe(true);
player.remove();
});
it("never raises the loading card with assets-loading-ui=none, and still reports every asset event", async () => {
const player = await createConnectedPlayer();
player.setAttribute("assets-loading-ui", "none");
const { doc, video } = createStalledVideoDoc();
stubIframeContentDocument(player.iframe, doc);
const showSpy = vi.spyOn(player.shaderLoader, "showAssetsLoading");
const events: string[] = [];
player.addEventListener("assetsready", () => events.push("assetsready"));
player.addEventListener("painted", () => events.push("painted"));
player._waitForAssetsReady(doc);
await new Promise((resolve) => setTimeout(resolve, 160));
expect(player.hasAttribute("assets-loading")).toBe(true);
expect(showSpy).not.toHaveBeenCalled();
video.dispatchEvent(new Event("canplay"));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(events).toEqual(["assetsready", "painted"]);
expect(player.hasAttribute("assets-loading")).toBe(false);
player.remove();
});
it("hides a loading card already up when assets-loading-ui switches to none", async () => {
const player = await createConnectedPlayer();
const { doc } = createStalledVideoDoc();
stubIframeContentDocument(player.iframe, doc);
const showSpy = vi.spyOn(player.shaderLoader, "showAssetsLoading");
const hideSpy = vi.spyOn(player.shaderLoader, "hide");
expect(player.assetsLoadingUi).toBe("player");
player._waitForAssetsReady(doc);
await new Promise((resolve) => setTimeout(resolve, 160));
expect(showSpy).toHaveBeenCalledTimes(1);
player.assetsLoadingUi = "none";
expect(player.getAttribute("assets-loading-ui")).toBe("none");
expect(hideSpy).toHaveBeenCalled();
player.remove();
});
it("keeps a shader load drawn over the loading card when assets-loading-ui switches to none", async () => {
const player = await createConnectedPlayer();
const { doc } = createStalledVideoDoc();
stubIframeContentDocument(player.iframe, doc);
player._waitForAssetsReady(doc);
await new Promise((resolve) => setTimeout(resolve, 160));
player.shaderLoader.update({ loading: true, ready: false }, "player");
player.assetsLoadingUi = "none";
const loader = player.shadowRoot?.querySelector(".hfp-shader-loader");
expect(loader?.classList.contains("hfp-visible")).toBe(true);
expect(loader?.getAttribute("aria-label")).toBe("Preparing scene transitions");
player.remove();
});
it("leaves a shader load on screen when assets settle, and reports painted once it is gone", async () => {
const player = await createConnectedPlayer();
const { doc, video } = createStalledVideoDoc();
stubIframeContentDocument(player.iframe, doc);
const events: string[] = [];
player.addEventListener("assetsready", () => events.push("assetsready"));
player.addEventListener("painted", () => events.push("painted"));
player._waitForAssetsReady(doc);
await new Promise((resolve) => setTimeout(resolve, 160));
player.shaderLoader.update({ loading: true, ready: false }, "player");
video.dispatchEvent(new Event("canplay"));
await new Promise((resolve) => setTimeout(resolve, 450));
const loader = player.shadowRoot?.querySelector(".hfp-shader-loader");
expect(loader?.classList.contains("hfp-visible")).toBe(true);
expect(events).toEqual(["assetsready"]);
player.shaderLoader.update({ loading: false, ready: true }, "player");
await new Promise((resolve) => setTimeout(resolve, 450));
expect(events).toEqual(["assetsready", "painted"]);
player.remove();
});
it("cancels a queued play if the user pauses while assets are still buffering", async () => {
const player = await createConnectedPlayer();
+34 -27
View File
@@ -42,6 +42,8 @@ const RUNTIME_DATA_DELIVERY_TIMEOUT_MS = 10_000;
// asset or a composition that never goes quiet must not block playback forever.
const ASSETS_READY_TIMEOUT_MS = 8_000;
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";
// 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.
@@ -96,6 +98,7 @@ class HyperframesPlayer extends HTMLElement {
RUNTIME_SRC_ATTR,
SHADER_CAPTURE_SCALE_ATTR,
SHADER_LOADING_ATTR,
ASSETS_LOADING_UI_ATTR,
];
}
@@ -231,10 +234,8 @@ class HyperframesPlayer extends HTMLElement {
this.controlsApi?.destroy();
this.controlsApi = null;
this._paused = true;
this._ready = false;
this._invalidateAssetsWait();
this._runtimeBridgeReady = false;
this._rejectAllRuntimeDataDeliveries("Player disconnected before runtime data was applied");
this._pendingPlay = false;
this._abandonComposition("Player disconnected before runtime data was applied");
}
// fallow-ignore-next-line complexity
@@ -247,23 +248,16 @@ class HyperframesPlayer extends HTMLElement {
// attributes are applied below by connectedCallback; only live changes navigate here.
if (!this.isConnected) break;
if (val) {
this._ready = false;
this._invalidateAssetsWait();
this._runtimeBridgeReady = false;
this._rejectAllRuntimeDataDeliveries(
"Composition navigated before runtime data was applied",
);
// A different composition: like a <video> given a new src, it does not inherit a queued play.
this._pendingPlay = false;
this._abandonComposition("Composition navigated before runtime data was applied");
this.iframe.src = prepareSrcForElement(this, val);
}
break;
case "srcdoc":
if (!this.isConnected) break;
this._ready = false;
this._invalidateAssetsWait();
this._runtimeBridgeReady = false;
this._rejectAllRuntimeDataDeliveries(
"Composition navigated before runtime data was applied",
);
this._pendingPlay = false;
this._abandonComposition("Composition navigated before runtime data was applied");
if (val !== null) this.iframe.srcdoc = prepareSrcdocForElement(this, val);
else this.iframe.removeAttribute("srcdoc");
break;
@@ -320,6 +314,9 @@ class HyperframesPlayer extends HTMLElement {
if (val) this._media.setupFromUrl(val);
else this._media.teardownUrlAudio();
break;
case ASSETS_LOADING_UI_ATTR:
if (val === "none") this.shaderLoader.hideAssetsLoading();
break;
case SHADER_CAPTURE_SCALE_ATTR:
case SHADER_LOADING_ATTR:
case RUNTIME_SRC_ATTR:
@@ -339,10 +336,7 @@ class HyperframesPlayer extends HTMLElement {
}
private _reloadForSandboxOriginPolicy(): void {
this._ready = false;
this._invalidateAssetsWait();
this._runtimeBridgeReady = false;
this._rejectAllRuntimeDataDeliveries("Sandbox policy changed before runtime data was applied");
this._abandonComposition("Sandbox policy changed before runtime data was applied");
const srcdoc = this.getAttribute("srcdoc");
if (srcdoc !== null) {
this.iframe.srcdoc = prepareSrcdocForElement(this, srcdoc);
@@ -562,6 +556,14 @@ class HyperframesPlayer extends HTMLElement {
else this.setAttribute(SHADER_LOADING_ATTR, mode);
}
get assetsLoadingUi(): "player" | "none" {
return this.getAttribute(ASSETS_LOADING_UI_ATTR) === "none" ? "none" : "player";
}
set assetsLoadingUi(mode: "player" | "none") {
if (mode === "none") this.setAttribute(ASSETS_LOADING_UI_ATTR, "none");
else this.removeAttribute(ASSETS_LOADING_UI_ATTR);
}
get muted() {
return this.hasAttribute("muted");
}
@@ -844,10 +846,7 @@ class HyperframesPlayer extends HTMLElement {
// `_runtimeBridgeReady` true lets a delivery post into a document that is being
// replaced, where it can only end in a delivery timeout rather than the immediate,
// explanatory rejection the caller gets from every other navigating path.
this._ready = false;
this._invalidateAssetsWait();
this._runtimeBridgeReady = false;
this._rejectAllRuntimeDataDeliveries("Shader options changed before runtime data was applied");
this._abandonComposition("Shader options changed before runtime data was applied");
if (getShaderModeFromElement(this) !== "player") this.shaderLoader.reset();
if (this.hasAttribute("srcdoc")) {
this.iframe.srcdoc = prepareSrcdocForElement(this, this.getAttribute("srcdoc") || "");
@@ -1074,7 +1073,7 @@ class HyperframesPlayer extends HTMLElement {
this._assetsLoadingShowTimer = null;
if (generation !== this._assetsGeneration || this._assetsReady) return;
this.setAttribute(ASSETS_LOADING_ATTR, "");
this.shaderLoader.showAssetsLoading();
if (this.assetsLoadingUi !== "none") this.shaderLoader.showAssetsLoading();
}, ASSETS_LOADING_SHOW_DELAY_MS);
}
@@ -1108,7 +1107,7 @@ class HyperframesPlayer extends HTMLElement {
this._clearAssetsLoadingShowTimer();
this._assetsReady = true;
this.removeAttribute(ASSETS_LOADING_ATTR);
this.shaderLoader.hide();
this.shaderLoader.hideAssetsLoading();
this.dispatchEvent(new Event("assetsready"));
this.shaderLoader.whenHidden(() => {
if (generation !== this._assetsGeneration) return;
@@ -1118,13 +1117,21 @@ class HyperframesPlayer extends HTMLElement {
if (this._pendingPlay) this.play();
}
/** Every host-driven navigation or teardown: the old document's handshake, asset wait and
* data deliveries end here. A queued play is the caller's, so only its owners clear it. */
private _abandonComposition(reason: string): void {
this._ready = false;
this._invalidateAssetsWait();
this._runtimeBridgeReady = false;
this._rejectAllRuntimeDataDeliveries(reason);
}
/** Abandons any in-flight asset wait — every `_ready = false` site calls
* this first, so a stale wait's settle can't apply to what comes next. */
private _invalidateAssetsWait(): void {
this._clearAssetsLoadingShowTimer();
this._assetsReady = false;
this._painted = false;
this._pendingPlay = false;
this._assetsGeneration++;
this.removeAttribute(ASSETS_LOADING_ATTR);
this.shaderLoader.hide();
@@ -9,7 +9,7 @@ function loader() {
[...elements.root.querySelectorAll<HTMLElement>(".hfp-shader-loader-row")]
.filter((row) => row.style.visibility !== "hidden")
.map((row) => row.querySelector(".hfp-shader-loader-label")?.textContent);
return { state: new ShaderLoaderState(elements), visibleLabels };
return { state: new ShaderLoaderState(elements), visibleLabels, root: elements.root };
}
describe("shader loader progress rows", () => {
@@ -46,4 +46,26 @@ describe("shader loader progress rows", () => {
state.update({ loading: true, ready: false, progress: 2, total: 5 }, "player");
expect(visibleLabels()).toEqual(["transition"]);
});
it("hides the Loading assets card but not a shader load that took the panel over", () => {
const { state, root } = loader();
state.showAssetsLoading();
state.hideAssetsLoading();
expect(root.classList.contains("hfp-visible")).toBe(false);
state.showAssetsLoading();
state.update({ loading: true, ready: false }, "player");
state.hideAssetsLoading();
expect(root.classList.contains("hfp-visible")).toBe(true);
});
it("keeps the Loading assets card through shader messages that draw nothing", () => {
const { state, root } = loader();
state.showAssetsLoading();
state.update({ loading: false, ready: false }, "player");
state.update({ loading: false, ready: true }, "player");
state.update({ loading: true, ready: false }, "composition");
expect(root.classList.contains("hfp-visible")).toBe(true);
expect(root.getAttribute("aria-label")).toBe("Loading assets");
});
});
@@ -24,6 +24,7 @@ export class ShaderLoaderState {
private readonly _el: ShaderLoaderElements;
private _hideTimeout: ReturnType<typeof setTimeout> | null = null;
private _hiddenCallbacks: Array<() => void> = [];
private _drawingAssets = false;
constructor(elements: ShaderLoaderElements) {
this._el = elements;
@@ -69,6 +70,7 @@ export class ShaderLoaderState {
this._hideTimeout = null;
}
this._el.root.classList.remove("hfp-visible", "hfp-hiding");
this._drawingAssets = false;
this._flushHidden();
this._el.fill.style.transform = "scaleX(0)";
setRowValue(this._el.transitionRow, this._el.transitionValue, "");
@@ -77,6 +79,9 @@ export class ShaderLoaderState {
// fallow-ignore-next-line unused-class-member, complexity
update(status: ShaderTransitionState, loadingMode: string): void {
const draws = loadingMode === "player" && status.loading && !status.ready;
// A shader message that draws nothing leaves the Loading assets card to its own owner.
if (this._drawingAssets && !draws) return;
if (loadingMode !== "player") {
this.reset();
return;
@@ -88,6 +93,7 @@ export class ShaderLoaderState {
// showAssetsLoading() may have left "Loading assets" here for a prior
// show; this path owns the label whenever it's the one drawing the panel.
this._el.root.setAttribute("aria-label", "Preparing scene transitions");
this._drawingAssets = false;
const progress =
typeof status.progress === "number" && Number.isFinite(status.progress) ? status.progress : 0;
@@ -144,9 +150,15 @@ export class ShaderLoaderState {
this._el.title.textContent = "Loading assets";
this._el.detail.textContent = "Waiting for images, video and fonts to finish loading.";
this._el.root.setAttribute("aria-label", "Loading assets");
this._drawingAssets = true;
this.show();
}
/** Hides the panel only while it is the Loading assets card, never a shader load drawn over it. */
hideAssetsLoading(): void {
if (this._drawingAssets) this.hide();
}
// fallow-ignore-next-line unused-class-member
get hideTimeout(): ReturnType<typeof setTimeout> | null {
return this._hideTimeout;