This commit is contained in:
Chen Yu
2026-08-31 01:07:05 +00:00
committed by GitHub
2 changed files with 302 additions and 25 deletions
+174 -6
View File
@@ -17,12 +17,14 @@ function makeFakeAudio(initiallyPaused: boolean): HTMLMediaElement {
return el;
}
function makeManager(overrides: Partial<{ isPaused: boolean; owner: "runtime" | "parent" }> = {}) {
function makeManager(
overrides: Partial<{ isPaused: boolean; playbackRate: number; volume: number }> = {},
) {
const mgr = new ParentMediaManager({
dispatchEvent: () => {},
getMuted: () => false,
getVolume: () => 1,
getPlaybackRate: () => 1,
getVolume: () => overrides.volume ?? 1,
getPlaybackRate: () => overrides.playbackRate ?? 1,
getCurrentTime: () => 0,
isPaused: () => overrides.isPaused ?? true,
});
@@ -90,7 +92,7 @@ describe("ParentMediaManager audio-src proxy lifecycle", () => {
});
it("pauses a proxy once the playhead passes the clip end (trimmed clip)", () => {
const mgr = makeManager({ owner: "parent", isPaused: false });
const mgr = makeManager({ isPaused: false });
const el = makeFakeAudio(false); // already playing within the clip
mgr.entries.push({ el, start: 0, duration: 5, driftSamples: 0 });
@@ -102,7 +104,7 @@ describe("ParentMediaManager audio-src proxy lifecycle", () => {
});
it("re-reads the source element's live data-duration so trims bound the proxy", () => {
const mgr = makeManager({ owner: "parent", isPaused: false });
const mgr = makeManager({ isPaused: false });
const source = new Audio();
source.setAttribute("data-start", "0");
source.setAttribute("data-duration", "30");
@@ -123,7 +125,7 @@ describe("ParentMediaManager audio-src proxy lifecycle", () => {
});
it("scrubAll plays in-window proxies at the playhead and pauses out-of-window ones", () => {
const mgr = makeManager({ owner: "parent" });
const mgr = makeManager();
const inWin = makeFakeAudio(true); // currently paused — scrub should start it
const outWin = makeFakeAudio(false); // currently playing, but outside its window
mgr.entries.push({ el: inWin, start: 0, duration: 5, driftSamples: 0 });
@@ -161,4 +163,170 @@ describe("ParentMediaManager audio-src proxy lifecycle", () => {
expect(mgr.entries).toHaveLength(1);
expect(mgr.entries[0]).toBe(adopted);
});
it("defers connected gain changes to the runtime's live effective volume", () => {
const mgr = makeManager({ volume: 0.5 });
const iframeDoc = document.implementation.createHTMLDocument();
const source = iframeDoc.createElement("audio");
source.src = "https://example.test/scored.mp3";
source.preload = "auto";
source.setAttribute("data-start", "0");
source.setAttribute("data-duration", "10");
source.setAttribute("data-volume", "0.12");
// The iframe runtime has already applied authored × global volume.
source.volume = 0.06;
iframeDoc.body.appendChild(source);
mgr.setupFromIframe(iframeDoc);
expect(mgr.entries).toHaveLength(1);
const proxy = mgr.entries[0].el;
expect(proxy.volume).toBeCloseTo(0.06);
// GSAP/runtime envelopes are already effective values on the source. The
// parent proxy copies them directly instead of multiplying global volume
// a second time.
source.volume = 0.018;
mgr.mirrorTime(1, { force: true });
expect(proxy.volume).toBeCloseTo(0.018);
// A non-zero player-volume update must not reconstruct the GSAP envelope
// from static data-volume. Keep the last effective gain until the iframe
// runtime publishes its next authoritative source.volume value.
mgr.updateVolume(0.25);
expect(proxy.volume).toBeCloseTo(0.018);
mgr.mirrorTime(1, { force: true });
expect(proxy.volume).toBeCloseTo(0.018);
// Once the iframe applies the new global volume, mirror it directly.
source.volume = 0.009;
mgr.mirrorTime(1, { force: true });
expect(proxy.volume).toBeCloseTo(0.009);
// Zero is safe to apply immediately. On unmute, remain silent until the
// runtime has re-established the current envelope at the new gain.
mgr.updateVolume(0);
expect(proxy.volume).toBe(0);
// A mirror can run before the iframe handles set-volume; stale non-zero
// source gain must not undo the immediate silence.
mgr.mirrorTime(1, { force: true });
expect(proxy.volume).toBe(0);
source.volume = 0;
mgr.mirrorTime(1, { force: true });
mgr.updateVolume(0.75);
expect(proxy.volume).toBe(0);
mgr.mirrorTime(1, { force: true });
expect(proxy.volume).toBe(0);
source.volume = 0.09;
mgr.mirrorTime(1, { force: true });
expect(proxy.volume).toBeCloseTo(0.09);
// A fully faded track stays fully faded when global volume changes.
source.volume = 0;
mgr.mirrorTime(1, { force: true });
mgr.updateVolume(0.5);
mgr.mirrorTime(1, { force: true });
expect(proxy.volume).toBe(0);
mgr.destroy();
});
it("applies live media offsets to mirror, seek, and scrub positioning", () => {
const mgr = makeManager();
const iframeDoc = document.implementation.createHTMLDocument();
const source = iframeDoc.createElement("audio");
source.src = "https://example.test/offset.mp3";
source.preload = "auto";
source.setAttribute("data-start", "5");
source.setAttribute("data-duration", "10");
source.setAttribute("data-media-start", "36.947");
iframeDoc.body.appendChild(source);
mgr.setupFromIframe(iframeDoc);
expect(mgr.entries).toHaveLength(1);
const proxy = mgr.entries[0].el;
mgr.mirrorTime(7, { force: true });
expect(proxy.currentTime).toBeCloseTo(38.947);
mgr.seekAll(8);
expect(proxy.currentTime).toBeCloseTo(39.947);
mgr.scrubAll(9);
expect(proxy.currentTime).toBeCloseTo(40.947);
// Both aliases are live, with data-playback-start taking precedence just
// as it does in the iframe runtime.
source.setAttribute("data-media-start", "10");
mgr.mirrorTime(7, { force: true });
expect(proxy.currentTime).toBeCloseTo(12);
source.setAttribute("data-playback-start", "4");
mgr.mirrorTime(7, { force: true });
expect(proxy.currentTime).toBeCloseTo(6);
mgr.destroy();
});
it("combines authored and global playback rates in positioning and playback", () => {
const state = { playbackRate: 0.5 };
const mgr = makeManager(state);
const iframeDoc = document.implementation.createHTMLDocument();
const source = iframeDoc.createElement("audio");
source.src = "https://example.test/rate.mp3";
source.preload = "auto";
source.setAttribute("data-start", "5");
source.setAttribute("data-duration", "10");
source.setAttribute("data-media-start", "3");
source.setAttribute("data-playback-rate", "2");
iframeDoc.body.appendChild(source);
mgr.setupFromIframe(iframeDoc);
expect(mgr.entries).toHaveLength(1);
const proxy = mgr.entries[0].el;
expect(proxy.playbackRate).toBe(1);
mgr.mirrorTime(7, { force: true });
expect(proxy.currentTime).toBe(7);
mgr.seekAll(8);
expect(proxy.currentTime).toBe(9);
mgr.scrubAll(9);
expect(proxy.currentTime).toBe(11);
state.playbackRate = 1.5;
mgr.updatePlaybackRate(state.playbackRate);
expect(proxy.playbackRate).toBe(3);
mgr.mirrorTime(7, { force: true });
expect(proxy.playbackRate).toBe(3);
// Live authored-rate edits affect both source-time mapping and effective
// proxy playback without requiring re-adoption.
source.setAttribute("data-playback-rate", "0.75");
mgr.mirrorTime(7, { force: true });
expect(proxy.currentTime).toBe(4.5);
expect(proxy.playbackRate).toBe(1.125);
mgr.destroy();
});
it("keeps URL-driven proxies at the global playback rate", () => {
const state = { playbackRate: 0.75, volume: 0.5 };
const mgr = makeManager(state);
mgr.setupFromUrl("https://example.test/url-rate.mp3");
expect(mgr.entries[0].el.playbackRate).toBe(0.75);
expect(mgr.entries[0].el.volume).toBe(0.5);
state.volume = 0.25;
mgr.updateVolume(state.volume);
expect(mgr.entries[0].el.volume).toBe(0.25);
state.playbackRate = 1.25;
mgr.updatePlaybackRate(state.playbackRate);
expect(mgr.entries[0].el.playbackRate).toBe(1.25);
mgr.mirrorTime(2, { force: true });
expect(mgr.entries[0].el.currentTime).toBe(2);
expect(mgr.entries[0].el.playbackRate).toBe(1.25);
mgr.destroy();
});
});
+128 -19
View File
@@ -26,14 +26,46 @@ const MIRROR_DRIFT_THRESHOLD_SECONDS = 0.05;
*/
const MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES = 2;
function clampVolume(volume: number): number {
if (!Number.isFinite(volume)) return 1;
return Math.max(0, Math.min(1, volume));
}
function readAuthorVolume(source: HTMLMediaElement): number {
const raw = Number.parseFloat(source.dataset.volume ?? "");
return clampVolume(Number.isFinite(raw) ? raw : 1);
}
function readMediaStart(source: HTMLMediaElement): number {
const raw = Number.parseFloat(source.dataset.playbackStart ?? source.dataset.mediaStart ?? "");
return Number.isFinite(raw) && raw >= 0 ? raw : 0;
}
function normalizePlaybackRate(rate: number): number {
return Number.isFinite(rate) && rate > 0 ? Math.max(0.1, Math.min(5, rate)) : 1;
}
function readAuthorPlaybackRate(source: HTMLMediaElement): number {
const authored = Number.parseFloat(source.dataset.playbackRate ?? "");
return normalizePlaybackRate(
Number.isFinite(authored) && authored > 0 ? authored : source.defaultPlaybackRate,
);
}
export interface ProxyEntry {
el: HTMLMediaElement;
start: number;
duration: number;
/** Offset into the source media at the start of the timeline clip. Defaults to 0. */
mediaStart?: number;
/** Per-track authored gain before global volume is applied. Defaults to 1. */
authorVolume?: number;
/** Per-clip source playback rate before global playback rate. Defaults to 1. */
authorPlaybackRate?: 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.
* Its timing, media offset, authored volume, and authored playback rate are
* re-read each tick so live edits stay reflected. Null for URL-driven proxies.
*/
source?: HTMLMediaElement | null;
/**
@@ -55,6 +87,7 @@ export class ParentMediaManager {
* replaced or cleared instead of accumulating on every attribute change. */
private _urlAudioEntry: ProxyEntry | null = null;
private _urlAudioSrc: string | null = null;
private _userVolume: number;
private readonly _dispatchEvent: (event: Event) => void;
private readonly _getMuted: () => boolean;
@@ -77,6 +110,7 @@ export class ParentMediaManager {
this._getPlaybackRate = opts.getPlaybackRate;
this._getCurrentTime = opts.getCurrentTime;
this._isPaused = opts.isPaused;
this._userVolume = clampVolume(opts.getVolume());
}
get audioOwner(): "runtime" | "parent" {
@@ -94,6 +128,7 @@ export class ParentMediaManager {
this._audioOwner = "runtime";
this.pauseAll();
this.teardownObserver();
this._userVolume = clampVolume(this._getVolume());
if (wasPromoted) {
this._dispatchEvent(
new CustomEvent("audioownershipchange", {
@@ -114,6 +149,7 @@ export class ParentMediaManager {
this._urlAudioSrc = null;
this._audioOwner = "runtime";
this._playbackErrorPosted = false;
this._userVolume = clampVolume(this._getVolume());
}
updateMuted(muted: boolean): void {
@@ -121,11 +157,29 @@ export class ParentMediaManager {
}
updateVolume(volume: number): void {
for (const m of this._entries) m.el.volume = volume;
const userVolume = clampVolume(volume);
for (const m of this._entries) {
this._refreshEntryContract(m);
if (m.source?.isConnected) {
// The iframe runtime owns GSAP volume envelopes and will publish the
// authoritative effective gain on `source.volume`. Never reconstruct
// that envelope from the static data-volume attribute. A zero player
// volume is safe to apply immediately; non-zero changes keep the last
// effective gain until the runtime's next state tick reaches us.
if (userVolume === 0) m.el.volume = 0;
} else {
m.el.volume = clampVolume((m.authorVolume ?? 1) * userVolume);
}
}
this._userVolume = userVolume;
}
updatePlaybackRate(rate: number): void {
for (const m of this._entries) m.el.playbackRate = rate;
const globalPlaybackRate = normalizePlaybackRate(rate);
for (const m of this._entries) {
this._refreshEntryContract(m);
m.el.playbackRate = (m.authorPlaybackRate ?? 1) * globalPlaybackRate;
}
}
private _playEntry(m: ProxyEntry): void {
@@ -137,15 +191,18 @@ export class ParentMediaManager {
// 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);
this._refreshEntryContract(m);
const relTime = this._getCurrentTime() - m.start;
if (relTime < 0 || relTime >= m.duration) return;
this._syncEntryVolume(m);
this._syncEntryPlaybackRate(m);
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 {
// Re-read the source clip's live contract so edits made after adoption are
// reflected by the proxy. URL-driven entries have no source and retain the
// defaults captured at creation.
private _refreshEntryContract(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
@@ -154,6 +211,36 @@ export class ParentMediaManager {
m.start = timing.start ?? 0;
m.duration =
timing.duration != null && timing.duration > 0 ? timing.duration : Number.POSITIVE_INFINITY;
m.mediaStart = readMediaStart(m.source);
m.authorVolume = readAuthorVolume(m.source);
m.authorPlaybackRate = readAuthorPlaybackRate(m.source);
}
// The runtime owns animation envelopes and writes their effective gain to
// the iframe media element. Copy that value directly: it already includes
// the player's global volume, so multiplying it again would attenuate the
// proxy twice. Entries without a live source use their authored ratio.
private _syncEntryVolume(m: ProxyEntry): void {
// Keep zero authoritative while the iframe's set-volume message is still
// in flight; otherwise a mirror tick could briefly restore stale audio.
if (this._userVolume === 0) {
m.el.volume = 0;
return;
}
if (m.source?.isConnected) {
m.el.volume = clampVolume(m.source.volume);
return;
}
m.el.volume = clampVolume((m.authorVolume ?? 1) * this._userVolume);
}
private _syncEntryPlaybackRate(m: ProxyEntry): void {
m.el.playbackRate =
(m.authorPlaybackRate ?? 1) * normalizePlaybackRate(this._getPlaybackRate());
}
private _sourceTime(m: ProxyEntry, relTime: number): number {
return (m.mediaStart ?? 0) + relTime * (m.authorPlaybackRate ?? 1);
}
// Pause the proxy outside its clip window; resume it on re-entry during
@@ -186,9 +273,13 @@ export class ParentMediaManager {
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);
this._refreshEntryContract(m);
const relTime = timeInSeconds - m.start;
if (relTime >= 0 && relTime < m.duration) m.el.currentTime = relTime;
if (relTime >= 0 && relTime < m.duration) {
m.el.currentTime = this._sourceTime(m, relTime);
this._syncEntryVolume(m);
this._syncEntryPlaybackRate(m);
}
}
}
@@ -201,10 +292,12 @@ export class ParentMediaManager {
// for output). Out-of-window proxies are paused.
scrubAll(timeInSeconds: number): void {
for (const m of this._entries) {
this._refreshEntryBounds(m);
this._refreshEntryContract(m);
const relTime = timeInSeconds - m.start;
if (relTime >= 0 && relTime < m.duration) {
m.el.currentTime = relTime;
m.el.currentTime = this._sourceTime(m, relTime);
this._syncEntryVolume(m);
this._syncEntryPlaybackRate(m);
this._playEntry(m);
} else if (!m.el.paused) {
m.el.pause();
@@ -221,13 +314,16 @@ export class ParentMediaManager {
mirrorTime(timelineSeconds: number, options?: { force?: boolean }): void {
const force = options?.force === true;
for (const m of this._entries) {
this._refreshEntryBounds(m);
this._refreshEntryContract(m);
const relTime = timelineSeconds - m.start;
if (!this._gateEntryPlayback(m, relTime)) continue;
if (Math.abs(m.el.currentTime - relTime) > MIRROR_DRIFT_THRESHOLD_SECONDS) {
this._syncEntryVolume(m);
this._syncEntryPlaybackRate(m);
const mediaTime = this._sourceTime(m, relTime);
if (Math.abs(m.el.currentTime - mediaTime) > MIRROR_DRIFT_THRESHOLD_SECONDS) {
m.driftSamples += 1;
if (force || m.driftSamples >= MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES) {
m.el.currentTime = relTime;
m.el.currentTime = mediaTime;
m.driftSamples = 0;
}
} else {
@@ -358,11 +454,24 @@ export class ParentMediaManager {
el.src = src;
el.load();
el.muted = this._getMuted();
el.volume = this._getVolume();
const rate = this._getPlaybackRate();
if (rate !== 1) el.playbackRate = rate;
const authorVolume = source ? readAuthorVolume(source) : 1;
const mediaStart = source ? readMediaStart(source) : 0;
const authorPlaybackRate = source ? readAuthorPlaybackRate(source) : 1;
el.volume = clampVolume(authorVolume * this._userVolume);
const effectivePlaybackRate =
authorPlaybackRate * normalizePlaybackRate(this._getPlaybackRate());
if (effectivePlaybackRate !== 1) el.playbackRate = effectivePlaybackRate;
const entry: ProxyEntry = { el, start, duration, driftSamples: 0, source };
const entry: ProxyEntry = {
el,
start,
duration,
mediaStart,
authorVolume,
authorPlaybackRate,
driftSamples: 0,
source,
};
this._entries.push(entry);
return entry;
}