diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index ccddca0..26847d0 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -477,6 +477,7 @@ "playbackStatusConnecting": "جارٍ الاتصال...", "playbackStatusLive": "مباشر", "playbackStatusPaused": "متوقف مؤقتًا", + "playbackStatusReconnecting": "جارٍ إعادة الاتصال...", "playbackStatusConnectionError": "خطأ في الاتصال", "playbackStatusStopped": "متوقف", "stationSemanticLabel": "محطة {stationName}", diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 8cb4a57..579114a 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -477,6 +477,7 @@ "playbackStatusConnecting": "সংযুক্ত হচ্ছে...", "playbackStatusLive": "লাইভ", "playbackStatusPaused": "বিরতিতে", + "playbackStatusReconnecting": "পুনরায় সংযোগ করা হচ্ছে...", "playbackStatusConnectionError": "সংযোগে ত্রুটি", "playbackStatusStopped": "বন্ধ", "stationSemanticLabel": "স্টেশন {stationName}", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index cbf82ca..78da69b 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -477,6 +477,7 @@ "playbackStatusConnecting": "Verbindung wird hergestellt...", "playbackStatusLive": "Live", "playbackStatusPaused": "Pausiert", + "playbackStatusReconnecting": "Wird neu verbunden...", "playbackStatusConnectionError": "Verbindungsfehler", "playbackStatusStopped": "Gestoppt", "stationSemanticLabel": "Sender {stationName}", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 80f8e03..cd29deb 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -477,6 +477,7 @@ "playbackStatusConnecting": "Connecting...", "playbackStatusLive": "Live", "playbackStatusPaused": "Paused", + "playbackStatusReconnecting": "Reconnecting...", "playbackStatusConnectionError": "Connection error", "playbackStatusStopped": "Stopped", "stationSemanticLabel": "Station {stationName}", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 3e0bfe7..c973297 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -473,6 +473,7 @@ "playbackStatusConnecting": "Conectando...", "playbackStatusLive": "En directo", "playbackStatusPaused": "Pausado", + "playbackStatusReconnecting": "Reconectando...", "playbackStatusConnectionError": "Error de conexión", "playbackStatusStopped": "Detenido", "stationSemanticLabel": "Emisora {stationName}", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 46ec909..7faf378 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -477,6 +477,7 @@ "playbackStatusConnecting": "Connexion...", "playbackStatusLive": "En direct", "playbackStatusPaused": "En pause", + "playbackStatusReconnecting": "Reconnexion...", "playbackStatusConnectionError": "Erreur de connexion", "playbackStatusStopped": "Arrêté", "stationSemanticLabel": "Station {stationName}", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 962dd64..ba6c81e 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -477,6 +477,7 @@ "playbackStatusConnecting": "कनेक्ट हो रहा है...", "playbackStatusLive": "लाइव", "playbackStatusPaused": "विराम पर", + "playbackStatusReconnecting": "पुनः कनेक्ट हो रहा है...", "playbackStatusConnectionError": "कनेक्शन त्रुटि", "playbackStatusStopped": "बंद", "stationSemanticLabel": "स्टेशन {stationName}", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 5c5794a..31a6ec5 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -477,6 +477,7 @@ "playbackStatusConnecting": "Menghubungkan...", "playbackStatusLive": "Siaran langsung", "playbackStatusPaused": "Dijeda", + "playbackStatusReconnecting": "Menyambung ulang...", "playbackStatusConnectionError": "Kesalahan koneksi", "playbackStatusStopped": "Dihentikan", "stationSemanticLabel": "Stasiun {stationName}", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 2f150df..95873b0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -477,6 +477,7 @@ "playbackStatusConnecting": "Connessione...", "playbackStatusLive": "In diretta", "playbackStatusPaused": "In pausa", + "playbackStatusReconnecting": "Riconnessione...", "playbackStatusConnectionError": "Errore di connessione", "playbackStatusStopped": "Interrotto", "stationSemanticLabel": "Stazione {stationName}", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index c64f601..66e5c7a 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -477,6 +477,7 @@ "playbackStatusConnecting": "接続中...", "playbackStatusLive": "ライブ", "playbackStatusPaused": "一時停止中", + "playbackStatusReconnecting": "再接続中...", "playbackStatusConnectionError": "接続エラー", "playbackStatusStopped": "停止中", "stationSemanticLabel": "ラジオ局 {stationName}", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index e0507af..5f71997 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -477,6 +477,7 @@ "playbackStatusConnecting": "Conectando...", "playbackStatusLive": "Ao vivo", "playbackStatusPaused": "Pausado", + "playbackStatusReconnecting": "Reconectando...", "playbackStatusConnectionError": "Erro de conexão", "playbackStatusStopped": "Parado", "stationSemanticLabel": "Estação {stationName}", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 5a10a3b..2f10c2c 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -477,6 +477,7 @@ "playbackStatusConnecting": "Подключение...", "playbackStatusLive": "В эфире", "playbackStatusPaused": "Приостановлено", + "playbackStatusReconnecting": "Переподключение...", "playbackStatusConnectionError": "Ошибка подключения", "playbackStatusStopped": "Остановлено", "stationSemanticLabel": "Станция {stationName}", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index fb0b29c..e10efff 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -477,6 +477,7 @@ "playbackStatusConnecting": "正在连接...", "playbackStatusLive": "直播中", "playbackStatusPaused": "已暂停", + "playbackStatusReconnecting": "正在重新连接...", "playbackStatusConnectionError": "连接错误", "playbackStatusStopped": "已停止", "stationSemanticLabel": "电台 {stationName}", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 8c3eeb7..0da5fcf 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1730,6 +1730,12 @@ abstract class AppLocalizations { /// **'Pausado'** String get playbackStatusPaused; + /// No description provided for @playbackStatusReconnecting. + /// + /// In es, this message translates to: + /// **'Reconectando...'** + String get playbackStatusReconnecting; + /// No description provided for @playbackStatusConnectionError. /// /// In es, this message translates to: diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index c2bbb01..b4fd192 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -919,6 +919,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get playbackStatusPaused => 'متوقف مؤقتًا'; + @override + String get playbackStatusReconnecting => 'جارٍ إعادة الاتصال...'; + @override String get playbackStatusConnectionError => 'خطأ في الاتصال'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 8291384..a7c54b4 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -928,6 +928,9 @@ class AppLocalizationsBn extends AppLocalizations { @override String get playbackStatusPaused => 'বিরতিতে'; + @override + String get playbackStatusReconnecting => 'পুনরায় সংযোগ করা হচ্ছে...'; + @override String get playbackStatusConnectionError => 'সংযোগে ত্রুটি'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index de96688..0527a86 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -930,6 +930,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get playbackStatusPaused => 'Pausiert'; + @override + String get playbackStatusReconnecting => 'Wird neu verbunden...'; + @override String get playbackStatusConnectionError => 'Verbindungsfehler'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index cab250f..48169bf 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -923,6 +923,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get playbackStatusPaused => 'Paused'; + @override + String get playbackStatusReconnecting => 'Reconnecting...'; + @override String get playbackStatusConnectionError => 'Connection error'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 115b00d..f99d9f7 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -927,6 +927,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get playbackStatusPaused => 'Pausado'; + @override + String get playbackStatusReconnecting => 'Reconectando...'; + @override String get playbackStatusConnectionError => 'Error de conexión'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index be2ad5c..2d79417 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -933,6 +933,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get playbackStatusPaused => 'En pause'; + @override + String get playbackStatusReconnecting => 'Reconnexion...'; + @override String get playbackStatusConnectionError => 'Erreur de connexion'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index 6eabde2..3aa6d4f 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -924,6 +924,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get playbackStatusPaused => 'विराम पर'; + @override + String get playbackStatusReconnecting => 'पुनः कनेक्ट हो रहा है...'; + @override String get playbackStatusConnectionError => 'कनेक्शन त्रुटि'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 8b5ea9a..7feb575 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -928,6 +928,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get playbackStatusPaused => 'Dijeda'; + @override + String get playbackStatusReconnecting => 'Menyambung ulang...'; + @override String get playbackStatusConnectionError => 'Kesalahan koneksi'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 0d85515..4a039a9 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -930,6 +930,9 @@ class AppLocalizationsIt extends AppLocalizations { @override String get playbackStatusPaused => 'In pausa'; + @override + String get playbackStatusReconnecting => 'Riconnessione...'; + @override String get playbackStatusConnectionError => 'Errore di connessione'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 6c1d569..123e4a2 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -895,6 +895,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get playbackStatusPaused => '一時停止中'; + @override + String get playbackStatusReconnecting => '再接続中...'; + @override String get playbackStatusConnectionError => '接続エラー'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index e6eecea..d40be85 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -925,6 +925,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get playbackStatusPaused => 'Pausado'; + @override + String get playbackStatusReconnecting => 'Reconectando...'; + @override String get playbackStatusConnectionError => 'Erro de conexão'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 11f4faf..8d96d78 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -929,6 +929,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get playbackStatusPaused => 'Приостановлено'; + @override + String get playbackStatusReconnecting => 'Переподключение...'; + @override String get playbackStatusConnectionError => 'Ошибка подключения'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 4280080..c33bf41 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -891,6 +891,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get playbackStatusPaused => '已暂停'; + @override + String get playbackStatusReconnecting => '正在重新连接...'; + @override String get playbackStatusConnectionError => '连接错误'; diff --git a/lib/pantallas/pantalla_alarma_sonando.dart b/lib/pantallas/pantalla_alarma_sonando.dart index 6dc6fe2..b23bda5 100644 --- a/lib/pantallas/pantalla_alarma_sonando.dart +++ b/lib/pantallas/pantalla_alarma_sonando.dart @@ -64,6 +64,11 @@ class _PantallaAlarmaSonandoState extends State { } _iniciarFadeIn(); + // S7-R4 boundary: only `reproduciendo` cancels the fallback timer — + // `reconectando`/`cargando` do NOT count as playing, so the 12-second + // fallback below stays authoritative during the alarm ring. Waking the + // user reliably beats reconnect persistence: if the radio is still + // retrying when the timer fires, the bundled WAV takes over. _estadoSub = radio.estadoStream.listen((estado) { if (estado == EstadoReproduccion.reproduciendo && mounted) { _fallbackTimer?.cancel(); diff --git a/lib/pantallas/pantalla_reproductor.dart b/lib/pantallas/pantalla_reproductor.dart index 72d5256..6783b9a 100644 --- a/lib/pantallas/pantalla_reproductor.dart +++ b/lib/pantallas/pantalla_reproductor.dart @@ -217,7 +217,10 @@ class _WaveHero extends StatelessWidget { stream: estadoStream, builder: (context, snapshot) { final reproduciendo = snapshot.data == EstadoReproduccion.reproduciendo; - final cargando = snapshot.data == EstadoReproduccion.cargando; + // S7-R3: reconectando renders as loading, never as error. + final cargando = + snapshot.data == EstadoReproduccion.cargando || + snapshot.data == EstadoReproduccion.reconectando; final hayError = snapshot.data == EstadoReproduccion.error; return SizedBox( @@ -506,9 +509,9 @@ class _GrabacionWidget extends StatelessWidget { ? AppLocalizations.of( ctx, ).durationMinutesOnly(opcion.duracion.inMinutes) - : AppLocalizations.of( - ctx, - ).durationSecondsOnly(opcion.duracion.inSeconds), + : AppLocalizations.of(ctx).durationSecondsOnly( + opcion.duracion.inSeconds, + ), ), onPressed: () { estado.iniciarGrabacion(duracion: opcion.duracion); @@ -651,7 +654,10 @@ class _Controles extends StatelessWidget { builder: (context, snapshot) { final s = snapshot.data ?? EstadoReproduccion.detenido; final reproduciendo = s == EstadoReproduccion.reproduciendo; - final cargando = s == EstadoReproduccion.cargando; + // S7-R3: reconectando shows the loading spinner, not the error column. + final cargando = + s == EstadoReproduccion.cargando || + s == EstadoReproduccion.reconectando; final hayError = s == EstadoReproduccion.error; if (hayError) { @@ -808,11 +814,9 @@ class _TimerWidget extends StatelessWidget { final s = t.inSeconds.remainder(60).toString().padLeft(2, '0'); final label = t.inHours > 0 - ? AppLocalizations.of(context).durationHoursMinutesSeconds( - t.inHours, - m, - s, - ) + ? AppLocalizations.of( + context, + ).durationHoursMinutesSeconds(t.inHours, m, s) : AppLocalizations.of(context).durationMinutesSeconds(m, s); return Row( diff --git a/lib/servicios/controlador_reconexion.dart b/lib/servicios/controlador_reconexion.dart new file mode 100644 index 0000000..4327bef --- /dev/null +++ b/lib/servicios/controlador_reconexion.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +/// Outcome of a playback failure reported to [ControladorReconexion]. +enum DecisionReconexion { + /// A retry was scheduled after the backoff delay. + reintentar, + + /// All retries were consumed — surface the terminal error to the user. + agotado, + + /// Playback was not intended (user pause/stop or interruption) — no retry. + ignorar, +} + +/// Reconnect decision and bounded exponential backoff logic (Design 7.2, +/// S7-R2/S7-R7), extracted from `PluriWaveAudioHandler` so it is unit-testable +/// without platform channels. The timer factory is injectable for tests. +/// +/// Defaults: 5 retries with delays 1s, 2s, 4s, 8s, 16s (capped at 30s). +/// Combined with the 12s source-change timeout per attempt this keeps the +/// total reconnect window inside the ~60-90s budget from the design. +class ControladorReconexion { + ControladorReconexion({ + this.maxReintentos = 5, + this.retrasoBase = const Duration(seconds: 1), + this.retrasoMaximo = const Duration(seconds: 30), + Timer Function(Duration, void Function())? crearTemporizador, + }) : _crearTemporizador = crearTemporizador ?? _temporizadorReal; + + static Timer _temporizadorReal(Duration duracion, void Function() callback) => + Timer(duracion, callback); + + final int maxReintentos; + final Duration retrasoBase; + final Duration retrasoMaximo; + final Timer Function(Duration, void Function()) _crearTemporizador; + + int _intentos = 0; + Timer? _temporizador; + + /// Retries consumed since the last [restablecer]. + int get intentos => _intentos; + + /// True while a retry is scheduled and has not fired or been cancelled. + bool get reintentoPendiente => _temporizador?.isActive ?? false; + + /// Backoff delay for the 1-based retry [intento]: + /// `retrasoBase * 2^(intento-1)`, capped at [retrasoMaximo]. + Duration retrasoParaIntento(int intento) { + assert(intento >= 1, 'los intentos se cuentan desde 1'); + final exponente = (intento - 1).clamp(0, 30); + final ms = retrasoBase.inMilliseconds * (1 << exponente); + if (ms >= retrasoMaximo.inMilliseconds) return retrasoMaximo; + return Duration(milliseconds: ms); + } + + /// Reports a stall / network failure. Schedules [alReintentar] after the + /// backoff delay only when the user still intends to play and retries + /// remain; otherwise returns [DecisionReconexion.ignorar] or + /// [DecisionReconexion.agotado] without scheduling anything. + DecisionReconexion registrarFallo({ + required bool intencionReproducir, + required void Function() alReintentar, + }) { + if (!intencionReproducir) { + cancelar(); + return DecisionReconexion.ignorar; + } + if (_intentos >= maxReintentos) { + cancelar(); + return DecisionReconexion.agotado; + } + _intentos++; + _temporizador?.cancel(); + _temporizador = _crearTemporizador( + retrasoParaIntento(_intentos), + alReintentar, + ); + return DecisionReconexion.reintentar; + } + + /// Successful playback (or a fresh user play): reset the retry counter and + /// drop any pending retry, so the next stall starts the backoff over. + void restablecer() { + _intentos = 0; + cancelar(); + } + + /// Cancels any pending retry without resetting the counter (user stop or + /// pause during the backoff window). + void cancelar() { + _temporizador?.cancel(); + _temporizador = null; + } +} diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index a6b31fa..45cdf56 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -9,10 +9,21 @@ 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, error } +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 @@ -44,6 +55,7 @@ class ServicioAudio { 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; @@ -118,6 +130,27 @@ class PluriWaveAudioHandler extends BaseAudioHandler 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; @@ -143,6 +176,15 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// 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; @@ -172,6 +214,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler AudioPlayer _crearPlayer() { return AudioPlayer( audioPipeline: AudioPipeline(androidAudioEffects: [_eq]), + audioLoadConfiguration: configuracionCargaAndroid, ); } @@ -179,6 +222,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler _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: [ @@ -217,13 +266,23 @@ class PluriWaveAudioHandler extends BaseAudioHandler } /// 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; @@ -235,6 +294,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler level: 900, ); + _detenerReconexion(); playbackState.add( playbackState.value.copyWith( processingState: AudioProcessingState.error, @@ -248,6 +308,71 @@ class PluriWaveAudioHandler extends BaseAudioHandler _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; @@ -292,6 +417,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler @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((_) {}) @@ -321,8 +450,19 @@ class PluriWaveAudioHandler extends BaseAudioHandler } 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', @@ -499,13 +639,19 @@ class PluriWaveAudioHandler extends BaseAudioHandler @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; diff --git a/lib/servicios/servicio_grabacion_radio.dart b/lib/servicios/servicio_grabacion_radio.dart index 856b09c..9173019 100644 --- a/lib/servicios/servicio_grabacion_radio.dart +++ b/lib/servicios/servicio_grabacion_radio.dart @@ -284,6 +284,10 @@ class ServicioGrabacionRadio { _emitir(const EstadoGrabacionRadio.inactiva()); } + // S7-R5 invariant: recording uses its OWN HTTP stream, independent of + // PluriWaveAudioHandler. Its error handling must NOT route through the S7 + // reconnect machine (ControladorReconexion) — a recording failure clears + // state and releases resources immediately, with no backoff retries. Future _fallar(Object error) async { _timerAutoStop?.cancel(); _timerAutoStop = null; diff --git a/lib/widgets/mini_reproductor.dart b/lib/widgets/mini_reproductor.dart index 6a62d56..da40dfd 100644 --- a/lib/widgets/mini_reproductor.dart +++ b/lib/widgets/mini_reproductor.dart @@ -155,7 +155,10 @@ class _MiniReproductorState extends State { stream: estado.estadoStream, builder: (context, snapshot) { final s = snapshot.data ?? EstadoReproduccion.detenido; - if (s == EstadoReproduccion.cargando) { + // S7-R3: reconectando is a transient stall — render it like + // cargando (spinner), never as the error/retry affordance. + if (s == EstadoReproduccion.cargando || + s == EstadoReproduccion.reconectando) { return const SizedBox( width: 48, height: 48, @@ -220,6 +223,7 @@ class _MiniReproductorState extends State { EstadoReproduccion.cargando => l10n.playbackStatusConnecting, EstadoReproduccion.reproduciendo => l10n.playbackStatusLive, EstadoReproduccion.pausado => l10n.playbackStatusPaused, + EstadoReproduccion.reconectando => l10n.playbackStatusReconnecting, EstadoReproduccion.error => l10n.playbackStatusConnectionError, EstadoReproduccion.detenido => l10n.playbackStatusStopped, }; diff --git a/lib/widgets/visualizador_audio.dart b/lib/widgets/visualizador_audio.dart index d1888dd..a4a39ca 100644 --- a/lib/widgets/visualizador_audio.dart +++ b/lib/widgets/visualizador_audio.dart @@ -68,7 +68,8 @@ class _VisualizadorAudioState extends State void _onEstado(EstadoReproduccion estado) { final nuevoActivo = estado == EstadoReproduccion.reproduciendo || - estado == EstadoReproduccion.cargando; + estado == EstadoReproduccion.cargando || + estado == EstadoReproduccion.reconectando; if (!mounted) return; if (nuevoActivo != _activo) { setState(() => _activo = nuevoActivo); diff --git a/openspec/changes/app-quality-and-native-alarms/apply-progress.md b/openspec/changes/app-quality-and-native-alarms/apply-progress.md index 9e82df9..dd46d74 100644 --- a/openspec/changes/app-quality-and-native-alarms/apply-progress.md +++ b/openspec/changes/app-quality-and-native-alarms/apply-progress.md @@ -3,7 +3,7 @@ **Mode**: Strict TDD (test runner: `flutter test`) **Artifact store**: openspec (Engram unavailable this session) **Delivery**: auto-chain, local apply — no commits, no PRs (user commits at own cadence) -**Last updated**: 2026-06-11 (Batch 3) +**Last updated**: 2026-06-11 (Batch 4) ## Batch log @@ -12,6 +12,7 @@ | 1 | S1 — Alarm native reliability | COMPLETE (Dart verified; Kotlin/manifest on-device verification deferred to user) | 2026-06-11 | | 2 | S2a + S2b — Snooze correctness end-to-end + Alarm UX parity | COMPLETE (Dart verified; Kotlin on-device verification deferred to user) | 2026-06-11 | | 3 | S3a + S3b — Test seams (statics/prefs/cache/mutex/dirty-guard/bounded set) + audio_session | COMPLETE (Dart-only batch; call-pause on-device verification deferred to user) | 2026-06-11 | +| 4 | S7 — Streaming resilience (buffer config, reconnect state machine, UI wiring) | COMPLETE (Dart-only batch; stream-drop on-device verification deferred to user) | 2026-06-11 | ## Task status (cumulative) @@ -111,9 +112,27 @@ | T-S3b-06 | [x] | `flutter analyze` — No issues found | | T-S3b-07 | [x] | `dart format` applied | +### Slice S7 — Streaming resilience — 13/13 complete + +| Task | Status | Notes | +|------|--------|-------| +| T-S7-01 | [x] | RED: `servicio_audio_reconnect_test.dart` — 8 tests over `ControladorReconexion` (backoff sequence + cap, retry scheduled with intent=true, NO retry with intent=false, exhaustion → agotado, restablecer resets counter + backoff base, cancelar kills pending timer) via injectable fake-timer factory | +| T-S7-02 | [x] | RED: buffer-config test asserts `PluriWaveAudioHandler.configuracionCargaAndroid` values (15s/50s/2.5s/5s, prioritizeTime=true) — construction wiring not unit-testable without platform channels (see deviations) | +| T-S7-03 | [x] | RED: `reconnect_ui_test.dart` — reconectando shows spinner + "Reconectando..." label, NO AlertDialog/SnackBar, NO manual-retry button; second test locks retry button to error state only | +| T-S7-04 | [x] | GREEN: `_crearPlayer` passes `audioLoadConfiguration: configuracionCargaAndroid`; named `static const` durations. just_audio 0.9.46 API verified in pub-cache source — all design params exist, NO deviation | +| T-S7-05 | [x] | GREEN: `EstadoReproduccion.reconectando` added; `estadoStream` maps the handler's `reconectando` flag (error wins, then reconectando, then cargando) | +| T-S7-06 | [x] | GREEN: NEW `lib/servicios/controlador_reconexion.dart` (pure logic: maxReintentos=5, base=1s, cap=30s); handler enters reconnect on network-class errors (PlayerException 2xxx OR TimeoutException) with intent=play; retry re-issues source via revision-guarded `_cambiarFuente`; ready+playing resets; pause/stop/playMediaItem cancel/reset; exhaustion → single terminal error; `_cambiarFuente` returns normally when reconnect engaged so `EstadoRadio.reproducir` doesn't snackbar mid-retry | +| T-S7-07 | [x] | GREEN: mini player (spinner + `playbackStatusReconnecting` in `_labelEstado`), full player (`_WaveHero`/`_Controles`: reconectando = loading, not error), visualizer stays active; l10n key in ALL 13 .arb locales + gen-l10n | +| T-S7-08 | [x] | GREEN: S7-R4 boundary comment at `_estadoSub` listener — only `reproduciendo` cancels the alarm's 12s fallback timer; reconectando never counts as playing (code already correct, now documented + locked by enum distinctness) | +| T-S7-09 | [x] | GREEN: `ServicioGrabacionRadio` untouched except S7-R5 invariant comment above `_fallar` | +| T-S7-10 | [x] | Targeted run 10/10 green (RED first: `+0 -2` load failures) | +| T-S7-11 | [x] | Full suite 99/99 (89 baseline + 10 new) | +| T-S7-12 | [x] | `flutter analyze` — No issues found | +| T-S7-13 | [x] | `dart format` on 9 touched files (2 reflowed); re-ran suite + analyze after format | + ### Remaining slices (not started) -S7, S4a, S4b, S5, S6, cross-cutting (T-CC-01, T-CC-02) — all pending. +S4a, S4b, S5, S6, cross-cutting (T-CC-01, T-CC-02) — all pending. ## Snooze defect fixes (design audit D1–D5 / S1–S5) @@ -153,6 +172,16 @@ RED run evidence: first run `+0 -3` (loading failures, three files); ringing tap RED run evidence (Batch 3): `00:06 +1 -6` before implementation (the single pass is the exactly-once write lock-in). GREEN: targeted 12/12; full suite `00:12 +89: All tests passed!`. +### Batch 4 TDD Cycle Evidence (S7) + +| Task | RED (test written first, failing) | GREEN (implementation passes) | REFACTOR | +|------|-----------------------------------|-------------------------------|----------| +| T-S7-01/T-S7-06 | Load failure: `controlador_reconexion.dart` missing (`+0 -2` run) | `ControladorReconexion` created; 6 decision/backoff tests pass | Doc comments tying defaults to the ~60-90s design window | +| T-S7-02/T-S7-04 | Same RED run: `configuracionCargaAndroid` undefined | Const config + `_crearPlayer` wiring; values test passes | Durations extracted as named `static const` | +| T-S7-03/T-S7-05/T-S7-07 | Compile failure: `EstadoReproduccion.reconectando` missing | Enum + stream mapping + UI wiring; both widget tests pass | Test fixed to double-pump (stream event delivery + frame); diag run proved impl correct before the fix | + +RED run evidence (Batch 4): `00:00 +0 -2` (both files fail to load). GREEN: targeted `00:01 +10: All tests passed!`; full suite `00:08 +99: All tests passed!` (89 baseline + 10 new). + ## Files changed (Batch 2) | File | Action | ~Lines | @@ -205,6 +234,24 @@ RED run evidence (Batch 3): `00:06 +1 -6` before implementation (the single pass Total Batch 3 diff: ~362 insertions / ~122 deletions in lib + helpers, plus ~458 lines of new tests. +## Files changed (Batch 4) + +| File | Action | ~Lines | +|------|--------|--------| +| `lib/servicios/controlador_reconexion.dart` | Created | +100 (decision enum + backoff controller, injectable timer factory) | +| `lib/servicios/servicio_audio.dart` | Modified | +148/-7 (enum `reconectando`, buffer config consts, reconnect integration, TimeoutException routing, pause/stop/play resets) | +| `lib/widgets/mini_reproductor.dart` | Modified | +6/-2 (spinner for reconectando, label case) | +| `lib/pantallas/pantalla_reproductor.dart` | Modified | +8/-2 (reconectando = loading in `_WaveHero` + `_Controles`) | +| `lib/widgets/visualizador_audio.dart` | Modified | +2/-1 (reconectando keeps visualizer active) | +| `lib/pantallas/pantalla_alarma_sonando.dart` | Modified | +5 (S7-R4 boundary comment) | +| `lib/servicios/servicio_grabacion_radio.dart` | Modified | +4 (S7-R5 invariant comment) | +| `lib/l10n/app_*.arb` (13 files) | Modified | +1 each (`playbackStatusReconnecting`) | +| `lib/l10n/gen/*` (14 files) | Regenerated | by `flutter gen-l10n` | +| `test/servicios/servicio_audio_reconnect_test.dart` | Created | +210 (8 tests) | +| `test/widgets/reconnect_ui_test.dart` | Created | +100 (2 tests) | + +Total Batch 4 diff: ~235 insertions / ~13 deletions in lib (incl. ARB/gen), plus ~310 lines of new tests. Within the ~285-line slice estimate. No Kotlin/native files touched. + ## Deviations from design (Batch 3) 1. **No deprecated static shim for `ServicioAlarmasAndroid.configurarLocalizaciones`** (task text asked for one). Dart forbids a static and an instance member with the same name in one class, and the ONLY external call site (`estado_radio.dart:74`) was rewired in this same slice — a renamed shim would be unreachable dead code. Instead `configurarLocalizaciones` joined the `PuertoAlarmasAndroid` interface (fakes no-op it). @@ -215,6 +262,16 @@ Total Batch 3 diff: ~362 insertions / ~122 deletions in lib + helpers, plus ~458 6. **`servicio_contenido_app.dart` also migrated** (3 getInstance sites; not named in the task). Its only construction site is `static final` in `pluri_onboarding_dialog.dart`, which keeps the fallback path at runtime — acceptable under the injected-with-fallback compat net; full injection there would require a dialog refactor out of S3 scope. 7. **Two-instances-same-channel semantics documented, not prevented**: with instance handlers, constructing a second `ServicioAlarmasAndroid` over the SAME MethodChannel re-binds the platform handler to the newest instance. Production creates exactly one per channel (provider singleton); tests use distinct channels. +## Deviations from design (Batch 4) + +1. **Reconnect logic lives in a NEW file `lib/servicios/controlador_reconexion.dart`**, not inline in `servicio_audio.dart` (task text said "edit servicio_audio.dart"). Pure decision/backoff logic must be testable without platform channels (S7-R7); the handler cannot be constructed in tests (`AudioPlayer` hits MethodChannels at construction). The handler keeps only the integration glue. +2. **Buffer-config test asserts the config VALUES, not the construction call.** Asserting that `AudioPlayer(...)` received the config would require constructing the real player (platform channels). The config is a `static const` on the handler; `_crearPlayer` passes it (one-line wiring, verified by review + on-device item 9). Honest scope of S7-R1's `[flutter test]` portion. +3. **`TimeoutException` treated as network-class** in addition to the spec'd PlayerException 2xxx range. A real network drop usually surfaces as the existing 12s source-change timeout, NOT as a 2xxx PlayerException — without this, the most common stall would bypass reconnect entirely. The generic-Exception terminal path is otherwise unchanged. +4. **Stall detection is error-driven only** (per task T-S7-06 text); the design's optional "buffering > 8-10s watchdog" was NOT implemented. ExoPlayer/just_audio surfaces dead live streams as errors or our timeout; a buffering watchdog would add a timer racing the buffer config for marginal gain. Flagged for on-device validation (item 9): if a silent endless-buffering hang is observed, add the watchdog in a follow-up. +5. **`_cambiarFuente` completes normally when reconnect is engaged** (returns instead of rethrowing). Previously every failure rejected the `playMediaItem` future and `EstadoRadio.reproducir`'s catch pushed an error snackbar — that would show an error on the FIRST failure even while reconnecting, violating S7-R3. User-facing rejection still happens when reconnect does NOT engage (non-network error, no intent, exhausted). +6. **`restablecer()` on `playMediaItem`** (fresh user play/source switch restarts the backoff budget). Not explicit in the task text but required so the retry path (which goes through `_cambiarFuente` internally, not `playMediaItem`) can exhaust while user-initiated switches always get a full budget. +7. **Widget tests need a double `tester.pump()`** after a broadcast-stream emission (one pump delivers the event, the second rebuilds). Verified with a diagnostic harness that the implementation was correct and only the test needed the fix. + ## Deviations from design (Batch 2) 1. **Event subscription lives in the `EstadoAlarmas` CONSTRUCTOR**, not `inicializar` (task text said inicializar). Rationale: snoozed events must be recorded even before/without full init, and tests with `iniciarAutomaticamente: false` stay light. Cancelled in `dispose`. @@ -271,9 +328,34 @@ From tasks.md Section 11 — S1 items still pending from Batch 1, plus new S2 it 4. **Locale switch sanity:** change app language in Ajustes → alarm titles/station names sent to new native schedules use the new language (l10n now configured per locale change, not per rebuild). 5. **Settings import still reflects alarms immediately** (cache bypass in `cargar()`): import a backup with alarms → the alarms list shows them without restarting the app. +## Verification summary (Batch 4) + +- `flutter test`: 99/99 passing (89 baseline + 10 new across 2 files); re-run after `dart format` +- `flutter analyze`: No issues found (identical to baseline); re-run after format +- `dart format`: applied to all 9 touched Dart files (2 reflowed); gen/ untouched by hand +- `flutter gen-l10n`: run once after the 13 .arb edits +- `flutter build`: NOT run (forbidden) +- No Kotlin/native files touched in this batch (S7-R4: native alarm audio path untouched by construction) + +### What the buffer actually buys (honest expectations, Design 7.1) + +- Configured: ExoPlayer keeps a 15-50s forward buffer; playback (re)starts after 2.5s buffered (5s after a rebuffer); time prioritized over byte thresholds. +- Real drop ≲ buffered cushion (typically a few seconds up to ~15-30s depending on bitrate and how full the buffer was): audio keeps playing through the cushion, no UI change. +- Drop longer than the cushion: playback stalls → "Reconectando..." spinner state (no error dialog/snackbar) → up to 5 backoff retries (1/2/4/8/16s delays + 12s attempt timeout each, total window ≈ up to ~90s) → on recovery the player rejoins the LIVE edge (live radio has no rewind — the missed audio is gone, not replayed) → on exhaustion, the single existing friendly error with manual retry. + +### On-device verification items added by Batch 4 (user — Android device) + +1. **Short drop plays through (S7-R1, checklist item 9):** while the radio plays (let it run ~1 min so the buffer fills), disable WiFi/LTE for ~10s → audio continues without interruption and no UI state change. +2. **Long drop reconnects (S7-R2/R3, checklist item 9):** disable connectivity ~45s → mini player and full player show "Reconectando..." with spinner (NO error dialog/snackbar); re-enable within ~90s → playback resumes at the live edge automatically. +3. **Exhaustion surfaces one error (S7-R2-C):** leave connectivity off >2 min → exactly ONE error state with the manual retry button appears after retries exhaust; no error spam during the retry window. +4. **User pause/stop during reconnect (S7-R6):** trigger a drop, then tap pause/stop while "Reconectando..." → playback stays stopped; it must NOT restart on its own when connectivity returns. +5. **Alarm fallback not delayed (S7-R4, checklist item 11):** alarm with a non-responding station URL → bundled WAV fires within the existing ~12-15s window, NOT extended by reconnect attempts. +6. **Recording during a drop (S7-R5):** record while forcing a drop → recording fails/stops cleanly per existing behavior; a subsequent recording start works. +7. **Sleep timer during a drop (S7-R6):** sleep timer expiring during "Reconectando..." stops audio for good. + ## Workload / boundary - Mode: auto-chain local slices (no PRs) -- Current work units: S1, S2a, S2b (committed f3e9487), S3a + S3b (complete, in working tree) -- Boundary (Batch 3): starts from the clean post-f3e9487 tree; ends with S3a+S3b fully checked off, suite green. Rollback = revert the Batch-3 files listed above (Dart-only; no native edits). -- Next batch: S7 (streaming resilience) — depends on the `_intencionReproducir` seam and `ObjetivoAudioInterrumpible` landed here. No on-device prerequisite for S7 implementation, but items 1-2 above validate the seam S7 builds on. +- Current work units: S1, S2a, S2b, S3a, S3b (committed f3e9487, 079e19f), S7 (complete, in working tree) +- Boundary (Batch 4): starts from the clean post-079e19f tree; ends with S7 fully checked off, suite green (99/99). Rollback = revert the Batch-4 files listed above (Dart-only; no native edits). +- Next batch: S4a (ServicioExportImport + EstadoEcualizador extraction). No dependency on S7; on-device items above can be verified in parallel. diff --git a/openspec/changes/app-quality-and-native-alarms/tasks.md b/openspec/changes/app-quality-and-native-alarms/tasks.md index c352840..9e56fd3 100644 --- a/openspec/changes/app-quality-and-native-alarms/tasks.md +++ b/openspec/changes/app-quality-and-native-alarms/tasks.md @@ -253,32 +253,32 @@ Chain strategy: N/A (local apply) ### S7 pre-work: write failing tests -- [ ] **T-S7-01** [RED] Create `test/servicios/servicio_audio_reconnect_test.dart`: - - Test A: backoff delay sequence for retries 1–5 is [1s, 2s, 4s, 8s, 16s] capped at maxDelay (S7-R7). - - Test B: `_intencionReproducir=true` + stall → `reconectando` state emitted, reconnect scheduled (S7-R2-A, S7-R7). - - Test C: `_intencionReproducir=false` + stall → NO reconnect (S7-R2-B, S7-R7). - - Test D: after `maxRetries` exhausted → error state emitted (S7-R2-C, S7-R7). - - Test E: successful reconnect resets retry counter (S7-R7). - - Test F: user stop during stall cancels reconnect (S7-R6, S7-R7). - **~70 lines.** -- [ ] **T-S7-02** [RED] Add test in `test/servicios/servicio_audio_reconnect_test.dart`: buffer config (`AndroidLoadControl`) applied to player construction (S7-R1). **~15 lines.** -- [ ] **T-S7-03** [RED] Add widget test `test/widgets/reconnect_ui_test.dart`: no `AlertDialog`/`SnackBar` shown while handler in `reconectando` state (S7-R3-A). **~20 lines.** +- [x] **T-S7-01** [RED] Create `test/servicios/servicio_audio_reconnect_test.dart`: + - Test A: backoff delay sequence for retries 1–5 is [1s, 2s, 4s, 8s, 16s] capped at maxDelay (S7-R7). **DONE (+ cap test with custom maxDelay).** + - Test B: `_intencionReproducir=true` + stall → `reconectando` state emitted, reconnect scheduled (S7-R2-A, S7-R7). **DONE — decision/scheduling tested at `ControladorReconexion` level (extracted pure logic; the handler itself needs platform channels); state emission covered by the widget test.** + - Test C: `_intencionReproducir=false` + stall → NO reconnect (S7-R2-B, S7-R7). **DONE.** + - Test D: after `maxRetries` exhausted → error state emitted (S7-R2-C, S7-R7). **DONE (`DecisionReconexion.agotado` → handler falls through to existing terminal error path).** + - Test E: successful reconnect resets retry counter (S7-R7). **DONE (`restablecer()` + backoff restarts at base).** + - Test F: user stop during stall cancels reconnect (S7-R6, S7-R7). **DONE (`cancelar()` kills the pending timer; fired-after-cancel never retries).** + **8 tests, RED captured first (load failure: controller file missing).** +- [x] **T-S7-02** [RED] Add test in `test/servicios/servicio_audio_reconnect_test.dart`: buffer config (`AndroidLoadControl`) applied to player construction (S7-R1). **DONE — asserts `PluriWaveAudioHandler.configuracionCargaAndroid` values (15s/50s/2.5s/5s/prioritizeTime); construction wiring not unit-testable without platform channels, verified by code + on-device.** +- [x] **T-S7-03** [RED] Add widget test `test/widgets/reconnect_ui_test.dart`: no `AlertDialog`/`SnackBar` shown while handler in `reconectando` state (S7-R3-A). **DONE — also asserts spinner + localized "Reconectando..." label and that the manual-retry button appears ONLY in error state.** ### S7 implementation -- [ ] **T-S7-04** [GREEN] Edit `lib/servicios/servicio_audio.dart` `_crearPlayer` (lines 159-163): pass `AudioLoadConfiguration(androidLoadControl: AndroidLoadControl(minBufferDuration: 15s, maxBufferDuration: 50s, bufferForPlaybackDuration: 2.5s, bufferForPlaybackAfterRebufferDuration: 5s, prioritizeTimeOverSizeThresholds: true))` at construction. Values extracted as named constants, NOT magic literals. **Reqs:** S7-R1. **~25 lines.** -- [ ] **T-S7-05** [GREEN] Edit `lib/servicios/servicio_audio.dart`: add `EstadoReproduccion.reconectando` to the state enum (line 14). **Reqs:** S7-R2, S7-R3. **~3 lines.** -- [ ] **T-S7-06** [GREEN] Edit `lib/servicios/servicio_audio.dart` `_gestionarErrorReproduccion` (lines 207-236) and `_eventosSub.onError` (lines 189-194): instead of transitioning immediately to terminal error when `_intencionReproducir == true` and error is network-class (2xxx range), enter the reconnect state machine — emit `reconectando`, schedule backoff retry using `_cambiarFuente` revision guard. Cancel/reset on user stop or source switch. After `maxRetries` exhaustion fall through to existing terminal error path. Configurable: `_maxRetries = 5`, `_baseDelay = 1s`, `_maxDelay = 30s`. **Reqs:** S7-R2. **~130 lines.** -- [ ] **T-S7-07** [GREEN] Edit `lib/widgets/mini_reproductor.dart` and any player UI: map `EstadoReproduccion.reconectando` → buffering/loading indicator (NOT error dialog). **Reqs:** S7-R3. **~20 lines.** -- [ ] **T-S7-08** [GREEN] Edit `lib/pantallas/pantalla_alarma_sonando.dart` (alarm pre-start / estadoStream listener): ensure `reconectando` is NOT treated as `reproduciendo`; the alarm's existing 12-second fallback timer remains authoritative. Add a comment documenting the boundary. **Reqs:** S7-R4. **~10 lines.** -- [ ] **T-S7-09** [GREEN] Confirm `ServicioGrabacionRadio` error-handling code is NOT modified by S7 changes. Add inline comment referencing S7-R5 invariant. **Reqs:** S7-R5. **~3 lines (comment only).** +- [x] **T-S7-04** [GREEN] `_crearPlayer` now passes `audioLoadConfiguration: configuracionCargaAndroid` (15s/50s/2.5s/5s, `prioritizeTimeOverSizeThresholds: true`) — values as named `static const` (`bufferMinimo`/`bufferMaximo`/`bufferParaIniciar`/`bufferTrasRebuffer`). API verified against installed just_audio 0.9.46 source: all params exist, no deviation. **Reqs:** S7-R1. **DONE.** +- [x] **T-S7-05** [GREEN] `EstadoReproduccion.reconectando` added; `ServicioAudio.estadoStream` maps it from the handler's `reconectando` flag (after the terminal-error check, before cargando). **Reqs:** S7-R2, S7-R3. **DONE.** +- [x] **T-S7-06** [GREEN] Reconnect state machine: pure backoff/decision logic extracted to NEW `lib/servicios/controlador_reconexion.dart` (`ControladorReconexion`, maxReintentos=5, base=1s, cap=30s, injectable timer factory); `_gestionarErrorReproduccion` enters it for network-class errors (`PlayerException` 2xxx OR `TimeoutException` from the 12s source guard) when intent=play; retries re-issue the source through the revision-guarded `_cambiarFuente` queue; success (`ready`+`playing`) resets; `pause`/`stop`/`playMediaItem` cancel/reset; exhaustion falls through to the single terminal error. `_cambiarFuente` completes normally when reconnect engaged so `EstadoRadio.reproducir` does NOT snackbar during retries (S7-R3). **Reqs:** S7-R2. **DONE.** +- [x] **T-S7-07** [GREEN] `mini_reproductor.dart` (spinner for reconectando + `playbackStatusReconnecting` label in `_labelEstado`), `pantalla_reproductor.dart` (`_WaveHero` + `_Controles` treat reconectando as loading, not error), `visualizador_audio.dart` (reconectando keeps the visualizer active like cargando). NEW l10n key `playbackStatusReconnecting` in ALL 13 .arb locales + gen-l10n. **Reqs:** S7-R3. **DONE.** +- [x] **T-S7-08** [GREEN] Boundary comment added at the `_estadoSub` listener in `pantalla_alarma_sonando.dart`: only `reproduciendo` cancels the 12s fallback timer; `reconectando` does NOT count as playing, WAV fallback stays authoritative. (Code already correct by construction — the listener checks `== reproduciendo`.) **Reqs:** S7-R4. **DONE.** +- [x] **T-S7-09** [GREEN] `ServicioGrabacionRadio` error handling untouched; S7-R5 invariant comment added above `_fallar`. **Reqs:** S7-R5. **DONE.** ### S7 verification -- [ ] **T-S7-10** Run `flutter test test/servicios/servicio_audio_reconnect_test.dart test/widgets/reconnect_ui_test.dart`. -- [ ] **T-S7-11** Run `flutter test` (full suite) — no regressions. -- [ ] **T-S7-12** Run `flutter analyze` — zero errors. -- [ ] **T-S7-13** Run `dart format lib/servicios/servicio_audio.dart lib/widgets/mini_reproductor.dart lib/pantallas/pantalla_alarma_sonando.dart`. +- [x] **T-S7-10** Run `flutter test test/servicios/servicio_audio_reconnect_test.dart test/widgets/reconnect_ui_test.dart` — 10/10 green (RED captured first: `+0 -2` load failures). +- [x] **T-S7-11** Run `flutter test` (full suite) — 99/99 passing (89 baseline + 10 new), no regressions. +- [x] **T-S7-12** Run `flutter analyze` — `No issues found!`. +- [x] **T-S7-13** Run `dart format` on all 9 touched Dart files (incl. new controller, pantalla_reproductor, visualizador, grabacion, both test files; 2 reflowed). ### S7 Definition of Done - `flutter test` green (all reconnect tests passing). diff --git a/test/servicios/servicio_audio_reconnect_test.dart b/test/servicios/servicio_audio_reconnect_test.dart new file mode 100644 index 0000000..c81a908 --- /dev/null +++ b/test/servicios/servicio_audio_reconnect_test.dart @@ -0,0 +1,210 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/servicios/controlador_reconexion.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; + +/// Fake timer injected through the controller's timer factory so backoff +/// scheduling is observable without real delays or platform channels. +class _TemporizadorFalso implements Timer { + _TemporizadorFalso(this.duracion, this.callback); + + final Duration duracion; + final void Function() callback; + bool cancelado = false; + + @override + void cancel() => cancelado = true; + + @override + bool get isActive => !cancelado; + + @override + int get tick => 0; + + void disparar() { + if (!cancelado) callback(); + } +} + +ControladorReconexion _controlador({ + int maxReintentos = 5, + Duration retrasoMaximo = const Duration(seconds: 30), + required List<_TemporizadorFalso> temporizadores, +}) { + return ControladorReconexion( + maxReintentos: maxReintentos, + retrasoMaximo: retrasoMaximo, + crearTemporizador: (duracion, callback) { + final timer = _TemporizadorFalso(duracion, callback); + temporizadores.add(timer); + return timer; + }, + ); +} + +void main() { + group('ControladorReconexion — backoff (S7-R2-D, S7-R7)', () { + test('secuencia de retrasos 1-5 es [1s, 2s, 4s, 8s, 16s]', () { + final controlador = ControladorReconexion(); + expect( + [for (var i = 1; i <= 5; i++) controlador.retrasoParaIntento(i)], + const [ + Duration(seconds: 1), + Duration(seconds: 2), + Duration(seconds: 4), + Duration(seconds: 8), + Duration(seconds: 16), + ], + ); + }); + + test('el retraso queda limitado por retrasoMaximo', () { + final controlador = ControladorReconexion( + retrasoMaximo: Duration(seconds: 10), + ); + expect( + controlador.retrasoParaIntento(5), + const Duration(seconds: 10), + reason: '16s sin limite, pero el maximo configurado es 10s', + ); + expect( + ControladorReconexion().retrasoParaIntento(6), + const Duration(seconds: 30), + reason: '32s supera el maximo por defecto de 30s', + ); + }); + }); + + group('ControladorReconexion — decision de reintento (S7-R2, S7-R7)', () { + test('intencion=true + fallo => reintento programado con backoff', () { + final temporizadores = <_TemporizadorFalso>[]; + final controlador = _controlador(temporizadores: temporizadores); + var reintentos = 0; + + final decision = controlador.registrarFallo( + intencionReproducir: true, + alReintentar: () => reintentos++, + ); + + expect(decision, DecisionReconexion.reintentar); + expect(controlador.intentos, 1); + expect(controlador.reintentoPendiente, isTrue); + expect(temporizadores, hasLength(1)); + expect(temporizadores.single.duracion, const Duration(seconds: 1)); + + temporizadores.single.disparar(); + expect(reintentos, 1, reason: 'el callback corre al vencer el backoff'); + }); + + test('intencion=false + fallo => NO se programa reintento (S7-R2-B)', () { + final temporizadores = <_TemporizadorFalso>[]; + final controlador = _controlador(temporizadores: temporizadores); + + final decision = controlador.registrarFallo( + intencionReproducir: false, + alReintentar: () => fail('no debe reintentar con intencion=false'), + ); + + expect(decision, DecisionReconexion.ignorar); + expect(controlador.intentos, 0); + expect(controlador.reintentoPendiente, isFalse); + expect(temporizadores, isEmpty); + }); + + test('tras maxReintentos agotados => agotado, sin mas reintentos ' + '(S7-R2-C)', () { + final temporizadores = <_TemporizadorFalso>[]; + final controlador = _controlador( + maxReintentos: 2, + temporizadores: temporizadores, + ); + + for (var i = 0; i < 2; i++) { + expect( + controlador.registrarFallo( + intencionReproducir: true, + alReintentar: () {}, + ), + DecisionReconexion.reintentar, + ); + } + final decision = controlador.registrarFallo( + intencionReproducir: true, + alReintentar: () => fail('no debe programar tras agotar reintentos'), + ); + + expect(decision, DecisionReconexion.agotado); + expect(controlador.reintentoPendiente, isFalse); + expect(temporizadores, hasLength(2)); + }); + + test('una reconexion exitosa restablece el contador (S7-R7)', () { + final temporizadores = <_TemporizadorFalso>[]; + final controlador = _controlador(temporizadores: temporizadores); + + controlador.registrarFallo( + intencionReproducir: true, + alReintentar: () {}, + ); + controlador.registrarFallo( + intencionReproducir: true, + alReintentar: () {}, + ); + expect(controlador.intentos, 2); + + controlador.restablecer(); + + expect(controlador.intentos, 0); + expect(controlador.reintentoPendiente, isFalse); + controlador.registrarFallo( + intencionReproducir: true, + alReintentar: () {}, + ); + expect( + temporizadores.last.duracion, + const Duration(seconds: 1), + reason: 'tras restablecer, el backoff arranca de nuevo en la base', + ); + }); + + test('stop del usuario durante el stall cancela el reintento (S7-R6)', () { + final temporizadores = <_TemporizadorFalso>[]; + final controlador = _controlador(temporizadores: temporizadores); + var reintentos = 0; + + controlador.registrarFallo( + intencionReproducir: true, + alReintentar: () => reintentos++, + ); + expect(controlador.reintentoPendiente, isTrue); + + controlador.cancelar(); + + expect(controlador.reintentoPendiente, isFalse); + expect(temporizadores.single.cancelado, isTrue); + temporizadores.single.disparar(); + expect(reintentos, 0, reason: 'un timer cancelado nunca reintenta'); + }); + }); + + group('Buffer de stream en vivo (S7-R1)', () { + test('la configuracion Android usa los valores del diseno 7.1', () { + const config = PluriWaveAudioHandler.configuracionCargaAndroid; + final control = config.androidLoadControl; + + expect(control, isNotNull); + expect(control!.minBufferDuration, const Duration(seconds: 15)); + expect(control.maxBufferDuration, const Duration(seconds: 50)); + expect( + control.bufferForPlaybackDuration, + const Duration(milliseconds: 2500), + ); + expect( + control.bufferForPlaybackAfterRebufferDuration, + const Duration(seconds: 5), + ); + expect(control.prioritizeTimeOverSizeThresholds, isTrue); + }); + }); +} diff --git a/test/widgets/reconnect_ui_test.dart b/test/widgets/reconnect_ui_test.dart new file mode 100644 index 0000000..7307e43 --- /dev/null +++ b/test/widgets/reconnect_ui_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; +import 'package:pluriwave/widgets/mini_reproductor.dart'; +import 'package:provider/provider.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +EstadoRadio _estadoRadio(FakeServicioAudio audio) { + return EstadoRadio( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + iniciarAutomaticamente: false, + ); +} + +/// S7-R3-A: while the handler is reconnecting, the UI shows a loading +/// indicator — never an error dialog or snackbar per retry attempt. +void main() { + testWidgets( + 'estado reconectando muestra indicador de carga, sin dialogo ni snackbar', + (tester) async { + final audio = FakeServicioAudio(); + final estado = _estadoRadio(audio); + addTearDown(estado.dispose); + await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno')); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: estado, + child: MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: MiniReproductor()), + ), + ), + ); + await tester.pumpAndSettle(); + + audio.emitirEstado(EstadoReproduccion.reconectando); + // Two pumps: one delivers the stream event, one rebuilds the frame. + await tester.pump(); + await tester.pump(); + + expect(find.byType(AlertDialog), findsNothing); + expect(find.byType(SnackBar), findsNothing); + expect( + find.byType(CircularProgressIndicator), + findsOneWidget, + reason: 'reconectando se presenta como carga, no como error', + ); + expect( + find.byIcon(Icons.refresh_rounded), + findsNothing, + reason: 'el boton de reintento manual es solo para el estado error', + ); + expect(find.text('Reconectando...'), findsOneWidget); + }, + ); + + testWidgets('el estado error si muestra el boton de reintento manual', ( + tester, + ) async { + final audio = FakeServicioAudio(); + final estado = _estadoRadio(audio); + addTearDown(estado.dispose); + await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno')); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: estado, + child: MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: MiniReproductor()), + ), + ), + ); + await tester.pumpAndSettle(); + + audio.emitirEstado(EstadoReproduccion.error); + await tester.pump(); + await tester.pump(); + + expect(find.byType(AlertDialog), findsNothing); + expect(find.byIcon(Icons.refresh_rounded), findsOneWidget); + }); +}