import 'dart:async' show Completer; import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; import 'package:just_audio/just_audio.dart'; import 'package:asciineverdie/src/core/audio/sfx_channel_pool.dart'; import 'package:asciineverdie/src/core/storage/settings_repository.dart'; /// 게임 오디오 서비스 (싱글톤, 핫 리로드 안전) /// /// BGM과 SFX를 관리하며, 설정과 연동하여 볼륨을 조절합니다. /// 웹/WASM 환경에서는 제한적으로 작동할 수 있습니다. /// /// 채널 구조: /// - BGM: 단일 플레이어 (루프 재생) /// - Player SFX: 플레이어 이펙트 (공격, 스킬, 아이템 등) /// - Monster SFX: 몬스터 이펙트 (몬스터 공격 = 플레이어 피격) /// /// 모든 플레이어 인스턴스를 static으로 관리하여 핫 리로드에서도 안전합니다. class AudioService { // ───────────────────────────────────────────────────────────────────────── // 싱글톤 패턴 // ───────────────────────────────────────────────────────────────────────── /// 싱글톤 인스턴스 static AudioService? _instance; /// 싱글톤 인스턴스 반환 static AudioService get instance { _instance ??= AudioService._internal(SettingsRepository()); return _instance!; } /// 팩토리 생성자 (싱글톤 반환) factory AudioService({SettingsRepository? settingsRepository}) { _instance ??= AudioService._internal( settingsRepository ?? SettingsRepository(), ); return _instance!; } /// private 생성자 AudioService._internal(this._settingsRepository); final SettingsRepository _settingsRepository; // ───────────────────────────────────────────────────────────────────────── // static 플레이어 관리 (핫 리로드에서 유지) // ───────────────────────────────────────────────────────────────────────── /// static BGM 플레이어 (핫 리로드에서 유지) static AudioPlayer? _staticBgmPlayer; /// static 초기화 완료 여부 static bool _staticInitialized = false; /// static 초기화 진행 중 Completer (중복 초기화 방지) static Completer? _staticInitCompleter; // ───────────────────────────────────────────────────────────────────────── // 인스턴스 변수 // ───────────────────────────────────────────────────────────────────────── // SFX 채널 풀 (채널별 분리, 완료 보장) SfxChannelPool? _playerSfxPool; SfxChannelPool? _monsterSfxPool; // 채널별 풀 크기 (줄임: 동시 재생 문제 완화) static const int _playerPoolSize = 2; static const int _monsterPoolSize = 2; // 현재 볼륨 double _bgmVolume = 0.7; double _sfxVolume = 0.8; // 현재 재생 중인 BGM String? _currentBgm; // 웹에서 사용자 상호작용 대기 중인 BGM (자동재생 정책 대응) String? _pendingBgm; // 사용자 상호작용 발생 여부 (웹 자동재생 정책 우회용) bool _userInteracted = false; // 오디오 일시정지 상태 (앱 백그라운드 시) bool _isPaused = false; // ───────────────────────────────────────────────────────────────────────── // 초기화 // ───────────────────────────────────────────────────────────────────────── /// 초기화 완료 여부 bool get isInitialized => _staticInitialized; /// 서비스 초기화 Future init() async { // 이미 초기화 완료됨 if (_staticInitialized) { return; } // 초기화 진행 중이면 완료 대기 if (_staticInitCompleter != null) { await _staticInitCompleter!.future; return; } // 초기화 시작 final completer = Completer(); _staticInitCompleter = completer; try { // 설정에서 볼륨 불러오기 _bgmVolume = await _settingsRepository.loadBgmVolume(); _sfxVolume = await _settingsRepository.loadSfxVolume(); // BGM 플레이어 초기화 (순차적, 지연 포함) await _initBgmPlayer(); // 지연 후 SFX 풀 초기화 (순차적) await Future.delayed(const Duration(milliseconds: 200)); await _initSfxPools(); _staticInitialized = true; // 모바일/데스크톱에서는 자동재생 제한 없음 if (!kIsWeb) { _userInteracted = true; } debugPrint('[AudioService] Initialized successfully'); completer.complete(); } catch (e) { debugPrint('[AudioService] Init error: $e'); // 에러여도 완료 처리 (오디오 없이 게임 진행 가능) _staticInitialized = true; completer.complete(); } finally { _staticInitCompleter = null; } } /// BGM 플레이어 초기화 (재시도 포함) Future _initBgmPlayer() async { // 기존 플레이어가 있으면 재사용 (핫 리로드 대응) if (_staticBgmPlayer != null) { debugPrint('[AudioService] Reusing existing BGM player'); try { await _staticBgmPlayer!.setVolume(_bgmVolume); } catch (_) {} return; } // 새 플레이어 생성 (재시도 포함) const maxRetries = 3; const baseDelay = Duration(milliseconds: 100); for (var attempt = 0; attempt < maxRetries; attempt++) { try { if (attempt > 0) { await Future.delayed(baseDelay * (attempt + 1)); } _staticBgmPlayer = AudioPlayer(); await _staticBgmPlayer!.setLoopMode(LoopMode.one); await _staticBgmPlayer!.setVolume(_bgmVolume); debugPrint('[AudioService] BGM player created'); return; } catch (e) { debugPrint( '[AudioService] BGM player attempt ${attempt + 1} failed: $e'); _staticBgmPlayer = null; if (attempt == maxRetries - 1) { debugPrint('[AudioService] BGM disabled'); } } } } /// SFX 채널 풀 순차 초기화 Future _initSfxPools() async { // Player SFX 풀 _playerSfxPool = SfxChannelPool( name: 'Player', poolSize: _playerPoolSize, volume: _sfxVolume, ); await _playerSfxPool!.init(); // 지연 후 Monster SFX 풀 await Future.delayed(const Duration(milliseconds: 200)); _monsterSfxPool = SfxChannelPool( name: 'Monster', poolSize: _monsterPoolSize, volume: _sfxVolume, ); await _monsterSfxPool!.init(); } // ───────────────────────────────────────────────────────────────────────── // BGM 재생 // ───────────────────────────────────────────────────────────────────────── /// BGM 재생 (단순화된 버전) /// /// 여러 곳에서 동시에 호출되어도 마지막 요청만 처리합니다. Future playBgm(String name) async { if (_isPaused) return; if (!_staticInitialized) await init(); if (_currentBgm == name) return; if (_staticBgmPlayer == null) return; await _playBgmInternal(name); } /// 내부 BGM 재생 (뮤텍스 내에서 호출) Future _playBgmInternal(String name) async { final assetPath = 'assets/audio/bgm/$name.mp3'; try { // 이전 BGM이 있을 때만 stop() 호출 if (_currentBgm != null) { debugPrint('[AudioService] Stopping previous BGM: $_currentBgm'); await _staticBgmPlayer!.stop(); } debugPrint('[AudioService] Loading BGM: $assetPath'); await _staticBgmPlayer!.setAsset(assetPath); debugPrint('[AudioService] Starting BGM playback'); await _staticBgmPlayer!.play(); _currentBgm = name; _pendingBgm = null; _userInteracted = true; debugPrint('[AudioService] Playing BGM: $name'); } on PlayerInterruptedException catch (e) { debugPrint('[AudioService] BGM $name interrupted: ${e.message}'); } catch (e) { final errorStr = e.toString(); debugPrint('[AudioService] BGM error: $errorStr'); // macOS Operation Stopped 에러: 플레이어 재생성 후 재시도 if (errorStr.contains('Operation Stopped') || errorStr.contains('-11849') || errorStr.contains('abort')) { debugPrint('[AudioService] Recreating BGM player...'); await _recreateBgmPlayer(); if (_staticBgmPlayer != null) { try { await _staticBgmPlayer!.setAsset(assetPath); await _staticBgmPlayer!.play(); _currentBgm = name; _userInteracted = true; debugPrint('[AudioService] Playing BGM: $name (after recreate)'); return; } catch (retryError) { debugPrint('[AudioService] BGM retry failed: $retryError'); } } } else if (kIsWeb && errorStr.contains('NotAllowedError')) { _pendingBgm = name; debugPrint('[AudioService] BGM $name pending (autoplay blocked)'); } else { debugPrint('[AudioService] BGM play failed: $e'); } _currentBgm = null; } } /// BGM 플레이어 재생성 (에러 복구용) Future _recreateBgmPlayer() async { try { await _staticBgmPlayer?.dispose(); } catch (_) {} _staticBgmPlayer = null; try { _staticBgmPlayer = AudioPlayer(); await _staticBgmPlayer!.setLoopMode(LoopMode.one); await _staticBgmPlayer!.setVolume(_bgmVolume); debugPrint('[AudioService] BGM player recreated'); } catch (e) { debugPrint('[AudioService] Failed to recreate BGM player: $e'); _staticBgmPlayer = null; } } /// BGM 정지 Future stopBgm() async { if (_staticBgmPlayer == null) return; try { await _staticBgmPlayer!.stop(); } catch (_) {} _currentBgm = null; } /// BGM 일시정지 Future pauseBgm() async { if (_staticBgmPlayer == null) return; try { await _staticBgmPlayer!.pause(); } catch (_) {} } /// BGM 재개 Future resumeBgm() async { if (_staticBgmPlayer == null || _currentBgm == null) return; try { await _staticBgmPlayer!.play(); } catch (_) {} } // ───────────────────────────────────────────────────────────────────────── // 전체 오디오 제어 // ───────────────────────────────────────────────────────────────────────── /// 전체 오디오 일시정지 (앱 백그라운드 시) Future pauseAll() async { _isPaused = true; try { await _staticBgmPlayer?.stop(); } catch (_) {} _currentBgm = null; debugPrint('[AudioService] All audio paused'); } /// 전체 오디오 재개 (앱 포그라운드 복귀 시) Future resumeAll() async { _isPaused = false; debugPrint('[AudioService] Audio resumed'); } // ───────────────────────────────────────────────────────────────────────── // SFX 재생 // ───────────────────────────────────────────────────────────────────────── /// 플레이어 이펙트 SFX 재생 Future playPlayerSfx(String name) async { if (_isPaused) return; if (!_staticInitialized) await init(); _tryPlayPendingBgm(); await _playerSfxPool?.play('assets/audio/sfx/$name.mp3'); } /// 몬스터 이펙트 SFX 재생 Future playMonsterSfx(String name) async { if (_isPaused) return; if (!_staticInitialized) await init(); _tryPlayPendingBgm(); await _monsterSfxPool?.play('assets/audio/sfx/$name.mp3'); } /// 웹에서 대기 중인 BGM 재생 시도 void _tryPlayPendingBgm() { if (!_userInteracted && _pendingBgm != null) { _userInteracted = true; final pending = _pendingBgm; _pendingBgm = null; playBgm(pending!); } } /// SFX 재생 (레거시 호환) @Deprecated('playPlayerSfx 또는 playMonsterSfx를 사용하세요.') Future playSfx(String name) => playPlayerSfx(name); // ───────────────────────────────────────────────────────────────────────── // 볼륨 제어 // ───────────────────────────────────────────────────────────────────────── /// BGM 볼륨 설정 (0.0 ~ 1.0) Future setBgmVolume(double volume) async { _bgmVolume = volume.clamp(0.0, 1.0); if (_staticBgmPlayer != null) { try { await _staticBgmPlayer!.setVolume(_bgmVolume); } catch (_) {} } await _settingsRepository.saveBgmVolume(_bgmVolume); } /// SFX 볼륨 설정 (0.0 ~ 1.0) Future setSfxVolume(double volume) async { _sfxVolume = volume.clamp(0.0, 1.0); await _playerSfxPool?.setVolume(_sfxVolume); await _monsterSfxPool?.setVolume(_sfxVolume); await _settingsRepository.saveSfxVolume(_sfxVolume); } /// 현재 BGM 볼륨 double get bgmVolume => _bgmVolume; /// 현재 SFX 볼륨 double get sfxVolume => _sfxVolume; /// 현재 재생 중인 BGM String? get currentBgm => _currentBgm; // ───────────────────────────────────────────────────────────────────────── // 유틸리티 // ───────────────────────────────────────────────────────────────────────── /// 사용자 상호작용 발생 알림 (웹 자동재생 정책 우회) Future notifyUserInteraction() async { if (_userInteracted) return; _userInteracted = true; if (_pendingBgm != null) { final pending = _pendingBgm; _pendingBgm = null; await playBgm(pending!); } } /// 서비스 정리 Future dispose() async { try { await _staticBgmPlayer?.dispose(); } catch (_) {} _staticBgmPlayer = null; await _playerSfxPool?.dispose(); await _monsterSfxPool?.dispose(); _staticInitialized = false; } /// 모든 static 리소스 정리 (테스트용) static void resetAll() { try { _staticBgmPlayer?.dispose(); } catch (_) {} _staticBgmPlayer = null; _staticInitialized = false; _staticInitCompleter = null; _instance = null; SfxChannelPool.resetAll(); } } /// BGM 타입 열거형 enum BgmType { title, town, battle, boss, victory, } /// SFX 타입 열거형 enum SfxType { attack, hit, skill, item, click, levelUp, questComplete, evade, block, parry, } /// BgmType을 파일명으로 변환 extension BgmTypeExtension on BgmType { String get fileName => name; } /// SfxType을 파일명으로 변환 extension SfxTypeExtension on SfxType { String get fileName => switch (this) { SfxType.attack => 'attack', SfxType.hit => 'hit', SfxType.skill => 'skill', SfxType.item => 'item', SfxType.click => 'click', SfxType.levelUp => 'level_up', SfxType.questComplete => 'quest_complete', SfxType.evade => 'evade', SfxType.block => 'block', SfxType.parry => 'parry', }; }