feat(audio): play sound effects and background music

Nine one-shot effects on the play flow and one ambient loop, both on by
default and mutable independently from every header.

lib/audio.js owns the single AudioContext, the decoded-buffer cache and
the two preferences. Those preferences live in module variables mirrored
into localStorage rather than read back from it: a private window throws
on both read and write, and re-reading before each sound turned a mute
that could not be persisted into a mute that never happened.

The gesture listeners stay registered for the life of the page. They
open the context on the first interaction, but they are also the only
thing that resumes one the browser suspended -- removing them after the
first gesture left the game silent for the rest of a session once a
phone had been locked.

MusicPlayer mounts in the root layout, not in a page, so walking from
the menu into a round does not tear down and restart the loop. It loops
a decoded buffer rather than an <audio> element, which returns to sample
zero and so has no seam.

The game header has no room below sm for a second pair of 44px cells
beside ThemeToggle's three, so sound collapses there to one
mute-everything switch. Both variants stay mounted and subscribe to the
preferences, or crossing the breakpoint would reveal a control showing
its mount-time state.

Assets are CC0 throughout, from Kenney and OpenGameArt, with per-file
provenance in public/audio/SOURCES.md. The loop was chosen on a
measurement: its first and last 250ms sit within 0.5dB, so the loop
point carries continuous energy.
This commit is contained in:
tiennm99 committed 2026-09-09 11:14:47 +07:00
1 parent 89f7a81d2d
commit 4d10f0a879
22 files changed
+1196

No files matched your search

