Revision previa al envio a produccion. Cada punto se verifico en el codigo antes de tocarlo; lo que ya estaba bien se dejo como estaba. Ubicacion: se declaraba precision fina sin usarla El unico consumidor de ubicacion pide `LocationAccuracy.low` y se queda solo con el codigo ISO del pais, asi que `ACCESS_FINE_LOCATION` no aportaba nada. Y contradecia la declaracion de Seguridad de los datos ya aprobada en Play, que dice ubicacion APROXIMADA: declarar una cosa y pedir otra es precisamente lo que se penaliza en revision. Verificado que los manifiestos de geolocator_android y geocoding_android no declaran permisos propios, asi que el merge no lo reinyecta y no hace falta `tools:node="remove"`. El plugin construye su peticion en tiempo de ejecucion a partir de lo declarado, de modo que con COARSE pide COARSE. Sin cambio funcional: la deteccion de pais sigue igual. El paywall vendia Android Auto como exclusivo, y ya no lo es La etiqueta era literalmente "Android Auto", a secas. Pero el tier gratuito recibio una carpeta navegable con emisoras reproducibles cuando hubo que cumplir las guias del coche, asi que esa frase dejo de ser cierta. Ahora dice que PRO añade el catalogo completo, favoritos, mis emisoras y musica local, y aclara que gratis tiene las destacadas. Un paywall que promete lo que el tier gratuito ya tiene expone a reclamacion y a que se cite en revision. Microfono: se pide al activar el visualizador, no antes Con una explicacion previa en los 13 idiomas, en vez de aparecer sin contexto. Grabacion: uso privado de verdad, no solo en el aviso La pantalla de grabaciones entregaba el fichero a cualquier aplicacion con `Share.shareXFiles`. La intencion era abrirlo en un reproductor del propio telefono, no redistribuirlo, y una cosa es copia privada y la otra no. Ahora usa el `openFile` que ya existia -- FileProvider + ACTION_VIEW -- y avisa cuando ningun reproductor del dispositivo puede abrirla, en vez de fallar en silencio. Se añade ademas el aviso de uso privado en esa pantalla. `recordingActionShare` la usaban DOS botones con significados distintos: el de grabaciones, que mandaba el audio, y el del reproductor, que comparte el nombre y la url de la emisora. Una clave, dos sentidos, y esa ambiguedad basto para que al leer el codigo pareciera que solo se compartian enlaces. Separadas en `stationActionShare` y `recordingActionOpenIn`. La grabacion sigue siendo PRO. Lo que reduce el riesgo es que la copia no salga del dispositivo, no regalar la funcion: los anuncios tambien son monetizacion. Suite completa: 1587 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos preexistentes.
481 lines
15 KiB
Dart
481 lines
15 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../servicios/servicio_audio.dart';
|
|
import '../tema/pluriwave_tokens.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;
|
|
|
|
/// Audit 1.7/2.5 (t4 lines 66-68, 120-122): the prototype draws `barras`
|
|
/// discrete bottom-anchored bars (radius 2, vertical gradient), not a
|
|
/// continuous oscilloscope stroke. Defaults to `false` so every OTHER
|
|
/// caller (if any is ever added) keeps the original `_WaveFlowPainter`
|
|
/// rendering unchanged.
|
|
final bool barrasDiscretas;
|
|
|
|
/// Alpha of the gradient's BOTTOM colour stop in discrete-bar mode. The
|
|
/// two current consumers disagree (Escuchar hero: `.3`, t4 line 67; full
|
|
/// player: `.45`, t4 line 121), so this is a parameter, not a constant.
|
|
final double gradienteFinAlpha;
|
|
|
|
/// Whether the user has opted in to reading the REAL audio level.
|
|
///
|
|
/// Subscribing to the native `pluriwave/audio_visualizer` EventChannel is
|
|
/// what makes `MainActivity.startVisualizerWhenAllowed` request
|
|
/// `RECORD_AUDIO`, so this flag is the Dart-side gate on a sensitive
|
|
/// permission, not a cosmetic preference. Defaults to `false`: without the
|
|
/// opt-in the widget never touches the channel and animates the synthetic
|
|
/// wave instead, which is exactly what it already did whenever the
|
|
/// permission was denied.
|
|
final bool capturaRealHabilitada;
|
|
|
|
const VisualizadorAudio({
|
|
super.key,
|
|
required this.estadoStream,
|
|
this.androidAudioSessionIdStream,
|
|
this.barras = 20,
|
|
this.color,
|
|
this.altura = 48,
|
|
this.anchuraTotal = double.infinity,
|
|
this.barrasDiscretas = false,
|
|
this.gradienteFinAlpha = 0.3,
|
|
this.capturaRealHabilitada = false,
|
|
});
|
|
|
|
@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 ||
|
|
estado == EstadoReproduccion.reconectando;
|
|
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();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(VisualizadorAudio oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
// The opt-in can be revoked while this widget is mounted; dropping the
|
|
// subscription is what makes the native side release the Visualizer.
|
|
if (oldWidget.capturaRealHabilitada != widget.capturaRealHabilitada) {
|
|
if (!widget.capturaRealHabilitada) {
|
|
unawaited(_ondaSubscription?.cancel());
|
|
_ondaSubscription = null;
|
|
_ultimaOndaReal = null;
|
|
}
|
|
_sincronizarOndaReal();
|
|
}
|
|
}
|
|
|
|
void _sincronizarOndaReal() {
|
|
final sessionId = _sessionId;
|
|
// `capturaRealHabilitada` first: subscribing to the EventChannel is what
|
|
// triggers the native RECORD_AUDIO request, so the opt-in gates the
|
|
// subscription itself, never just the rendering of its result.
|
|
final puedeCapturar =
|
|
widget.capturaRealHabilitada &&
|
|
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;
|
|
return SizedBox(
|
|
height: widget.altura,
|
|
width: widget.anchuraTotal,
|
|
child: RepaintBoundary(
|
|
child:
|
|
widget.barrasDiscretas
|
|
? _construirBarrasDiscretas(color)
|
|
: _construirOnda(color),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _construirOnda(Color color) {
|
|
final t = _controller.value * pi * 2;
|
|
return CustomPaint(
|
|
painter: _WaveFlowPainter(
|
|
color: color,
|
|
phase: t,
|
|
active: _activo,
|
|
waveform: _ondaVisual,
|
|
),
|
|
child: const SizedBox.expand(),
|
|
);
|
|
}
|
|
|
|
/// Audit 1.7/2.5 (t4 lines 66-68, 120-122): `barras` discrete,
|
|
/// bottom-anchored bars — radius 2, `gap:2px`, a top-to-bottom gradient
|
|
/// from [color] to `color@gradienteFinAlpha`. Reuses the SAME
|
|
/// `_ondaVisual`/`_ondaOrganica` amplitude source the continuous painter
|
|
/// already computes (real audio-session data when available, an organic
|
|
/// synthetic fallback otherwise) — only the paint strategy changes.
|
|
Widget _construirBarrasDiscretas(Color color) {
|
|
final valores = _valoresBarras();
|
|
final children = <Widget>[];
|
|
for (var i = 0; i < valores.length; i++) {
|
|
if (i > 0) children.add(const SizedBox(width: 2));
|
|
final valor = valores[i].clamp(0.08, 1.0);
|
|
children.add(
|
|
Expanded(
|
|
child: Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: Container(
|
|
key: ValueKey('visualizador-barra-$i'),
|
|
height: widget.altura * valor,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(2),
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
color,
|
|
color.withValues(alpha: widget.gradienteFinAlpha),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return Row(crossAxisAlignment: CrossAxisAlignment.end, children: children);
|
|
}
|
|
|
|
/// `_ondaVisual` is empty until the animation controller ticks at least
|
|
/// once (only happens once playback goes active, `_onEstado`'s
|
|
/// `_controller.repeat()`) — a discrete render before that must still show
|
|
/// `barras` resting bars, not zero, so it falls back to a flat, low
|
|
/// baseline of exactly [VisualizadorAudio.barras] values (clamped the same
|
|
/// way `_ondaOrganica` clamps its own count).
|
|
List<double> _valoresBarras() {
|
|
if (_ondaVisual.isNotEmpty) return _ondaVisual;
|
|
return List<double>.filled(widget.barras.clamp(8, 96), 0.12);
|
|
}
|
|
}
|
|
|
|
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),
|
|
PluriWaveTokens.dark.warmCoral.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),
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
}
|