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