Files
hyperframes/packages/player/src/hyperframes-player.ts
T
Miguel Ángel 443ded4261 fix(player): preview voice no longer goes silent while other tracks play (#6859)
* fix(core): audio that decodes short plays from its element in preview

* fix(core): keep the decoded copy when the element's duration is unknown

* fix(player): preview pauses with Buffering until every track due can play

* fix(core): keep the transport awake while it buffers

* fix(core): wait only for tracks the clock would also treat as playable

* test(core): buffering fixture pauses like a browser, with a pause event

* fix(core): hold for a clip only after media sync has asked it to play

* test(core): hold lands on the frame a slow tick skips into a clip with no data

* fix(core): a held clip counts as ready once it has data buffered ahead
2026-10-30 21:25:28 +00:00

1677 lines
62 KiB
TypeScript

import { CompositionProbe, type ProbeResult, readPositiveDimension } from "./composition-probe.js";
import { isControlsClick, setupControls, setupPoster } from "./controls-setup.js";
import { adoptShadowStyles, createCompositionIframe, scaleIframeToFit } from "./iframe-dom.js";
import { DirectTimelineClock } from "./direct-timeline-clock.js";
import { ParentMediaManager } from "./parent-media.js";
import { isRealmHtmlMediaElement } from "./media-element-guards.js";
import { handleRuntimeMessage } from "./runtime-message-handler.js";
import {
isOutsidePlayRange,
type PlayRange,
playRangeStopTime,
resolvePlayRange,
} from "./play-range.js";
import {
SHADER_CAPTURE_SCALE_ATTR,
SHADER_LOADING_ATTR,
RUNTIME_SRC_ATTR,
type ShaderLoadingMode,
getShaderCaptureScaleFromElement,
getShaderModeFromElement,
prepareSrcForElement,
prepareSrcdocForElement,
runtimeSrcFromElement,
} from "./shader-options.js";
import { createShaderLoader } from "./shader-loader-element.js";
import { ShaderLoaderState } from "./shader-loader-state.js";
import { PLAYER_STYLES } from "./styles.js";
import { type DirectTimelineAdapter } from "./timeline-adapters.js";
import { createVideoSource, isVideoType, type VideoSource } from "./video-source.js";
import { frameDisplayScale, runtimeProtocolMetadata } from "@hyperframes/core/runtime/protocol";
import {
FIRST_FRAME_READINESS_SCOPE,
scanPendingCompositionAssets,
settleFirstFrameCompositionReadiness,
} from "@hyperframes/core/composition-readiness";
// Playback-rate bounds mirror the runtime clamp in
// packages/core/src/runtime/init.ts (applyPlaybackRate) and media.ts so the
// player accepts the same range as the in-iframe runtime: an out-of-range rate
// would otherwise drive the parent-proxied <audio> outside the bounds the
// timeline itself respects. Clamping here also shields the native
// HTMLMediaElement.playbackRate setter, which throws for extreme values in
// production browsers.
const MIN_PLAYBACK_RATE = 0.1;
const MAX_PLAYBACK_RATE = 5;
const SANDBOX_ORIGIN_ATTR = "sandbox-origin";
const RUNTIME_DATA_DELIVERY_TIMEOUT_MS = 10_000;
// Bounds how long the player waits on a same-origin composition's readiness
// inputs (media, compute, paint-and-idle) before playing anyway — a stuck
// 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";
const BUFFERING_ATTR = "buffering";
const LOW_POWER_IDLE_ATTR = "low-power-idle";
const DISABLE_CLICK_TO_PLAY_ATTR = "disable-click-to-play";
const RANGE_START_ATTR = "range-start";
const RANGE_END_ATTR = "range-end";
// 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.
const ASSETS_LOADING_SHOW_DELAY_MS = 150;
export type ColorGradingTarget =
| string
| {
id?: string | null;
hfId?: string | null;
selector?: string | null;
selectorIndex?: number | null;
};
export type ColorGradingCompareState = {
enabled: boolean;
position?: number;
softness?: number;
lineWidth?: number;
};
type RuntimeDataBridge = {
setRuntimeData?: (channel: string, payload: unknown, requestId?: number) => void;
clearRuntimeData?: (channel: string, requestId?: number) => void;
};
type PendingRuntimeDataDelivery = {
requestId: number;
timeoutId: number;
};
function clampPlaybackRate(rate: number): number {
if (!Number.isFinite(rate) || rate <= 0) return 1;
return Math.max(MIN_PLAYBACK_RATE, Math.min(MAX_PLAYBACK_RATE, rate));
}
class HyperframesPlayer extends HTMLElement {
static get observedAttributes() {
return [
"src",
"srcdoc",
"width",
"height",
"controls",
"muted",
"audio-locked",
"volume",
"poster",
"playback-rate",
"audio-src",
"type",
SANDBOX_ORIGIN_ATTR,
RUNTIME_SRC_ATTR,
SHADER_CAPTURE_SCALE_ATTR,
SHADER_LOADING_ATTR,
ASSETS_LOADING_UI_ATTR,
LOW_POWER_IDLE_ATTR,
RANGE_START_ATTR,
RANGE_END_ATTR,
];
}
private shadow: ShadowRoot;
private container: HTMLDivElement;
private readonly sizer = document.createElement("div");
private iframe: HTMLIFrameElement;
private posterEl: HTMLImageElement | null = null;
private controlsApi: ReturnType<typeof setupControls> | null = null;
private resizeObserver: ResizeObserver;
private shaderLoader: ShaderLoaderState;
private probe: CompositionProbe;
private _ready = false;
private _readyDocument: Document | null = null;
private _connected = false;
private _assetsReady = false;
private _painted = false;
private _pendingPlay = false;
private _assetsGeneration = 0;
private _assetsLoadingShowTimer: ReturnType<typeof setTimeout> | null = null;
private _bufferingShowTimer: ReturnType<typeof setTimeout> | null = null;
private _currentTime = 0;
private _duration = 0;
private _paused = true;
/** True while the user is dragging the scrubber — makes seek() play audio at the
* playhead (audible scrub) instead of positioning it silently. */
private _scrubbing = false;
private _lastUpdateMs = 0;
private _volume = 1;
private _compositionWidth = 1920;
private _compositionHeight = 1080;
private _rescaleWarned = false;
private _directTimelineAdapter: DirectTimelineAdapter | null = null;
private _directTimelineClock: DirectTimelineClock;
private _parentTickRaf: number | null = null;
private _media: ParentMediaManager;
private _scenes: { id: string; start: number; duration: number }[] = [];
private _runtimeFps = 30;
private _runtimeBridgeReady = false;
private _runtimeAssetsReadyGeneration = -1;
private _runtimeData = new Map<string, unknown>();
private _runtimeDataRequestId = 0;
private _pendingRuntimeData = new Map<string, PendingRuntimeDataDelivery>();
private _afterUpdate: Array<() => void> | null = null;
private _videoSource: VideoSource | null = null;
private _runtimeOwnsPlayRange = false;
private _enteringRange = false;
private _rangeClampReported = "";
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
adoptShadowStyles(this.shadow, PLAYER_STYLES);
({ container: this.container, iframe: this.iframe } = createCompositionIframe());
this.sizer.className = "hfp-sizer";
this.shadow.appendChild(this.sizer);
this.shadow.appendChild(this.container);
const loaderElements = createShaderLoader();
this.shadow.appendChild(loaderElements.root);
this.shaderLoader = new ShaderLoaderState(loaderElements);
this._media = new ParentMediaManager({
dispatchEvent: (e) => this._emit(e),
getMuted: () => this.muted,
getVolume: () => this._volume,
getPlaybackRate: () => this.playbackRate,
getCurrentTime: () => this._currentTime,
isPaused: () => this._paused,
});
this._directTimelineClock = new DirectTimelineClock({
onTimeUpdate: (currentTime, duration) => {
this._currentTime = currentTime;
this.controlsApi?.updateTime(currentTime, duration);
this._emit(new CustomEvent("timeupdate", { detail: { currentTime } }));
},
getLoop: () => this.loop,
restart: () => {
this.seek(this._playRange()?.start ?? 0);
this.play();
},
onPaused: () => {
if (this._media.audioOwner === "parent") this._media.pauseAll();
this._paused = true;
this.controlsApi?.updatePlaying(false);
this._emit(new Event("ended"));
},
onEnded: () => this.loop,
});
this.probe = new CompositionProbe(this.iframe, {
resolveRuntimeUrl: () => runtimeSrcFromElement(this),
hostOwnsDeadline: () => this.hasAttribute("no-load-timeout"),
onReady: (result) => this._onProbeReady(result),
onError: (message) => this._emit(new CustomEvent("error", { detail: { message } })),
});
this.addEventListener("click", (event) => {
if (this.disableClickToPlay || isControlsClick(event)) return;
if (this._paused) this.play();
else this.pause();
});
this.resizeObserver = new ResizeObserver(() => this._rescale());
this._onMessage = this._onMessage.bind(this);
this._onIframeLoad = this._onIframeLoad.bind(this);
// Before any host can listen; _onIframeLoad skips the blank-document load before connect.
this.iframe.addEventListener("load", this._onIframeLoad);
}
connectedCallback() {
this._connected = true;
this._applySandboxOriginPolicy();
this.resizeObserver.observe(this);
window.addEventListener("message", this._onMessage);
if (this.hasAttribute("controls")) this._setupControls();
if (this.hasAttribute("poster"))
this.posterEl = setupPoster(this.shadow, this.getAttribute("poster"), this.posterEl);
if (this.hasAttribute("audio-src")) this._media.setupFromUrl(this.getAttribute("audio-src")!);
if (this.hasAttribute("srcdoc"))
this.iframe.srcdoc = prepareSrcdocForElement(this, this.getAttribute("srcdoc")!);
if (this.hasAttribute("src")) this._loadSrc(this.getAttribute("src")!);
// Host-environment audio lock: when the embedding host (e.g. Claude
// desktop) drops the `audio-locked` attribute, attributeChangedCallback
// never fires for it, so apply the lock here based on UA detection.
if (!this.hasAttribute("audio-locked") && this._isLockedHostEnvironment()) {
this._applyAudioLock(true);
}
}
disconnectedCallback() {
this._connected = false;
this._teardownVideo();
this._sendControl("pause");
this._stopIframeMedia();
this.resizeObserver.disconnect();
window.removeEventListener("message", this._onMessage);
this.probe.stop();
this._directTimelineClock.stop();
this._stopParentTickClock();
this._directTimelineAdapter = null;
this.shaderLoader.destroy();
this._media.destroy();
this.controlsApi?.destroy();
this.controlsApi = null;
this._paused = true;
this._pendingPlay = false;
this._abandonComposition("Player disconnected before runtime data was applied");
}
// fallow-ignore-next-line complexity
attributeChangedCallback(name: string, oldVal: string | null, val: string | null) {
switch (name) {
case "src":
// Custom-element attributes are normally assigned before insertion (React does this for
// every render). Navigating the inner iframe here would let its one-shot runtime `ready`
// message fire before connectedCallback installs the parent message listener. Initial
// attributes are applied below by connectedCallback; only live changes navigate here.
if (!this.isConnected) break;
if (val) this._navigateSrc(val);
break;
case "type": {
const src = this.getAttribute("src");
if (!this.isConnected || src === null || !!this._videoSource === this._wantsVideo()) break;
this._navigateSrc(src);
break;
}
case "srcdoc":
if (!this.isConnected) break;
this._pendingPlay = false;
this._abandonComposition("Composition navigated before runtime data was applied");
if (val !== null) {
this._pauseForVideoSwitch();
this._teardownVideo();
this.iframe.srcdoc = prepareSrcdocForElement(this, val);
} else {
this.iframe.removeAttribute("srcdoc");
const src = this.getAttribute("src");
if (src !== null && this._wantsVideo()) this._navigateSrc(src);
}
break;
case SANDBOX_ORIGIN_ATTR:
this._applySandboxOriginPolicy(this.isConnected && oldVal !== val);
break;
// Reject NaN/zero/negative dimensions the same way the composition
// probe does (a typo like width="abc" or width="0" would otherwise
// reach scaleIframeToFit as scale(NaN) or a division by zero and
// blank the player); fall back to the defaults instead.
case "width":
this._setCompositionSize(readPositiveDimension(val) ?? 1920, this._compositionHeight);
break;
case "height":
this._setCompositionSize(this._compositionWidth, readPositiveDimension(val) ?? 1080);
break;
case "controls":
if (val !== null) this._setupControls();
else {
this.controlsApi?.destroy();
this.controlsApi = null;
}
break;
case "poster":
this.posterEl = setupPoster(this.shadow, val, this.posterEl);
break;
case "playback-rate": {
const rate = clampPlaybackRate(parseFloat(val || "1"));
this._media.updatePlaybackRate(rate);
this._sendControl("set-playback-rate", { playbackRate: rate });
this._directTimelineAdapter?.timeScale?.(rate);
this.controlsApi?.updateSpeed(rate);
this._emit(new Event("ratechange"));
break;
}
case "muted":
this._handleMutedChange(val);
break;
case "audio-locked":
this._applyAudioLock(val !== null);
break;
case "volume": {
const v = Math.max(0, Math.min(1, parseFloat(val || "1")));
this._volume = v;
this._syncVideoAudio();
this._media.updateVolume(v);
this._sendControl("set-volume", { volume: v });
this.controlsApi?.updateVolume(v);
this._emit(new Event("volumechange"));
break;
}
case "audio-src":
if (val) this._media.setupFromUrl(val);
else this._media.teardownUrlAudio();
break;
case ASSETS_LOADING_UI_ATTR:
if (val === "none") this.shaderLoader.hideAssetsLoading();
break;
case LOW_POWER_IDLE_ATTR:
this._sendControl("set-idle-heartbeat", { slow: val !== null });
break;
case RANGE_START_ATTR:
case RANGE_END_ATTR:
this._applyPlayRange(true);
break;
case SHADER_CAPTURE_SCALE_ATTR:
case SHADER_LOADING_ATTR:
case RUNTIME_SRC_ATTR:
if (!this.isConnected) break;
this._reloadShaderOptions();
break;
}
}
private _applySandboxOriginPolicy(reloadActiveDocument = false): void {
if (this.hasAttribute(SANDBOX_ORIGIN_ATTR)) {
this.iframe.sandbox.remove("allow-same-origin");
} else {
this.iframe.sandbox.add("allow-same-origin");
}
if (reloadActiveDocument) this._reloadForSandboxOriginPolicy();
}
private _reloadForSandboxOriginPolicy(): void {
// A video does not play in the iframe, so neither reload applies to it.
if (this._videoSource) return;
this._abandonComposition("Sandbox policy changed before runtime data was applied");
const srcdoc = this.getAttribute("srcdoc");
if (srcdoc !== null) {
this.iframe.srcdoc = prepareSrcdocForElement(this, srcdoc);
return;
}
const src = this.getAttribute("src");
if (src === null) this.iframe.src = "about:blank";
else this._loadSrc(src);
}
/**
* The inner `<iframe>` rendering the composition. Use this when integrating
* with tools that need `contentWindow` — `.contentWindow` on the
* `<hyperframes-player>` element itself returns `null` (Shadow DOM).
*/
get iframeElement(): HTMLIFrameElement {
return this.iframe;
}
/** Scene list from the last-received runtime timeline message. Empty until
* the composition runtime fires its first "timeline" postMessage. */
get scenes(): { id: string; start: number; duration: number }[] {
return this._scenes;
}
play() {
this._play(true);
}
// fallow-ignore-next-line complexity
private _play(announce: boolean) {
if (this._ready && !this._assetsReady) {
this._pendingPlay = true;
return;
}
this._pendingPlay = false;
this.posterEl?.remove();
this.posterEl = null;
const range = this._ready ? this._playRange() : null;
if (range) {
if (isOutsidePlayRange(this._currentTime, range, this._duration, this._runtimeFps)) {
this.seek(range.start);
this._enteringRange = true;
}
} else if (this._duration > 0 && this._currentTime >= this._duration) this.seek(0);
// Must be set before _startParentTickClock so the RAF loop's `_paused`
// check doesn't immediately self-terminate on the first callback.
this._paused = false;
const directTimelineStarted = this._tryDirectTimelinePlay();
// Set when the probe hasn't resolved yet: retried from _onProbeReady /
// _onRuntimeTimelineReady once ready, which is the call that actually
// starts playback — this premature call must not ALSO dispatch "play"
// for what hasn't started, or a host listener sees it fire twice.
let queuedForReady = false;
this._sendDisplayScale();
if (!directTimelineStarted) {
this._sendControl("play");
// Only start the parent tick clock once the composition is ready and
// confirmed on the runtime bridge path (not the direct-timeline path).
if (this._ready && !this._directTimelineAdapter) {
this._startParentTickClock();
} else if (!this._ready) {
this._pendingPlay = true;
queuedForReady = true;
}
}
if (this._media.audioOwner === "parent") this._media.playAll();
else this._media.primeAll();
this.controlsApi?.updatePlaying(true);
if (!queuedForReady && announce) this._emit(new Event("play"));
if (directTimelineStarted && this._directTimelineAdapter) {
this._directTimelineClock.start(
this._directTimelineAdapter,
() => this._currentTime,
() => this._duration,
() => this._paused,
() => this._clockStop(),
);
}
}
pause() {
// A play queued while assets were still buffering must not survive an
// explicit user pause — otherwise it fires once assets settle, seconds
// after the user stopped playback.
this._pendingPlay = false;
if (!this._tryDirectTimelinePause()) this._sendControl("pause");
else this._showPausedVideoTime();
this._directTimelineClock.stop();
this._stopParentTickClock();
if (this._media.audioOwner === "parent") this._media.pauseAll();
this._paused = true;
this.controlsApi?.updatePlaying(false);
this._emit(new Event("pause"));
}
stopMedia() {
this._sendControl("stop-media");
this._stopIframeMedia();
this._media.stopAdoptedMedia();
}
seek(timeInSeconds: number) {
// seek()'s own contract is that it lands paused, so a play still queued
// from the asset-buffering window is cancelled here too — same reason as
// pause() above.
this._pendingPlay = false;
if (!this._trySyncSeek(timeInSeconds) && !this._tryDirectTimelineSeek(timeInSeconds)) {
this._sendControl("seek", {
timeSeconds: timeInSeconds,
// Legacy runtimes read only `frame`. Protocol-v1 runtimes prefer
// `timeSeconds`, so carrying both keeps cross-origin embeds seekable
// while preserving seconds-first precision between current peers.
frame: Math.round(timeInSeconds * this._runtimeFps),
});
}
this._directTimelineClock.stop();
this._stopParentTickClock();
this._currentTime = timeInSeconds;
if (this._media.audioOwner === "parent") {
if (this._scrubbing) {
// Audible scrub: play the proxy audio at the playhead so the viewer hears
// the track as they drag. Each move re-seeks, restarting playback from the
// new position. onScrubEnd settles back to silence via a normal seek.
this._media.scrubAll(timeInSeconds);
} else {
// Pause BEFORE seek: leaving the proxy playing turns the next
// `mirrorTime` drift-correction tick into a perpetual seek→play→drift→seek
// stutter loop, where ~80ms of audio plays past the (now frozen) timeline,
// then mirrorTime yanks `currentTime` back to match it. Symmetric with
// `pause()` below.
this._media.pauseAll();
this._media.seekAll(timeInSeconds);
}
}
this._paused = true;
this.controlsApi?.updatePlaying(false);
this.controlsApi?.updateTime(this._currentTime, this._duration);
}
setColorGrading(target: ColorGradingTarget, grading: unknown) {
this._sendControl("set-color-grading", { target, grading });
}
clearColorGrading(target: ColorGradingTarget) {
this._sendControl("set-color-grading", { target, grading: null });
}
setColorGradingCompare(target: ColorGradingTarget, compare: ColorGradingCompareState) {
this._sendControl("set-color-grading-compare", { target, compare });
}
clearColorGradingCompare(target: ColorGradingTarget) {
this._sendControl("set-color-grading-compare", {
target,
compare: { enabled: false },
});
}
/** Retain and deliver structured runtime data through the shared runtime protocol. */
setRuntimeData(channel: string, payload: unknown): void {
if (!/^[a-z][a-z0-9-]{0,63}$/.test(channel)) {
throw new Error(`Invalid HyperFrames runtime-data channel: ${channel}`);
}
if (typeof structuredClone !== "function") {
throw new Error(
"HyperFrames runtime data requires structuredClone support; refusing an unverified payload",
);
}
const retained = structuredClone(payload);
this._runtimeData.set(channel, retained);
this._deliverRuntimeData(channel, retained);
}
clearRuntimeData(channel: string): void {
if (!/^[a-z][a-z0-9-]{0,63}$/.test(channel)) {
throw new Error(`Invalid HyperFrames runtime-data channel: ${channel}`);
}
this._runtimeData.delete(channel);
this._deliverRuntimeDataClear(channel);
}
get currentTime() {
return this._currentTime;
}
set currentTime(t: number) {
this.seek(t);
}
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;
}
get ready() {
return this._ready;
}
/** True once every readiness input (media, compute, paint-and-idle) has
* settled or the wait timed out. Mirrors `assetsready`. Always true for
* cross-origin compositions, which the player has no DOM access to wait on. */
get assetsReady() {
return this._assetsReady;
}
/** True once assets are ready and the loading panel has finished fading out,
* so the document is what is on screen. Mirrors `painted`; resets per load. */
get painted() {
return this._painted;
}
get playbackRate() {
return clampPlaybackRate(parseFloat(this.getAttribute("playback-rate") || "1"));
}
set playbackRate(r: number) {
this.setAttribute("playback-rate", String(clampPlaybackRate(r)));
}
get shaderCaptureScale() {
return getShaderCaptureScaleFromElement(this);
}
set shaderCaptureScale(scale: number) {
this.setAttribute(SHADER_CAPTURE_SCALE_ATTR, String(scale));
}
get shaderLoading() {
return getShaderModeFromElement(this);
}
set shaderLoading(mode: ShaderLoadingMode) {
if (mode === "composition") this.removeAttribute(SHADER_LOADING_ATTR);
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");
}
set muted(m: boolean) {
if (m) this.setAttribute("muted", "");
else this.removeAttribute("muted");
}
get audioLocked() {
return this.hasAttribute("audio-locked");
}
set audioLocked(locked: boolean) {
if (locked) this.setAttribute("audio-locked", "");
else this.removeAttribute("audio-locked");
}
/**
* Host renderers that strip unknown custom-element attributes before they
* reach the DOM (observed on the Claude desktop Electron client) can defeat
* `audio-locked` even when the host *intends* to lock audio. When we detect
* such an environment, self-impose the same restriction the attribute would
* apply. Web (browser) hosts preserve the attribute and don't need this.
*/
private _isLockedHostEnvironment(): boolean {
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent || "";
// Claude desktop ships as an Electron app with a "Claude/<version>" UA token.
return /\bClaude\/\d/.test(ua) && /\bElectron\b/.test(ua);
}
/** True when audio playback must be locked: attribute OR host fallback. */
private _isAudioLocked(): boolean {
return this.hasAttribute("audio-locked") || this._isLockedHostEnvironment();
}
private _isSlideshowPlayer(): boolean {
return this.closest("hyperframes-slideshow") !== null;
}
/** Apply a change to the `muted` attribute: re-assert under an audio lock,
* else mute/unmute the media, sync the controls, and fire `volumechange`. */
private _handleMutedChange(val: string | null): void {
// While audio is locked, ignore any attempt to clear `muted` (host control,
// stray script, raw `removeAttribute`) and re-assert it. The re-set fires
// this callback again with val="" (not null) so it mutes normally — no loop.
if (val === null && this._isAudioLocked()) {
this.setAttribute("muted", "");
return;
}
this._media.updateMuted(val !== null);
this._syncVideoAudio();
this._setIframeMediaMuted(val !== null);
this._sendControl("set-muted", { muted: val !== null });
this.controlsApi?.updateMuted(val !== null);
this._emit(new Event("volumechange"));
}
/**
* Host-mandated silent playback (e.g. embedded in a chat host): force mute
* and hide the volume controls so the viewer cannot turn sound on. Unlocking
* only unhides the controls — it does not auto-unmute; callers manage `muted`
* explicitly after unlocking.
*/
private _applyAudioLock(locked: boolean): void {
if (locked) this.muted = true;
this.controlsApi?.setVolumeControlsHidden(locked);
}
get volume() {
return this._volume;
}
set volume(v: number) {
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");
}
set loop(l: boolean) {
if (l) this.setAttribute("loop", "");
else this.removeAttribute("loop");
}
/** Film time where range playback starts; null (no `range-start`) starts at 0. */
get rangeStart(): number | null {
return this._readSeconds(RANGE_START_ATTR);
}
set rangeStart(seconds: number | null) {
this._writeSeconds(RANGE_START_ATTR, seconds);
}
/** Film time where range playback ends or loops; null (no `range-end`) plays to the end.
* When it ends inside the film, `currentTime` at `ended` is inside its last frame, before this. */
get rangeEnd(): number | null {
return this._readSeconds(RANGE_END_ATTR);
}
set rangeEnd(seconds: number | null) {
this._writeSeconds(RANGE_END_ATTR, seconds);
}
private _readSeconds(name: string): number | null {
const value = this.getAttribute(name);
return value === null ? null : Number(value);
}
private _writeSeconds(name: string, seconds: number | null): void {
if (seconds == null) this.removeAttribute(name);
else this.setAttribute(name, String(seconds));
}
private _hasPlayRange(): boolean {
return this.hasAttribute(RANGE_START_ATTR) || this.hasAttribute(RANGE_END_ATTR);
}
private _playRange(): PlayRange | null {
return resolvePlayRange(this.rangeStart, this.rangeEnd, this._duration).range;
}
private _sendPlayRange(range: PlayRange | null): void {
const endSeconds = range?.end ?? null;
this._sendControl("set-play-range", { startSeconds: range?.start ?? null, endSeconds });
}
/** A new range or duration: the runtime gets the new end and the host hears of a clamp. With
* `park`, a paused player moves to the start and a playing one outside the range jumps there;
* a queued play() starts at the range start itself. */
private _applyPlayRange(park: boolean): void {
const { range, clamped } = resolvePlayRange(this.rangeStart, this.rangeEnd, this._duration);
this._sendPlayRange(range);
if (!this._ready) return;
this._reportRangeClamp(range, clamped);
if (range && park && !this._pendingPlay) this._moveIntoPlayRange(range);
}
// Once per asked-for range and duration.
private _reportRangeClamp(range: PlayRange | null, clamped: boolean): void {
const clampKey = clamped ? `${this.rangeStart},${this.rangeEnd},${this._duration}` : "";
if (clampKey === this._rangeClampReported) return;
this._rangeClampReported = clampKey;
if (!clamped) return;
const detail = { rangeStart: range?.start ?? null, rangeEnd: range?.end ?? null };
this._emit(
new CustomEvent("rangeclamped", { detail: { ...detail, duration: this._duration } }),
);
}
private _moveIntoPlayRange(range: PlayRange): void {
if (!isOutsidePlayRange(this._currentTime, range, this._duration, this._runtimeFps)) return;
if (this._paused) this.seek(range.start);
else this._jumpWhilePlaying(range.start);
}
/** A `play-range` runtime jumps by itself and a player clock seeks without pausing; an older
* runtime is seeked and resumed, with no second `play` event. */
private _jumpWhilePlaying(time: number): void {
if (this._directTimelineAdapter) {
this._directTimelineAdapter.seek(time, false);
this._currentTime = time;
} else if (!this._runtimeOwnsPlayRange) {
this.seek(time);
this._enteringRange = true;
this._play(false);
}
}
/** Where the player's own clock stops, and the time it then shows: mid last frame, since a video
* seeked onto a frame boundary can decode the frame before it. */
private _clockStop(): { end: number; shown: number } {
const range = this._playRange();
const end = range?.end ?? this._duration;
if (!range) return { end, shown: end };
const hold = playRangeStopTime(range, this._duration, this._runtimeFps);
return { end, shown: hold < end ? (hold + end) / 2 : hold };
}
private _sendControl(action: string, extra: Record<string, unknown> = {}): boolean {
try {
const frameWindow = this.iframe.contentWindow;
if (!frameWindow) {
if (action === "set-runtime-data" || action === "clear-runtime-data") {
this._rejectRuntimeDataDelivery(
extra["channel"],
extra["requestId"],
"Composition iframe is unavailable",
);
}
return false;
}
frameWindow.postMessage(
{
...extra,
source: "hf-parent",
type: "control",
action,
...runtimeProtocolMetadata(this._runtimeFps),
},
"*",
);
return true;
} catch (error) {
if (action === "set-runtime-data" || action === "clear-runtime-data") {
this._rejectRuntimeDataDelivery(
extra["channel"],
extra["requestId"],
error instanceof Error ? error.message : String(error),
);
}
return false;
}
}
private _deliverRuntimeData(channel: string, payload: unknown): void {
if (!this.isConnected || !this._runtimeBridgeReady) return;
const requestId = this._beginRuntimeDataDelivery(channel);
if (this._trySetRuntimeDataDirect(channel, payload, requestId)) return;
this._sendControl("set-runtime-data", { channel, payload, requestId });
}
private _deliverRuntimeDataClear(channel: string): void {
if (!this.isConnected || !this._runtimeBridgeReady) return;
const requestId = this._beginRuntimeDataDelivery(channel);
if (this._tryClearRuntimeDataDirect(channel, requestId)) return;
this._sendControl("clear-runtime-data", { channel, requestId });
}
private _trySetRuntimeDataDirect(channel: string, payload: unknown, requestId: number): boolean {
try {
const bridge = (
this.iframe.contentWindow as (Window & { __hyperframes?: RuntimeDataBridge }) | null
)?.__hyperframes;
if (typeof bridge?.setRuntimeData !== "function") return false;
bridge.setRuntimeData(channel, payload, requestId);
return true;
} catch {
return false;
}
}
private _tryClearRuntimeDataDirect(channel: string, requestId: number): boolean {
try {
const bridge = (
this.iframe.contentWindow as (Window & { __hyperframes?: RuntimeDataBridge }) | null
)?.__hyperframes;
if (typeof bridge?.clearRuntimeData !== "function") return false;
bridge.clearRuntimeData(channel, requestId);
return true;
} catch {
return false;
}
}
private _replayRuntimeData(): void {
for (const [channel, payload] of this._runtimeData) {
this._deliverRuntimeData(channel, payload);
}
}
private _beginRuntimeDataDelivery(channel: string): number {
const previous = this._pendingRuntimeData.get(channel);
if (previous) window.clearTimeout(previous.timeoutId);
this._runtimeDataRequestId += 1;
const requestId = this._runtimeDataRequestId;
const timeoutId = window.setTimeout(() => {
this._rejectRuntimeDataDelivery(
channel,
requestId,
`Runtime data delivery timed out after ${RUNTIME_DATA_DELIVERY_TIMEOUT_MS}ms`,
);
}, RUNTIME_DATA_DELIVERY_TIMEOUT_MS);
this._pendingRuntimeData.set(channel, { requestId, timeoutId });
return requestId;
}
private _resolveRuntimeDataDelivery(channel: unknown, requestId: unknown): void {
const pending = this._takeRuntimeDataDelivery(channel, requestId);
if (!pending) return;
this._emit(
new CustomEvent("runtimedataapplied", {
detail: { channel, requestId: pending.requestId },
}),
);
}
private _rejectRuntimeDataDelivery(channel: unknown, requestId: unknown, message: unknown): void {
const pending = this._takeRuntimeDataDelivery(channel, requestId);
if (!pending) return;
this._emit(
new CustomEvent("runtimedataerror", {
detail: {
channel,
requestId: pending.requestId,
message: typeof message === "string" ? message : String(message),
},
}),
);
}
private _takeRuntimeDataDelivery(
channel: unknown,
requestId: unknown,
): PendingRuntimeDataDelivery | null {
if (
typeof channel !== "string" ||
typeof requestId !== "number" ||
!Number.isSafeInteger(requestId)
)
return null;
const pending = this._pendingRuntimeData.get(channel);
if (!pending || pending.requestId !== requestId) return null;
window.clearTimeout(pending.timeoutId);
this._pendingRuntimeData.delete(channel);
return pending;
}
private _rejectAllRuntimeDataDeliveries(message: string): void {
for (const [channel, pending] of [...this._pendingRuntimeData]) {
this._rejectRuntimeDataDelivery(channel, pending.requestId, message);
}
}
/**
* Returns the iframe's contentDocument if same-origin and reachable,
* otherwise null. Accessing contentDocument can throw on cross-origin
* iframes — this swallows that as a clean null sentinel.
*/
private _getSameOriginIframeDocument(): Document | null {
try {
return this.iframe.contentDocument;
} catch {
return null;
}
}
private _setIframeMediaMuted(muted: boolean): void {
const iframeDoc = this._getSameOriginIframeDocument();
if (!iframeDoc) return;
for (const el of iframeDoc.querySelectorAll("video, audio")) {
if (isRealmHtmlMediaElement(el)) el.muted = muted || el.defaultMuted;
}
}
private _stopIframeMedia(): void {
const iframeDoc = this._getSameOriginIframeDocument();
if (!iframeDoc) return;
for (const el of iframeDoc.querySelectorAll("video, audio")) {
if (isRealmHtmlMediaElement(el)) el.pause();
}
}
/**
* Replay current bridge state to the iframe runtime. Triggered when the
* runtime announces `{type: "ready"}` — repairs the race where the parent
* posts control messages before the iframe's bridge listener is installed
* (warm-cache reloads, the Claude desktop Electron client, anywhere the
* iframe finishes loading after we've already called `set-muted` etc).
* Re-sending current state is idempotent — even at default values it just
* confirms what the runtime would have done anyway.
*/
private _replayBridgeState(): void {
this._sendControl("set-muted", { muted: this.muted });
this._sendControl("set-volume", { volume: this._volume });
this._sendControl("set-playback-rate", { playbackRate: this.playbackRate });
this._sendControl("set-native-media-sync-disabled", {
disabled: this._isSlideshowPlayer(),
});
this._sendControl("set-web-audio-media-disabled", {
disabled: this._isSlideshowPlayer(),
});
this._sendControl("set-idle-heartbeat", { slow: this.hasAttribute(LOW_POWER_IDLE_ATTR) });
this._sendDisplayScale();
}
private _sendDisplayScale(): void {
const scale = frameDisplayScale(this.iframe);
if (scale) this._sendControl("set-display-scale", { scale });
}
private _reloadShaderOptions(): void {
if (this._videoSource) return;
// This navigates the frame, so readiness has to fall with it. Leaving
// `_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._abandonComposition("Shader options changed before runtime data was applied");
if (this.hasAttribute("srcdoc")) {
this.iframe.srcdoc = prepareSrcdocForElement(this, this.getAttribute("srcdoc") || "");
return;
}
if (this.hasAttribute("src")) this._loadSrc(this.getAttribute("src") || "");
}
// A different source inherits no queued play.
private _navigateSrc(src: string): void {
this._pendingPlay = false;
this._pauseForVideoSwitch();
this._abandonComposition("Composition navigated before runtime data was applied");
this._loadSrc(src);
}
/** A `type="video/..."` src plays in a `<video>`, unless `srcdoc` (which wins, as in an iframe)
* shows a composition. */
private _wantsVideo(): boolean {
return isVideoType(this.getAttribute("type")) && !this.hasAttribute("srcdoc");
}
// Like a <video> given a new src, a switch into or out of a video starts paused.
private _pauseForVideoSwitch(): void {
if (!this._videoSource && !this._wantsVideo()) return;
this._paused = true;
this.controlsApi?.updatePlaying(false);
}
private _loadSrc(src: string): void {
if (this._wantsVideo()) {
this._loadVideo(src);
return;
}
this._teardownVideo();
this.iframe.src = prepareSrcForElement(this, src);
}
private _loadVideo(src: string): void {
if (!this._videoSource) {
this._videoSource = createVideoSource({
onMetadata: (video) => this._applyThenEmit(() => this._onVideoReady(video)),
onDurationChange: (video) => this._applyTimelineDuration(video.duration),
onResize: (video) => this._applyVideoSize(video),
// Follow a play or pause the page did not ask for, but not a queued one it since reversed.
onPlay: (video) => {
if (!video.paused && this._paused) this.play();
},
onPause: (video) => {
if (video.paused && !video.ended && !this._paused) this.pause();
},
// A background tab runs no animation frames; a range's end is checked on timeupdate instead.
onTimeUpdate: () => {
if (document.hidden && this._playRange()) this._directTimelineClock.poll();
},
onError: (message, code) => {
if (!this._paused) this.pause();
this._emit(new CustomEvent("error", { detail: { message, code } }));
},
onPlayRejected: (error) => this._onVideoPlayRejected(error),
});
this.container.appendChild(this._videoSource.video);
this.iframe.hidden = true;
// Unloads a composition this player was showing before it switched to video.
this.iframe.src = "about:blank";
}
this.probe.stop();
const { video, adapter } = this._videoSource;
this._directTimelineAdapter = adapter;
this._currentTime = 0;
this._duration = 0;
this.controlsApi?.updateTime(0, 0);
this._syncVideoAudio();
adapter.timeScale?.(this.playbackRate);
video.src = src;
}
private _teardownVideo(): void {
if (!this._videoSource) return;
this._videoSource.destroy();
this._videoSource = null;
this.iframe.hidden = false;
}
private _syncVideoAudio(): void {
if (!this._videoSource) return;
this._videoSource.video.muted = this.muted;
this._videoSource.video.volume = this._volume;
}
private _onVideoReady(video: HTMLVideoElement): void {
this._applyTimelineDuration(video.duration);
this._applyVideoSize(video);
this._ready = true;
this._dispatchReady();
// A video has no composition assets to wait on; this settles at once.
this._waitForAssetsReady(null);
this._afterEvents(() => {
if (this.hasAttribute("autoplay") && this._paused) this.play();
});
}
private _applyTimelineDuration(duration: number): void {
if (!Number.isFinite(duration) || duration <= 0) return;
this._setDuration(duration);
this.controlsApi?.updateTime(this._currentTime, duration);
}
private _applyVideoSize(video: HTMLVideoElement): void {
if (video.videoWidth > 0 && video.videoHeight > 0) {
this._setCompositionSize(video.videoWidth, video.videoHeight);
}
}
// Only a blocked play is a playbackerror: a failed load already fired `error`.
private _onVideoPlayRejected(error: unknown): void {
if (!(error instanceof DOMException && error.name === "NotAllowedError")) return;
if (!this._paused) this.pause();
this._emit(new CustomEvent("playbackerror", { detail: { source: "video", error } }));
}
private _trySyncSeek(timeInSeconds: number): boolean {
if (this._videoSource) return false;
try {
const win = this.iframe.contentWindow as
| (Window & { __player?: { seek?: (t: number) => void } })
| null;
const player = win?.__player;
if (typeof player?.seek !== "function") return false;
player.seek.call(player, timeInSeconds);
return true;
} catch {
return false;
}
}
private _withDirectTimeline(fn: (tl: DirectTimelineAdapter) => void): boolean {
const resolved = this._videoSource ? null : this.probe.resolveDirectTimelineAdapter();
const tl = resolved || this._directTimelineAdapter;
if (!tl) return false;
try {
fn(tl);
if (resolved && resolved !== this._directTimelineAdapter) {
this._applyTimelineDuration(resolved.duration());
}
this._directTimelineAdapter = tl;
return true;
} catch {
return false;
}
}
// GSAP seek() preserves play state; player seek() contract lands paused.
private _tryDirectTimelineSeek(t: number): boolean {
return this._withDirectTimeline((tl) => {
// suppressEvents=false: fire the timeline's onUpdate so compositions that
// drive scene visibility imperatively (via the root timeline's onUpdate,
// e.g. slideshow decks) repaint on a paused seek — not only while playing.
tl.seek(t, false);
tl.pause();
});
}
private _tryDirectTimelinePlay(): boolean {
return this._withDirectTimeline((tl) => void tl.play());
}
private _tryDirectTimelinePause(): boolean {
return this._withDirectTimeline((tl) => void tl.pause());
}
// The clock samples every ~100 ms; like a <video>, a pause reports the exact frame.
private _showPausedVideoTime(): void {
if (!this._videoSource) return;
this._currentTime = this._videoSource.video.currentTime;
this.controlsApi?.updateTime(this._currentTime, this._duration);
this._emit(new CustomEvent("timeupdate", { detail: { currentTime: this._currentTime } }));
}
/**
* Widget-frame RAF loop that sends "tick" postMessages to the composition
* iframe on every frame. Used for the runtime bridge path so that animation
* advances even when the composition iframe's own rAF is throttled by
* Chromium (e.g. deeply nested cross-origin iframes in Electron / Claude desktop).
* The runtime skips a tick seek only when that timeline time is already
* rendered. Host ticks still advance a throttled iframe.
*/
private _startParentTickClock(): void {
this._stopParentTickClock();
const tick = () => {
if (this._paused) {
this._parentTickRaf = null;
return;
}
this._sendControl("tick");
this._parentTickRaf = requestAnimationFrame(tick);
};
this._parentTickRaf = requestAnimationFrame(tick);
}
private _stopParentTickClock(): void {
if (this._parentTickRaf === null) return;
cancelAnimationFrame(this._parentTickRaf);
this._parentTickRaf = null;
}
private _onMessage(e: MessageEvent) {
// The iframe window outlives its documents: a late composition message must not reach a video.
if (this._videoSource || this.probe.failed) return;
this._applyThenEmit(() => this._handleRuntimeMessage(e));
}
private _handleRuntimeMessage(e: MessageEvent) {
handleRuntimeMessage(e, this.iframe.contentWindow, {
getPlaybackState: () => ({
currentTime: this._currentTime,
duration: this._duration,
paused: this._paused,
lastUpdateMs: this._lastUpdateMs,
enteringRange: this._enteringRange,
}),
setPlaybackState: ({ currentTime, duration, paused, lastUpdateMs, enteringRange }) => {
this._enteringRange = enteringRange === true;
this._currentTime = currentTime;
this._setDuration(duration);
this._paused = paused;
this._lastUpdateMs = lastUpdateMs;
},
getShaderLoadingMode: () => getShaderModeFromElement(this),
shaderLoader: this.shaderLoader,
setCompositionSize: (w, h) => this._setCompositionSize(w, h),
sendControl: (action, extra) => this._sendControl(action, extra),
getIframeDoc: () => this.iframe.contentDocument,
onRuntimeReady: () => {
this._runtimeBridgeReady = true;
this._replayBridgeState();
if (this._hasPlayRange()) this._sendPlayRange(this._playRange());
this._replayRuntimeData();
},
onRuntimeAssetsReady: () => {
if (this._runtimeAssetsReadyGeneration === this._assetsGeneration) {
this._settleAssetsReady(this._assetsGeneration);
}
},
onRuntimeDataApplied: (channel, requestId) =>
this._resolveRuntimeDataDelivery(channel, requestId),
onRuntimeDataError: (channel, requestId, message) =>
this._rejectRuntimeDataDelivery(channel, requestId, message),
onRuntimeTimelineReady: (duration, assetsReady) =>
this._onRuntimeTimelineReady(duration, assetsReady),
setBuffering: (buffering) => this._setBuffering(buffering),
setRuntimeFps: (fps) => {
this._runtimeFps = fps;
},
setRuntimeOwnsPlayRange: (owns) => {
this._runtimeOwnsPlayRange = owns;
},
shouldPromoteMediaAutoplayFallback: () => !this._isSlideshowPlayer(),
setScenes: (scenes) => {
this._scenes = 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._emit(ev),
seek: (t) => this.seek(t),
play: () => this.play(),
getLoop: () => this.loop,
getPlayRange: () => this._playRange(),
media: this._media,
});
}
private _onRuntimeTimelineReady(duration: number, assetsReady: boolean | undefined) {
if (this._ready) return;
this.probe.stop();
this._setDuration(duration);
this._directTimelineAdapter = null;
this._ready = true;
this.controlsApi?.updateTime(this._currentTime, duration);
this._dispatchReady();
const doc = this._getSameOriginIframeDocument();
if (doc) this._media.setupFromIframe(doc);
this._replayBridgeState();
this._setIframeMediaMuted(this.muted);
this._waitForAssetsReady(doc, assetsReady);
this._playWhenWanted();
}
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._dispatchReady();
const doc = this._getSameOriginIframeDocument();
if (doc) this._media.setupFromIframe(doc);
this._setIframeMediaMuted(this.muted);
this._waitForAssetsReady(doc);
this._playWhenWanted();
}
/** Gates play() on composition readiness (media, compute, paint-and-idle),
* bounded by ASSETS_READY_TIMEOUT_MS. The overlay is debounced by
* ASSETS_LOADING_SHOW_DELAY_MS rather than shown the instant a wait
* starts, since one is now pending on nearly every Play. */
private _waitForAssetsReady(doc: Document | null, runtimeAssetsReady?: boolean): void {
this._clearAssetsLoadingShowTimer();
this._assetsReady = false;
this._painted = false;
// Invalidates any earlier wait still in flight (a composition swap, or
// disconnect, mid-wait) — its eventual settle checks this and no-ops
// rather than resolving a since-superseded generation.
const generation = ++this._assetsGeneration;
if (!doc) {
// An opaque-origin iframe cannot be scanned from here; a runtime that
// reports `assetsReady: false` runs the same scan itself and posts the result.
if (runtimeAssetsReady === false) {
this._runtimeAssetsReadyGeneration = generation;
this._startAssetsLoadingOverlayTimer(generation);
setTimeout(() => this._settleAssetsReady(generation), ASSETS_READY_TIMEOUT_MS);
} else {
this._settleAssetsReady(generation);
}
return;
}
settleFirstFrameCompositionReadiness(
doc,
({ timedOut }) => {
if (generation !== this._assetsGeneration) return;
if (timedOut) this._warnStuckAssets(doc);
this._settleAssetsReady(generation);
},
{ timeoutMs: ASSETS_READY_TIMEOUT_MS },
);
if (!this._assetsReady) this._startAssetsLoadingOverlayTimer(generation);
}
private _startAssetsLoadingOverlayTimer(generation: number): void {
this._assetsLoadingShowTimer = setTimeout(() => {
this._assetsLoadingShowTimer = null;
if (generation !== this._assetsGeneration || this._assetsReady) return;
this.setAttribute(ASSETS_LOADING_ATTR, "");
if (this.assetsLoadingUi !== "none") this.shaderLoader.showAssetsLoading();
}, ASSETS_LOADING_SHOW_DELAY_MS);
}
private _setBuffering(buffering: boolean): void {
if (buffering) {
if (this._bufferingShowTimer !== null || this.hasAttribute(BUFFERING_ATTR)) return;
this._bufferingShowTimer = setTimeout(() => {
this._bufferingShowTimer = null;
this.setAttribute(BUFFERING_ATTR, "");
if (this.assetsLoadingUi !== "none") this.shaderLoader.showAssetsLoading("Buffering", "");
}, ASSETS_LOADING_SHOW_DELAY_MS);
return;
}
if (this._bufferingShowTimer !== null) clearTimeout(this._bufferingShowTimer);
this._bufferingShowTimer = null;
if (!this.hasAttribute(BUFFERING_ATTR)) return;
this.removeAttribute(BUFFERING_ATTR);
this.shaderLoader.hideAssetsLoading();
}
private _hasPendingFirstFrameAssets(doc: Document): boolean {
const { pendingMedia, pendingImages, fontsLoading } = scanPendingCompositionAssets(doc, {
scope: FIRST_FRAME_READINESS_SCOPE,
});
return pendingMedia.length > 0 || pendingImages.length > 0 || fontsLoading;
}
/** Timeout diagnostic. Re-scans since some assets may have resolved by
* now. Compute can cause the timeout, so it's reported too. A hidden
* document can starve paint-and-idle of frames for the full 8s — that's
* reported directly rather than inferred, since it can't be bounded. */
private _warnStuckAssets(doc: Document): void {
const { pendingMedia, pendingImages, fontsLoading } = scanPendingCompositionAssets(doc, {
scope: FIRST_FRAME_READINESS_SCOPE,
});
const win = doc.defaultView as (Window & { __renderReady?: boolean }) | null;
console.warn(
`[hyperframes-player] assets-loading timed out after ${ASSETS_READY_TIMEOUT_MS}ms, playing anyway`,
{
stuckMedia: pendingMedia.map(
(el) => el.currentSrc || el.getAttribute("src") || `<${el.tagName.toLowerCase()}>`,
),
stuckImages: pendingImages.map(
(img) => img.currentSrc || img.getAttribute("src") || "<img>",
),
fontsLoading,
computeReady: win?.__renderReady === true,
documentHidden: doc.hidden === true,
},
);
}
private _settleAssetsReady(generation: number): void {
if (generation !== this._assetsGeneration || this._assetsReady) return;
this._clearAssetsLoadingShowTimer();
this._assetsReady = true;
this.removeAttribute(ASSETS_LOADING_ATTR);
this.shaderLoader.hideAssetsLoading();
this._emit(new Event("assetsready"));
this.shaderLoader.whenHidden(() => {
if (generation !== this._assetsGeneration) return;
this._painted = true;
this._emit(new Event("painted"));
});
this._afterEvents(() => {
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.probe.stop();
this._ready = false;
this._readyDocument = null;
this._invalidateAssetsWait();
this._releaseDocument();
this._runtimeBridgeReady = false;
this._rejectAllRuntimeDataDeliveries(reason);
}
private _releaseDocument(): void {
this._runtimeOwnsPlayRange = false;
this._enteringRange = false;
this._directTimelineAdapter = null;
this._directTimelineClock.stop();
this._stopParentTickClock();
this._setBuffering(false);
this.shaderLoader.reset();
this._media.resetForIframeLoad();
}
/** 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._assetsGeneration++;
this.removeAttribute(ASSETS_LOADING_ATTR);
this.shaderLoader.hide();
}
private _clearAssetsLoadingShowTimer(): void {
if (this._assetsLoadingShowTimer === null) return;
clearTimeout(this._assetsLoadingShowTimer);
this._assetsLoadingShowTimer = null;
}
private _dispatchReady(): void {
this._readyDocument = this._getSameOriginIframeDocument();
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();
this._rangeClampReported = "";
if (this._hasPlayRange()) this._applyPlayRange(true);
}
/** `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) return;
this._emit(new CustomEvent("durationchange", { detail: { duration } }));
if (this._hasPlayRange()) this._applyPlayRange(false);
}
private _setCompositionSize(width: number, height: number): void {
const changed = width !== this._compositionWidth || height !== this._compositionHeight;
this._compositionWidth = width;
this._compositionHeight = height;
this.sizer.style.aspectRatio = `${width} / ${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,
this.iframe,
this._compositionWidth,
this._compositionHeight,
);
// A no-op before "ready" is expected (element not painted yet). A no-op
// once ready means the composition is stuck unscaled/untransformed —
// pinned to the iframe's default top-left position — with no evidence of
// why in the field. Surface it once (not on every ResizeObserver tick —
// a legitimately hidden/zero-sized player, e.g. a collapsed tab or
// off-screen carousel card, would otherwise spam the console forever).
if (applied) this._sendDisplayScale();
if (!applied && this._ready && !this._rescaleWarned) {
this._rescaleWarned = true;
console.warn("[hyperframes-player] rescale no-op after ready: zero-size player element", {
src: this.getAttribute("src"),
offsetWidth: this.offsetWidth,
offsetHeight: this.offsetHeight,
compositionWidth: this._compositionWidth,
compositionHeight: this._compositionHeight,
});
}
}
private _onIframeLoad() {
// In video mode the iframe only ever loads about:blank; its load must not reset the video.
if (!this._connected || this._videoSource) return;
// The runtime posts its timeline once its scripts ran, before or after `load`, and every
// host-initiated navigation clears `_ready` first. So a ready player already holds this
// document's handshake (an opaque origin reads as null); a paused runtime never posts it again.
const doc = this._getSameOriginIframeDocument();
if (this._ready && doc === this._readyDocument) {
// Its asset wait scanned at DOMContentLoaded; a script may have added first-frame media since.
if (doc && this._hasPendingFirstFrameAssets(doc)) this._waitForAssetsReady(doc);
return;
}
this._ready = false;
// The runtime installs its bridge once its scripts ran and posts `ready`, which can come before
// the iframe's load event. Do not erase that authoritative handshake here: doing so strands
// retained data set after load until a second `ready` that never comes. Source setters and
// sandbox-policy reloads already clear bridge readiness before starting a navigation.
this._invalidateAssetsWait();
this._releaseDocument();
this.probe.start();
}
private _setupControls() {
if (this.controlsApi) return;
this.controlsApi = setupControls(
this.shadow,
this.muted,
this._volume,
this.getAttribute("speed-presets"),
{
onPlay: () => this.play(),
onPause: () => this.pause(),
onSeek: (f) => this.seek(f * this._duration),
onScrubStart: () => {
this._scrubbing = true;
},
onScrubEnd: () => {
this._scrubbing = false;
// Settle: a normal (silent) seek pauses the proxy audio at the final
// scrub position, matching the paused playhead.
this.seek(this._currentTime);
},
onSpeedChange: (s) => void (this.playbackRate = s),
onMuteToggle: () => void (this.muted = !this.muted),
onVolumeChange: (v) => void (this.volume = v),
},
this._isAudioLocked(),
);
this.controlsApi.updateTime(this._currentTime, this._duration);
this.controlsApi.updatePlaying(!this._paused);
this.controlsApi.updateSpeed(this.playbackRate);
}
// Test-instrumentation pass-throughs (match original field names).
get _audioOwner() {
return this._media.audioOwner;
}
get _parentMedia() {
return this._media.entries;
}
_mirrorParentMediaTime(t: number, opts?: { force?: boolean }) {
this._media.mirrorTime(t, opts);
}
_promoteToParentProxy() {
let d: Document | null = null;
try {
d = this.iframe.contentDocument;
} catch {
/* x-origin */
}
this._media.promoteToParentProxy(d, (t, o) => this._mirrorParentMediaTime(t, o));
this._sendControl("set-media-output-muted", { muted: true });
}
_observeDynamicMedia(doc: Document) {
this._media.setupFromIframe(doc);
}
}
if (!customElements.get("hyperframes-player")) {
customElements.define("hyperframes-player", HyperframesPlayer);
}
export { HyperframesPlayer };
export { formatTime, formatSpeed, SPEED_PRESETS } from "./controls.js";
export type { ControlsCallbacks, ControlsOptions } from "./controls.js";
export type { ShaderLoadingMode } from "./shader-options.js";