From 88050dbc7d2464ea2cab6a01b37e3d33d6e5bb3e Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 23 Mar 2026 19:35:08 +0100 Subject: [PATCH] feat: Refactor Wolf3dApp to manage audio shutdown on dispose and add audio shutdown test Signed-off-by: Hans Kokx --- .../lib/widgets/wolf3d_app.dart | 125 ++++-------------- .../game_data_directory_persistence_test.dart | 57 ++++++++ 2 files changed, 82 insertions(+), 100 deletions(-) diff --git a/packages/wolf_3d_flutter/lib/widgets/wolf3d_app.dart b/packages/wolf_3d_flutter/lib/widgets/wolf3d_app.dart index 6f2baca..4acf54d 100644 --- a/packages/wolf_3d_flutter/lib/widgets/wolf3d_app.dart +++ b/packages/wolf_3d_flutter/lib/widgets/wolf3d_app.dart @@ -1,8 +1,7 @@ library; -import 'dart:collection'; +import 'dart:async'; -import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; @@ -21,108 +20,38 @@ class Wolf3dApp extends StatefulWidget { State createState() => _Wolf3dAppState(); } -class _Wolf3dAppState extends State { - bool _isLoadingGameData = false; - String? _pickerError; +class _Wolf3dAppState extends State with WidgetsBindingObserver { + Future? _shutdownFuture; - Future _pickGameDataDirectory() async { - setState(() { - _isLoadingGameData = true; - _pickerError = null; - }); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } - try { - final String? directoryPath = await getDirectoryPath( - confirmButtonText: 'Use this folder', - ); + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + unawaited(_ensureAudioShutdown()); + super.dispose(); + } - if (directoryPath == null || directoryPath.trim().isEmpty) { - return; - } - - await widget.engine.init(directory: directoryPath.trim()); - } catch (error) { - setState(() { - _pickerError = 'Unable to load selected directory: $error'; - }); - } finally { - if (mounted) { - setState(() { - _isLoadingGameData = false; - }); - } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.detached) { + unawaited(_ensureAudioShutdown()); } } - Future _pickGameDataFiles() async { - setState(() { - _isLoadingGameData = true; - _pickerError = null; - }); - - try { - final List selectedFiles = await openFiles(); - - if (selectedFiles.isEmpty) { - return; - } - - final LinkedHashSet directories = LinkedHashSet(); - for (final XFile file in selectedFiles) { - final String directory = _directoryFromFilePath(file.path); - if (directory.isNotEmpty) { - directories.add(directory); - } - } - - if (directories.isEmpty) { - setState(() { - _pickerError = 'Selected files do not expose local filesystem paths.'; - }); - return; - } - - final List orderedDirectories = directories.toList( - growable: false, - ); - 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 ''; + Future _ensureAudioShutdown() { + final Future? existing = _shutdownFuture; + if (existing != null) { + return existing; } - 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); + final Future shutdown = widget.engine.shutdownAudio(); + _shutdownFuture = shutdown; + return shutdown; } @override @@ -130,10 +59,6 @@ class _Wolf3dAppState extends State { return widget.engine.availableGames.isEmpty ? NoGameDataScreen( configuredDataDirectory: widget.engine.configuredDataDirectory, - onPickGameDataDirectory: _pickGameDataDirectory, - onPickGameDataFiles: _pickGameDataFiles, - isLoadingGameData: _isLoadingGameData, - pickerError: _pickerError, ) : GameScreen(wolf3d: widget.engine); } diff --git a/packages/wolf_3d_flutter/test/game_data_directory_persistence_test.dart b/packages/wolf_3d_flutter/test/game_data_directory_persistence_test.dart index 5aa036e..e06422e 100644 --- a/packages/wolf_3d_flutter/test/game_data_directory_persistence_test.dart +++ b/packages/wolf_3d_flutter/test/game_data_directory_persistence_test.dart @@ -38,6 +38,46 @@ class _NoopAudio implements EngineAudio { void dispose() {} } +class _CountingAudio implements EngineAudio { + @override + WolfensteinData? activeGame; + + int stopAllAudioCallCount = 0; + int disposeCallCount = 0; + + @override + Future debugSoundTest() async {} + + @override + Future init() async {} + + @override + void playLevelMusic(Music music) {} + + @override + void playMenuMusic() {} + + @override + void playSoundEffect(SoundEffect effect) {} + + @override + void playSoundEffectId(int sfxId) {} + + @override + Future stopAllAudio() async { + stopAllAudioCallCount++; + await Future.delayed(const Duration(milliseconds: 1)); + } + + @override + void stopMusic() {} + + @override + void dispose() { + disposeCallCount++; + } +} + void main() { group('DefaultGameDataDirectoryPersistence', () { test('saves and loads configured directory', () async { @@ -129,4 +169,21 @@ void main() { expect(find.textContaining('Configured data directory:'), 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); + }); }