feat: Add Audio Gallery screen and integrate into Debug Tools menu

feat: Implement audio playback controls and audio management in the gallery
refactor: Update audio engine interface to include stopAllAudio method

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-20 16:15:46 +01:00
parent 03dd871a46
commit 8cca66e966
11 changed files with 505 additions and 2 deletions

View File

@@ -1,6 +1,7 @@
{
"cmake.ignoreCMakeListsMissing": true,
"chat.tools.terminal.autoApprove": {
"flutter": true
"flutter": true,
"dart": true
}
}

View File

@@ -22,6 +22,9 @@ void main() async {
runApp(
MaterialApp(
darkTheme: ThemeData.dark(useMaterial3: true),
theme: ThemeData.light(useMaterial3: true),
themeMode: ThemeMode.system,
home: wolf3d.availableGames.isEmpty
? const _NoGameDataScreen()
: GameScreen(wolf3d: wolf3d),

View File

@@ -0,0 +1,455 @@
/// Debug browser for SFX and music assets with playback controls.
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_synth.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'package:wolf_3d_gui/screens/gallery_game_selector.dart';
class _AudioRow {
final int id;
final List<String> aliases;
const _AudioRow({required this.id, required this.aliases});
String get subtitle {
if (aliases.isEmpty) {
return 'No known alias';
}
return aliases.join(', ');
}
}
/// Displays all decoded SFX and music tracks for the selected game data.
class AudioGallery extends StatefulWidget {
/// Shared app facade used to access game assets and the audio backend.
final Wolf3d wolf3d;
const AudioGallery({super.key, required this.wolf3d});
@override
State<AudioGallery> createState() => _AudioGalleryState();
}
class _AudioGalleryState extends State<AudioGallery> {
late WolfensteinData _selectedGame;
int? _playingMusicTrackIndex;
int _gridColumnsForWidth(double width) {
if (width >= 1400) return 6;
if (width >= 1100) return 5;
if (width >= 800) return 4;
if (width >= 560) return 3;
return 2;
}
static const List<SfxKey> _knownSfxKeys = [
SfxKey.openDoor,
SfxKey.closeDoor,
SfxKey.pushWall,
SfxKey.knifeAttack,
SfxKey.pistolFire,
SfxKey.machineGunFire,
SfxKey.chainGunFire,
SfxKey.enemyFire,
SfxKey.getMachineGun,
SfxKey.getAmmo,
SfxKey.getChainGun,
SfxKey.healthSmall,
SfxKey.healthLarge,
SfxKey.treasure1,
SfxKey.treasure2,
SfxKey.treasure3,
SfxKey.treasure4,
SfxKey.extraLife,
SfxKey.guardHalt,
SfxKey.dogBark,
SfxKey.dogDeath,
SfxKey.dogAttack,
SfxKey.deathScream1,
SfxKey.deathScream2,
SfxKey.deathScream3,
SfxKey.ssAlert,
SfxKey.ssDeath,
SfxKey.bossActive,
SfxKey.hansGrosseDeath,
SfxKey.schabbs,
SfxKey.schabbsDeath,
SfxKey.hitlerGreeting,
SfxKey.hitlerDeath,
SfxKey.mechaSteps,
SfxKey.ottoAlert,
SfxKey.gretelDeath,
SfxKey.levelComplete,
SfxKey.endBonus1,
SfxKey.endBonus2,
SfxKey.noBonus,
SfxKey.percent100,
];
static const List<MusicKey> _knownMusicKeys = [
MusicKey.menuTheme,
MusicKey.level01,
MusicKey.level02,
MusicKey.level03,
MusicKey.level04,
MusicKey.level05,
MusicKey.level06,
MusicKey.level07,
MusicKey.level08,
MusicKey.level09,
MusicKey.level10,
MusicKey.level11,
MusicKey.level12,
MusicKey.level13,
MusicKey.level14,
MusicKey.level15,
MusicKey.level16,
MusicKey.level17,
MusicKey.level18,
MusicKey.level19,
MusicKey.level20,
];
@override
void initState() {
super.initState();
_selectedGame =
widget.wolf3d.maybeActiveGame ?? widget.wolf3d.availableGames.first;
}
@override
void dispose() {
// Ensure debug playback does not continue after closing the gallery.
unawaited(widget.wolf3d.audio.stopAllAudio());
super.dispose();
}
Map<int, List<String>> _buildSfxAliases() {
final Map<int, Set<String>> aliasesById = {};
for (final key in _knownSfxKeys) {
final ref = _selectedGame.registry.sfx.resolve(key);
if (ref == null) {
continue;
}
aliasesById
.putIfAbsent(ref.slotIndex, () => <String>{})
.add(_readableKeyName(key.toString(), 'SfxKey('));
}
return aliasesById.map(
(id, aliases) => MapEntry(id, aliases.toList()..sort()),
);
}
Map<int, List<String>> _buildMusicAliases() {
final Map<int, Set<String>> aliasesById = {};
for (final key in _knownMusicKeys) {
final route = _selectedGame.registry.music.resolve(key);
if (route == null) {
continue;
}
aliasesById
.putIfAbsent(route.trackIndex, () => <String>{})
.add(_readableKeyName(key.toString(), 'MusicKey('));
}
return aliasesById.map(
(id, aliases) => MapEntry(id, aliases.toList()..sort()),
);
}
String _readableKeyName(String raw, String prefix) {
final String trimmed = raw.startsWith(prefix) && raw.endsWith(')')
? raw.substring(prefix.length, raw.length - 1)
: raw;
if (trimmed.isEmpty) {
return raw;
}
return trimmed.replaceAllMapped(
RegExp(r'([a-z0-9])([A-Z])'),
(match) => '${match.group(1)} ${match.group(2)}',
);
}
Future<void> _stopAllAudioPlayback() async {
await widget.wolf3d.audio.stopAllAudio();
if (!mounted) {
return;
}
setState(() {
_playingMusicTrackIndex = null;
});
}
Future<void> _selectGame(WolfensteinData game) async {
if (identical(_selectedGame, game)) {
return;
}
await _stopAllAudioPlayback();
widget.wolf3d.setActiveGame(game);
if (!mounted) {
return;
}
setState(() {
_selectedGame = game;
_playingMusicTrackIndex = null;
});
}
void _playSfx(int id) {
widget.wolf3d.audio.playSoundEffect(id);
}
Future<void> _toggleMusic(int trackIndex) async {
if (_playingMusicTrackIndex == trackIndex) {
await _stopAllAudioPlayback();
return;
}
final engineAudio = widget.wolf3d.audio;
if (engineAudio is! WolfAudio) {
return;
}
if (trackIndex < 0 || trackIndex >= _selectedGame.music.length) {
return;
}
await engineAudio.playMusic(_selectedGame.music[trackIndex]);
if (!mounted) {
return;
}
setState(() {
_playingMusicTrackIndex = trackIndex;
});
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Audio Gallery'),
actions: [
if (widget.wolf3d.availableGames.length > 1)
GalleryGameSelector(
wolf3d: widget.wolf3d,
selectedGame: _selectedGame,
onSelected: (game) {
unawaited(_selectGame(game));
},
),
],
bottom: const TabBar(
tabs: [
Tab(icon: Icon(Icons.graphic_eq), text: 'SFX'),
Tab(icon: Icon(Icons.music_note), text: 'Music'),
],
),
),
body: TabBarView(
children: [
_buildSfxTab(),
_buildMusicTab(),
],
),
),
);
}
Widget _buildSfxTab() {
final aliasesById = _buildSfxAliases();
final rows = List<_AudioRow>.generate(
_selectedGame.sounds.length,
(id) => _AudioRow(id: id, aliases: aliasesById[id] ?? const []),
);
if (rows.isEmpty) {
return const Center(child: Text('No SFX available in this game data.'));
}
return Column(
children: [
const ListTile(
leading: Icon(Icons.info_outline),
title: Text('Tap any SFX to play it once'),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final columns = _gridColumnsForWidth(constraints.maxWidth);
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 2.8,
),
itemCount: rows.length,
itemBuilder: (context, index) {
final row = rows[index];
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _playSfx(row.id),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Row(
children: [
CircleAvatar(child: Text('${row.id}')),
const SizedBox(width: 10),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SFX ${row.id}',
style: Theme.of(
context,
).textTheme.titleSmall,
),
const SizedBox(height: 2),
Text(
row.subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall,
),
],
),
),
const Icon(Icons.play_arrow),
],
),
),
),
);
},
);
},
),
),
],
);
}
Widget _buildMusicTab() {
final aliasesById = _buildMusicAliases();
final rows = List<_AudioRow>.generate(
_selectedGame.music.length,
(id) => _AudioRow(id: id, aliases: aliasesById[id] ?? const []),
);
if (rows.isEmpty) {
return const Center(
child: Text('No music tracks available in this game data.'),
);
}
return Column(
children: [
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('Tap a track to play, tap again to stop'),
subtitle: Text(
_playingMusicTrackIndex == null
? 'No track currently playing'
: 'Playing track ${_playingMusicTrackIndex!}',
),
trailing: FilledButton.icon(
onPressed: _playingMusicTrackIndex == null
? null
: () {
unawaited(_stopAllAudioPlayback());
},
icon: const Icon(Icons.stop),
label: const Text('Stop'),
),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final columns = _gridColumnsForWidth(constraints.maxWidth);
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 2.8,
),
itemCount: rows.length,
itemBuilder: (context, index) {
final row = rows[index];
final bool isPlaying = _playingMusicTrackIndex == row.id;
return Card(
color: isPlaying
? Theme.of(context).colorScheme.primaryContainer
: null,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
unawaited(_toggleMusic(row.id));
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Row(
children: [
CircleAvatar(child: Text('${row.id}')),
const SizedBox(width: 10),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Track ${row.id}',
style: Theme.of(
context,
).textTheme.titleSmall,
),
const SizedBox(height: 2),
Text(
row.subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall,
),
],
),
),
Icon(isPlaying ? Icons.stop : Icons.play_arrow),
],
),
),
),
);
},
);
},
),
),
],
);
}
}

