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