Some checks failed
Flutter CI/CD — PluriWave / Test + Build (pull_request) Has been cancelled
**Fix 1 — HTTP cleartext (streams sin HTTPS):** - Añadir android/app/src/main/res/xml/network_security_config.xml con cleartextTrafficPermitted=true para permitir streams de radio HTTP - Referenciar en AndroidManifest.xml con android:networkSecurityConfig - Resuelve: 'Cleartext HTTP traffic to [host] not permitted' en ExoPlayer - Radio Paradise (Dance Wave, HTTP) y otras radios HTTP funcionan ahora **Fix 2 — Gestión de error TYPE_SOURCE y todos los PlaybackException:** - Añadir listener en playbackEventStream.onError en PluriWaveAudioHandler - _gestionarErrorReproduccion() emite AudioProcessingState.error al UI, loggea el error y resetea el player a estado idle limpio - _mensajeAmigable() traduce códigos ERROR_CODE_IO_*, ERROR_CODE_PARSING_*, ERROR_CODE_DECODING_* y mensajes de Cleartext/HandshakeException a texto legible - EstadoRadio.reproducir() captura la excepción y cancela el timer si estaba activo - EstadoRadio escucha el estadoStream y cancela timer ante cualquier error **Fix 3 — Artwork con certificado autofirmado:** - errorWidget en CachedNetworkImage captura HandshakeException silenciosamente - Muestra _iconoFallback (icono de radio) en lugar de imagen rota - El error de artwork no se propaga ni interrumpe la reproducción **Fix 4 — UI consistente en estado de error:** - PantallaReproductor._Controles muestra mensaje + botón Reintentar en error - PantallaReproductor._Artwork muestra overlay wifi_off en estado de error - MiniReproductor muestra botón refresh (reintentar) en estado de error - EstadoReproduccion.error ya estaba definido; ahora el estadoStream lo emite - Timer cancelado automáticamente cuando la reproducción falla - Test de smoke corregido (boilerplate MyApp → placeholder válido) Fixes: cleartext HTTP, cert autofirmado, ExoPlayer TYPE_SOURCE, UI inconsistente
298 lines
9.6 KiB
Dart
298 lines
9.6 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import '../modelos/emisora.dart';
|
|
import '../modelos/preset_ecualizador.dart';
|
|
import '../servicios/servicio_audio.dart';
|
|
import '../servicios/servicio_favoritos.dart';
|
|
import '../servicios/servicio_radio.dart';
|
|
import '../servicios/servicio_timer.dart';
|
|
|
|
/// Estado global de la app con ChangeNotifier (Provider).
|
|
class EstadoRadio extends ChangeNotifier {
|
|
final ServicioAudio audio = ServicioAudio();
|
|
final ServicioFavoritos favoritos = ServicioFavoritos();
|
|
final ServicioRadio radio = ServicioRadio();
|
|
late final ServicioTimer timer;
|
|
|
|
// Errores de reproducción → SnackBar
|
|
final _errorController = StreamController<String>.broadcast();
|
|
Stream<String> get errorStream => _errorController.stream;
|
|
|
|
List<Emisora> _populares = [];
|
|
List<Emisora> _tendencias = [];
|
|
List<Emisora> _resultadosBusqueda = [];
|
|
List<Emisora> _listafavoritos = [];
|
|
List<Emisora> _emisorasCustom = [];
|
|
|
|
// Presets EQ guardados por uuid de emisora
|
|
final Map<String, PresetEcualizador> _presetsEmisoraMap = {};
|
|
PresetEcualizador _presetActual = PresetEcualizador.flat;
|
|
|
|
bool _cargandoPopulares = false;
|
|
bool _cargandoBusqueda = false;
|
|
String? _errorCarga;
|
|
|
|
EstadoRadio() {
|
|
timer = ServicioTimer(audio);
|
|
_init();
|
|
_escucharErroresReproduccion();
|
|
}
|
|
|
|
/// Escucha el stream de estado del audio y gestiona errores de reproducción
|
|
/// de forma centralizada: cancela el timer y notifica al usuario.
|
|
void _escucharErroresReproduccion() {
|
|
audio.estadoStream.listen((estado) {
|
|
if (estado == EstadoReproduccion.error) {
|
|
// Cancelar el timer si estaba activo — no debe contar sin audio
|
|
if (timer.activo) {
|
|
timer.cancelar();
|
|
}
|
|
notifyListeners();
|
|
}
|
|
});
|
|
}
|
|
|
|
List<Emisora> get populares => _populares;
|
|
List<Emisora> get tendencias => _tendencias;
|
|
List<Emisora> get resultadosBusqueda => _resultadosBusqueda;
|
|
List<Emisora> get listaFavoritos => _listafavoritos;
|
|
List<Emisora> get emisorasCustom => _emisorasCustom;
|
|
bool get cargandoPopulares => _cargandoPopulares;
|
|
bool get cargandoBusqueda => _cargandoBusqueda;
|
|
String? get error => _errorCarga;
|
|
Emisora? get emisoraActual => audio.emisoraActual;
|
|
Stream<EstadoReproduccion> get estadoStream => audio.estadoStream;
|
|
PresetEcualizador get presetEcualizador => _presetActual;
|
|
bool get ecualizadorDisponible => audio.ecualizadorDisponible;
|
|
|
|
Future<void> _init() async {
|
|
await Future.wait([
|
|
cargarPopulares(),
|
|
cargarFavoritos(),
|
|
_cargarEmisoresCustom(),
|
|
]);
|
|
}
|
|
|
|
Future<void> cargarPopulares() async {
|
|
_cargandoPopulares = true;
|
|
_errorCarga = null;
|
|
notifyListeners();
|
|
try {
|
|
final results = await Future.wait([
|
|
radio.obtenerPopulares(limit: 30),
|
|
radio.obtenerTendencias(limit: 20),
|
|
]);
|
|
_populares = results[0];
|
|
_tendencias = results[1];
|
|
} catch (e) {
|
|
_errorCarga = 'Sin conexión a la API de radio';
|
|
} finally {
|
|
_cargandoPopulares = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> cargarFavoritos() async {
|
|
_listafavoritos = await favoritos.obtenerTodos();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> buscar({
|
|
String? nombre,
|
|
String? pais,
|
|
String? idioma,
|
|
String? tag,
|
|
}) async {
|
|
_cargandoBusqueda = true;
|
|
_resultadosBusqueda = [];
|
|
notifyListeners();
|
|
try {
|
|
_resultadosBusqueda = await radio.buscar(
|
|
nombre: nombre,
|
|
pais: pais,
|
|
idioma: idioma,
|
|
tag: tag,
|
|
);
|
|
} catch (e) {
|
|
_errorController.add('Error en la búsqueda. Comprueba tu conexión.');
|
|
} finally {
|
|
_cargandoBusqueda = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> reproducir(Emisora emisora) async {
|
|
try {
|
|
await audio.reproducir(emisora);
|
|
radio.registrarClick(emisora.uuid); // fire & forget
|
|
// Restaurar preset del ecualizador de esta emisora
|
|
final preset = _presetsEmisoraMap[emisora.uuid] ?? PresetEcualizador.flat;
|
|
await cambiarPresetEcualizador(preset, guardPorEmisora: false);
|
|
notifyListeners();
|
|
} catch (e) {
|
|
// La reproducción falló: cancelar el timer para evitar estado inconsistente
|
|
// (el timer no debe contar si no hay audio reproduciéndose)
|
|
if (timer.activo) {
|
|
timer.cancelar();
|
|
}
|
|
// Emitir mensaje claro al usuario con el nombre de la emisora
|
|
final mensajeError = e.toString().replaceFirst('Exception: ', '');
|
|
_errorController.add(
|
|
mensajeError.isNotEmpty && mensajeError != 'Exception'
|
|
? mensajeError
|
|
: 'No se puede reproducir "${emisora.nombre}"',
|
|
);
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> togglePlay() async {
|
|
await audio.togglePlay();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<bool> toggleFavorito(Emisora emisora) async {
|
|
final esFav = await favoritos.toggleFavorito(emisora);
|
|
await cargarFavoritos();
|
|
notifyListeners();
|
|
return esFav;
|
|
}
|
|
|
|
Future<bool> esFavorito(String uuid) => favoritos.esFavorito(uuid);
|
|
|
|
// ── Ecualizador ──────────────────────────────────────────────────────────
|
|
|
|
Future<void> cambiarPresetEcualizador(
|
|
PresetEcualizador preset, {
|
|
bool guardPorEmisora = true,
|
|
}) async {
|
|
_presetActual = preset;
|
|
await audio.aplicarPreset(preset);
|
|
if (guardPorEmisora && emisoraActual != null) {
|
|
_presetsEmisoraMap[emisoraActual!.uuid] = preset;
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> cambiarBandaEcualizador(int index, double db) async {
|
|
final bandas = List<double>.from(_presetActual.bandas);
|
|
if (index >= 0 && index < bandas.length) bandas[index] = db;
|
|
_presetActual = PresetEcualizador(nombre: 'Personalizado', bandas: bandas);
|
|
await audio.setBanda(index, db);
|
|
if (emisoraActual != null) {
|
|
_presetsEmisoraMap[emisoraActual!.uuid] = _presetActual;
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
// ── Emisoras personalizadas ───────────────────────────────────────────────
|
|
|
|
Future<File> _archivoCustom() async {
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
return File('${dir.path}/emisoras_custom.json');
|
|
}
|
|
|
|
Future<void> _cargarEmisoresCustom() async {
|
|
try {
|
|
final f = await _archivoCustom();
|
|
if (!await f.exists()) return;
|
|
final data = jsonDecode(await f.readAsString()) as List;
|
|
_emisorasCustom = data
|
|
.map((e) => Emisora.fromMap(Map<String, dynamic>.from(e as Map)))
|
|
.toList();
|
|
} catch (_) {
|
|
_emisorasCustom = [];
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> _guardarEmisoresCustom() async {
|
|
final f = await _archivoCustom();
|
|
await f.writeAsString(jsonEncode(_emisorasCustom.map((e) => e.toMap()).toList()));
|
|
}
|
|
|
|
Future<void> agregarEmitoraCustom(Emisora emisora) async {
|
|
_emisorasCustom.removeWhere((e) => e.uuid == emisora.uuid);
|
|
_emisorasCustom.add(emisora);
|
|
await _guardarEmisoresCustom();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> eliminarEmitoraCustom(String uuid) async {
|
|
_emisorasCustom.removeWhere((e) => e.uuid == uuid);
|
|
await _guardarEmisoresCustom();
|
|
notifyListeners();
|
|
}
|
|
|
|
// ── Export / Import ───────────────────────────────────────────────────────
|
|
|
|
/// Genera el JSON de toda la configuración.
|
|
Future<Map<String, dynamic>> exportarConfig() async {
|
|
final favs = await favoritos.obtenerTodos();
|
|
return {
|
|
'version': 1,
|
|
'exportedAt': DateTime.now().toIso8601String(),
|
|
'favoritos': favs.map((e) => e.toMap()).toList(),
|
|
'emisorasCustom': _emisorasCustom.map((e) => e.toMap()).toList(),
|
|
'presetsEcualizador': _presetsEmisoraMap.map(
|
|
(uuid, preset) => MapEntry(uuid, preset.toJson()),
|
|
),
|
|
};
|
|
}
|
|
|
|
/// Importa configuración desde un JSON exportado previamente.
|
|
Future<void> importarConfig(Map<String, dynamic> data) async {
|
|
final version = data['version'] as int? ?? 1;
|
|
if (version != 1) throw Exception('Versión de configuración no compatible');
|
|
|
|
// Importar favoritos
|
|
final favRaw = data['favoritos'] as List? ?? [];
|
|
for (final raw in favRaw) {
|
|
final emisora = Emisora.fromMap(Map<String, dynamic>.from(raw as Map));
|
|
await favoritos.agregar(emisora);
|
|
}
|
|
|
|
// Importar emisoras custom
|
|
final customRaw = data['emisorasCustom'] as List? ?? [];
|
|
_emisorasCustom = customRaw
|
|
.map((e) => Emisora.fromMap(Map<String, dynamic>.from(e as Map)))
|
|
.toList();
|
|
await _guardarEmisoresCustom();
|
|
|
|
// Importar presets EQ
|
|
final presetsRaw = data['presetsEcualizador'] as Map? ?? {};
|
|
_presetsEmisoraMap.clear();
|
|
presetsRaw.forEach((uuid, presetJson) {
|
|
_presetsEmisoraMap[uuid as String] =
|
|
PresetEcualizador.desdeJson(Map<String, dynamic>.from(presetJson as Map));
|
|
});
|
|
|
|
await cargarFavoritos();
|
|
notifyListeners();
|
|
}
|
|
|
|
// ── Timer ─────────────────────────────────────────────────────────────────
|
|
|
|
void iniciarTimer(int minutos) {
|
|
timer.iniciar(minutos);
|
|
notifyListeners();
|
|
}
|
|
|
|
void cancelarTimer() {
|
|
timer.cancelar();
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_errorController.close();
|
|
audio.dispose();
|
|
timer.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|