fix(v0.3.0): audio background + emisoras rotas + errores toast + icono
- ServicioAudio: delega a PluriWaveAudioHandler (audio_service) para mantener audio vivo en background. AudioService.init() en main.dart. onTaskRemoved() libera player. mediaItem con nombre/artista/artwork. - ServicioRadio: lastcheckok=1 en todas las peticiones — solo emisoras verificadas como funcionales por Radio Browser API. - EstadoRadio: errorStream (broadcast) para errores de reproducción y búsqueda. App.dart suscribe y muestra SnackBar flotante 3s. Los errores de carga de lista siguen como banner inline. - Icono: generado con SDXL (morado, ondas radio blancas, Material You). 5 densidades Android (48-192px), ic_launcher_round añadido.
This commit is contained in:
@@ -5,115 +5,89 @@ import '../modelos/emisora.dart';
|
||||
/// Estado de reproducción expuesto al UI.
|
||||
enum EstadoReproduccion { detenido, cargando, reproduciendo, pausado, error }
|
||||
|
||||
/// Wrapper sobre just_audio + audio_service para reproducción de radio en streaming.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Handler global — inicializado en main.dart con AudioService.init
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
PluriWaveAudioHandler? _handlerGlobal;
|
||||
|
||||
/// Registra el handler. Llamar desde main.dart tras AudioService.init.
|
||||
void registrarHandler(PluriWaveAudioHandler handler) {
|
||||
_handlerGlobal = handler;
|
||||
}
|
||||
|
||||
/// Wrapper de alto nivel para el UI.
|
||||
///
|
||||
/// ### Uso
|
||||
/// ```dart
|
||||
/// final servicio = ServicioAudio();
|
||||
/// await servicio.inicializar();
|
||||
/// await servicio.reproducir(emisora);
|
||||
/// await servicio.pausar();
|
||||
/// await servicio.detener();
|
||||
/// ```
|
||||
///
|
||||
/// ### Background audio
|
||||
/// Para habilitar reproducción en background, el handler [PluriWaveAudioHandler]
|
||||
/// debe registrarse en main.dart con [AudioService.init]. Si no está registrado,
|
||||
/// just_audio seguirá funcionando en foreground.
|
||||
/// Delega TODA la reproducción al [PluriWaveAudioHandler] para garantizar
|
||||
/// que el audio siga vivo en background con notificación foreground.
|
||||
class ServicioAudio {
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
Emisora? _emisoraActual;
|
||||
PluriWaveAudioHandler get _handler {
|
||||
assert(_handlerGlobal != null,
|
||||
'ServicioAudio: handler no registrado. '
|
||||
'Llama registrarHandler() en main.dart tras AudioService.init.');
|
||||
return _handlerGlobal!;
|
||||
}
|
||||
|
||||
EstadoReproduccion _estado = EstadoReproduccion.detenido;
|
||||
EstadoReproduccion get estado => _estado;
|
||||
Emisora? get emisoraActual => _emisoraActual;
|
||||
Emisora? get emisoraActual => _handler.emisoraActual;
|
||||
|
||||
/// Stream de cambios de estado para el UI.
|
||||
Stream<EstadoReproduccion> get estadoStream => _player.playerStateStream.map(
|
||||
(s) {
|
||||
if (s.processingState == ProcessingState.loading ||
|
||||
s.processingState == ProcessingState.buffering) {
|
||||
return EstadoReproduccion.cargando;
|
||||
}
|
||||
if (s.playing) return EstadoReproduccion.reproduciendo;
|
||||
if (s.processingState == ProcessingState.idle) return EstadoReproduccion.detenido;
|
||||
return EstadoReproduccion.pausado;
|
||||
},
|
||||
);
|
||||
Stream<EstadoReproduccion> get estadoStream =>
|
||||
_handler.playbackState.map((s) {
|
||||
if (s.processingState == AudioProcessingState.loading ||
|
||||
s.processingState == AudioProcessingState.buffering) {
|
||||
return EstadoReproduccion.cargando;
|
||||
}
|
||||
if (s.playing) return EstadoReproduccion.reproduciendo;
|
||||
if (s.processingState == AudioProcessingState.idle) {
|
||||
return EstadoReproduccion.detenido;
|
||||
}
|
||||
return EstadoReproduccion.pausado;
|
||||
});
|
||||
|
||||
/// Inicia la reproducción de la [emisora] indicada.
|
||||
Future<void> reproducir(Emisora emisora) async {
|
||||
try {
|
||||
_estado = EstadoReproduccion.cargando;
|
||||
|
||||
// Si es la misma emisora, reanudar sin recargar
|
||||
if (_emisoraActual?.uuid == emisora.uuid && _player.audioSource != null) {
|
||||
await _player.play();
|
||||
_estado = EstadoReproduccion.reproduciendo;
|
||||
return;
|
||||
}
|
||||
|
||||
_emisoraActual = emisora;
|
||||
await _player.stop();
|
||||
await _player.setUrl(emisora.url);
|
||||
await _player.play();
|
||||
_estado = EstadoReproduccion.reproduciendo;
|
||||
} on PlayerException catch (_) {
|
||||
_estado = EstadoReproduccion.error;
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
_estado = EstadoReproduccion.error;
|
||||
rethrow;
|
||||
}
|
||||
final item = MediaItem(
|
||||
id: emisora.url,
|
||||
title: emisora.nombre,
|
||||
artist: emisora.pais ?? '',
|
||||
album: 'PluriWave',
|
||||
artUri: emisora.favicon != null && emisora.favicon!.isNotEmpty
|
||||
? Uri.tryParse(emisora.favicon!)
|
||||
: null,
|
||||
extras: {'uuid': emisora.uuid},
|
||||
);
|
||||
await _handler.playMediaItem(item);
|
||||
}
|
||||
|
||||
/// Pausa la reproducción actual.
|
||||
Future<void> pausar() async {
|
||||
await _player.pause();
|
||||
_estado = EstadoReproduccion.pausado;
|
||||
}
|
||||
Future<void> pausar() => _handler.pause();
|
||||
Future<void> reanudar() => _handler.play();
|
||||
|
||||
/// Reanuda si estaba pausado.
|
||||
Future<void> reanudar() async {
|
||||
if (_player.audioSource != null) {
|
||||
await _player.play();
|
||||
_estado = EstadoReproduccion.reproduciendo;
|
||||
}
|
||||
}
|
||||
|
||||
/// Alterna entre pausa y reproducción.
|
||||
Future<void> togglePlay() async {
|
||||
if (_player.playing) {
|
||||
if (_handler.playbackState.value.playing) {
|
||||
await pausar();
|
||||
} else {
|
||||
await reanudar();
|
||||
}
|
||||
}
|
||||
|
||||
/// Detiene la reproducción y libera la fuente.
|
||||
Future<void> detener() async {
|
||||
await _player.stop();
|
||||
_emisoraActual = null;
|
||||
_estado = EstadoReproduccion.detenido;
|
||||
}
|
||||
Future<void> detener() => _handler.stop();
|
||||
|
||||
/// Ajusta el volumen (0.0 - 1.0).
|
||||
Future<void> setVolumen(double volumen) async {
|
||||
await _player.setVolume(volumen.clamp(0.0, 1.0));
|
||||
}
|
||||
Future<void> setVolumen(double vol) => _handler.setVolume(vol.clamp(0.0, 1.0));
|
||||
|
||||
double get volumen => _player.volume;
|
||||
bool get estaSonando => _player.playing;
|
||||
double get volumen => _handler.volumen;
|
||||
bool get estaSonando => _handler.playbackState.value.playing;
|
||||
|
||||
/// Libera recursos. Llamar al destruir la pantalla raíz.
|
||||
Future<void> dispose() async {
|
||||
await _player.dispose();
|
||||
}
|
||||
/// No-op: el handler se limpia en main.dart al cerrar la app.
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
/// Handler de audio_service para reproducción en background con notificación.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// AudioHandler — núcleo del audio en background
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// Handler de audio_service.
|
||||
///
|
||||
/// Registrar en main.dart:
|
||||
/// Gestiona la reproducción con `just_audio` y mantiene la notificación
|
||||
/// foreground activa mientras hay audio reproduciéndose.
|
||||
///
|
||||
/// ### Inicialización en main.dart
|
||||
/// ```dart
|
||||
/// final handler = await AudioService.init(
|
||||
/// builder: () => PluriWaveAudioHandler(),
|
||||
@@ -122,40 +96,72 @@ class ServicioAudio {
|
||||
/// androidNotificationChannelName: 'PluriWave Radio',
|
||||
/// androidNotificationOngoing: true,
|
||||
/// androidStopForegroundOnPause: true,
|
||||
/// androidNotificationIcon: 'drawable/ic_stat_radio',
|
||||
/// ),
|
||||
/// );
|
||||
/// registrarHandler(handler);
|
||||
/// ```
|
||||
class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
Emisora? emisoraActual;
|
||||
double _volumen = 1.0;
|
||||
double get volumen => _volumen;
|
||||
|
||||
PluriWaveAudioHandler() {
|
||||
_setupStreams();
|
||||
}
|
||||
|
||||
void _setupStreams() {
|
||||
// Propagar estado del player → playbackState (lo que ve la notificación)
|
||||
_player.playerStateStream.listen((state) {
|
||||
final playing = state.playing;
|
||||
final proc = state.processingState;
|
||||
|
||||
playbackState.add(playbackState.value.copyWith(
|
||||
controls: [
|
||||
if (playing) MediaControl.pause else MediaControl.play,
|
||||
MediaControl.stop,
|
||||
],
|
||||
systemActions: const {MediaAction.seek},
|
||||
systemActions: const {MediaAction.seek, MediaAction.stop},
|
||||
androidCompactActionIndices: const [0],
|
||||
processingState: {
|
||||
ProcessingState.idle: AudioProcessingState.idle,
|
||||
ProcessingState.loading: AudioProcessingState.loading,
|
||||
ProcessingState.buffering: AudioProcessingState.buffering,
|
||||
ProcessingState.ready: AudioProcessingState.ready,
|
||||
ProcessingState.completed: AudioProcessingState.completed,
|
||||
}[proc]!,
|
||||
processingState: _mapProcState(proc),
|
||||
playing: playing,
|
||||
bufferedPosition: _player.bufferedPosition,
|
||||
speed: _player.speed,
|
||||
));
|
||||
});
|
||||
|
||||
// Actualizar bufferedPosition
|
||||
_player.bufferedPositionStream.listen((pos) {
|
||||
playbackState.add(playbackState.value.copyWith(bufferedPosition: pos));
|
||||
});
|
||||
}
|
||||
|
||||
AudioProcessingState _mapProcState(ProcessingState state) {
|
||||
return switch (state) {
|
||||
ProcessingState.idle => AudioProcessingState.idle,
|
||||
ProcessingState.loading => AudioProcessingState.loading,
|
||||
ProcessingState.buffering => AudioProcessingState.buffering,
|
||||
ProcessingState.ready => AudioProcessingState.ready,
|
||||
ProcessingState.completed => AudioProcessingState.completed,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> playMediaItem(MediaItem item) async {
|
||||
mediaItem.add(item);
|
||||
await _player.setUrl(item.id);
|
||||
await _player.play();
|
||||
try {
|
||||
await _player.stop();
|
||||
await _player.setUrl(item.id);
|
||||
await _player.play();
|
||||
} on PlayerException catch (e) {
|
||||
playbackState.add(playbackState.value.copyWith(
|
||||
processingState: AudioProcessingState.error,
|
||||
errorMessage: e.message ?? 'Error de reproducción',
|
||||
errorCode: e.code,
|
||||
));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -167,9 +173,22 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
await _player.stop();
|
||||
emisoraActual = null;
|
||||
mediaItem.add(null);
|
||||
await super.stop();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> seek(Duration position) => _player.seek(position);
|
||||
|
||||
Future<void> setVolume(double vol) async {
|
||||
_volumen = vol.clamp(0.0, 1.0);
|
||||
await _player.setVolume(_volumen);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onTaskRemoved() async {
|
||||
await stop();
|
||||
await _player.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user