playSfx method

Future<void> playSfx(
  1. String fileName, {
  2. double volume = 1.0,
  3. bool duck = false,
})

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++;
    }
  }
}