/** * 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(null); export function PlatformAudioProvider({ children }: { children: ReactNode }) { const [enabled, setEnabled] = useState(storedEnabled); const [track, setTrackState] = useState(storedTrack); const [playing, setPlaying] = useState(false); const elementRef = useRef(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 ( {/* 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. */} ); } /** Null outside the provider, so the anonymous Learn page stays silent. */ export function usePlatformAudio(): PlatformAudio | null { return useContext(Context); }