fix(player): separate selection from audio state
This commit is contained in:
@@ -4,22 +4,22 @@ Referencia interna para futuras correcciones del reproductor de PluriWave. Este
|
|||||||
|
|
||||||
## Hallazgos útiles
|
## 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.
|
- `AudioPlayer.play()` completa cuando la reproducción termina, se pausa o se detiene. En radio en vivo no representa simplemente “ya empezó a sonar”.
|
||||||
- Para streams en vivo, la UI debe depender de `playerStateStream`: `loading`/`buffering` para spinner, `ready + playing` para estado en directo.
|
- Las versiones antiguas de PluriWave que sí cambiaban de emisora usaban el flujo simple de `audio_service` + `just_audio`: `stop() -> setUrl() -> play()` dentro de `playMediaItem`.
|
||||||
- El ejemplo de radio de `just_audio` configura sesión de audio 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.
|
- La regresión apareció cuando mezclamos dos responsabilidades: usar `handler.emisoraActual` como estado técnico de audio y también como estado visual inmediato para mostrar el mini reproductor.
|
||||||
- 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.
|
- En la versión vieja, el audio podía cambiar correctamente aunque `emisoraActual` no se usara como “selección visual inmediata”. Para arreglar el primer render del mini reproductor sin romper audio, conviene separar ambos conceptos.
|
||||||
|
|
||||||
## Decisión aplicada en PluriWave
|
## Decisión aplicada en PluriWave
|
||||||
|
|
||||||
- Mantener `setUrl(...)` porque era la ruta que conectaba correctamente la primera emisora en esta app.
|
- Restaurar el flujo histórico del handler: `stop -> setUrl -> play`.
|
||||||
- Serializar cambios de emisora con una cola interna para que no se solapen `stop/setUrl/play`.
|
- Mantener la selección visual inmediata en `EstadoRadio` mediante una emisora seleccionada propia, separada del `emisoraActual` interno del handler.
|
||||||
- Usar una revisión incremental para que solo la última solicitud pueda actualizar estado/errores.
|
- No usar `setAudioSource(..., preload: false)` como reemplazo de `setUrl(...)`: en esta app rompió incluso la primera conexión.
|
||||||
- Ejecutar `play()` sin `await`, porque en radios en vivo su `Future` no representa “ya arrancó”, sino “terminó/pausó/detuvo”.
|
- Proteger `EstadoRadio.reproducir` con revisión para que una operación vieja no aplique presets/clicks encima de una nueva.
|
||||||
- Proteger `EstadoRadio.reproducir` con revisión para que una reproducción vieja que termine tarde no aplique presets/clicks encima de la emisora nueva.
|
|
||||||
|
|
||||||
## Intento descartado
|
## Intentos descartados
|
||||||
|
|
||||||
- Se probó `setAudioSource(..., preload: false)` siguiendo una interpretación más transaccional del API, pero en PluriWave rompió incluso la primera conexión. Queda descartado salvo que se acompañe de logs nativos que justifiquen retomarlo.
|
- `setAudioSource(..., preload: false)`: teóricamente razonable, pero en PluriWave rompió la primera conexión.
|
||||||
|
- Hacer que el handler publique `emisoraActual` antes de que el flujo histórico de audio avance: arregla el mini reproductor, pero cambia la semántica que tenían las versiones viejas.
|
||||||
|
|
||||||
## Fuentes consultadas
|
## Fuentes consultadas
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudio;
|
StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudio;
|
||||||
Future<void>? _initFuture;
|
Future<void>? _initFuture;
|
||||||
int _revisionReproduccion = 0;
|
int _revisionReproduccion = 0;
|
||||||
|
Emisora? _emisoraSeleccionada;
|
||||||
|
|
||||||
// Errores de reproducción → SnackBar.
|
// Errores de reproducción → SnackBar.
|
||||||
final _errorController = StreamController<String>.broadcast();
|
final _errorController = StreamController<String>.broadcast();
|
||||||
@@ -91,7 +92,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
String? get paisCercanoDetectado => _paisCercanoDetectado;
|
String? get paisCercanoDetectado => _paisCercanoDetectado;
|
||||||
String? get errorCercanas => _errorCercanas;
|
String? get errorCercanas => _errorCercanas;
|
||||||
String? get error => _errorCarga;
|
String? get error => _errorCarga;
|
||||||
Emisora? get emisoraActual => audio.emisoraActual;
|
Emisora? get emisoraActual => _emisoraSeleccionada ?? audio.emisoraActual;
|
||||||
Stream<EstadoReproduccion> get estadoStream => audio.estadoStream;
|
Stream<EstadoReproduccion> get estadoStream => audio.estadoStream;
|
||||||
PresetEcualizador get presetEcualizador => _presetActual;
|
PresetEcualizador get presetEcualizador => _presetActual;
|
||||||
PresetEcualizador get presetPrincipalEcualizador => _presetPrincipal;
|
PresetEcualizador get presetPrincipalEcualizador => _presetPrincipal;
|
||||||
@@ -305,6 +306,8 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> reproducir(Emisora emisora) async {
|
Future<void> reproducir(Emisora emisora) async {
|
||||||
final revision = ++_revisionReproduccion;
|
final revision = ++_revisionReproduccion;
|
||||||
|
_emisoraSeleccionada = emisora;
|
||||||
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
await audio.reproducir(emisora);
|
await audio.reproducir(emisora);
|
||||||
if (revision != _revisionReproduccion) return;
|
if (revision != _revisionReproduccion) return;
|
||||||
@@ -318,6 +321,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
unawaited(timer.cancelar());
|
unawaited(timer.cancelar());
|
||||||
}
|
}
|
||||||
final mensajeError = e.toString().replaceFirst('Exception: ', '');
|
final mensajeError = e.toString().replaceFirst('Exception: ', '');
|
||||||
|
_emisoraSeleccionada = audio.emisoraActual;
|
||||||
_errorController.add(
|
_errorController.add(
|
||||||
mensajeError.isNotEmpty && mensajeError != 'Exception'
|
mensajeError.isNotEmpty && mensajeError != 'Exception'
|
||||||
? mensajeError
|
? mensajeError
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:developer' as developer;
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
@@ -91,8 +90,6 @@ 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(
|
||||||
@@ -110,10 +107,6 @@ 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() {
|
||||||
_setupStreams();
|
_setupStreams();
|
||||||
}
|
}
|
||||||
@@ -143,15 +136,6 @@ 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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -230,78 +214,33 @@ 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().timeout(_timeoutCambioFuente);
|
await _player.stop();
|
||||||
if (revision != _revisionFuente) return;
|
await _player.setUrl(mediaItem.id);
|
||||||
|
await _player.play();
|
||||||
await _player.setUrl(mediaItem.id).timeout(_timeoutCambioFuente);
|
emisoraActual = _emisoraDesdeMediaItem(mediaItem);
|
||||||
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, stackTrace) {
|
} on Exception catch (e) {
|
||||||
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,
|
errorMessage: 'Error inesperado al reproducir',
|
||||||
errorMessage: 'Error inesperado al reproducir',
|
));
|
||||||
));
|
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 {
|
||||||
try {
|
try {
|
||||||
final params = await _eq.parameters;
|
final params = await _eq.parameters;
|
||||||
@@ -356,7 +295,6 @@ 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user