import 'dart:async'; 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 SingleTickerProviderStateMixin { late AnimationController _controller; bool _activo = false; StreamSubscription? _estadoSubscription; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(seconds: 2), )..addListener(() { if (mounted) setState(() {}); }); _estadoSubscription = widget.estadoStream.listen(_onEstado); } void _onEstado(EstadoReproduccion estado) { final nuevoActivo = estado == EstadoReproduccion.reproduciendo || estado == EstadoReproduccion.cargando; if (nuevoActivo == _activo) return; if (!mounted) return; setState(() => _activo = nuevoActivo); nuevoActivo ? _controller.repeat() : _controller.stop(); } @override void dispose() { _estadoSubscription?.cancel(); _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: RepaintBoundary( child: CustomPaint( painter: _WaveFlowPainter( color: color, phase: t, active: _activo, ), child: const SizedBox.expand(), ), ), ); } } class _WaveFlowPainter extends CustomPainter { const _WaveFlowPainter({ required this.color, required this.phase, required this.active, }); final Color color; final double phase; final bool active; @override void paint(Canvas canvas, Size size) { final center = size.height / 2; final amp = active ? size.height * 0.24 : size.height * 0.06; final glowPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = active ? 8 : 4 ..strokeCap = StrokeCap.round ..color = color.withValues(alpha: active ? 0.18 : 0.08); final linePaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 3 ..strokeCap = StrokeCap.round ..shader = LinearGradient( colors: [ color.withValues(alpha: 0.08), color.withValues(alpha: active ? 0.95 : 0.35), const Color(0xFFF4B860).withValues(alpha: active ? 0.75 : 0.20), color.withValues(alpha: 0.08), ], ).createShader(Offset.zero & size); Path pathFor(double shift, double amplitude) { final path = Path()..moveTo(0, center); for (double x = 0; x <= size.width; x += 8) { final p = x / size.width; final y = center + sin((p * pi * 2.4) + phase + shift) * amplitude + sin((p * pi * 5.2) - phase * 0.7 + shift) * amplitude * 0.32; path.lineTo(x, y); } return path; } canvas.drawPath(pathFor(0, amp), glowPaint); canvas.drawPath(pathFor(0.6, amp * 0.62), glowPaint..color = color.withValues(alpha: active ? 0.10 : 0.05)); canvas.drawPath(pathFor(0, amp), linePaint); canvas.drawPath( pathFor(0.9, amp * 0.58), Paint() ..style = PaintingStyle.stroke ..strokeWidth = 2 ..strokeCap = StrokeCap.round ..color = Colors.white.withValues(alpha: active ? 0.35 : 0.12), ); } @override bool shouldRepaint(covariant _WaveFlowPainter oldDelegate) { return oldDelegate.phase != phase || oldDelegate.active != active || oldDelegate.color != color; } } /// 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; StreamSubscription? _estadoSubscription; @override void initState() { super.initState(); _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 600)) ..addListener(() => setState(() {})); _estadoSubscription = widget.estadoStream.listen((s) { final rep = s == EstadoReproduccion.reproduciendo; if (rep == _reproduciendo) return; if (!mounted) return; setState(() => _reproduciendo = rep); rep ? _ctrl.repeat(reverse: true) : _ctrl.stop(); }); } @override void dispose() { _estadoSubscription?.cancel(); _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), ), ); }), ); } }