fix(player): recreate audio player on station switch
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m52s

This commit is contained in:
2026-05-21 20:52:28 +02:00
parent 0456850f3d
commit 0e18c82292
2 changed files with 101 additions and 25 deletions
+4 -2
View File
@@ -7,19 +7,21 @@ Referencia interna para futuras correcciones del reproductor de PluriWave. Este
- `AudioPlayer.play()` completa cuando la reproducción termina, se pausa o se detiene. En radio en vivo no representa simplemente “ya empezó a sonar”. - `AudioPlayer.play()` completa cuando la reproducción termina, se pausa o se detiene. En radio en vivo no representa simplemente “ya empezó a sonar”.
- 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`. - 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`.
- 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. - 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 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. - El logcat de 2026-05-21 mostró la media session de PluriWave atascada en `CONNECTING` sin `PlayerException`, con metadata de la emisora anterior (`Track FM`). Eso apunta a un player/ExoPlayer reutilizado que queda colgado entre `stop/setUrl/play`, no a un error HTTP visible.
## Decisión aplicada en PluriWave ## Decisión aplicada en PluriWave
- Restaurar el flujo histórico del handler: `stop -> setUrl -> play`.
- Mantener la selección visual inmediata en `EstadoRadio` mediante una emisora seleccionada propia, separada del `emisoraActual` interno del handler. - Mantener la selección visual inmediata en `EstadoRadio` mediante una emisora seleccionada propia, separada del `emisoraActual` interno del handler.
- No usar `setAudioSource(..., preload: false)` como reemplazo de `setUrl(...)`: en esta app rompió incluso la primera conexión. - No usar `setAudioSource(..., preload: false)` como reemplazo de `setUrl(...)`: en esta app rompió incluso la primera conexión.
- No esperar `play()` como operación de finalización para radio en vivo.
- Al cambiar emisora, recrear el `AudioPlayer`/ExoPlayer para matar completamente la reproducción anterior antes de `setUrl(...)`.
- 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 operación vieja no aplique presets/clicks encima de una nueva.
## Intentos descartados ## Intentos descartados
- `setAudioSource(..., preload: false)`: teóricamente razonable, pero en PluriWave rompió la primera conexión. - `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. - 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.
- Reutilizar siempre el mismo `AudioPlayer` con `stop()`: logcat mostró estado `CONNECTING` persistente sin excepción al cambiar/reintentar.
## Fuentes consultadas ## Fuentes consultadas
+97 -23
View File
@@ -1,3 +1,4 @@
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';
@@ -90,11 +91,16 @@ class ServicioAudio {
// AudioHandler // AudioHandler
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler { class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
final AndroidEqualizer _eq = AndroidEqualizer(); static const _timeoutCambioFuente = Duration(seconds: 12);
static const _timeoutCierrePlayer = Duration(seconds: 3);
late final AudioPlayer _player = AudioPlayer( AndroidEqualizer _eq = AndroidEqualizer();
audioPipeline: AudioPipeline(androidAudioEffects: [_eq]), late AudioPlayer _player = _crearPlayer();
); StreamSubscription<PlayerState>? _estadoPlayerSub;
StreamSubscription<Duration>? _bufferedSub;
StreamSubscription<PlaybackEvent>? _eventosSub;
Future<void> _colaCambioFuente = Future<void>.value();
int _revisionFuente = 0;
Emisora? emisoraActual; Emisora? emisoraActual;
double _volumen = 1.0; double _volumen = 1.0;
@@ -108,11 +114,17 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
PresetEcualizador get presetActual => _presetActual; PresetEcualizador get presetActual => _presetActual;
PluriWaveAudioHandler() { PluriWaveAudioHandler() {
_setupStreams(); _conectarStreamsPlayer();
} }
void _setupStreams() { AudioPlayer _crearPlayer() {
_player.playerStateStream.listen((state) { return AudioPlayer(
audioPipeline: AudioPipeline(androidAudioEffects: [_eq]),
);
}
void _conectarStreamsPlayer() {
_estadoPlayerSub = _player.playerStateStream.listen((state) {
final playing = state.playing; final playing = state.playing;
final proc = state.processingState; final proc = state.processingState;
playbackState.add(playbackState.value.copyWith( playbackState.add(playbackState.value.copyWith(
@@ -129,11 +141,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
)); ));
}); });
_player.bufferedPositionStream.listen((pos) { _bufferedSub = _player.bufferedPositionStream.listen((pos) {
playbackState.add(playbackState.value.copyWith(bufferedPosition: pos)); playbackState.add(playbackState.value.copyWith(bufferedPosition: pos));
}); });
_player.playbackEventStream.listen( _eventosSub = _player.playbackEventStream.listen(
(_) {}, (_) {},
onError: (Object error, StackTrace stackTrace) { onError: (Object error, StackTrace stackTrace) {
_gestionarErrorReproduccion(error); _gestionarErrorReproduccion(error);
@@ -214,33 +226,91 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
@override @override
Future<void> playMediaItem(MediaItem mediaItem) async { Future<void> playMediaItem(MediaItem mediaItem) async {
final revision = ++_revisionFuente;
_colaCambioFuente = _colaCambioFuente
.catchError((_) {})
.then((_) => _cambiarFuente(mediaItem, revision));
return _colaCambioFuente;
}
Future<void> _cambiarFuente(MediaItem mediaItem, int revision) async {
this.mediaItem.add(mediaItem); this.mediaItem.add(mediaItem);
emisoraActual = _emisoraDesdeMediaItem(mediaItem);
playbackState.add(playbackState.value.copyWith(
processingState: AudioProcessingState.loading,
playing: false,
errorMessage: null,
));
try { try {
await _player.stop(); await _recrearPlayer();
await _player.setUrl(mediaItem.id); if (revision != _revisionFuente) return;
await _player.play();
emisoraActual = _emisoraDesdeMediaItem(mediaItem); await _player.setUrl(mediaItem.id).timeout(_timeoutCambioFuente);
await _activarEcualizador(); if (revision != _revisionFuente) return;
_iniciarPlaySinBloquear(mediaItem, revision);
unawaited(_activarEcualizador());
} 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;
} }
} }
Future<void> _recrearPlayer() async {
await _estadoPlayerSub?.cancel();
await _bufferedSub?.cancel();
await _eventosSub?.cancel();
final anterior = _player;
try {
await anterior.stop().timeout(_timeoutCierrePlayer);
} catch (_) {}
try {
await anterior.dispose().timeout(_timeoutCierrePlayer);
} catch (_) {}
_eq = AndroidEqualizer();
_eqDisponible = false;
_player = _crearPlayer();
await _player.setVolume(_volumen);
_conectarStreamsPlayer();
}
void _iniciarPlaySinBloquear(MediaItem mediaItem, int revision) {
unawaited(
_player.play().catchError((Object error, StackTrace stackTrace) {
developer.log(
'[PluriWave] Error al iniciar ${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 +365,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);
@@ -307,6 +378,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
@override @override
Future<void> onTaskRemoved() async { Future<void> onTaskRemoved() async {
await stop(); await stop();
await _estadoPlayerSub?.cancel();
await _bufferedSub?.cancel();
await _eventosSub?.cancel();
await _player.dispose(); await _player.dispose();
} }