diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f7220..f034b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog — PluriWave +## [0.4.0] — 2026-04-04 + +### Añadido +- **PantallaReproductor** — pantalla completa del reproductor. Accesible tocando MiniReproductor o cualquier emisora. Incluye: artwork/logo grande con sombra animada al reproducir, nombre + chips info (país, idioma), codec/bitrate, controles play/pause/stop con indicador "en vivo", botón favorito (toggle), widget de timer (iniciar/cancelar desde la pantalla), animación de entrada slide-up. Transición pageRoute desde cualquier pantalla. +- **PantallaAjustes** — pantalla de ajustes básica (tab nuevo en NavigationBar). Muestra estado del sistema (filtro emisoras, audio background), conteo de favoritos, preview de features próximas (Export/Import, radio personalizada, ecualizador). +- **MiniReproductor** — ahora es tappable: toca la barra para abrir PantallaReproductor. +- **NavigationBar** — añadido tab "Ajustes" (4 destinos: Inicio/Buscar/Favoritos/Ajustes). + +## [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"> { PantallaInicio(), PantallaBuscar(), PantallaFavoritos(), + PantallaAjustes(), ]; static const _destinos = [ @@ -77,21 +83,43 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { selectedIcon: Icon(Icons.favorite), label: 'Favoritos', ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Ajustes', + ), ]; + @override + void didChangeDependencies() { + super.didChangeDependencies(); + 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( - appBar: AppBar( - title: const Text('PluriWave'), - actions: [ - IconButton( - icon: const Icon(Icons.bedtime_outlined), - tooltip: 'Timer de sueño', - onPressed: () => _mostrarTimerDialog(context), - ), - ], - ), + appBar: _indice == 3 + ? null // PantallaAjustes tiene su propio AppBar + : AppBar( + title: const Text('PluriWave'), + actions: [ + IconButton( + icon: const Icon(Icons.bedtime_outlined), + tooltip: 'Timer de sueño', + onPressed: () => _mostrarTimerDialog(context), + ), + ], + ), body: _paginas[_indice], bottomNavigationBar: Column( mainAxisSize: MainAxisSize.min, @@ -125,12 +153,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 +177,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/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart new file mode 100644 index 0000000..68d1ba8 --- /dev/null +++ b/lib/pantallas/pantalla_ajustes.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../estado/estado_radio.dart'; + +/// Pantalla de ajustes — por ahora muestra info de la app. +/// En Fase 3 se añadirá Export/Import config y gestión PRO. +class PantallaAjustes extends StatelessWidget { + const PantallaAjustes({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final estado = context.read(); + + return Scaffold( + appBar: AppBar(title: const Text('Ajustes')), + body: ListView( + children: [ + // Info app + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('PluriWave'), + subtitle: const Text('v0.3.0 — Radio mundial'), + ), + const Divider(), + // Favoritos + FutureBuilder( + future: estado.favoritos.obtenerTodos().then((l) => l.length), + builder: (ctx, snap) => ListTile( + leading: const Icon(Icons.favorite_outline), + title: const Text('Favoritos guardados'), + trailing: Text( + snap.data?.toString() ?? '—', + style: theme.textTheme.bodyLarge, + ), + ), + ), + const Divider(), + // Filtro emisoras + ListTile( + leading: const Icon(Icons.verified_outlined), + title: const Text('Filtro de emisoras'), + subtitle: const Text('Solo emisoras verificadas como activas'), + trailing: const Icon(Icons.check_circle, color: Colors.green), + ), + ListTile( + leading: const Icon(Icons.music_off_outlined), + title: const Text('Audio en background'), + subtitle: const Text('Activo — continúa al apagar pantalla'), + trailing: const Icon(Icons.check_circle, color: Colors.green), + ), + const Divider(), + // Próximamente + const Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text('PRÓXIMAMENTE', style: TextStyle(fontSize: 12, letterSpacing: 1.2)), + ), + ListTile( + leading: const Icon(Icons.upload_outlined), + title: const Text('Exportar configuración'), + subtitle: const Text('Favoritos, radios custom, presets EQ'), + enabled: false, + ), + ListTile( + leading: const Icon(Icons.download_outlined), + title: const Text('Importar configuración'), + enabled: false, + ), + ListTile( + leading: const Icon(Icons.add_circle_outline), + title: const Text('Añadir radio personalizada'), + enabled: false, + ), + ListTile( + leading: const Icon(Icons.equalizer_outlined), + title: const Text('Ecualizador'), + subtitle: const Text('5 bandas, presets por emisora'), + enabled: false, + ), + ], + ), + ); + } +} diff --git a/lib/pantallas/pantalla_reproductor.dart b/lib/pantallas/pantalla_reproductor.dart new file mode 100644 index 0000000..3d9e95b --- /dev/null +++ b/lib/pantallas/pantalla_reproductor.dart @@ -0,0 +1,472 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:provider/provider.dart'; +import 'package:shimmer/shimmer.dart'; +import '../estado/estado_radio.dart'; +import '../modelos/emisora.dart'; +import '../servicios/servicio_audio.dart'; +import '../servicios/servicio_timer.dart'; + +/// Pantalla completa del reproductor de radio. +/// +/// Muestra: carátula/logo grande, nombre emisora, información (país, idioma, +/// codec/bitrate), controles play/pause, botón favorito, acceso al timer. +/// +/// Se abre como ruta desde cualquier pantalla al pulsar sobre una emisora +/// o desde el MiniReproductor. +class PantallaReproductor extends StatefulWidget { + final Emisora emisora; + + const PantallaReproductor({super.key, required this.emisora}); + + /// Navega a la pantalla del reproductor. + static Future abrir(BuildContext context, Emisora emisora) { + return Navigator.push( + context, + PageRouteBuilder( + pageBuilder: (_, animation, __) => PantallaReproductor(emisora: emisora), + transitionsBuilder: (_, animation, __, child) => SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)), + child: child, + ), + transitionDuration: const Duration(milliseconds: 350), + ), + ); + } + + @override + State createState() => _PantallaReproductorState(); +} + +class _PantallaReproductorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _pulseController; + bool _esFavorito = false; + + @override + void initState() { + super.initState(); + _pulseController = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + ); + _checkFavorito(); + _iniciarReproduccion(); + } + + Future _checkFavorito() async { + final estado = context.read(); + final fav = await estado.esFavorito(widget.emisora.uuid); + if (mounted) setState(() => _esFavorito = fav); + } + + Future _iniciarReproduccion() async { + final estado = context.read(); + // Solo reproductor si no es ya la emisora activa + if (estado.emisoraActual?.uuid != widget.emisora.uuid) { + await estado.reproducir(widget.emisora); + } + } + + @override + void dispose() { + _pulseController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final estado = context.watch(); + + return Scaffold( + backgroundColor: theme.colorScheme.surface, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.keyboard_arrow_down_rounded, size: 32), + tooltip: 'Cerrar', + onPressed: () => Navigator.pop(context), + ), + actions: [ + // Botón favorito + IconButton( + icon: Icon( + _esFavorito ? Icons.favorite_rounded : Icons.favorite_outline_rounded, + color: _esFavorito ? theme.colorScheme.error : null, + ), + tooltip: _esFavorito ? 'Quitar de favoritos' : 'Añadir a favoritos', + onPressed: () async { + final esFav = await estado.toggleFavorito(widget.emisora); + if (mounted) setState(() => _esFavorito = esFav); + }, + ), + ], + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + children: [ + const Spacer(flex: 1), + // Carátula / logo grande + _Artwork( + emisora: widget.emisora, + estadoStream: estado.estadoStream, + ).animate().scale(begin: const Offset(0.8, 0.8), duration: 400.ms, + curve: Curves.easeOutBack), + const SizedBox(height: 32), + // Nombre de la emisora + Text( + widget.emisora.nombre, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ).animate().fadeIn(delay: 150.ms), + const SizedBox(height: 8), + // Info: país, idioma + _InfoChips(emisora: widget.emisora) + .animate() + .fadeIn(delay: 200.ms) + .slideY(begin: 0.2), + const SizedBox(height: 4), + // Codec / bitrate + if (widget.emisora.codec != null || widget.emisora.bitrate != null) + Text( + _codecInfo(widget.emisora), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ).animate().fadeIn(delay: 250.ms), + const Spacer(flex: 2), + // Controles + _Controles( + estado: estado, + emisora: widget.emisora, + ).animate().fadeIn(delay: 300.ms).slideY(begin: 0.3), + const SizedBox(height: 24), + // Timer + _TimerWidget(estado: estado) + .animate() + .fadeIn(delay: 400.ms), + const Spacer(flex: 1), + ], + ), + ), + ), + ); + } + + String _codecInfo(Emisora e) { + final parts = []; + if (e.codec != null) parts.add(e.codec!.toUpperCase()); + if (e.bitrate != null && e.bitrate! > 0) parts.add('${e.bitrate} kbps'); + return parts.join(' · '); + } +} + +// ─── Artwork ──────────────────────────────────────────────────────────────── + +class _Artwork extends StatelessWidget { + final Emisora emisora; + final Stream estadoStream; + + const _Artwork({required this.emisora, required this.estadoStream}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final size = MediaQuery.of(context).size.width * 0.65; + + return StreamBuilder( + stream: estadoStream, + builder: (context, snapshot) { + final reproduciendo = snapshot.data == EstadoReproduccion.reproduciendo; + final cargando = snapshot.data == EstadoReproduccion.cargando; + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: size, + height: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + boxShadow: reproduciendo + ? [ + BoxShadow( + color: theme.colorScheme.primary.withValues(alpha: 0.4), + blurRadius: 30, + spreadRadius: 5, + ), + ] + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 12, + spreadRadius: 2, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Stack( + fit: StackFit.expand, + children: [ + // Logo / imagen + if (emisora.favicon != null && emisora.favicon!.isNotEmpty) + CachedNetworkImage( + imageUrl: emisora.favicon!, + fit: BoxFit.cover, + placeholder: (_, __) => _shimmer(theme), + errorWidget: (_, __, ___) => _iconoFallback(theme), + ) + else + _iconoFallback(theme), + // Overlay de carga + if (cargando) + Container( + color: Colors.black45, + child: const Center( + child: CircularProgressIndicator(color: Colors.white), + ), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _shimmer(ThemeData theme) => Shimmer.fromColors( + baseColor: theme.colorScheme.surfaceContainerHighest, + highlightColor: theme.colorScheme.surface, + child: Container(color: theme.colorScheme.surfaceContainerHighest), + ); + + Widget _iconoFallback(ThemeData theme) => Container( + color: theme.colorScheme.primaryContainer, + child: Icon( + Icons.radio_rounded, + size: 80, + color: theme.colorScheme.onPrimaryContainer, + ), + ); +} + +// ─── Info chips ───────────────────────────────────────────────────────────── + +class _InfoChips extends StatelessWidget { + final Emisora emisora; + const _InfoChips({required this.emisora}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final items = []; + if (emisora.pais != null) items.add(emisora.pais!); + if (emisora.idioma != null) items.add(emisora.idioma!); + if (items.isEmpty) return const SizedBox.shrink(); + + return Wrap( + spacing: 6, + children: items + .map((label) => Chip( + label: Text(label), + visualDensity: VisualDensity.compact, + backgroundColor: theme.colorScheme.secondaryContainer, + labelStyle: TextStyle( + color: theme.colorScheme.onSecondaryContainer, + fontSize: 12), + padding: EdgeInsets.zero, + )) + .toList(), + ); + } +} + +// ─── Controles ────────────────────────────────────────────────────────────── + +class _Controles extends StatelessWidget { + final EstadoRadio estado; + final Emisora emisora; + + const _Controles({required this.estado, required this.emisora}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return StreamBuilder( + stream: estado.estadoStream, + builder: (context, snapshot) { + final s = snapshot.data ?? EstadoReproduccion.detenido; + final reproduciendo = s == EstadoReproduccion.reproduciendo; + final cargando = s == EstadoReproduccion.cargando; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Botón detener + IconButton( + icon: const Icon(Icons.stop_rounded), + iconSize: 36, + color: theme.colorScheme.onSurfaceVariant, + tooltip: 'Detener', + onPressed: cargando ? null : () => estado.audio.detener(), + ), + const SizedBox(width: 16), + // Botón play/pause principal + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 72, + height: 72, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.primary, + boxShadow: [ + BoxShadow( + color: theme.colorScheme.primary.withValues(alpha: 0.35), + blurRadius: reproduciendo ? 16 : 6, + spreadRadius: reproduciendo ? 4 : 0, + ), + ], + ), + child: Material( + color: Colors.transparent, + shape: const CircleBorder(), + child: InkWell( + customBorder: const CircleBorder(), + onTap: cargando + ? null + : () { + if (reproduciendo || s == EstadoReproduccion.pausado) { + estado.togglePlay(); + } else { + estado.reproducir(emisora); + } + }, + child: Center( + child: cargando + ? const SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ) + : Icon( + reproduciendo + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + size: 40, + color: theme.colorScheme.onPrimary, + ), + ), + ), + ), + ), + const SizedBox(width: 16), + // Indicador en vivo + Icon( + Icons.fiber_manual_record_rounded, + size: 36, + color: reproduciendo + ? theme.colorScheme.error + : theme.colorScheme.surfaceContainerHighest, + ), + ], + ); + }, + ); + } +} + +// ─── Timer widget ──────────────────────────────────────────────────────────── + +class _TimerWidget extends StatelessWidget { + final EstadoRadio estado; + const _TimerWidget({required this.estado}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (!estado.timer.activo) { + return TextButton.icon( + icon: const Icon(Icons.bedtime_outlined, size: 18), + label: const Text('Timer de sueño'), + onPressed: () => _mostrarTimerDialog(context), + ); + } + + return StreamBuilder( + stream: estado.timer.tiempoRestanteStream, + builder: (context, snap) { + final t = snap.data ?? Duration.zero; + final m = t.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = t.inSeconds.remainder(60).toString().padLeft(2, '0'); + final label = t.inHours > 0 + ? '${t.inHours}h ${m}m' + : '${m}m ${s}s'; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.bedtime_rounded, size: 16, color: theme.colorScheme.primary), + const SizedBox(width: 6), + Text(label, style: theme.textTheme.bodyMedium), + const SizedBox(width: 8), + TextButton( + onPressed: () => estado.cancelarTimer(), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + child: const Text('Cancelar'), + ), + ], + ); + }, + ); + } + + void _mostrarTimerDialog(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Timer de sueño', style: Theme.of(ctx).textTheme.titleLarge), + const SizedBox(height: 16), + Wrap( + spacing: 8, + children: opcionesTimer + .map((min) => ActionChip( + label: Text('$min min'), + onPressed: () { + estado.iniciarTimer(min); + Navigator.pop(ctx); + }, + )) + .toList(), + ), + ], + ), + ), + ), + ); + } +} 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)', diff --git a/lib/widgets/mini_reproductor.dart b/lib/widgets/mini_reproductor.dart index 149d791..6690379 100644 --- a/lib/widgets/mini_reproductor.dart +++ b/lib/widgets/mini_reproductor.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../estado/estado_radio.dart'; +import '../pantallas/pantalla_reproductor.dart'; import '../servicios/servicio_audio.dart'; /// Barra inferior persistente con controles básicos de reproducción. -/// Se muestra siempre que haya una emisora cargada. +/// Toca la barra para abrir PantallaReproductor completa. class MiniReproductor extends StatelessWidget { const MiniReproductor({super.key}); @@ -17,80 +18,94 @@ class MiniReproductor extends StatelessWidget { final theme = Theme.of(context); - return Container( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainer, - border: Border(top: BorderSide(color: theme.colorScheme.outlineVariant, width: 0.5)), - ), - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - // Logo emisora - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Container( - width: 40, - height: 40, - color: theme.colorScheme.primaryContainer, - child: const Icon(Icons.radio, size: 22), + return GestureDetector( + onTap: () => PantallaReproductor.abrir(context, emisora), + child: Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer, + border: Border( + top: BorderSide( + color: theme.colorScheme.outlineVariant, width: 0.5)), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + // Logo + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Container( + width: 40, + height: 40, + color: theme.colorScheme.primaryContainer, + child: Icon(Icons.radio, + size: 22, + color: theme.colorScheme.onPrimaryContainer), + ), ), - ), - const SizedBox(width: 12), - // Nombre y estado - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - emisora.nombre, - style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - StreamBuilder( - stream: estado.estadoStream, - builder: (context, snapshot) { - final s = snapshot.data ?? EstadoReproduccion.detenido; - return Text( - _labelEstado(s), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ); - }, - ), - ], - ), - ), - // Botón play/pause - StreamBuilder( - stream: estado.estadoStream, - builder: (context, snapshot) { - final s = snapshot.data ?? EstadoReproduccion.detenido; - if (s == EstadoReproduccion.cargando) { - return const SizedBox( - width: 40, - height: 40, - child: Padding( - padding: EdgeInsets.all(10), - child: CircularProgressIndicator(strokeWidth: 2), + const SizedBox(width: 12), + // Nombre y estado + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + emisora.nombre, + style: theme.textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), + StreamBuilder( + stream: estado.estadoStream, + builder: (context, snapshot) { + final s = snapshot.data ?? + EstadoReproduccion.detenido; + return Text( + _labelEstado(s), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ); + }, + ), + ], + ), + ), + // Botón play/pause + StreamBuilder( + stream: estado.estadoStream, + builder: (context, snapshot) { + final s = + snapshot.data ?? EstadoReproduccion.detenido; + if (s == EstadoReproduccion.cargando) { + return const SizedBox( + width: 40, + height: 40, + child: Padding( + padding: EdgeInsets.all(10), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + return IconButton( + icon: Icon( + s == EstadoReproduccion.reproduciendo + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + onPressed: () { + // Evitar que el tap en el botón abra el reproductor + estado.togglePlay(); + }, ); - } - return IconButton( - icon: Icon(s == EstadoReproduccion.reproduciendo - ? Icons.pause_rounded - : Icons.play_arrow_rounded), - onPressed: estado.togglePlay, - tooltip: s == EstadoReproduccion.reproduciendo ? 'Pausar' : 'Reproducir', - ); - }, - ), - ], + }, + ), + ], + ), ), ), ), @@ -98,17 +113,12 @@ class MiniReproductor extends StatelessWidget { } String _labelEstado(EstadoReproduccion estado) { - switch (estado) { - case EstadoReproduccion.cargando: - return 'Conectando...'; - case EstadoReproduccion.reproduciendo: - return 'En directo ●'; - case EstadoReproduccion.pausado: - return 'Pausado'; - case EstadoReproduccion.error: - return 'Error de conexión'; - case EstadoReproduccion.detenido: - return 'Detenido'; - } + return switch (estado) { + EstadoReproduccion.cargando => 'Conectando...', + EstadoReproduccion.reproduciendo => 'En directo ●', + EstadoReproduccion.pausado => 'Pausado', + EstadoReproduccion.error => 'Error de conexión', + EstadoReproduccion.detenido => 'Detenido', + }; } }