feat(player): add radio recording and real waveform
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m27s

This commit is contained in:
2026-05-21 21:17:51 +02:00
parent 6aa9a59d7b
commit a6a91af402
12 changed files with 1518 additions and 286 deletions
+1
View File
@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
@@ -1,5 +1,154 @@
package es.freetimelab.pluriwave 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 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<Double> {
if (data.isEmpty()) return emptyList()
val bucket = maxOf(1, data.size / bands)
val values = ArrayList<Double>(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<out String>,
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()
}
}
+32
View File
@@ -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
+77 -16
View File
@@ -12,6 +12,7 @@ import '../modelos/preset_ecualizador.dart';
import '../servicios/servicio_audio.dart'; import '../servicios/servicio_audio.dart';
import '../servicios/servicio_ecualizador.dart'; import '../servicios/servicio_ecualizador.dart';
import '../servicios/servicio_favoritos.dart'; import '../servicios/servicio_favoritos.dart';
import '../servicios/servicio_grabacion_radio.dart';
import '../servicios/servicio_radio.dart'; import '../servicios/servicio_radio.dart';
import '../servicios/servicio_timer.dart'; import '../servicios/servicio_timer.dart';
@@ -22,15 +23,18 @@ class EstadoRadio extends ChangeNotifier {
ServicioFavoritos? favoritos, ServicioFavoritos? favoritos,
ServicioRadio? radio, ServicioRadio? radio,
ServicioEcualizador? servicioEcualizador, ServicioEcualizador? servicioEcualizador,
ServicioGrabacionRadio? servicioGrabacion,
Future<File> Function()? resolverArchivoCustom, Future<File> Function()? resolverArchivoCustom,
bool iniciarAutomaticamente = true, bool iniciarAutomaticamente = true,
}) : audio = audio ?? ServicioAudio(), }) : audio = audio ?? ServicioAudio(),
favoritos = favoritos ?? ServicioFavoritos(), favoritos = favoritos ?? ServicioFavoritos(),
radio = radio ?? ServicioRadio(), radio = radio ?? ServicioRadio(),
servicioEcualizador = servicioEcualizador ?? ServicioEcualizador(), servicioEcualizador = servicioEcualizador ?? ServicioEcualizador(),
_resolverArchivoCustom = resolverArchivoCustom { grabacion = servicioGrabacion ?? ServicioGrabacionRadio(),
_resolverArchivoCustom = resolverArchivoCustom {
timer = ServicioTimer(this.audio); timer = ServicioTimer(this.audio);
_escucharErroresReproduccion(); _escucharErroresReproduccion();
_escucharGrabacion();
if (iniciarAutomaticamente) { if (iniciarAutomaticamente) {
_initFuture = _init(); _initFuture = _init();
} }
@@ -40,10 +44,12 @@ class EstadoRadio extends ChangeNotifier {
final ServicioFavoritos favoritos; final ServicioFavoritos favoritos;
final ServicioRadio radio; final ServicioRadio radio;
final ServicioEcualizador servicioEcualizador; final ServicioEcualizador servicioEcualizador;
final ServicioGrabacionRadio grabacion;
final Future<File> Function()? _resolverArchivoCustom; final Future<File> Function()? _resolverArchivoCustom;
late final ServicioTimer timer; late final ServicioTimer timer;
StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudio; StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudio;
StreamSubscription<EstadoGrabacionRadio>? _suscripcionGrabacion;
Future<void>? _initFuture; Future<void>? _initFuture;
int _revisionReproduccion = 0; int _revisionReproduccion = 0;
Emisora? _emisoraSeleccionada; Emisora? _emisoraSeleccionada;
@@ -110,6 +116,10 @@ class EstadoRadio extends ChangeNotifier {
return tienePresetEcualizadorPorEmisora(actual.uuid); 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. /// Lista principal (home): custom + populares, sin duplicados.
List<Emisora> get emisorasInicio { List<Emisora> get emisorasInicio {
final mapa = <String, Emisora>{}; final mapa = <String, Emisora>{};
@@ -128,6 +138,7 @@ class EstadoRadio extends ChangeNotifier {
} }
Future<void> _init() async { Future<void> _init() async {
await grabacion.inicializar();
await _cargarEcualizadorPersistido(); await _cargarEcualizadorPersistido();
await Future.wait([ await Future.wait([
cargarPopulares(), 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<void> _cargarEcualizadorPersistido() async { Future<void> _cargarEcualizadorPersistido() async {
try { try {
final config = await servicioEcualizador.cargar(); final config = await servicioEcualizador.cargar();
@@ -296,7 +317,8 @@ class EstadoRadio extends ChangeNotifier {
_paisCercanoDetectado = pais; _paisCercanoDetectado = pais;
_emisorasCercanas = await radio.buscar(pais: pais, limit: 30); _emisorasCercanas = await radio.buscar(pais: pais, limit: 30);
} catch (_) { } catch (_) {
_errorCercanas = 'No pudimos detectar emisoras cercanas. Usa filtros por pais.'; _errorCercanas =
'No pudimos detectar emisoras cercanas. Usa filtros por pais.';
_emisorasCercanas = []; _emisorasCercanas = [];
} finally { } finally {
_cargandoCercanas = false; _cargandoCercanas = false;
@@ -331,6 +353,34 @@ class EstadoRadio extends ChangeNotifier {
} }
} }
Future<void> 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<void> detenerGrabacion() => grabacion.detener();
Future<void> cambiarDirectorioGrabacion(String path) async {
await grabacion.guardarDirectorio(path);
notifyListeners();
}
Future<void> restaurarDirectorioGrabacion() async {
await grabacion.limpiarDirectorioConfigurado();
notifyListeners();
}
Future<String> directorioGrabacionEfectivo() =>
grabacion.directorioEfectivo();
Future<void> togglePlay() async { Future<void> togglePlay() async {
await audio.togglePlay(); await audio.togglePlay();
notifyListeners(); notifyListeners();
@@ -339,7 +389,10 @@ class EstadoRadio extends ChangeNotifier {
Future<bool> toggleFavorito(Emisora emisora) async { Future<bool> toggleFavorito(Emisora emisora) async {
final esFav = await favoritos.toggleFavorito(emisora); final esFav = await favoritos.toggleFavorito(emisora);
if (!esFav) { if (!esFav) {
await deshabilitarPresetEcualizadorPorEmisora(emisora.uuid, notificar: false); await deshabilitarPresetEcualizadorPorEmisora(
emisora.uuid,
notificar: false,
);
} }
await cargarFavoritos(); await cargarFavoritos();
return esFav; return esFav;
@@ -418,7 +471,9 @@ class EstadoRadio extends ChangeNotifier {
if (notificar) notifyListeners(); if (notificar) notifyListeners();
} }
Future<void> cambiarModoEcualizadorEmisoraActual({required bool usarPropio}) async { Future<void> cambiarModoEcualizadorEmisoraActual({
required bool usarPropio,
}) async {
final actual = emisoraActual; final actual = emisoraActual;
if (actual == null) return; if (actual == null) return;
if (usarPropio) { if (usarPropio) {
@@ -433,7 +488,8 @@ class EstadoRadio extends ChangeNotifier {
bool guardarPorEmisora = true, bool guardarPorEmisora = true,
}) async { }) async {
final actual = emisoraActual; final actual = emisoraActual;
final usarPresetPropio = guardarPorEmisora && final usarPresetPropio =
guardarPorEmisora &&
actual != null && actual != null &&
_presetsEmisoraMap.containsKey(actual.uuid); _presetsEmisoraMap.containsKey(actual.uuid);
@@ -476,9 +532,10 @@ class EstadoRadio extends ChangeNotifier {
} }
final data = jsonDecode(await archivo.readAsString()) as List; final data = jsonDecode(await archivo.readAsString()) as List;
_emisorasCustom = data _emisorasCustom =
.map((e) => Emisora.fromMap(Map<String, dynamic>.from(e as Map))) data
.toList(); .map((e) => Emisora.fromMap(Map<String, dynamic>.from(e as Map)))
.toList();
} catch (_) { } catch (_) {
_emisorasCustom = []; _emisorasCustom = [];
} }
@@ -510,7 +567,8 @@ class EstadoRadio extends ChangeNotifier {
} }
// Compatibilidad con el nombre histórico (typo original). // Compatibilidad con el nombre histórico (typo original).
Future<void> eliminarEmitoraCustom(String uuid) => eliminarEmisoraCustom(uuid); Future<void> eliminarEmitoraCustom(String uuid) =>
eliminarEmisoraCustom(uuid);
// ── Export / Import ─────────────────────────────────────────────────────── // ── Export / Import ───────────────────────────────────────────────────────
@@ -541,9 +599,10 @@ class EstadoRadio extends ChangeNotifier {
} }
final customRaw = data['emisorasCustom'] as List? ?? []; final customRaw = data['emisorasCustom'] as List? ?? [];
_emisorasCustom = customRaw _emisorasCustom =
.map((e) => Emisora.fromMap(Map<String, dynamic>.from(e as Map))) customRaw
.toList(); .map((e) => Emisora.fromMap(Map<String, dynamic>.from(e as Map)))
.toList();
await _guardarEmisorasCustom(); await _guardarEmisorasCustom();
final principalRaw = data['presetPrincipalEcualizador']; final principalRaw = data['presetPrincipalEcualizador'];
@@ -600,8 +659,10 @@ class EstadoRadio extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
_suscripcionEstadoAudio?.cancel(); _suscripcionEstadoAudio?.cancel();
_suscripcionGrabacion?.cancel();
_errorController.close(); _errorController.close();
audio.dispose(); audio.dispose();
unawaited(grabacion.dispose());
timer.dispose(); timer.dispose();
super.dispose(); super.dispose();
} }
+95 -1
View File
@@ -25,7 +25,8 @@ class PantallaAjustes extends StatelessWidget {
children: const [ children: const [
PluriScreenHeader( PluriScreenHeader(
title: 'Ajustes', 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, glyph: PluriIconGlyph.settings,
trailing: PluriStatusPill( trailing: PluriStatusPill(
icon: Icons.security_rounded, icon: Icons.security_rounded,
@@ -50,6 +51,8 @@ class _AjustesContent extends StatelessWidget {
children: const [ children: const [
_SeccionEcualizador(), _SeccionEcualizador(),
SizedBox(height: 12), SizedBox(height: 12),
_SeccionGrabaciones(),
SizedBox(height: 12),
_SeccionEmisoras(), _SeccionEmisoras(),
SizedBox(height: 12), SizedBox(height: 12),
_SeccionBackup(), _SeccionBackup(),
@@ -60,6 +63,97 @@ class _AjustesContent extends StatelessWidget {
} }
} }
class _SeccionGrabaciones extends StatelessWidget {
const _SeccionGrabaciones();
Future<void> _seleccionarRuta(BuildContext context) async {
final estado = context.read<EstadoRadio>();
final messenger = ScaffoldMessenger.of(context);
final ruta = await FilePicker.platform.getDirectoryPath(
dialogTitle: 'Selecciona la carpeta de grabaciones',
);
if (ruta == null) return;
try {
await estado.cambiarDirectorioGrabacion(ruta);
messenger.showSnackBar(
const SnackBar(content: Text('Ruta de grabación actualizada')),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('No se pudo guardar la ruta: $e')),
);
}
}
Future<void> _restaurarRuta(BuildContext context) async {
final estado = context.read<EstadoRadio>();
final messenger = ScaffoldMessenger.of(context);
await estado.restaurarDirectorioGrabacion();
messenger.showSnackBar(
const SnackBar(content: Text('Se usará la carpeta interna por defecto')),
);
}
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>();
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.radio_button_checked),
const SizedBox(width: 12),
Text(
'Grabaciones',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
FutureBuilder<String>(
future: estado.directorioGrabacionEfectivo(),
builder:
(ctx, snap) => ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.folder_outlined),
title: const Text('Carpeta de grabación'),
subtitle: Text(
snap.data ?? 'Calculando ruta...',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onTap: () => _seleccionarRuta(context),
),
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.folder_open_rounded),
label: const Text('Cambiar ruta'),
onPressed: () => _seleccionarRuta(context),
),
),
const SizedBox(width: 8),
IconButton.filledTonal(
tooltip: 'Usar ruta por defecto',
icon: const Icon(Icons.restore_rounded),
onPressed: () => _restaurarRuta(context),
),
],
),
const SizedBox(height: 8),
Text(
'La radio se guarda desde el stream original, sin recomprimir.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}
class _SeccionEcualizador extends StatelessWidget { class _SeccionEcualizador extends StatelessWidget {
const _SeccionEcualizador(); const _SeccionEcualizador();
+389 -91
View File
@@ -1,4 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -22,12 +22,18 @@ class PantallaReproductor extends StatefulWidget {
return Navigator.push( return Navigator.push(
context, context,
PageRouteBuilder( PageRouteBuilder(
pageBuilder: (_, animation, __) => PantallaReproductor(emisora: emisora), pageBuilder:
transitionsBuilder: (_, animation, __, child) => SlideTransition( (_, animation, __) => PantallaReproductor(emisora: emisora),
position: Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero) transitionsBuilder:
.animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)), (_, animation, __, child) => SlideTransition(
child: child, position: Tween<Offset>(
), begin: const Offset(0, 1),
end: Offset.zero,
).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
),
child: child,
),
transitionDuration: const Duration(milliseconds: 350), transitionDuration: const Duration(milliseconds: 350),
), ),
); );
@@ -44,7 +50,10 @@ class _PantallaReproductorState extends State<PantallaReproductor>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_pulseController = AnimationController(vsync: this, duration: const Duration(seconds: 2)); _pulseController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
_iniciarReproduccion(); _iniciarReproduccion();
} }
@@ -67,7 +76,9 @@ class _PantallaReproductorState extends State<PantallaReproductor>
final tokens = context.pluriTokens; final tokens = context.pluriTokens;
final estado = context.watch<EstadoRadio>(); final estado = context.watch<EstadoRadio>();
final emisoraActiva = estado.emisoraActual ?? widget.emisora; 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( return PluriWaveScaffold(
appBar: AppBar( appBar: AppBar(
@@ -81,7 +92,9 @@ class _PantallaReproductorState extends State<PantallaReproductor>
actions: [ actions: [
IconButton( IconButton(
icon: Icon( icon: Icon(
esFavorito ? Icons.favorite_rounded : Icons.favorite_outline_rounded, esFavorito
? Icons.favorite_rounded
: Icons.favorite_outline_rounded,
color: esFavorito ? theme.colorScheme.error : null, color: esFavorito ? theme.colorScheme.error : null,
), ),
tooltip: esFavorito ? 'Quitar de favoritos' : 'Anadir a favoritos', tooltip: esFavorito ? 'Quitar de favoritos' : 'Anadir a favoritos',
@@ -95,42 +108,60 @@ class _PantallaReproductorState extends State<PantallaReproductor>
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 8), const SizedBox(height: 8),
_WaveHero(emisora: emisoraActiva, estadoStream: estado.estadoStream) _WaveHero(
.animate() emisora: emisoraActiva,
.scale(begin: const Offset(0.86, 0.86), duration: 420.ms, curve: Curves.easeOutBack), estadoStream: estado.estadoStream,
).animate().scale(
begin: const Offset(0.86, 0.86),
duration: 420.ms,
curve: Curves.easeOutBack,
),
const SizedBox(height: 18), const SizedBox(height: 18),
Text( Text(
emisoraActiva.nombre, emisoraActiva.nombre,
style: theme.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700), style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
).animate().fadeIn(delay: 150.ms), ).animate().fadeIn(delay: 150.ms),
const SizedBox(height: 10), 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), const SizedBox(height: 6),
if (emisoraActiva.codec != null || emisoraActiva.bitrate != null) if (emisoraActiva.codec != null || emisoraActiva.bitrate != null)
Text( Text(
_codecInfo(emisoraActiva), _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), ).animate().fadeIn(delay: 250.ms),
const SizedBox(height: 14), const SizedBox(height: 14),
PluriGlassSurface( PluriGlassSurface(
borderRadius: BorderRadius.circular(tokens.radiusLg), borderRadius: BorderRadius.circular(tokens.radiusLg),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
child: VisualizadorAudio( child: VisualizadorAudio(
estadoStream: estado.estadoStream, estadoStream: estado.estadoStream,
androidAudioSessionIdStream:
estado.audio.androidAudioSessionIdStream,
barras: 26, barras: 26,
color: tokens.electricMagenta, color: tokens.warmCoral,
altura: 46, altura: 46,
), ),
).animate().fadeIn(delay: 280.ms), ).animate().fadeIn(delay: 280.ms),
const Spacer(), const Spacer(),
_Controles(estado: estado, emisora: emisoraActiva) _Controles(
.animate() estado: estado,
.fadeIn(delay: 300.ms) emisora: emisoraActiva,
.slideY(begin: 0.3), ).animate().fadeIn(delay: 300.ms).slideY(begin: 0.3),
const SizedBox(height: 24), const SizedBox(height: 14),
_GrabacionWidget(estado: estado).animate().fadeIn(delay: 360.ms),
const SizedBox(height: 14),
_TimerWidget(estado: estado).animate().fadeIn(delay: 400.ms), _TimerWidget(estado: estado).animate().fadeIn(delay: 400.ms),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
@@ -144,7 +175,9 @@ class _PantallaReproductorState extends State<PantallaReproductor>
final parts = <String>[]; final parts = <String>[];
if (e.codec != null) parts.add(e.codec!.toUpperCase()); if (e.codec != null) parts.add(e.codec!.toUpperCase());
if (e.bitrate != null && e.bitrate! > 0) parts.add('${e.bitrate} kbps'); 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, shape: BoxShape.circle,
gradient: RadialGradient( gradient: RadialGradient(
colors: [ 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), t.deepViolet.withValues(alpha: 0.0),
], ],
), ),
@@ -191,7 +226,9 @@ class _WaveHero extends StatelessWidget {
height: size + 12, height: size + 12,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: Colors.white.withValues(alpha: 0.16)), border: Border.all(
color: Colors.white.withValues(alpha: 0.16),
),
), ),
), ),
PluriGlassSurface( PluriGlassSurface(
@@ -204,7 +241,8 @@ class _WaveHero extends StatelessWidget {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
if (emisora.favicon != null && emisora.favicon!.isNotEmpty) if (emisora.favicon != null &&
emisora.favicon!.isNotEmpty)
CachedNetworkImage( CachedNetworkImage(
imageUrl: emisora.favicon!, imageUrl: emisora.favicon!,
fit: BoxFit.cover, fit: BoxFit.cover,
@@ -216,7 +254,11 @@ class _WaveHero extends StatelessWidget {
if (cargando) if (cargando)
Container( Container(
color: Colors.black45, color: Colors.black45,
child: const Center(child: CircularProgressIndicator(color: Colors.white)), child: const Center(
child: CircularProgressIndicator(
color: Colors.white,
),
),
), ),
if (hayError) if (hayError)
Container( Container(
@@ -249,7 +291,11 @@ class _WaveHero extends StatelessWidget {
Widget _iconoFallback(ThemeData theme) => Container( Widget _iconoFallback(ThemeData theme) => Container(
color: theme.colorScheme.primaryContainer, color: theme.colorScheme.primaryContainer,
child: Icon(Icons.radio_rounded, size: 80, color: theme.colorScheme.onPrimaryContainer), child: Icon(
Icons.radio_rounded,
size: 80,
color: theme.colorScheme.onPrimaryContainer,
),
); );
} }
@@ -263,27 +309,247 @@ class _InfoChips extends StatelessWidget {
final items = <String>[]; final items = <String>[];
if (emisora.pais != null) items.add(emisora.pais!); if (emisora.pais != null) items.add(emisora.pais!);
if (emisora.idioma != null) items.add(emisora.idioma!); 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(); if (items.isEmpty) return const SizedBox.shrink();
return Wrap( return Wrap(
spacing: 8, spacing: 8,
runSpacing: 6, runSpacing: 6,
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
children: items children:
.map( items
(label) => Chip( .map(
label: Text(label), (label) => Chip(
visualDensity: VisualDensity.compact, label: Text(label),
backgroundColor: theme.colorScheme.secondaryContainer.withValues(alpha: 0.8), visualDensity: VisualDensity.compact,
labelStyle: TextStyle(color: theme.colorScheme.onSecondaryContainer, fontSize: 12), backgroundColor: theme.colorScheme.secondaryContainer
padding: EdgeInsets.zero, .withValues(alpha: 0.8),
), labelStyle: TextStyle(
) color: theme.colorScheme.onSecondaryContainer,
.toList(), fontSize: 12,
),
padding: EdgeInsets.zero,
),
)
.toList(),
); );
} }
} }
class _GrabacionWidget extends StatelessWidget {
final EstadoRadio estado;
const _GrabacionWidget({required this.estado});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final grabacion = estado.estadoGrabacion;
final activa = grabacion.activa;
return PluriGlassSurface(
borderRadius: BorderRadius.circular(24),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
Icon(
activa
? Icons.fiber_manual_record_rounded
: Icons.radio_button_checked,
color: activa ? theme.colorScheme.error : theme.colorScheme.primary,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
activa ? 'Grabando radio' : 'Grabación directa',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
activa
? '${_formatearDuracion(grabacion.transcurrido)} · ${_formatearBytes(grabacion.bytes)}'
: 'Guarda el stream original, sin recomprimir.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
),
),
const SizedBox(width: 8),
FilledButton.tonalIcon(
icon: Icon(activa ? Icons.stop_rounded : Icons.mic_rounded),
label: Text(activa ? 'Parar' : 'Grabar'),
onPressed:
activa
? estado.detenerGrabacion
: () => _mostrarDialogoGrabacion(context),
),
],
),
);
}
void _mostrarDialogoGrabacion(BuildContext context) {
showModalBottomSheet(
context: context,
builder:
(ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Grabar radio',
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: 8),
const Text('Elige cuánto tiempo querés grabar.'),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ActionChip(
avatar: const Icon(
Icons.all_inclusive_rounded,
size: 18,
),
label: const Text('Indefinida'),
onPressed: () {
estado.iniciarGrabacion();
Navigator.pop(ctx);
},
),
for (final opcion in _opciones)
ActionChip(
label: Text(opcion.label),
onPressed: () {
estado.iniciarGrabacion(duracion: opcion.duracion);
Navigator.pop(ctx);
},
),
ActionChip(
avatar: const Icon(Icons.tune_rounded, size: 18),
label: const Text('Personalizada'),
onPressed: () {
Navigator.pop(ctx);
_mostrarDuracionPersonalizada(context);
},
),
],
),
],
),
),
),
);
}
Future<void> _mostrarDuracionPersonalizada(BuildContext context) async {
final minutosCtrl = TextEditingController();
final segundosCtrl = TextEditingController(text: '0');
final formKey = GlobalKey<FormState>();
await showDialog<void>(
context: context,
builder:
(ctx) => AlertDialog(
title: const Text('Duración de grabación'),
content: Form(
key: formKey,
child: Row(
children: [
Expanded(
child: TextFormField(
controller: minutosCtrl,
decoration: const InputDecoration(labelText: 'Minutos'),
keyboardType: TextInputType.number,
validator: _validarNumero,
),
),
const SizedBox(width: 12),
Expanded(
child: TextFormField(
controller: segundosCtrl,
decoration: const InputDecoration(labelText: 'Segundos'),
keyboardType: TextInputType.number,
validator: _validarNumero,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancelar'),
),
FilledButton(
onPressed: () {
if (!formKey.currentState!.validate()) return;
final minutos = int.tryParse(minutosCtrl.text.trim()) ?? 0;
final segundos = int.tryParse(segundosCtrl.text.trim()) ?? 0;
final duracion = Duration(
minutes: minutos,
seconds: segundos,
);
if (duracion <= Duration.zero) return;
estado.iniciarGrabacion(duracion: duracion);
Navigator.pop(ctx);
},
child: const Text('Grabar'),
),
],
),
);
minutosCtrl.dispose();
segundosCtrl.dispose();
}
String? _validarNumero(String? value) {
if (value == null || value.trim().isEmpty) return null;
final n = int.tryParse(value.trim());
if (n == null || n < 0) return 'Número inválido';
return null;
}
String _formatearDuracion(Duration d) {
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '${h}h ${m}m ${s}s' : '${m}m ${s}s';
}
String _formatearBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
class _OpcionGrabacion {
const _OpcionGrabacion(this.label, this.duracion);
final String label;
final Duration duracion;
}
const _opciones = [
_OpcionGrabacion('30 s', Duration(seconds: 30)),
_OpcionGrabacion('1 min', Duration(minutes: 1)),
_OpcionGrabacion('5 min', Duration(minutes: 5)),
_OpcionGrabacion('15 min', Duration(minutes: 15)),
_OpcionGrabacion('30 min', Duration(minutes: 30)),
];
class _Controles extends StatelessWidget { class _Controles extends StatelessWidget {
final EstadoRadio estado; final EstadoRadio estado;
final Emisora emisora; final Emisora emisora;
@@ -306,11 +572,17 @@ class _Controles extends StatelessWidget {
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(height: 8),
Text( Text(
'No se puede reproducir esta radio', '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, textAlign: TextAlign.center,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -335,7 +607,10 @@ class _Controles extends StatelessWidget {
child: IconButton( child: IconButton(
icon: const Icon(Icons.stop_rounded), icon: const Icon(Icons.stop_rounded),
iconSize: 34, iconSize: 34,
constraints: const BoxConstraints(minWidth: 56, minHeight: 56), constraints: const BoxConstraints(
minWidth: 56,
minHeight: 56,
),
color: Colors.white.withValues(alpha: 0.78), color: Colors.white.withValues(alpha: 0.78),
tooltip: 'Detener', tooltip: 'Detener',
onPressed: cargando ? null : () => estado.audio.detener(), onPressed: cargando ? null : () => estado.audio.detener(),
@@ -363,30 +638,41 @@ class _Controles extends StatelessWidget {
child: InkWell( child: InkWell(
customBorder: const CircleBorder(), customBorder: const CircleBorder(),
radius: 40, radius: 40,
onTap: cargando onTap:
? null cargando
: () { ? null
if (reproduciendo || s == EstadoReproduccion.pausado) { : () {
estado.togglePlay(); if (reproduciendo ||
} else { s == EstadoReproduccion.pausado) {
estado.reproducir(emisora); estado.togglePlay();
} } else {
}, estado.reproducir(emisora);
}
},
child: Semantics( child: Semantics(
button: true, button: true,
label: reproduciendo ? 'Pausar reproduccion' : 'Iniciar reproduccion', label:
reproduciendo
? 'Pausar reproduccion'
: 'Iniciar reproduccion',
child: Center( child: Center(
child: cargando child:
? const SizedBox( cargando
width: 28, ? const SizedBox(
height: 28, width: 28,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white), height: 28,
) child: CircularProgressIndicator(
: Icon( strokeWidth: 2.5,
reproduciendo ? Icons.pause_rounded : Icons.play_arrow_rounded, color: Colors.white,
size: 40, ),
color: theme.colorScheme.onPrimary, )
), : 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( child: Icon(
Icons.fiber_manual_record_rounded, Icons.fiber_manual_record_rounded,
size: 32, 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( return Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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), const SizedBox(width: 6),
Text(label, style: theme.textTheme.bodyMedium), Text(label, style: theme.textTheme.bodyMedium),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -457,33 +750,38 @@ class _TimerWidget extends StatelessWidget {
void _mostrarTimerDialog(BuildContext context) { void _mostrarTimerDialog(BuildContext context) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: (ctx) => SafeArea( builder:
child: Padding( (ctx) => SafeArea(
padding: const EdgeInsets.all(24), child: Padding(
child: Column( padding: const EdgeInsets.all(24),
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Text('Timer de sueno', style: Theme.of(ctx).textTheme.titleLarge), children: [
const SizedBox(height: 16), Text(
Wrap( 'Timer de sueno',
spacing: 8, style: Theme.of(ctx).textTheme.titleLarge,
children: opcionesTimer ),
.map( const SizedBox(height: 16),
(min) => ActionChip( Wrap(
label: Text('$min min'), spacing: 8,
onPressed: () { children:
estado.iniciarTimer(min); opcionesTimer
Navigator.pop(ctx); .map(
}, (min) => ActionChip(
), label: Text('$min min'),
) onPressed: () {
.toList(), estado.iniciarTimer(min);
Navigator.pop(ctx);
},
),
)
.toList(),
),
],
), ),
], ),
), ),
),
),
); );
} }
} }
+66 -34
View File
@@ -22,7 +22,10 @@ void registrarHandler(PluriWaveAudioHandler handler) {
/// Wrapper de alto nivel para el UI. /// Wrapper de alto nivel para el UI.
class ServicioAudio { class ServicioAudio {
PluriWaveAudioHandler get _handler { 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!; return _handlerGlobal!;
} }
@@ -50,9 +53,10 @@ class ServicioAudio {
title: emisora.nombre, title: emisora.nombre,
artist: emisora.pais ?? '', artist: emisora.pais ?? '',
album: 'PluriWave', album: 'PluriWave',
artUri: emisora.favicon != null && emisora.favicon!.isNotEmpty artUri:
? Uri.tryParse(emisora.favicon!) emisora.favicon != null && emisora.favicon!.isNotEmpty
: null, ? Uri.tryParse(emisora.favicon!)
: null,
extras: {'uuid': emisora.uuid}, extras: {'uuid': emisora.uuid},
); );
await _handler.playMediaItem(item); await _handler.playMediaItem(item);
@@ -73,6 +77,8 @@ class ServicioAudio {
Future<void> setVolumen(double vol) => _handler.setVolumen(vol); Future<void> setVolumen(double vol) => _handler.setVolumen(vol);
double get volumen => _handler.volumen; double get volumen => _handler.volumen;
bool get estaSonando => _handler.playbackState.value.playing; bool get estaSonando => _handler.playbackState.value.playing;
Stream<int?> get androidAudioSessionIdStream =>
_handler.androidAudioSessionIdStream;
Future<void> dispose() async {} Future<void> dispose() async {}
// ── Ecualizador ─────────────────────────────────────────────────────────── // ── Ecualizador ───────────────────────────────────────────────────────────
@@ -83,8 +89,7 @@ class ServicioAudio {
Future<void> aplicarPreset(PresetEcualizador preset) => Future<void> aplicarPreset(PresetEcualizador preset) =>
_handler.aplicarPreset(preset); _handler.aplicarPreset(preset);
Future<void> setBanda(int index, double db) => Future<void> setBanda(int index, double db) => _handler.setBanda(index, db);
_handler.setBanda(index, db);
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -99,6 +104,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
StreamSubscription<PlayerState>? _estadoPlayerSub; StreamSubscription<PlayerState>? _estadoPlayerSub;
StreamSubscription<Duration>? _bufferedSub; StreamSubscription<Duration>? _bufferedSub;
StreamSubscription<PlaybackEvent>? _eventosSub; StreamSubscription<PlaybackEvent>? _eventosSub;
StreamSubscription<int?>? _androidAudioSessionIdSub;
final _androidAudioSessionIdController = StreamController<int?>.broadcast();
Future<void> _colaCambioFuente = Future<void>.value(); Future<void> _colaCambioFuente = Future<void>.value();
int _revisionFuente = 0; int _revisionFuente = 0;
@@ -112,6 +119,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
PresetEcualizador _presetActual = PresetEcualizador.flat; PresetEcualizador _presetActual = PresetEcualizador.flat;
PresetEcualizador get presetActual => _presetActual; PresetEcualizador get presetActual => _presetActual;
Stream<int?> get androidAudioSessionIdStream =>
_androidAudioSessionIdController.stream;
PluriWaveAudioHandler() { PluriWaveAudioHandler() {
_conectarStreamsPlayer(); _conectarStreamsPlayer();
@@ -127,18 +136,20 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
_estadoPlayerSub = _player.playerStateStream.listen((state) { _estadoPlayerSub = _player.playerStateStream.listen((state) {
final playing = state.playing; final playing = state.playing;
final proc = state.processingState; final proc = state.processingState;
playbackState.add(playbackState.value.copyWith( playbackState.add(
controls: [ playbackState.value.copyWith(
if (playing) MediaControl.pause else MediaControl.play, controls: [
MediaControl.stop, if (playing) MediaControl.pause else MediaControl.play,
], MediaControl.stop,
systemActions: const {MediaAction.seek, MediaAction.stop}, ],
androidCompactActionIndices: const [0], systemActions: const {MediaAction.seek, MediaAction.stop},
processingState: _mapProcState(proc), androidCompactActionIndices: const [0],
playing: playing, processingState: _mapProcState(proc),
bufferedPosition: _player.bufferedPosition, playing: playing,
speed: _player.speed, bufferedPosition: _player.bufferedPosition,
)); speed: _player.speed,
),
);
}); });
_bufferedSub = _player.bufferedPositionStream.listen((pos) { _bufferedSub = _player.bufferedPositionStream.listen((pos) {
@@ -151,6 +162,14 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
_gestionarErrorReproduccion(error); _gestionarErrorReproduccion(error);
}, },
); );
_androidAudioSessionIdSub = _player.androidAudioSessionIdStream.listen((
sessionId,
) {
if (!_androidAudioSessionIdController.isClosed) {
_androidAudioSessionIdController.add(sessionId);
}
});
} }
/// Gestiona cualquier error de reproducción de ExoPlayer. /// Gestiona cualquier error de reproducción de ExoPlayer.
@@ -172,11 +191,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
level: 900, level: 900,
); );
playbackState.add(playbackState.value.copyWith( playbackState.add(
processingState: AudioProcessingState.error, playbackState.value.copyWith(
playing: false, processingState: AudioProcessingState.error,
errorMessage: mensaje, playing: false,
)); errorMessage: mensaje,
),
);
emisoraActual = null; emisoraActual = null;
mediaItem.add(null); mediaItem.add(null);
@@ -236,11 +257,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
Future<void> _cambiarFuente(MediaItem mediaItem, int revision) async { Future<void> _cambiarFuente(MediaItem mediaItem, int revision) async {
this.mediaItem.add(mediaItem); this.mediaItem.add(mediaItem);
emisoraActual = _emisoraDesdeMediaItem(mediaItem); emisoraActual = _emisoraDesdeMediaItem(mediaItem);
playbackState.add(playbackState.value.copyWith( playbackState.add(
processingState: AudioProcessingState.loading, playbackState.value.copyWith(
playing: false, processingState: AudioProcessingState.loading,
errorMessage: null, playing: false,
)); errorMessage: null,
),
);
try { try {
await _recrearPlayer(); await _recrearPlayer();
if (revision != _revisionFuente) return; if (revision != _revisionFuente) return;
@@ -263,11 +286,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
stackTrace: stackTrace, stackTrace: stackTrace,
); );
if (revision == _revisionFuente) { if (revision == _revisionFuente) {
playbackState.add(playbackState.value.copyWith( playbackState.add(
processingState: AudioProcessingState.error, playbackState.value.copyWith(
playing: false, processingState: AudioProcessingState.error,
errorMessage: 'Error inesperado al reproducir', playing: false,
)); errorMessage: 'Error inesperado al reproducir',
),
);
emisoraActual = null; emisoraActual = null;
this.mediaItem.add(null); this.mediaItem.add(null);
} }
@@ -279,6 +304,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
await _estadoPlayerSub?.cancel(); await _estadoPlayerSub?.cancel();
await _bufferedSub?.cancel(); await _bufferedSub?.cancel();
await _eventosSub?.cancel(); await _eventosSub?.cancel();
await _androidAudioSessionIdSub?.cancel();
final anterior = _player; final anterior = _player;
try { try {
@@ -330,7 +356,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
if (!_eqDisponible) return; if (!_eqDisponible) return;
try { try {
final params = await _eq.parameters; 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]); await params.bands[i].setGain(preset.bandas[i]);
} }
} catch (_) {} } catch (_) {}
@@ -381,7 +411,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler {
await _estadoPlayerSub?.cancel(); await _estadoPlayerSub?.cancel();
await _bufferedSub?.cancel(); await _bufferedSub?.cancel();
await _eventosSub?.cancel(); await _eventosSub?.cancel();
await _androidAudioSessionIdSub?.cancel();
await _player.dispose(); await _player.dispose();
await _androidAudioSessionIdController.close();
} }
Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) { Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) {
+313
View File
@@ -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<Directory> 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<Directory> Function()? _resolverDirectorioBase;
final DateTime Function() _reloj;
final _estadoController = StreamController<EstadoGrabacionRadio>.broadcast();
EstadoGrabacionRadio _estado = const EstadoGrabacionRadio.inactiva();
StreamSubscription<List<int>>? _subscripcionStream;
IOSink? _sink;
http.Client? _clienteActivo;
Timer? _timerAutoStop;
String? _directorioConfigurado;
EstadoGrabacionRadio get estado => _estado;
Stream<EstadoGrabacionRadio> get estadoStream => _estadoController.stream;
String? get directorioConfigurado => _directorioConfigurado;
Future<void> inicializar() async {
try {
final prefs = await SharedPreferences.getInstance();
_directorioConfigurado = prefs.getString(_claveDirectorio);
} catch (_) {
_directorioConfigurado = null;
}
}
Future<String> 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<void> 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<void> limpiarDirectorioConfigurado() async {
_directorioConfigurado = null;
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_claveDirectorio);
} catch (_) {}
_emitir(_estado);
}
Future<void> 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<void> detener() async {
if (!_estado.activa) return;
_emitir(_estado.copyWith(tipo: EstadoGrabacionRadioTipo.deteniendo));
_timerAutoStop?.cancel();
_timerAutoStop = null;
_clienteActivo?.close();
await _subscripcionStream?.cancel();
await _finalizar();
}
Future<void> _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<void> _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<String, String> 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<void> dispose() async {
_timerAutoStop?.cancel();
_clienteActivo?.close();
await _subscripcionStream?.cancel();
await _sink?.close();
await _estadoController.close();
}
}
+79 -39
View File
@@ -26,13 +26,14 @@ class ServicioRadio {
int maxIntentos = _maxIntentosPorDefecto, int maxIntentos = _maxIntentosPorDefecto,
Duration retryDelay = _retryDelayPorDefecto, Duration retryDelay = _retryDelayPorDefecto,
Duration timeout = _timeoutPorDefecto, Duration timeout = _timeoutPorDefecto,
}) : _cliente = cliente ?? http.Client(), }) : _cliente = cliente ?? http.Client(),
_servidores = (servidores == null || servidores.isEmpty) _servidores =
? List<String>.from(_servidoresFallback) (servidores == null || servidores.isEmpty)
: List<String>.from(servidores), ? List<String>.from(_servidoresFallback)
_maxIntentos = maxIntentos < 1 ? 1 : maxIntentos, : List<String>.from(servidores),
_retryDelay = retryDelay, _maxIntentos = maxIntentos < 1 ? 1 : maxIntentos,
_timeout = timeout; _retryDelay = retryDelay,
_timeout = timeout;
final http.Client _cliente; final http.Client _cliente;
final List<String> _servidores; final List<String> _servidores;
@@ -56,10 +57,7 @@ class ServicioRadio {
} }
Uri _uri(String servidor, String path, Map<String, String> params) { Uri _uri(String servidor, String path, Map<String, String> params) {
return Uri.https(servidor, path, { return Uri.https(servidor, path, {'hidebroken': 'true', ...params});
'hidebroken': 'true',
...params,
});
} }
Future<List<Emisora>> _get(String path, Map<String, String> params) async { Future<List<Emisora>> _get(String path, Map<String, String> params) async {
@@ -69,15 +67,17 @@ class ServicioRadio {
for (int intento = 0; intento < totalIntentos; intento++) { for (int intento = 0; intento < totalIntentos; intento++) {
final servidor = _servidorPorIntento(indiceBase, intento); final servidor = _servidorPorIntento(indiceBase, intento);
final uri = _uri(servidor, path, { final uri = _uri(servidor, path, {'lastcheckok': '1', ...params});
'lastcheckok': '1',
...params,
});
try { try {
final resp = await _cliente.get(uri, headers: { final resp = await _cliente
'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)', .get(
}).timeout(_timeout); uri,
headers: {
'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)',
},
)
.timeout(_timeout);
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
throw Exception('API error ${resp.statusCode}'); throw Exception('API error ${resp.statusCode}');
@@ -85,11 +85,14 @@ class ServicioRadio {
final lista = json.decode(resp.body) as List<dynamic>; final lista = json.decode(resp.body) as List<dynamic>;
_servidorActual = servidor; _servidorActual = servidor;
return lista final emisoras =
.cast<Map<String, dynamic>>() lista
.map(Emisora.fromApi) .cast<Map<String, dynamic>>()
.where((e) => e.uuid.isNotEmpty && e.url.isNotEmpty) .map(Emisora.fromApi)
.toList(); .where((e) => e.uuid.isNotEmpty && e.url.isNotEmpty)
.toList();
emisoras.sort(_compararCalidad);
return emisoras;
} on Exception catch (e) { } on Exception catch (e) {
ultimoError = e; ultimoError = e;
_servidorActual = null; _servidorActual = null;
@@ -105,57 +108,78 @@ class ServicioRadio {
} }
/// Emisoras más votadas globalmente. /// Emisoras más votadas globalmente.
Future<List<Emisora>> obtenerPopulares({int limit = 30, int offset = 0}) async { Future<List<Emisora>> obtenerPopulares({
int limit = 30,
int offset = 0,
}) async {
return _get('/json/stations/search', { return _get('/json/stations/search', {
'limit': limit.toString(), 'limit': limit.toString(),
'offset': offset.toString(), 'offset': offset.toString(),
'order': 'votes', 'order': 'bitrate',
'reverse': 'true', 'reverse': 'true',
}); });
} }
/// Emisoras más escuchadas (por clicks) globalmente. /// Emisoras más escuchadas (por clicks) globalmente.
Future<List<Emisora>> obtenerTendencias({int limit = 20}) async { Future<List<Emisora>> 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. /// Buscar por nombre de emisora.
Future<List<Emisora>> buscarPorNombre(String query, {int limit = 30, int offset = 0}) async { Future<List<Emisora>> buscarPorNombre(
String query, {
int limit = 30,
int offset = 0,
}) async {
return _get('/json/stations/search', { return _get('/json/stations/search', {
'name': query, 'name': query,
'limit': limit.toString(), 'limit': limit.toString(),
'offset': offset.toString(), 'offset': offset.toString(),
'order': 'votes', 'order': 'bitrate',
'reverse': 'true', 'reverse': 'true',
}); });
} }
/// Buscar por código de país (ISO 3166-1 alpha-2, e.g. 'ES', 'US'). /// Buscar por código de país (ISO 3166-1 alpha-2, e.g. 'ES', 'US').
Future<List<Emisora>> buscarPorPais(String codigoPais, {int limit = 50, int offset = 0}) async { Future<List<Emisora>> buscarPorPais(
String codigoPais, {
int limit = 50,
int offset = 0,
}) async {
return _get('/json/stations/bycountrycodeexact/$codigoPais', { return _get('/json/stations/bycountrycodeexact/$codigoPais', {
'limit': limit.toString(), 'limit': limit.toString(),
'offset': offset.toString(), 'offset': offset.toString(),
'order': 'votes', 'order': 'bitrate',
'reverse': 'true', 'reverse': 'true',
}); });
} }
/// Buscar por idioma (e.g. 'spanish', 'english'). /// Buscar por idioma (e.g. 'spanish', 'english').
Future<List<Emisora>> buscarPorIdioma(String idioma, {int limit = 30, int offset = 0}) async { Future<List<Emisora>> buscarPorIdioma(
String idioma, {
int limit = 30,
int offset = 0,
}) async {
return _get('/json/stations/bylanguageexact/$idioma', { return _get('/json/stations/bylanguageexact/$idioma', {
'limit': limit.toString(), 'limit': limit.toString(),
'offset': offset.toString(), 'offset': offset.toString(),
'order': 'votes', 'order': 'bitrate',
'reverse': 'true', 'reverse': 'true',
}); });
} }
/// Buscar por tag/género (e.g. 'rock', 'jazz', 'pop'). /// Buscar por tag/género (e.g. 'rock', 'jazz', 'pop').
Future<List<Emisora>> buscarPorTag(String tag, {int limit = 30, int offset = 0}) async { Future<List<Emisora>> buscarPorTag(
String tag, {
int limit = 30,
int offset = 0,
}) async {
return _get('/json/stations/bytagexact/$tag', { return _get('/json/stations/bytagexact/$tag', {
'limit': limit.toString(), 'limit': limit.toString(),
'offset': offset.toString(), 'offset': offset.toString(),
'order': 'votes', 'order': 'bitrate',
'reverse': 'true', 'reverse': 'true',
}); });
} }
@@ -176,20 +200,36 @@ class ServicioRadio {
if (tag != null && tag.isNotEmpty) 'tag': tag, if (tag != null && tag.isNotEmpty) 'tag': tag,
'limit': limit.toString(), 'limit': limit.toString(),
'offset': offset.toString(), 'offset': offset.toString(),
'order': 'votes', 'order': 'bitrate',
'reverse': 'true', '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). /// Registrar un click en la API (best effort).
Future<void> registrarClick(String uuid) async { Future<void> registrarClick(String uuid) async {
try { try {
final servidor = final servidor =
_servidorActual ?? _servidorPorIntento(_indiceServidorInicial(), 0); _servidorActual ?? _servidorPorIntento(_indiceServidorInicial(), 0);
await _cliente.get( await _cliente
Uri.https(servidor, '/json/url/$uuid'), .get(
headers: {'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)'}, Uri.https(servidor, '/json/url/$uuid'),
).timeout(_timeout); headers: {
'User-Agent': 'PluriWave/0.1.0 (es.freetimelab.pluriwave)',
},
)
.timeout(_timeout);
} catch (_) { } catch (_) {
// No crítico, ignorar. // No crítico, ignorar.
} }
+146 -65
View File
@@ -1,31 +1,19 @@
import 'dart:async'; import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../servicios/servicio_audio.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 /// En Android intenta capturar la forma de onda real del audio mediante un
/// basado en ruido suavizado mientras la radio está reproduciéndose. /// canal nativo. Si el dispositivo o los permisos no lo permiten, mantiene un
/// Cuando está pausado/detenido, las barras se aplanan suavemente. /// fallback animado para que la interfaz nunca quede rota.
///
/// ### 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,
/// )
/// ```
class VisualizadorAudio extends StatefulWidget { class VisualizadorAudio extends StatefulWidget {
final Stream<EstadoReproduccion> estadoStream; final Stream<EstadoReproduccion> estadoStream;
final Stream<int?>? androidAudioSessionIdStream;
final int barras; final int barras;
final Color? color; final Color? color;
final double altura; final double altura;
@@ -34,6 +22,7 @@ class VisualizadorAudio extends StatefulWidget {
const VisualizadorAudio({ const VisualizadorAudio({
super.key, super.key,
required this.estadoStream, required this.estadoStream,
this.androidAudioSessionIdStream,
this.barras = 20, this.barras = 20,
this.color, this.color,
this.altura = 48, this.altura = 48,
@@ -46,9 +35,15 @@ class VisualizadorAudio extends StatefulWidget {
class _VisualizadorAudioState extends State<VisualizadorAudio> class _VisualizadorAudioState extends State<VisualizadorAudio>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late AnimationController _controller; static const _eventChannel = EventChannel('pluriwave/audio_visualizer');
late final AnimationController _controller;
bool _activo = false; bool _activo = false;
int? _sessionId;
List<double> _ondaReal = const [];
StreamSubscription<EstadoReproduccion>? _estadoSubscription; StreamSubscription<EstadoReproduccion>? _estadoSubscription;
StreamSubscription<int?>? _sessionSubscription;
StreamSubscription<dynamic>? _ondaSubscription;
@override @override
void initState() { void initState() {
@@ -57,23 +52,78 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
vsync: this, vsync: this,
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
)..addListener(() { )..addListener(() {
if (mounted) setState(() {}); if (mounted) setState(() {});
}); });
_estadoSubscription = widget.estadoStream.listen(_onEstado); _estadoSubscription = widget.estadoStream.listen(_onEstado);
_sessionSubscription = widget.androidAudioSessionIdStream?.listen(
_onSessionId,
);
} }
void _onEstado(EstadoReproduccion estado) { void _onEstado(EstadoReproduccion estado) {
final nuevoActivo = estado == EstadoReproduccion.reproduciendo || final nuevoActivo =
estado == EstadoReproduccion.reproduciendo ||
estado == EstadoReproduccion.cargando; estado == EstadoReproduccion.cargando;
if (nuevoActivo == _activo) return;
if (!mounted) return; if (!mounted) return;
setState(() => _activo = nuevoActivo); if (nuevoActivo != _activo) {
nuevoActivo ? _controller.repeat() : _controller.stop(); 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<num>()
.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 @override
void dispose() { void dispose() {
_estadoSubscription?.cancel(); _estadoSubscription?.cancel();
_sessionSubscription?.cancel();
_ondaSubscription?.cancel();
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }
@@ -84,12 +134,14 @@ class _VisualizadorAudioState extends State<VisualizadorAudio>
final t = _controller.value * pi * 2; final t = _controller.value * pi * 2;
return SizedBox( return SizedBox(
height: widget.altura, height: widget.altura,
width: widget.anchuraTotal,
child: RepaintBoundary( child: RepaintBoundary(
child: CustomPaint( child: CustomPaint(
painter: _WaveFlowPainter( painter: _WaveFlowPainter(
color: color, color: color,
phase: t, phase: t,
active: _activo, active: _activo,
waveform: _ondaReal,
), ),
child: const SizedBox.expand(), child: const SizedBox.expand(),
), ),
@@ -103,51 +155,62 @@ class _WaveFlowPainter extends CustomPainter {
required this.color, required this.color,
required this.phase, required this.phase,
required this.active, required this.active,
required this.waveform,
}); });
final Color color; final Color color;
final double phase; final double phase;
final bool active; final bool active;
final List<double> waveform;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final center = size.height / 2; final center = size.height / 2;
final amp = active ? size.height * 0.24 : size.height * 0.06; final amp = active ? size.height * 0.24 : size.height * 0.06;
final glowPaint = Paint() final glowPaint =
..style = PaintingStyle.stroke Paint()
..strokeWidth = active ? 8 : 4 ..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round ..strokeWidth = active ? 8 : 4
..color = color.withValues(alpha: active ? 0.18 : 0.08); ..strokeCap = StrokeCap.round
final linePaint = Paint() ..color = color.withValues(alpha: active ? 0.18 : 0.08);
..style = PaintingStyle.stroke final linePaint =
..strokeWidth = 3 Paint()
..strokeCap = StrokeCap.round ..style = PaintingStyle.stroke
..shader = LinearGradient( ..strokeWidth = 3
colors: [ ..strokeCap = StrokeCap.round
color.withValues(alpha: 0.08), ..shader = LinearGradient(
color.withValues(alpha: active ? 0.95 : 0.35), colors: [
const Color(0xFFF4B860).withValues(alpha: active ? 0.75 : 0.20), color.withValues(alpha: 0.08),
color.withValues(alpha: 0.08), color.withValues(alpha: active ? 0.95 : 0.35),
], const Color(0xFFF4B860).withValues(alpha: active ? 0.75 : 0.20),
).createShader(Offset.zero & size); 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); final path = Path()..moveTo(0, center);
for (double x = 0; x <= size.width; x += 8) { for (double x = 0; x <= size.width; x += 8) {
final p = x / size.width; final p = x / size.width;
final y = center + final muestra = real ? _muestraReal(p) : null;
sin((p * pi * 2.4) + phase + shift) * amplitude + final sintetica =
sin((p * pi * 5.2) - phase * 0.7 + shift) * amplitude * 0.32; 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); path.lineTo(x, y);
} }
return path; return path;
} }
canvas.drawPath(pathFor(0, amp), glowPaint); final usaReal = waveform.isNotEmpty;
canvas.drawPath(pathFor(0.6, amp * 0.62), glowPaint..color = color.withValues(alpha: active ? 0.10 : 0.05)); canvas.drawPath(pathFor(0, amp, real: usaReal), glowPaint);
canvas.drawPath(pathFor(0, amp), linePaint);
canvas.drawPath( 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() Paint()
..style = PaintingStyle.stroke ..style = PaintingStyle.stroke
..strokeWidth = 2 ..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 @override
bool shouldRepaint(covariant _WaveFlowPainter oldDelegate) { bool shouldRepaint(covariant _WaveFlowPainter oldDelegate) {
return oldDelegate.phase != phase || return oldDelegate.phase != phase ||
oldDelegate.active != active || oldDelegate.active != active ||
oldDelegate.color != color; oldDelegate.color != color ||
oldDelegate.waveform != waveform;
} }
} }
/// Versión compacta del visualizador — 5 barras, para uso en MiniReproductor /// Versión compacta del visualizador para el MiniReproductor.
/// o indicadores pequeños de "en reproducción".
class IndicadorReproduccion extends StatefulWidget { class IndicadorReproduccion extends StatefulWidget {
final Stream<EstadoReproduccion> estadoStream; final Stream<EstadoReproduccion> estadoStream;
final Color? color; final Color? color;
@@ -191,12 +263,15 @@ class _IndicadorReproduccionState extends State<IndicadorReproduccion>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 600)) _ctrl = AnimationController(
..addListener(() => setState(() {})); vsync: this,
duration: const Duration(milliseconds: 600),
)..addListener(() {
if (mounted) setState(() {});
});
_estadoSubscription = widget.estadoStream.listen((s) { _estadoSubscription = widget.estadoStream.listen((s) {
final rep = s == EstadoReproduccion.reproduciendo; final rep = s == EstadoReproduccion.reproduciendo;
if (rep == _reproduciendo) return; if (rep == _reproduciendo || !mounted) return;
if (!mounted) return;
setState(() => _reproduciendo = rep); setState(() => _reproduciendo = rep);
rep ? _ctrl.repeat(reverse: true) : _ctrl.stop(); rep ? _ctrl.repeat(reverse: true) : _ctrl.stop();
}); });
@@ -211,11 +286,13 @@ class _IndicadorReproduccionState extends State<IndicadorReproduccion>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final color = widget.color ?? final color = widget.color ?? Theme.of(context).colorScheme.primary;
Theme.of(context).colorScheme.primary;
if (!_reproduciendo) { if (!_reproduciendo) {
return Icon(Icons.radio, size: widget.size, return Icon(
color: Theme.of(context).colorScheme.onSurfaceVariant); Icons.radio,
size: widget.size,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
} }
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -223,8 +300,12 @@ class _IndicadorReproduccionState extends State<IndicadorReproduccion>
children: List.generate(3, (i) { children: List.generate(3, (i) {
final alts = [0.5, 1.0, 0.7]; final alts = [0.5, 1.0, 0.7];
final fases = [0.0, 0.3, 0.6]; final fases = [0.0, 0.3, 0.6];
final h = ((sin(_ctrl.value * pi + fases[i]) + 1) / 2 * alts[i] + 0.2) final h =
.clamp(0.15, 1.0) * widget.size; ((sin(_ctrl.value * pi + fases[i]) + 1) / 2 * alts[i] + 0.2).clamp(
0.15,
1.0,
) *
widget.size;
return Container( return Container(
width: widget.size * 0.2, width: widget.size * 0.2,
height: h, height: h,
@@ -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<void>.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<List<int>>();
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<void>.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<List<int>> stream;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
return http.StreamedResponse(
stream,
200,
headers: {'content-type': 'audio/aac'},
);
}
}
+75 -39
View File
@@ -8,47 +8,48 @@ import 'package:pluriwave/servicios/servicio_radio.dart';
void main() { void main() {
group('ServicioRadio retry + rotación', () { group('ServicioRadio retry + rotación', () {
test( test(
'reintenta con otro host cuando el primero falla y recupera en el segundo', 'reintenta con otro host cuando el primero falla y recupera en el segundo',
() async { () async {
final hostsSolicitados = <String>[]; final hostsSolicitados = <String>[];
final servicio = ServicioRadio( final servicio = ServicioRadio(
cliente: MockClient((request) async { cliente: MockClient((request) async {
hostsSolicitados.add(request.url.host); hostsSolicitados.add(request.url.host);
if (request.url.host == 'host-1.api.radio-browser.info') { if (request.url.host == 'host-1.api.radio-browser.info') {
return http.Response('fallo', 500); return http.Response('fallo', 500);
} }
return http.Response( return http.Response(
jsonEncode([ jsonEncode([
{ {
'stationuuid': 'uuid-ok', 'stationuuid': 'uuid-ok',
'name': 'Radio Recuperada', 'name': 'Radio Recuperada',
'url_resolved': 'https://stream.recuperada/audio', 'url_resolved': 'https://stream.recuperada/audio',
}, },
]), ]),
200, 200,
headers: {'content-type': 'application/json'}, headers: {'content-type': 'application/json'},
); );
}), }),
servidores: const [ servidores: const [
'host-1.api.radio-browser.info', 'host-1.api.radio-browser.info',
'host-2.api.radio-browser.info', 'host-2.api.radio-browser.info',
], ],
maxIntentos: 3, maxIntentos: 3,
retryDelay: Duration.zero, retryDelay: Duration.zero,
); );
final emisoras = await servicio.obtenerPopulares(limit: 1); final emisoras = await servicio.obtenerPopulares(limit: 1);
expect(emisoras, hasLength(1)); expect(emisoras, hasLength(1));
expect(emisoras.first.uuid, 'uuid-ok'); expect(emisoras.first.uuid, 'uuid-ok');
expect( expect(
hostsSolicitados, hostsSolicitados,
equals([ equals([
'host-1.api.radio-browser.info', 'host-1.api.radio-browser.info',
'host-2.api.radio-browser.info', 'host-2.api.radio-browser.info',
]), ]),
); );
}); },
);
test('corta al llegar al tope de intentos y propaga error final', () async { test('corta al llegar al tope de intentos y propaga error final', () async {
var intentos = 0; var intentos = 0;
@@ -68,5 +69,40 @@ void main() {
); );
expect(intentos, 2); 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']));
});
}); });
} }