+55
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+48
View File
@@ -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 }) {
<div className="flex items-center gap-2">
<ThemeToggle />
{/* 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. */}
<span className="sm:hidden">
<SoundToggle compact />
</span>
<span className="hidden sm:inline">
<SoundToggle />
</span>
<Button
onClick={() => setShowDonate(true)}
variant="ghost"
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { useEffect, useRef } from 'react';
import {
MUSIC_VOLUME,
getStoredMusicEnabled,
isUnlocked,
getAudioContext,
loadAudioBuffer,
watchMusicPreference,
watchUnlock,
} from '../../lib/audio';
/**
* Pick the loop the browser can actually decode.
*
* Safari only decodes Opus in WebM from 18.4, so an MP3 of the same loop ships
* beside it. MP3 carries encoder padding that decodeAudioData renders as
* silence, which puts a short gap at the loop point -- only the browsers that
* cannot take the WebM pay for it.
* @returns {string} Path to the loop file.
*/
function pickSource() {
const probe = document.createElement('audio');
return probe.canPlayType('audio/webm; codecs="opus"') !== ''
? '/audio/music.webm'
: '/audio/music.mp3';
}
/**
* The background loop. Renders nothing, and is mounted once in the root layout
* rather than per page: `/` to `/game/[region]` is a client navigation, so a
* player mounted inside either page would tear down and restart the loop on
* every move between them.
* @returns {null} Nothing to render.
*/
export default function MusicPlayer() {
// A playing audio node is not render state, so none of this lives in
// useState -- re-rendering the root layout to track a loop would be waste.
const sourceRef = useRef(null);
const aliveRef = useRef(true);
useEffect(() => {
aliveRef.current = true;
const stop = () => {
const source = sourceRef.current;
// Cleared before stop(), so a start() racing this cannot see a node that
// is on its way out and decide one is already playing.
sourceRef.current = null;
if (!source) return;
try {
source.stop();
source.disconnect();
} catch {
// Already stopped; nothing left to do.
}
};
const start = async () => {
if (sourceRef.current || !isUnlocked() || !getStoredMusicEnabled()) return;
const context = getAudioContext();
if (!context) return;
try {
const buffer = await loadAudioBuffer(pickSource());
// The decode can outlive the mount, or the player can be muted while
// it runs; either way the node must not reach the speakers.
if (!aliveRef.current || sourceRef.current || !getStoredMusicEnabled()) return;
const source = context.createBufferSource();
source.buffer = buffer;
// Looping a decoded buffer returns to sample zero, so the seam is
// sample-accurate -- which an <audio loop> element cannot promise
// because the container framing rides along with it.
source.loop = true;
const gain = context.createGain();
gain.gain.value = MUSIC_VOLUME;
source.connect(gain).connect(context.destination);
source.start();
sourceRef.current = source;
} catch {
// A missing or undecodable loop leaves the game silent, not broken.
}
};
// Two ways in: the context opening on the first gesture, and the player
// flipping the switch afterwards.
const unwatchUnlock = watchUnlock(start);
const unwatchPreference = watchMusicPreference((enabled) => {
if (enabled) start();
else stop();
});
// A gesture may already have landed before this mounted -- a click on the
// name prompt, say, on a page that then navigated here.
start();
return () => {
aliveRef.current = false;
unwatchUnlock();
unwatchPreference();
stop();
};
}, []);
return null;
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useEffect, useState } from 'react';
import { Music, Volume2, VolumeX } from 'lucide-react';
import {
getStoredMusicEnabled,
getStoredSfxEnabled,
setStoredMusicEnabled,
setStoredSfxEnabled,
unlockAudio,
watchMusicPreference,
watchSfxPreference,
} from '../../lib/audio';
/**
* Music and sound-effect switches, styled as a sibling of ThemeToggle so the
* header chrome reads as one system.
*
* The game header runs out of room below the `sm` breakpoint -- Back, the
* region badges, ThemeToggle's three cells and the donate button already fill
* a 360px viewport -- so it renders the compact variant there and the pair
* above it. Both are mounted at once, one of them display:none, which is why
* this subscribes to the preferences rather than reading them once: without
* that, rotating a phone past the breakpoint revealed a control still showing
* its mount-time state, and the first press on it went the wrong way.
*
* @param {string} className Extra classes for the caller's layout.
* @param {boolean} compact True for the one-button mute-all variant.
* @returns {JSX.Element} The control.
*/
export default function SoundToggle({ className = '', compact = false }) {
// Seeded with the same defaults the server renders, so the first client
// render matches the HTML and the effect below only ever corrects a player
// who has actually chosen otherwise. No `mounted` gate: with a default of
// on, gating would paint every load as muted and then flip.
const [musicOn, setMusicOn] = useState(true);
const [sfxOn, setSfxOn] = useState(true);
useEffect(() => {
setMusicOn(getStoredMusicEnabled());
setSfxOn(getStoredSfxEnabled());
const unwatchMusic = watchMusicPreference(setMusicOn);
const unwatchSfx = watchSfxPreference(setSfxOn);
return () => {
unwatchMusic();
unwatchSfx();
};
}, []);
// A click here is a real user gesture, which is the one thing the audio
// context needs -- so unmuting is audible immediately rather than on the
// next interaction. The preference setters notify every mounted control,
// including this one.
const applyMusic = (enabled) => {
unlockAudio();
setStoredMusicEnabled(enabled);
};
const applySfx = (enabled) => {
unlockAudio();
setStoredSfxEnabled(enabled);
};
const groupClass =
`inline-flex h-11 items-center rounded-lg border border-border bg-card ${className}`;
const cellClass = (on) =>
`flex h-11 w-11 items-center justify-center rounded-lg outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 ${
on
? 'bg-brand text-brand-foreground shadow-sm'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`;
if (compact) {
// Anything audible counts as on, so one press always silences everything.
const anyOn = musicOn || sfxOn;
const Icon = anyOn ? Volume2 : VolumeX;
return (
<div role="group" aria-label="Sound" className={groupClass}>
<button
type="button"
aria-pressed={anyOn}
aria-label="Sound"
title={anyOn ? 'Mute everything' : 'Unmute'}
onClick={() => {
applyMusic(!anyOn);
applySfx(!anyOn);
}}
className={cellClass(anyOn)}
>
<Icon className="size-4" aria-hidden="true" />
</button>
</div>
);
}
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.
<div role="group" aria-label="Sound" className={groupClass}>
<button
type="button"
aria-pressed={musicOn}
aria-label="Music"
title={musicOn ? 'Mute music' : 'Unmute music'}
onClick={() => applyMusic(!musicOn)}
className={cellClass(musicOn)}
>
<Music className="size-4" aria-hidden="true" />
</button>
<button
type="button"
aria-pressed={sfxOn}
aria-label="Sound effects"
title={sfxOn ? 'Mute sound effects' : 'Unmute sound effects'}
onClick={() => applySfx(!sfxOn)}
className={cellClass(sfxOn)}
>
<SfxIcon className="size-4" aria-hidden="true" />
</button>
</div>
);
}
+33
View File
@@ -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 (
<div className="flex-1 vn-surface">
@@ -42,6 +53,7 @@ export default function CreditsPage() {
</Link>
<div className="flex items-center gap-3">
<ThemeToggle />
<SoundToggle />
<Link href="/" className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-2">
← Home
</Link>
@@ -105,6 +117,27 @@ export default function CreditsPage() {
</CardContent>
</Card>
<Card className="bg-card border-border shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-xl font-bold text-card-foreground">Sound &amp; music</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground leading-relaxed">
<p>
Every sound in the game is dedicated to the public domain under{' '}
<ExternalLink href="https://creativecommons.org/publicdomain/zero/1.0/">CC0 1.0</ExternalLink>,
so none of it has to be credited. It is credited anyway.
</p>
<ul className="space-y-1.5">
{AUDIO_SOURCES.map((source) => (
<li key={source.name} className="flex justify-between gap-4">
<ExternalLink href={source.href}>{source.name}</ExternalLink>
<span className="text-muted-foreground/70">{source.author}</span>
</li>
))}
</ul>
</CardContent>
</Card>
<Card className="bg-card border-border shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-xl font-bold text-card-foreground">Open-source software</CardTitle>
+2
View File
@@ -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 }) {
<DebugNav />
<ThemeToggle />
<SoundToggle />
</header>
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</main>
+7
View File
@@ -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 }) {
<body> paints over the body background and under every page. */}
<AppBackground />
{/* Renders nothing, and its position is load-bearing: inside <body>
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. */}
<MusicPlayer />
{/* 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
+4
View File
@@ -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() {
</Link>
<div className="flex flex-wrap items-center justify-end gap-3">
<ThemeToggle />
<SoundToggle />
{/* 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
+280
View File
@@ -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<AudioBuffer>} 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<void>} 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();
});
}
+379
View File
@@ -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<Object>} 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);
});
});
+154
View File
@@ -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');
});