Files
pluriwave/lib/widgets/visualizador_audio.dart
T
FreeTLab 10520fef48
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m17s
fix(ui): unify scroll and improve playback switching
2026-05-20 23:44:24 +02:00

241 lines
7.1 KiB
Dart

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<EstadoReproduccion> 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<VisualizadorAudio> createState() => _VisualizadorAudioState();
}
class _VisualizadorAudioState extends State<VisualizadorAudio>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
bool _activo = false;
StreamSubscription<EstadoReproduccion>? _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<EstadoReproduccion> estadoStream;
final Color? color;
final double size;
const IndicadorReproduccion({
super.key,
required this.estadoStream,
this.color,
this.size = 16,
});
@override
State<IndicadorReproduccion> createState() => _IndicadorReproduccionState();
}
class _IndicadorReproduccionState extends State<IndicadorReproduccion>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
bool _reproduciendo = false;
StreamSubscription<EstadoReproduccion>? _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),
),
);
}),
);
}
}