diff --git a/apps/web/src/lib/audio.tsx b/apps/web/src/lib/audio.tsx index aacfb26..61c4e92 100644 --- a/apps/web/src/lib/audio.tsx +++ b/apps/web/src/lib/audio.tsx @@ -141,39 +141,58 @@ export function PlatformAudioProvider({ children }: { children: ReactNode }) { let armed: (() => 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 = () => audio .play() .then(() => { - if (cancelled) return; - setPlaying(true); + if (cancelled || audio.paused) return; cancelRamp?.(); cancelRamp = ramp(audio, VOLUME, FADE_IN_MS); }) - .catch(() => false); + .catch(() => {}); - void start().then((ok) => { - if (ok !== false || cancelled) return; - // Refused for want of a gesture. Wait for one rather than reporting a - // state that is not true. - const onGesture = () => { - void start(); - detach(); - }; - const detach = () => { - window.removeEventListener('pointerdown', onGesture); - window.removeEventListener('keydown', onGesture); - armed = null; - }; - armed = detach; - window.addEventListener('pointerdown', onGesture, { once: true }); - window.addEventListener('keydown', onGesture, { once: true }); - }); + void start(); + + const events = ['pointerdown', 'touchend', 'click', 'keydown'] as const; + const onGesture = () => { + if (cancelled) return; + void start(); + detach(); + }; + const detach = () => { + for (const name of events) window.removeEventListener(name, onGesture, true); + }; + for (const name of events) { + window.addEventListener(name, onGesture, { capture: true, once: true }); + } return () => { cancelled = true; - armed?.(); + detach(); cancelRamp?.(); + audio.removeEventListener('playing', onPlaying); + audio.removeEventListener('pause', onPause); }; }, [enabled, source.src]);