b63ed0181f
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>
268 lines
8.5 KiB
TypeScript
268 lines
8.5 KiB
TypeScript
/**
|
|
* Platform music.
|
|
*
|
|
* ## Autoplay does not work the way the word suggests
|
|
*
|
|
* Every current browser blocks audio *with sound* until the page has received
|
|
* a real user gesture. Chrome will sometimes allow it on a site the user
|
|
* visits constantly (its Media Engagement Index); Safari essentially never
|
|
* does on a first visit. So `audio.play()` on mount returns a promise that
|
|
* REJECTS, and a naive implementation looks like a bug: the toggle says
|
|
* playing, nothing is audible, and the console has one line about it.
|
|
*
|
|
* The honest behaviour, and what this does: try immediately, and if the browser
|
|
* refuses, arm a one-shot listener and start on the first click or keypress.
|
|
* The effect is that music begins when someone actually starts using the app
|
|
* rather than the instant a tab opens — which is also the kinder behaviour for
|
|
* anyone who opened five tabs at once.
|
|
*
|
|
* ## Per-device, not per-account
|
|
*
|
|
* Stored in localStorage and deliberately not mirrored to the server, for the
|
|
* same reason the sidebar collapse is not: whether you want music depends on
|
|
* whether you are wearing headphones, not on who you are. Theme is mirrored;
|
|
* this is not. See lib/layout.tsx.
|
|
*/
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from 'react';
|
|
|
|
export const PLATFORM_TRACKS = [
|
|
{ id: 'ambient', label: 'Ambient', src: '/audio/pig-ambient.mp3' },
|
|
{ id: 'jazz', label: 'Jazz', src: '/audio/pig-jazz.mp3' },
|
|
{ id: 'tech', label: 'Tech', src: '/audio/pig-tech.mp3' },
|
|
] as const;
|
|
|
|
export type PlatformTrackId = (typeof PLATFORM_TRACKS)[number]['id'];
|
|
|
|
const ENABLED_KEY = 'pig.audio.enabled';
|
|
const TRACK_KEY = 'pig.audio.track';
|
|
|
|
/**
|
|
* Low, and deliberately so. The source files are mastered at about -26 LUFS
|
|
* and this sits under them, because the failure mode of background music in a
|
|
* tool someone has open for eight hours is not "too quiet".
|
|
*/
|
|
const VOLUME = 0.14;
|
|
|
|
/**
|
|
* Ramp length. Music that snaps to full volume on the first click reads as a
|
|
* glitch; a second of ramp reads as the room having been there all along.
|
|
* Short enough that muting feels immediate.
|
|
*/
|
|
const FADE_IN_MS = 1100;
|
|
const FADE_OUT_MS = 420;
|
|
|
|
/**
|
|
* Ease the element's volume, resolving when it arrives. Returns a canceller so
|
|
* a fade that is superseded (mute pressed mid-fade-in) stops rather than
|
|
* fighting the new one.
|
|
*/
|
|
function ramp(audio: HTMLAudioElement, to: number, ms: number): () => void {
|
|
const from = audio.volume;
|
|
const started = performance.now();
|
|
let frame = 0;
|
|
const step = () => {
|
|
const t = Math.min(1, (performance.now() - started) / ms);
|
|
// easeOutQuad: most of the travel early, so a fade-out feels prompt.
|
|
audio.volume = Math.max(0, Math.min(1, from + (to - from) * (1 - (1 - t) * (1 - t))));
|
|
if (t < 1) frame = requestAnimationFrame(step);
|
|
};
|
|
frame = requestAnimationFrame(step);
|
|
return () => cancelAnimationFrame(frame);
|
|
}
|
|
|
|
function storedTrack(): PlatformTrackId {
|
|
try {
|
|
const raw = localStorage.getItem(TRACK_KEY);
|
|
if (PLATFORM_TRACKS.some((track) => track.id === raw)) return raw as PlatformTrackId;
|
|
} catch {
|
|
/* Private browsing throws on localStorage. The default is fine. */
|
|
}
|
|
return 'ambient';
|
|
}
|
|
|
|
function storedEnabled(): boolean {
|
|
try {
|
|
// Default ON. An explicit "false" is the only thing that turns it off, so a
|
|
// cleared storage does not silently change the product's character.
|
|
return localStorage.getItem(ENABLED_KEY) !== 'false';
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
interface PlatformAudio {
|
|
enabled: boolean;
|
|
/** True only when sound is actually coming out — see the autoplay note. */
|
|
playing: boolean;
|
|
track: PlatformTrackId;
|
|
toggle: () => void;
|
|
setTrack: (id: PlatformTrackId) => void;
|
|
}
|
|
|
|
const Context = createContext<PlatformAudio | null>(null);
|
|
|
|
export function PlatformAudioProvider({ children }: { children: ReactNode }) {
|
|
const [enabled, setEnabled] = useState(storedEnabled);
|
|
const [track, setTrackState] = useState<PlatformTrackId>(storedTrack);
|
|
const [playing, setPlaying] = useState(false);
|
|
const elementRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
const source = PLATFORM_TRACKS.find((entry) => entry.id === track) ?? PLATFORM_TRACKS[0];
|
|
|
|
useEffect(() => {
|
|
const audio = elementRef.current;
|
|
if (!audio) return;
|
|
|
|
if (!enabled) {
|
|
const cancel = ramp(audio, 0, FADE_OUT_MS);
|
|
const stop = window.setTimeout(() => {
|
|
audio.pause();
|
|
audio.currentTime = 0;
|
|
}, FADE_OUT_MS);
|
|
setPlaying(false);
|
|
return () => {
|
|
cancel();
|
|
window.clearTimeout(stop);
|
|
};
|
|
}
|
|
|
|
// Start silent so the ramp has somewhere to come from.
|
|
audio.volume = 0;
|
|
let cancelled = false;
|
|
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 || audio.paused) return;
|
|
cancelRamp?.();
|
|
cancelRamp = ramp(audio, VOLUME, FADE_IN_MS);
|
|
})
|
|
.catch(() => {});
|
|
|
|
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;
|
|
detach();
|
|
cancelRamp?.();
|
|
audio.removeEventListener('playing', onPlaying);
|
|
audio.removeEventListener('pause', onPause);
|
|
};
|
|
}, [enabled, source.src]);
|
|
|
|
// Pause when the tab is hidden. Music from a tab nobody is looking at is the
|
|
// thing people hunt through twenty tabs to kill.
|
|
useEffect(() => {
|
|
const onVisibility = () => {
|
|
const audio = elementRef.current;
|
|
if (!audio || !enabled) return;
|
|
if (document.hidden) {
|
|
audio.pause();
|
|
setPlaying(false);
|
|
} else {
|
|
void audio.play().then(() => setPlaying(true)).catch(() => {});
|
|
}
|
|
};
|
|
document.addEventListener('visibilitychange', onVisibility);
|
|
return () => document.removeEventListener('visibilitychange', onVisibility);
|
|
}, [enabled]);
|
|
|
|
useEffect(() => () => elementRef.current?.pause(), []);
|
|
|
|
const toggle = useCallback(() => {
|
|
setEnabled((current) => {
|
|
const next = !current;
|
|
try {
|
|
localStorage.setItem(ENABLED_KEY, String(next));
|
|
} catch {
|
|
/* Nothing to do; the session still honours the choice. */
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const setTrack = useCallback((id: PlatformTrackId) => {
|
|
setTrackState(id);
|
|
try {
|
|
localStorage.setItem(TRACK_KEY, id);
|
|
} catch {
|
|
/* As above. */
|
|
}
|
|
}, []);
|
|
|
|
const value = useMemo(
|
|
() => ({ enabled, playing, track, toggle, setTrack }),
|
|
[enabled, playing, track, toggle, setTrack],
|
|
);
|
|
|
|
return (
|
|
<Context.Provider value={value}>
|
|
{/*
|
|
A real element in the tree rather than `new Audio()`, so it is
|
|
inspectable in devtools and in a test. `preload="none"` keeps a user
|
|
who never enables music from downloading a track.
|
|
*/}
|
|
<audio
|
|
ref={elementRef}
|
|
src={source.src}
|
|
loop
|
|
preload="none"
|
|
aria-hidden
|
|
className="hidden"
|
|
/>
|
|
{children}
|
|
</Context.Provider>
|
|
);
|
|
}
|
|
|
|
/** Null outside the provider, so the anonymous Learn page stays silent. */
|
|
export function usePlatformAudio(): PlatformAudio | null {
|
|
return useContext(Context);
|
|
}
|