playSfx method
Plays a one-shot SFX from a CONSUMING app's own assets (e.g.
assets/audio/tap.mp3), respecting the same muted state as the bgm
track. No-ops immediately when muted — doesn't even touch the audio
cache. Borrows an AudioPlayer from _sfxPool (ENH-79) instead of
creating+disposing a fresh one every call, so rapid overlapping taps
each still play independently (up to sfxPoolCapacity concurrently
pooled, more than that still plays — just via a short-lived extra
instance instead of a pooled one) without opening unbounded native
audio channels during a combo/SFX burst.
duck = true (IDEA-45) temporarily lowers the bgm volume for the
duration of this SFX (e.g. a win/lose stinger that should read as more
important than the music) — restored once this SFX finishes, or once
every OTHER concurrently-playing ducked SFX also finishes, whichever is
later.
Implementation
Future<void> playSfx(
String fileName, {
double volume = 1.0,
bool duck = false,
}) async {
if (muted.value) return;
if (duck) _duckBgm();
// Acquired INSIDE the try (not before it) — the pool's `create`
// callback (a fresh AudioPlayer constructor) can throw/reject (e.g. no
// audio plugin available in a test), and that must be caught same as a
// play() failure. Stays in scope for `finally` via this nullable local
// so a failed acquire (player still null) has nothing to release.
AudioPlayer? player;
try {
player = _sfxPool.acquire();
// ENH-95: `volume` stays the call site's own per-SFX relative weight
// (a quieter footstep vs. a louder explosion); `sfxVolume` is the
// player's overall SFX mix preference layered on top of it.
await player.play(
AssetSource(fileName),
volume: volume * sfxVolume.value,
);
// Wait for the SFX to actually finish before releasing (BUG-24) —
// releasing right after play() returns would cut the sound off,
// since play() resolves once playback STARTS, not once it ends.
// Subscribed only AFTER play() succeeds (not before): AudioPlayer's
// own internal creation can fail (e.g. no audio plugin available, as
// in a plain unit test), and subscribing to onPlayerComplete first
// observably interferes with that failure reaching this catch block
// — confirmed by reverting just this ordering and watching the same
// existing test throw uncaught. A timeout is a safety net for a
// platform that never fires onPlayerComplete (or a hung/looping
// asset) — better to leak this one wait than never release at all.
await player.onPlayerComplete.first.timeout(const Duration(seconds: 30));
} catch (e) {
// no audio backend (tests), missing asset, or the completion timeout
// above → swallow, don't crash
dlog('playSfx failed for $fileName: $e');
} finally {
if (duck) _unduckBgm();
if (player != null) {
// Synchronous, not awaited — the pool's own bookkeeping
// (activeCount/totalCreated) updates immediately, which is what
// debugSfxPoolActiveCount/debugSfxTotalCreated rely on being
// deterministically testable right after this call returns. Any
// actual native stop()/dispose() the pool triggers internally is
// fire-and-forget (see _sfxPool's `reset`/`dispose` callbacks).
//
// Guarded: onClose() (see below) can run _sfxPool.disposeAll()
// while THIS call is still in flight — that already disposes
// `player` out from under it, so release() would otherwise throw
// "not currently active" on top of whatever else was going on.
// Best-effort, same as every other cleanup op in this class.
try {
_sfxPool.release(player);
} catch (_) {
// ignore — player was already torn down by onClose().
}
debugSfxReleaseCount++;
}
}
}