feat: Implement audio shutdown procedure for graceful app exit

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 17:38:18 +01:00
parent f4d6db2db0
commit a7353e45b3
3 changed files with 95 additions and 17 deletions
+2 -17
View File
@@ -143,7 +143,6 @@ class _GameScreenState extends State<GameScreen> {
DefaultRendererSettingsPersistence();
final DefaultSaveGamePersistence _savePersistence =
DefaultSaveGamePersistence();
Future<void>? _audioShutdownFuture;
/// Mirrors [WolfRendererSettings.mode] into the Flutter renderer enum.
RendererMode _rendererMode = RendererMode.hardware;
@@ -213,29 +212,15 @@ class _GameScreenState extends State<GameScreen> {
@override
void dispose() {
unawaited(_shutdownAudioForExit());
unawaited(widget.wolf3d.shutdownAudio());
super.dispose();
}
Future<void> _quitApplication() async {
await _shutdownAudioForExit();
await widget.wolf3d.shutdownAudio();
await SystemNavigator.pop();
}
Future<void> _shutdownAudioForExit() {
final existing = _audioShutdownFuture;
if (existing != null) {
return existing;
}
final shutdown = () async {
await widget.wolf3d.audio.stopAllAudio();
widget.wolf3d.audio.dispose();
}();
_audioShutdownFuture = shutdown;
return shutdown;
}
@override
Widget build(BuildContext context) {
return PopScope(
@@ -26,6 +26,8 @@ class Wolf3d {
/// Shared engine audio backend used by menus and gameplay sessions.
final EngineAudio audio;
Future<void>? _audioShutdownFuture;
/// Engine menu background color as 24-bit RGB.
int menuBackgroundRgb = 0x890000;
@@ -246,6 +248,24 @@ class Wolf3d {
return this;
}
/// Stops and disposes shared audio exactly once for app shutdown.
///
/// Repeated calls return the same in-flight/completed future so hosts can
/// safely invoke shutdown from multiple lifecycle paths.
Future<void> shutdownAudio() {
final existing = _audioShutdownFuture;
if (existing != null) {
return existing;
}
final shutdown = () async {
await audio.stopAllAudio();
audio.dispose();
}();
_audioShutdownFuture = shutdown;
return shutdown;
}
/// Loads an asset from the Flutter bundle, returning `null` when absent.
Future<ByteData?> _tryLoad(String path) async {
try {
@@ -0,0 +1,73 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
class _CountingAudio implements EngineAudio {
@override
WolfensteinData? activeGame;
int stopAllAudioCallCount = 0;
int disposeCallCount = 0;
@override
Future<void> debugSoundTest() async {}
@override
Future<void> init() async {}
@override
void playLevelMusic(Music music) {}
@override
void playMenuMusic() {}
@override
void playSoundEffect(SoundEffect effect) {}
@override
void playSoundEffectId(int sfxId) {}
@override
Future<void> stopAllAudio() async {
stopAllAudioCallCount++;
await Future<void>.delayed(const Duration(milliseconds: 1));
}
@override
void stopMusic() {}
@override
void dispose() {
disposeCallCount++;
}
}
void main() {
group('Wolf3d.shutdownAudio', () {
test('stops and disposes audio once', () async {
final audio = _CountingAudio();
final wolf3d = Wolf3d(audioBackend: audio);
await wolf3d.shutdownAudio();
await wolf3d.shutdownAudio();
expect(audio.stopAllAudioCallCount, 1);
expect(audio.disposeCallCount, 1);
});
test('concurrent calls share the same shutdown work', () async {
final audio = _CountingAudio();
final wolf3d = Wolf3d(audioBackend: audio);
await Future.wait<void>([
wolf3d.shutdownAudio(),
wolf3d.shutdownAudio(),
wolf3d.shutdownAudio(),
]);
expect(audio.stopAllAudioCallCount, 1);
expect(audio.disposeCallCount, 1);
});
});
}