feat(audio): looping per-season background music system
MusicService (looping audioplayers player, independent of SFX) driven by the active season theme's new 'bgm' key; switches track on season change, pauses on app background, all failures swallowed. Separate Music on/off toggle in Settings (persisted, independent of SFX). Season packs carry bgm keys (menu/season_001/ season_002), manifest regenerated. Assets slot assets/audio/bgm/ ready — drop in menu.mp3/season_001.mp3/season_002.mp3 (CC0) and it plays; silent until then. 180 tests green, analyze clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
|
||||
/// Looping background music, independent of the SFX pool in [AudioService].
|
||||
/// One track plays at a time from `assets/audio/bgm/<key>.mp3`. A track change
|
||||
/// (e.g. switching seasons) restarts; toggling sound off or backgrounding the
|
||||
/// app pauses/resumes the same track. Every player call is swallowed — music
|
||||
/// must never break the app, and a missing track asset is silently ignored.
|
||||
class MusicService {
|
||||
MusicService({bool enabled = true}) : _enabled = enabled;
|
||||
|
||||
/// Background music sits well under the SFX so it never competes.
|
||||
static const double _volume = 0.45;
|
||||
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
bool _enabled;
|
||||
bool _backgrounded = false;
|
||||
String? _currentKey;
|
||||
|
||||
bool get enabled => _enabled;
|
||||
bool get _shouldPlay => _enabled && !_backgrounded && _currentKey != null;
|
||||
|
||||
/// Request a looping track by theme key (e.g. 'menu', 'season_001'). No-op
|
||||
/// if it is already the current track (avoids restarting on every rebuild).
|
||||
Future<void> playKey(String key) async {
|
||||
if (key == _currentKey) {
|
||||
await _reconcile();
|
||||
return;
|
||||
}
|
||||
_currentKey = key;
|
||||
await _restart();
|
||||
}
|
||||
|
||||
set enabled(bool value) {
|
||||
if (_enabled == value) return;
|
||||
_enabled = value;
|
||||
_reconcile();
|
||||
}
|
||||
|
||||
/// App lifecycle: pause when backgrounded, resume on return.
|
||||
void setBackgrounded(bool value) {
|
||||
if (_backgrounded == value) return;
|
||||
_backgrounded = value;
|
||||
_reconcile();
|
||||
}
|
||||
|
||||
Future<void> _restart() async {
|
||||
try {
|
||||
await _player.stop();
|
||||
if (!_shouldPlay) return;
|
||||
await _player.setReleaseMode(ReleaseMode.loop);
|
||||
await _player.play(
|
||||
AssetSource('audio/bgm/$_currentKey.mp3'),
|
||||
volume: _volume,
|
||||
);
|
||||
} catch (_) {
|
||||
// Missing asset or platform hiccup: stay silent.
|
||||
}
|
||||
}
|
||||
|
||||
/// Pause/resume the CURRENT track to match [_shouldPlay] without restarting.
|
||||
Future<void> _reconcile() async {
|
||||
try {
|
||||
if (_shouldPlay) {
|
||||
if (_player.state == PlayerState.paused) {
|
||||
await _player.resume();
|
||||
} else if (_player.state != PlayerState.playing) {
|
||||
await _restart();
|
||||
}
|
||||
} else if (_player.state == PlayerState.playing) {
|
||||
await _player.pause();
|
||||
}
|
||||
} catch (_) {
|
||||
// Never throw from lifecycle/toggle changes.
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() => _player.dispose();
|
||||
}
|
||||
Reference in New Issue
Block a user