241 lines
7.1 KiB
Dart
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),
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
}
|