diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 8423788bdc0..90ffdd4f8dc 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -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", { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index a93258938a4..b94380d3ad2 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -84,6 +84,7 @@ import { MEDIA_SYNC_TOLERANCE_SECONDS, isUnplayable, mediaSourceTimeAt, + primeMediaForLaterStarts, readElementPlaybackRate, readElementPlaybackStart, readElementRateSpec, @@ -4720,6 +4721,7 @@ export function initSandboxRuntimeModular(): void { } runAdapters("play"); syncMediaForCurrentState(); + primeMediaForLaterStarts(resolveMediaClipIndex().clips.map((clip) => clip.el)); colorGrading.redraw(); postState(true); }, diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index cc9b9e14630..145f1d12244 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -4,6 +4,7 @@ import { mediaElementTime } from "./mediaClock"; import { evictMediaSyncState, hasMediaSyncStateForTest, + primeMediaForLaterStarts, readElementPlaybackRate, readElementPlaybackStart, refreshRuntimeMediaCache, @@ -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 }); diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 101bdf883b3..bc9b33be1fb 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -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"; @@ -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): 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 diff --git a/packages/core/src/runtime/mediaClock.ts b/packages/core/src/runtime/mediaClock.ts index e57970e9f6f..14ce5766d6b 100644 --- a/packages/core/src/runtime/mediaClock.ts +++ b/packages/core/src/runtime/mediaClock.ts @@ -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); +} diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index b6da0c9aabc..f78aace8b89 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -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) { diff --git a/packages/player/src/parent-media.test.ts b/packages/player/src/parent-media.test.ts index 3a34d5329b9..c6c91139cac 100644 --- a/packages/player/src/parent-media.test.ts +++ b/packages/player/src/parent-media.test.ts @@ -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()); diff --git a/packages/player/src/parent-media.ts b/packages/player/src/parent-media.ts index 3c73bcb4784..496bb779318 100644 --- a/packages/player/src/parent-media.ts +++ b/packages/player/src/parent-media.ts @@ -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, @@ -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(); }