Files
hyperframes/packages/player/src/hyperframes-player.ts

833 lines
29 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 {
SHADER_CAPTURE_SCALE_ATTR,
SHADER_LOADING_ATTR,
type ShaderLoadingMode,
getShaderCaptureScaleFromElement,
getShaderModeFromElement,
prepareSrcForElement,
prepareSrcdocForElement,
} 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 { runtimeProtocolMetadata } from "@hyperframes/core/runtime/protocol";
// 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;
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;
};
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",
SHADER_CAPTURE_SCALE_ATTR,
SHADER_LOADING_ATTR,
];
}
private shadow: ShadowRoot;
private container: HTMLDivElement;
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 _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;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
adoptShadowStyles(this.shadow, PLAYER_STYLES);
({ container: this.container, iframe: this.iframe } = createCompositionIframe());
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.dispatchEvent(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.dispatchEvent(new CustomEvent("timeupdate", { detail: { currentTime } }));
},
getLoop: () => this.loop,
restart: () => {
this.seek(0);
this.play();
},
onPaused: () => {
if (this._media.audioOwner === "parent") this._media.pauseAll();
this._paused = true;
this.controlsApi?.updatePlaying(false);
this.dispatchEvent(new Event("ended"));
},
onEnded: () => this.loop,
});
this.probe = new CompositionProbe(this.iframe, {
onReady: (result) => this._onProbeReady(result),
onError: (message) => this.dispatchEvent(new CustomEvent("error", { detail: { message } })),
});
this.addEventListener("click", (event) => {
if (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);
}
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.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.iframe.src = prepareSrcForElement(this, 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._sendControl("pause");
this._stopIframeMedia();
this.resizeObserver.disconnect();
window.removeEventListener("message", this._onMessage);
this.iframe.removeEventListener("load", this._onIframeLoad);
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._ready = false;
}
// fallow-ignore-next-line complexity
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
switch (name) {
case "src":
if (val) {
this._ready = false;
this.iframe.src = prepareSrcForElement(this, val);
}
break;
case "srcdoc":
this._ready = false;
if (val !== null) this.iframe.srcdoc = prepareSrcdocForElement(this, val);
else this.iframe.removeAttribute("srcdoc");
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._compositionWidth = readPositiveDimension(val) ?? 1920;
this._rescale();
break;
case "height":
this._compositionHeight = readPositiveDimension(val) ?? 1080;
this._rescale();
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.dispatchEvent(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._media.updateVolume(v);
this._sendControl("set-volume", { volume: v });
this.controlsApi?.updateVolume(v);
this.dispatchEvent(new Event("volumechange"));
break;
}
case "audio-src":
if (val) this._media.setupFromUrl(val);
else this._media.teardownUrlAudio();
break;
case SHADER_CAPTURE_SCALE_ATTR:
case SHADER_LOADING_ATTR:
this._reloadShaderOptions();
break;
}
}
/**
* 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.posterEl?.remove();
this.posterEl = null;
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();
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).
// Guards against firing ticks into an uninitialized iframe when play()
// is called before the probe has resolved.
if (this._ready && !this._directTimelineAdapter) {
this._startParentTickClock();
}
}
if (this._media.audioOwner === "parent") this._media.playAll();
this.controlsApi?.updatePlaying(true);
this.dispatchEvent(new Event("play"));
if (directTimelineStarted && this._directTimelineAdapter) {
this._directTimelineClock.start(
this._directTimelineAdapter,
() => this._currentTime,
() => this._duration,
() => this._paused,
);
}
}
pause() {
if (!this._tryDirectTimelinePause()) this._sendControl("pause");
this._directTimelineClock.stop();
this._stopParentTickClock();
if (this._media.audioOwner === "parent") this._media.pauseAll();
this._paused = true;
this.controlsApi?.updatePlaying(false);
this.dispatchEvent(new Event("pause"));
}
stopMedia() {
this._sendControl("stop-media");
this._stopIframeMedia();
this._media.stopAdoptedMedia();
}
seek(timeInSeconds: number) {
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 },
});
}
get currentTime() {
return this._currentTime;
}
set currentTime(t: number) {
this.seek(t);
}
get duration() {
return this._duration;
}
get paused() {
return this._paused;
}
get ready() {
return this._ready;
}
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 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._setIframeMediaMuted(val !== null);
this._sendControl("set-muted", { muted: val !== null });
this.controlsApi?.updateMuted(val !== null);
this.dispatchEvent(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 loop() {
return this.hasAttribute("loop");
}
set loop(l: boolean) {
if (l) this.setAttribute("loop", "");
else this.removeAttribute("loop");
}
private _sendControl(action: string, extra: Record<string, unknown> = {}) {
try {
this.iframe.contentWindow?.postMessage(
{
...extra,
source: "hf-parent",
type: "control",
action,
...runtimeProtocolMetadata(this._runtimeFps),
},
"*",
);
} catch {
/* cross-origin */
}
}
/**
* 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(),
});
}
private _reloadShaderOptions(): void {
if (getShaderModeFromElement(this) !== "player") this.shaderLoader.reset();
if (this.hasAttribute("srcdoc")) {
this.iframe.srcdoc = prepareSrcdocForElement(this, this.getAttribute("srcdoc") || "");
return;
}
if (this.hasAttribute("src")) {
this.iframe.src = prepareSrcForElement(this, this.getAttribute("src") || "");
}
}
private _trySyncSeek(timeInSeconds: number): boolean {
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 tl = this._directTimelineAdapter || this.probe.resolveDirectTimelineAdapter();
if (!tl) return false;
try {
fn(tl);
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());
}
/**
* 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's own rAF loop still runs — ticking GSAP twice per frame is
* harmless because seekTimelineAndAdapters is idempotent.
*/
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) {
handleRuntimeMessage(e, this.iframe.contentWindow, {
getPlaybackState: () => ({
currentTime: this._currentTime,
duration: this._duration,
paused: this._paused,
lastUpdateMs: this._lastUpdateMs,
}),
setPlaybackState: ({ currentTime, duration, paused, lastUpdateMs }) => {
this._currentTime = currentTime;
this._duration = duration;
this._paused = paused;
this._lastUpdateMs = lastUpdateMs;
},
getShaderLoadingMode: () => getShaderModeFromElement(this),
shaderLoader: this.shaderLoader,
setCompositionSize: (w, h) => {
this._compositionWidth = w;
this._compositionHeight = h;
this._rescale();
},
sendControl: (action, extra) => this._sendControl(action, extra),
getIframeDoc: () => this.iframe.contentDocument,
onRuntimeReady: () => this._replayBridgeState(),
onRuntimeTimelineReady: (duration) => this._onRuntimeTimelineReady(duration),
setRuntimeFps: (fps) => {
this._runtimeFps = fps;
},
shouldPromoteMediaAutoplayFallback: () => !this._isSlideshowPlayer(),
setScenes: (scenes) => {
this._scenes = scenes;
this.dispatchEvent(new CustomEvent("scenes", { detail: { scenes } }));
},
updateControlsTime: (t, d) => this.controlsApi?.updateTime(t, d),
updateControlsPlaying: (p) => this.controlsApi?.updatePlaying(p),
dispatchEvent: (ev) => this.dispatchEvent(ev),
seek: (t) => this.seek(t),
play: () => this.play(),
getLoop: () => this.loop,
media: this._media,
});
}
private _onRuntimeTimelineReady(duration: number) {
if (this._ready) return;
this.probe.stop();
this._duration = duration;
this._directTimelineAdapter = null;
this._ready = true;
this.controlsApi?.updateTime(this._currentTime, duration);
this.dispatchEvent(new CustomEvent("ready", { detail: { duration } }));
// stage-size may not have arrived yet (race in the runtime's postTimeline
// resolving the root's data-width/data-height on first paint) — rescale
// here too so cross-origin compositions never stay unscaled/untransformed.
this._rescale();
const doc = this._getSameOriginIframeDocument();
if (doc) this._media.setupFromIframe(doc);
this._replayBridgeState();
this._setIframeMediaMuted(this.muted);
if (this.hasAttribute("autoplay")) this.play();
}
private _onProbeReady({ duration, adapter, compositionSize }: ProbeResult) {
this._duration = duration;
this._directTimelineAdapter = adapter.kind === "direct-timeline" ? adapter.timeline : null;
this._ready = true;
this.controlsApi?.updateTime(0, duration);
this.dispatchEvent(new CustomEvent("ready", { detail: { duration } }));
if (compositionSize) {
this._compositionWidth = compositionSize.width;
this._compositionHeight = compositionSize.height;
this._rescale();
}
try {
const doc = this.iframe.contentDocument;
if (doc) this._media.setupFromIframe(doc);
} catch {
/* cross-origin */
}
this._setIframeMediaMuted(this.muted);
if (this.hasAttribute("autoplay")) this.play();
}
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._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() {
this._ready = false;
this._directTimelineAdapter = null;
this._directTimelineClock.stop();
this._stopParentTickClock();
this.shaderLoader.reset();
this._media.resetForIframeLoad();
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(),
);
}
// 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";