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
17 changes: 17 additions & 0 deletions packages/core/src/runtime/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ function createMockDeps() {
onPause: vi.fn(),
onSeek: vi.fn(),
onSetMuted: vi.fn(),
onSetMediaOutputMuted: vi.fn(),
onSetPlaybackRate: vi.fn(),
onEnablePickMode: vi.fn(),
onDisablePickMode: vi.fn(),
Expand Down Expand Up @@ -55,6 +56,22 @@ describe("installRuntimeControlBridge", () => {
expect(deps.onSetMuted).toHaveBeenCalledWith(true);
});

it("dispatches set-media-output-muted command", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
handler(makeControlMessage("set-media-output-muted", { muted: true }));
expect(deps.onSetMediaOutputMuted).toHaveBeenCalledWith(true);
handler(makeControlMessage("set-media-output-muted", { muted: false }));
expect(deps.onSetMediaOutputMuted).toHaveBeenCalledWith(false);
});

it("set-media-output-muted coerces absent flag to false", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
handler(makeControlMessage("set-media-output-muted"));
expect(deps.onSetMediaOutputMuted).toHaveBeenCalledWith(false);
});

it("dispatches set-playback-rate command", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/runtime/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ type BridgeDeps = {
onPause: () => void;
onSeek: (frame: number, seekMode: "drag" | "commit") => void;
onSetMuted: (muted: boolean) => void;
onSetMediaOutputMuted: (muted: boolean) => void;
onSetPlaybackRate: (rate: number) => void;
onEnablePickMode: () => void;
onDisablePickMode: () => void;
Expand Down Expand Up @@ -39,6 +40,10 @@ export function installRuntimeControlBridge(deps: BridgeDeps): (event: MessageEv
deps.onSetMuted(Boolean(data.muted));
return;
}
if (action === "set-media-output-muted") {
deps.onSetMediaOutputMuted(Boolean(data.muted));
return;
}
if (action === "set-playback-rate") {
deps.onSetPlaybackRate(Number(data.playbackRate ?? 1));
return;
Expand Down
18 changes: 17 additions & 1 deletion packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,12 @@ export function initSandboxRuntimeModular(): void {
timeSeconds: state.currentTime,
playing: state.isPlaying,
playbackRate: state.playbackRate,
outputMuted: state.mediaOutputMuted,
onAutoplayBlocked: () => {
if (state.mediaAutoplayBlockedPosted) return;
state.mediaAutoplayBlockedPosted = true;
postRuntimeMessage({ source: "hf-preview", type: "media-autoplay-blocked" });
},
});
const rootCompId =
document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ?? null;
Expand Down Expand Up @@ -1424,10 +1430,20 @@ export function initSandboxRuntimeModular(): void {
},
onSetMuted: (muted) => {
state.bridgeMuted = muted;
const effective = muted || state.mediaOutputMuted;
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
el.muted = effective;
}
},
onSetMediaOutputMuted: (muted) => {
state.mediaOutputMuted = muted;
const effective = muted || state.bridgeMuted;
const mediaEls = document.querySelectorAll("video, audio");
for (const el of mediaEls) {
if (!(el instanceof HTMLMediaElement)) continue;
el.muted = muted;
el.muted = effective;
}
},
onSetPlaybackRate: (rate) => applyPlaybackRate(rate),
Expand Down
77 changes: 77 additions & 0 deletions packages/core/src/runtime/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,4 +381,81 @@ describe("syncRuntimeMedia", () => {
syncRuntimeMedia({ clips: [clip], timeSeconds: 7, playing: false, playbackRate: 1 });
expect(clip.el.currentTime).toBe(7);
});

it("asserts muted=true every tick while outputMuted is set", () => {
// Parent ownership has taken over audible playback via parent-frame
// proxies. The iframe runtime must silence every active media element
// per tick so new sub-composition media inherits the mute as soon as
// it appears in the DOM — otherwise a late <audio> insertion would
// briefly play audibly and double-voice the viewer.
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: true,
});
expect(clip.el.muted).toBe(true);
// A second tick re-asserts — captures the sticky behavior, since
// the bridge handler only runs on flip transitions.
Object.defineProperty(clip.el, "muted", { value: false, writable: true });
syncRuntimeMedia({
clips: [clip],
timeSeconds: 5.02,
playing: true,
playbackRate: 1,
outputMuted: true,
});
expect(clip.el.muted).toBe(true);
});

