diff --git a/packages/player/src/parent-media.test.ts b/packages/player/src/parent-media.test.ts index d894c7ac70..f1df5a72d7 100644 --- a/packages/player/src/parent-media.test.ts +++ b/packages/player/src/parent-media.test.ts @@ -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(); + }); }); diff --git a/packages/player/src/parent-media.ts b/packages/player/src/parent-media.ts index 7311461c93..8e91c4234c 100644 --- a/packages/player/src/parent-media.ts +++ b/packages/player/src/parent-media.ts @@ -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 entry: ProxyEntry = { el, start, duration, driftSamples: 0, source }; + 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, + mediaStart, + authorVolume, + authorPlaybackRate, + driftSamples: 0, + source, + }; this._entries.push(entry); return entry; }