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; } }