diff --git a/public/audio/SOURCES.md b/public/audio/SOURCES.md new file mode 100644 index 0000000..2a062d7 --- /dev/null +++ b/public/audio/SOURCES.md @@ -0,0 +1,55 @@ +# Audio sources and licenses + +Every file in this directory is **CC0 1.0 Universal** (public domain +dedication). No attribution is legally required; the Credits page names the +authors anyway, as it does for every other asset in this project. + +Licenses on asset sites are per file, not per site. This table is the audit +trail: one row per shipped file, naming the exact upstream source. Add a row +before adding a file. + +Downloaded 2026-09-09. + +## Sound effects + +Kenney's packs ship as 44.1 kHz stereo Ogg Vorbis. Each file here is +RMS-normalised to −18 dBFS, limited to −1 dBFS peak, downmixed to mono and +encoded as 112 kbps MP3 — the raw sources span 16 dB, which would leave the +click inaudible beside the submit sound. + +| File | Role | Upstream file | Pack | Author | License | Source | +|------|------|---------------|------|--------|---------|--------| +| `click.mp3` | Navigational buttons | `click2.ogg` | UI Audio | Kenney | CC0 1.0 | https://kenney.nl/assets/ui-audio | +| `pin.mp3` | Guess marker dropped | `drop_002.ogg` | Interface Sounds | Kenney | CC0 1.0 | https://kenney.nl/assets/interface-sounds | +| `submit.mp3` | Guess submitted | `maximize_002.ogg` | Interface Sounds | Kenney | CC0 1.0 | https://kenney.nl/assets/interface-sounds | +| `skip.mp3` | Round skipped | `minimize_002.ogg` | Interface Sounds | Kenney | CC0 1.0 | https://kenney.nl/assets/interface-sounds | +| `next.mp3` | Next round | `open_001.ogg` | Interface Sounds | Kenney | CC0 1.0 | https://kenney.nl/assets/interface-sounds | +| `error.mp3` | Panorama or submit failure | `error_004.ogg` | Interface Sounds | Kenney | CC0 1.0 | https://kenney.nl/assets/interface-sounds | +| `great.mp3` | Result, 4-5 points | `jingles_PIZZI02.ogg` | Music Jingles | Kenney | CC0 1.0 | https://kenney.nl/assets/music-jingles | +| `good.mp3` | Result, 1-3 points | `jingles_PIZZI12.ogg` | Music Jingles | Kenney | CC0 1.0 | https://kenney.nl/assets/music-jingles | +| `poor.mp3` | Result, 0 points | `jingles_PIZZI14.ogg` | Music Jingles | Kenney | CC0 1.0 | https://kenney.nl/assets/music-jingles | + +`submit.mp3` and `skip.mp3` are the same sound in opposite directions — +Kenney's `maximize_002` rises, `minimize_002` falls. Sending a guess and +abandoning one are mirror actions, so they get mirror sounds. + +## Music + +| File | Track | Author | License | Source | +|------|-------|--------|---------|--------| +| `music.webm` | Ambient Relaxing Loop | isaiah658 | CC0 1.0 | https://opengameart.org/content/ambient-relaxing-loop | +| `music.mp3` | Ambient Relaxing Loop | isaiah658 | CC0 1.0 | https://opengameart.org/content/ambient-relaxing-loop | + +One 24.5-second loop, shipped twice: Opus in WebM for current browsers, MP3 for +Safari below 18.4, which cannot decode Opus in WebM. Both are encoded from the +upstream WAV with no level change — the app plays the bed at 0.15 gain. + +The track was chosen on a measurement rather than a hunch: its first and last +250 ms sit within 0.5 dB of each other, so the loop point carries continuous +energy. The two longer OpenGameArt candidates that were also auditioned +(Contemplation, Daydream) fade in from and out to silence — 29 dB and 74 dB +apart across the seam — and would drop out audibly on every repeat. + +**MP3 loop caveat:** MP3 carries encoder padding that `decodeAudioData` renders +as silence, so the fallback path can have a short gap at the loop point. Only +Safari below 18.4 takes that path. diff --git a/public/audio/click.mp3 b/public/audio/click.mp3 new file mode 100644 index 0000000..00a7fba Binary files /dev/null and b/public/audio/click.mp3 differ diff --git a/public/audio/error.mp3 b/public/audio/error.mp3 new file mode 100644 index 0000000..351da84 Binary files /dev/null and b/public/audio/error.mp3 differ diff --git a/public/audio/good.mp3 b/public/audio/good.mp3 new file mode 100644 index 0000000..b2184cb Binary files /dev/null and b/public/audio/good.mp3 differ diff --git a/public/audio/great.mp3 b/public/audio/great.mp3 new file mode 100644 index 0000000..252bd03 Binary files /dev/null and b/public/audio/great.mp3 differ diff --git a/public/audio/music.mp3 b/public/audio/music.mp3 new file mode 100644 index 0000000..12d1469 Binary files /dev/null and b/public/audio/music.mp3 differ diff --git a/public/audio/music.webm b/public/audio/music.webm new file mode 100644 index 0000000..bab35b5 Binary files /dev/null and b/public/audio/music.webm differ diff --git a/public/audio/next.mp3 b/public/audio/next.mp3 new file mode 100644 index 0000000..669ecce Binary files /dev/null and b/public/audio/next.mp3 differ diff --git a/public/audio/pin.mp3 b/public/audio/pin.mp3 new file mode 100644 index 0000000..dc02f8e Binary files /dev/null and b/public/audio/pin.mp3 differ diff --git a/public/audio/poor.mp3 b/public/audio/poor.mp3 new file mode 100644 index 0000000..6405cb8 Binary files /dev/null and b/public/audio/poor.mp3 differ diff --git a/public/audio/skip.mp3 b/public/audio/skip.mp3 new file mode 100644 index 0000000..56d9349 Binary files /dev/null and b/public/audio/skip.mp3 differ diff --git a/public/audio/submit.mp3 b/public/audio/submit.mp3 new file mode 100644 index 0000000..9e1cd28 Binary files /dev/null and b/public/audio/submit.mp3 differ diff --git a/src/app/components/GameClient.js b/src/app/components/GameClient.js index 4b820c8..25aebf9 100644 --- a/src/app/components/GameClient.js +++ b/src/app/components/GameClient.js @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { ArrowLeft, Beer } from 'lucide-react'; import PanoramaViewer from './PanoramaViewer'; import ThemeToggle from './ThemeToggle'; +import SoundToggle from './SoundToggle'; import DonateQRModal from './DonateQRModal'; import FirstRoundHint from './FirstRoundHint'; import GuessMapPanel from './GuessMapPanel'; @@ -13,11 +14,24 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { generateRandomUsername, getUsername, setUsername } from '../../lib/username'; import { setLastRegion } from '../../lib/last-region'; +import { playSound } from '../../lib/audio'; // The revealed path comes from /api/guess (the RESOLVED district), not from // regionPath(pickedRegion) -- computing it client-side from what the player // chose would make the reveal meaningless for a country round. import { getRegion, isRegion } from '../../lib/regions'; +/** + * The result sound for a score, following the scoring ladder in lib/game.js: + * 4-5 points is a guess within 100m and worth celebrating, 1-3 is a hit, 0 is + * a miss. + * @param {number} score Points awarded, 0 to 5. + * @returns {string} Key of a sound in lib/audio.js. + */ +function resultSound(score) { + if (score >= 4) return 'great'; + return score > 0 ? 'good' : 'poor'; +} + /** * Ask the server for a new round. Throws on failure; touches no state, so the * result-screen prefetch can call it without disturbing the round on screen. @@ -108,6 +122,12 @@ export default function GameClient({ region }) { const roundEpochRef = useRef(0); // The epoch of the round currently applied to the screen; see applyRound. const appliedEpochRef = useRef(0); + // Back stays live while a guess is in flight, so a submit can resolve after + // the player has already left for the menu. State updates after that are + // harmless no-ops; a victory jingle over the home screen is not. + const mountedRef = useRef(true); + + useEffect(() => () => { mountedRef.current = false; }, []); const applyRound = useCallback((data) => { setSessionId(data.sessionId); @@ -223,6 +243,7 @@ export default function GameClient({ region }) { const handlePanoramaError = useCallback((error) => { console.error('Panorama error:', error); if (appliedEpochRef.current !== roundEpochRef.current) return; + playSound('error'); setRoundLoading(false); }, []); @@ -237,6 +258,9 @@ export default function GameClient({ region }) { }, [roundLoading]); const handleMapClick = (coordinates) => { + // Before the state update, so the pin lands with the sound rather than + // after React has re-rendered the map. + playSound('pin'); setGuessCoordinates([coordinates.lat, coordinates.lng]); }; @@ -259,6 +283,8 @@ export default function GameClient({ region }) { const handleSubmitGuess = async () => { if (!guessCoordinates || !imageData || submitting) return; + // After the guard: a blocked submit stays silent. + playSound('submit'); setSubmitting(true); const currentSession = sessionId; @@ -278,10 +304,12 @@ export default function GameClient({ region }) { resolvedPath: submitted.region?.path ?? null, leaderboardMessage: submitted.leaderboard?.message ?? '', }); + if (mountedRef.current) playSound(resultSound(submitted.score ?? 0)); } else { // The guess did not record. Say so instead of rendering a 99999m round, // which reads as a real miss and is indistinguishable from one. setResult({ failed: true }); + if (mountedRef.current) playSound('error'); } } catch (error) { // A throw here means the same thing as a null result: nothing was @@ -289,6 +317,7 @@ export default function GameClient({ region }) { // confident 99999m miss for a round the server never saw. console.error('Error submitting guess:', error); setResult({ failed: true }); + if (mountedRef.current) playSound('error'); } setSubmitting(false); @@ -309,6 +338,7 @@ export default function GameClient({ region }) { // Radix keeps the dialog interactive through its exit animation, so a // double-click would issue a second fetch and burn a session. if (roundLoading) return; + playSound('next'); roundEpochRef.current += 1; const epoch = roundEpochRef.current; setShowResult(false); @@ -340,6 +370,7 @@ export default function GameClient({ region }) { const handleSkipGuess = async () => { if (!imageData && !loadError) return; + playSound('skip'); try { if (sessionId) { @@ -375,6 +406,7 @@ export default function GameClient({ region }) { }; const handleGoBack = () => { + playSound('click'); router.push('/'); }; @@ -429,6 +461,22 @@ export default function GameClient({ region }) {
+ {/* Two variants, swapped by breakpoint rather than by a resize + listener: below sm the header has no room for a second pair of + 44px cells beside ThemeToggle's three, so sound collapses to one + mute-everything switch. + The breakpoint class goes on a wrapper, not on the control: the + control's own class list already sets inline-flex, and `hidden` + fights it for the same property -- whichever Tailwind emits last + wins, which is how both variants ended up visible at once. A + wrapper that is display:none also takes its child out of the + accessibility tree, so nothing is announced twice. */} + + + + + + +
+ ); + } + + const SfxIcon = sfxOn ? Volume2 : VolumeX; + + return ( + // A plain group of toggle buttons rather than an ARIA radiogroup, matching + // ThemeToggle: aria-pressed carries the state without obliging roving + // tabindex and arrow-key navigation. +
+ + +
+ ); +} diff --git a/src/app/credits/page.js b/src/app/credits/page.js index 228428e..8433efa 100644 --- a/src/app/credits/page.js +++ b/src/app/credits/page.js @@ -2,6 +2,7 @@ import Link from 'next/link'; import Image from 'next/image'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import ThemeToggle from '../components/ThemeToggle'; +import SoundToggle from '../components/SoundToggle'; export const metadata = { title: 'Credits — VNGeoGuessr', @@ -32,6 +33,16 @@ const LIBRARIES = [ { name: 'Lucide', license: 'ISC', href: 'https://lucide.dev/' }, ]; +// The shipped audio, by upstream pack rather than by file: nine effects come +// from three Kenney packs and the loop from one OpenGameArt track. Per-file +// provenance lives in public/audio/SOURCES.md. +const AUDIO_SOURCES = [ + { name: 'Interface Sounds', author: 'Kenney', href: 'https://kenney.nl/assets/interface-sounds' }, + { name: 'UI Audio', author: 'Kenney', href: 'https://kenney.nl/assets/ui-audio' }, + { name: 'Music Jingles', author: 'Kenney', href: 'https://kenney.nl/assets/music-jingles' }, + { name: 'Ambient Relaxing Loop', author: 'isaiah658', href: 'https://opengameart.org/content/ambient-relaxing-loop' }, +]; + export default function CreditsPage() { return (
@@ -42,6 +53,7 @@ export default function CreditsPage() {
+ ← Home @@ -105,6 +117,27 @@ export default function CreditsPage() { + + + Sound & music + + +

+ Every sound in the game is dedicated to the public domain under{' '} + CC0 1.0, + so none of it has to be credited. It is credited anyway. +

+
    + {AUDIO_SOURCES.map((source) => ( +
  • + {source.name} + {source.author} +
  • + ))} +
+
+
+ Open-source software diff --git a/src/app/debug/layout.js b/src/app/debug/layout.js index 9a8c2ca..a1a855f 100644 --- a/src/app/debug/layout.js +++ b/src/app/debug/layout.js @@ -2,6 +2,7 @@ import Link from 'next/link'; import { ArrowLeft } from 'lucide-react'; import { Button } from '@/components/ui/button'; import ThemeToggle from '../components/ThemeToggle'; +import SoundToggle from '../components/SoundToggle'; import DebugNav from './DebugNav'; // One shell for every debug page, styled like the game screen's app bar, so @@ -35,6 +36,7 @@ export default function DebugLayout({ children }) { +
{children}
diff --git a/src/app/layout.js b/src/app/layout.js index 5cf4c5a..fc32a00 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -1,5 +1,6 @@ import { THEME_STORAGE_KEY } from '../lib/theme'; import AppBackground from './components/AppBackground'; +import MusicPlayer from './components/MusicPlayer'; import InlineScript from './components/InlineScript'; import DebugFooter from './components/DebugFooter'; import { Analytics } from '@vercel/analytics/next'; @@ -59,6 +60,12 @@ export default function RootLayout({ children }) { paints over the body background and under every page. */} + {/* Renders nothing, and its position is load-bearing: inside + but outside the flex column below, so the loop survives every + client navigation between the menu and a round, and a null- + rendering component can never become a row in that column. */} + + {/* Sticky-footer column: pages fill the viewport via flex-1 and the footer keeps its own strip below them, so it can never overlap the game's panorama, map, or action bar — and nothing covers it. The diff --git a/src/app/page.js b/src/app/page.js index 81ae11e..2f15319 100644 --- a/src/app/page.js +++ b/src/app/page.js @@ -7,11 +7,13 @@ import React, { useState, useEffect } from "react"; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import ThemeToggle from './components/ThemeToggle'; +import SoundToggle from './components/SoundToggle'; import UsernameModal from './components/UsernameModal'; import DonateQRModal from './components/DonateQRModal'; import LeaderboardModal from './components/LeaderboardModal'; import RegionPicker from './components/RegionPicker'; import { generateRandomUsername, getUsername, setUsername } from '../lib/username'; +import { playSound } from '../lib/audio'; import { SCORE_BANDS, formatDistance } from '../lib/game'; const STEP_LABELS = [ @@ -87,6 +89,7 @@ export default function Home() { // Returns true when the click is intercepted: no saved name yet, so the // prompt opens and navigation resumes after save/skip. const handlePlayClick = (href) => { + playSound('click'); if (getUsername()) return false; setPendingHref(href); setShowUsernameModal(true); @@ -104,6 +107,7 @@ export default function Home() {
+ {/* One chip whether or not a name exists: the only way to fix a typo'd name is reopening this modal, so the entry point must always be visible -- including on phones, where it truncates diff --git a/src/lib/audio.js b/src/lib/audio.js new file mode 100644 index 0000000..72656cd --- /dev/null +++ b/src/lib/audio.js @@ -0,0 +1,280 @@ +// Game sound: one AudioContext, one buffer cache, two preferences. +// +// Client-only. Nothing here may be imported from a route handler or from the +// server-side pano modules -- it reaches for window, document and Web Audio. +// +// Playback never throws into a caller. A missing file, a decode failure or a +// browser without Web Audio all mean the same thing to the game: silence. + +export const MUSIC_STORAGE_KEY = 'vngeoguessr_music'; +export const SFX_STORAGE_KEY = 'vngeoguessr_sfx'; + +// Effects sit forward; the music is a bed under a panorama the player is +// reading, so it stays well below them. +export const SFX_VOLUME = 0.4; +export const MUSIC_VOLUME = 0.15; + +// One file per sound. Nine of them decode once and stay in the cache, which is +// why there is no sprite sheet -- sprites buy a latency win this count does +// not need, at the cost of an offset table to keep in step with the assets. +const SOUNDS = { + click: '/audio/click.mp3', + pin: '/audio/pin.mp3', + submit: '/audio/submit.mp3', + great: '/audio/great.mp3', + good: '/audio/good.mp3', + poor: '/audio/poor.mp3', + next: '/audio/next.mp3', + skip: '/audio/skip.mp3', + error: '/audio/error.mp3', +}; + +let context = null; +// url -> AudioBuffer, or the in-flight promise for one. Caching the promise is +// what stops two simultaneous plays of the same sound decoding it twice. +const buffers = new Map(); +const unlockListeners = new Set(); +const musicListeners = new Set(); +const sfxListeners = new Set(); + +// The live answer to "should this play", held in memory and only mirrored into +// localStorage. Storage is a persistence hint, not the source of truth: it +// throws on both read and write in a private window, and a mute read back out +// of a store that refused the write is a mute that never happens. +// theme.js can read on every call because it drives a class name the browser +// then owns; here the value gates playback on every single sound. +let musicEnabled = null; +let sfxEnabled = null; + +/** + * Read a stored on/off flag. + * @param {string} key Storage key. + * @returns {boolean} True unless the stored value is exactly 'off'. + */ +function readEnabled(key) { + if (typeof window === 'undefined') return true; + try { + return localStorage.getItem(key) !== 'off'; + } catch { + // Private browsing and blocked site data both throw here; sound on is the + // right answer when the choice cannot be read. + return true; + } +} + +/** + * Mirror an on/off flag into storage. Best effort by design. + * @param {string} key Storage key. + * @param {boolean} enabled Whether the sound should play. + * @returns {void} + */ +function writeEnabled(key, enabled) { + if (typeof window === 'undefined') return; + try { + localStorage.setItem(key, enabled ? 'on' : 'off'); + } catch { + // The choice will not survive a reload, but it still applies to this + // session -- the in-memory flag above is what playback actually reads. + } +} + +/** + * Whether background music should play. + * @returns {boolean} True when music is enabled. + */ +export function getStoredMusicEnabled() { + if (musicEnabled === null) musicEnabled = readEnabled(MUSIC_STORAGE_KEY); + return musicEnabled; +} + +/** + * Set the music choice, persist it if the browser allows, and tell the player. + * @param {boolean} enabled Whether music should play. + * @returns {void} + */ +export function setStoredMusicEnabled(enabled) { + musicEnabled = enabled; + writeEnabled(MUSIC_STORAGE_KEY, enabled); + for (const listener of musicListeners) listener(enabled); +} + +/** + * Whether sound effects should play. + * @returns {boolean} True when effects are enabled. + */ +export function getStoredSfxEnabled() { + if (sfxEnabled === null) sfxEnabled = readEnabled(SFX_STORAGE_KEY); + return sfxEnabled; +} + +/** + * Set the sound-effects choice, persist it if the browser allows, and tell + * every mounted control. + * @param {boolean} enabled Whether effects should play. + * @returns {void} + */ +export function setStoredSfxEnabled(enabled) { + sfxEnabled = enabled; + writeEnabled(SFX_STORAGE_KEY, enabled); + for (const listener of sfxListeners) listener(enabled); +} + +/** + * Watch the music preference. The toggle lives in the header and the player + * lives in the root layout, so they need a channel that is not React state -- + * and the game header mounts two toggles at once, only one of them visible. + * @param {Function} onChange Called with the new value on every change. + * @returns {Function} Unsubscribe. + */ +export function watchMusicPreference(onChange) { + musicListeners.add(onChange); + return () => musicListeners.delete(onChange); +} + +/** + * Watch the sound-effects preference. + * @param {Function} onChange Called with the new value on every change. + * @returns {Function} Unsubscribe. + */ +export function watchSfxPreference(onChange) { + sfxListeners.add(onChange); + return () => sfxListeners.delete(onChange); +} + +/** + * Watch for the audio context opening. The music player mounts before the + * first gesture, so it cannot start the loop itself -- it waits for this. + * @param {Function} onUnlock Called once the context is running. + * @returns {Function} Unsubscribe. + */ +export function watchUnlock(onUnlock) { + unlockListeners.add(onUnlock); + return () => unlockListeners.delete(onUnlock); +} + +/** + * Whether audio can play yet. + * @returns {boolean} True once a user gesture has opened the context. + */ +export function isUnlocked() { + return context !== null; +} + +/** + * The shared audio context, or null before the first gesture. + * @returns {AudioContext|null} The context. + */ +export function getAudioContext() { + return context; +} + +/** + * Open the audio context, or resume one the browser has suspended. Every + * browser creates it suspended until a user gesture, so the first call must + * come from inside a real event handler. Idempotent and cheap: the listeners + * below call it on every gesture for the life of the page. + * @returns {void} + */ +export function unlockAudio() { + if (typeof window === 'undefined') return; + + if (!context) { + const Ctor = window.AudioContext || window.webkitAudioContext; + // A browser with no Web Audio leaves the game silent rather than broken. + if (!Ctor) return; + try { + context = new Ctor(); + } catch { + // Constructing one can throw where audio is disabled outright. This runs + // inside a capture-phase document listener, where an escaping error + // would surface as an uncaught page error. + return; + } + for (const listener of unlockListeners) listener(); + } + + // Backgrounding a tab suspends the context on mobile, and it does not come + // back on its own -- without this, locking the phone mid-round would leave + // the game silent for the rest of the session. + if (context.state === 'suspended') { + // Safari rejects this when it is not called from a gesture. + Promise.resolve(context.resume()).catch(() => {}); + } +} + +/** + * Fetch and decode an audio file once, then serve it from memory. + * @param {string} url Path under /public. + * @returns {Promise} The decoded buffer. + */ +export function loadAudioBuffer(url) { + const cached = buffers.get(url); + if (cached) return cached; + + const pending = fetch(url) + .then((response) => { + if (!response.ok) throw new Error(`audio ${response.status} for ${url}`); + return response.arrayBuffer(); + }) + .then((data) => context.decodeAudioData(data)) + .catch((error) => { + // Drop the rejected promise, or one bad response would keep this sound + // broken for the rest of the session. + buffers.delete(url); + throw error; + }); + + buffers.set(url, pending); + return pending; +} + +/** + * Play a one-shot. Fire and forget: callers on the game path must never await + * this, and it resolves whether or not a sound was actually heard. + * @param {string} name Key of SOUNDS. + * @param {number} volume Gain, 0 to 1. + * @returns {Promise} Resolves once the sound has been started, or given up on. + */ +export async function playSound(name, volume = SFX_VOLUME) { + const url = SOUNDS[name]; + if (!url || !context || !getStoredSfxEnabled()) return; + + try { + const buffer = await loadAudioBuffer(url); + // The decode can outlive the choice that allowed it. + if (!getStoredSfxEnabled()) return; + + const source = context.createBufferSource(); + source.buffer = buffer; + const gain = context.createGain(); + gain.gain.value = volume; + source.connect(gain).connect(context.destination); + // Nothing else releases these: a long session would otherwise leave one + // dangling node pair on the destination per sound played. + source.onended = () => { + source.disconnect(); + gain.disconnect(); + }; + source.start(); + } catch { + // A missing or undecodable file must not break gameplay. + } +} + +// Unlock on the first gesture anywhere in the document, and keep listening. +// Music is on by default and the first thing a player touches might be the +// name prompt, the region picker or Play, so no single handler can own the +// opening. The listeners stay registered afterwards because unlockAudio is +// also how a context the browser suspended gets resumed -- removing them after +// the first gesture is what silenced the game for the rest of a session once +// the phone had been locked. Capture phase, so a handler that stops +// propagation cannot swallow it. +if (typeof document !== 'undefined') { + document.addEventListener('pointerdown', unlockAudio, true); + document.addEventListener('keydown', unlockAudio, true); + // Coming back to a backgrounded tab is not a gesture, but Chrome resumes on + // it and Safari at least stops refusing. + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && context) unlockAudio(); + }); +} diff --git a/tests/audio.test.js b/tests/audio.test.js new file mode 100644 index 0000000..002cd4f --- /dev/null +++ b/tests/audio.test.js @@ -0,0 +1,379 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// The suite runs in node, so lib/audio.js sees no document at import time and +// registers no gesture listener -- which is itself part of the client-safety +// rule and is asserted below. +// +// lib/audio.js holds each preference in a module-level variable once resolved, +// because storage is unreadable in a private window and playback cannot depend +// on reading it back. That makes the module stateful, so every case loads a +// fresh copy of it rather than sharing one across tests. + +// A localStorage stand-in whose reads and writes can be made to throw, which +// is what a private window or blocked site data actually does. +function fakeStorage(failing = null) { + const store = new Map(); + return { + getItem(key) { + if (failing === 'read' || failing === 'both') throw new Error('blocked'); + return store.has(key) ? store.get(key) : null; + }, + setItem(key, value) { + if (failing === 'write' || failing === 'both') throw new Error('blocked'); + store.set(key, String(value)); + }, + }; +} + +// Enough of the Web Audio API to drive our own control flow: the cache, the +// eviction on failure, the preference gates and the node wiring. The browser's +// implementation is not under test, but the code around it is, and none of it +// runs without something to stand in for a context. +function fakeAudio() { + const started = []; + const disconnected = []; + + class FakeContext { + constructor() { + this.state = 'suspended'; + this.destination = { name: 'destination' }; + this.resumes = 0; + } + + resume() { + this.resumes += 1; + this.state = 'running'; + return Promise.resolve(); + } + + decodeAudioData() { + return Promise.resolve({ duration: 1 }); + } + + createBufferSource() { + const source = { + buffer: null, + loop: false, + onended: null, + connect: (next) => next, + disconnect: () => disconnected.push('source'), + start: () => started.push(source), + }; + return source; + } + + createGain() { + return { + gain: { value: null }, + connect: (next) => next, + disconnect: () => disconnected.push('gain'), + }; + } + } + + return { FakeContext, started, disconnected }; +} + +function okResponse() { + return { ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)) }; +} + +/** + * Load a fresh copy of lib/audio.js against a given browser environment. + * @param {Object} storage localStorage stand-in. + * @param {Function|null} AudioContextCtor Constructor to expose on window, or null. + * @returns {Promise} The module's exports. + */ +async function loadAudio(storage, AudioContextCtor = null) { + vi.resetModules(); + globalThis.window = AudioContextCtor ? { AudioContext: AudioContextCtor } : {}; + globalThis.localStorage = storage; + return import('../src/lib/audio.js'); +} + +afterEach(() => { + delete globalThis.window; + delete globalThis.localStorage; + vi.unstubAllGlobals(); +}); + +describe('sound preferences', () => { + it('defaults both to on when nothing is stored', async () => { + const audio = await loadAudio(fakeStorage()); + expect(audio.getStoredMusicEnabled()).toBe(true); + expect(audio.getStoredSfxEnabled()).toBe(true); + }); + + it('round-trips a choice through storage', async () => { + const audio = await loadAudio(fakeStorage()); + + audio.setStoredMusicEnabled(false); + audio.setStoredSfxEnabled(false); + expect(audio.getStoredMusicEnabled()).toBe(false); + expect(audio.getStoredSfxEnabled()).toBe(false); + + audio.setStoredMusicEnabled(true); + audio.setStoredSfxEnabled(true); + expect(audio.getStoredMusicEnabled()).toBe(true); + expect(audio.getStoredSfxEnabled()).toBe(true); + }); + + it('keeps the two choices independent', async () => { + const audio = await loadAudio(fakeStorage()); + audio.setStoredMusicEnabled(false); + expect(audio.getStoredSfxEnabled()).toBe(true); + }); + + it('stores the exact values a later page load reads back', async () => { + const storage = fakeStorage(); + const audio = await loadAudio(storage); + + audio.setStoredMusicEnabled(false); + audio.setStoredSfxEnabled(true); + expect(storage.getItem(audio.MUSIC_STORAGE_KEY)).toBe('off'); + expect(storage.getItem(audio.SFX_STORAGE_KEY)).toBe('on'); + }); + + it('treats any value other than off as on', async () => { + const storage = fakeStorage(); + storage.setItem('vngeoguessr_music', 'garbage'); + const audio = await loadAudio(storage); + expect(audio.getStoredMusicEnabled()).toBe(true); + }); + + it('falls back to on when reading throws', async () => { + const audio = await loadAudio(fakeStorage('read')); + expect(audio.getStoredMusicEnabled()).toBe(true); + expect(audio.getStoredSfxEnabled()).toBe(true); + }); + + it('reports on when there is no window at all', async () => { + vi.resetModules(); + delete globalThis.window; + delete globalThis.localStorage; + const audio = await import('../src/lib/audio.js'); + expect(audio.getStoredMusicEnabled()).toBe(true); + expect(audio.getStoredSfxEnabled()).toBe(true); + }); + + // The whole reason the module holds these in memory: a private window + // refuses the write, and reading the flag back would report the mute never + // happened, leaving the player with no way to silence the game. + it('honours a choice this session even when the write is refused', async () => { + const audio = await loadAudio(fakeStorage('both')); + + expect(() => audio.setStoredMusicEnabled(false)).not.toThrow(); + expect(() => audio.setStoredSfxEnabled(false)).not.toThrow(); + expect(audio.getStoredMusicEnabled()).toBe(false); + expect(audio.getStoredSfxEnabled()).toBe(false); + }); +}); + +describe('preference subscriptions', () => { + it('notifies music and effects watchers with the new value', async () => { + const audio = await loadAudio(fakeStorage()); + const music = []; + const sfx = []; + const unwatchMusic = audio.watchMusicPreference((on) => music.push(on)); + const unwatchSfx = audio.watchSfxPreference((on) => sfx.push(on)); + + audio.setStoredMusicEnabled(false); + audio.setStoredSfxEnabled(false); + audio.setStoredMusicEnabled(true); + expect(music).toEqual([false, true]); + expect(sfx).toEqual([false]); + + unwatchMusic(); + unwatchSfx(); + audio.setStoredMusicEnabled(false); + audio.setStoredSfxEnabled(true); + expect(music).toEqual([false, true]); + expect(sfx).toEqual([false]); + }); + + // Two SoundToggles are mounted at once in the game header, one of them + // display:none. Both must hear about a change the other made. + it('notifies every watcher, not just the first', async () => { + const audio = await loadAudio(fakeStorage()); + const seen = []; + audio.watchSfxPreference(() => seen.push('a')); + audio.watchSfxPreference(() => seen.push('b')); + + audio.setStoredSfxEnabled(false); + expect(seen).toEqual(['a', 'b']); + }); + + it('notifies even when the write is refused', async () => { + const audio = await loadAudio(fakeStorage('write')); + const seen = []; + audio.watchMusicPreference((on) => seen.push(on)); + + audio.setStoredMusicEnabled(false); + expect(seen).toEqual([false]); + }); +}); + +describe('unlockAudio', () => { + it('stays locked and requests nothing before a gesture', async () => { + const audio = await loadAudio(fakeStorage(), fakeAudio().FakeContext); + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + expect(audio.isUnlocked()).toBe(false); + await audio.playSound('click'); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('opens the context once and tells its watchers', async () => { + const { FakeContext } = fakeAudio(); + const audio = await loadAudio(fakeStorage(), FakeContext); + let opened = 0; + audio.watchUnlock(() => { opened += 1; }); + + audio.unlockAudio(); + const first = audio.getAudioContext(); + audio.unlockAudio(); + + expect(audio.isUnlocked()).toBe(true); + expect(audio.getAudioContext()).toBe(first); + expect(opened).toBe(1); + }); + + // A backgrounded tab suspends the context on mobile and it does not come + // back on its own, so every later gesture has to try again. + it('resumes a context the browser suspended', async () => { + const { FakeContext } = fakeAudio(); + const audio = await loadAudio(fakeStorage(), FakeContext); + + audio.unlockAudio(); + const context = audio.getAudioContext(); + expect(context.resumes).toBe(1); + + context.state = 'suspended'; + audio.unlockAudio(); + expect(context.resumes).toBe(2); + }); + + it('stays silent rather than throwing when the context cannot be built', async () => { + class Hostile { + constructor() { + throw new Error('audio disabled'); + } + } + const audio = await loadAudio(fakeStorage(), Hostile); + + expect(() => audio.unlockAudio()).not.toThrow(); + expect(audio.isUnlocked()).toBe(false); + }); + + it('stays silent on a browser with no Web Audio', async () => { + const audio = await loadAudio(fakeStorage(), null); + expect(() => audio.unlockAudio()).not.toThrow(); + expect(audio.isUnlocked()).toBe(false); + }); +}); + +describe('playSound', () => { + it('fetches and decodes a sound once, however often it plays', async () => { + const { FakeContext, started } = fakeAudio(); + const audio = await loadAudio(fakeStorage(), FakeContext); + const fetchSpy = vi.fn(() => Promise.resolve(okResponse())); + vi.stubGlobal('fetch', fetchSpy); + audio.unlockAudio(); + + await audio.playSound('pin'); + await audio.playSound('pin'); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0][0]).toBe('/audio/pin.mp3'); + expect(started).toHaveLength(2); + }); + + it('releases its nodes when the sound ends', async () => { + const { FakeContext, started, disconnected } = fakeAudio(); + const audio = await loadAudio(fakeStorage(), FakeContext); + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(okResponse()))); + audio.unlockAudio(); + + await audio.playSound('click'); + started[0].onended(); + expect(disconnected).toEqual(['source', 'gain']); + }); + + // A cached rejection would keep one sound broken for the whole session. + it('retries a sound whose first fetch failed', async () => { + const { FakeContext, started } = fakeAudio(); + const audio = await loadAudio(fakeStorage(), FakeContext); + const fetchSpy = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 404 }) + .mockResolvedValue(okResponse()); + vi.stubGlobal('fetch', fetchSpy); + audio.unlockAudio(); + + await audio.playSound('error'); + expect(started).toHaveLength(0); + + await audio.playSound('error'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(started).toHaveLength(1); + }); + + it('swallows a fetch that rejects outright', async () => { + const { FakeContext } = fakeAudio(); + const audio = await loadAudio(fakeStorage(), FakeContext); + vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('offline')))); + audio.unlockAudio(); + + await expect(audio.playSound('next')).resolves.toBeUndefined(); + }); + + it('plays nothing while effects are muted', async () => { + const { FakeContext, started } = fakeAudio(); + const audio = await loadAudio(fakeStorage(), FakeContext); + const fetchSpy = vi.fn(() => Promise.resolve(okResponse())); + vi.stubGlobal('fetch', fetchSpy); + audio.unlockAudio(); + audio.setStoredSfxEnabled(false); + + await audio.playSound('submit'); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(started).toHaveLength(0); + }); + + // The regression that finding #1 described: with storage refusing writes, a + // mute used to be unreadable and every sound kept playing. + it('stays muted in a private window', async () => { + const { FakeContext, started } = fakeAudio(); + const audio = await loadAudio(fakeStorage('both'), FakeContext); + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(okResponse()))); + audio.unlockAudio(); + audio.setStoredSfxEnabled(false); + + await audio.playSound('great'); + expect(started).toHaveLength(0); + }); + + it('ignores a name that is not a sound, even once unlocked', async () => { + const { FakeContext, started } = fakeAudio(); + const audio = await loadAudio(fakeStorage(), FakeContext); + const fetchSpy = vi.fn(() => Promise.resolve(okResponse())); + vi.stubGlobal('fetch', fetchSpy); + audio.unlockAudio(); + + await expect(audio.playSound('not-a-sound')).resolves.toBeUndefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(started).toHaveLength(0); + }); + + it('plays every sound the game wires up', async () => { + const { FakeContext, started } = fakeAudio(); + const audio = await loadAudio(fakeStorage(), FakeContext); + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(okResponse()))); + audio.unlockAudio(); + + const names = ['click', 'pin', 'submit', 'great', 'good', 'poor', 'next', 'skip', 'error']; + for (const name of names) await audio.playSound(name); + expect(started).toHaveLength(names.length); + }); +}); diff --git a/tests/e2e/audio.spec.js b/tests/e2e/audio.spec.js new file mode 100644 index 0000000..8df8610 --- /dev/null +++ b/tests/e2e/audio.spec.js @@ -0,0 +1,154 @@ +import { test, expect } from '@playwright/test'; +import { stubGameApis, seedUsername, seedHintSeen } from './helpers.js'; + +// The audio layer, from the browser's side. What a headless run can honestly +// prove is state and wiring: that nothing is fetched before a gesture, that +// the toggles persist, and that the game header still fits a small phone with +// the extra control in it. Whether the sounds are any good, whether the loop +// seam is audible, and whether anything double-fires are questions for ears, +// and the plan leaves those to a manual pass. + +const MUSIC_KEY = 'vngeoguessr_music'; +const SFX_KEY = 'vngeoguessr_sfx'; + +test.beforeEach(async ({ page }) => { + await seedUsername(page, 'e2e-player'); + await seedHintSeen(page); + await stubGameApis(page, 'e2e-player'); +}); + +test('requests no audio before the first user gesture', async ({ page }) => { + const requested = []; + await page.route('**/audio/**', async (route) => { + requested.push(new URL(route.request().url()).pathname); + await route.fulfill({ status: 404, body: '' }); + }); + + await page.goto('/'); + await expect(page.getByRole('group', { name: 'Sound' }).first()).toBeVisible(); + // Every browser blocks audio until a gesture, so fetching a file before one + // would be bandwidth spent on something that cannot play. + expect(requested).toEqual([]); +}); + +test('remembers music and sound-effect choices independently', async ({ page }) => { + await page.goto('/'); + const music = page.getByRole('button', { name: 'Music' }); + const sfx = page.getByRole('button', { name: 'Sound effects' }); + + // Both default on: a first-time player hears the game. + await expect(music).toHaveAttribute('aria-pressed', 'true'); + await expect(sfx).toHaveAttribute('aria-pressed', 'true'); + + await music.click(); + await expect(music).toHaveAttribute('aria-pressed', 'false'); + await expect(sfx).toHaveAttribute('aria-pressed', 'true'); + + expect(await page.evaluate((key) => localStorage.getItem(key), MUSIC_KEY)).toBe('off'); + // Never written, because it was never touched -- and an absent key reads as + // the default, which is on. + expect(await page.evaluate((key) => localStorage.getItem(key), SFX_KEY)).toBeNull(); + + await page.reload(); + await expect(page.getByRole('button', { name: 'Music' })).toHaveAttribute('aria-pressed', 'false'); + await expect(page.getByRole('button', { name: 'Sound effects' })).toHaveAttribute('aria-pressed', 'true'); +}); + +test('carries the choice from the menu into a round', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Sound effects' }).click(); + + await page.getByRole('link', { name: /Play anywhere in Vietnam/ }).click(); + await expect(page).toHaveURL(/\/game\/vn$/); + + // The game header shows the split pair at desktop widths. + await expect(page.getByRole('button', { name: 'Sound effects' })).toHaveAttribute('aria-pressed', 'false'); + await expect(page.getByRole('button', { name: 'Music' })).toHaveAttribute('aria-pressed', 'true'); +}); + +// The game header carries Back, the region badges, ThemeToggle's three cells +// and the donate button before sound is added at all, which is why it collapses +// to a single mute switch below `sm`. +for (const width of [320, 360]) { + test(`game header fits a ${width}px viewport`, async ({ page }) => { + await page.setViewportSize({ width, height: 720 }); + await page.goto('/game/tphcm'); + await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible(); + + // One combined switch, not the pair: the pair is display:none here, which + // also keeps it out of the accessibility tree. + await expect(page.getByRole('button', { name: 'Sound', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Music' })).toHaveCount(0); + + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth + ); + expect(overflow).toBe(0); + }); +} + +test('mutes everything from the compact switch on a phone', async ({ page }) => { + await page.setViewportSize({ width: 360, height: 720 }); + await page.goto('/game/tphcm'); + await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible(); + + const mute = page.getByRole('button', { name: 'Sound', exact: true }); + await expect(mute).toHaveAttribute('aria-pressed', 'true'); + await mute.click(); + + await expect(mute).toHaveAttribute('aria-pressed', 'false'); + expect(await page.evaluate((key) => localStorage.getItem(key), MUSIC_KEY)).toBe('off'); + expect(await page.evaluate((key) => localStorage.getItem(key), SFX_KEY)).toBe('off'); +}); + +test('plays on with audio files missing', async ({ page }) => { + // A 404 on every sound must cost the player nothing but silence. The failure + // this guards against is an unhandled rejection out of fetch/decode, which + // `pageerror` does not report -- so the page records them itself. + await page.addInitScript(() => { + window.__rejections = []; + addEventListener('unhandledrejection', (event) => { + window.__rejections.push(String(event.reason)); + }); + }); + await page.route('**/audio/**', (route) => route.fulfill({ status: 404, body: '' })); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + + await page.goto('/game/tphcm'); + await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible(); + + // Effects stay ON here: muting them is what made an earlier version of this + // test exercise only the music path. + const pinRequest = page.waitForRequest((request) => request.url().includes('/audio/pin.mp3')); + const map = page.locator('.leaflet-container').first(); + await map.waitFor(); + await map.click({ position: { x: 200, y: 150 } }); + await pinRequest; + + // The round is still playable: the guess registered despite the dead sound. + await expect(page.getByRole('button', { name: 'Submit Guess' })).toBeEnabled(); + expect(errors).toEqual([]); + expect(await page.evaluate(() => window.__rejections)).toEqual([]); +}); + +// Both header variants are mounted at once, one of them display:none, so each +// has to hear about a change the other made -- otherwise rotating a phone past +// the breakpoint revealed a control showing its mount-time state, and the first +// press on it went the wrong way. +test('keeps both header variants in step across the breakpoint', async ({ page }) => { + await page.setViewportSize({ width: 360, height: 720 }); + await page.goto('/game/tphcm'); + await expect(page.getByText('Ho Chi Minh', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Sound', exact: true }).click(); + + await page.setViewportSize({ width: 900, height: 720 }); + await expect(page.getByRole('button', { name: 'Music' })).toHaveAttribute('aria-pressed', 'false'); + await expect(page.getByRole('button', { name: 'Sound effects' })).toHaveAttribute('aria-pressed', 'false'); + + // And back the other way: unmute on the pair, then check the compact switch. + await page.getByRole('button', { name: 'Music' }).click(); + await page.setViewportSize({ width: 360, height: 720 }); + await expect(page.getByRole('button', { name: 'Sound', exact: true })).toHaveAttribute('aria-pressed', 'true'); +});