diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 7e6408f..95a6b0f 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
index 3c70ffc..34568ed 100644
--- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
+++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
@@ -1,5 +1,154 @@
package es.freetimelab.pluriwave
+import android.Manifest
+import android.content.pm.PackageManager
+import android.media.audiofx.Visualizer
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
import com.ryanheise.audioservice.AudioServiceActivity
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.EventChannel
-class MainActivity : AudioServiceActivity()
+class MainActivity : AudioServiceActivity() {
+ private val visualizerChannel = "pluriwave/audio_visualizer"
+ private val permissionRequestCode = 4821
+ private var visualizer: Visualizer? = null
+ private var pendingSink: EventChannel.EventSink? = null
+ private var pendingArgs: Map<*, *>? = null
+ private val mainHandler = Handler(Looper.getMainLooper())
+
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+ EventChannel(
+ flutterEngine.dartExecutor.binaryMessenger,
+ visualizerChannel
+ ).setStreamHandler(object : EventChannel.StreamHandler {
+ override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
+ pendingSink = events
+ pendingArgs = arguments as? Map<*, *>
+ startVisualizerWhenAllowed()
+ }
+
+ override fun onCancel(arguments: Any?) {
+ stopVisualizer()
+ pendingSink = null
+ pendingArgs = null
+ }
+ })
+ }
+
+ private fun startVisualizerWhenAllowed() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
+ checkSelfPermission(Manifest.permission.RECORD_AUDIO)
+ != PackageManager.PERMISSION_GRANTED
+ ) {
+ requestPermissions(
+ arrayOf(Manifest.permission.RECORD_AUDIO),
+ permissionRequestCode
+ )
+ return
+ }
+ startVisualizer()
+ }
+
+ private fun startVisualizer() {
+ val sink = pendingSink ?: return
+ val args = pendingArgs
+ val sessionId = (args?.get("sessionId") as? Number)?.toInt() ?: 0
+ val bands = ((args?.get("bands") as? Number)?.toInt() ?: 26).coerceIn(8, 96)
+
+ stopVisualizer()
+
+ try {
+ val captureSize = Visualizer.getCaptureSizeRange()[1]
+ visualizer = Visualizer(sessionId).apply {
+ enabled = false
+ setCaptureSize(captureSize)
+ setDataCaptureListener(
+ object : Visualizer.OnDataCaptureListener {
+ override fun onWaveFormDataCapture(
+ visualizer: Visualizer?,
+ waveform: ByteArray?,
+ samplingRate: Int
+ ) {
+ val data = waveform ?: return
+ val values = downsample(data, bands)
+ mainHandler.post { sink.success(values) }
+ }
+
+ override fun onFftDataCapture(
+ visualizer: Visualizer?,
+ fft: ByteArray?,
+ samplingRate: Int
+ ) = Unit
+ },
+ Visualizer.getMaxCaptureRate() / 2,
+ true,
+ false
+ )
+ enabled = true
+ }
+ } catch (error: Throwable) {
+ sink.error("VISUALIZER_UNAVAILABLE", error.message, null)
+ stopVisualizer()
+ }
+ }
+
+ private fun downsample(data: ByteArray, bands: Int): List {
+ if (data.isEmpty()) return emptyList()
+ val bucket = maxOf(1, data.size / bands)
+ val values = ArrayList(bands)
+
+ var index = 0
+ while (index < data.size && values.size < bands) {
+ var sum = 0.0
+ var count = 0
+ val end = minOf(index + bucket, data.size)
+ for (i in index until end) {
+ val centered = (data[i].toInt() and 0xFF) - 128
+ sum += kotlin.math.abs(centered) / 128.0
+ count++
+ }
+ values.add(if (count == 0) 0.0 else (sum / count).coerceIn(0.0, 1.0))
+ index = end
+ }
+
+ while (values.size < bands) values.add(0.0)
+ return values
+ }
+
+ private fun stopVisualizer() {
+ try {
+ visualizer?.enabled = false
+ visualizer?.release()
+ } catch (_: Throwable) {
+ } finally {
+ visualizer = null
+ }
+ }
+
+ override fun onRequestPermissionsResult(
+ requestCode: Int,
+ permissions: Array,
+ grantResults: IntArray
+ ) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ if (requestCode != permissionRequestCode) return
+
+ if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
+ startVisualizer()
+ } else {
+ pendingSink?.error(
+ "RECORD_AUDIO_DENIED",
+ "Permiso de audio denegado para visualizar la onda real",
+ null
+ )
+ }
+ }
+
+ override fun onDestroy() {
+ stopVisualizer()
+ super.onDestroy()
+ }
+}
diff --git a/docs/audio-recording-visualizer-notes.md b/docs/audio-recording-visualizer-notes.md
new file mode 100644
index 0000000..a94ea11
--- /dev/null
+++ b/docs/audio-recording-visualizer-notes.md
@@ -0,0 +1,32 @@
+# Notas de grabación y visualización real de audio
+
+Referencia interna: este archivo vive en `docs/` y no está listado en
+`flutter.assets`, así que no se compila dentro de la aplicación.
+
+## Decisiones aplicadas
+
+- La grabación de radio se hace leyendo el stream HTTP original de la emisora y
+ escribiendo sus bytes a disco. No se graba micrófono ni salida del sistema.
+- La ventaja es que se conserva la calidad original del stream y se evita
+ recomprimir audio.
+- La forma de onda real se intenta capturar en Android con
+ `android.media.audiofx.Visualizer` usando el `androidAudioSessionId` expuesto
+ por `just_audio`.
+- Si Android deniega permisos o el dispositivo no permite capturar esa sesión,
+ la UI cae al visualizador animado anterior para no bloquear el reproductor.
+
+## Fuentes consultadas
+
+- `just_audio` expone `androidAudioSessionIdStream` para enlazar efectos o
+ visualizadores Android a la sesión activa:
+ https://pub.dev/packages/just_audio/versions/0.10.4
+- Android `Visualizer` permite capturar waveform de contenido en reproducción y
+ requiere permiso `RECORD_AUDIO`:
+ https://www.android-doc.com/reference/android/media/audiofx/Visualizer.html
+- Radio Browser permite ordenar búsquedas por `bitrate` y expone campos
+ `codec`/`bitrate`:
+ https://stations.radioss.app/
+- El paquete `audio_visualizer` existe, pero se descartó como dependencia
+ inmediata porque duplicaría reproducción con su propio player; PluriWave ya
+ usa `audio_service` + `just_audio` y acabamos de estabilizar ese flujo:
+ https://pub.dev/packages/audio_visualizer
diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart
index cf77318..f46e9e9 100644
--- a/lib/estado/estado_radio.dart
+++ b/lib/estado/estado_radio.dart
@@ -12,6 +12,7 @@ import '../modelos/preset_ecualizador.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_ecualizador.dart';
import '../servicios/servicio_favoritos.dart';
+import '../servicios/servicio_grabacion_radio.dart';
import '../servicios/servicio_radio.dart';
import '../servicios/servicio_timer.dart';
@@ -22,15 +23,18 @@ class EstadoRadio extends ChangeNotifier {
ServicioFavoritos? favoritos,
ServicioRadio? radio,
ServicioEcualizador? servicioEcualizador,
+ ServicioGrabacionRadio? servicioGrabacion,
Future Function()? resolverArchivoCustom,
bool iniciarAutomaticamente = true,
- }) : audio = audio ?? ServicioAudio(),
- favoritos = favoritos ?? ServicioFavoritos(),
- radio = radio ?? ServicioRadio(),
- servicioEcualizador = servicioEcualizador ?? ServicioEcualizador(),
- _resolverArchivoCustom = resolverArchivoCustom {
+ }) : audio = audio ?? ServicioAudio(),
+ favoritos = favoritos ?? ServicioFavoritos(),
+ radio = radio ?? ServicioRadio(),
+ servicioEcualizador = servicioEcualizador ?? ServicioEcualizador(),
+ grabacion = servicioGrabacion ?? ServicioGrabacionRadio(),
+ _resolverArchivoCustom = resolverArchivoCustom {
timer = ServicioTimer(this.audio);
_escucharErroresReproduccion();
+ _escucharGrabacion();
if (iniciarAutomaticamente) {
_initFuture = _init();
}
@@ -40,10 +44,12 @@ class EstadoRadio extends ChangeNotifier {
final ServicioFavoritos favoritos;
final ServicioRadio radio;
final ServicioEcualizador servicioEcualizador;
+ final ServicioGrabacionRadio grabacion;
final Future Function()? _resolverArchivoCustom;
late final ServicioTimer timer;
StreamSubscription? _suscripcionEstadoAudio;
+ StreamSubscription? _suscripcionGrabacion;
Future? _initFuture;
int _revisionReproduccion = 0;
Emisora? _emisoraSeleccionada;
@@ -110,6 +116,10 @@ class EstadoRadio extends ChangeNotifier {
return tienePresetEcualizadorPorEmisora(actual.uuid);
}
+ EstadoGrabacionRadio get estadoGrabacion => grabacion.estado;
+ bool get grabacionActiva => grabacion.estado.activa;
+ String? get directorioGrabacion => grabacion.directorioConfigurado;
+
/// Lista principal (home): custom + populares, sin duplicados.
List get emisorasInicio {
final mapa = {};
@@ -128,6 +138,7 @@ class EstadoRadio extends ChangeNotifier {
}
Future _init() async {
+ await grabacion.inicializar();
await _cargarEcualizadorPersistido();
await Future.wait([
cargarPopulares(),
@@ -146,6 +157,16 @@ class EstadoRadio extends ChangeNotifier {
});
}
+ void _escucharGrabacion() {
+ _suscripcionGrabacion = grabacion.estadoStream.listen((estado) {
+ if (estado.tipo == EstadoGrabacionRadioTipo.error &&
+ estado.error != null) {
+ _errorController.add('Error al grabar la radio: ${estado.error}');
+ }
+ notifyListeners();
+ });
+ }
+
Future _cargarEcualizadorPersistido() async {
try {
final config = await servicioEcualizador.cargar();
@@ -296,7 +317,8 @@ class EstadoRadio extends ChangeNotifier {
_paisCercanoDetectado = pais;
_emisorasCercanas = await radio.buscar(pais: pais, limit: 30);
} catch (_) {
- _errorCercanas = 'No pudimos detectar emisoras cercanas. Usa filtros por pais.';
+ _errorCercanas =
+ 'No pudimos detectar emisoras cercanas. Usa filtros por pais.';
_emisorasCercanas = [];
} finally {
_cargandoCercanas = false;
@@ -331,6 +353,34 @@ class EstadoRadio extends ChangeNotifier {
}
}
+ Future iniciarGrabacion({Duration? duracion}) async {
+ final actual = emisoraActual;
+ if (actual == null) {
+ _errorController.add('Primero selecciona una emisora para grabar.');
+ return;
+ }
+ try {
+ await grabacion.iniciar(actual, duracion: duracion);
+ } catch (e) {
+ _errorController.add('No se pudo iniciar la grabación: $e');
+ }
+ }
+
+ Future detenerGrabacion() => grabacion.detener();
+
+ Future cambiarDirectorioGrabacion(String path) async {
+ await grabacion.guardarDirectorio(path);
+ notifyListeners();
+ }
+
+ Future restaurarDirectorioGrabacion() async {
+ await grabacion.limpiarDirectorioConfigurado();
+ notifyListeners();
+ }
+
+ Future directorioGrabacionEfectivo() =>
+ grabacion.directorioEfectivo();
+
Future togglePlay() async {
await audio.togglePlay();
notifyListeners();
@@ -339,7 +389,10 @@ class EstadoRadio extends ChangeNotifier {
Future toggleFavorito(Emisora emisora) async {
final esFav = await favoritos.toggleFavorito(emisora);
if (!esFav) {
- await deshabilitarPresetEcualizadorPorEmisora(emisora.uuid, notificar: false);
+ await deshabilitarPresetEcualizadorPorEmisora(
+ emisora.uuid,
+ notificar: false,
+ );
}
await cargarFavoritos();
return esFav;
@@ -418,7 +471,9 @@ class EstadoRadio extends ChangeNotifier {
if (notificar) notifyListeners();
}
- Future cambiarModoEcualizadorEmisoraActual({required bool usarPropio}) async {
+ Future cambiarModoEcualizadorEmisoraActual({
+ required bool usarPropio,
+ }) async {
final actual = emisoraActual;
if (actual == null) return;
if (usarPropio) {
@@ -433,7 +488,8 @@ class EstadoRadio extends ChangeNotifier {
bool guardarPorEmisora = true,
}) async {
final actual = emisoraActual;
- final usarPresetPropio = guardarPorEmisora &&
+ final usarPresetPropio =
+ guardarPorEmisora &&
actual != null &&
_presetsEmisoraMap.containsKey(actual.uuid);
@@ -476,9 +532,10 @@ class EstadoRadio extends ChangeNotifier {
}
final data = jsonDecode(await archivo.readAsString()) as List;
- _emisorasCustom = data
- .map((e) => Emisora.fromMap(Map.from(e as Map)))
- .toList();
+ _emisorasCustom =
+ data
+ .map((e) => Emisora.fromMap(Map.from(e as Map)))
+ .toList();
} catch (_) {
_emisorasCustom = [];
}
@@ -510,7 +567,8 @@ class EstadoRadio extends ChangeNotifier {
}
// Compatibilidad con el nombre histórico (typo original).
- Future eliminarEmitoraCustom(String uuid) => eliminarEmisoraCustom(uuid);
+ Future eliminarEmitoraCustom(String uuid) =>
+ eliminarEmisoraCustom(uuid);
// ── Export / Import ───────────────────────────────────────────────────────
@@ -541,9 +599,10 @@ class EstadoRadio extends ChangeNotifier {
}
final customRaw = data['emisorasCustom'] as List? ?? [];
- _emisorasCustom = customRaw
- .map((e) => Emisora.fromMap(Map.from(e as Map)))
- .toList();
+ _emisorasCustom =
+ customRaw
+ .map((e) => Emisora.fromMap(Map.from(e as Map)))
+ .toList();
await _guardarEmisorasCustom();
final principalRaw = data['presetPrincipalEcualizador'];
@@ -600,8 +659,10 @@ class EstadoRadio extends ChangeNotifier {
@override
void dispose() {
_suscripcionEstadoAudio?.cancel();
+ _suscripcionGrabacion?.cancel();
_errorController.close();
audio.dispose();
+ unawaited(grabacion.dispose());
timer.dispose();
super.dispose();
}
diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart
index f4bd8da..77397eb 100644
--- a/lib/pantallas/pantalla_ajustes.dart
+++ b/lib/pantallas/pantalla_ajustes.dart
@@ -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 _seleccionarRuta(BuildContext context) async {
+ final estado = context.read();
+ 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 _restaurarRuta(BuildContext context) async {
+ final estado = context.read();
+ 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();
+
+ 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(
+ 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();
diff --git a/lib/pantallas/pantalla_reproductor.dart b/lib/pantallas/pantalla_reproductor.dart
index 7c9a808..682779b 100644
--- a/lib/pantallas/pantalla_reproductor.dart
+++ b/lib/pantallas/pantalla_reproductor.dart
@@ -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(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(
+ 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
@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
final tokens = context.pluriTokens;
final estado = context.watch();
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
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
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
final parts = [];
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 = [];
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 _mostrarDuracionPersonalizada(BuildContext context) async {
+ final minutosCtrl = TextEditingController();
+ final segundosCtrl = TextEditingController(text: '0');
+ final formKey = GlobalKey();
+
+ await showDialog(
+ 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(),
+ ),
+ ],
),
- ],
+ ),
),
- ),
- ),
);
}
}
diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart
index 90f4b05..f4eb58e 100644
--- a/lib/servicios/servicio_audio.dart
+++ b/lib/servicios/servicio_audio.dart
@@ -22,7 +22,10 @@ void registrarHandler(PluriWaveAudioHandler handler) {
/// Wrapper de alto nivel para el UI.
class ServicioAudio {
PluriWaveAudioHandler get _handler {
- assert(_handlerGlobal != null, 'registrarHandler() no fue llamado en main.dart');
+ assert(
+ _handlerGlobal != null,
+ 'registrarHandler() no fue llamado en main.dart',
+ );
return _handlerGlobal!;
}
@@ -50,9 +53,10 @@ class ServicioAudio {
title: emisora.nombre,
artist: emisora.pais ?? '',
album: 'PluriWave',
- artUri: emisora.favicon != null && emisora.favicon!.isNotEmpty
- ? Uri.tryParse(emisora.favicon!)
- : null,
+ artUri:
+ emisora.favicon != null && emisora.favicon!.isNotEmpty
+ ? Uri.tryParse(emisora.favicon!)
+ : null,
extras: {'uuid': emisora.uuid},
);
await _handler.playMediaItem(item);
@@ -73,6 +77,8 @@ class ServicioAudio {
Future setVolumen(double vol) => _handler.setVolumen(vol);
double get volumen => _handler.volumen;
bool get estaSonando => _handler.playbackState.value.playing;
+ Stream get androidAudioSessionIdStream =>
+ _handler.androidAudioSessionIdStream;
Future dispose() async {}
// ── Ecualizador ───────────────────────────────────────────────────────────
@@ -83,8 +89,7 @@ class ServicioAudio {
Future aplicarPreset(PresetEcualizador preset) =>
_handler.aplicarPreset(preset);
- Future setBanda(int index, double db) =>
- _handler.setBanda(index, db);
+ Future setBanda(int index, double db) => _handler.setBanda(index, db);
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -99,6 +104,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
StreamSubscription? _estadoPlayerSub;
StreamSubscription? _bufferedSub;
StreamSubscription? _eventosSub;
+ StreamSubscription? _androidAudioSessionIdSub;
+ final _androidAudioSessionIdController = StreamController.broadcast();
Future _colaCambioFuente = Future.value();
int _revisionFuente = 0;
@@ -112,6 +119,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
PresetEcualizador _presetActual = PresetEcualizador.flat;
PresetEcualizador get presetActual => _presetActual;
+ Stream get androidAudioSessionIdStream =>
+ _androidAudioSessionIdController.stream;
PluriWaveAudioHandler() {
_conectarStreamsPlayer();
@@ -127,18 +136,20 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
_estadoPlayerSub = _player.playerStateStream.listen((state) {
final playing = state.playing;
final proc = state.processingState;
- playbackState.add(playbackState.value.copyWith(
- controls: [
- if (playing) MediaControl.pause else MediaControl.play,
- MediaControl.stop,
- ],
- systemActions: const {MediaAction.seek, MediaAction.stop},
- androidCompactActionIndices: const [0],
- processingState: _mapProcState(proc),
- playing: playing,
- bufferedPosition: _player.bufferedPosition,
- speed: _player.speed,
- ));
+ playbackState.add(
+ playbackState.value.copyWith(
+ controls: [
+ if (playing) MediaControl.pause else MediaControl.play,
+ MediaControl.stop,
+ ],
+ systemActions: const {MediaAction.seek, MediaAction.stop},
+ androidCompactActionIndices: const [0],
+ processingState: _mapProcState(proc),
+ playing: playing,
+ bufferedPosition: _player.bufferedPosition,
+ speed: _player.speed,
+ ),
+ );
});
_bufferedSub = _player.bufferedPositionStream.listen((pos) {
@@ -151,6 +162,14 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
_gestionarErrorReproduccion(error);
},
);
+
+ _androidAudioSessionIdSub = _player.androidAudioSessionIdStream.listen((
+ sessionId,
+ ) {
+ if (!_androidAudioSessionIdController.isClosed) {
+ _androidAudioSessionIdController.add(sessionId);
+ }
+ });
}
/// Gestiona cualquier error de reproducción de ExoPlayer.
@@ -172,11 +191,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
level: 900,
);
- playbackState.add(playbackState.value.copyWith(
- processingState: AudioProcessingState.error,
- playing: false,
- errorMessage: mensaje,
- ));
+ playbackState.add(
+ playbackState.value.copyWith(
+ processingState: AudioProcessingState.error,
+ playing: false,
+ errorMessage: mensaje,
+ ),
+ );
emisoraActual = null;
mediaItem.add(null);
@@ -236,11 +257,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
Future _cambiarFuente(MediaItem mediaItem, int revision) async {
this.mediaItem.add(mediaItem);
emisoraActual = _emisoraDesdeMediaItem(mediaItem);
- playbackState.add(playbackState.value.copyWith(
- processingState: AudioProcessingState.loading,
- playing: false,
- errorMessage: null,
- ));
+ playbackState.add(
+ playbackState.value.copyWith(
+ processingState: AudioProcessingState.loading,
+ playing: false,
+ errorMessage: null,
+ ),
+ );
try {
await _recrearPlayer();
if (revision != _revisionFuente) return;
@@ -263,11 +286,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
stackTrace: stackTrace,
);
if (revision == _revisionFuente) {
- playbackState.add(playbackState.value.copyWith(
- processingState: AudioProcessingState.error,
- playing: false,
- errorMessage: 'Error inesperado al reproducir',
- ));
+ playbackState.add(
+ playbackState.value.copyWith(
+ processingState: AudioProcessingState.error,
+ playing: false,
+ errorMessage: 'Error inesperado al reproducir',
+ ),
+ );
emisoraActual = null;
this.mediaItem.add(null);
}
@@ -279,6 +304,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
await _estadoPlayerSub?.cancel();
await _bufferedSub?.cancel();
await _eventosSub?.cancel();
+ await _androidAudioSessionIdSub?.cancel();
final anterior = _player;
try {
@@ -330,7 +356,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
if (!_eqDisponible) return;
try {
final params = await _eq.parameters;
- for (int i = 0; i < params.bands.length && i < preset.bandas.length; i++) {
+ for (
+ int i = 0;
+ i < params.bands.length && i < preset.bandas.length;
+ i++
+ ) {
await params.bands[i].setGain(preset.bandas[i]);
}
} catch (_) {}
@@ -381,7 +411,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
await _estadoPlayerSub?.cancel();
await _bufferedSub?.cancel();
await _eventosSub?.cancel();
+ await _androidAudioSessionIdSub?.cancel();
await _player.dispose();
+ await _androidAudioSessionIdController.close();
}
Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) {
diff --git a/lib/servicios/servicio_grabacion_radio.dart b/lib/servicios/servicio_grabacion_radio.dart
new file mode 100644
index 0000000..e3cf8b7
--- /dev/null
+++ b/lib/servicios/servicio_grabacion_radio.dart
@@ -0,0 +1,313 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:http/http.dart' as http;
+import 'package:path_provider/path_provider.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+import '../modelos/emisora.dart';
+
+enum EstadoGrabacionRadioTipo {
+ inactiva,
+ preparando,
+ grabando,
+ deteniendo,
+ error,
+}
+
+class EstadoGrabacionRadio {
+ const EstadoGrabacionRadio({
+ required this.tipo,
+ this.emisora,
+ this.archivo,
+ this.bytes = 0,
+ this.inicio,
+ this.duracionObjetivo,
+ this.error,
+ });
+
+ const EstadoGrabacionRadio.inactiva()
+ : tipo = EstadoGrabacionRadioTipo.inactiva,
+ emisora = null,
+ archivo = null,
+ bytes = 0,
+ inicio = null,
+ duracionObjetivo = null,
+ error = null;
+
+ final EstadoGrabacionRadioTipo tipo;
+ final Emisora? emisora;
+ final File? archivo;
+ final int bytes;
+ final DateTime? inicio;
+ final Duration? duracionObjetivo;
+ final String? error;
+
+ bool get activa =>
+ tipo == EstadoGrabacionRadioTipo.preparando ||
+ tipo == EstadoGrabacionRadioTipo.grabando ||
+ tipo == EstadoGrabacionRadioTipo.deteniendo;
+
+ Duration get transcurrido {
+ final inicioLocal = inicio;
+ if (inicioLocal == null) return Duration.zero;
+ return DateTime.now().difference(inicioLocal);
+ }
+
+ EstadoGrabacionRadio copyWith({
+ EstadoGrabacionRadioTipo? tipo,
+ Emisora? emisora,
+ File? archivo,
+ int? bytes,
+ DateTime? inicio,
+ Duration? duracionObjetivo,
+ String? error,
+ }) {
+ return EstadoGrabacionRadio(
+ tipo: tipo ?? this.tipo,
+ emisora: emisora ?? this.emisora,
+ archivo: archivo ?? this.archivo,
+ bytes: bytes ?? this.bytes,
+ inicio: inicio ?? this.inicio,
+ duracionObjetivo: duracionObjetivo ?? this.duracionObjetivo,
+ error: error,
+ );
+ }
+}
+
+class ServicioGrabacionRadio {
+ ServicioGrabacionRadio({
+ http.Client? cliente,
+ Future Function()? resolverDirectorioBase,
+ DateTime Function()? reloj,
+ }) : _clienteExterno = cliente,
+ _resolverDirectorioBase = resolverDirectorioBase,
+ _reloj = reloj ?? DateTime.now;
+
+ static const _claveDirectorio = 'grabacion_radio_directorio';
+
+ final http.Client? _clienteExterno;
+ final Future Function()? _resolverDirectorioBase;
+ final DateTime Function() _reloj;
+ final _estadoController = StreamController.broadcast();
+
+ EstadoGrabacionRadio _estado = const EstadoGrabacionRadio.inactiva();
+ StreamSubscription>? _subscripcionStream;
+ IOSink? _sink;
+ http.Client? _clienteActivo;
+ Timer? _timerAutoStop;
+ String? _directorioConfigurado;
+
+ EstadoGrabacionRadio get estado => _estado;
+ Stream get estadoStream => _estadoController.stream;
+ String? get directorioConfigurado => _directorioConfigurado;
+
+ Future inicializar() async {
+ try {
+ final prefs = await SharedPreferences.getInstance();
+ _directorioConfigurado = prefs.getString(_claveDirectorio);
+ } catch (_) {
+ _directorioConfigurado = null;
+ }
+ }
+
+ Future directorioEfectivo() async {
+ final configurado = _directorioConfigurado;
+ if (configurado != null && configurado.trim().isNotEmpty) {
+ return configurado;
+ }
+ final base =
+ _resolverDirectorioBase != null
+ ? await _resolverDirectorioBase()
+ : await getApplicationDocumentsDirectory();
+ return '${base.path}${Platform.pathSeparator}grabaciones';
+ }
+
+ Future guardarDirectorio(String path) async {
+ final normalizado = path.trim();
+ if (normalizado.isEmpty) {
+ throw ArgumentError('La ruta de grabación no puede estar vacía');
+ }
+ _directorioConfigurado = normalizado;
+ try {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString(_claveDirectorio, normalizado);
+ } catch (_) {}
+ _emitir(_estado);
+ }
+
+ Future limpiarDirectorioConfigurado() async {
+ _directorioConfigurado = null;
+ try {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.remove(_claveDirectorio);
+ } catch (_) {}
+ _emitir(_estado);
+ }
+
+ Future iniciar(
+ Emisora emisora, {
+ Duration? duracion,
+ String? directorio,
+ }) async {
+ if (_estado.activa) {
+ throw StateError('Ya hay una grabación en curso');
+ }
+
+ final inicio = _reloj();
+ _emitir(
+ EstadoGrabacionRadio(
+ tipo: EstadoGrabacionRadioTipo.preparando,
+ emisora: emisora,
+ inicio: inicio,
+ duracionObjetivo: duracion,
+ ),
+ );
+
+ try {
+ final carpeta = Directory(directorio ?? await directorioEfectivo());
+ await carpeta.create(recursive: true);
+
+ final cliente = _clienteExterno ?? http.Client();
+ _clienteActivo = cliente;
+ final request = http.Request('GET', Uri.parse(emisora.url))
+ ..headers['User-Agent'] = 'PluriWave/0.1.0 (radio recorder)';
+ final response = await cliente.send(request);
+ if (response.statusCode < 200 || response.statusCode >= 300) {
+ throw HttpException('HTTP ${response.statusCode}', uri: request.url);
+ }
+
+ final archivo = File(
+ '${carpeta.path}${Platform.pathSeparator}'
+ '${_nombreArchivo(emisora, inicio, response.headers)}',
+ );
+ _sink = archivo.openWrite();
+ _emitir(
+ EstadoGrabacionRadio(
+ tipo: EstadoGrabacionRadioTipo.grabando,
+ emisora: emisora,
+ archivo: archivo,
+ inicio: inicio,
+ duracionObjetivo: duracion,
+ ),
+ );
+
+ if (duracion != null && duracion > Duration.zero) {
+ _timerAutoStop = Timer(duracion, () => unawaited(detener()));
+ }
+
+ _subscripcionStream = response.stream.listen(
+ (chunk) {
+ _sink?.add(chunk);
+ _emitir(_estado.copyWith(bytes: _estado.bytes + chunk.length));
+ },
+ onDone: () => unawaited(_finalizar()),
+ onError: (Object error) => unawaited(_fallar(error)),
+ cancelOnError: true,
+ );
+ } catch (error) {
+ await _fallar(error);
+ rethrow;
+ }
+ }
+
+ Future detener() async {
+ if (!_estado.activa) return;
+ _emitir(_estado.copyWith(tipo: EstadoGrabacionRadioTipo.deteniendo));
+ _timerAutoStop?.cancel();
+ _timerAutoStop = null;
+ _clienteActivo?.close();
+ await _subscripcionStream?.cancel();
+ await _finalizar();
+ }
+
+ Future _finalizar() async {
+ _timerAutoStop?.cancel();
+ _timerAutoStop = null;
+ await _subscripcionStream?.cancel();
+ _subscripcionStream = null;
+ await _sink?.flush();
+ await _sink?.close();
+ _sink = null;
+ if (_clienteExterno == null) {
+ _clienteActivo?.close();
+ }
+ _clienteActivo = null;
+ _emitir(const EstadoGrabacionRadio.inactiva());
+ }
+
+ Future _fallar(Object error) async {
+ _timerAutoStop?.cancel();
+ _timerAutoStop = null;
+ await _subscripcionStream?.cancel();
+ _subscripcionStream = null;
+ try {
+ await _sink?.flush();
+ await _sink?.close();
+ } catch (_) {}
+ _sink = null;
+ if (_clienteExterno == null) {
+ _clienteActivo?.close();
+ }
+ _clienteActivo = null;
+ _emitir(
+ EstadoGrabacionRadio(
+ tipo: EstadoGrabacionRadioTipo.error,
+ error: error.toString(),
+ ),
+ );
+ }
+
+ void _emitir(EstadoGrabacionRadio estado) {
+ _estado = estado;
+ if (!_estadoController.isClosed) {
+ _estadoController.add(estado);
+ }
+ }
+
+ String _nombreArchivo(
+ Emisora emisora,
+ DateTime inicio,
+ Map headers,
+ ) {
+ final fecha = inicio
+ .toIso8601String()
+ .replaceAll(':', '-')
+ .replaceAll('.', '-');
+ final nombre = _slug(emisora.nombre);
+ final extension = _extension(emisora.codec, headers['content-type']);
+ return '$fecha-$nombre.$extension';
+ }
+
+ String _extension(String? codec, String? contentType) {
+ final c = codec?.toLowerCase().trim();
+ if (c == 'mp3' || c == 'mpeg') return 'mp3';
+ if (c == 'aac' || c == 'aac+' || c == 'heaac') return 'aac';
+ if (c == 'ogg' || c == 'opus') return 'ogg';
+ if (c == 'flac') return 'flac';
+
+ final type = contentType?.toLowerCase() ?? '';
+ if (type.contains('mpeg') || type.contains('mp3')) return 'mp3';
+ if (type.contains('aac')) return 'aac';
+ if (type.contains('ogg') || type.contains('opus')) return 'ogg';
+ if (type.contains('flac')) return 'flac';
+ return 'mp3';
+ }
+
+ String _slug(String value) {
+ final slug = value
+ .toLowerCase()
+ .replaceAll(RegExp(r'[^a-z0-9áéíóúüñ]+', unicode: true), '-')
+ .replaceAll(RegExp('-+'), '-')
+ .replaceAll(RegExp(r'^-|-$'), '');
+ return slug.isEmpty ? 'radio' : slug;
+ }
+
+ Future dispose() async {
+ _timerAutoStop?.cancel();
+ _clienteActivo?.close();
+ await _subscripcionStream?.cancel();
+ await _sink?.close();
+ await _estadoController.close();
+ }
+}
diff --git a/lib/servicios/servicio_radio.dart b/lib/servicios/servicio_radio.dart
index 1445f7e..7b1869c 100644
--- a/lib/servicios/servicio_radio.dart
+++ b/lib/servicios/servicio_radio.dart
@@ -26,13 +26,14 @@ class ServicioRadio {
int maxIntentos = _maxIntentosPorDefecto,
Duration retryDelay = _retryDelayPorDefecto,
Duration timeout = _timeoutPorDefecto,
- }) : _cliente = cliente ?? http.Client(),
- _servidores = (servidores == null || servidores.isEmpty)
- ? List.from(_servidoresFallback)
- : List.from(servidores),
- _maxIntentos = maxIntentos < 1 ? 1 : maxIntentos,
- _retryDelay = retryDelay,
- _timeout = timeout;
+ }) : _cliente = cliente ?? http.Client(),
+ _servidores =
+ (servidores == null || servidores.isEmpty)
+ ? List.from(_servidoresFallback)
+ : List.from(servidores),
+ _maxIntentos = maxIntentos < 1 ? 1 : maxIntentos,
+ _retryDelay = retryDelay,
+ _timeout = timeout;
final http.Client _cliente;
final List _servidores;
@@ -56,10 +57,7 @@ class ServicioRadio {
}
Uri _uri(String servidor, String path, Map params) {
- return Uri.https(servidor, path, {
- 'hidebroken': 'true',
- ...params,
- });
+ return Uri.https(servidor, path, {'hidebroken': 'true', ...params});
}
Future> _get(String path, Map params) async {
@@ -69,15 +67,17 @@ class ServicioRadio {
for (int intento = 0; intento < totalIntentos; intento++) {
final servidor = _servidorPorIntento(indiceBase, intento);
- final uri = _uri(servidor, path, {
- 'lastcheckok': '1',
- ...params,
- });
+ final uri = _uri(servidor, path, {'lastcheckok': '1', ...params});
try {
- final resp = await _cliente.get(uri, headers: {
- 'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)',
- }).timeout(_timeout);
+ final resp = await _cliente
+ .get(
+ uri,
+ headers: {
+ 'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)',
+ },
+ )
+ .timeout(_timeout);
if (resp.statusCode != 200) {
throw Exception('API error ${resp.statusCode}');
@@ -85,11 +85,14 @@ class ServicioRadio {
final lista = json.decode(resp.body) as List;
_servidorActual = servidor;
- return lista
- .cast