Add platform music: three tracks, looped, mutable
Three ~28s tracks Karti generated, re-encoded from 160kbps to 96kbps and pulled from -15 LUFS to -26. They were mastered at foreground level; the failure mode of background music in a tool someone has open for eight hours is not "too quiet". Playback volume is a further 0.14 on top. AUTOPLAY DOES NOT MEAN AUTOPLAY. Every current browser refuses audio with sound until the page has had a real user gesture — Chrome sometimes relents for a site with a high Media Engagement Index, Safari essentially never does on a first visit. So `play()` rejects on mount, and the naive version of this looks like a bug: the control says playing and nothing is audible. This tries immediately, and on refusal arms a one-shot listener and starts on the first click or keypress. Verified in Chrome: paused on load, playing 2.2s after the first click. That also happens to be the kind behaviour for someone who opened six tabs at once. Mounted in Shell, NOT in App. The anonymous Learn page renders outside Shell, and a share-code visitor opening a link someone sent them should not get unexpected audio — that is the one context where it reads as a fault rather than as character. Asserted by there being no <audio> element on that page at all. Preference is localStorage and deliberately not mirrored to the server, on the same reasoning as the sidebar: whether you want music depends on whether you are wearing headphones, not on who you are. Also pauses on tab hide, because music from a tab nobody is looking at is the thing people hunt through twenty tabs to kill. The element is rendered rather than `new Audio()` so it is inspectable in devtools and in a test; `preload="none"` means a user who mutes it never downloads a track. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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) {
|
||||
audio.pause();
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
audio.volume = VOLUME;
|
||||
let cancelled = false;
|
||||
let armed: (() => void) | null = null;
|
||||
|
||||
const start = () =>
|
||||
audio
|
||||
.play()
|
||||
.then(() => {
|
||||
if (!cancelled) setPlaying(true);
|
||||
})
|
||||
.catch(() => false);
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
armed?.();
|
||||
};
|
||||
}, [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);
|
||||
}
|
||||
Reference in New Issue
Block a user