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:
@@ -477,6 +477,7 @@
|
||||
"playbackStatusConnecting": "جارٍ الاتصال...",
|
||||
"playbackStatusLive": "مباشر",
|
||||
"playbackStatusPaused": "متوقف مؤقتًا",
|
||||
"playbackStatusReconnecting": "جارٍ إعادة الاتصال...",
|
||||
"playbackStatusConnectionError": "خطأ في الاتصال",
|
||||
"playbackStatusStopped": "متوقف",
|
||||
"stationSemanticLabel": "محطة {stationName}",
|
||||
|
||||
@@ -477,6 +477,7 @@
|
||||
"playbackStatusConnecting": "সংযুক্ত হচ্ছে...",
|
||||
"playbackStatusLive": "লাইভ",
|
||||
"playbackStatusPaused": "বিরতিতে",
|
||||
"playbackStatusReconnecting": "পুনরায় সংযোগ করা হচ্ছে...",
|
||||
"playbackStatusConnectionError": "সংযোগে ত্রুটি",
|
||||
"playbackStatusStopped": "বন্ধ",
|
||||
"stationSemanticLabel": "স্টেশন {stationName}",
|
||||
|
||||
@@ -477,6 +477,7 @@
|
||||
"playbackStatusConnecting": "Verbindung wird hergestellt...",
|
||||
"playbackStatusLive": "Live",
|
||||
"playbackStatusPaused": "Pausiert",
|
||||
"playbackStatusReconnecting": "Wird neu verbunden...",
|
||||
"playbackStatusConnectionError": "Verbindungsfehler",
|
||||
"playbackStatusStopped": "Gestoppt",
|
||||
"stationSemanticLabel": "Sender {stationName}",
|
||||
|
||||
@@ -477,6 +477,7 @@
|
||||
"playbackStatusConnecting": "Connecting...",
|
||||
"playbackStatusLive": "Live",
|
||||
"playbackStatusPaused": "Paused",
|
||||
"playbackStatusReconnecting": "Reconnecting...",
|
||||
"playbackStatusConnectionError": "Connection error",
|
||||
"playbackStatusStopped": "Stopped",
|
||||
"stationSemanticLabel": "Station {stationName}",
|
||||
|
||||
@@ -473,6 +473,7 @@
|
||||
"playbackStatusConnecting": "Conectando...",
|
||||
"playbackStatusLive": "En directo",
|
||||
"playbackStatusPaused": "Pausado",
|
||||
"playbackStatusReconnecting": "Reconectando...",
|
||||
"playbackStatusConnectionError": "Error de conexión",
|
||||
"playbackStatusStopped": "Detenido",
|
||||
"stationSemanticLabel": "Emisora {stationName}",
|
||||
|
||||
@@ -477,6 +477,7 @@
|
||||
"playbackStatusConnecting": "Connexion...",
|
||||
"playbackStatusLive": "En direct",
|
||||
"playbackStatusPaused": "En pause",
|
||||
"playbackStatusReconnecting": "Reconnexion...",
|
||||
"playbackStatusConnectionError": "Erreur de connexion",
|
||||
"playbackStatusStopped": "Arrêté",
|
||||
"stationSemanticLabel": "Station {stationName}",
|
||||
|
||||
@@ -477,6 +477,7 @@
|
||||
"playbackStatusConnecting": "कनेक्ट हो रहा है...",
|
||||
"playbackStatusLive": "लाइव",
|
||||
"playbackStatusPaused": "विराम पर",
|
||||
"playbackStatusReconnecting": "पुनः कनेक्ट हो रहा है...",
|
||||
"playbackStatusConnectionError": "कनेक्शन त्रुटि",
|
||||
"playbackStatusStopped": "बंद",
|
||||
"stationSemanticLabel": "स्टेशन {stationName}",
|
||||
|
||||
@@ -477,6 +477,7 @@
|
||||
"playbackStatusConnecting": "Menghubungkan...",
|
||||
"playbackStatusLive": "Siaran langsung",
|
||||
"playbackStatusPaused": "Dijeda",
|
||||
"playbackStatusReconnecting": "Menyambung ulang...",
|
||||
"playbackStatusConnectionError": "Kesalahan koneksi",
|
||||
"playbackStatusStopped": "Dihentikan",
|
||||
"stationSemanticLabel": "Stasiun {stationName}",
|
||||
|
||||
@@ -477,6 +477,7 @@
|
||||
"playbackStatusConnecting": "Connessione...",
|
||||
"playbackStatusLive": "In diretta",
|
||||
"playbackStatusPaused": "In pausa",
|
||||
"playbackStatusReconnecting": "Riconnessione...",
|
||||
"playbackStatusConnectionError": "Errore di connessione",
|
||||
"playbackStatusStopped": "Interrotto",
|
||||
"stationSemanticLabel": "Stazione {stationName}",
|
||||
|
||||
@@ -477,6 +477,7 @@
|
||||
"playbackStatusConnecting": "接続中...",
|
||||
"playbackStatusLive": "ライブ",
|
||||
"playbackStatusPaused": "一時停止中",
|
||||
"playbackStatusReconnecting": "再接続中...",
|
||||
"playbackStatusConnectionError": "接続エラー",
|
||||
"playbackStatusStopped": "停止中",
|
||||
"stationSemanticLabel": "ラジオ局 {stationName}",
|
||||
|
||||
@@ -477,6 +477,7 @@
|
||||
"playbackStatusConnecting": "Conectando...",
|
||||
"playbackStatusLive": "Ao vivo",
|
||||
"playbackStatusPaused": "Pausado",
|
||||
"playbackStatusReconnecting": "Reconectando...",
|
||||
"playbackStatusConnectionError": "Erro de conexão",
|
||||
"playbackStatusStopped": "Parado",
|
||||
"stationSemanticLabel": "Estação {stationName}",
|
||||
|
||||
@@ -477,6 +477,7 @@
|
||||
"playbackStatusConnecting": "Подключение...",
|
||||
"playbackStatusLive": "В эфире",
|
||||
"playbackStatusPaused": "Приостановлено",
|
||||
"playbackStatusReconnecting": "Переподключение...",
|
||||
"playbackStatusConnectionError": "Ошибка подключения",
|
||||
"playbackStatusStopped": "Остановлено",
|
||||
"stationSemanticLabel": "Станция {stationName}",
|
||||
|
||||
@@ -477,6 +477,7 @@
|
||||
"playbackStatusConnecting": "正在连接...",
|
||||
"playbackStatusLive": "直播中",
|
||||
"playbackStatusPaused": "已暂停",
|
||||
"playbackStatusReconnecting": "正在重新连接...",
|
||||
"playbackStatusConnectionError": "连接错误",
|
||||
"playbackStatusStopped": "已停止",
|
||||
"stationSemanticLabel": "电台 {stationName}",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -919,6 +919,9 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
@override
|
||||
String get playbackStatusPaused => 'متوقف مؤقتًا';
|
||||
|
||||
@override
|
||||
String get playbackStatusReconnecting => 'جارٍ إعادة الاتصال...';
|
||||
|
||||
@override
|
||||
String get playbackStatusConnectionError => 'خطأ في الاتصال';
|
||||
|
||||
|
||||
@@ -928,6 +928,9 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
@override
|
||||
String get playbackStatusPaused => 'বিরতিতে';
|
||||
|
||||
@override
|
||||
String get playbackStatusReconnecting => 'পুনরায় সংযোগ করা হচ্ছে...';
|
||||
|
||||
@override
|
||||
String get playbackStatusConnectionError => 'সংযোগে ত্রুটি';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -923,6 +923,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get playbackStatusPaused => 'Paused';
|
||||
|
||||
@override
|
||||
String get playbackStatusReconnecting => 'Reconnecting...';
|
||||
|
||||
@override
|
||||
String get playbackStatusConnectionError => 'Connection error';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -924,6 +924,9 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
@override
|
||||
String get playbackStatusPaused => 'विराम पर';
|
||||
|
||||
@override
|
||||
String get playbackStatusReconnecting => 'पुनः कनेक्ट हो रहा है...';
|
||||
|
||||
@override
|
||||
String get playbackStatusConnectionError => 'कनेक्शन त्रुटि';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -895,6 +895,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get playbackStatusPaused => '一時停止中';
|
||||
|
||||
@override
|
||||
String get playbackStatusReconnecting => '再接続中...';
|
||||
|
||||
@override
|
||||
String get playbackStatusConnectionError => '接続エラー';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -929,6 +929,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
@override
|
||||
String get playbackStatusPaused => 'Приостановлено';
|
||||
|
||||
@override
|
||||
String get playbackStatusReconnecting => 'Переподключение...';
|
||||
|
||||
@override
|
||||
String get playbackStatusConnectionError => 'Ошибка подключения';
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user