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
10 changes: 10 additions & 0 deletions packages/core/src/runtime/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3957,6 +3957,16 @@ describe("initSandboxRuntimeModular", () => {
expect(startedAt[0]).toBeGreaterThanOrEqual(1 - 0.02);
});

it("primes a clip that starts later inside the tap on iOS, so a late start is not refused", () => {
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148",
);
const { sfx } = mountLateSfx();
window.__player?.play();
expect(sfx.play).toHaveBeenCalledTimes(1);
expect(sfx.pause).toHaveBeenCalledTimes(1);
});

const setRate = (playbackRate: number) =>
window.dispatchEvent(
new MessageEvent("message", {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/runtime/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import {
MEDIA_SYNC_TOLERANCE_SECONDS,
isUnplayable,
mediaSourceTimeAt,
primeMediaForLaterStarts,
readElementPlaybackRate,
readElementPlaybackStart,
readElementRateSpec,
Expand Down Expand Up @@ -4720,6 +4721,7 @@ export function initSandboxRuntimeModular(): void {
}
runAdapters("play");
syncMediaForCurrentState();
primeMediaForLaterStarts(resolveMediaClipIndex().clips.map((clip) => clip.el));
colorGrading.redraw();
postState(true);
},
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/runtime/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { mediaElementTime } from "./mediaClock";
import {
evictMediaSyncState,
hasMediaSyncStateForTest,
primeMediaForLaterStarts,
readElementPlaybackRate,
readElementPlaybackStart,
refreshRuntimeMediaCache,
Expand Down Expand Up @@ -1822,6 +1823,34 @@ describe("syncRuntimeMedia", () => {
expect(clip.el.muted).toBe(true);
});

describe("priming for iOS", () => {
const iPhone =
"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148";
afterEach(() => vi.restoreAllMocks());

it("plays then pauses every idle unmuted take so iOS lets it start after the tap", () => {
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(iPhone);
const idle = createMockClip({ start: 20, end: 30 }).el;
const muted = createMockClip({ start: 20, end: 30 }).el;
const sounding = createMockClip({ start: 0, end: 10 }).el;
const order: string[] = [];
idle.play = vi.fn(() => (order.push("play"), Promise.resolve()));
idle.pause = vi.fn(() => void order.push("pause"));
muted.setAttribute("muted", "");
Object.defineProperty(sounding, "paused", { value: false });
primeMediaForLaterStarts([idle, muted, sounding]);
expect(order).toEqual(["play", "pause"]);
expect(muted.play).not.toHaveBeenCalled();
expect(sounding.pause).not.toHaveBeenCalled();
});

it("leaves every take parked where no tap is needed", () => {
const idle = createMockClip({ start: 20, end: 30 }).el;
primeMediaForLaterStarts([idle]);
expect(idle.play).not.toHaveBeenCalled();
});
});

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 });
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/runtime/media.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { swallow } from "./diagnostics";
import { isClipVisibleAt, isInClipWindow } from "./clipWindow";
import { mediaElementTime } from "./mediaClock";
import { mediaElementTime, startsSoundOnlyInsideATap } from "./mediaClock";
import { sameInstant } from "../clipFacts";
import { interpolateVolumeGain } from "./mediaVolumeEnvelope.js";
import { elementVolumeLaneGain } from "./audioAutomationVolume.js";
Expand Down Expand Up @@ -203,6 +203,17 @@ function markPlayRequested(el: HTMLMediaElement): void {
el.addEventListener("error", clear, { once: true });
}

// iOS refuses an unmuted start ~5 s after the tap unless that element played inside a tap: play-then-pause
// each idle unmuted one from the tap's own call stack. Authored-muted clips start freely and stay deferred.
export function primeMediaForLaterStarts(els: Iterable<HTMLMediaElement>): void {
if (!startsSoundOnlyInsideATap()) return;
for (const el of els) {
if (el.defaultMuted || !el.paused || playRequested.has(el) || isUnplayable(el)) continue;
el.play().catch(() => {});
el.pause();
}
}

// HTMLMediaElement.NETWORK_NO_SOURCE — no usable source (404 / unsupported).
const MEDIA_NETWORK_NO_SOURCE = 3;
// An element that errored or has no source can't play; re-issuing play() every
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/runtime/mediaClock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ export const MEDIA_ELEMENT_LEAD_SECONDS = 1e-5;
export function mediaElementTime(sourceSeconds: number): number {
return sourceSeconds + MEDIA_ELEMENT_LEAD_SECONDS;
}

export function startsSoundOnlyInsideATap(): boolean {
const { userAgent, maxTouchPoints } = navigator;
return /iP(hone|ad|od)/.test(userAgent) || (/Macintosh/.test(userAgent) && maxTouchPoints > 1);
}
1 change: 1 addition & 0 deletions packages/player/src/hyperframes-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ class HyperframesPlayer extends HTMLElement {
}
}
if (this._media.audioOwner === "parent") this._media.playAll();
else this._media.primeAll();
this.controlsApi?.updatePlaying(true);
if (!queuedForReady && announce) this._emit(new Event("play"));
if (directTimelineStarted && this._directTimelineAdapter) {
Expand Down
28 changes: 28 additions & 0 deletions packages/player/src/parent-media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,34 @@ describe("ParentMediaManager audio-src proxy lifecycle", () => {
});
});

describe("ParentMediaManager priming", () => {
const iPhone =
"Mozilla/5.0 (iPhone; CPU iPhone OS 26_5 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148";
afterEach(() => vi.restoreAllMocks());

function primed() {
const mgr = makeManager();
mgr.setupFromUrl("https://example.test/a.mp3");
const el = mgr.entries[0].el;
const calls: string[] = [];
const play = el.play.bind(el);
el.play = () => (calls.push("play"), play());
const pause = el.pause.bind(el);
el.pause = () => (calls.push("pause"), pause());
mgr.primeAll();
return calls;
}

it("plays then pauses every idle copy so iOS lets it start after the tap", () => {
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(iPhone);
expect(primed()).toEqual(["play", "pause"]);
});

it("leaves the copies alone where no tap is needed", () => {
expect(primed()).toEqual([]);
});
});

describe("ParentMediaManager across documents", () => {
afterEach(() => document.body.replaceChildren());

Expand Down
12 changes: 11 additions & 1 deletion packages/player/src/parent-media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { isRealmElement, isRealmHtmlMediaElement } from "./media-element-guards.
import { isInClipWindow } from "@hyperframes/core/runtime/clip-window";
import { readClipTiming } from "@hyperframes/core/composition-contract";
import { readLinkTiming } from "@hyperframes/core/media-link";
import { mediaElementTime } from "@hyperframes/core/runtime/media-clock";
import { mediaElementTime, startsSoundOnlyInsideATap } from "@hyperframes/core/runtime/media-clock";
import {
MEDIA_SYNC_TOLERANCE_SECONDS,
forgetSteering,
Expand Down Expand Up @@ -219,6 +219,16 @@ export class ParentMediaManager {
for (const m of this._entries) this._playEntryIfActive(m);
}

/** iOS lets an element start with sound later only if it was played inside a tap; do it for every copy. */
primeAll(): void {
if (!startsSoundOnlyInsideATap()) return;
for (const m of this._entries) {
if (!m.el.paused) continue;
m.el.play().catch(() => {});
m.el.pause();
}
}

pauseAll(): void {
for (const m of this._entries) m.el.pause();
}
Expand Down
Loading