mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
477 lines
17 KiB
TypeScript
477 lines
17 KiB
TypeScript
/**
|
||
* Parent-frame media proxy subsystem.
|
||
*
|
||
* Maintains mirror copies of the iframe's timed `<audio>`/`<video>` elements
|
||
* in the parent frame so that mobile browsers — which gate `el.play()` on user
|
||
* activation in the *same* frame — can still produce audible output via proxies
|
||
* the parent controls directly.
|
||
*
|
||
* See the class-level JSDoc on `HyperframesPlayer` for the full ownership model.
|
||
*/
|
||
|
||
import { selectMediaObserverTargets } from "./mediaObserverScope.js";
|
||
import { isRealmElement, isRealmHtmlMediaElement } from "./media-element-guards.js";
|
||
|
||
/** Minimum absolute drift before a currentTime correction is attempted. */
|
||
const MIRROR_DRIFT_THRESHOLD_SECONDS = 0.05;
|
||
|
||
/**
|
||
* How many *consecutive* over-threshold samples are required before issuing a
|
||
* `currentTime` write. Absorbs single-sample jitter (GC pause, slow bridge
|
||
* tick) without thrashing. Forced calls bypass this gate.
|
||
*
|
||
* Worst-case correction latency ≈ this × bridgeMaxPostIntervalMs (80 ms in
|
||
* core/runtime/state.ts) = 160 ms — well under human A/V re-sync tolerance.
|
||
*/
|
||
const MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES = 2;
|
||
|
||
export interface ProxyEntry {
|
||
el: HTMLMediaElement;
|
||
start: number;
|
||
duration: number;
|
||
/**
|
||
* The iframe media element this proxy mirrors, when adopted from the DOM.
|
||
* Its `data-start`/`data-duration` are re-read each tick so live timeline
|
||
* edits (trim/move) bound the proxy correctly. Null for URL-driven proxies.
|
||
*/
|
||
source?: HTMLMediaElement | null;
|
||
/**
|
||
* 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. A write is only issued once this
|
||
* reaches `MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES`, absorbing
|
||
* single-sample jitter without thrashing.
|
||
*/
|
||
driftSamples: number;
|
||
}
|
||
|
||
export class ParentMediaManager {
|
||
private _entries: ProxyEntry[] = [];
|
||
private _mediaObserver?: MutationObserver;
|
||
private _playbackErrorPosted = false;
|
||
private _audioOwner: "runtime" | "parent" = "runtime";
|
||
/** The proxy created from the `audio-src` attribute, tracked so it can be
|
||
* replaced or cleared instead of accumulating on every attribute change. */
|
||
private _urlAudioEntry: ProxyEntry | null = null;
|
||
private _urlAudioSrc: string | null = null;
|
||
|
||
private readonly _dispatchEvent: (event: Event) => void;
|
||
private readonly _getMuted: () => boolean;
|
||
private readonly _getVolume: () => number;
|
||
private readonly _getPlaybackRate: () => number;
|
||
private readonly _getCurrentTime: () => number;
|
||
private readonly _isPaused: () => boolean;
|
||
|
||
constructor(opts: {
|
||
dispatchEvent: (event: Event) => void;
|
||
getMuted: () => boolean;
|
||
getVolume: () => number;
|
||
getPlaybackRate: () => number;
|
||
getCurrentTime: () => number;
|
||
isPaused: () => boolean;
|
||
}) {
|
||
this._dispatchEvent = opts.dispatchEvent;
|
||
this._getMuted = opts.getMuted;
|
||
this._getVolume = opts.getVolume;
|
||
this._getPlaybackRate = opts.getPlaybackRate;
|
||
this._getCurrentTime = opts.getCurrentTime;
|
||
this._isPaused = opts.isPaused;
|
||
}
|
||
|
||
get audioOwner(): "runtime" | "parent" {
|
||
return this._audioOwner;
|
||
}
|
||
|
||
/** Exposed for test instrumentation only — do not use in production code. */
|
||
get entries(): ProxyEntry[] {
|
||
return this._entries;
|
||
}
|
||
|
||
resetForIframeLoad(): void {
|
||
this._playbackErrorPosted = false;
|
||
const wasPromoted = this._audioOwner === "parent";
|
||
this._audioOwner = "runtime";
|
||
this.pauseAll();
|
||
this.teardownObserver();
|
||
if (wasPromoted) {
|
||
this._dispatchEvent(
|
||
new CustomEvent("audioownershipchange", {
|
||
detail: { owner: "runtime", reason: "iframe-reload" },
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
|
||
destroy(): void {
|
||
this.teardownObserver();
|
||
for (const m of this._entries) {
|
||
m.el.pause();
|
||
m.el.src = "";
|
||
}
|
||
this._entries = [];
|
||
this._urlAudioEntry = null;
|
||
this._urlAudioSrc = null;
|
||
this._audioOwner = "runtime";
|
||
this._playbackErrorPosted = false;
|
||
}
|
||
|
||
updateMuted(muted: boolean): void {
|
||
for (const m of this._entries) m.el.muted = muted;
|
||
}
|
||
|
||
updateVolume(volume: number): void {
|
||
for (const m of this._entries) m.el.volume = volume;
|
||
}
|
||
|
||
updatePlaybackRate(rate: number): void {
|
||
for (const m of this._entries) m.el.playbackRate = rate;
|
||
}
|
||
|
||
private _playEntry(m: ProxyEntry): void {
|
||
if (!m.el.src) return;
|
||
m.el.play().catch((err: unknown) => this._reportPlaybackError(err));
|
||
}
|
||
|
||
// Play only if the current playhead is inside the clip's (live) window, so
|
||
// bulk starts (playAll / adopt) don't blip audio for clips outside their
|
||
// window until the next mirrorTime tick gates them off.
|
||
private _playEntryIfActive(m: ProxyEntry): void {
|
||
this._refreshEntryBounds(m);
|
||
const relTime = this._getCurrentTime() - m.start;
|
||
if (relTime < 0 || relTime >= m.duration) return;
|
||
this._playEntry(m);
|
||
}
|
||
|
||
// Re-read the source clip's live timing so trims/moves bound the proxy
|
||
// (adopt-time values go stale when the timeline is edited).
|
||
private _refreshEntryBounds(m: ProxyEntry): void {
|
||
if (!m.source?.isConnected) return;
|
||
// Guard against a malformed (non-numeric) attribute parsing to NaN: an NaN
|
||
// duration makes every `relTime >= m.duration` window check false, so the
|
||
// gate never closes and the proxy plays past its clip end.
|
||
const start = parseFloat(m.source.getAttribute("data-start") || "0");
|
||
m.start = Number.isFinite(start) ? start : 0;
|
||
const duration = parseFloat(m.source.getAttribute("data-duration") || "");
|
||
m.duration = Number.isFinite(duration) && duration > 0 ? duration : Number.POSITIVE_INFINITY;
|
||
}
|
||
|
||
// Pause the proxy outside its clip window; resume it on re-entry during
|
||
// parent-owned playback. Returns whether the proxy is within the window.
|
||
private _gateEntryPlayback(m: ProxyEntry, relTime: number): boolean {
|
||
if (relTime < 0 || relTime >= m.duration) {
|
||
if (!m.el.paused) m.el.pause();
|
||
m.driftSamples = 0;
|
||
return false;
|
||
}
|
||
if (this._audioOwner === "parent" && !this._isPaused() && m.el.paused) this._playEntry(m);
|
||
return true;
|
||
}
|
||
|
||
playAll(): void {
|
||
for (const m of this._entries) this._playEntryIfActive(m);
|
||
}
|
||
|
||
pauseAll(): void {
|
||
for (const m of this._entries) m.el.pause();
|
||
}
|
||
|
||
stopAdoptedMedia(): void {
|
||
for (const m of this._entries) {
|
||
if (m.source) m.el.pause();
|
||
}
|
||
}
|
||
|
||
seekAll(timeInSeconds: number): void {
|
||
for (const m of this._entries) {
|
||
// Re-read live bounds so a trim/move just before a paused scrub gates and
|
||
// positions against the current clip window, not the adopt-time one.
|
||
this._refreshEntryBounds(m);
|
||
const relTime = timeInSeconds - m.start;
|
||
if (relTime >= 0 && relTime < m.duration) m.el.currentTime = relTime;
|
||
}
|
||
}
|
||
|
||
// Audible scrub: position every proxy at `timeInSeconds` AND play the ones whose
|
||
// clip window covers it, so the viewer hears the track under the playhead while
|
||
// dragging the scrubber (vs seekAll, which positions silently). Each drag move
|
||
// re-seeks to the new position, so playback restarts from the playhead and you
|
||
// hear the audio you're scrubbing over. The caller settles back to silence on
|
||
// scrub end (a normal pause+seekAll). Muted proxies stay silent (play() is a no-op
|
||
// for output). Out-of-window proxies are paused.
|
||
scrubAll(timeInSeconds: number): void {
|
||
for (const m of this._entries) {
|
||
this._refreshEntryBounds(m);
|
||
const relTime = timeInSeconds - m.start;
|
||
if (relTime >= 0 && relTime < m.duration) {
|
||
m.el.currentTime = relTime;
|
||
this._playEntry(m);
|
||
} else if (!m.el.paused) {
|
||
m.el.pause();
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Mirror parent-proxy `currentTime` to the iframe timeline, with optional
|
||
* jitter-coalescing. Pass `{ force: true }` for alignment moments (ownership
|
||
* promotion, new proxy initialization) where drift must be corrected
|
||
* immediately.
|
||
*/
|
||
mirrorTime(timelineSeconds: number, options?: { force?: boolean }): void {
|
||
const force = options?.force === true;
|
||
for (const m of this._entries) {
|
||
this._refreshEntryBounds(m);
|
||
const relTime = timelineSeconds - m.start;
|
||
if (!this._gateEntryPlayback(m, relTime)) continue;
|
||
if (Math.abs(m.el.currentTime - relTime) > MIRROR_DRIFT_THRESHOLD_SECONDS) {
|
||
m.driftSamples += 1;
|
||
if (force || m.driftSamples >= MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES) {
|
||
m.el.currentTime = relTime;
|
||
m.driftSamples = 0;
|
||
}
|
||
} else {
|
||
m.driftSamples = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Take ownership of audible playback in response to the runtime's
|
||
* `media-autoplay-blocked` signal. Idempotent.
|
||
*
|
||
* The caller is responsible for muting the iframe's own media output via the
|
||
* postMessage bridge (`set-media-output-muted`) after calling this.
|
||
*/
|
||
/**
|
||
* Take ownership of audible playback. Idempotent. The `onMirror` callback
|
||
* is called with the current timeline time and `{ force: true }` so the
|
||
* caller's mirror implementation runs (enabling test spies on the player
|
||
* to fire). If omitted, `mirrorTime` is called directly.
|
||
*/
|
||
promoteToParentProxy(
|
||
iframeDoc: Document | null,
|
||
onMirror?: (t: number, opts: { force: boolean }) => void,
|
||
): void {
|
||
if (this._audioOwner === "parent") return;
|
||
this._audioOwner = "parent";
|
||
|
||
// Synchronously mute iframe media to close the race window.
|
||
if (iframeDoc) {
|
||
for (const el of iframeDoc.querySelectorAll("video, audio")) {
|
||
if (isRealmHtmlMediaElement(el)) el.muted = true;
|
||
}
|
||
}
|
||
|
||
// One-shot alignment — bypass jitter-coalescing gate.
|
||
const t = this._getCurrentTime();
|
||
if (onMirror) onMirror(t, { force: true });
|
||
else this.mirrorTime(t, { force: true });
|
||
if (!this._isPaused()) this.playAll();
|
||
|
||
this._dispatchEvent(
|
||
new CustomEvent("audioownershipchange", {
|
||
detail: { owner: "parent", reason: "autoplay-blocked" },
|
||
}),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Set up proxies for all timed media currently in the iframe document, then
|
||
* install a MutationObserver for media added later (sub-composition activation).
|
||
*/
|
||
setupFromIframe(iframeDoc: Document): void {
|
||
const mediaEls = iframeDoc.querySelectorAll("audio[data-start], video[data-start]");
|
||
for (const iframeEl of mediaEls) {
|
||
if (isRealmHtmlMediaElement(iframeEl)) this._adoptIframeMedia(iframeEl);
|
||
}
|
||
this._observeDynamicMedia(iframeDoc);
|
||
}
|
||
|
||
/**
|
||
* Set (or replace) the parent-frame audio proxy driven by the `audio-src`
|
||
* attribute. Re-setting with a different URL tears down the previous proxy
|
||
* first, so changing `audio-src` swaps the track instead of stacking a
|
||
* second one that keeps preloading and plays in parallel.
|
||
*/
|
||
setupFromUrl(audioSrc: string): void {
|
||
if (this._urlAudioSrc === audioSrc && this._urlAudioEntry) return;
|
||
this.teardownUrlAudio();
|
||
const entry = this._createEntry(audioSrc, "audio", 0, Infinity);
|
||
// `_createEntry` returns null when a proxy for this URL already exists
|
||
// (e.g. the composition already adopted the same media). In that case we do
|
||
// not own a proxy, so leave the tracking cleared rather than recording a
|
||
// src with no entry — otherwise teardown would target nothing and the
|
||
// no-op guard would never engage.
|
||
this._urlAudioEntry = entry;
|
||
this._urlAudioSrc = entry ? audioSrc : null;
|
||
// If the parent already owns playback, bring the fresh proxy online so a
|
||
// mid-playback swap is not silent until the next play tick.
|
||
if (entry && this._audioOwner === "parent" && !this._isPaused()) {
|
||
this.mirrorTime(this._getCurrentTime(), { force: true });
|
||
this.playAll();
|
||
}
|
||
}
|
||
|
||
/** Tear down the `audio-src` proxy (used when the attribute is removed). */
|
||
teardownUrlAudio(): void {
|
||
const entry = this._urlAudioEntry;
|
||
this._urlAudioEntry = null;
|
||
this._urlAudioSrc = null;
|
||
if (!entry) return;
|
||
entry.el.pause();
|
||
entry.el.src = "";
|
||
const idx = this._entries.indexOf(entry);
|
||
if (idx !== -1) this._entries.splice(idx, 1);
|
||
}
|
||
|
||
teardownObserver(): void {
|
||
this._mediaObserver?.disconnect();
|
||
this._mediaObserver = undefined;
|
||
}
|
||
|
||
// ── Private ──────────────────────────────────────────────────────────────
|
||
|
||
private _reportPlaybackError(err: unknown): void {
|
||
if (this._playbackErrorPosted) return;
|
||
this._playbackErrorPosted = true;
|
||
this._dispatchEvent(
|
||
new CustomEvent("playbackerror", { detail: { source: "parent-proxy", error: err } }),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Create a parent-frame media element and start preloading it. Returns the
|
||
* new entry, or `null` if a proxy for this src already exists (dedup).
|
||
*/
|
||
private _createEntry(
|
||
src: string,
|
||
tag: "audio" | "video",
|
||
start: number,
|
||
duration: number,
|
||
source?: HTMLMediaElement | null,
|
||
): ProxyEntry | null {
|
||
if (this._entries.some((m) => m.el.src === src)) return null;
|
||
|
||
const el = tag === "video" ? document.createElement("video") : new Audio();
|
||
el.preload = "auto";
|
||
el.src = src;
|
||
el.load();
|
||
el.muted = this._getMuted();
|
||
el.volume = this._getVolume();
|
||
const rate = this._getPlaybackRate();
|
||
if (rate !== 1) el.playbackRate = rate;
|
||
|
||
const entry: ProxyEntry = { el, start, duration, driftSamples: 0, source };
|
||
this._entries.push(entry);
|
||
return entry;
|
||
}
|
||
|
||
/** Resolve an iframe media element's source to an absolute URL, or null. */
|
||
private _resolveIframeMediaSrc(iframeEl: HTMLMediaElement): string | null {
|
||
const rawSrc =
|
||
iframeEl.getAttribute("src") || iframeEl.querySelector("source")?.getAttribute("src");
|
||
return rawSrc ? new URL(rawSrc, iframeEl.ownerDocument.baseURI).href : null;
|
||
}
|
||
|
||
// fallow-ignore-next-line complexity
|
||
private _adoptIframeMedia(iframeEl: HTMLMediaElement): void {
|
||
// Skip elements the preloader has demoted — the observer will re-trigger
|
||
// when the preload attribute is promoted to "auto".
|
||
if (iframeEl.preload === "metadata" || iframeEl.preload === "none") return;
|
||
|
||
const src = this._resolveIframeMediaSrc(iframeEl);
|
||
if (!src) return;
|
||
|
||
const start = parseFloat(iframeEl.getAttribute("data-start") || "0");
|
||
const duration = parseFloat(iframeEl.getAttribute("data-duration") || "Infinity");
|
||
const tag = iframeEl.tagName === "VIDEO" ? ("video" as const) : ("audio" as const);
|
||
|
||
const created = this._createEntry(src, tag, start, duration, iframeEl);
|
||
|
||
// If already under parent ownership and playing, the new proxy must catch
|
||
// up immediately — bypass the jitter-coalescing gate.
|
||
if (created && this._audioOwner === "parent") {
|
||
this.mirrorTime(this._getCurrentTime(), { force: true });
|
||
if (!this._isPaused()) this._playEntryIfActive(created);
|
||
}
|
||
}
|
||
|
||
private _detachIframeMedia(iframeEl: HTMLMediaElement): void {
|
||
const src = this._resolveIframeMediaSrc(iframeEl);
|
||
if (!src) return;
|
||
const idx = this._entries.findIndex((m) => m.el.src === src);
|
||
if (idx === -1) return;
|
||
const entry = this._entries[idx];
|
||
entry.el.pause();
|
||
entry.el.src = "";
|
||
this._entries.splice(idx, 1);
|
||
}
|
||
|
||
private _observeDynamicMedia(doc: Document): void {
|
||
this.teardownObserver();
|
||
if (typeof MutationObserver === "undefined" || !doc.body) return;
|
||
|
||
// fallow-ignore-next-line complexity
|
||
const obs = new MutationObserver((mutations) => {
|
||
for (const m of mutations) {
|
||
if (m.type === "attributes" && m.attributeName === "preload") {
|
||
const target = m.target;
|
||
if (
|
||
isRealmHtmlMediaElement(target) &&
|
||
target.matches("audio[data-start], video[data-start]") &&
|
||
target.preload === "auto"
|
||
) {
|
||
this._adoptIframeMedia(target);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
for (const added of m.addedNodes) {
|
||
if (!isRealmElement(added)) continue;
|
||
const candidates: HTMLMediaElement[] = [];
|
||
if (
|
||
isRealmHtmlMediaElement(added) &&
|
||
added.matches("audio[data-start], video[data-start]")
|
||
) {
|
||
candidates.push(added);
|
||
}
|
||
const inside = added.querySelectorAll("audio[data-start], video[data-start]");
|
||
for (const el of inside) {
|
||
if (isRealmHtmlMediaElement(el)) candidates.push(el);
|
||
}
|
||
for (const el of candidates) this._adoptIframeMedia(el);
|
||
}
|
||
|
||
for (const removed of m.removedNodes) {
|
||
if (!isRealmElement(removed)) continue;
|
||
const dropped: HTMLMediaElement[] = [];
|
||
if (
|
||
isRealmHtmlMediaElement(removed) &&
|
||
removed.matches("audio[data-start], video[data-start]")
|
||
) {
|
||
dropped.push(removed);
|
||
}
|
||
const inside = removed.querySelectorAll("audio[data-start], video[data-start]");
|
||
for (const el of inside) {
|
||
if (isRealmHtmlMediaElement(el)) dropped.push(el);
|
||
}
|
||
for (const el of dropped) this._detachIframeMedia(el);
|
||
}
|
||
}
|
||
});
|
||
|
||
const observeOpts: MutationObserverInit = {
|
||
childList: true,
|
||
subtree: true,
|
||
attributes: true,
|
||
attributeFilter: ["preload"],
|
||
};
|
||
|
||
const targets = selectMediaObserverTargets(doc);
|
||
for (const target of targets) {
|
||
obs.observe(target, observeOpts);
|
||
}
|
||
this._mediaObserver = obs;
|
||
}
|
||
}
|