feat(player): add radio recording and real waveform
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user