feat: Refactor Wolf3dApp to manage audio shutdown on dispose and add audio shutdown test

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 19:35:08 +01:00
parent 70b4fc3fe0
commit 88050dbc7d
2 changed files with 82 additions and 100 deletions
@@ -1,8 +1,7 @@
library; library;
import 'dart:collection'; import 'dart:async';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
@@ -21,108 +20,38 @@ class Wolf3dApp extends StatefulWidget {
State<Wolf3dApp> createState() => _Wolf3dAppState(); State<Wolf3dApp> createState() => _Wolf3dAppState();
} }
class _Wolf3dAppState extends State<Wolf3dApp> { class _Wolf3dAppState extends State<Wolf3dApp> with WidgetsBindingObserver {
bool _isLoadingGameData = false; Future<void>? _shutdownFuture;
String? _pickerError;
Future<void> _pickGameDataDirectory() async { @override
setState(() { void initState() {
_isLoadingGameData = true; super.initState();
_pickerError = null; WidgetsBinding.instance.addObserver(this);
});
try {
final String? directoryPath = await getDirectoryPath(
confirmButtonText: 'Use this folder',
);
if (directoryPath == null || directoryPath.trim().isEmpty) {
return;
} }
await widget.engine.init(directory: directoryPath.trim()); @override
} catch (error) { void dispose() {
setState(() { WidgetsBinding.instance.removeObserver(this);
_pickerError = 'Unable to load selected directory: $error'; unawaited(_ensureAudioShutdown());
}); super.dispose();
} finally {
if (mounted) {
setState(() {
_isLoadingGameData = false;
});
}
}
} }
Future<void> _pickGameDataFiles() async { @override
setState(() { void didChangeAppLifecycleState(AppLifecycleState state) {
_isLoadingGameData = true; if (state == AppLifecycleState.detached) {
_pickerError = null; unawaited(_ensureAudioShutdown());
});
try {
final List<XFile> selectedFiles = await openFiles();
if (selectedFiles.isEmpty) {
return;
}
final LinkedHashSet<String> directories = LinkedHashSet<String>();
for (final XFile file in selectedFiles) {
final String directory = _directoryFromFilePath(file.path);
if (directory.isNotEmpty) {
directories.add(directory);
} }
} }
if (directories.isEmpty) { Future<void> _ensureAudioShutdown() {
setState(() { final Future<void>? existing = _shutdownFuture;
_pickerError = 'Selected files do not expose local filesystem paths.'; if (existing != null) {
}); return existing;
return;
} }
final List<String> orderedDirectories = directories.toList( final Future<void> shutdown = widget.engine.shutdownAudio();
growable: false, _shutdownFuture = shutdown;
); return shutdown;
await widget.engine.init(
directory: orderedDirectories.first,
additionalDirectories: orderedDirectories.skip(1),
);
} catch (error) {
setState(() {
_pickerError = 'Unable to load selected files: $error';
});
} finally {
if (mounted) {
setState(() {
_isLoadingGameData = false;
});
}
}
}
String _directoryFromFilePath(String path) {
final String trimmedPath = path.trim();
if (trimmedPath.isEmpty) {
return '';
}
final int slashIndex = trimmedPath.lastIndexOf('/');
final int backslashIndex = trimmedPath.lastIndexOf(r'\');
final int separatorIndex = slashIndex > backslashIndex
? slashIndex
: backslashIndex;
if (separatorIndex < 0) {
return '';
}
if (separatorIndex == 0) {
return trimmedPath[0];
}
return trimmedPath.substring(0, separatorIndex);
} }
@override @override
@@ -130,10 +59,6 @@ class _Wolf3dAppState extends State<Wolf3dApp> {
return widget.engine.availableGames.isEmpty return widget.engine.availableGames.isEmpty
? NoGameDataScreen( ? NoGameDataScreen(
configuredDataDirectory: widget.engine.configuredDataDirectory, configuredDataDirectory: widget.engine.configuredDataDirectory,
onPickGameDataDirectory: _pickGameDataDirectory,
onPickGameDataFiles: _pickGameDataFiles,
isLoadingGameData: _isLoadingGameData,
pickerError: _pickerError,
) )
: GameScreen(wolf3d: widget.engine); : GameScreen(wolf3d: widget.engine);
} }
@@ -38,6 +38,46 @@ class _NoopAudio implements EngineAudio {
void dispose() {} void dispose() {}
} }
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() { void main() {
group('DefaultGameDataDirectoryPersistence', () { group('DefaultGameDataDirectoryPersistence', () {
test('saves and loads configured directory', () async { test('saves and loads configured directory', () async {
@@ -129,4 +169,21 @@ void main() {
expect(find.textContaining('Configured data directory:'), findsOneWidget); expect(find.textContaining('Configured data directory:'), findsOneWidget);
expect(find.textContaining('/tmp/wolf-data'), findsOneWidget); expect(find.textContaining('/tmp/wolf-data'), findsOneWidget);
}); });
testWidgets('Wolf3dApp dispose path shuts down audio', (tester) async {
final audio = _CountingAudio();
final wolf3d = Wolf3dFlutterEngine(audioBackend: audio);
await tester.pumpWidget(
MaterialApp(
home: Wolf3dApp(engine: wolf3d),
),
);
await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink()));
await tester.pump(const Duration(milliseconds: 10));
expect(audio.stopAllAudioCallCount, 1);
expect(audio.disposeCallCount, 1);
});
} }