fix(player): serialize live stream switching
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m24s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s

This commit is contained in:
2026-05-21 00:50:17 +02:00
parent 26d8151d7a
commit 6b0faebc7f
4 changed files with 197 additions and 13 deletions
+25
View File
@@ -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
+5
View File
@@ -45,6 +45,7 @@ class EstadoRadio extends ChangeNotifier {
late final ServicioTimer timer; late final ServicioTimer timer;
StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudio; StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudio;
Future<void>? _initFuture; Future<void>? _initFuture;
int _revisionReproduccion = 0;
// Errores de reproducción → SnackBar. // Errores de reproducción → SnackBar.
final _errorController = StreamController<String>.broadcast(); final _errorController = StreamController<String>.broadcast();
@@ -303,12 +304,16 @@ class EstadoRadio extends ChangeNotifier {
} }
Future<void> reproducir(Emisora emisora) async { Future<void> reproducir(Emisora emisora) async {
final revision = ++_revisionReproduccion;
try { try {
await audio.reproducir(emisora); await audio.reproducir(emisora);
if (revision != _revisionReproduccion) return;
unawaited(radio.registrarClick(emisora.uuid)); unawaited(radio.registrarClick(emisora.uuid));
await _aplicarPresetActivo(_presetParaEmisora(emisora.uuid)); await _aplicarPresetActivo(_presetParaEmisora(emisora.uuid));
if (revision != _revisionReproduccion) return;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
if (revision != _revisionReproduccion) return;
if (timer.activo) { if (timer.activo) {
unawaited(timer.cancelar()); unawaited(timer.cancelar());
} }
+96 -13
View File
@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:developer' as developer; import 'dart:developer' as developer;
import 'package:audio_session/audio_session.dart';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
@@ -90,9 +92,12 @@ class ServicioAudio {
// AudioHandler // AudioHandler
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
static const _timeoutCambioFuente = Duration(seconds: 12);
final AndroidEqualizer _eq = AndroidEqualizer(); final AndroidEqualizer _eq = AndroidEqualizer();
late final AudioPlayer _player = AudioPlayer( late final AudioPlayer _player = AudioPlayer(
userAgent: 'PluriWave/0.1.0 (es.freetimelab.pluriwave)',
audioPipeline: AudioPipeline(androidAudioEffects: [_eq]), audioPipeline: AudioPipeline(androidAudioEffects: [_eq]),
); );
@@ -107,10 +112,28 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
PresetEcualizador _presetActual = PresetEcualizador.flat; PresetEcualizador _presetActual = PresetEcualizador.flat;
PresetEcualizador get presetActual => _presetActual; PresetEcualizador get presetActual => _presetActual;
Future<void> _colaCambiosFuente = Future<void>.value();
int _revisionFuente = 0;
bool _cambiandoFuente = false;
PluriWaveAudioHandler() { PluriWaveAudioHandler() {
unawaited(_configurarSesionAudio());
_setupStreams(); _setupStreams();
} }
Future<void> _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() { void _setupStreams() {
_player.playerStateStream.listen((state) { _player.playerStateStream.listen((state) {
final playing = state.playing; final playing = state.playing;
@@ -136,6 +159,15 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
_player.playbackEventStream.listen( _player.playbackEventStream.listen(
(_) {}, (_) {},
onError: (Object error, StackTrace stackTrace) { 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); _gestionarErrorReproduccion(error);
}, },
); );
@@ -214,33 +246,83 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
@override @override
Future<void> playMediaItem(MediaItem mediaItem) async { Future<void> playMediaItem(MediaItem mediaItem) async {
final revision = ++_revisionFuente;
_colaCambiosFuente = _colaCambiosFuente
.catchError((_) {})
.then((_) => _cambiarFuente(mediaItem, revision));
return _colaCambiosFuente;
}
Future<void> _cambiarFuente(MediaItem mediaItem, int revision) async {
final emisora = _emisoraDesdeMediaItem(mediaItem);
this.mediaItem.add(mediaItem); this.mediaItem.add(mediaItem);
emisoraActual = emisora;
playbackState.add(playbackState.value.copyWith(
processingState: AudioProcessingState.loading,
playing: false,
errorMessage: null,
));
_cambiandoFuente = true;
try { try {
await _player.stop(); await _player.stop().timeout(_timeoutCambioFuente);
await _player.setUrl(mediaItem.id); if (revision != _revisionFuente) return;
await _player.play();
emisoraActual = _emisoraDesdeMediaItem(mediaItem); await _player
.setAudioSource(
AudioSource.uri(Uri.parse(mediaItem.id), tag: mediaItem),
preload: false,
)
.timeout(_timeoutCambioFuente);
if (revision != _revisionFuente) return;
await _activarEcualizador(); await _activarEcualizador();
_reproducirSinBloquear(mediaItem, revision);
} on PlayerException catch (e) { } on PlayerException catch (e) {
_gestionarErrorReproduccion(e); if (revision == _revisionFuente) {
_gestionarErrorReproduccion(e);
}
throw Exception(_mensajeAmigable(e)); throw Exception(_mensajeAmigable(e));
} on Exception catch (e) { } on Exception catch (e, stackTrace) {
developer.log( developer.log(
'[PluriWave] Error inesperado en playMediaItem: $e', '[PluriWave] Error inesperado en playMediaItem: $e',
name: 'ServicioAudio', name: 'ServicioAudio',
level: 900, level: 900,
stackTrace: stackTrace,
); );
playbackState.add(playbackState.value.copyWith( if (revision == _revisionFuente) {
processingState: AudioProcessingState.error, playbackState.add(playbackState.value.copyWith(
playing: false, processingState: AudioProcessingState.error,
errorMessage: 'Error inesperado al reproducir', playing: false,
)); errorMessage: 'Error inesperado al reproducir',
emisoraActual = null; ));
this.mediaItem.add(null); emisoraActual = null;
this.mediaItem.add(null);
}
rethrow; 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<void> _activarEcualizador() async { Future<void> _activarEcualizador() async {
try { try {
final params = await _eq.parameters; final params = await _eq.parameters;
@@ -295,6 +377,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
@override @override
Future<void> stop() async { Future<void> stop() async {
_revisionFuente++;
await _player.stop(); await _player.stop();
emisoraActual = null; emisoraActual = null;
mediaItem.add(null); mediaItem.add(null);
+71
View File
@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart'; import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_audio.dart'; import 'package:pluriwave/servicios/servicio_audio.dart';
@@ -216,6 +218,41 @@ void main() {
expect(audio.emisorasReproducidas, hasLength(2)); 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 { test('reordenar favoritos reindexa de forma determinística', () async {
final favoritos = FakeServicioFavoritos(); final favoritos = FakeServicioFavoritos();
await favoritos.agregar(emisoraDemo(uuid: 'a', nombre: 'A')); await favoritos.agregar(emisoraDemo(uuid: 'a', nombre: 'A'));
@@ -282,6 +319,40 @@ void main() {
}); });
} }
class _AudioControlado extends ServicioAudio {
final _estadoController = StreamController<EstadoReproduccion>.broadcast();
final _pendientes = <String, Completer<void>>{};
Emisora? _actual;
@override
Emisora? get emisoraActual => _actual;
@override
Stream<EstadoReproduccion> get estadoStream => _estadoController.stream;
@override
Future<void> reproducir(Emisora emisora) async {
final completer = Completer<void>();
_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<void> aplicarPreset(PresetEcualizador preset) async {}
@override
Future<void> dispose() async {
await _estadoController.close();
}
}
Future<File> _archivoCustomVacio() async => _crearArchivoCustom(const []); Future<File> _archivoCustomVacio() async => _crearArchivoCustom(const []);
Future<File> _crearArchivoCustom(List<dynamic> emisoras) async { Future<File> _crearArchivoCustom(List<dynamic> emisoras) async {