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