import { createControls, SPEED_PRESETS, type ControlsCallbacks } from "./controls.js"; import { shouldInjectRuntime } from "./shouldInjectRuntime.js"; import { PLAYER_STYLES } from "./styles.js"; let sharedSheet: CSSStyleSheet | null = null; function getSharedSheet(): CSSStyleSheet | null { if (sharedSheet) return sharedSheet; if (typeof CSSStyleSheet === "undefined") return null; try { const sheet = new CSSStyleSheet(); sheet.replaceSync(PLAYER_STYLES); sharedSheet = sheet; return sheet; } catch { return null; } } const DEFAULT_FPS = 30; const RUNTIME_CDN_URL = "https://cdn.jsdelivr.net/npm/@hyperframes/core/dist/hyperframe.runtime.iife.js"; class HyperframesPlayer extends HTMLElement { static get observedAttributes() { return [ "src", "srcdoc", "width", "height", "controls", "muted", "poster", "playback-rate", "audio-src", ]; } private shadow: ShadowRoot; private container: HTMLDivElement; private iframe: HTMLIFrameElement; private posterEl: HTMLImageElement | null = null; private controlsApi: ReturnType | null = null; private resizeObserver: ResizeObserver; private _ready = false; private _duration = 0; private _currentTime = 0; private _paused = true; private _compositionWidth = 1920; private _compositionHeight = 1080; private _probeInterval: ReturnType | null = null; private _lastUpdateMs = 0; /** * Parent-frame audio/video proxies, preloaded mirror copies of the iframe's * timed media. They exist as a fallback for environments that block iframe * `.play()` — mobile browsers require the user gesture to originate in the * same frame as the media element, and postMessage doesn't transfer user * activation (User Activation v2). The runtime inside the iframe signals * `media-autoplay-blocked` the first time a play() attempt rejects with * `NotAllowedError`; receiving that message flips `_audioOwner` to `parent` * and these proxies start driving audible output while the iframe keeps * advancing timed media silently for frame-accurate state. * * Preloading at iframe-load time (rather than lazily on promotion) keeps * the audible audio cut-in tight when the promotion fires mid-playback. */ private _parentMedia: Array<{ el: HTMLMediaElement; start: number; duration: number; /** * Count of consecutive steady-state samples in which the proxy's * `currentTime` was found drifted beyond `MIRROR_DRIFT_THRESHOLD_SECONDS`. * Reset on every in-threshold sample. `_mirrorParentMediaTime` only * issues a write once this passes `MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES`, * which absorbs single-sample jitter (e.g. one slow bridge tick) without * thrashing the media element with seeks. Forced calls (promotion, * media-added) bypass the gate and reset the counter. */ driftSamples: number; }> = []; /** * Who owns audible playback right now. * * - `runtime` (default): the iframe's runtime drives timed media; parent * proxies stay paused and silent. This is the correct path on desktop, * in same-frame embeds, and anywhere the iframe has user activation. * - `parent`: parent-frame proxies drive audible output; the iframe keeps * syncing timed media but at `muted = true` (orthogonal to author/user * volume settings). Entered only in response to an actual autoplay * rejection from the runtime — we don't guess device class. * * The transition is one-way per session; once autoplay is known to be * gated, there's no benefit to attempting the iframe path again. */ private _audioOwner: "runtime" | "parent" = "runtime"; /** * Watches the iframe document for sub-composition media added after * initial setup. Disconnected on iframe reload (fresh iframe = fresh * observer against the new document). */ private _mediaObserver?: MutationObserver; /** * One-shot latch for `playbackerror`. Without it, under parent ownership * where the parent frame itself lacks activation, every paused→playing * transition in the iframe state loop would re-fire `play()` (and its * rejection) on each proxy — spamming host subscribers through a whole * playback session. Mirrors the `mediaAutoplayBlockedPosted` latch on the * runtime side. Cleared on `_onIframeLoad` alongside the owner reset, so * a fresh composition gets a fresh shot at surfacing the error. */ private _playbackErrorPosted = false; constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); const sheet = getSharedSheet(); if (sheet) { this.shadow.adoptedStyleSheets = [sheet]; } else { const style = document.createElement("style"); style.textContent = PLAYER_STYLES; this.shadow.appendChild(style); } this.container = document.createElement("div"); this.container.className = "hfp-container"; this.iframe = document.createElement("iframe"); this.iframe.className = "hfp-iframe"; this.iframe.sandbox.add("allow-scripts", "allow-same-origin"); this.iframe.allow = "autoplay; fullscreen"; this.iframe.referrerPolicy = "no-referrer"; this.iframe.title = "HyperFrames Composition"; this.container.appendChild(this.iframe); this.shadow.appendChild(this.container); // Clicking the bare player surface toggles play/pause. // Ignore shadow-DOM control interactions so overlay clicks don't double-handle. this.addEventListener("click", (event) => { if (this._isControlsClick(event)) return; if (this._paused) this.play(); else this.pause(); }); this.resizeObserver = new ResizeObserver(() => this._updateScale()); this._onMessage = this._onMessage.bind(this); this._onIframeLoad = this._onIframeLoad.bind(this); } connectedCallback() { this.resizeObserver.observe(this); window.addEventListener("message", this._onMessage); this.iframe.addEventListener("load", this._onIframeLoad); if (this.hasAttribute("controls")) this._setupControls(); if (this.hasAttribute("poster")) this._setupPoster(); if (this.hasAttribute("audio-src")) this._setupParentAudioFromUrl(this.getAttribute("audio-src")!); // srcdoc wins over src per HTML spec when both are set; mirror both attributes // so the browser applies the standard precedence rules. if (this.hasAttribute("srcdoc")) this.iframe.srcdoc = this.getAttribute("srcdoc")!; if (this.hasAttribute("src")) this.iframe.src = this.getAttribute("src")!; } disconnectedCallback() { this.resizeObserver.disconnect(); window.removeEventListener("message", this._onMessage); this.iframe.removeEventListener("load", this._onIframeLoad); if (this._probeInterval) clearInterval(this._probeInterval); this._teardownMediaObserver(); this.controlsApi?.destroy(); for (const m of this._parentMedia) { m.el.pause(); m.el.src = ""; } this._parentMedia = []; } attributeChangedCallback(name: string, _old: string | null, val: string | null) { switch (name) { case "src": if (val) { this._ready = false; this.iframe.src = val; } break; case "srcdoc": // Distinguish removal (null) from empty-string ("") so callers can clear // srcdoc and let src take over. Always reset readiness; the iframe will // load a new document either way. this._ready = false; if (val !== null) this.iframe.srcdoc = val; else this.iframe.removeAttribute("srcdoc"); break; case "width": this._compositionWidth = parseInt(val || "1920", 10); this._updateScale(); break; case "height": this._compositionHeight = parseInt(val || "1080", 10); this._updateScale(); break; case "controls": if (val !== null) this._setupControls(); else { this.controlsApi?.destroy(); this.controlsApi = null; } break; case "poster": this._setupPoster(); break; case "playback-rate": { const rate = parseFloat(val || "1"); for (const m of this._parentMedia) m.el.playbackRate = rate; this._sendControl("set-playback-rate", { playbackRate: rate }); this.controlsApi?.updateSpeed(rate); this.dispatchEvent(new Event("ratechange")); break; } case "muted": for (const m of this._parentMedia) m.el.muted = val !== null; this._sendControl("set-muted", { muted: val !== null }); break; case "audio-src": if (val) this._setupParentAudioFromUrl(val); break; } } // ── Public API ── /** * Access the inner `