353 lines
9.5 KiB
Dart
353 lines
9.5 KiB
Dart
import 'dart:typed_data';
|
|
|
|
import '../diagnostics/runtime_diagnostics.dart';
|
|
import '../lifecycle/runtime_async_gate.dart';
|
|
import '../packages/game_package.dart';
|
|
import '../packages/game_package_manifest.dart';
|
|
import '../resources/game_resource_manager.dart';
|
|
import '../resources/resource_load_limiter.dart';
|
|
import 'runtime_audio_player.dart';
|
|
|
|
// These part files only group RuntimeAudioManager private helpers. The public
|
|
// facade stays in RuntimeAudioManager so callers do not depend on extensions.
|
|
part 'runtime_audio_loading.dart';
|
|
part 'runtime_audio_debug.dart';
|
|
part 'runtime_audio_cache.dart';
|
|
|
|
class RuntimeAudioManager {
|
|
RuntimeAudioManager({
|
|
RuntimeDiagnostics? diagnostics,
|
|
RuntimeAudioPlayer Function()? playerFactory,
|
|
int maxSfxPoolSize = 8,
|
|
int? maxCacheBytes,
|
|
int? maxCacheEntries,
|
|
int maxConcurrentLoads = 4,
|
|
}) : _diagnostics = diagnostics,
|
|
_playerFactory = playerFactory ?? AudioplayersRuntimeAudioPlayer.new,
|
|
_maxSfxPoolSize = maxSfxPoolSize,
|
|
_maxCacheBytes = maxCacheBytes,
|
|
_maxCacheEntries = maxCacheEntries,
|
|
_loadLimiter = ResourceLoadLimiter(maxConcurrentLoads);
|
|
|
|
final RuntimeDiagnostics? _diagnostics;
|
|
final RuntimeAudioPlayer Function() _playerFactory;
|
|
final int _maxSfxPoolSize;
|
|
final int? _maxCacheBytes;
|
|
final int? _maxCacheEntries;
|
|
final ResourceLoadLimiter _loadLimiter;
|
|
final RuntimeAsyncGate _asyncGate = RuntimeAsyncGate(initiallyClosed: true);
|
|
final Map<String, _AudioResourceRecord> _audios = {};
|
|
final Set<RuntimeAudioPlayer> _players = {};
|
|
final Map<String, RuntimeAudioPlayback> _channels = {};
|
|
final List<RuntimeAudioPlayer> _sfxPool = [];
|
|
GamePackage? _package;
|
|
int _cacheBytes = 0;
|
|
int _accessCounter = 0;
|
|
bool _disposed = false;
|
|
|
|
int get generation => _asyncGate.generation;
|
|
|
|
bool get hasPackage => _package != null;
|
|
|
|
Future<void> mount(GamePackage package) async {
|
|
_releaseCachedAudio();
|
|
_asyncGate.activate();
|
|
_disposed = false;
|
|
_package = package;
|
|
await preloadDeclaredAudio(package.manifest);
|
|
}
|
|
|
|
void dispose() {
|
|
_disposed = true;
|
|
_asyncGate.close();
|
|
_releaseCachedAudio();
|
|
_package = null;
|
|
}
|
|
|
|
GameResourceState audioState(String keyOrPath) {
|
|
final path = _tryResolve(keyOrPath);
|
|
if (path == null) {
|
|
return GameResourceState.failed;
|
|
}
|
|
return _audios[path]?.state ?? GameResourceState.idle;
|
|
}
|
|
|
|
Object? audioError(String keyOrPath) {
|
|
final path = _tryResolve(keyOrPath);
|
|
if (path == null) {
|
|
return StateError('RuntimeAudioManager has no active package');
|
|
}
|
|
return _audios[path]?.lastError;
|
|
}
|
|
|
|
Map<String, Object?> audioDebugJson() {
|
|
final activePackage = _package;
|
|
final declaredPaths = <String>{};
|
|
final resources = <Map<String, Object?>>[];
|
|
|
|
if (activePackage != null) {
|
|
for (final entry in activePackage.manifest.resources.entries) {
|
|
final resource = entry.value;
|
|
if (resource.type != GameResourceType.audio) {
|
|
continue;
|
|
}
|
|
final path = activePackage.resolveResourcePath(entry.key);
|
|
declaredPaths.add(path);
|
|
resources.add(
|
|
_audioRecordDebugJson(
|
|
key: entry.key,
|
|
path: path,
|
|
preload: resource.preload,
|
|
declared: true,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
for (final path in _audios.keys) {
|
|
if (declaredPaths.contains(path)) {
|
|
continue;
|
|
}
|
|
resources.add(
|
|
_audioRecordDebugJson(
|
|
key: null,
|
|
path: path,
|
|
preload: null,
|
|
declared: false,
|
|
),
|
|
);
|
|
}
|
|
|
|
return {
|
|
'generation': generation,
|
|
'hasPackage': activePackage != null,
|
|
'count': resources.length,
|
|
'activeLoads': _loadLimiter.activeCount,
|
|
'pendingLoads': _loadLimiter.pendingCount,
|
|
'activePlayers': _players.length,
|
|
'pooledPlayers': _sfxPool.length,
|
|
'channels': _channels.keys.toList(growable: false)..sort(),
|
|
'resources': resources,
|
|
};
|
|
}
|
|
|
|
bool evictAudio(String keyOrPath) {
|
|
final path = _tryResolve(keyOrPath);
|
|
if (path == null) {
|
|
return false;
|
|
}
|
|
return _removeAudioRecord(path);
|
|
}
|
|
|
|
Future<bool> retryAudio(String keyOrPath) async {
|
|
evictAudio(keyOrPath);
|
|
final bytes = await _loadAudio(keyOrPath, failOnError: false);
|
|
return bytes != null;
|
|
}
|
|
|
|
Future<void> preloadDeclaredAudio(GamePackageManifest manifest) async {
|
|
final futures = <Future<void>>[];
|
|
for (final entry in manifest.resources.entries) {
|
|
final resource = entry.value;
|
|
if (resource.type != GameResourceType.audio ||
|
|
resource.preload == GameResourcePreload.lazy) {
|
|
continue;
|
|
}
|
|
|
|
final failOnError = resource.preload == GameResourcePreload.required;
|
|
futures.add(_loadAudio(entry.key, failOnError: failOnError).then((_) {}));
|
|
}
|
|
await Future.wait(futures);
|
|
}
|
|
|
|
Future<RuntimeAudioPlayback?> play(
|
|
String? keyOrPath, {
|
|
double volume = 1,
|
|
}) async {
|
|
if (_disposed) {
|
|
return null;
|
|
}
|
|
final bytes = await _loadAudio(keyOrPath, failOnError: false);
|
|
if (_disposed || bytes == null) {
|
|
return null;
|
|
}
|
|
|
|
final player = _takeSfxPlayer();
|
|
_players.add(player);
|
|
try {
|
|
await player.start(bytes, volume: volume);
|
|
} catch (error) {
|
|
_players.remove(player);
|
|
await player.dispose();
|
|
_diagnostics?.record(
|
|
type: RuntimeDiagnosticType.resourceLoadError,
|
|
message: 'Audio resource failed to play',
|
|
error: error,
|
|
context: {'resource': keyOrPath, 'generation': generation},
|
|
);
|
|
return null;
|
|
}
|
|
|
|
final playback = RuntimeAudioPlayback._(player, player.done);
|
|
playback.done.whenComplete(() async {
|
|
_players.remove(player);
|
|
if (playback.isCancelled) {
|
|
await player.dispose();
|
|
return;
|
|
}
|
|
await _releaseSfxPlayer(player);
|
|
});
|
|
return playback;
|
|
}
|
|
|
|
Future<RuntimeAudioPlayback?> playBgm(
|
|
String? keyOrPath, {
|
|
String channel = RuntimeAudioChannel.defaultBgm,
|
|
double volume = 1,
|
|
bool loop = true,
|
|
}) async {
|
|
if (_disposed) {
|
|
return null;
|
|
}
|
|
final bytes = await _loadAudio(keyOrPath, failOnError: false);
|
|
if (_disposed || bytes == null) {
|
|
return null;
|
|
}
|
|
|
|
await stopBgm(channel: channel);
|
|
if (_disposed) {
|
|
return null;
|
|
}
|
|
|
|
final player = _playerFactory();
|
|
_players.add(player);
|
|
try {
|
|
await player.start(bytes, volume: volume, loop: loop);
|
|
} catch (error) {
|
|
_players.remove(player);
|
|
await player.dispose();
|
|
_diagnostics?.record(
|
|
type: RuntimeDiagnosticType.resourceLoadError,
|
|
message: 'BGM resource failed to play',
|
|
error: error,
|
|
context: {
|
|
'resource': keyOrPath,
|
|
'channel': channel,
|
|
'generation': generation,
|
|
},
|
|
);
|
|
return null;
|
|
}
|
|
|
|
final playback = RuntimeAudioPlayback._(player, player.done);
|
|
_channels[channel] = playback;
|
|
playback.done.whenComplete(() async {
|
|
if (_channels[channel] == playback) {
|
|
_channels.remove(channel);
|
|
}
|
|
_players.remove(player);
|
|
await player.dispose();
|
|
});
|
|
return playback;
|
|
}
|
|
|
|
Future<void> pauseBgm({String channel = RuntimeAudioChannel.defaultBgm}) {
|
|
return _channels[channel]?.pause() ?? Future.value();
|
|
}
|
|
|
|
Future<void> resumeBgm({String channel = RuntimeAudioChannel.defaultBgm}) {
|
|
return _channels[channel]?.resume() ?? Future.value();
|
|
}
|
|
|
|
Future<void> stopBgm({
|
|
String channel = RuntimeAudioChannel.defaultBgm,
|
|
}) async {
|
|
final playback = _channels.remove(channel);
|
|
await playback?.stop();
|
|
}
|
|
|
|
bool hasBgm({String channel = RuntimeAudioChannel.defaultBgm}) {
|
|
return _channels.containsKey(channel);
|
|
}
|
|
|
|
Future<void> preloadGroup(String group, {bool failOnError = false}) async {
|
|
final activePackage = _package;
|
|
if (activePackage == null) {
|
|
throw StateError('RuntimeAudioManager has no active package');
|
|
}
|
|
final futures = <Future<void>>[];
|
|
for (final entry in activePackage.manifest.resources.entries) {
|
|
final resource = entry.value;
|
|
if (resource.type == GameResourceType.audio && resource.group == group) {
|
|
futures.add(
|
|
_loadAudio(entry.key, failOnError: failOnError).then((_) {}),
|
|
);
|
|
}
|
|
}
|
|
await Future.wait(futures);
|
|
}
|
|
|
|
int evictGroup(String group) {
|
|
final activePackage = _package;
|
|
if (activePackage == null) {
|
|
return 0;
|
|
}
|
|
var count = 0;
|
|
for (final entry in activePackage.manifest.resources.entries) {
|
|
final resource = entry.value;
|
|
if (resource.type != GameResourceType.audio || resource.group != group) {
|
|
continue;
|
|
}
|
|
final path = activePackage.resolveResourcePath(entry.key);
|
|
if (_removeAudioRecord(path)) {
|
|
count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
}
|
|
|
|
abstract final class RuntimeAudioChannel {
|
|
static const defaultBgm = 'bgm';
|
|
}
|
|
|
|
class RuntimeAudioPlayback {
|
|
RuntimeAudioPlayback._(this._player, this.done);
|
|
|
|
final RuntimeAudioPlayer _player;
|
|
bool _cancelled = false;
|
|
|
|
final Future<void> done;
|
|
|
|
bool get isCancelled => _cancelled;
|
|
|
|
Future<void> pause() {
|
|
return _player.pause();
|
|
}
|
|
|
|
Future<void> resume() {
|
|
return _player.resume();
|
|
}
|
|
|
|
Future<void> stop() async {
|
|
_cancelled = true;
|
|
await _player.stop();
|
|
}
|
|
|
|
Future<void> cancel() async {
|
|
_cancelled = true;
|
|
await _player.dispose();
|
|
}
|
|
}
|
|
|
|
class _AudioResourceRecord {
|
|
_AudioResourceRecord({required this.generation});
|
|
|
|
final int generation;
|
|
GameResourceState state = GameResourceState.idle;
|
|
Future<Uint8List?>? inflight;
|
|
Uint8List? bytes;
|
|
Object? lastError;
|
|
int lastUsed = 0;
|
|
}
|