373 lines
11 KiB
Dart
373 lines
11 KiB
Dart
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<EstadoReproduccion> estadoStream;
|
|
final Stream<int?>? 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<VisualizadorAudio> createState() => _VisualizadorAudioState();
|
|
}
|
|
|
|
class _VisualizadorAudioState extends State<VisualizadorAudio>
|
|
with SingleTickerProviderStateMixin {
|
|
static const _eventChannel = EventChannel('pluriwave/audio_visualizer');
|
|
|
|
late final AnimationController _controller;
|
|
bool _activo = false;
|
|
int? _sessionId;
|
|
List<double> _ondaObjetivo = const [];
|
|
List<double> _ondaVisual = const [];
|
|
DateTime? _ultimaOndaReal;
|
|
StreamSubscription<EstadoReproduccion>? _estadoSubscription;
|
|
StreamSubscription<int?>? _sessionSubscription;
|
|
StreamSubscription<dynamic>? _ondaSubscription;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(seconds: 2),
|
|
)..addListener(() {
|
|
if (mounted) {
|
|
_actualizarOndaVisual();
|
|
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;
|
|
_ultimaOndaReal = null;
|
|
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) {
|
|
_ultimaOndaReal = DateTime.now();
|
|
_ondaObjetivo = _normalizar(muestras);
|
|
}
|
|
},
|
|
onError: (_) {
|
|
unawaited(_ondaSubscription?.cancel());
|
|
_ondaSubscription = null;
|
|
_ultimaOndaReal = null;
|
|
},
|
|
cancelOnError: false,
|
|
);
|
|
}
|
|
|
|
void _actualizarOndaVisual() {
|
|
final objetivo = _objetivoActual();
|
|
if (_ondaVisual.length != objetivo.length) {
|
|
_ondaVisual = List<double>.from(objetivo);
|
|
return;
|
|
}
|
|
_ondaVisual = List<double>.generate(objetivo.length, (i) {
|
|
final suavizado = _ondaVisual[i] + (objetivo[i] - _ondaVisual[i]) * 0.16;
|
|
return suavizado.clamp(0.0, 1.0);
|
|
}, growable: false);
|
|
}
|
|
|
|
List<double> _objetivoActual() {
|
|
final ahora = DateTime.now();
|
|
final tieneReal =
|
|
_ultimaOndaReal != null &&
|
|
ahora.difference(_ultimaOndaReal!) <
|
|
const Duration(milliseconds: 900) &&
|
|
_ondaObjetivo.isNotEmpty;
|
|
if (tieneReal) return _ondaObjetivo;
|
|
return _ondaOrganica();
|
|
}
|
|
|
|
List<double> _ondaOrganica() {
|
|
final count = widget.barras.clamp(8, 96);
|
|
final phase = _controller.value * pi * 2;
|
|
final intensidad = _activo ? 1.0 : 0.18;
|
|
return List<double>.generate(count, (i) {
|
|
final p = count <= 1 ? 0.0 : i / (count - 1);
|
|
final envelope = sin(pi * p).clamp(0.10, 1.0);
|
|
final flow =
|
|
sin(phase + p * pi * 2.2) * 0.24 +
|
|
sin(phase * 0.63 - p * pi * 5.1) * 0.18 +
|
|
sin(phase * 1.37 + p * pi * 9.0) * 0.08;
|
|
final value = 0.5 + flow * envelope * intensidad;
|
|
return value.clamp(0.12, 0.88);
|
|
}, growable: false);
|
|
}
|
|
|
|
List<double> _normalizar(List<double> muestras) {
|
|
final maximo = muestras.fold<double>(0, (max, v) => v > max ? v : max);
|
|
if (maximo <= 0.001) return muestras;
|
|
return muestras
|
|
.map((v) => (0.08 + (v / maximo) * 0.84).clamp(0.0, 1.0))
|
|
.toList(growable: 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: _ondaVisual,
|
|
),
|
|
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<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);
|
|
|
|
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<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(() {
|
|
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),
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
}
|