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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user