diff --git a/CHANGELOG.md b/CHANGELOG.md index f034b69..4d37a7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog — PluriWave +## [0.5.0] — 2026-04-04 + +### Añadido +- **VisualizadorAudio** — visualizador de barras animadas en `PantallaReproductor`. 24 barras verticales con movimiento orgánico pseudo-aleatorio (combinación de ondas seno con fases distintas). Se activa al reproducir y decae suavemente al parar. Sin FFT real ni permisos de micrófono — animación simulada visualmente equivalente a las apps de streaming. +- **IndicadorReproduccion** — versión compacta de 3 barras para el `MiniReproductor`. Reemplaza el icono estático de radio y pulsa mientras hay audio activo. + ## [0.4.0] — 2026-04-04 ### Añadido diff --git a/lib/pantallas/pantalla_reproductor.dart b/lib/pantallas/pantalla_reproductor.dart index 3d9e95b..3e2ae9e 100644 --- a/lib/pantallas/pantalla_reproductor.dart +++ b/lib/pantallas/pantalla_reproductor.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:provider/provider.dart'; import 'package:shimmer/shimmer.dart'; +import '../widgets/visualizador_audio.dart'; import '../estado/estado_radio.dart'; import '../modelos/emisora.dart'; import '../servicios/servicio_audio.dart'; @@ -146,6 +147,14 @@ class _PantallaReproductorState extends State color: theme.colorScheme.onSurfaceVariant, ), ).animate().fadeIn(delay: 250.ms), + const SizedBox(height: 16), + // Visualizador de audio + VisualizadorAudio( + estadoStream: estado.estadoStream, + barras: 24, + color: theme.colorScheme.primary, + altura: 48, + ).animate().fadeIn(delay: 280.ms), const Spacer(flex: 2), // Controles _Controles( diff --git a/lib/widgets/mini_reproductor.dart b/lib/widgets/mini_reproductor.dart index 6690379..eaf9fa0 100644 --- a/lib/widgets/mini_reproductor.dart +++ b/lib/widgets/mini_reproductor.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../estado/estado_radio.dart'; import '../pantallas/pantalla_reproductor.dart'; import '../servicios/servicio_audio.dart'; +import 'visualizador_audio.dart'; /// Barra inferior persistente con controles básicos de reproducción. /// Toca la barra para abrir PantallaReproductor completa. @@ -33,16 +34,16 @@ class MiniReproductor extends StatelessWidget { 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), + // Indicador de reproducción (mini visualizador) + SizedBox( + width: 40, + height: 40, + child: Center( + child: IndicadorReproduccion( + estadoStream: estado.estadoStream, + color: theme.colorScheme.primary, + size: 20, + ), ), ), const SizedBox(width: 12), diff --git a/lib/widgets/visualizador_audio.dart b/lib/widgets/visualizador_audio.dart new file mode 100644 index 0000000..03332d7 --- /dev/null +++ b/lib/widgets/visualizador_audio.dart @@ -0,0 +1,241 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import '../servicios/servicio_audio.dart'; + +/// Visualizador de audio animado para la pantalla del reproductor. +/// +/// Muestra barras verticales que se animan con movimiento pseudo-aleatorio +/// basado en ruido suavizado mientras la radio está reproduciéndose. +/// Cuando está pausado/detenido, las barras se aplanan suavemente. +/// +/// ### Implementación +/// No usa FFT real (requeriría captura de micrófono con permisos). +/// En cambio, usa un generador de movimiento orgánico con interpolación +/// suavizada — el resultado visual es similar al de apps de streaming como +/// Spotify o Apple Music en sus visualizadores de "en reproducción". +/// +/// ### Uso +/// ```dart +/// VisualizadorAudio( +/// estadoStream: estado.estadoStream, +/// barras: 24, +/// color: theme.colorScheme.primary, +/// altura: 60, +/// ) +/// ``` +class VisualizadorAudio extends StatefulWidget { + final Stream estadoStream; + final int barras; + final Color? color; + final double altura; + final double anchuraTotal; + + const VisualizadorAudio({ + super.key, + required this.estadoStream, + this.barras = 20, + this.color, + this.altura = 48, + this.anchuraTotal = double.infinity, + }); + + @override + State createState() => _VisualizadorAudioState(); +} + +class _VisualizadorAudioState extends State + with TickerProviderStateMixin { + late AnimationController _controller; + late List<_BarraState> _barras; + final _random = Random(); + bool _activo = false; + + @override + void initState() { + super.initState(); + _barras = List.generate( + widget.barras, + (i) => _BarraState( + fase: _random.nextDouble() * pi * 2, + velocidad: 0.8 + _random.nextDouble() * 1.4, + amplitud: 0.4 + _random.nextDouble() * 0.6, + offset: _random.nextDouble() * 0.3, + ), + ); + + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 1), + )..addListener(_actualizar); + + widget.estadoStream.listen(_onEstado); + } + + void _onEstado(EstadoReproduccion estado) { + final nuevoActivo = estado == EstadoReproduccion.reproduciendo || + estado == EstadoReproduccion.cargando; + if (nuevoActivo == _activo) return; + setState(() => _activo = nuevoActivo); + if (nuevoActivo) { + _controller.repeat(); + } else { + _controller.forward(from: _controller.value).whenComplete(() { + if (!_activo && mounted) _controller.stop(); + }); + } + } + + void _actualizar() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final color = widget.color ?? Theme.of(context).colorScheme.primary; + final t = _controller.value * pi * 2; + + return SizedBox( + height: widget.altura, + child: LayoutBuilder( + builder: (context, constraints) { + final totalAncho = constraints.maxWidth == double.infinity + ? 300.0 + : constraints.maxWidth; + final espaciado = totalAncho / widget.barras; + final anchoBar = (espaciado * 0.55).clamp(2.0, 8.0); + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate(widget.barras, (i) { + final b = _barras[i]; + final double altura; + + if (_activo) { + // Movimiento orgánico: combinación de senos con diferentes fases + final onda1 = sin(t * b.velocidad + b.fase); + final onda2 = sin(t * b.velocidad * 0.7 + b.fase * 1.3) * 0.5; + final valor = ((onda1 + onda2 + 1.5) / 3.0).clamp(0.0, 1.0); + altura = (b.offset + valor * b.amplitud) * widget.altura; + } else { + // Decaer suavemente a altura mínima + final progreso = _controller.value; + final alturaActual = b.alturaActual; + b.alturaActual = alturaActual * (1 - progreso * 0.1); + altura = b.alturaActual.clamp(2.0, widget.altura * 0.05); + } + + return Padding( + padding: EdgeInsets.symmetric(horizontal: (espaciado - anchoBar) / 2), + child: AnimatedContainer( + duration: const Duration(milliseconds: 80), + width: anchoBar, + height: altura.clamp(2.0, widget.altura), + decoration: BoxDecoration( + color: color.withValues( + alpha: _activo ? 0.7 + (altura / widget.altura) * 0.3 : 0.3, + ), + borderRadius: BorderRadius.circular(anchoBar / 2), + ), + ), + ); + }), + ); + }, + ), + ); + } +} + +class _BarraState { + final double fase; + final double velocidad; + final double amplitud; + final double offset; + double alturaActual; + + _BarraState({ + required this.fase, + required this.velocidad, + required this.amplitud, + required this.offset, + }) : alturaActual = offset * 20; +} + +/// Versión compacta del visualizador — 5 barras, para uso en MiniReproductor +/// o indicadores pequeños de "en reproducción". +class IndicadorReproduccion extends StatefulWidget { + final Stream estadoStream; + final Color? color; + final double size; + + const IndicadorReproduccion({ + super.key, + required this.estadoStream, + this.color, + this.size = 16, + }); + + @override + State createState() => _IndicadorReproduccionState(); +} + +class _IndicadorReproduccionState extends State + with SingleTickerProviderStateMixin { + late AnimationController _ctrl; + bool _reproduciendo = false; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 600)) + ..addListener(() => setState(() {})); + widget.estadoStream.listen((s) { + final rep = s == EstadoReproduccion.reproduciendo; + if (rep == _reproduciendo) return; + setState(() => _reproduciendo = rep); + rep ? _ctrl.repeat(reverse: true) : _ctrl.stop(); + }); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final color = widget.color ?? + Theme.of(context).colorScheme.primary; + if (!_reproduciendo) { + return Icon(Icons.radio, size: widget.size, + color: Theme.of(context).colorScheme.onSurfaceVariant); + } + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate(3, (i) { + final alts = [0.5, 1.0, 0.7]; + final fases = [0.0, 0.3, 0.6]; + final h = ((sin(_ctrl.value * pi + fases[i]) + 1) / 2 * alts[i] + 0.2) + .clamp(0.15, 1.0) * widget.size; + return Container( + width: widget.size * 0.2, + height: h, + margin: EdgeInsets.only(right: i < 2 ? widget.size * 0.1 : 0), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(1), + ), + ); + }), + ); + } +}