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
@@ -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(