import 'dart:developer' as developer; import 'package:audio_service/audio_service.dart'; import 'package:just_audio/just_audio.dart'; import '../modelos/emisora.dart'; import '../modelos/preset_ecualizador.dart'; /// Estado de reproducción expuesto al UI. enum EstadoReproduccion { detenido, cargando, reproduciendo, pausado, error } // ───────────────────────────────────────────────────────────────────────────── // Handler global — inicializado en main.dart con AudioService.init // ───────────────────────────────────────────────────────────────────────────── PluriWaveAudioHandler? _handlerGlobal; void registrarHandler(PluriWaveAudioHandler handler) { _handlerGlobal = handler; } /// Wrapper de alto nivel para el UI. class ServicioAudio { PluriWaveAudioHandler get _handler { assert(_handlerGlobal != null, 'registrarHandler() no fue llamado en main.dart'); return _handlerGlobal!; } Emisora? get emisoraActual => _handler.emisoraActual; Stream get estadoStream => _handler.playbackState.map((s) { if (s.processingState == AudioProcessingState.error) { return EstadoReproduccion.error; } if (s.processingState == AudioProcessingState.loading || s.processingState == AudioProcessingState.buffering) { return EstadoReproduccion.cargando; } if (s.playing) return EstadoReproduccion.reproduciendo; if (s.processingState == AudioProcessingState.idle) { return EstadoReproduccion.detenido; } return EstadoReproduccion.pausado; }); Future reproducir(Emisora emisora) async { final item = MediaItem( id: emisora.url, title: emisora.nombre, artist: emisora.pais ?? '', album: 'PluriWave', artUri: emisora.favicon != null && emisora.favicon!.isNotEmpty ? Uri.tryParse(emisora.favicon!) : null, extras: {'uuid': emisora.uuid}, ); await _handler.playMediaItem(item); } Future pausar() => _handler.pause(); Future reanudar() => _handler.play(); Future togglePlay() async { if (_handler.playbackState.value.playing) { await pausar(); } else { await reanudar(); } } Future detener() => _handler.stop(); Future setVolumen(double vol) => _handler.setVolumen(vol); double get volumen => _handler.volumen; bool get estaSonando => _handler.playbackState.value.playing; Future dispose() async {} // ── Ecualizador ─────────────────────────────────────────────────────────── AndroidEqualizer? get ecualizador => _handler.ecualizador; bool get ecualizadorDisponible => _handler.ecualizadorDisponible; PresetEcualizador get presetActual => _handler.presetActual; Future aplicarPreset(PresetEcualizador preset) => _handler.aplicarPreset(preset); Future setBanda(int index, double db) => _handler.setBanda(index, db); } // ───────────────────────────────────────────────────────────────────────────── // AudioHandler // ───────────────────────────────────────────────────────────────────────────── class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { final AndroidEqualizer _eq = AndroidEqualizer(); late final AudioPlayer _player = AudioPlayer( audioPipeline: AudioPipeline(androidAudioEffects: [_eq]), ); Emisora? emisoraActual; double _volumen = 1.0; double get volumen => _volumen; AndroidEqualizer? get ecualizador => _eq; bool _eqDisponible = false; bool get ecualizadorDisponible => _eqDisponible; PresetEcualizador _presetActual = PresetEcualizador.flat; PresetEcualizador get presetActual => _presetActual; PluriWaveAudioHandler() { _setupStreams(); } void _setupStreams() { _player.playerStateStream.listen((state) { final playing = state.playing; final proc = state.processingState; playbackState.add(playbackState.value.copyWith( controls: [ if (playing) MediaControl.pause else MediaControl.play, MediaControl.stop, ], systemActions: const {MediaAction.seek, MediaAction.stop}, androidCompactActionIndices: const [0], processingState: _mapProcState(proc), playing: playing, bufferedPosition: _player.bufferedPosition, speed: _player.speed, )); }); _player.bufferedPositionStream.listen((pos) { playbackState.add(playbackState.value.copyWith(bufferedPosition: pos)); }); _player.playbackEventStream.listen( (_) {}, onError: (Object error, StackTrace stackTrace) { _gestionarErrorReproduccion(error); }, ); } /// Gestiona cualquier error de reproducción de ExoPlayer. void _gestionarErrorReproduccion(Object error) { String mensaje; String codigoLog; if (error is PlayerException) { codigoLog = 'PlayerException(code=${error.code}): ${error.message}'; mensaje = _mensajeAmigable(error); } else { codigoLog = 'Error desconocido: $error'; mensaje = 'Error de reproducción'; } developer.log( '[PluriWave] Error reproducción: $codigoLog', name: 'ServicioAudio', level: 900, ); playbackState.add(playbackState.value.copyWith( processingState: AudioProcessingState.error, playing: false, errorMessage: mensaje, )); emisoraActual = null; mediaItem.add(null); _player.stop().catchError((_) {}); } /// Traduce códigos de error de ExoPlayer a mensajes para el usuario. String _mensajeAmigable(PlayerException e) { final code = e.code; if (code >= 2000 && code < 3000) { if (code == 2001) return 'Sin conexión a internet'; if (code == 2002) return 'La URL de la radio no es válida'; if (code == 2003) return 'La radio no está disponible (error 404)'; if (code == 2004) return 'Tiempo de espera agotado al conectar'; return 'No se puede conectar a la radio'; } if (code >= 3000 && code < 4000) { return 'Formato de stream no compatible'; } if (code >= 4000 && code < 5000) { return 'Error al decodificar el stream de audio'; } final msg = e.message ?? ''; if (msg.contains('Cleartext') || msg.contains('cleartext')) { return 'Esta radio usa HTTP sin cifrar (no permitido)'; } if (msg.contains('CERTIFICATE') || msg.contains('HandshakeException')) { return 'Certificado SSL inválido en la radio'; } return 'No se puede reproducir esta radio'; } AudioProcessingState _mapProcState(ProcessingState state) { return switch (state) { ProcessingState.idle => AudioProcessingState.idle, ProcessingState.loading => AudioProcessingState.loading, ProcessingState.buffering => AudioProcessingState.buffering, ProcessingState.ready => AudioProcessingState.ready, ProcessingState.completed => AudioProcessingState.completed, }; } @override Future playMediaItem(MediaItem mediaItem) async { this.mediaItem.add(mediaItem); try { await _player.stop(); await _player.setUrl(mediaItem.id); await _player.play(); emisoraActual = _emisoraDesdeMediaItem(mediaItem); await _activarEcualizador(); } on PlayerException catch (e) { _gestionarErrorReproduccion(e); throw Exception(_mensajeAmigable(e)); } on Exception catch (e) { developer.log( '[PluriWave] Error inesperado en playMediaItem: $e', name: 'ServicioAudio', level: 900, ); playbackState.add(playbackState.value.copyWith( processingState: AudioProcessingState.error, playing: false, errorMessage: 'Error inesperado al reproducir', )); emisoraActual = null; this.mediaItem.add(null); rethrow; } } Future _activarEcualizador() async { try { final params = await _eq.parameters; _eqDisponible = params.bands.isNotEmpty; if (_eqDisponible) { await _eq.setEnabled(true); await aplicarPreset(_presetActual); } } catch (_) { _eqDisponible = false; } } /// Aplica un preset al ecualizador nativo Android. Future aplicarPreset(PresetEcualizador preset) async { _presetActual = preset; if (!_eqDisponible) return; try { final params = await _eq.parameters; for (int i = 0; i < params.bands.length && i < preset.bandas.length; i++) { await params.bands[i].setGain(preset.bandas[i]); } } catch (_) {} } /// Ajusta una banda individual. Future setBanda(int index, double db) async { if (!_eqDisponible) return; final bandas = List.from(_presetActual.bandas); if (index >= 0 && index < bandas.length) { bandas[index] = db; _presetActual = _presetActual.copyWithBandas(bandas); } try { final params = await _eq.parameters; if (index < params.bands.length) { await params.bands[index].setGain(db); } } catch (_) {} } Future setVolumen(double vol) async { _volumen = vol.clamp(0.0, 1.0); await _player.setVolume(_volumen); } @override Future play() => _player.play(); @override Future pause() => _player.pause(); @override Future stop() async { await _player.stop(); emisoraActual = null; mediaItem.add(null); await super.stop(); } @override Future seek(Duration position) => _player.seek(position); @override Future onTaskRemoved() async { await stop(); await _player.dispose(); } Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) { final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id; return Emisora( uuid: uuid, nombre: mediaItem.title, url: mediaItem.id, pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null, favicon: mediaItem.artUri?.toString(), ); } }