mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix(player): replace or clear the audio-src proxy instead of stacking (#1409)
This commit is contained in:
@@ -165,6 +165,7 @@ class HyperframesPlayer extends HTMLElement {
|
||||
this.controlsApi?.destroy();
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
attributeChangedCallback(name: string, _old: string | null, val: string | null) {
|
||||
switch (name) {
|
||||
case "src":
|
||||
@@ -222,6 +223,7 @@ class HyperframesPlayer extends HTMLElement {
|
||||
}
|
||||
case "audio-src":
|
||||
if (val) this._media.setupFromUrl(val);
|
||||
else this._media.teardownUrlAudio();
|
||||
break;
|
||||
case SHADER_CAPTURE_SCALE_ATTR:
|
||||
case SHADER_LOADING_ATTR:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ParentMediaManager, type ProxyEntry } from "./parent-media";
|
||||
|
||||
function makeManager(overrides: Partial<{ isPaused: boolean; owner: "runtime" | "parent" }> = {}) {
|
||||
const mgr = new ParentMediaManager({
|
||||
dispatchEvent: () => {},
|
||||
getMuted: () => false,
|
||||
getVolume: () => 1,
|
||||
getPlaybackRate: () => 1,
|
||||
getCurrentTime: () => 0,
|
||||
isPaused: () => overrides.isPaused ?? true,
|
||||
});
|
||||
return mgr;
|
||||
}
|
||||
|
||||
describe("ParentMediaManager audio-src proxy lifecycle", () => {
|
||||
it("replaces the audio-src proxy instead of stacking a second one", () => {
|
||||
const mgr = makeManager();
|
||||
mgr.setupFromUrl("https://example.test/a.mp3");
|
||||
expect(mgr.entries).toHaveLength(1);
|
||||
|
||||
mgr.setupFromUrl("https://example.test/b.mp3");
|
||||
// The old proxy must be gone, not accumulated alongside the new one.
|
||||
expect(mgr.entries).toHaveLength(1);
|
||||
expect(mgr.entries[0].el.src).toBe("https://example.test/b.mp3");
|
||||
});
|
||||
|
||||
it("is a no-op when the same audio-src URL is set again", () => {
|
||||
const mgr = makeManager();
|
||||
mgr.setupFromUrl("https://example.test/a.mp3");
|
||||
const first = mgr.entries[0];
|
||||
|
||||
mgr.setupFromUrl("https://example.test/a.mp3");
|
||||
expect(mgr.entries).toHaveLength(1);
|
||||
// Same element reference — not torn down and rebuilt.
|
||||
expect(mgr.entries[0]).toBe(first);
|
||||
});
|
||||
|
||||
it("clears the audio-src proxy on teardownUrlAudio", () => {
|
||||
const mgr = makeManager();
|
||||
mgr.setupFromUrl("https://example.test/a.mp3");
|
||||
const el = mgr.entries[0].el;
|
||||
|
||||
mgr.teardownUrlAudio();
|
||||
expect(mgr.entries).toHaveLength(0);
|
||||
// The proxy's source is reset so it stops preloading.
|
||||
expect(el.src).not.toBe("https://example.test/a.mp3");
|
||||
});
|
||||
|
||||
it("teardownUrlAudio removes only the url proxy, leaving other entries", () => {
|
||||
const mgr = makeManager();
|
||||
// Simulate an iframe-adopted entry already in the pool.
|
||||
const adopted: ProxyEntry = {
|
||||
el: new Audio(),
|
||||
start: 0,
|
||||
duration: Infinity,
|
||||
driftSamples: 0,
|
||||
};
|
||||
adopted.el.src = "https://example.test/iframe-clip.mp4";
|
||||
mgr.entries.push(adopted);
|
||||
|
||||
mgr.setupFromUrl("https://example.test/a.mp3");
|
||||
expect(mgr.entries).toHaveLength(2);
|
||||
|
||||
mgr.teardownUrlAudio();
|
||||
expect(mgr.entries).toHaveLength(1);
|
||||
expect(mgr.entries[0]).toBe(adopted);
|
||||
});
|
||||
|
||||
it("teardownUrlAudio is safe to call with no audio-src set", () => {
|
||||
const mgr = makeManager();
|
||||
expect(() => mgr.teardownUrlAudio()).not.toThrow();
|
||||
expect(mgr.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not duplicate or hijack a clip the composition already owns", () => {
|
||||
const mgr = makeManager();
|
||||
// The composition already adopted a clip with this URL.
|
||||
const adopted: ProxyEntry = {
|
||||
el: new Audio(),
|
||||
start: 0,
|
||||
duration: Infinity,
|
||||
driftSamples: 0,
|
||||
};
|
||||
adopted.el.src = "https://example.test/shared.mp3";
|
||||
mgr.entries.push(adopted);
|
||||
|
||||
// Pointing audio-src at the same URL must not create a second proxy...
|
||||
mgr.setupFromUrl("https://example.test/shared.mp3");
|
||||
expect(mgr.entries).toHaveLength(1);
|
||||
expect(mgr.entries[0]).toBe(adopted);
|
||||
|
||||
// ...and removing audio-src must not tear down the composition's own clip
|
||||
// (teardown targets the tracked proxy by reference, not by URL match).
|
||||
mgr.teardownUrlAudio();
|
||||
expect(mgr.entries).toHaveLength(1);
|
||||
expect(mgr.entries[0]).toBe(adopted);
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,10 @@ export class ParentMediaManager {
|
||||
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;
|
||||
@@ -76,10 +80,6 @@ export class ParentMediaManager {
|
||||
return this._entries;
|
||||
}
|
||||
|
||||
get playbackErrorPosted(): boolean {
|
||||
return this._playbackErrorPosted;
|
||||
}
|
||||
|
||||
resetForIframeLoad(): void {
|
||||
this._playbackErrorPosted = false;
|
||||
const wasPromoted = this._audioOwner === "parent";
|
||||
@@ -102,6 +102,8 @@ export class ParentMediaManager {
|
||||
m.el.src = "";
|
||||
}
|
||||
this._entries = [];
|
||||
this._urlAudioEntry = null;
|
||||
this._urlAudioSrc = null;
|
||||
}
|
||||
|
||||
updateMuted(muted: boolean): void {
|
||||
@@ -212,9 +214,41 @@ export class ParentMediaManager {
|
||||
this._observeDynamicMedia(iframeDoc);
|
||||
}
|
||||
|
||||
/** Set up a single proxy from an explicit URL (the `audio-src` attribute path). */
|
||||
/**
|
||||
* 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 {
|
||||
this._createEntry(audioSrc, "audio", 0, Infinity);
|
||||
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 {
|
||||
@@ -258,16 +292,22 @@ export class ParentMediaManager {
|
||||
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 rawSrc =
|
||||
iframeEl.getAttribute("src") || iframeEl.querySelector("source")?.getAttribute("src");
|
||||
if (!rawSrc) return;
|
||||
const src = this._resolveIframeMediaSrc(iframeEl);
|
||||
if (!src) return;
|
||||
|
||||
const src = new URL(rawSrc, iframeEl.ownerDocument.baseURI).href;
|
||||
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);
|
||||
@@ -285,10 +325,8 @@ export class ParentMediaManager {
|
||||
}
|
||||
|
||||
private _detachIframeMedia(iframeEl: HTMLMediaElement): void {
|
||||
const rawSrc =
|
||||
iframeEl.getAttribute("src") || iframeEl.querySelector("source")?.getAttribute("src");
|
||||
if (!rawSrc) return;
|
||||
const src = new URL(rawSrc, iframeEl.ownerDocument.baseURI).href;
|
||||
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];
|
||||
@@ -301,6 +339,7 @@ export class ParentMediaManager {
|
||||
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") {
|
||||
|
||||
Reference in New Issue
Block a user