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
+95 -1
View File
@@ -25,7 +25,8 @@ class PantallaAjustes extends StatelessWidget {
children: const [
PluriScreenHeader(
title: 'Ajustes',
subtitle: 'Control fino de sonido, copias de seguridad y emisoras personalizadas.',
subtitle:
'Control fino de sonido, copias de seguridad y emisoras personalizadas.',
glyph: PluriIconGlyph.settings,
trailing: PluriStatusPill(
icon: Icons.security_rounded,
@@ -50,6 +51,8 @@ class _AjustesContent extends StatelessWidget {
children: const [
_SeccionEcualizador(),
SizedBox(height: 12),
_SeccionGrabaciones(),
SizedBox(height: 12),
_SeccionEmisoras(),
SizedBox(height: 12),
_SeccionBackup(),
@@ -60,6 +63,97 @@ class _AjustesContent extends StatelessWidget {
}
}
class _SeccionGrabaciones extends StatelessWidget {
const _SeccionGrabaciones();
Future<void> _seleccionarRuta(BuildContext context) async {
final estado = context.read<EstadoRadio>();
final messenger = ScaffoldMessenger.of(context);
final ruta = await FilePicker.platform.getDirectoryPath(
dialogTitle: 'Selecciona la carpeta de grabaciones',
);
if (ruta == null) return;
try {
await estado.cambiarDirectorioGrabacion(ruta);
messenger.showSnackBar(
const SnackBar(content: Text('Ruta de grabación actualizada')),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('No se pudo guardar la ruta: $e')),
);
}
}
Future<void> _restaurarRuta(BuildContext context) async {
final estado = context.read<EstadoRadio>();
final messenger = ScaffoldMessenger.of(context);
await estado.restaurarDirectorioGrabacion();
messenger.showSnackBar(
const SnackBar(content: Text('Se usará la carpeta interna por defecto')),
);
}
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.radio_button_checked),
const SizedBox(width: 12),
Text(
'Grabaciones',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
FutureBuilder<String>(
future: estado.directorioGrabacionEfectivo(),
builder:
(ctx, snap) => ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.folder_outlined),
title: const Text('Carpeta de grabación'),
subtitle: Text(
snap.data ?? 'Calculando ruta...',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onTap: () => _seleccionarRuta(context),
),
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.folder_open_rounded),
label: const Text('Cambiar ruta'),
onPressed: () => _seleccionarRuta(context),
),
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: 'Usar ruta por defecto',
icon: const Icon(Icons.restore_rounded),
onPressed: () => _restaurarRuta(context),
),
],
),
const SizedBox(height: 8),
Text(
'La radio se guarda desde el stream original, sin recomprimir.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}
class _SeccionEcualizador extends StatelessWidget {
const _SeccionEcualizador();
+389 -91
View File
@@ -1,4 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
@@ -22,12 +22,18 @@ class PantallaReproductor extends StatefulWidget {
return Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (_, animation, __) => PantallaReproductor(emisora: emisora),
transitionsBuilder: (_, animation, __, child) => SlideTransition(
position: Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero)
.animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)),
child: child,
),
pageBuilder:
(_, animation, __) => PantallaReproductor(emisora: emisora),
transitionsBuilder:
(_, animation, __, child) => SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
),
child: child,
),
transitionDuration: const Duration(milliseconds: 350),
),
);
@@ -44,7 +50,10 @@ class _PantallaReproductorState extends State<PantallaReproductor>
@override
void initState() {
super.initState();
_pulseController = AnimationController(vsync: this, duration: const Duration(seconds: 2));
_pulseController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
_iniciarReproduccion();
}
@@ -67,7 +76,9 @@ class _PantallaReproductorState extends State<PantallaReproductor>
final tokens = context.pluriTokens;
final estado = context.watch<EstadoRadio>();
final emisoraActiva = estado.emisoraActual ?? widget.emisora;
final esFavorito = estado.listaFavoritos.any((e) => e.uuid == emisoraActiva.uuid);
final esFavorito = estado.listaFavoritos.any(
(e) => e.uuid == emisoraActiva.uuid,
);
return PluriWaveScaffold(
appBar: AppBar(
@@ -81,7 +92,9 @@ class _PantallaReproductorState extends State<PantallaReproductor>
actions: [
IconButton(
icon: Icon(
esFavorito ? Icons.favorite_rounded : Icons.favorite_outline_rounded,
esFavorito
? Icons.favorite_rounded
: Icons.favorite_outline_rounded,
color: esFavorito ? theme.colorScheme.error : null,
),
tooltip: esFavorito ? 'Quitar de favoritos' : 'Anadir a favoritos',
@@ -95,42 +108,60 @@ class _PantallaReproductorState extends State<PantallaReproductor>
child: Column(
children: [
const SizedBox(height: 8),
_WaveHero(emisora: emisoraActiva, estadoStream: estado.estadoStream)
.animate()
.scale(begin: const Offset(0.86, 0.86), duration: 420.ms, curve: Curves.easeOutBack),
_WaveHero(
emisora: emisoraActiva,
estadoStream: estado.estadoStream,
).animate().scale(
begin: const Offset(0.86, 0.86),
duration: 420.ms,
curve: Curves.easeOutBack,
),
const SizedBox(height: 18),
Text(
emisoraActiva.nombre,
style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
).animate().fadeIn(delay: 150.ms),
const SizedBox(height: 10),
_InfoChips(emisora: emisoraActiva).animate().fadeIn(delay: 200.ms).slideY(begin: 0.2),
_InfoChips(
emisora: emisoraActiva,
).animate().fadeIn(delay: 200.ms).slideY(begin: 0.2),
const SizedBox(height: 6),
if (emisoraActiva.codec != null || emisoraActiva.bitrate != null)
Text(
_codecInfo(emisoraActiva),
style: theme.textTheme.bodySmall?.copyWith(color: Colors.white.withValues(alpha: 0.72)),
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white.withValues(alpha: 0.72),
),
).animate().fadeIn(delay: 250.ms),
const SizedBox(height: 14),
PluriGlassSurface(
borderRadius: BorderRadius.circular(tokens.radiusLg),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
child: VisualizadorAudio(
estadoStream: estado.estadoStream,
androidAudioSessionIdStream:
estado.audio.androidAudioSessionIdStream,
barras: 26,
color: tokens.electricMagenta,
color: tokens.warmCoral,
altura: 46,
),
).animate().fadeIn(delay: 280.ms),
const Spacer(),
_Controles(estado: estado, emisora: emisoraActiva)
.animate()
.fadeIn(delay: 300.ms)
.slideY(begin: 0.3),
const SizedBox(height: 24),
_Controles(
estado: estado,
emisora: emisoraActiva,
).animate().fadeIn(delay: 300.ms).slideY(begin: 0.3),
const SizedBox(height: 14),
_GrabacionWidget(estado: estado).animate().fadeIn(delay: 360.ms),
const SizedBox(height: 14),
_TimerWidget(estado: estado).animate().fadeIn(delay: 400.ms),
const SizedBox(height: 16),
],
@@ -144,7 +175,9 @@ class _PantallaReproductorState extends State<PantallaReproductor>
final parts = <String>[];
if (e.codec != null) parts.add(e.codec!.toUpperCase());
if (e.bitrate != null && e.bitrate! > 0) parts.add('${e.bitrate} kbps');
return parts.join(' · ');
return parts.isEmpty
? 'Calidad no informada'
: 'Calidad original: ${parts.join(' · ')}';
}
}
@@ -180,7 +213,9 @@ class _WaveHero extends StatelessWidget {
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
t.electricMagenta.withValues(alpha: reproduciendo ? 0.35 : 0.18),
t.electricMagenta.withValues(
alpha: reproduciendo ? 0.35 : 0.18,
),
t.deepViolet.withValues(alpha: 0.0),
],
),
@@ -191,7 +226,9 @@ class _WaveHero extends StatelessWidget {
height: size + 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withValues(alpha: 0.16)),
border: Border.all(
color: Colors.white.withValues(alpha: 0.16),
),
),
),
PluriGlassSurface(
@@ -204,7 +241,8 @@ class _WaveHero extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
if (emisora.favicon != null && emisora.favicon!.isNotEmpty)
if (emisora.favicon != null &&
emisora.favicon!.isNotEmpty)
CachedNetworkImage(
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
@@ -216,7 +254,11 @@ class _WaveHero extends StatelessWidget {
if (cargando)
Container(
color: Colors.black45,
child: const Center(child: CircularProgressIndicator(color: Colors.white)),
child: const Center(
child: CircularProgressIndicator(
color: Colors.white,
),
),
),
if (hayError)
Container(
@@ -249,7 +291,11 @@ class _WaveHero extends StatelessWidget {
Widget _iconoFallback(ThemeData theme) => Container(
color: theme.colorScheme.primaryContainer,
child: Icon(Icons.radio_rounded, size: 80, color: theme.colorScheme.onPrimaryContainer),
child: Icon(
Icons.radio_rounded,
size: 80,
color: theme.colorScheme.onPrimaryContainer,
),
);
}
@@ -263,27 +309,247 @@ class _InfoChips extends StatelessWidget {
final items = <String>[];
if (emisora.pais != null) items.add(emisora.pais!);
if (emisora.idioma != null) items.add(emisora.idioma!);
if ((emisora.bitrate ?? 0) > 0) items.add('${emisora.bitrate} kbps');
if (emisora.codec != null) items.add(emisora.codec!.toUpperCase());
if (items.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: 8,
runSpacing: 6,
alignment: WrapAlignment.center,
children: items
.map(
(label) => Chip(
label: Text(label),
visualDensity: VisualDensity.compact,
backgroundColor: theme.colorScheme.secondaryContainer.withValues(alpha: 0.8),
labelStyle: TextStyle(color: theme.colorScheme.onSecondaryContainer, fontSize: 12),
padding: EdgeInsets.zero,
),
)
.toList(),
children:
items
.map(
(label) => Chip(
label: Text(label),
visualDensity: VisualDensity.compact,
backgroundColor: theme.colorScheme.secondaryContainer
.withValues(alpha: 0.8),
labelStyle: TextStyle(
color: theme.colorScheme.onSecondaryContainer,
fontSize: 12,
),
padding: EdgeInsets.zero,
),
)
.toList(),
);
}
}
class _GrabacionWidget extends StatelessWidget {
final EstadoRadio estado;
const _GrabacionWidget({required this.estado});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final grabacion = estado.estadoGrabacion;
final activa = grabacion.activa;
return PluriGlassSurface(
borderRadius: BorderRadius.circular(24),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
Icon(
activa
? Icons.fiber_manual_record_rounded
: Icons.radio_button_checked,
color: activa ? theme.colorScheme.error : theme.colorScheme.primary,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
activa ? 'Grabando radio' : 'Grabación directa',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
activa
? '${_formatearDuracion(grabacion.transcurrido)} · ${_formatearBytes(grabacion.bytes)}'
: 'Guarda el stream original, sin recomprimir.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
),
),
const SizedBox(width: 8),
FilledButton.tonalIcon(
icon: Icon(activa ? Icons.stop_rounded : Icons.mic_rounded),
label: Text(activa ? 'Parar' : 'Grabar'),
onPressed:
activa
? estado.detenerGrabacion
: () => _mostrarDialogoGrabacion(context),
),
],
),
);
}
void _mostrarDialogoGrabacion(BuildContext context) {
showModalBottomSheet(
context: context,
builder:
(ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Grabar radio',
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: 8),
const Text('Elige cuánto tiempo querés grabar.'),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ActionChip(
avatar: const Icon(
Icons.all_inclusive_rounded,
size: 18,
),
label: const Text('Indefinida'),
onPressed: () {
estado.iniciarGrabacion();
Navigator.pop(ctx);
},
),
for (final opcion in _opciones)
ActionChip(
label: Text(opcion.label),
onPressed: () {
estado.iniciarGrabacion(duracion: opcion.duracion);
Navigator.pop(ctx);
},
),
ActionChip(
avatar: const Icon(Icons.tune_rounded, size: 18),
label: const Text('Personalizada'),
onPressed: () {
Navigator.pop(ctx);
_mostrarDuracionPersonalizada(context);
},
),
],
),
],
),
),
),
);
}
Future<void> _mostrarDuracionPersonalizada(BuildContext context) async {
final minutosCtrl = TextEditingController();
final segundosCtrl = TextEditingController(text: '0');
final formKey = GlobalKey<FormState>();
await showDialog<void>(
context: context,
builder:
(ctx) => AlertDialog(
title: const Text('Duración de grabación'),
content: Form(
key: formKey,
child: Row(
children: [
Expanded(
child: TextFormField(
controller: minutosCtrl,
decoration: const InputDecoration(labelText: 'Minutos'),
keyboardType: TextInputType.number,
validator: _validarNumero,
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: segundosCtrl,
decoration: const InputDecoration(labelText: 'Segundos'),
keyboardType: TextInputType.number,
validator: _validarNumero,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancelar'),
),
FilledButton(
onPressed: () {
if (!formKey.currentState!.validate()) return;
final minutos = int.tryParse(minutosCtrl.text.trim()) ?? 0;
final segundos = int.tryParse(segundosCtrl.text.trim()) ?? 0;
final duracion = Duration(
minutes: minutos,
seconds: segundos,
);
if (duracion <= Duration.zero) return;
estado.iniciarGrabacion(duracion: duracion);
Navigator.pop(ctx);
},
child: const Text('Grabar'),
),
],
),
);
minutosCtrl.dispose();
segundosCtrl.dispose();
}
String? _validarNumero(String? value) {
if (value == null || value.trim().isEmpty) return null;
final n = int.tryParse(value.trim());
if (n == null || n < 0) return 'Número inválido';
return null;
}
String _formatearDuracion(Duration d) {
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '${h}h ${m}m ${s}s' : '${m}m ${s}s';
}
String _formatearBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
class _OpcionGrabacion {
const _OpcionGrabacion(this.label, this.duracion);
final String label;
final Duration duracion;
}
const _opciones = [
_OpcionGrabacion('30 s', Duration(seconds: 30)),
_OpcionGrabacion('1 min', Duration(minutes: 1)),
_OpcionGrabacion('5 min', Duration(minutes: 5)),
_OpcionGrabacion('15 min', Duration(minutes: 15)),
_OpcionGrabacion('30 min', Duration(minutes: 30)),
];
class _Controles extends StatelessWidget {
final EstadoRadio estado;
final Emisora emisora;
@@ -306,11 +572,17 @@ class _Controles extends StatelessWidget {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline_rounded, size: 40, color: theme.colorScheme.error),
Icon(
Icons.error_outline_rounded,
size: 40,
color: theme.colorScheme.error,
),
const SizedBox(height: 8),
Text(
'No se puede reproducir esta radio',
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.error),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
@@ -335,7 +607,10 @@ class _Controles extends StatelessWidget {
child: IconButton(
icon: const Icon(Icons.stop_rounded),
iconSize: 34,
constraints: const BoxConstraints(minWidth: 56, minHeight: 56),
constraints: const BoxConstraints(
minWidth: 56,
minHeight: 56,
),
color: Colors.white.withValues(alpha: 0.78),
tooltip: 'Detener',
onPressed: cargando ? null : () => estado.audio.detener(),
@@ -363,30 +638,41 @@ class _Controles extends StatelessWidget {
child: InkWell(
customBorder: const CircleBorder(),
radius: 40,
onTap: cargando
? null
: () {
if (reproduciendo || s == EstadoReproduccion.pausado) {
estado.togglePlay();
} else {
estado.reproducir(emisora);
}
},
onTap:
cargando
? null
: () {
if (reproduciendo ||
s == EstadoReproduccion.pausado) {
estado.togglePlay();
} else {
estado.reproducir(emisora);
}
},
child: Semantics(
button: true,
label: reproduciendo ? 'Pausar reproduccion' : 'Iniciar reproduccion',
label:
reproduciendo
? 'Pausar reproduccion'
: 'Iniciar reproduccion',
child: Center(
child: cargando
? const SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
)
: Icon(
reproduciendo ? Icons.pause_rounded : Icons.play_arrow_rounded,
size: 40,
color: theme.colorScheme.onPrimary,
),
child:
cargando
? const SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: Icon(
reproduciendo
? Icons.pause_rounded
: Icons.play_arrow_rounded,
size: 40,
color: theme.colorScheme.onPrimary,
),
),
),
),
@@ -398,7 +684,10 @@ class _Controles extends StatelessWidget {
child: Icon(
Icons.fiber_manual_record_rounded,
size: 32,
color: reproduciendo ? theme.colorScheme.error : theme.colorScheme.surfaceContainerHighest,
color:
reproduciendo
? theme.colorScheme.error
: theme.colorScheme.surfaceContainerHighest,
),
),
],
@@ -436,7 +725,11 @@ class _TimerWidget extends StatelessWidget {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.bedtime_rounded, size: 16, color: theme.colorScheme.primary),
Icon(
Icons.bedtime_rounded,
size: 16,
color: theme.colorScheme.primary,
),
const SizedBox(width: 6),
Text(label, style: theme.textTheme.bodyMedium),
const SizedBox(width: 8),
@@ -457,33 +750,38 @@ class _TimerWidget extends StatelessWidget {
void _mostrarTimerDialog(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Timer de sueno', style: Theme.of(ctx).textTheme.titleLarge),
const SizedBox(height: 16),
Wrap(
spacing: 8,
children: opcionesTimer
.map(
(min) => ActionChip(
label: Text('$min min'),
onPressed: () {
estado.iniciarTimer(min);
Navigator.pop(ctx);
},
),
)
.toList(),
builder:
(ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Timer de sueno',
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
children:
opcionesTimer
.map(
(min) => ActionChip(
label: Text('$min min'),
onPressed: () {
estado.iniciarTimer(min);
Navigator.pop(ctx);
},
),
)
.toList(),
),
],
),
],
),
),
),
),
);
}
}