Files
asciinevrdie/lib/src/core/audio/audio_service.dart
JiWoong Sul c02978c960 fix(core): 오디오 및 진행 루프 수정
- AudioService 디버그 로그 정리
- ProgressLoop 개선
2026-01-07 22:13:19 +09:00

484 lines
17 KiB
Dart

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<void>? _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<void> init() async {
// 이미 초기화 완료됨
if (_staticInitialized) {
return;
}
// 초기화 진행 중이면 완료 대기
if (_staticInitCompleter != null) {
await _staticInitCompleter!.future;
return;
}
// 초기화 시작
final completer = Completer<void>();
_staticInitCompleter = completer;
try {
// 설정에서 볼륨 불러오기
_bgmVolume = await _settingsRepository.loadBgmVolume();
_sfxVolume = await _settingsRepository.loadSfxVolume();
// BGM 플레이어 초기화 (순차적, 지연 포함)
await _initBgmPlayer();
// 지연 후 SFX 풀 초기화 (순차적)
await Future<void>.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<void> _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<void>.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<void> _initSfxPools() async {
// Player SFX 풀
_playerSfxPool = SfxChannelPool(
name: 'Player',
poolSize: _playerPoolSize,
volume: _sfxVolume,
);
await _playerSfxPool!.init();
// 지연 후 Monster SFX 풀
await Future<void>.delayed(const Duration(milliseconds: 200));
_monsterSfxPool = SfxChannelPool(
name: 'Monster',
poolSize: _monsterPoolSize,
volume: _sfxVolume,
);
await _monsterSfxPool!.init();
}
// ─────────────────────────────────────────────────────────────────────────
// BGM 재생
// ─────────────────────────────────────────────────────────────────────────
/// BGM 재생 (단순화된 버전)
///
/// 여러 곳에서 동시에 호출되어도 마지막 요청만 처리합니다.
Future<void> playBgm(String name) async {
if (_isPaused) return;
if (!_staticInitialized) await init();
if (_currentBgm == name) return;
if (_staticBgmPlayer == null) return;
await _playBgmInternal(name);
}
/// 내부 BGM 재생 (뮤텍스 내에서 호출)
Future<void> _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<void> _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<void> stopBgm() async {
if (_staticBgmPlayer == null) return;
try {
await _staticBgmPlayer!.stop();
} catch (_) {}
_currentBgm = null;
}
/// BGM 일시정지
Future<void> pauseBgm() async {
if (_staticBgmPlayer == null) return;
try {
await _staticBgmPlayer!.pause();
} catch (_) {}
}
/// BGM 재개
Future<void> resumeBgm() async {
if (_staticBgmPlayer == null || _currentBgm == null) return;
try {
await _staticBgmPlayer!.play();
} catch (_) {}
}
// ─────────────────────────────────────────────────────────────────────────
// 전체 오디오 제어
// ─────────────────────────────────────────────────────────────────────────
/// 전체 오디오 일시정지 (앱 백그라운드 시)
Future<void> pauseAll() async {
_isPaused = true;
try {
await _staticBgmPlayer?.stop();
} catch (_) {}
_currentBgm = null;
debugPrint('[AudioService] All audio paused');
}
/// 전체 오디오 재개 (앱 포그라운드 복귀 시)
Future<void> resumeAll() async {
_isPaused = false;
debugPrint('[AudioService] Audio resumed');
}
// ─────────────────────────────────────────────────────────────────────────
// SFX 재생
// ─────────────────────────────────────────────────────────────────────────
/// 플레이어 이펙트 SFX 재생
Future<void> playPlayerSfx(String name) async {
if (_isPaused) return;
if (!_staticInitialized) await init();
_tryPlayPendingBgm();
await _playerSfxPool?.play('assets/audio/sfx/$name.mp3');
}
/// 몬스터 이펙트 SFX 재생
Future<void> 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<void> playSfx(String name) => playPlayerSfx(name);
// ─────────────────────────────────────────────────────────────────────────
// 볼륨 제어
// ─────────────────────────────────────────────────────────────────────────
/// BGM 볼륨 설정 (0.0 ~ 1.0)
Future<void> 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<void> 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<void> notifyUserInteraction() async {
if (_userInteracted) return;
_userInteracted = true;
if (_pendingBgm != null) {
final pending = _pendingBgm;
_pendingBgm = null;
await playBgm(pending!);
}
}
/// 서비스 정리
Future<void> 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',
};
}