View File

@@ -3,6 +3,7 @@ library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'package:wolf_3d_gui/screens/audio_gallery.dart';
import 'package:wolf_3d_gui/screens/sprite_gallery.dart';
import 'package:wolf_3d_gui/screens/vga_gallery.dart';
@@ -29,6 +30,21 @@ class DebugToolsScreen extends StatelessWidget {
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Card(
child: ListTile(
leading: const Icon(Icons.library_music),
title: const Text('Audio Gallery'),
subtitle: const Text('Browse and test SFX and music tracks.'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => AudioGallery(wolf3d: wolf3d),
),
);
},
),
),
Card(
child: ListTile(
leading: const Icon(Icons.image_search),

View File

@@ -6,6 +6,7 @@ abstract class EngineAudio {
void playMenuMusic();
void playLevelMusic(WolfLevel level);
void stopMusic();
Future<void> stopAllAudio();
void playSoundEffect(int sfxId);
Future<void> init();
void dispose();

View File

@@ -22,6 +22,9 @@ class CliSilentAudio implements EngineAudio {
@override
void stopMusic() {}
@override
Future<void> stopAllAudio() async {}
@override
void playSoundEffect(int sfxId) {
// Optional: You could use the terminal 'bell' character here

View File

@@ -62,7 +62,7 @@ class WolfAudio implements EngineAudio {
/// Disposes of the audio engine and frees resources.
@override
void dispose() {
stopMusic();
stopAllAudio();
_musicPlayer.dispose();
for (final player in _sfxPlayers) {
@@ -101,6 +101,16 @@ class WolfAudio implements EngineAudio {
await _musicPlayer.stop();
}
@override
Future<void> stopAllAudio() async {
if (!_isInitialized) return;
await _musicPlayer.stop();
for (final player in _sfxPlayers) {
await player.stop();
}
}
Future<void> pauseMusic() async {
if (_isInitialized) await _musicPlayer.pause();
}

View File

@@ -181,6 +181,9 @@ class _CapturingAudio implements EngineAudio {
@override
void stopMusic() {}
@override
Future<void> stopAllAudio() async {}
@override
void dispose() {}
}

View File

@@ -130,6 +130,9 @@ class _SilentAudio implements EngineAudio {
@override
void stopMusic() {}
@override
Future<void> stopAllAudio() async {}
@override
void dispose() {}
}

View File

@@ -407,6 +407,9 @@ class _SilentAudio implements EngineAudio {
@override
void stopMusic() {}
@override
Future<void> stopAllAudio() async {}
@override
void dispose() {}
}

View File

@@ -17,6 +17,11 @@ class FlutterAudioAdapter implements EngineAudio {
wolf3d.audio.stopMusic();
}
@override
Future<void> stopAllAudio() async {
await wolf3d.audio.stopAllAudio();
}
@override
void playSoundEffect(int sfxId) {
wolf3d.audio.playSoundEffect(sfxId);