import 'dart:async'; import 'dart:developer' as developer; import 'dart:ui' show Locale; import 'package:audio_service/audio_service.dart'; import 'package:just_audio/just_audio.dart'; import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/emisora.dart'; import '../modelos/preset_ecualizador.dart'; import 'controlador_reconexion.dart'; import 'servicio_audio_session.dart'; /// Estado de reproducción expuesto al UI. enum EstadoReproduccion { detenido, cargando, reproduciendo, pausado, /// Transient network stall: the handler is retrying with backoff (S7-R2). /// UI surfaces it as a loading indicator, never as an error dialog (S7-R3). reconectando, 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; void configurarLocalizaciones(AppLocalizations l10n) { _handler.configurarLocalizaciones(l10n); } Stream get estadoStream => _handler.playbackState.map((s) { if (s.processingState == AudioProcessingState.error) { return EstadoReproduccion.error; } if (_handler.reconectando) return EstadoReproduccion.reconectando; 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: localizedStationName( lookupAppLocalizations(const Locale('es')), 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; Stream get androidAudioSessionIdStream async* { yield _handler.androidAudioSessionId; yield* _handler.androidAudioSessionIdStream; } 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 setEcualizadorActivo(bool activo) => _handler.setEcualizadorActivo(activo); Future setBanda(int index, double db) => _handler.setBanda(index, db); } // ───────────────────────────────────────────────────────────────────────────── // AudioHandler // ───────────────────────────────────────────────────────────────────────────── class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler implements ObjetivoAudioInterrumpible { static const _timeoutCambioFuente = Duration(seconds: 12); static const _timeoutCierrePlayer = Duration(seconds: 3); static const _factorAtenuacion = 0.3; // ── Live-stream buffer (Design 7.1, S7-R1) ──────────────────────────────── // Forward jitter cushion for live radio: there is no rewind history, so the // buffer only absorbs short drops (up to roughly what was buffered when the // drop hit); on reconnect we rejoin the live edge. static const bufferMinimo = Duration(seconds: 15); static const bufferMaximo = Duration(seconds: 50); static const bufferParaIniciar = Duration(milliseconds: 2500); static const bufferTrasRebuffer = Duration(seconds: 5); /// Buffer configuration applied at [AudioPlayer] construction. Exposed so /// tests can assert the values without touching platform channels (S7-R1). static const configuracionCargaAndroid = AudioLoadConfiguration( androidLoadControl: AndroidLoadControl( minBufferDuration: bufferMinimo, maxBufferDuration: bufferMaximo, bufferForPlaybackDuration: bufferParaIniciar, bufferForPlaybackAfterRebufferDuration: bufferTrasRebuffer, prioritizeTimeOverSizeThresholds: true, ), ); AndroidEqualizer _eq = AndroidEqualizer(); late AudioPlayer _player = _crearPlayer(); StreamSubscription? _estadoPlayerSub; StreamSubscription? _bufferedSub; StreamSubscription? _eventosSub; StreamSubscription? _androidAudioSessionIdSub; final _androidAudioSessionIdController = StreamController.broadcast(); int? _androidAudioSessionId; Future _colaCambioFuente = Future.value(); int _revisionFuente = 0; Emisora? emisoraActual; double _volumen = 1.0; double get volumen => _volumen; AppLocalizations? _l10n; /// Intent-to-play flag (Designs 3.1/7.2): reflects the LAST explicit /// intent (play/pause/stop, including audio-session interruptions, which /// pause through [pausar]). The S7 reconnect state machine reads it to /// distinguish a network stall from an intentional pause. bool _intencionReproducir = false; /// Ducked state requested by the audio session (transient focus loss). bool _atenuado = false; /// Reconnect-on-stall state machine (Design 7.2, S7-R2). final ControladorReconexion _reconexion = ControladorReconexion(); /// True while the handler is inside the reconnect window. [ServicioAudio] /// maps it to [EstadoReproduccion.reconectando] so the UI shows a loading /// indicator instead of an error during retries (S7-R3). bool _reconectando = false; bool get reconectando => _reconectando; AndroidEqualizer? get ecualizador => _eq; bool _eqDisponible = false; bool get ecualizadorDisponible => _eqDisponible; bool _ecualizadorActivo = true; bool get ecualizadorActivo => _ecualizadorActivo; PresetEcualizador _presetActual = PresetEcualizador.flat; PresetEcualizador get presetActual => _presetActual; int? get androidAudioSessionId => _androidAudioSessionId; Stream get androidAudioSessionIdStream => _androidAudioSessionIdController.stream; PluriWaveAudioHandler() { _conectarStreamsPlayer(); } AppLocalizations get _textos { final actual = _l10n; if (actual != null) return actual; return lookupAppLocalizations(const Locale('es')); } void configurarLocalizaciones(AppLocalizations l10n) { _l10n = l10n; } AudioPlayer _crearPlayer() { return AudioPlayer( audioPipeline: AudioPipeline(androidAudioEffects: [_eq]), audioLoadConfiguration: configuracionCargaAndroid, ); } void _conectarStreamsPlayer() { _estadoPlayerSub = _player.playerStateStream.listen((state) { final playing = state.playing; final proc = state.processingState; if (playing && proc == ProcessingState.ready) { // Successful (re)connection: reset the backoff so the next stall // starts over, and leave the reconnect window (S7-R7). _reconexion.restablecer(); _reconectando = false; } 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, ), ); }); _bufferedSub = _player.bufferedPositionStream.listen((pos) { playbackState.add(playbackState.value.copyWith(bufferedPosition: pos)); }); _eventosSub = _player.playbackEventStream.listen( (_) {}, onError: (Object error, StackTrace stackTrace) { _gestionarErrorReproduccion(error); }, ); _androidAudioSessionIdSub = _player.androidAudioSessionIdStream.listen(( sessionId, ) { _androidAudioSessionId = sessionId; if (!_androidAudioSessionIdController.isClosed) { _androidAudioSessionIdController.add(sessionId); } }); } /// Gestiona cualquier error de reproducción de ExoPlayer. /// /// Network-class failures while the user still intends to play enter the /// reconnect state machine (S7-R2) instead of surfacing a terminal error; /// only retry exhaustion (or non-network errors) falls through to the /// existing error path, so the user sees a single error — no spam per retry. void _gestionarErrorReproduccion(Object error) { if (_intentarReconexion(error)) return; String mensaje; String codigoLog; if (error is PlayerException) { codigoLog = 'PlayerException(code=${error.code}): ${error.message}'; mensaje = _mensajeAmigable(error); } else if (error is TimeoutException) { codigoLog = 'TimeoutException: $error'; mensaje = _textos.audioErrorTimeout; } else { codigoLog = 'Error desconocido: $error'; mensaje = _textos.audioErrorGeneric; } developer.log( '[PluriWave] Error reproducción: $codigoLog', name: 'ServicioAudio', level: 900, ); _detenerReconexion(); playbackState.add( playbackState.value.copyWith( processingState: AudioProcessingState.error, playing: false, errorMessage: mensaje, ), ); emisoraActual = null; mediaItem.add(null); _player.stop().catchError((_) {}); } /// Network-class failures: ExoPlayer 2xxx source errors (no internet, bad /// URL/host, timeout) and our own source-change timeout guard. bool _esErrorDeRed(Object error) => (error is PlayerException && error.code >= 2000 && error.code < 3000) || error is TimeoutException; /// Attempts to enter (or stay in) the reconnect window. Returns true when a /// retry was scheduled and the terminal error path must be skipped. bool _intentarReconexion(Object error) { if (!_esErrorDeRed(error)) return false; final item = mediaItem.value; if (item == null) return false; final decision = _reconexion.registrarFallo( intencionReproducir: _intencionReproducir, alReintentar: () => _reintentarFuente(item), ); if (decision != DecisionReconexion.reintentar) { // ignorar (user pause/stop or interruption) keeps the player quiet; // agotado falls through to the single terminal error (S7-R2-C). if (decision == DecisionReconexion.ignorar) { _reconectando = false; } return decision == DecisionReconexion.ignorar; } _reconectando = true; developer.log( '[PluriWave] Stall de red, reintento ${_reconexion.intentos}/' '${_reconexion.maxReintentos} en ' '${_reconexion.retrasoParaIntento(_reconexion.intentos).inSeconds}s', name: 'ServicioAudio', level: 800, ); playbackState.add( playbackState.value.copyWith( processingState: AudioProcessingState.buffering, playing: false, errorMessage: null, ), ); return true; } /// Re-issues the live source through the revision-guarded source-change /// queue, so a user source switch or stop during the retry cancels it. void _reintentarFuente(MediaItem item) { if (!_intencionReproducir) { _detenerReconexion(); return; } final revision = ++_revisionFuente; _colaCambioFuente = _colaCambioFuente .catchError((_) {}) .then((_) => _cambiarFuente(item, revision)) // Failures already routed through _gestionarErrorReproduccion, which // schedules the next backoff retry or surfaces the terminal error. .catchError((_) {}); } void _detenerReconexion() { _reconexion.cancelar(); _reconectando = false; } /// 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 _textos.audioErrorNoInternet; if (code == 2002) return _textos.audioErrorInvalidUrl; if (code == 2003) return _textos.audioErrorNotFound; if (code == 2004) return _textos.audioErrorTimeout; return _textos.audioErrorCannotConnect; } if (code >= 3000 && code < 4000) { return _textos.audioErrorUnsupportedFormat; } if (code >= 4000 && code < 5000) { return _textos.audioErrorDecode; } final msg = e.message ?? ''; if (msg.contains('Cleartext') || msg.contains('cleartext')) { return _textos.audioErrorCleartext; } if (msg.contains('CERTIFICATE') || msg.contains('HandshakeException')) { return _textos.audioErrorSsl; } return _textos.audioErrorCannotPlay; } 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 { _intencionReproducir = true; // Fresh user play/source switch: restart the backoff from scratch and // leave any previous reconnect window (S7-R2). _reconexion.restablecer(); _reconectando = false; final revision = ++_revisionFuente; _colaCambioFuente = _colaCambioFuente .catchError((_) {}) .then((_) => _cambiarFuente(mediaItem, revision)); return _colaCambioFuente; } Future _cambiarFuente(MediaItem mediaItem, int revision) async { this.mediaItem.add(mediaItem); emisoraActual = _emisoraDesdeMediaItem(mediaItem); playbackState.add( playbackState.value.copyWith( processingState: AudioProcessingState.loading, playing: false, errorMessage: null, ), ); try { await _recrearPlayer(); if (revision != _revisionFuente) return; await _player.setUrl(mediaItem.id).timeout(_timeoutCambioFuente); if (revision != _revisionFuente) return; _iniciarPlaySinBloquear(mediaItem, revision); unawaited(_activarEcualizador()); } on PlayerException catch (e) { if (revision == _revisionFuente) { _gestionarErrorReproduccion(e); // Reconnect engaged: complete normally so callers do not surface a // snackbar/dialog while the handler keeps retrying (S7-R3). if (_reconectando) return; } throw Exception(_mensajeAmigable(e)); } on TimeoutException catch (e) { // A real network drop usually surfaces as our 12s source timeout: // route it through the reconnect machine instead of a terminal error. if (revision == _revisionFuente) { _gestionarErrorReproduccion(e); if (_reconectando) return; } rethrow; } on Exception catch (e, stackTrace) { developer.log( '[PluriWave] Error inesperado en playMediaItem: $e', name: 'ServicioAudio', level: 900, stackTrace: stackTrace, ); if (revision == _revisionFuente) { playbackState.add( playbackState.value.copyWith( processingState: AudioProcessingState.error, playing: false, errorMessage: _textos.audioErrorUnexpectedPlayback, ), ); emisoraActual = null; this.mediaItem.add(null); } rethrow; } } Future _recrearPlayer() async { await _estadoPlayerSub?.cancel(); await _bufferedSub?.cancel(); await _eventosSub?.cancel(); await _androidAudioSessionIdSub?.cancel(); final anterior = _player; try { await anterior.stop().timeout(_timeoutCierrePlayer); } catch (_) {} try { await anterior.dispose().timeout(_timeoutCierrePlayer); } catch (_) {} _eq = AndroidEqualizer(); _eqDisponible = false; _androidAudioSessionId = null; _player = _crearPlayer(); await _player.setVolume(_volumenEfectivo); _conectarStreamsPlayer(); } void _iniciarPlaySinBloquear(MediaItem mediaItem, int revision) { unawaited( _player.play().catchError((Object error, StackTrace stackTrace) { developer.log( '[PluriWave] Error al iniciar ${mediaItem.title}: $error', name: 'ServicioAudio', level: 900, stackTrace: stackTrace, ); if (revision == _revisionFuente) { _gestionarErrorReproduccion(error); } }), ); } Future _activarEcualizador() async { try { final params = await _eq.parameters; _eqDisponible = params.bands.isNotEmpty; if (_eqDisponible) { await _eq.setEnabled(_ecualizadorActivo); await aplicarPreset(_presetActual); } } catch (_) { _eqDisponible = false; } } /// Aplica un preset al ecualizador nativo Android. Future aplicarPreset(PresetEcualizador preset) async { _presetActual = preset; if (!_eqDisponible) return; try { await _eq.setEnabled(_ecualizadorActivo); if (!_ecualizadorActivo) return; final params = await _eq.parameters; for ( int i = 0; i < params.bands.length && i < preset.bandas.length; i++ ) { await params.bands[i].setGain( _mapearGananciaNativa( preset.bandas[i], minDecibels: params.minDecibels, maxDecibels: params.maxDecibels, ), ); } } catch (_) {} } /// Ajusta una banda individual. Future setBanda(int index, double db) async { final bandas = List.from(_presetActual.bandas); if (index >= 0 && index < bandas.length) { bandas[index] = db; _presetActual = _presetActual.copyWithBandas(bandas); } if (!_eqDisponible || !_ecualizadorActivo) return; try { final params = await _eq.parameters; if (index < params.bands.length) { await params.bands[index].setGain( _mapearGananciaNativa( db, minDecibels: params.minDecibels, maxDecibels: params.maxDecibels, ), ); } } catch (_) {} } double _mapearGananciaNativa( double db, { required double minDecibels, required double maxDecibels, }) { final normalizado = ((db.clamp(-12.0, 12.0) + 12.0) / 24.0).clamp(0.0, 1.0); return minDecibels + (normalizado * (maxDecibels - minDecibels)); } Future setEcualizadorActivo(bool activo) async { _ecualizadorActivo = activo; if (!_eqDisponible) return; try { await _eq.setEnabled(activo); if (activo) { await aplicarPreset(_presetActual); } } catch (_) {} } Future setVolumen(double vol) async { _volumen = vol.clamp(0.0, 1.0); await _player.setVolume(_volumenEfectivo); } double get _volumenEfectivo => _atenuado ? _volumen * _factorAtenuacion : _volumen; // ── ObjetivoAudioInterrumpible (audio-session seam, S3-R1) ─────────────── @override bool get intencionReproducir => _intencionReproducir; @override bool get estaReproduciendo => playbackState.value.playing; @override Future pausar() => pause(); @override Future reanudar() => play(); @override Future setAtenuado(bool atenuado) async { if (_atenuado == atenuado) return; _atenuado = atenuado; await _player.setVolume(_volumenEfectivo); } @override Future play() { _intencionReproducir = true; return _player.play(); } @override Future pause() { // User (or audio-session interruption) pause: disarm any pending retry — // a stall must never fight an intentional pause (S7-R2-B, S7-R6). _intencionReproducir = false; _detenerReconexion(); return _player.pause(); } @override Future stop() async { // User stop (including the sleep-timer fade-out stop): cancel reconnect // so retries never restart playback after a stop (S7-R6). _intencionReproducir = false; _detenerReconexion(); _revisionFuente++; 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 _estadoPlayerSub?.cancel(); await _bufferedSub?.cancel(); await _eventosSub?.cancel(); await _androidAudioSessionIdSub?.cancel(); await _player.dispose(); await _androidAudioSessionIdController.close(); } 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(), ); } }