it("does not touch muted when outputMuted is absent", () => {
// The un-mute decision belongs to author intent (`<audio muted>`) and
// user preference (`onSetMuted`) — syncRuntimeMedia must not race them.
const clip = createMockClip({ start: 0, end: 10 });
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
Object.defineProperty(clip.el, "muted", { value: true, writable: true });
syncRuntimeMedia({ clips: [clip], timeSeconds: 5, playing: true, playbackRate: 1 });
expect(clip.el.muted).toBe(true);
});

it("fires onAutoplayBlocked when play() rejects with NotAllowedError", async () => {
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();
syncRuntimeMedia({
clips: [clip],
timeSeconds: 5,
playing: true,
playbackRate: 1,
onAutoplayBlocked,
});
// The rejection is delivered on a microtask — flush it.
await Promise.resolve();
await Promise.resolve();
expect(onAutoplayBlocked).toHaveBeenCalledTimes(1);
});

it("does not fire onAutoplayBlocked for non-autoplay rejections", async () => {
const clip = createMockClip({ start: 0, end: 10 });
Object.defineProperty(clip.el, "readyState", { value: 4, writable: true });
const rejection = Object.assign(new Error("aborted"), { name: "AbortError" });
clip.el.play = vi.fn(() => Promise.reject(rejection));
const onAutoplayBlocked = vi.fn();
syncRuntimeMedia({
clips: [clip],
timeSeconds: 5,
playing: true,
playbackRate: 1,
onAutoplayBlocked,
});
await Promise.resolve();
await Promise.resolve();
expect(onAutoplayBlocked).not.toHaveBeenCalled();
});
});
26 changes: 25 additions & 1 deletion packages/core/src/runtime/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,20 @@ export function syncRuntimeMedia(params: {
timeSeconds: number;
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.
*/
outputMuted?: 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 {
for (const clip of params.clips) {
const { el } = clip;
Expand All @@ -108,6 +122,7 @@ export function syncRuntimeMedia(params: {
}
}
if (clip.volume != null) el.volume = clip.volume;
if (params.outputMuted) el.muted = true;
try {
// Per-element rate × global transport rate
el.playbackRate = clip.playbackRate * params.playbackRate;
Expand Down Expand Up @@ -167,11 +182,20 @@ export function syncRuntimeMedia(params: {
// inserted after the runtime bound its listeners.
if (el.preload !== "auto") el.preload = "auto";
markPlayRequested(el);
void el.play().catch(() => {
void el.play().catch((err: unknown) => {
// If play() rejects — e.g. autoplay blocked, element removed
// mid-flight — drop the in-flight flag so a future sync tick can
// retry rather than getting stuck waiting for `playing`/`pause`.
playRequested.delete(el);
// `NotAllowedError` is the autoplay-gating browser response when
// the iframe has no user activation. Signal the parent exactly
// once so it can promote to parent-frame audio proxies. Retries
// here would be pointless — nothing the runtime does fixes it.
const name =
err && typeof err === "object" && "name" in err
? String((err as { name?: unknown }).name ?? "")
: "";
if (name === "NotAllowedError") params.onAutoplayBlocked?.();
});
} else if (!params.playing && !el.paused) {
el.pause();
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/runtime/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ export type RuntimeState = {
parityModeEnabled: boolean;
canonicalFps: number;
bridgeMuted: boolean;
/**
* Internal mute of audible media output, owned by the audio-ownership
* protocol between the parent (`<hyperframes-player>`) and this runtime.
* Independent of `bridgeMuted` (the user's mute preference). When the
* parent takes over audible playback via parent-frame proxies, it sets
* this to `true` so the runtime keeps driving timed media for frame
* accuracy but produces no audio of its own.
*/
mediaOutputMuted: boolean;
/**
* Latch so the `media-autoplay-blocked` outbound message is posted at most
* once per runtime session. The parent only needs the first signal — it
* takes over playback and further rejections are the same problem.
*/
mediaAutoplayBlockedPosted: boolean;
playbackRate: number;
bridgeLastPostedFrame: number;
bridgeLastPostedAt: number;
Expand Down Expand Up @@ -42,6 +57,8 @@ export function createRuntimeState(): RuntimeState {
parityModeEnabled: true,
canonicalFps: 30,
bridgeMuted: false,
mediaOutputMuted: false,
mediaAutoplayBlockedPosted: false,
playbackRate: 1,
bridgeLastPostedFrame: -1,
bridgeLastPostedAt: 0,
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type RuntimeBridgeControlAction =
| "pause"
| "seek"
| "set-muted"
| "set-media-output-muted"
| "set-playback-rate"
| "enable-pick-mode"
| "disable-pick-mode"
Expand Down Expand Up @@ -137,6 +138,18 @@ export type RuntimeStageSizeMessage = {
height: number;
};

/**
* Fired once per session when the runtime's attempt to play a timed media
* element is rejected with `NotAllowedError`. The parent (web component / host
* app) uses this as the signal to promote to parent-frame audio proxies —
* iframes lose autoplay privileges when the user gesture originated in the
* parent frame, so the host has to take over audible playback there.
*/
export type RuntimeMediaAutoplayBlockedMessage = {
source: "hf-preview";
type: "media-autoplay-blocked";
};

/**
* Analytics events emitted by the runtime.
*
Expand Down Expand Up @@ -167,6 +180,7 @@ export type RuntimeOutboundMessage =
| RuntimePickerPickedManyMessage
| RuntimePickerCancelledMessage
| RuntimeStageSizeMessage
| RuntimeMediaAutoplayBlockedMessage
| RuntimeAnalyticsMessage;

export type RuntimePlayer = {
Expand Down
62 changes: 53 additions & 9 deletions packages/player/src/hyperframes-player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,17 +57,22 @@ describe("formatTime", () => {
});
});

// ── Parent-frame media for mobile playback ──
// ── Parent-frame audio proxies (ownership-based) ──
//
// Mobile browsers block media.play() inside iframes when the user gesture
// happened in the parent. The player works around this by extracting media
// from the iframe and playing it in the parent frame.
// Parent-frame audio/video copies are preloaded mirror proxies of the iframe's
// timed media. They exist as a fallback for environments that block iframe
// `.play()`. Under the default `runtime` audio ownership, the iframe drives
// audible playback and the proxies stay paused. Ownership flips to `parent`
// only when the runtime posts `media-autoplay-blocked` — then the proxies
// become the audible source and the iframe is silenced via bridge.

describe("HyperframesPlayer parent-frame media", () => {
type PlayerElement = HTMLElement & {
play: () => void;
pause: () => void;
seek: (t: number) => void;
_audioOwner?: "runtime" | "parent";
_promoteToParentProxy?: () => void;
};

let player: PlayerElement;
Expand Down Expand Up @@ -143,28 +148,67 @@ describe("HyperframesPlayer parent-frame media", () => {
expect(mockAudio.playbackRate).toBe(1.5);
});

it("play() calls parentMedia.play()", () => {
it("play() does NOT start parent-proxy under runtime ownership", () => {
// Default ownership is `runtime` — the iframe drives audible playback.
// If we also started parent proxies here, both would play and the user
// would hear doubled, slightly-offset audio (the original bug).
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
document.body.appendChild(player);

player.play();
expect(mockAudio.play).toHaveBeenCalled();
expect(mockAudio.play).not.toHaveBeenCalled();
expect(player._audioOwner).toBe("runtime");
});

it("pause() calls parentMedia.pause()", () => {
it("pause() does NOT touch parent-proxy under runtime ownership", () => {
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
document.body.appendChild(player);

player.pause();
expect(mockAudio.pause).toHaveBeenCalled();
expect(mockAudio.pause).not.toHaveBeenCalled();
});

it("seek() does NOT update parent currentTime under runtime ownership", () => {
// Under runtime ownership the iframe is authoritative for time; touching
// the proxy's currentTime would just trigger a re-buffer for no gain.
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
document.body.appendChild(player);

player.seek(12.5);
expect(mockAudio.currentTime).toBe(0);
});

it("seek() sets parentMedia.currentTime", () => {
it("after promotion to parent ownership: play/pause/seek drive parent proxy", () => {
// Simulates the runtime having posted `media-autoplay-blocked`. Post
// promotion: the web component owns audible output and fully drives
// the parent proxy.
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
document.body.appendChild(player);

player._promoteToParentProxy?.();
expect(player._audioOwner).toBe("parent");

player.play();
expect(mockAudio.play).toHaveBeenCalled();

player.seek(12.5);
expect(mockAudio.currentTime).toBe(12.5);

player.pause();
expect(mockAudio.pause).toHaveBeenCalled();
});

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

player._promoteToParentProxy?.();
player._promoteToParentProxy?.();
player._promoteToParentProxy?.();
// Only one play() attempt is triggered by promotion itself (gated on
// `!this._paused`, which is true by default so it doesn't trigger at all).
// The test's meaning is: ownership stays `parent`, no thrash, no errors.
expect(player._audioOwner).toBe("parent");
});

it("cleans up parent media on disconnect", () => {
Expand Down
Loading
Loading