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), ), ); }), ); } }