Fix the music never starting in Safari
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:
+40
-21
@@ -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;
|
for (const name of events) {
|
||||||
};
|
window.addEventListener(name, onGesture, { capture: true, once: true });
|
||||||
armed = detach;
|
}
|
||||||
window.addEventListener('pointerdown', onGesture, { 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]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user