Fix the music never starting in Safari
CI / verify (push) Successful in 3m20s
CI / publish (push) Has been skipped

It played in Chrome and was silent on every Apple device. Two false signals,
compounding.

Neither the play() promise nor a synchronous `paused` check tells you whether
audio is playing. Chrome REJECTS the promise when autoplay is blocked, which is
the behaviour the obvious implementation is written against. WebKit RESOLVES
it, reports `paused === false` for an instant, and quietly pauses the element a
moment later. So the code concluded it had started, faded the volume up on a
silent element, and — because it thought it had succeeded — never armed the
gesture listener that was the entire fallback. Music could then never start at
all, no matter how many times the visitor clicked.

Traced by hooking HTMLMediaElement.prototype.play and addEventListener before
the app booted: exactly one play() call, "resolved paused=false", and no
pointerdown listener ever registered on window.

Two changes. Playing state is now driven by the element's own `playing` and
`pause` events, which are the only honest source. And the gesture listener is
armed UNCONDITIONALLY rather than only on a detected failure — play() on an
already-playing element is a no-op, so the redundant case costs nothing and the
broken case is fixed. The listeners are capture-phase so a component calling
stopPropagation cannot swallow them, and cover touchend and keydown as well.

Verified 12/12: Chrome and WebKit x desktop and iPhone x /, /learn and /margin
all reach t>2.5s at volume 0.14, and mute still fades and persists in both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-13 20:12:43 -07:00
parent 30171767f7
commit b63ed0181f
+34 -15
View File
@@ -141,39 +141,58 @@ export function PlatformAudioProvider({ children }: { children: ReactNode }) {
let armed: (() => void) | null = null; let armed: (() => void) | null = null;
let cancelRamp: (() => void) | null = null; let cancelRamp: (() => void) | null = null;
/*
* Drive state from the ELEMENT'S OWN EVENTS, and always arm a gesture.
*
* Neither the play() promise nor a synchronous `paused` check is
* trustworthy. Chrome rejects the promise when autoplay is blocked;
* WebKit RESOLVES it, reports `paused === false` for an instant, and then
* quietly pauses the element a moment later. Trusting either signal meant
* Safari believed it was playing, faded the volume up on a silent element,
* and never armed the gesture listener — so music never started on any
* Apple device, while Chrome was fine.
*
* So: attempt playback, and ALSO arm a one-shot gesture listener
* unconditionally. Calling play() on an element that is already playing is
* a no-op, which makes the redundant case free and the broken case fixed.
*/
const onPlaying = () => setPlaying(true);
const onPause = () => setPlaying(false);
audio.addEventListener('playing', onPlaying);
audio.addEventListener('pause', onPause);
const start = () => const start = () =>
audio audio
.play() .play()
.then(() => { .then(() => {
if (cancelled) return; if (cancelled || audio.paused) return;
setPlaying(true);
cancelRamp?.(); cancelRamp?.();
cancelRamp = ramp(audio, VOLUME, FADE_IN_MS); cancelRamp = ramp(audio, VOLUME, FADE_IN_MS);
}) })
.catch(() => false); .catch(() => {});
void start().then((ok) => { void start();
if (ok !== false || cancelled) return;
// Refused for want of a gesture. Wait for one rather than reporting a const events = ['pointerdown', 'touchend', 'click', 'keydown'] as const;
// state that is not true.
const onGesture = () => { const onGesture = () => {
if (cancelled) return;
void start(); void start();
detach(); detach();
}; };
const detach = () => { const detach = () => {
window.removeEventListener('pointerdown', onGesture); for (const name of events) window.removeEventListener(name, onGesture, true);
window.removeEventListener('keydown', onGesture);
armed = null;
}; };
armed = detach; for (const name of events) {
window.addEventListener('pointerdown', onGesture, { once: true }); window.addEventListener(name, onGesture, { capture: true, once: true });
window.addEventListener('keydown', onGesture, { once: true }); }
});
return () => { return () => {
cancelled = true; cancelled = true;
armed?.(); detach();
cancelRamp?.(); cancelRamp?.();
audio.removeEventListener('playing', onPlaying);
audio.removeEventListener('pause', onPause);
}; };
}, [enabled, source.src]); }, [enabled, source.src]);