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