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:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"cmake.ignoreCMakeListsMissing": true,
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"flutter": true
|
||||
"flutter": true,
|
||||
"dart": true
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
455
apps/wolf_3d_gui/lib/screens/audio_gallery.dart
Normal file
455
apps/wolf_3d_gui/lib/screens/audio_gallery.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -181,6 +181,9 @@ class _CapturingAudio implements EngineAudio {
|
||||
@override
|
||||
void stopMusic() {}
|
||||
|
||||
@override
|
||||
Future<void> stopAllAudio() async {}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,9 @@ class _SilentAudio implements EngineAudio {
|
||||
@override
|
||||
void stopMusic() {}
|
||||
|
||||
@override
|
||||
Future<void> stopAllAudio() async {}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
@@ -407,6 +407,9 @@ class _SilentAudio implements EngineAudio {
|
||||
@override
|
||||
void stopMusic() {}
|
||||
|
||||
@override
|
||||
Future<void> stopAllAudio() async {}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user