import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../servicios/servicio_audio.dart'; /// Visualizador de audio para el reproductor. /// /// En Android intenta capturar la forma de onda real del audio mediante un /// canal nativo. Si el dispositivo o los permisos no lo permiten, mantiene un /// fallback animado para que la interfaz nunca quede rota. class VisualizadorAudio extends StatefulWidget { final Stream estadoStream; final Stream? androidAudioSessionIdStream; final int barras; final Color? color; final double altura; final double anchuraTotal; const VisualizadorAudio({ super.key, required this.estadoStream, this.androidAudioSessionIdStream, this.barras = 20, this.color, this.altura = 48, this.anchuraTotal = double.infinity, }); @override State createState() => _VisualizadorAudioState(); } class _VisualizadorAudioState extends State with SingleTickerProviderStateMixin { static const _eventChannel = EventChannel('pluriwave/audio_visualizer'); late final AnimationController _controller; bool _activo = false; int? _sessionId; List _ondaReal = const []; StreamSubscription? _estadoSubscription; StreamSubscription? _sessionSubscription; StreamSubscription? _ondaSubscription; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(seconds: 2), )..addListener(() { if (mounted) setState(() {}); }); _estadoSubscription = widget.estadoStream.listen(_onEstado); _sessionSubscription = widget.androidAudioSessionIdStream?.listen( _onSessionId, ); } void _onEstado(EstadoReproduccion estado) { final nuevoActivo = estado == EstadoReproduccion.reproduciendo || estado == EstadoReproduccion.cargando; if (!mounted) return; if (nuevoActivo != _activo) { setState(() => _activo = nuevoActivo); nuevoActivo ? _controller.repeat() : _controller.stop(); } _sincronizarOndaReal(); } void _onSessionId(int? sessionId) { if (!mounted || sessionId == _sessionId) return; _sessionId = sessionId; unawaited(_ondaSubscription?.cancel()); _ondaSubscription = null; _sincronizarOndaReal(); } void _sincronizarOndaReal() { final sessionId = _sessionId; final puedeCapturar = sessionId != null && sessionId > 0 && _activo; if (!puedeCapturar) { unawaited(_ondaSubscription?.cancel()); _ondaSubscription = null; if (_ondaReal.isNotEmpty && mounted) { setState(() => _ondaReal = const []); } return; } if (_ondaSubscription != null) return; _ondaSubscription = _eventChannel .receiveBroadcastStream({ 'sessionId': sessionId, 'bands': widget.barras, }) .listen( (event) { if (!mounted || event is! List) return; final muestras = event .whereType() .map((v) => v.toDouble().clamp(0.0, 1.0)) .toList(growable: false); if (muestras.isNotEmpty) { setState(() => _ondaReal = muestras); } }, onError: (_) { unawaited(_ondaSubscription?.cancel()); _ondaSubscription = null; if (mounted) setState(() => _ondaReal = const []); }, cancelOnError: false, ); } @override void dispose() { _estadoSubscription?.cancel(); _sessionSubscription?.cancel(); _ondaSubscription?.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, width: widget.anchuraTotal, child: RepaintBoundary( child: CustomPaint( painter: _WaveFlowPainter( color: color, phase: t, active: _activo, waveform: _ondaReal, ), child: const SizedBox.expand(), ), ), ); } } class _WaveFlowPainter extends CustomPainter { const _WaveFlowPainter({ required this.color, required this.phase, required this.active, required this.waveform, }); final Color color; final double phase; final bool active; final List waveform; @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, {bool real = false}) { final path = Path()..moveTo(0, center); for (double x = 0; x <= size.width; x += 8) { final p = x / size.width; final muestra = real ? _muestraReal(p) : null; final sintetica = sin((p * pi * 2.4) + phase + shift) + sin((p * pi * 5.2) - phase * 0.7 + shift) * 0.32; final forma = muestra == null ? sintetica : (muestra * 2.0) - 1.0; final y = center + forma * amplitude; path.lineTo(x, y); } return path; } final usaReal = waveform.isNotEmpty; canvas.drawPath(pathFor(0, amp, real: usaReal), glowPaint); canvas.drawPath( pathFor(0.6, amp * 0.62, real: usaReal), glowPaint..color = color.withValues(alpha: active ? 0.10 : 0.05), ); canvas.drawPath(pathFor(0, amp, real: usaReal), linePaint); canvas.drawPath( pathFor(0.9, amp * 0.58, real: usaReal), Paint() ..style = PaintingStyle.stroke ..strokeWidth = 2 ..strokeCap = StrokeCap.round ..color = Colors.white.withValues(alpha: active ? 0.35 : 0.12), ); } double? _muestraReal(double p) { if (waveform.isEmpty) return null; final index = (p * (waveform.length - 1)).round().clamp( 0, waveform.length - 1, ); return waveform[index]; } @override bool shouldRepaint(covariant _WaveFlowPainter oldDelegate) { return oldDelegate.phase != phase || oldDelegate.active != active || oldDelegate.color != color || oldDelegate.waveform != waveform; } } /// Versión compacta del visualizador para el MiniReproductor. 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(() { if (mounted) setState(() {}); }); _estadoSubscription = widget.estadoStream.listen((s) { final rep = s == EstadoReproduccion.reproduciendo; if (rep == _reproduciendo || !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), ), ); }), ); } }