Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 174 additions & 6 deletions packages/player/src/parent-media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -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 });

Expand All @@ -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");
Expand All @@ -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 });
Expand Down Expand Up @@ -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();
});
});
Loading
Loading