feat(streaming): buffer resilience and automatic reconnection

- Construct the audio player with an enlarged live-stream buffer (15-50s forward cushion, 2.5s to start, 5s after rebuffer) so short network drops play through silently
- Add reconnect-on-stall state machine with bounded exponential backoff (1/2/4/8/16s, ~90s total window, 5 attempts) that re-prepares to the live edge; backoff/decision logic extracted to controlador_reconexion.dart as pure testable code
- Surface a new reconnecting playback state in the mini player and full player (localized in all 13 locales) instead of error dialogs during the retry window; a single friendly error appears only after exhaustion
- Guard interplay: user pause/stop cancels retries, audio interruptions cancel reconnect, alarm wake-up path keeps precedence, recording fails cleanly during drops
- Reset retry budget on station change; route stream timeouts through the network-error class
- 10 new tests (99 total green), flutter analyze clean
This commit is contained in:
2026-06-11 19:54:30 +02:00
parent 079e19f0ee
commit 0380bbb1e7
38 changed files with 743 additions and 38 deletions
+1
View File
@@ -477,6 +477,7 @@
"playbackStatusConnecting": "جارٍ الاتصال...",
"playbackStatusLive": "مباشر",
"playbackStatusPaused": "متوقف مؤقتًا",
"playbackStatusReconnecting": "جارٍ إعادة الاتصال...",
"playbackStatusConnectionError": "خطأ في الاتصال",
"playbackStatusStopped": "متوقف",
"stationSemanticLabel": "محطة {stationName}",
+1
View File
@@ -477,6 +477,7 @@
"playbackStatusConnecting": "সংযুক্ত হচ্ছে...",
"playbackStatusLive": "লাইভ",
"playbackStatusPaused": "বিরতিতে",
"playbackStatusReconnecting": "পুনরায় সংযোগ করা হচ্ছে...",
"playbackStatusConnectionError": "সংযোগে ত্রুটি",
"playbackStatusStopped": "বন্ধ",
"stationSemanticLabel": "স্টেশন {stationName}",
+1
View File
@@ -477,6 +477,7 @@
"playbackStatusConnecting": "Verbindung wird hergestellt...",
"playbackStatusLive": "Live",
"playbackStatusPaused": "Pausiert",
"playbackStatusReconnecting": "Wird neu verbunden...",
"playbackStatusConnectionError": "Verbindungsfehler",
"playbackStatusStopped": "Gestoppt",
"stationSemanticLabel": "Sender {stationName}",
+1
View File
@@ -477,6 +477,7 @@
"playbackStatusConnecting": "Connecting...",
"playbackStatusLive": "Live",
"playbackStatusPaused": "Paused",
"playbackStatusReconnecting": "Reconnecting...",
"playbackStatusConnectionError": "Connection error",
"playbackStatusStopped": "Stopped",
"stationSemanticLabel": "Station {stationName}",
+1
View File
@@ -473,6 +473,7 @@
"playbackStatusConnecting": "Conectando...",
"playbackStatusLive": "En directo",
"playbackStatusPaused": "Pausado",
"playbackStatusReconnecting": "Reconectando...",
"playbackStatusConnectionError": "Error de conexión",
"playbackStatusStopped": "Detenido",
"stationSemanticLabel": "Emisora {stationName}",
+1
View File
@@ -477,6 +477,7 @@
"playbackStatusConnecting": "Connexion...",
"playbackStatusLive": "En direct",
"playbackStatusPaused": "En pause",
"playbackStatusReconnecting": "Reconnexion...",
"playbackStatusConnectionError": "Erreur de connexion",
"playbackStatusStopped": "Arrêté",
"stationSemanticLabel": "Station {stationName}",
+1
View File
@@ -477,6 +477,7 @@
"playbackStatusConnecting": "कनेक्ट हो रहा है...",
"playbackStatusLive": "लाइव",
"playbackStatusPaused": "विराम पर",
"playbackStatusReconnecting": "पुनः कनेक्ट हो रहा है...",
"playbackStatusConnectionError": "कनेक्शन त्रुटि",
"playbackStatusStopped": "बंद",
"stationSemanticLabel": "स्टेशन {stationName}",
+1
View File
@@ -477,6 +477,7 @@
"playbackStatusConnecting": "Menghubungkan...",
"playbackStatusLive": "Siaran langsung",
"playbackStatusPaused": "Dijeda",
"playbackStatusReconnecting": "Menyambung ulang...",
"playbackStatusConnectionError": "Kesalahan koneksi",
"playbackStatusStopped": "Dihentikan",
"stationSemanticLabel": "Stasiun {stationName}",
+1
View File
@@ -477,6 +477,7 @@
"playbackStatusConnecting": "Connessione...",
"playbackStatusLive": "In diretta",
"playbackStatusPaused": "In pausa",
"playbackStatusReconnecting": "Riconnessione...",
"playbackStatusConnectionError": "Errore di connessione",
"playbackStatusStopped": "Interrotto",
"stationSemanticLabel": "Stazione {stationName}",
+1
View File
@@ -477,6 +477,7 @@
"playbackStatusConnecting": "接続中...",
"playbackStatusLive": "ライブ",
"playbackStatusPaused": "一時停止中",
"playbackStatusReconnecting": "再接続中...",
"playbackStatusConnectionError": "接続エラー",
"playbackStatusStopped": "停止中",
"stationSemanticLabel": "ラジオ局 {stationName}",
+1
View File
@@ -477,6 +477,7 @@
"playbackStatusConnecting": "Conectando...",
"playbackStatusLive": "Ao vivo",
"playbackStatusPaused": "Pausado",
"playbackStatusReconnecting": "Reconectando...",
"playbackStatusConnectionError": "Erro de conexão",
"playbackStatusStopped": "Parado",
"stationSemanticLabel": "Estação {stationName}",
+1
View File
@@ -477,6 +477,7 @@
"playbackStatusConnecting": "Подключение...",
"playbackStatusLive": "В эфире",
"playbackStatusPaused": "Приостановлено",
"playbackStatusReconnecting": "Переподключение...",
"playbackStatusConnectionError": "Ошибка подключения",
"playbackStatusStopped": "Остановлено",
"stationSemanticLabel": "Станция {stationName}",
+1
View File
@@ -477,6 +477,7 @@
"playbackStatusConnecting": "正在连接...",
"playbackStatusLive": "直播中",
"playbackStatusPaused": "已暂停",
"playbackStatusReconnecting": "正在重新连接...",
"playbackStatusConnectionError": "连接错误",
"playbackStatusStopped": "已停止",
"stationSemanticLabel": "电台 {stationName}",
+6
View File
@@ -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:
+3
View File
@@ -919,6 +919,9 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get playbackStatusPaused => 'متوقف مؤقتًا';
@override
String get playbackStatusReconnecting => 'جارٍ إعادة الاتصال...';
@override
String get playbackStatusConnectionError => 'خطأ في الاتصال';
+3
View File
@@ -928,6 +928,9 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get playbackStatusPaused => 'বিরতিতে';
@override
String get playbackStatusReconnecting => 'পুনরায় সংযোগ করা হচ্ছে...';
@override
String get playbackStatusConnectionError => 'সংযোগে ত্রুটি';
+3
View File
@@ -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';
+3
View File
@@ -923,6 +923,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get playbackStatusPaused => 'Paused';
@override
String get playbackStatusReconnecting => 'Reconnecting...';
@override
String get playbackStatusConnectionError => 'Connection error';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -924,6 +924,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get playbackStatusPaused => 'विराम पर';
@override
String get playbackStatusReconnecting => 'पुनः कनेक्ट हो रहा है...';
@override
String get playbackStatusConnectionError => 'कनेक्शन त्रुटि';
+3
View File
@@ -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';
+3
View File
@@ -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';
+3
View File
@@ -895,6 +895,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get playbackStatusPaused => '一時停止中';
@override
String get playbackStatusReconnecting => '再接続中...';
@override
String get playbackStatusConnectionError => '接続エラー';
+3
View File
@@ -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';
+3
View File
@@ -929,6 +929,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get playbackStatusPaused => 'Приостановлено';
@override
String get playbackStatusReconnecting => 'Переподключение...';
@override
String get playbackStatusConnectionError => 'Ошибка подключения';
+3
View File
@@ -891,6 +891,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get playbackStatusPaused => '已暂停';
@override
String get playbackStatusReconnecting => '正在重新连接...';
@override
String get playbackStatusConnectionError => '连接错误';
@@ -64,6 +64,11 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
}
_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();
+14 -10
View File
@@ -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(
+95
View File
@@ -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;
}
}
+147 -1
View File
@@ -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<PlayerState>? _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<void> 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<void> 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<void> 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;
@@ -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<void> _fallar(Object error) async {
_timerAutoStop?.cancel();
_timerAutoStop = null;
+5 -1
View File
@@ -155,7 +155,10 @@ class _MiniReproductorState extends State<MiniReproductor> {
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<MiniReproductor> {
EstadoReproduccion.cargando => l10n.playbackStatusConnecting,
EstadoReproduccion.reproduciendo => l10n.playbackStatusLive,
EstadoReproduccion.pausado => l10n.playbackStatusPaused,
EstadoReproduccion.reconectando => l10n.playbackStatusReconnecting,
EstadoReproduccion.error => l10n.playbackStatusConnectionError,
EstadoReproduccion.detenido => l10n.playbackStatusStopped,
};
+2 -1
View File
@@ -68,7 +68,8 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
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);
@@ -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 D1D5 / S1S5)
@@ -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.
@@ -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 15 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 15 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).
@@ -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);
});
});
}
+96
View File
@@ -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<EstadoRadio>.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<EstadoRadio>.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);
});
}