fix(player): serialize live stream switching
This commit is contained in:
@@ -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
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,22 +246,51 @@ 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) {
|
||||||
|
if (revision == _revisionFuente) {
|
||||||
_gestionarErrorReproduccion(e);
|
_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,
|
||||||
);
|
);
|
||||||
|
if (revision == _revisionFuente) {
|
||||||
playbackState.add(playbackState.value.copyWith(
|
playbackState.add(playbackState.value.copyWith(
|
||||||
processingState: AudioProcessingState.error,
|
processingState: AudioProcessingState.error,
|
||||||
playing: false,
|
playing: false,
|
||||||
@@ -237,8 +298,29 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
|
|||||||
));
|
));
|
||||||
emisoraActual = null;
|
emisoraActual = null;
|
||||||
this.mediaItem.add(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 {
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user