diff --git a/apps/web/public/audio/pig-ambient.mp3 b/apps/web/public/audio/pig-ambient.mp3 new file mode 100644 index 0000000..8b4911b Binary files /dev/null and b/apps/web/public/audio/pig-ambient.mp3 differ diff --git a/apps/web/public/audio/pig-jazz.mp3 b/apps/web/public/audio/pig-jazz.mp3 new file mode 100644 index 0000000..9d47f6b Binary files /dev/null and b/apps/web/public/audio/pig-jazz.mp3 differ diff --git a/apps/web/public/audio/pig-tech.mp3 b/apps/web/public/audio/pig-tech.mp3 new file mode 100644 index 0000000..d3d1250 Binary files /dev/null and b/apps/web/public/audio/pig-tech.mp3 differ diff --git a/apps/web/src/components/AppHeader.tsx b/apps/web/src/components/AppHeader.tsx index 59e30b7..e186f95 100644 --- a/apps/web/src/components/AppHeader.tsx +++ b/apps/web/src/components/AppHeader.tsx @@ -26,6 +26,7 @@ import { activeNavItem, visibleNav } from '@/lib/nav'; import { CommandPalette } from './CommandPalette'; import { PiggyLogo } from './PiggyMark'; import { PiggyDockToggle } from './PiggyDock'; +import { AudioControl } from './AudioControl'; import { Button, cn } from './ui'; import { Breadcrumb, @@ -115,6 +116,7 @@ export function AppHeader() { + diff --git a/apps/web/src/components/AudioControl.tsx b/apps/web/src/components/AudioControl.tsx new file mode 100644 index 0000000..602e0cf --- /dev/null +++ b/apps/web/src/components/AudioControl.tsx @@ -0,0 +1,80 @@ +/** + * The music control in the header. + * + * A single button toggles it; the caret opens the track list. The button's + * label distinguishes three states rather than two, because "on but waiting + * for a click" is a real state the browser puts us in and a speaker icon that + * claims to be playing when nothing is audible is the confusing part. + */ +import { ChevronDown, Music, Volume2, VolumeX } from 'lucide-react'; +import { PLATFORM_TRACKS, usePlatformAudio } from '@/lib/audio'; +import { Button, cn } from './ui'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from './ui/dropdown-menu'; + +export function AudioControl({ className }: { className?: string }) { + const audio = usePlatformAudio(); + if (!audio) return null; + + const { enabled, playing, track, toggle, setTrack } = audio; + const current = PLATFORM_TRACKS.find((entry) => entry.id === track); + const label = !enabled + ? 'Play platform music' + : playing + ? `Mute platform music (${current?.label})` + : 'Music starts when you interact with the page'; + + return ( +
+ + + + + + + + + + Platform music + + + {PLATFORM_TRACKS.map((entry) => ( + setTrack(entry.id)} + className={cn(entry.id === track && 'font-medium text-accent-fg')} + > + {entry.label} + {entry.id === track ? playing : null} + + ))} + + +
+ ); +} diff --git a/apps/web/src/components/Shell.tsx b/apps/web/src/components/Shell.tsx index e6a1a1b..60c60b2 100644 --- a/apps/web/src/components/Shell.tsx +++ b/apps/web/src/components/Shell.tsx @@ -29,6 +29,7 @@ import { NavLink } from 'react-router-dom'; import { useIdentity } from '@/lib/identity'; import { useLayout } from '@/lib/layout'; import { visibleNav, type NavItem } from '@/lib/nav'; +import { PlatformAudioProvider } from '@/lib/audio'; import { AppHeader } from './AppHeader'; import { AppSidebar } from './AppSidebar'; import { PiggyDock } from './PiggyDock'; @@ -44,6 +45,14 @@ export function Shell() { const items = visibleNav(identity); return ( + /* + * Music is mounted HERE rather than in App.tsx, so it wraps only the + * signed-in application. 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. + */ + item.primary)} /> + ); } diff --git a/apps/web/src/lib/audio.tsx b/apps/web/src/lib/audio.tsx new file mode 100644 index 0000000..9f06f9b --- /dev/null +++ b/apps/web/src/lib/audio.tsx @@ -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(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) { + 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 ( + + {/* + 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); +}