From 6b0faebc7f8d28b61d58f08fb20bdf6f2b5fb76e Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 21 May 2026 00:50:17 +0200 Subject: [PATCH] fix(player): serialize live stream switching --- docs/audio-switching-notes.md | 25 +++++++ lib/estado/estado_radio.dart | 5 ++ lib/servicios/servicio_audio.dart | 109 +++++++++++++++++++++++++---- test/estado/estado_radio_test.dart | 71 +++++++++++++++++++ 4 files changed, 197 insertions(+), 13 deletions(-) create mode 100644 docs/audio-switching-notes.md diff --git a/docs/audio-switching-notes.md b/docs/audio-switching-notes.md new file mode 100644 index 0000000..8bef9e0 --- /dev/null +++ b/docs/audio-switching-notes.md @@ -0,0 +1,25 @@ +# Notas de reproducción de radio con just_audio/audio_service + +Referencia interna para futuras correcciones del reproductor de PluriWave. Este archivo está en `docs/` y no se incluye en `flutter.assets`, por lo que no compila dentro de la app. + +## Hallazgos útiles + +- `AudioPlayer.play()` **no debe esperarse como si fuera “arrancar y terminar”** en un stream de radio en vivo. Según la documentación de `just_audio`, el `Future` de `play()` completa cuando la reproducción termina, se pausa o se detiene. En una radio en vivo puede quedar vivo hasta que otra acción lo interrumpa. +- Para streams en vivo, la UI debe depender de `playerStateStream`: `loading`/`buffering` para spinner, `ready + playing` para estado en directo. +- El ejemplo de radio de `just_audio` configura `AudioSession` y escucha errores de playback. Conviene tratar el cambio de emisora como una operación transaccional: parar fuente anterior, asignar fuente nueva y arrancar sin bloquear el flujo principal. +- En `audio_service`, si se cambia de fuente desde `playMediaItem`, hay que evitar que errores tardíos de la fuente anterior limpien la emisora nueva. Es una carrera típica cuando se hace `stop()` y enseguida se carga otro stream. + +## Decisión aplicada en PluriWave + +- Serializar cambios de emisora con una cola interna. +- Usar una revisión incremental para que solo la última solicitud pueda actualizar estado/errores. +- Usar `setAudioSource(..., preload: false)` y luego `play()` sin `await`, para que la carga de stream vivo no bloquee la operación. +- Ignorar errores emitidos durante la ventana de cambio de fuente, porque pueden pertenecer al stream anterior. +- Proteger `EstadoRadio.reproducir` con revisión para que una reproducción vieja que termine tarde no aplique presets/clicks encima de la emisora nueva. + +## Fuentes consultadas + +- just_audio `AudioPlayer.play()` API: https://pub.dev/documentation/just_audio/latest/just_audio/AudioPlayer/play.html +- just_audio `AudioPlayer` API general: https://pub.dev/documentation/just_audio/latest/just_audio/AudioPlayer-class.html +- Ejemplo de radio en vivo de just_audio: https://gist.github.com/scysys/7f700cd49f09ba788021504e8d3477aa +- Discusión sobre cambiar fuente con audio_service + just_audio: https://stackoverflow.com/questions/70526156/changing-audio-source-in-audio-service-and-just-audio-flutter diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index ae8517b..d47c991 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -45,6 +45,7 @@ class EstadoRadio extends ChangeNotifier { late final ServicioTimer timer; StreamSubscription? _suscripcionEstadoAudio; Future? _initFuture; + int _revisionReproduccion = 0; // Errores de reproducción → SnackBar. final _errorController = StreamController.broadcast(); @@ -303,12 +304,16 @@ class EstadoRadio extends ChangeNotifier { } Future reproducir(Emisora emisora) async { + final revision = ++_revisionReproduccion; try { await audio.reproducir(emisora); + if (revision != _revisionReproduccion) return; unawaited(radio.registrarClick(emisora.uuid)); await _aplicarPresetActivo(_presetParaEmisora(emisora.uuid)); + if (revision != _revisionReproduccion) return; notifyListeners(); } catch (e) { + if (revision != _revisionReproduccion) return; if (timer.activo) { unawaited(timer.cancelar()); } diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 633801e..487b94a 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -1,5 +1,7 @@ +import 'dart:async'; import 'dart:developer' as developer; +import 'package:audio_session/audio_session.dart'; import 'package:audio_service/audio_service.dart'; import 'package:just_audio/just_audio.dart'; @@ -90,9 +92,12 @@ class ServicioAudio { // AudioHandler // ───────────────────────────────────────────────────────────────────────────── class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { + static const _timeoutCambioFuente = Duration(seconds: 12); + final AndroidEqualizer _eq = AndroidEqualizer(); late final AudioPlayer _player = AudioPlayer( + userAgent: 'PluriWave/0.1.0 (es.freetimelab.pluriwave)', audioPipeline: AudioPipeline(androidAudioEffects: [_eq]), ); @@ -107,10 +112,28 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { PresetEcualizador _presetActual = PresetEcualizador.flat; PresetEcualizador get presetActual => _presetActual; + Future _colaCambiosFuente = Future.value(); + int _revisionFuente = 0; + bool _cambiandoFuente = false; + PluriWaveAudioHandler() { + unawaited(_configurarSesionAudio()); _setupStreams(); } + Future _configurarSesionAudio() async { + try { + final session = await AudioSession.instance; + await session.configure(const AudioSessionConfiguration.music()); + } catch (e) { + developer.log( + '[PluriWave] No se pudo configurar AudioSession: $e', + name: 'ServicioAudio', + level: 800, + ); + } + } + void _setupStreams() { _player.playerStateStream.listen((state) { final playing = state.playing; @@ -136,6 +159,15 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { _player.playbackEventStream.listen( (_) {}, onError: (Object error, StackTrace stackTrace) { + if (_cambiandoFuente) { + developer.log( + '[PluriWave] Error ignorado durante cambio de emisora: $error', + name: 'ServicioAudio', + level: 800, + stackTrace: stackTrace, + ); + return; + } _gestionarErrorReproduccion(error); }, ); @@ -214,33 +246,83 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { @override Future playMediaItem(MediaItem mediaItem) async { + final revision = ++_revisionFuente; + _colaCambiosFuente = _colaCambiosFuente + .catchError((_) {}) + .then((_) => _cambiarFuente(mediaItem, revision)); + return _colaCambiosFuente; + } + + Future _cambiarFuente(MediaItem mediaItem, int revision) async { + final emisora = _emisoraDesdeMediaItem(mediaItem); this.mediaItem.add(mediaItem); + emisoraActual = emisora; + playbackState.add(playbackState.value.copyWith( + processingState: AudioProcessingState.loading, + playing: false, + errorMessage: null, + )); + + _cambiandoFuente = true; try { - await _player.stop(); - await _player.setUrl(mediaItem.id); - await _player.play(); - emisoraActual = _emisoraDesdeMediaItem(mediaItem); + await _player.stop().timeout(_timeoutCambioFuente); + if (revision != _revisionFuente) return; + + await _player + .setAudioSource( + AudioSource.uri(Uri.parse(mediaItem.id), tag: mediaItem), + preload: false, + ) + .timeout(_timeoutCambioFuente); + if (revision != _revisionFuente) return; + await _activarEcualizador(); + _reproducirSinBloquear(mediaItem, revision); } on PlayerException catch (e) { - _gestionarErrorReproduccion(e); + if (revision == _revisionFuente) { + _gestionarErrorReproduccion(e); + } throw Exception(_mensajeAmigable(e)); - } on Exception catch (e) { + } on Exception catch (e, stackTrace) { developer.log( '[PluriWave] Error inesperado en playMediaItem: $e', name: 'ServicioAudio', level: 900, + stackTrace: stackTrace, ); - playbackState.add(playbackState.value.copyWith( - processingState: AudioProcessingState.error, - playing: false, - errorMessage: 'Error inesperado al reproducir', - )); - emisoraActual = null; - this.mediaItem.add(null); + if (revision == _revisionFuente) { + playbackState.add(playbackState.value.copyWith( + processingState: AudioProcessingState.error, + playing: false, + errorMessage: 'Error inesperado al reproducir', + )); + emisoraActual = null; + this.mediaItem.add(null); + } rethrow; + } finally { + if (revision == _revisionFuente) { + _cambiandoFuente = false; + } } } + void _reproducirSinBloquear(MediaItem mediaItem, int revision) { + unawaited( + _player.play().catchError((Object error, StackTrace stackTrace) { + developer.log( + '[PluriWave] Error al arrancar ${mediaItem.title}: $error', + name: 'ServicioAudio', + level: 900, + stackTrace: stackTrace, + ); + if (revision == _revisionFuente) { + _gestionarErrorReproduccion(error); + } + }), + ); + } + Future _activarEcualizador() async { try { final params = await _eq.parameters; @@ -295,6 +377,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { @override Future stop() async { + _revisionFuente++; await _player.stop(); emisoraActual = null; mediaItem.add(null); diff --git a/test/estado/estado_radio_test.dart b/test/estado/estado_radio_test.dart index 4637012..827f545 100644 --- a/test/estado/estado_radio_test.dart +++ b/test/estado/estado_radio_test.dart @@ -1,8 +1,10 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; @@ -216,6 +218,41 @@ void main() { expect(audio.emisorasReproducidas, hasLength(2)); }); + test('ignora finalizaciones stale cuando se cambia de emisora rapido', () async { + final audio = _AudioControlado(); + final radio = FakeServicioRadio(); + final primera = emisoraDemo(uuid: 'slow-1', nombre: 'Lenta'); + final segunda = emisoraDemo(uuid: 'fast-2', nombre: 'Rapida'); + final estado = EstadoRadio( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: radio, + servicioEcualizador: FakeServicioEcualizador(), + resolverArchivoCustom: _archivoCustomVacio, + iniciarAutomaticamente: false, + ); + + await estado.inicializar(); + await estado.guardarPresetEcualizadorPorEmisora( + primera.uuid, + PresetEcualizador.rock, + ); + await estado.guardarPresetEcualizadorPorEmisora( + segunda.uuid, + PresetEcualizador.jazz, + ); + + final primeraFuture = estado.reproducir(primera); + final segundaFuture = estado.reproducir(segunda); + audio.completar(segunda.uuid); + await segundaFuture; + audio.completar(primera.uuid); + await primeraFuture; + + expect(estado.presetEcualizador, PresetEcualizador.jazz); + expect(radio.ultimoUuidClick, segunda.uuid); + }); + test('reordenar favoritos reindexa de forma determinística', () async { final favoritos = FakeServicioFavoritos(); await favoritos.agregar(emisoraDemo(uuid: 'a', nombre: 'A')); @@ -282,6 +319,40 @@ void main() { }); } +class _AudioControlado extends ServicioAudio { + final _estadoController = StreamController.broadcast(); + final _pendientes = >{}; + Emisora? _actual; + + @override + Emisora? get emisoraActual => _actual; + + @override + Stream get estadoStream => _estadoController.stream; + + @override + Future reproducir(Emisora emisora) async { + final completer = Completer(); + _pendientes[emisora.uuid] = completer; + _actual = emisora; + _estadoController.add(EstadoReproduccion.cargando); + await completer.future; + _estadoController.add(EstadoReproduccion.reproduciendo); + } + + void completar(String uuid) { + _pendientes.remove(uuid)?.complete(); + } + + @override + Future aplicarPreset(PresetEcualizador preset) async {} + + @override + Future dispose() async { + await _estadoController.close(); + } +} + Future _archivoCustomVacio() async => _crearArchivoCustom(const []); Future _crearArchivoCustom(List emisoras) async {