Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1190,6 +1190,7 @@ export function initSandboxRuntimeModular(): void {
playing: state.isPlaying,
playbackRate: state.playbackRate,
outputMuted: state.mediaOutputMuted,
userMuted: state.bridgeMuted,
onAutoplayBlocked: () => {
if (state.mediaAutoplayBlockedPosted) return;
state.mediaAutoplayBlockedPosted = true;
Expand Down
117 changes: 117 additions & 0 deletions packages/core/src/runtime/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,4 +458,121 @@ describe("syncRuntimeMedia", () => {
await Promise.resolve();
expect(onAutoplayBlocked).not.toHaveBeenCalled();
});

it("asserts muted=true every tick while userMuted is set", () => {
// Mirror of the `outputMuted` test — user preference must be sticky
// too. A sub-composition that activates after the user mutes should
// inherit the silence, not briefly play at author volume before the
// next bridge message lands.
const clip = createMockClip({ start: 0, end: 10, volume: 1 });
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
Object.defineProperty(clip.el, "muted", { value: false, writable: true });
syncRuntimeMedia({
clips: [clip],
timeSeconds: 5,
playing: true,
playbackRate: 1,
userMuted: true,
});
expect(clip.el.muted).toBe(true);
});

it("fires onAutoplayBlocked for every rejected play (caller owns the latch)", async () => {
// media.ts is intentionally memoryless — each NotAllowedError rejection
// invokes the callback. The init.ts caller wraps with
// `mediaAutoplayBlockedPosted` so the outbound message is posted at most
// once per session. This test pins down the contract (fires always) so
// a future refactor can't quietly add deduplication here and break the
// caller's latching logic.
const clip = createMockClip({ start: 0, end: 10 });
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
const rejection = Object.assign(new Error("blocked"), { name: "NotAllowedError" });
clip.el.play = vi.fn(() => Promise.reject(rejection));
const onAutoplayBlocked = vi.fn();

// Simulate two ticks — between them `playRequested` clears so play() runs
// again and rejects again.
syncRuntimeMedia({
clips: [clip],
timeSeconds: 5,
playing: true,
playbackRate: 1,
onAutoplayBlocked,
});
await Promise.resolve();
await Promise.resolve();
syncRuntimeMedia({
clips: [clip],
timeSeconds: 5.05,
playing: true,
playbackRate: 1,
onAutoplayBlocked,
});
await Promise.resolve();
await Promise.resolve();

// No latch inside media.ts — two rejections, two callback invocations.
// The caller's latch is what prevents a second outbound message.
expect(onAutoplayBlocked).toHaveBeenCalledTimes(2);
});

it("caller-side latch pattern posts once across many rejections", async () => {
// Mirrors what init.ts does: the onAutoplayBlocked wrapper checks and
// sets a boolean flag so the outbound post fires exactly once even if
// the raw callback fires many times. Regression guard for the latch
// wiring in the init.ts handler.
const clip = createMockClip({ start: 0, end: 10 });
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
const rejection = Object.assign(new Error("blocked"), { name: "NotAllowedError" });
clip.el.play = vi.fn(() => Promise.reject(rejection));

let posted = 0;
const state = { latched: false };
const wrapped = () => {
if (state.latched) return;
state.latched = true;
posted += 1;
};

for (let i = 0; i < 5; i++) {
syncRuntimeMedia({
clips: [clip],
timeSeconds: 5 + i * 0.05,
playing: true,
playbackRate: 1,
onAutoplayBlocked: wrapped,
});
await Promise.resolve();
await Promise.resolve();
}

expect(posted).toBe(1);
});

it("mutes when either outputMuted OR userMuted is true (OR invariant)", () => {
// Explicit validation of the combined-flag contract: setting one to
// false while the other is true must keep the element muted.
const clip = createMockClip({ start: 0, end: 10, volume: 1 });
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
Object.defineProperty(clip.el, "muted", { value: false, writable: true });
syncRuntimeMedia({
clips: [clip],
timeSeconds: 5,
playing: true,
playbackRate: 1,
outputMuted: false,
userMuted: true,
});
expect(clip.el.muted).toBe(true);
Object.defineProperty(clip.el, "muted", { value: false, writable: true });
syncRuntimeMedia({
clips: [clip],
timeSeconds: 5,
playing: true,
playbackRate: 1,
outputMuted: true,
userMuted: false,
});
expect(clip.el.muted).toBe(true);
});
});
20 changes: 14 additions & 6 deletions packages/core/src/runtime/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,20 +93,28 @@ export function syncRuntimeMedia(params: {
playing: boolean;
playbackRate: number;
/**
* When `true`, assert `el.muted = true` on every active media element on
* every tick. Sticky against newly-discovered media (sub-composition
* activation, dynamic DOM) so the parent-frame audio-owner invariant holds.
* `false` is a no-op — we don't un-mute, because other code paths
* (`<audio muted>` author intent, `onSetMuted`) own the un-mute decision.
* Parent-frame audio-owner has taken over audible playback. Assert
* `el.muted = true` on every active media element per tick so that any
* sub-composition media inserted mid-playback inherits the silence.
*/
outputMuted?: boolean;
/**
* User's explicit mute preference (set via `onSetMuted`). Symmetric to
* `outputMuted` — also asserted per tick — so a sub-composition that
* activates after the user mutes doesn't briefly play at author volume
* before the next bridge message lands.
*/
userMuted?: boolean;
/**
* Invoked at most once when a media element's `play()` promise rejects with
* `NotAllowedError`. The caller is expected to latch and post a single
* outbound message; further invocations are suppressed by the caller.
*/
onAutoplayBlocked?: () => void;
}): void {
// Either flag silences output. Combined up front so the per-clip loop is
// a single branch instead of two.
const shouldMute = !!(params.outputMuted || params.userMuted);
for (const clip of params.clips) {
const { el } = clip;
if (!el.isConnected) continue;
Expand All @@ -122,7 +130,7 @@ export function syncRuntimeMedia(params: {
}
}
if (clip.volume != null) el.volume = clip.volume;
if (params.outputMuted) el.muted = true;
if (shouldMute) el.muted = true;
try {
// Per-element rate × global transport rate
el.playbackRate = clip.playbackRate * params.playbackRate;
Expand Down
83 changes: 83 additions & 0 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,89 @@ describe("HyperframesPlayer parent-frame media", () => {
expect(player._audioOwner).toBe("parent");
});

it("dispatches audioownershipchange on promotion", () => {
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
document.body.appendChild(player);

const events: Array<{ owner: string; reason: string }> = [];
player.addEventListener("audioownershipchange", (e: Event) => {
const detail = (e as CustomEvent<{ owner: string; reason: string }>).detail;
events.push(detail);
});

player._promoteToParentProxy?.();
expect(events).toEqual([{ owner: "parent", reason: "autoplay-blocked" }]);

// Second promote is idempotent — no duplicate event.
player._promoteToParentProxy?.();
expect(events).toHaveLength(1);
});

it("promotion mid-playback plays parent proxy immediately", () => {
// Previously-missing coverage: if the user is already playing when
// the runtime reports autoplay-blocked, the proxy must start audible
// right away — not wait for the user to hit pause/play again.
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
document.body.appendChild(player);

player.play(); // `_paused = false`, owner still `runtime` → no parent play yet
expect(mockAudio.play).not.toHaveBeenCalled();

player._promoteToParentProxy?.();
expect(mockAudio.play).toHaveBeenCalled();
});

it("surfaces playbackerror when parent proxy play() rejects", async () => {
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
document.body.appendChild(player);

const rejection = Object.assign(new Error("blocked"), { name: "NotAllowedError" });
mockAudio.play = vi.fn().mockRejectedValueOnce(rejection);

const errors: unknown[] = [];
player.addEventListener("playbackerror", (e: Event) => {
errors.push((e as CustomEvent).detail);
});

player._promoteToParentProxy?.();
player.play();
// Promise rejection delivered on a microtask — flush.
await Promise.resolve();
await Promise.resolve();

expect(errors.length).toBeGreaterThan(0);
expect((errors[0] as { source: string }).source).toBe("parent-proxy");
});

it("playbackerror dedup: fires at most once per parent-ownership session", async () => {
// Under parent ownership with parent-also-blocked, every iframe
// paused→playing transition in the state loop re-invokes `_playParentMedia`.
// Without a latch, each rejection would re-fire `playbackerror`, spamming
// subscribers. Mirrors the runtime's `mediaAutoplayBlockedPosted` latch.
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
document.body.appendChild(player);

const rejection = Object.assign(new Error("blocked"), { name: "NotAllowedError" });
mockAudio.play = vi.fn().mockRejectedValue(rejection);

const errors: unknown[] = [];
player.addEventListener("playbackerror", (e: Event) => {
errors.push((e as CustomEvent).detail);
});

player._promoteToParentProxy?.();
player.play();
player.pause();
player.play();
player.pause();
player.play();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();

expect(errors).toHaveLength(1);
});

it("cleans up parent media on disconnect", () => {
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
document.body.appendChild(player);
Expand Down
Loading
Loading