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 _audios = {}; final Set _players = {}; final Map _channels = {}; final List _sfxPool = []; GamePackage? _package; int _cacheBytes = 0; int _accessCounter = 0; bool _disposed = false; int get generation => _asyncGate.generation; bool get hasPackage => _package != null; Future 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 audioDebugJson() { final activePackage = _package; final declaredPaths = {}; final resources = >[]; 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 retryAudio(String keyOrPath) async { evictAudio(keyOrPath); final bytes = await _loadAudio(keyOrPath, failOnError: false); return bytes != null; } Future preloadDeclaredAudio(GamePackageManifest manifest) async { final futures = >[]; 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 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 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 pauseBgm({String channel = RuntimeAudioChannel.defaultBgm}) { return _channels[channel]?.pause() ?? Future.value(); } Future resumeBgm({String channel = RuntimeAudioChannel.defaultBgm}) { return _channels[channel]?.resume() ?? Future.value(); } Future 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 preloadGroup(String group, {bool failOnError = false}) async { final activePackage = _package; if (activePackage == null) { throw StateError('RuntimeAudioManager has no active package'); } final futures = >[]; 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 done; bool get isCancelled => _cancelled; Future pause() { return _player.pause(); } Future resume() { return _player.resume(); } Future stop() async { _cancelled = true; await _player.stop(); } Future cancel() async { _cancelled = true; await _player.dispose(); } } class _AudioResourceRecord { _AudioResourceRecord({required this.generation}); final int generation; GameResourceState state = GameResourceState.idle; Future? inflight; Uint8List? bytes; Object? lastError; int lastUsed = 0; }