From a6a91af402070f1e63f5877ff15eb248c951353e Mon Sep 17 00:00:00 2001 From: freetlab Date: Thu, 21 May 2026 21:17:51 +0200 Subject: [PATCH] feat(player): add radio recording and real waveform --- android/app/src/main/AndroidManifest.xml | 1 + .../es/freetimelab/pluriwave/MainActivity.kt | 151 +++++- docs/audio-recording-visualizer-notes.md | 32 ++ lib/estado/estado_radio.dart | 93 +++- lib/pantallas/pantalla_ajustes.dart | 96 +++- lib/pantallas/pantalla_reproductor.dart | 480 ++++++++++++++---- lib/servicios/servicio_audio.dart | 100 ++-- lib/servicios/servicio_grabacion_radio.dart | 313 ++++++++++++ lib/servicios/servicio_radio.dart | 118 +++-- lib/widgets/visualizador_audio.dart | 211 +++++--- .../servicio_grabacion_radio_test.dart | 95 ++++ test/servicios/servicio_radio_test.dart | 114 +++-- 12 files changed, 1518 insertions(+), 286 deletions(-) create mode 100644 docs/audio-recording-visualizer-notes.md create mode 100644 lib/servicios/servicio_grabacion_radio.dart create mode 100644 test/servicios/servicio_grabacion_radio_test.dart 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>() - .map(Emisora.fromApi) - .where((e) => e.uuid.isNotEmpty && e.url.isNotEmpty) - .toList(); + final emisoras = + lista + .cast>() + .map(Emisora.fromApi) + .where((e) => e.uuid.isNotEmpty && e.url.isNotEmpty) + .toList(); + emisoras.sort(_compararCalidad); + return emisoras; } on Exception catch (e) { ultimoError = e; _servidorActual = null; @@ -105,57 +108,78 @@ class ServicioRadio { } /// Emisoras más votadas globalmente. - Future> obtenerPopulares({int limit = 30, int offset = 0}) async { + Future> obtenerPopulares({ + int limit = 30, + int offset = 0, + }) async { return _get('/json/stations/search', { 'limit': limit.toString(), 'offset': offset.toString(), - 'order': 'votes', + 'order': 'bitrate', 'reverse': 'true', }); } /// Emisoras más escuchadas (por clicks) globalmente. Future> obtenerTendencias({int limit = 20}) async { - return _get('/json/stations/topclick/$limit', {}); + final emisoras = await _get('/json/stations/topclick/$limit', {}); + emisoras.sort(_compararCalidad); + return emisoras; } /// Buscar por nombre de emisora. - Future> buscarPorNombre(String query, {int limit = 30, int offset = 0}) async { + Future> buscarPorNombre( + String query, { + int limit = 30, + int offset = 0, + }) async { return _get('/json/stations/search', { 'name': query, 'limit': limit.toString(), 'offset': offset.toString(), - 'order': 'votes', + 'order': 'bitrate', 'reverse': 'true', }); } /// Buscar por código de país (ISO 3166-1 alpha-2, e.g. 'ES', 'US'). - Future> buscarPorPais(String codigoPais, {int limit = 50, int offset = 0}) async { + Future> buscarPorPais( + String codigoPais, { + int limit = 50, + int offset = 0, + }) async { return _get('/json/stations/bycountrycodeexact/$codigoPais', { 'limit': limit.toString(), 'offset': offset.toString(), - 'order': 'votes', + 'order': 'bitrate', 'reverse': 'true', }); } /// Buscar por idioma (e.g. 'spanish', 'english'). - Future> buscarPorIdioma(String idioma, {int limit = 30, int offset = 0}) async { + Future> buscarPorIdioma( + String idioma, { + int limit = 30, + int offset = 0, + }) async { return _get('/json/stations/bylanguageexact/$idioma', { 'limit': limit.toString(), 'offset': offset.toString(), - 'order': 'votes', + 'order': 'bitrate', 'reverse': 'true', }); } /// Buscar por tag/género (e.g. 'rock', 'jazz', 'pop'). - Future> buscarPorTag(String tag, {int limit = 30, int offset = 0}) async { + Future> buscarPorTag( + String tag, { + int limit = 30, + int offset = 0, + }) async { return _get('/json/stations/bytagexact/$tag', { 'limit': limit.toString(), 'offset': offset.toString(), - 'order': 'votes', + 'order': 'bitrate', 'reverse': 'true', }); } @@ -176,20 +200,36 @@ class ServicioRadio { if (tag != null && tag.isNotEmpty) 'tag': tag, 'limit': limit.toString(), 'offset': offset.toString(), - 'order': 'votes', + 'order': 'bitrate', 'reverse': 'true', }); } + int _compararCalidad(Emisora a, Emisora b) { + final bitrateA = a.bitrate ?? 0; + final bitrateB = b.bitrate ?? 0; + final porBitrate = bitrateB.compareTo(bitrateA); + if (porBitrate != 0) return porBitrate; + + final porClicks = b.clickcount.compareTo(a.clickcount); + if (porClicks != 0) return porClicks; + + return b.votes.compareTo(a.votes); + } + /// Registrar un click en la API (best effort). Future registrarClick(String uuid) async { try { final servidor = _servidorActual ?? _servidorPorIntento(_indiceServidorInicial(), 0); - await _cliente.get( - Uri.https(servidor, '/json/url/$uuid'), - headers: {'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)'}, - ).timeout(_timeout); + await _cliente + .get( + Uri.https(servidor, '/json/url/$uuid'), + headers: { + 'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)', + }, + ) + .timeout(_timeout); } catch (_) { // No crítico, ignorar. } diff --git a/lib/widgets/visualizador_audio.dart b/lib/widgets/visualizador_audio.dart index b360bbb..f3b3943 100644 --- a/lib/widgets/visualizador_audio.dart +++ b/lib/widgets/visualizador_audio.dart @@ -1,31 +1,19 @@ import 'dart:async'; import 'dart:math'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import '../servicios/servicio_audio.dart'; -/// Visualizador de audio animado para la pantalla del reproductor. +/// Visualizador de audio para el reproductor. /// -/// Muestra barras verticales que se animan con movimiento pseudo-aleatorio -/// basado en ruido suavizado mientras la radio está reproduciéndose. -/// Cuando está pausado/detenido, las barras se aplanan suavemente. -/// -/// ### Implementación -/// No usa FFT real (requeriría captura de micrófono con permisos). -/// En cambio, usa un generador de movimiento orgánico con interpolación -/// suavizada — el resultado visual es similar al de apps de streaming como -/// Spotify o Apple Music en sus visualizadores de "en reproducción". -/// -/// ### Uso -/// ```dart -/// VisualizadorAudio( -/// estadoStream: estado.estadoStream, -/// barras: 24, -/// color: theme.colorScheme.primary, -/// altura: 60, -/// ) -/// ``` +/// En Android intenta capturar la forma de onda real del audio mediante un +/// canal nativo. Si el dispositivo o los permisos no lo permiten, mantiene un +/// fallback animado para que la interfaz nunca quede rota. class VisualizadorAudio extends StatefulWidget { final Stream estadoStream; + final Stream? androidAudioSessionIdStream; final int barras; final Color? color; final double altura; @@ -34,6 +22,7 @@ class VisualizadorAudio extends StatefulWidget { const VisualizadorAudio({ super.key, required this.estadoStream, + this.androidAudioSessionIdStream, this.barras = 20, this.color, this.altura = 48, @@ -46,9 +35,15 @@ class VisualizadorAudio extends StatefulWidget { class _VisualizadorAudioState extends State with SingleTickerProviderStateMixin { - late AnimationController _controller; + static const _eventChannel = EventChannel('pluriwave/audio_visualizer'); + + late final AnimationController _controller; bool _activo = false; + int? _sessionId; + List _ondaReal = const []; StreamSubscription? _estadoSubscription; + StreamSubscription? _sessionSubscription; + StreamSubscription? _ondaSubscription; @override void initState() { @@ -57,23 +52,78 @@ class _VisualizadorAudioState extends State vsync: this, duration: const Duration(seconds: 2), )..addListener(() { - if (mounted) setState(() {}); - }); + if (mounted) setState(() {}); + }); _estadoSubscription = widget.estadoStream.listen(_onEstado); + _sessionSubscription = widget.androidAudioSessionIdStream?.listen( + _onSessionId, + ); } void _onEstado(EstadoReproduccion estado) { - final nuevoActivo = estado == EstadoReproduccion.reproduciendo || + final nuevoActivo = + estado == EstadoReproduccion.reproduciendo || estado == EstadoReproduccion.cargando; - if (nuevoActivo == _activo) return; if (!mounted) return; - setState(() => _activo = nuevoActivo); - nuevoActivo ? _controller.repeat() : _controller.stop(); + if (nuevoActivo != _activo) { + setState(() => _activo = nuevoActivo); + nuevoActivo ? _controller.repeat() : _controller.stop(); + } + _sincronizarOndaReal(); + } + + void _onSessionId(int? sessionId) { + if (!mounted || sessionId == _sessionId) return; + _sessionId = sessionId; + unawaited(_ondaSubscription?.cancel()); + _ondaSubscription = null; + _sincronizarOndaReal(); + } + + void _sincronizarOndaReal() { + final sessionId = _sessionId; + final puedeCapturar = sessionId != null && sessionId > 0 && _activo; + + if (!puedeCapturar) { + unawaited(_ondaSubscription?.cancel()); + _ondaSubscription = null; + if (_ondaReal.isNotEmpty && mounted) { + setState(() => _ondaReal = const []); + } + return; + } + + if (_ondaSubscription != null) return; + _ondaSubscription = _eventChannel + .receiveBroadcastStream({ + 'sessionId': sessionId, + 'bands': widget.barras, + }) + .listen( + (event) { + if (!mounted || event is! List) return; + final muestras = event + .whereType() + .map((v) => v.toDouble().clamp(0.0, 1.0)) + .toList(growable: false); + if (muestras.isNotEmpty) { + setState(() => _ondaReal = muestras); + } + }, + onError: (_) { + unawaited(_ondaSubscription?.cancel()); + _ondaSubscription = null; + if (mounted) setState(() => _ondaReal = const []); + }, + cancelOnError: false, + ); } @override void dispose() { _estadoSubscription?.cancel(); + _sessionSubscription?.cancel(); + _ondaSubscription?.cancel(); _controller.dispose(); super.dispose(); } @@ -84,12 +134,14 @@ class _VisualizadorAudioState extends State final t = _controller.value * pi * 2; return SizedBox( height: widget.altura, + width: widget.anchuraTotal, child: RepaintBoundary( child: CustomPaint( painter: _WaveFlowPainter( color: color, phase: t, active: _activo, + waveform: _ondaReal, ), child: const SizedBox.expand(), ), @@ -103,51 +155,62 @@ class _WaveFlowPainter extends CustomPainter { required this.color, required this.phase, required this.active, + required this.waveform, }); final Color color; final double phase; final bool active; + final List waveform; @override void paint(Canvas canvas, Size size) { final center = size.height / 2; final amp = active ? size.height * 0.24 : size.height * 0.06; - final glowPaint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = active ? 8 : 4 - ..strokeCap = StrokeCap.round - ..color = color.withValues(alpha: active ? 0.18 : 0.08); - final linePaint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 3 - ..strokeCap = StrokeCap.round - ..shader = LinearGradient( - colors: [ - color.withValues(alpha: 0.08), - color.withValues(alpha: active ? 0.95 : 0.35), - const Color(0xFFF4B860).withValues(alpha: active ? 0.75 : 0.20), - color.withValues(alpha: 0.08), - ], - ).createShader(Offset.zero & size); + final glowPaint = + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = active ? 8 : 4 + ..strokeCap = StrokeCap.round + ..color = color.withValues(alpha: active ? 0.18 : 0.08); + final linePaint = + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round + ..shader = LinearGradient( + colors: [ + color.withValues(alpha: 0.08), + color.withValues(alpha: active ? 0.95 : 0.35), + const Color(0xFFF4B860).withValues(alpha: active ? 0.75 : 0.20), + color.withValues(alpha: 0.08), + ], + ).createShader(Offset.zero & size); - Path pathFor(double shift, double amplitude) { + Path pathFor(double shift, double amplitude, {bool real = false}) { final path = Path()..moveTo(0, center); for (double x = 0; x <= size.width; x += 8) { final p = x / size.width; - final y = center + - sin((p * pi * 2.4) + phase + shift) * amplitude + - sin((p * pi * 5.2) - phase * 0.7 + shift) * amplitude * 0.32; + final muestra = real ? _muestraReal(p) : null; + final sintetica = + sin((p * pi * 2.4) + phase + shift) + + sin((p * pi * 5.2) - phase * 0.7 + shift) * 0.32; + final forma = muestra == null ? sintetica : (muestra * 2.0) - 1.0; + final y = center + forma * amplitude; path.lineTo(x, y); } return path; } - canvas.drawPath(pathFor(0, amp), glowPaint); - canvas.drawPath(pathFor(0.6, amp * 0.62), glowPaint..color = color.withValues(alpha: active ? 0.10 : 0.05)); - canvas.drawPath(pathFor(0, amp), linePaint); + final usaReal = waveform.isNotEmpty; + canvas.drawPath(pathFor(0, amp, real: usaReal), glowPaint); canvas.drawPath( - pathFor(0.9, amp * 0.58), + pathFor(0.6, amp * 0.62, real: usaReal), + glowPaint..color = color.withValues(alpha: active ? 0.10 : 0.05), + ); + canvas.drawPath(pathFor(0, amp, real: usaReal), linePaint); + canvas.drawPath( + pathFor(0.9, amp * 0.58, real: usaReal), Paint() ..style = PaintingStyle.stroke ..strokeWidth = 2 @@ -156,16 +219,25 @@ class _WaveFlowPainter extends CustomPainter { ); } + double? _muestraReal(double p) { + if (waveform.isEmpty) return null; + final index = (p * (waveform.length - 1)).round().clamp( + 0, + waveform.length - 1, + ); + return waveform[index]; + } + @override bool shouldRepaint(covariant _WaveFlowPainter oldDelegate) { return oldDelegate.phase != phase || oldDelegate.active != active || - oldDelegate.color != color; + oldDelegate.color != color || + oldDelegate.waveform != waveform; } } -/// Versión compacta del visualizador — 5 barras, para uso en MiniReproductor -/// o indicadores pequeños de "en reproducción". +/// Versión compacta del visualizador para el MiniReproductor. class IndicadorReproduccion extends StatefulWidget { final Stream estadoStream; final Color? color; @@ -191,12 +263,15 @@ class _IndicadorReproduccionState extends State @override void initState() { super.initState(); - _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 600)) - ..addListener(() => setState(() {})); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + )..addListener(() { + if (mounted) setState(() {}); + }); _estadoSubscription = widget.estadoStream.listen((s) { final rep = s == EstadoReproduccion.reproduciendo; - if (rep == _reproduciendo) return; - if (!mounted) return; + if (rep == _reproduciendo || !mounted) return; setState(() => _reproduciendo = rep); rep ? _ctrl.repeat(reverse: true) : _ctrl.stop(); }); @@ -211,11 +286,13 @@ class _IndicadorReproduccionState extends State @override Widget build(BuildContext context) { - final color = widget.color ?? - Theme.of(context).colorScheme.primary; + final color = widget.color ?? Theme.of(context).colorScheme.primary; if (!_reproduciendo) { - return Icon(Icons.radio, size: widget.size, - color: Theme.of(context).colorScheme.onSurfaceVariant); + return Icon( + Icons.radio, + size: widget.size, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ); } return Row( mainAxisSize: MainAxisSize.min, @@ -223,8 +300,12 @@ class _IndicadorReproduccionState extends State children: List.generate(3, (i) { final alts = [0.5, 1.0, 0.7]; final fases = [0.0, 0.3, 0.6]; - final h = ((sin(_ctrl.value * pi + fases[i]) + 1) / 2 * alts[i] + 0.2) - .clamp(0.15, 1.0) * widget.size; + final h = + ((sin(_ctrl.value * pi + fases[i]) + 1) / 2 * alts[i] + 0.2).clamp( + 0.15, + 1.0, + ) * + widget.size; return Container( width: widget.size * 0.2, height: h, diff --git a/test/servicios/servicio_grabacion_radio_test.dart b/test/servicios/servicio_grabacion_radio_test.dart new file mode 100644 index 0000000..abba18a --- /dev/null +++ b/test/servicios/servicio_grabacion_radio_test.dart @@ -0,0 +1,95 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/servicios/servicio_grabacion_radio.dart'; + +void main() { + group('ServicioGrabacionRadio', () { + test( + 'guarda el stream original en disco con extensión por codec', + () async { + final dir = await Directory.systemTemp.createTemp('pluriwave-rec-'); + final servicio = ServicioGrabacionRadio( + cliente: MockClient((request) async { + return http.Response.bytes( + [1, 2, 3, 4, 5], + 200, + headers: {'content-type': 'audio/mpeg'}, + ); + }), + resolverDirectorioBase: () async => dir, + reloj: () => DateTime(2026, 5, 21, 18, 30), + ); + + await servicio.iniciar( + const Emisora( + uuid: 'r1', + nombre: 'Radio Prueba', + url: 'https://stream.example/radio', + codec: 'MP3', + ), + ); + await Future.delayed(Duration.zero); + + final carpeta = Directory( + '${dir.path}${Platform.pathSeparator}grabaciones', + ); + final archivos = await carpeta.list().where((e) => e is File).toList(); + expect(archivos, hasLength(1)); + expect(archivos.single.path, endsWith('.mp3')); + expect(await File(archivos.single.path).readAsBytes(), [1, 2, 3, 4, 5]); + expect(servicio.estado.tipo, EstadoGrabacionRadioTipo.inactiva); + + await servicio.dispose(); + }, + ); + + test('detiene una grabación activa bajo demanda', () async { + final dir = await Directory.systemTemp.createTemp('pluriwave-rec-stop-'); + final controller = StreamController>(); + final servicio = ServicioGrabacionRadio( + cliente: _StreamClient(controller.stream), + resolverDirectorioBase: () async => dir, + ); + + await servicio.iniciar( + const Emisora( + uuid: 'r2', + nombre: 'Radio Larga', + url: 'https://stream.example/live', + codec: 'aac', + ), + ); + controller.add([10, 20, 30]); + await Future.delayed(Duration.zero); + + expect(servicio.estado.activa, isTrue); + expect(servicio.estado.bytes, 3); + + await servicio.detener(); + + expect(servicio.estado.tipo, EstadoGrabacionRadioTipo.inactiva); + await controller.close(); + await servicio.dispose(); + }); + }); +} + +class _StreamClient extends http.BaseClient { + _StreamClient(this.stream); + + final Stream> stream; + + @override + Future send(http.BaseRequest request) async { + return http.StreamedResponse( + stream, + 200, + headers: {'content-type': 'audio/aac'}, + ); + } +} diff --git a/test/servicios/servicio_radio_test.dart b/test/servicios/servicio_radio_test.dart index 950647c..14d6a0e 100644 --- a/test/servicios/servicio_radio_test.dart +++ b/test/servicios/servicio_radio_test.dart @@ -8,47 +8,48 @@ import 'package:pluriwave/servicios/servicio_radio.dart'; void main() { group('ServicioRadio retry + rotación', () { test( - 'reintenta con otro host cuando el primero falla y recupera en el segundo', - () async { - final hostsSolicitados = []; - final servicio = ServicioRadio( - cliente: MockClient((request) async { - hostsSolicitados.add(request.url.host); - if (request.url.host == 'host-1.api.radio-browser.info') { - return http.Response('fallo', 500); - } - return http.Response( - jsonEncode([ - { - 'stationuuid': 'uuid-ok', - 'name': 'Radio Recuperada', - 'url_resolved': 'https://stream.recuperada/audio', - }, - ]), - 200, - headers: {'content-type': 'application/json'}, - ); - }), - servidores: const [ - 'host-1.api.radio-browser.info', - 'host-2.api.radio-browser.info', - ], - maxIntentos: 3, - retryDelay: Duration.zero, - ); + 'reintenta con otro host cuando el primero falla y recupera en el segundo', + () async { + final hostsSolicitados = []; + final servicio = ServicioRadio( + cliente: MockClient((request) async { + hostsSolicitados.add(request.url.host); + if (request.url.host == 'host-1.api.radio-browser.info') { + return http.Response('fallo', 500); + } + return http.Response( + jsonEncode([ + { + 'stationuuid': 'uuid-ok', + 'name': 'Radio Recuperada', + 'url_resolved': 'https://stream.recuperada/audio', + }, + ]), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + servidores: const [ + 'host-1.api.radio-browser.info', + 'host-2.api.radio-browser.info', + ], + maxIntentos: 3, + retryDelay: Duration.zero, + ); - final emisoras = await servicio.obtenerPopulares(limit: 1); + final emisoras = await servicio.obtenerPopulares(limit: 1); - expect(emisoras, hasLength(1)); - expect(emisoras.first.uuid, 'uuid-ok'); - expect( - hostsSolicitados, - equals([ - 'host-1.api.radio-browser.info', - 'host-2.api.radio-browser.info', - ]), - ); - }); + expect(emisoras, hasLength(1)); + expect(emisoras.first.uuid, 'uuid-ok'); + expect( + hostsSolicitados, + equals([ + 'host-1.api.radio-browser.info', + 'host-2.api.radio-browser.info', + ]), + ); + }, + ); test('corta al llegar al tope de intentos y propaga error final', () async { var intentos = 0; @@ -68,5 +69,40 @@ void main() { ); expect(intentos, 2); }); + + test('prioriza emisoras verificadas de mayor bitrate', () async { + final servicio = ServicioRadio( + cliente: MockClient((request) async { + expect(request.url.queryParameters['order'], 'bitrate'); + expect(request.url.queryParameters['reverse'], 'true'); + return http.Response( + jsonEncode([ + { + 'stationuuid': 'baja', + 'name': 'Baja', + 'url_resolved': 'https://stream.example/low', + 'bitrate': 64, + 'votes': 999, + }, + { + 'stationuuid': 'alta', + 'name': 'Alta', + 'url_resolved': 'https://stream.example/high', + 'bitrate': 320, + 'votes': 1, + }, + ]), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + servidores: const ['host.api.radio-browser.info'], + retryDelay: Duration.zero, + ); + + final emisoras = await servicio.buscar(nombre: 'radio'); + + expect(emisoras.map((e) => e.uuid), equals(['alta', 'baja'])); + }); }); }