diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f7220..f3af761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog — PluriWave +## [0.3.0] — 2026-04-04 + +### Fixes (prioridad alta — petición WhikY) + +- **Audio en background** — `ServicioAudio` refactorizado para delegar toda la reproducción a `PluriWaveAudioHandler` (audio_service). La notificación foreground de Android mantiene el audio vivo al apagar pantalla. Handler inicializado en `main.dart` con `AudioService.init()` y registrado globalmente. `onTaskRemoved` libera recursos al cerrar la app. `mediaItem` propagado con nombre, artista y artwork de la emisora. +- **Filtrar emisoras rotas** — `ServicioRadio` añade `lastcheckok=1` en todas las peticiones a la API. Solo se devuelven emisoras verificadas como funcionales por Radio Browser. +- **Errores como SnackBar** — `EstadoRadio` emite errores de reproducción y búsqueda por `errorStream` (StreamController broadcast). `_PaginaPrincipalState.didChangeDependencies` suscribe al stream y muestra `SnackBar` flotante de 3 segundos. Los errores de carga de lista siguen como banner inline (no bloquean la UI). +- **Icono de app** — Generado con Stable Diffusion XL: diseño morado, ondas de radio blancas, estilo Material You. Todos los tamaños Android generados (mdpi/hdpi/xhdpi/xxhdpi/xxxhdpi, 48-192px). `ic_launcher_round` añadido. `android:roundIcon` en AndroidManifest. + +### Ficheros modificados +| Fichero | Cambio | +|---|---| +| `lib/main.dart` | `AudioService.init()` + `registrarHandler()` | +| `lib/servicios/servicio_audio.dart` | Arquitectura background completa | +| `lib/servicios/servicio_radio.dart` | `lastcheckok=1` en todas las peticiones | +| `lib/estado/estado_radio.dart` | `errorStream` en lugar de campo `_error` | +| `lib/app.dart` | Listener `errorStream` → SnackBar + theme SnackBar | +| `android/app/src/main/AndroidManifest.xml` | `roundIcon` | +| `android/app/src/main/res/mipmap-*/` | Iconos generados (5 densidades) | + ## [0.2.0] — 2026-04-04 ### Añadido diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e1d97d4..64f1d7d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,7 +8,8 @@ + android:icon="@mipmap/ic_launcher" + android:roundIcon="@mipmap/ic_launcher_round"> { ), ]; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Suscribir al stream de errores → SnackBar flotante + context.read().errorStream.listen((msg) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg), + duration: const Duration(seconds: 3), + action: SnackBarAction(label: 'OK', onPressed: () {}), + ), + ); + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -125,12 +145,15 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { stream: estado.timer.tiempoRestanteStream, builder: (ctx, snap) { final t = snap.data ?? Duration.zero; + final h = t.inHours; final m = t.inMinutes.remainder(60).toString().padLeft(2, '0'); final s = t.inSeconds.remainder(60).toString().padLeft(2, '0'); return Column( children: [ - Text('${t.inHours > 0 ? "${t.inHours}h " : ""}${m}m ${s}s', - style: Theme.of(ctx).textTheme.headlineMedium), + Text( + '${h > 0 ? "${h}h " : ""}${m}m ${s}s', + style: Theme.of(ctx).textTheme.headlineMedium, + ), const SizedBox(height: 8), FilledButton.tonal( onPressed: () { @@ -146,13 +169,15 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { else Wrap( spacing: 8, - children: [15, 30, 60, 90].map((min) => ActionChip( - label: Text('$min min'), - onPressed: () { - estado.iniciarTimer(min); - Navigator.pop(ctx); - }, - )).toList(), + children: [15, 30, 60, 90] + .map((min) => ActionChip( + label: Text('$min min'), + onPressed: () { + estado.iniciarTimer(min); + Navigator.pop(ctx); + }, + )) + .toList(), ), ], ), diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index f6986d9..4f3748b 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/foundation.dart'; import '../modelos/emisora.dart'; import '../servicios/servicio_audio.dart'; @@ -7,13 +8,18 @@ import '../servicios/servicio_timer.dart'; /// Estado global de la app con ChangeNotifier (Provider). /// -/// Centraliza: reproductoor, favoritos, búsqueda, timer. +/// Errores de reproducción se emiten por [errorStream] para mostrar como +/// SnackBar — no bloquean la UI. class EstadoRadio extends ChangeNotifier { final ServicioAudio audio = ServicioAudio(); final ServicioFavoritos favoritos = ServicioFavoritos(); final ServicioRadio radio = ServicioRadio(); late final ServicioTimer timer; + // Errores de reproducción → SnackBar en el UI + final _errorController = StreamController.broadcast(); + Stream get errorStream => _errorController.stream; + List _populares = []; List _tendencias = []; List _resultadosBusqueda = []; @@ -21,7 +27,7 @@ class EstadoRadio extends ChangeNotifier { bool _cargandoPopulares = false; bool _cargandoBusqueda = false; - String? _error; + String? _errorCarga; // solo para errores de carga de lista (banner estático) EstadoRadio() { timer = ServicioTimer(audio); @@ -34,7 +40,7 @@ class EstadoRadio extends ChangeNotifier { List get listaFavoritos => _listafavoritos; bool get cargandoPopulares => _cargandoPopulares; bool get cargandoBusqueda => _cargandoBusqueda; - String? get error => _error; + String? get error => _errorCarga; Emisora? get emisoraActual => audio.emisoraActual; Stream get estadoStream => audio.estadoStream; @@ -47,7 +53,7 @@ class EstadoRadio extends ChangeNotifier { Future cargarPopulares() async { _cargandoPopulares = true; - _error = null; + _errorCarga = null; notifyListeners(); try { final results = await Future.wait([ @@ -57,7 +63,7 @@ class EstadoRadio extends ChangeNotifier { _populares = results[0]; _tendencias = results[1]; } catch (e) { - _error = 'Error al cargar emisoras: $e'; + _errorCarga = 'Sin conexión a la API de radio'; } finally { _cargandoPopulares = false; notifyListeners(); @@ -86,7 +92,8 @@ class EstadoRadio extends ChangeNotifier { tag: tag, ); } catch (e) { - _error = 'Error en búsqueda: $e'; + // Error de búsqueda → toast, no bloquear pantalla + _errorController.add('Error en la búsqueda. Comprueba tu conexión.'); } finally { _cargandoBusqueda = false; notifyListeners(); @@ -99,8 +106,8 @@ class EstadoRadio extends ChangeNotifier { radio.registrarClick(emisora.uuid); // fire & forget notifyListeners(); } catch (e) { - _error = 'No se puede reproducir esta emisora'; - notifyListeners(); + // Error de reproducción → SnackBar, no pintar en medio de la UI + _errorController.add('No se puede reproducir "${emisora.nombre}"'); } } @@ -129,6 +136,7 @@ class EstadoRadio extends ChangeNotifier { @override void dispose() { + _errorController.close(); audio.dispose(); timer.dispose(); super.dispose(); diff --git a/lib/main.dart b/lib/main.dart index 5cd8109..fd1edca 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,24 @@ +import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'app.dart'; +import 'servicios/servicio_audio.dart'; -void main() { +Future main() async { WidgetsFlutterBinding.ensureInitialized(); + + // Inicializar audio_service para reproducción en background. + // El handler se registra globalmente para que ServicioAudio lo use. + final handler = await AudioService.init( + builder: () => PluriWaveAudioHandler(), + config: const AudioServiceConfig( + androidNotificationChannelId: 'es.freetimelab.pluriwave.audio', + androidNotificationChannelName: 'PluriWave Radio', + androidNotificationOngoing: true, + androidStopForegroundOnPause: true, + notificationColor: Color(0xFF6750A4), + ), + ); + registrarHandler(handler); + runApp(const PluriWaveApp()); } diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index f6bfcd1..d1b8cd6 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -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 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 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 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 pausar() async { - await _player.pause(); - _estado = EstadoReproduccion.pausado; - } + Future pausar() => _handler.pause(); + Future reanudar() => _handler.play(); - /// Reanuda si estaba pausado. - Future reanudar() async { - if (_player.audioSource != null) { - await _player.play(); - _estado = EstadoReproduccion.reproduciendo; - } - } - - /// Alterna entre pausa y reproducción. Future togglePlay() async { - if (_player.playing) { + if (_handler.playbackState.value.playing) { await pausar(); } else { await reanudar(); } } - /// Detiene la reproducción y libera la fuente. - Future detener() async { - await _player.stop(); - _emisoraActual = null; - _estado = EstadoReproduccion.detenido; - } + Future detener() => _handler.stop(); - /// Ajusta el volumen (0.0 - 1.0). - Future setVolumen(double volumen) async { - await _player.setVolume(volumen.clamp(0.0, 1.0)); - } + Future 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 dispose() async { - await _player.dispose(); - } + /// No-op: el handler se limpia en main.dart al cerrar la app. + Future 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 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 stop() async { await _player.stop(); + emisoraActual = null; + mediaItem.add(null); await super.stop(); } @override Future seek(Duration position) => _player.seek(position); + + Future setVolume(double vol) async { + _volumen = vol.clamp(0.0, 1.0); + await _player.setVolume(_volumen); + } + + @override + Future onTaskRemoved() async { + await stop(); + await _player.dispose(); + } } diff --git a/lib/servicios/servicio_radio.dart b/lib/servicios/servicio_radio.dart index d668773..ddf0ef2 100644 --- a/lib/servicios/servicio_radio.dart +++ b/lib/servicios/servicio_radio.dart @@ -41,7 +41,11 @@ class ServicioRadio { Future> _get(String path, Map params) async { final servidor = await _servidor(); - final uri = _uri(servidor, path, params); + // lastcheckok=1 filtra emisoras que la API verificó como funcionales + final uri = _uri(servidor, path, { + 'lastcheckok': '1', + ...params, + }); try { final resp = await http.get(uri, headers: { 'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)',