feat(player): add radio recording and real waveform
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m27s

This commit is contained in:
2026-05-21 21:17:51 +02:00
parent 6aa9a59d7b
commit a6a91af402
12 changed files with 1518 additions and 286 deletions
+146 -65
View File
@@ -1,31 +1,19 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../servicios/servicio_audio.dart';
/// Visualizador de audio animado para la pantalla del reproductor.
/// Visualizador de audio para el 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,
/// )
/// ```
/// 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<EstadoReproduccion> estadoStream;
final Stream<int?>? androidAudioSessionIdStream;
final int barras;
final Color? color;
final double altura;
@@ -34,6 +22,7 @@ class VisualizadorAudio extends StatefulWidget {
const VisualizadorAudio({
super.key,
required this.estadoStream,
this.androidAudioSessionIdStream,
this.barras = 20,
this.color,
this.altura = 48,
@@ -46,9 +35,15 @@ class VisualizadorAudio extends StatefulWidget {
class _VisualizadorAudioState extends State<VisualizadorAudio>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
static const _eventChannel = EventChannel('pluriwave/audio_visualizer');
late final AnimationController _controller;
bool _activo = false;
int? _sessionId;
List<double> _ondaReal = const [];
StreamSubscription<EstadoReproduccion>? _estadoSubscription;
StreamSubscription<int?>? _sessionSubscription;
StreamSubscription<dynamic>? _ondaSubscription;
@override
void initState() {
@@ -57,23 +52,78 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
vsync: this,
duration: const Duration(seconds: 2),
)..addListener(() {
if (mounted) setState(() {});
});
if (mounted) setState(() {});
});
_estadoSubscription = widget.estadoStream.listen(_onEstado);
_sessionSubscription = widget.androidAudioSessionIdStream?.listen(
_onSessionId,
);
}
void _onEstado(EstadoReproduccion estado) {
final nuevoActivo = estado == EstadoReproduccion.reproduciendo ||
final nuevoActivo =
estado == EstadoReproduccion.reproduciendo ||
estado == EstadoReproduccion.cargando;
if (nuevoActivo == _activo) return;
if (!mounted) return;
setState(() => _activo = nuevoActivo);
nuevoActivo ? _controller.repeat() : _controller.stop();
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<num>()
.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();
}
@@ -84,12 +134,14 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
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(),
),
@@ -103,51 +155,62 @@ class _WaveFlowPainter extends CustomPainter {
required this.color,
required this.phase,
required this.active,
required this.waveform,
});
final Color color;
final double phase;
final bool active;
final List<double> 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);
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) {
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 y = center +
sin((p * pi * 2.4) + phase + shift) * amplitude +
sin((p * pi * 5.2) - phase * 0.7 + shift) * amplitude * 0.32;
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;
}
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);
final usaReal = waveform.isNotEmpty;
canvas.drawPath(pathFor(0, amp, real: usaReal), glowPaint);
canvas.drawPath(
pathFor(0.9, amp * 0.58),
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
@@ -156,16 +219,25 @@ class _WaveFlowPainter extends CustomPainter {
);
}
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.color != color ||
oldDelegate.waveform != waveform;
}
}
/// Versión compacta del visualizador — 5 barras, para uso en MiniReproductor
/// o indicadores pequeños de "en reproducción".
/// Versión compacta del visualizador para el MiniReproductor.
class IndicadorReproduccion extends StatefulWidget {
final Stream<EstadoReproduccion> estadoStream;
final Color? color;
@@ -191,12 +263,15 @@ class _IndicadorReproduccionState extends State<IndicadorReproduccion>
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 600))
..addListener(() => setState(() {}));
_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) return;
if (!mounted) return;
if (rep == _reproduciendo || !mounted) return;
setState(() => _reproduciendo = rep);
rep ? _ctrl.repeat(reverse: true) : _ctrl.stop();
});
@@ -211,11 +286,13 @@ class _IndicadorReproduccionState extends State<IndicadorReproduccion>
@override
Widget build(BuildContext context) {
final color = widget.color ??
Theme.of(context).colorScheme.primary;
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 Icon(
Icons.radio,
size: widget.size,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
}
return Row(
mainAxisSize: MainAxisSize.min,
@@ -223,8 +300,12 @@ class _IndicadorReproduccionState extends State<IndicadorReproduccion>
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;
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,