refactor(state): extract recording and search state, scope screen rebuilds
- New EstadoGrabacion owns the recording service, subscription, directory/size preferences and open-file actions - New EstadoBusqueda owns search, nearby stations, pagination and the min-bitrate filter - New orden_emisoras.dart with the OrdenEmisoras enum, shared sorter and list identity memoization so context.select comparisons work on derived lists - Large screens (inicio, buscar, favoritos, ajustes, reproductor) consume scoped selects/dedicated notifiers instead of root context.watch<EstadoRadio>, so audio buffer events no longer rebuild whole screens - Remove all 15 TODO(S4b) compat members from EstadoRadio; consumers use the dedicated providers. EstadoRadio drops from ~1121 to 753 lines, keeping playback/stations/favorites orchestration - 8 new tests including a rebuild-scoping probe (110 total green), flutter analyze clean
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
import 'dart:ui' show Locale, PlatformDispatcher;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geocoding/geocoding.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../servicios/servicio_radio.dart';
|
||||
import 'orden_emisoras.dart';
|
||||
|
||||
/// Search state extracted from `EstadoRadio` (S4-R3).
|
||||
///
|
||||
/// Owns the search query/filters, paged results, the nearby-stations lookup
|
||||
/// and every loading flag. Notifies ONLY its own listeners so search activity
|
||||
/// never rebuilds `EstadoRadio` consumers (S4-R5).
|
||||
class EstadoBusqueda extends ChangeNotifier {
|
||||
EstadoBusqueda({
|
||||
required this.radio,
|
||||
OrdenEmisoras Function()? ordenListas,
|
||||
AppLocalizations Function()? textos,
|
||||
void Function(String mensaje)? alError,
|
||||
}) : _ordenListas = ordenListas ?? (() => OrdenEmisoras.calidad),
|
||||
_textos = textos ?? (() => lookupAppLocalizations(const Locale('es'))),
|
||||
_alError = alError;
|
||||
|
||||
static const int _tamanoPagina = 30;
|
||||
static const int _maxResultadosEnMemoria = 180;
|
||||
|
||||
final ServicioRadio radio;
|
||||
|
||||
/// Current list ordering, owned by EstadoRadio (user preference).
|
||||
final OrdenEmisoras Function() _ordenListas;
|
||||
final AppLocalizations Function() _textos;
|
||||
|
||||
/// User-visible error sink (EstadoRadio routes it to its snackbar stream).
|
||||
final void Function(String mensaje)? _alError;
|
||||
|
||||
List<Emisora> _resultados = [];
|
||||
List<Emisora> _cercanas = [];
|
||||
bool _cargando = false;
|
||||
bool _cargandoMas = false;
|
||||
bool _hayMas = true;
|
||||
bool _cargandoCercanas = false;
|
||||
String? _paisCercanoDetectado;
|
||||
String? _errorCercanas;
|
||||
int _offset = 0;
|
||||
String? _ultimoNombre;
|
||||
String? _ultimoPais;
|
||||
String? _ultimoIdioma;
|
||||
String? _ultimoTag;
|
||||
int? _ultimoMinBitrate;
|
||||
|
||||
final _memoResultados = MemoLista<Emisora>();
|
||||
final _memoCercanas = MemoLista<Emisora>();
|
||||
|
||||
List<Emisora> get resultados => _memoResultados.obtener([
|
||||
_resultados,
|
||||
_ordenListas(),
|
||||
], () => ordenarEmisoras(_resultados, _ordenListas()));
|
||||
List<Emisora> get cercanas => _memoCercanas.obtener([
|
||||
_cercanas,
|
||||
_ordenListas(),
|
||||
], () => ordenarEmisoras(_cercanas, _ordenListas()));
|
||||
bool get cargando => _cargando;
|
||||
bool get cargandoMas => _cargandoMas;
|
||||
bool get hayMas => _hayMas;
|
||||
bool get cargandoCercanas => _cargandoCercanas;
|
||||
String? get paisCercanoDetectado => _paisCercanoDetectado;
|
||||
String? get errorCercanas => _errorCercanas;
|
||||
|
||||
/// Re-renders sorted views after the user changes the list ordering
|
||||
/// (called by EstadoRadio, which owns that preference).
|
||||
void notificarCambioOrden() => notifyListeners();
|
||||
|
||||
Future<void> buscar({
|
||||
String? nombre,
|
||||
String? pais,
|
||||
String? idioma,
|
||||
String? tag,
|
||||
int? minBitrate,
|
||||
}) async {
|
||||
_ultimoNombre = nombre;
|
||||
_ultimoPais = pais;
|
||||
_ultimoIdioma = idioma;
|
||||
_ultimoTag = tag;
|
||||
_ultimoMinBitrate = minBitrate;
|
||||
_offset = 0;
|
||||
_hayMas = true;
|
||||
_cargando = true;
|
||||
_resultados = [];
|
||||
notifyListeners();
|
||||
try {
|
||||
final pagina = await _buscarPaginaFiltrada(
|
||||
nombre: nombre,
|
||||
pais: pais,
|
||||
idioma: idioma,
|
||||
tag: tag,
|
||||
minBitrate: minBitrate,
|
||||
);
|
||||
_resultados = pagina;
|
||||
} catch (_) {
|
||||
_alError?.call(_textos().radioSearchError);
|
||||
} finally {
|
||||
_cargando = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cargarMas() async {
|
||||
if (_cargando || _cargandoMas || !_hayMas) return;
|
||||
_cargandoMas = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final pagina = await _buscarPaginaFiltrada(
|
||||
nombre: _ultimoNombre,
|
||||
pais: _ultimoPais,
|
||||
idioma: _ultimoIdioma,
|
||||
tag: _ultimoTag,
|
||||
minBitrate: _ultimoMinBitrate,
|
||||
);
|
||||
final porUuid = <String, Emisora>{
|
||||
for (final emisora in _resultados) emisora.uuid: emisora,
|
||||
};
|
||||
for (final emisora in pagina) {
|
||||
porUuid[emisora.uuid] = emisora;
|
||||
}
|
||||
var nuevaLista = porUuid.values.toList();
|
||||
if (nuevaLista.length > _maxResultadosEnMemoria) {
|
||||
nuevaLista = nuevaLista.sublist(
|
||||
nuevaLista.length - _maxResultadosEnMemoria,
|
||||
);
|
||||
}
|
||||
_resultados = nuevaLista;
|
||||
// _buscarPaginaFiltrada actualiza offset/hayMas usando páginas crudas.
|
||||
_hayMas = _hayMas && pagina.isNotEmpty;
|
||||
} catch (_) {
|
||||
_alError?.call(_textos().radioLoadMoreStationsError);
|
||||
} finally {
|
||||
_cargandoMas = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Emisora>> _buscarPaginaFiltrada({
|
||||
String? nombre,
|
||||
String? pais,
|
||||
String? idioma,
|
||||
String? tag,
|
||||
int? minBitrate,
|
||||
}) async {
|
||||
final acumuladas = <Emisora>[];
|
||||
var intentos = 0;
|
||||
while (intentos < 4 && acumuladas.isEmpty && _hayMas) {
|
||||
final pagina = await radio.buscar(
|
||||
nombre: nombre,
|
||||
pais: pais,
|
||||
idioma: idioma,
|
||||
tag: tag,
|
||||
limit: _tamanoPagina,
|
||||
offset: _offset,
|
||||
);
|
||||
_offset += pagina.length;
|
||||
_hayMas = pagina.length == _tamanoPagina;
|
||||
acumuladas.addAll(_filtrarMinBitrate(pagina, minBitrate));
|
||||
intentos++;
|
||||
}
|
||||
return acumuladas;
|
||||
}
|
||||
|
||||
List<Emisora> _filtrarMinBitrate(List<Emisora> emisoras, int? minBitrate) {
|
||||
if (minBitrate == null || minBitrate <= 0) return emisoras;
|
||||
return emisoras.where((e) => (e.bitrate ?? 0) >= minBitrate).toList();
|
||||
}
|
||||
|
||||
Future<void> cargarEmisorasCercanas() async {
|
||||
_cargandoCercanas = true;
|
||||
_errorCercanas = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
var pais = PlatformDispatcher.instance.locale.countryCode;
|
||||
final servicioActivo = await Geolocator.isLocationServiceEnabled();
|
||||
if (servicioActivo) {
|
||||
var permiso = await Geolocator.checkPermission();
|
||||
if (permiso == LocationPermission.denied) {
|
||||
permiso = await Geolocator.requestPermission();
|
||||
}
|
||||
if (permiso == LocationPermission.always ||
|
||||
permiso == LocationPermission.whileInUse) {
|
||||
final posicion = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.low,
|
||||
timeLimit: Duration(seconds: 8),
|
||||
),
|
||||
);
|
||||
final marcas = await placemarkFromCoordinates(
|
||||
posicion.latitude,
|
||||
posicion.longitude,
|
||||
);
|
||||
if (marcas.isNotEmpty) {
|
||||
pais = marcas.first.isoCountryCode ?? pais;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pais == null || pais.isEmpty) {
|
||||
throw StateError('nearby-region-not-detected');
|
||||
}
|
||||
_paisCercanoDetectado = pais;
|
||||
_cercanas = _filtrarMinBitrate(
|
||||
await radio.buscar(pais: pais, limit: 30),
|
||||
_ultimoMinBitrate,
|
||||
);
|
||||
} catch (_) {
|
||||
_errorCercanas = _textos().radioNearbyStationsError;
|
||||
_cercanas = [];
|
||||
} finally {
|
||||
_cargandoCercanas = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' show Locale;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../servicios/servicio_grabacion_radio.dart';
|
||||
|
||||
/// Recording state extracted from `EstadoRadio` (S4-R2).
|
||||
///
|
||||
/// Owns [ServicioGrabacionRadio] and the recording-state subscription, and
|
||||
/// notifies ONLY its own listeners — recording progress must not rebuild
|
||||
/// `EstadoRadio` consumers (S4-R5). Playback orchestration (stop recording on
|
||||
/// pause/stop/station switch) stays in `EstadoRadio`, which keeps a reference
|
||||
/// to this notifier.
|
||||
class EstadoGrabacion extends ChangeNotifier {
|
||||
EstadoGrabacion({
|
||||
ServicioGrabacionRadio? servicio,
|
||||
Emisora? Function()? emisoraActual,
|
||||
void Function(String mensaje)? alError,
|
||||
}) : servicio = servicio ?? ServicioGrabacionRadio(),
|
||||
_emisoraActual = emisoraActual ?? (() => null),
|
||||
_alError = alError {
|
||||
_suscripcion = this.servicio.estadoStream.listen((estado) {
|
||||
if (estado.tipo == EstadoGrabacionRadioTipo.error &&
|
||||
estado.error != null) {
|
||||
_alError?.call(_textos.radioRecordingError(estado.error!));
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
static const MethodChannel _fileActionsChannel = MethodChannel(
|
||||
'pluriwave/file_actions',
|
||||
);
|
||||
|
||||
final ServicioGrabacionRadio servicio;
|
||||
|
||||
/// Callback into the owner (EstadoRadio) for the currently playing station;
|
||||
/// keeps this notifier free of any station-list coupling.
|
||||
final Emisora? Function() _emisoraActual;
|
||||
|
||||
/// User-visible error sink (EstadoRadio routes it to its snackbar stream).
|
||||
final void Function(String mensaje)? _alError;
|
||||
|
||||
StreamSubscription<EstadoGrabacionRadio>? _suscripcion;
|
||||
AppLocalizations? _l10n;
|
||||
|
||||
AppLocalizations get _textos {
|
||||
final actual = _l10n;
|
||||
if (actual != null) return actual;
|
||||
return lookupAppLocalizations(const Locale('es'));
|
||||
}
|
||||
|
||||
void configurarLocalizaciones(AppLocalizations l10n) {
|
||||
_l10n = l10n;
|
||||
servicio.configurarLocalizaciones(l10n);
|
||||
}
|
||||
|
||||
Future<void> inicializar() => servicio.inicializar();
|
||||
|
||||
EstadoGrabacionRadio get estado => servicio.estado;
|
||||
bool get activa => servicio.estado.activa;
|
||||
String? get directorioConfigurado => servicio.directorioConfigurado;
|
||||
int get maxBytes => servicio.maxBytes;
|
||||
File? get ultimoArchivo => servicio.ultimoArchivo;
|
||||
|
||||
Future<void> iniciar({Duration? duracion}) async {
|
||||
final actual = _emisoraActual();
|
||||
if (actual == null) {
|
||||
_alError?.call(_textos.recordingSelectStationFirst);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await servicio.iniciar(actual, duracion: duracion);
|
||||
} catch (e) {
|
||||
_alError?.call(_textos.recordingStartError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> detener() => servicio.detener();
|
||||
|
||||
Future<void> cambiarMaxBytes(int bytes) async {
|
||||
await servicio.guardarMaxBytes(bytes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> cambiarDirectorio(String path) async {
|
||||
await servicio.guardarDirectorio(path);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> restaurarDirectorio() async {
|
||||
await servicio.limpiarDirectorioConfigurado();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<String> directorioEfectivo() => servicio.directorioEfectivo();
|
||||
|
||||
Future<bool> abrirDirectorio() async {
|
||||
final ruta = await directorioEfectivo();
|
||||
await Directory(ruta).create(recursive: true);
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
final abierto = await _fileActionsChannel.invokeMethod<bool>(
|
||||
'viewDirectory',
|
||||
{'path': ruta},
|
||||
);
|
||||
return abierto ?? false;
|
||||
}
|
||||
final uri = Uri.directory(ruta);
|
||||
return launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Future<bool> abrirUltimaGrabacion() async {
|
||||
final archivo = ultimoArchivo;
|
||||
if (archivo == null || !await archivo.exists()) {
|
||||
debugPrint('[PluriWave][recordings] last recording missing');
|
||||
return false;
|
||||
}
|
||||
debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}');
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', {
|
||||
'path': archivo.path,
|
||||
'mimeType': 'audio/*',
|
||||
});
|
||||
return abierto ?? false;
|
||||
}
|
||||
return launchUrl(
|
||||
Uri.file(archivo.path),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_suscripcion?.cancel();
|
||||
unawaited(servicio.dispose());
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
+116
-413
@@ -3,20 +3,19 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart' show Locale;
|
||||
import 'package:geocoding/geocoding.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../l10n/display_names.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import 'estado_busqueda.dart';
|
||||
import 'estado_ecualizador.dart';
|
||||
import 'estado_grabacion.dart';
|
||||
import 'orden_emisoras.dart';
|
||||
import '../servicios/servicio_audio.dart';
|
||||
import '../servicios/servicio_ecualizador.dart';
|
||||
import '../servicios/servicio_export_import.dart';
|
||||
@@ -25,14 +24,15 @@ import '../servicios/servicio_grabacion_radio.dart';
|
||||
import '../servicios/servicio_radio.dart';
|
||||
import '../servicios/servicio_timer.dart';
|
||||
|
||||
enum OrdenEmisoras { nombre, calidad }
|
||||
export 'orden_emisoras.dart' show OrdenEmisoras;
|
||||
|
||||
/// Estado global de la app con ChangeNotifier (Provider).
|
||||
///
|
||||
/// S4 end-state: playback + stations + favorites orchestration. EQ, recording
|
||||
/// and search state live in their own notifiers (EstadoEcualizador,
|
||||
/// EstadoGrabacion, EstadoBusqueda) created here during the S4 transition and
|
||||
/// exposed app-wide through ListenableProviders in app.dart.
|
||||
class EstadoRadio extends ChangeNotifier {
|
||||
static const MethodChannel _fileActionsChannel = MethodChannel(
|
||||
'pluriwave/file_actions',
|
||||
);
|
||||
|
||||
EstadoRadio({
|
||||
ServicioAudio? audio,
|
||||
ServicioFavoritos? favoritos,
|
||||
@@ -47,7 +47,6 @@ class EstadoRadio extends ChangeNotifier {
|
||||
radio = radio ?? ServicioRadio(),
|
||||
servicioEcualizador =
|
||||
servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
|
||||
grabacion = servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs),
|
||||
_prefs = prefs,
|
||||
_resolverArchivoCustom = resolverArchivoCustom {
|
||||
ecualizador = EstadoEcualizador(
|
||||
@@ -55,9 +54,19 @@ class EstadoRadio extends ChangeNotifier {
|
||||
servicio: this.servicioEcualizador,
|
||||
emisoraActualUuid: () => emisoraActual?.uuid,
|
||||
);
|
||||
grabacion = EstadoGrabacion(
|
||||
servicio: servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs),
|
||||
emisoraActual: () => emisoraActual,
|
||||
alError: _errorController.add,
|
||||
);
|
||||
busqueda = EstadoBusqueda(
|
||||
radio: this.radio,
|
||||
ordenListas: () => _ordenListas,
|
||||
textos: () => _textos,
|
||||
alError: _errorController.add,
|
||||
);
|
||||
timer = ServicioTimer(this.audio);
|
||||
_escucharErroresReproduccion();
|
||||
_escucharGrabacion();
|
||||
if (iniciarAutomaticamente) {
|
||||
_initFuture = _init();
|
||||
}
|
||||
@@ -68,11 +77,12 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final ServicioRadio radio;
|
||||
final ServicioEcualizador servicioEcualizador;
|
||||
|
||||
/// EQ state extracted to its own notifier (S4-R1). Owned (and disposed)
|
||||
/// by EstadoRadio during the S4 transition; exposed app-wide through a
|
||||
/// ListenableProvider in app.dart.
|
||||
/// Domain notifiers extracted from this class (S4). Created and disposed
|
||||
/// here (they need EstadoRadio's services and callbacks at construction);
|
||||
/// exposed app-wide through ListenableProviders in app.dart.
|
||||
late final EstadoEcualizador ecualizador;
|
||||
final ServicioGrabacionRadio grabacion;
|
||||
late final EstadoGrabacion grabacion;
|
||||
late final EstadoBusqueda busqueda;
|
||||
static const ServicioExportImport _exportImport = ServicioExportImport();
|
||||
final SharedPreferences? _prefs;
|
||||
final Future<File> Function()? _resolverArchivoCustom;
|
||||
@@ -99,7 +109,6 @@ class EstadoRadio extends ChangeNotifier {
|
||||
|
||||
late final ServicioTimer timer;
|
||||
StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudio;
|
||||
StreamSubscription<EstadoGrabacionRadio>? _suscripcionGrabacion;
|
||||
Future<void>? _initFuture;
|
||||
int _revisionReproduccion = 0;
|
||||
Emisora? _emisoraSeleccionada;
|
||||
@@ -112,26 +121,23 @@ class EstadoRadio extends ChangeNotifier {
|
||||
|
||||
List<Emisora> _populares = [];
|
||||
List<Emisora> _tendencias = [];
|
||||
List<Emisora> _resultadosBusqueda = [];
|
||||
List<Emisora> _emisorasCercanas = [];
|
||||
List<Emisora> _listaFavoritos = [];
|
||||
List<GrupoFavoritos> _gruposFavoritos = [];
|
||||
List<Emisora> _emisorasCustom = [];
|
||||
|
||||
bool _cargandoPopulares = false;
|
||||
bool _cargandoBusqueda = false;
|
||||
bool _cargandoMasBusqueda = false;
|
||||
bool _hayMasBusqueda = true;
|
||||
bool _cargandoCercanas = false;
|
||||
String? _paisCercanoDetectado;
|
||||
String? _errorCercanas;
|
||||
int _offsetBusqueda = 0;
|
||||
String? _ultimoNombreBusqueda;
|
||||
String? _ultimoPaisBusqueda;
|
||||
String? _ultimoIdiomaBusqueda;
|
||||
String? _ultimoTagBusqueda;
|
||||
int? _ultimoMinBitrateBusqueda;
|
||||
String? _errorCarga;
|
||||
|
||||
// Identity-memoized derived lists so `context.select` consumers only
|
||||
// rebuild when the underlying data actually changes (S4-R5).
|
||||
final _memoPopulares = MemoLista<Emisora>();
|
||||
final _memoTendencias = MemoLista<Emisora>();
|
||||
final _memoFavoritos = MemoLista<Emisora>();
|
||||
final _memoGrupos = MemoLista<GrupoFavoritos>();
|
||||
final _memoCustom = MemoLista<Emisora>();
|
||||
final _memoInicio = MemoLista<Emisora>();
|
||||
final _memoDisponibles = MemoLista<Emisora>();
|
||||
final _memoTimerPresets = MemoLista<int>();
|
||||
static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1';
|
||||
static const _keyOrdenListas = 'orden_listas_emisoras_v1';
|
||||
static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1';
|
||||
@@ -151,38 +157,35 @@ class EstadoRadio extends ChangeNotifier {
|
||||
);
|
||||
OrdenEmisoras _ordenListas = OrdenEmisoras.calidad;
|
||||
|
||||
List<Emisora> get populares => _ordenarEmisoras(_populares);
|
||||
List<Emisora> get tendencias => _ordenarEmisoras(_tendencias);
|
||||
List<Emisora> get resultadosBusqueda => _ordenarEmisoras(_resultadosBusqueda);
|
||||
List<Emisora> get emisorasCercanas => _ordenarEmisoras(_emisorasCercanas);
|
||||
List<Emisora> get listaFavoritos => _ordenarEmisoras(_listaFavoritos);
|
||||
List<GrupoFavoritos> get gruposFavoritos =>
|
||||
List.unmodifiable(_gruposFavoritos);
|
||||
List<Emisora> get emisorasCustom => _ordenarEmisoras(_emisorasCustom);
|
||||
List<Emisora> get populares => _memoPopulares.obtener([
|
||||
_populares,
|
||||
_ordenListas,
|
||||
], () => ordenarEmisoras(_populares, _ordenListas));
|
||||
List<Emisora> get tendencias => _memoTendencias.obtener([
|
||||
_tendencias,
|
||||
_ordenListas,
|
||||
], () => ordenarEmisoras(_tendencias, _ordenListas));
|
||||
List<Emisora> get listaFavoritos => _memoFavoritos.obtener([
|
||||
_listaFavoritos,
|
||||
_ordenListas,
|
||||
], () => ordenarEmisoras(_listaFavoritos, _ordenListas));
|
||||
List<GrupoFavoritos> get gruposFavoritos => _memoGrupos.obtener([
|
||||
_gruposFavoritos,
|
||||
], () => List<GrupoFavoritos>.unmodifiable(_gruposFavoritos));
|
||||
List<Emisora> get emisorasCustom => _memoCustom.obtener([
|
||||
_emisorasCustom,
|
||||
_ordenListas,
|
||||
], () => ordenarEmisoras(_emisorasCustom, _ordenListas));
|
||||
bool get cargandoPopulares => _cargandoPopulares;
|
||||
bool get cargandoBusqueda => _cargandoBusqueda;
|
||||
bool get cargandoMasBusqueda => _cargandoMasBusqueda;
|
||||
bool get hayMasBusqueda => _hayMasBusqueda;
|
||||
bool get cargandoCercanas => _cargandoCercanas;
|
||||
String? get paisCercanoDetectado => _paisCercanoDetectado;
|
||||
String? get errorCercanas => _errorCercanas;
|
||||
String? get error => _errorCarga;
|
||||
Emisora? get emisoraActual => _emisoraSeleccionada ?? audio.emisoraActual;
|
||||
Emisora? get emisoraPreferida => _resolverEmisoraPreferida();
|
||||
String? get emisoraPreferidaUuid => emisoraPreferida?.uuid;
|
||||
Stream<EstadoReproduccion> get estadoStream => audio.estadoStream;
|
||||
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||
PresetEcualizador get presetEcualizador => ecualizador.presetActual;
|
||||
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||
PresetEcualizador get presetPrincipalEcualizador =>
|
||||
ecualizador.presetPrincipal;
|
||||
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||
bool get ecualizadorActivo => ecualizador.activo;
|
||||
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||
bool get ecualizadorDisponible => ecualizador.disponible;
|
||||
OrdenEmisoras get ordenListas => _ordenListas;
|
||||
List<int> get timerSuenoPresetsSegundos =>
|
||||
List<int>.unmodifiable(_timerSuenoPresetsSegundos);
|
||||
List<int> get timerSuenoPresetsSegundos => _memoTimerPresets.obtener([
|
||||
_timerSuenoPresetsSegundos,
|
||||
], () => List<int>.unmodifiable(_timerSuenoPresetsSegundos));
|
||||
|
||||
bool get emisoraActualEsFavorita {
|
||||
final actual = emisoraActual;
|
||||
@@ -190,50 +193,51 @@ class EstadoRadio extends ChangeNotifier {
|
||||
return _listaFavoritos.any((e) => e.uuid == actual.uuid);
|
||||
}
|
||||
|
||||
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||
bool get emisoraActualTienePresetPropio =>
|
||||
ecualizador.emisoraActualTienePresetPropio;
|
||||
|
||||
EstadoGrabacionRadio get estadoGrabacion => grabacion.estado;
|
||||
bool get grabacionActiva => grabacion.estado.activa;
|
||||
String? get directorioGrabacion => grabacion.directorioConfigurado;
|
||||
int get maxBytesGrabacion => grabacion.maxBytes;
|
||||
File? get ultimaGrabacion => grabacion.ultimoArchivo;
|
||||
|
||||
/// Lista principal (home): custom + populares, sin duplicados.
|
||||
List<Emisora> get emisorasInicio {
|
||||
final mapa = <String, Emisora>{};
|
||||
for (final emisora in _emisorasCustom) {
|
||||
mapa[emisora.uuid] = emisora;
|
||||
}
|
||||
for (final emisora in _populares) {
|
||||
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
||||
}
|
||||
return mapa.values.toList();
|
||||
}
|
||||
List<Emisora> get emisorasInicio =>
|
||||
_memoInicio.obtener([_emisorasCustom, _populares], () {
|
||||
final mapa = <String, Emisora>{};
|
||||
for (final emisora in _emisorasCustom) {
|
||||
mapa[emisora.uuid] = emisora;
|
||||
}
|
||||
for (final emisora in _populares) {
|
||||
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
||||
}
|
||||
return mapa.values.toList();
|
||||
});
|
||||
|
||||
List<Emisora> get emisorasDisponiblesPreferencia {
|
||||
final mapa = <String, Emisora>{};
|
||||
for (final emisora in _listaFavoritos) {
|
||||
mapa[emisora.uuid] = emisora;
|
||||
}
|
||||
for (final emisora in _emisorasCustom) {
|
||||
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
||||
}
|
||||
for (final emisora in _populares) {
|
||||
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
||||
}
|
||||
for (final emisora in _tendencias) {
|
||||
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
||||
}
|
||||
for (final emisora in _resultadosBusqueda) {
|
||||
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
||||
}
|
||||
for (final emisora in _emisorasCercanas) {
|
||||
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
||||
}
|
||||
return mapa.values.toList();
|
||||
}
|
||||
List<Emisora> get emisorasDisponiblesPreferencia => _memoDisponibles.obtener(
|
||||
[
|
||||
_listaFavoritos,
|
||||
_emisorasCustom,
|
||||
_populares,
|
||||
_tendencias,
|
||||
busqueda.resultados,
|
||||
busqueda.cercanas,
|
||||
],
|
||||
() {
|
||||
final mapa = <String, Emisora>{};
|
||||
for (final emisora in _listaFavoritos) {
|
||||
mapa[emisora.uuid] = emisora;
|
||||
}
|
||||
for (final emisora in _emisorasCustom) {
|
||||
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
||||
}
|
||||
for (final emisora in _populares) {
|
||||
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
||||
}
|
||||
for (final emisora in _tendencias) {
|
||||
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
||||
}
|
||||
for (final emisora in busqueda.resultados) {
|
||||
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
||||
}
|
||||
for (final emisora in busqueda.cercanas) {
|
||||
mapa.putIfAbsent(emisora.uuid, () => emisora);
|
||||
}
|
||||
return mapa.values.toList();
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> inicializar() {
|
||||
_initFuture ??= _init();
|
||||
@@ -264,23 +268,13 @@ class EstadoRadio extends ChangeNotifier {
|
||||
if ((estado == EstadoReproduccion.detenido ||
|
||||
estado == EstadoReproduccion.pausado ||
|
||||
estado == EstadoReproduccion.error) &&
|
||||
grabacion.estado.activa) {
|
||||
grabacion.activa) {
|
||||
unawaited(grabacion.detener());
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void _escucharGrabacion() {
|
||||
_suscripcionGrabacion = grabacion.estadoStream.listen((estado) {
|
||||
if (estado.tipo == EstadoGrabacionRadioTipo.error &&
|
||||
estado.error != null) {
|
||||
_errorController.add(_textos.radioRecordingError(estado.error!));
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> cargarPopulares() async {
|
||||
_cargandoPopulares = true;
|
||||
_errorCarga = null;
|
||||
@@ -390,6 +384,8 @@ class EstadoRadio extends ChangeNotifier {
|
||||
_ordenListas = orden;
|
||||
final prefs = await _resolverPrefs();
|
||||
await prefs.setString(_keyOrdenListas, orden.name);
|
||||
// Search owns its own listeners (S4-R3) but sorts with this preference.
|
||||
busqueda.notificarCambioOrden();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -422,176 +418,9 @@ class EstadoRadio extends ChangeNotifier {
|
||||
return disponibles.isEmpty ? null : disponibles.first;
|
||||
}
|
||||
|
||||
static const int _tamanoPaginaBusqueda = 30;
|
||||
static const int _maxResultadosBusquedaEnMemoria = 180;
|
||||
|
||||
Future<void> buscar({
|
||||
String? nombre,
|
||||
String? pais,
|
||||
String? idioma,
|
||||
String? tag,
|
||||
int? minBitrate,
|
||||
}) async {
|
||||
_ultimoNombreBusqueda = nombre;
|
||||
_ultimoPaisBusqueda = pais;
|
||||
_ultimoIdiomaBusqueda = idioma;
|
||||
_ultimoTagBusqueda = tag;
|
||||
_ultimoMinBitrateBusqueda = minBitrate;
|
||||
_offsetBusqueda = 0;
|
||||
_hayMasBusqueda = true;
|
||||
_cargandoBusqueda = true;
|
||||
_resultadosBusqueda = [];
|
||||
notifyListeners();
|
||||
try {
|
||||
final pagina = await _buscarPaginaFiltrada(
|
||||
nombre: nombre,
|
||||
pais: pais,
|
||||
idioma: idioma,
|
||||
tag: tag,
|
||||
minBitrate: minBitrate,
|
||||
);
|
||||
_resultadosBusqueda = pagina;
|
||||
} catch (_) {
|
||||
_errorController.add(_textos.radioSearchError);
|
||||
} finally {
|
||||
_cargandoBusqueda = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cargarMasBusqueda() async {
|
||||
if (_cargandoBusqueda || _cargandoMasBusqueda || !_hayMasBusqueda) return;
|
||||
_cargandoMasBusqueda = true;
|
||||
notifyListeners();
|
||||
try {
|
||||
final pagina = await _buscarPaginaFiltrada(
|
||||
nombre: _ultimoNombreBusqueda,
|
||||
pais: _ultimoPaisBusqueda,
|
||||
idioma: _ultimoIdiomaBusqueda,
|
||||
tag: _ultimoTagBusqueda,
|
||||
minBitrate: _ultimoMinBitrateBusqueda,
|
||||
);
|
||||
final porUuid = <String, Emisora>{
|
||||
for (final emisora in _resultadosBusqueda) emisora.uuid: emisora,
|
||||
};
|
||||
for (final emisora in pagina) {
|
||||
porUuid[emisora.uuid] = emisora;
|
||||
}
|
||||
var nuevaLista = porUuid.values.toList();
|
||||
if (nuevaLista.length > _maxResultadosBusquedaEnMemoria) {
|
||||
nuevaLista = nuevaLista.sublist(
|
||||
nuevaLista.length - _maxResultadosBusquedaEnMemoria,
|
||||
);
|
||||
}
|
||||
_resultadosBusqueda = nuevaLista;
|
||||
// _buscarPaginaFiltrada actualiza offset/hayMas usando páginas crudas.
|
||||
_hayMasBusqueda = _hayMasBusqueda && pagina.isNotEmpty;
|
||||
} catch (_) {
|
||||
_errorController.add(_textos.radioLoadMoreStationsError);
|
||||
} finally {
|
||||
_cargandoMasBusqueda = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Emisora>> _buscarPaginaFiltrada({
|
||||
String? nombre,
|
||||
String? pais,
|
||||
String? idioma,
|
||||
String? tag,
|
||||
int? minBitrate,
|
||||
}) async {
|
||||
final acumuladas = <Emisora>[];
|
||||
var intentos = 0;
|
||||
while (intentos < 4 && acumuladas.isEmpty && _hayMasBusqueda) {
|
||||
final pagina = await radio.buscar(
|
||||
nombre: nombre,
|
||||
pais: pais,
|
||||
idioma: idioma,
|
||||
tag: tag,
|
||||
limit: _tamanoPaginaBusqueda,
|
||||
offset: _offsetBusqueda,
|
||||
);
|
||||
_offsetBusqueda += pagina.length;
|
||||
_hayMasBusqueda = pagina.length == _tamanoPaginaBusqueda;
|
||||
acumuladas.addAll(_filtrarMinBitrate(pagina, minBitrate));
|
||||
intentos++;
|
||||
}
|
||||
return acumuladas;
|
||||
}
|
||||
|
||||
List<Emisora> _filtrarMinBitrate(List<Emisora> emisoras, int? minBitrate) {
|
||||
if (minBitrate == null || minBitrate <= 0) return emisoras;
|
||||
return emisoras.where((e) => (e.bitrate ?? 0) >= minBitrate).toList();
|
||||
}
|
||||
|
||||
List<Emisora> _ordenarEmisoras(List<Emisora> emisoras) {
|
||||
final ordenadas = List<Emisora>.from(emisoras);
|
||||
switch (_ordenListas) {
|
||||
case OrdenEmisoras.nombre:
|
||||
ordenadas.sort(
|
||||
(a, b) => a.nombre.toLowerCase().compareTo(b.nombre.toLowerCase()),
|
||||
);
|
||||
case OrdenEmisoras.calidad:
|
||||
ordenadas.sort((a, b) {
|
||||
final porBitrate = (b.bitrate ?? 0).compareTo(a.bitrate ?? 0);
|
||||
if (porBitrate != 0) return porBitrate;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
return ordenadas;
|
||||
}
|
||||
|
||||
Future<void> cargarEmisorasCercanas() async {
|
||||
_cargandoCercanas = true;
|
||||
_errorCercanas = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
var pais = PlatformDispatcher.instance.locale.countryCode;
|
||||
final servicioActivo = await Geolocator.isLocationServiceEnabled();
|
||||
if (servicioActivo) {
|
||||
var permiso = await Geolocator.checkPermission();
|
||||
if (permiso == LocationPermission.denied) {
|
||||
permiso = await Geolocator.requestPermission();
|
||||
}
|
||||
if (permiso == LocationPermission.always ||
|
||||
permiso == LocationPermission.whileInUse) {
|
||||
final posicion = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.low,
|
||||
timeLimit: Duration(seconds: 8),
|
||||
),
|
||||
);
|
||||
final marcas = await placemarkFromCoordinates(
|
||||
posicion.latitude,
|
||||
posicion.longitude,
|
||||
);
|
||||
if (marcas.isNotEmpty) {
|
||||
pais = marcas.first.isoCountryCode ?? pais;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pais == null || pais.isEmpty) {
|
||||
throw StateError('nearby-region-not-detected');
|
||||
}
|
||||
_paisCercanoDetectado = pais;
|
||||
_emisorasCercanas = _filtrarMinBitrate(
|
||||
await radio.buscar(pais: pais, limit: 30),
|
||||
_ultimoMinBitrateBusqueda,
|
||||
);
|
||||
} catch (_) {
|
||||
_errorCercanas = _textos.radioNearbyStationsError;
|
||||
_emisorasCercanas = [];
|
||||
} finally {
|
||||
_cargandoCercanas = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reproducir(Emisora emisora) async {
|
||||
final revision = ++_revisionReproduccion;
|
||||
if (grabacion.estado.activa) {
|
||||
if (grabacion.activa) {
|
||||
await grabacion.detener();
|
||||
}
|
||||
_emisoraSeleccionada = emisora;
|
||||
@@ -623,83 +452,16 @@ class EstadoRadio extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> iniciarGrabacion({Duration? duracion}) async {
|
||||
final actual = emisoraActual;
|
||||
if (actual == null) {
|
||||
_errorController.add(_textos.recordingSelectStationFirst);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await grabacion.iniciar(actual, duracion: duracion);
|
||||
} catch (e) {
|
||||
_errorController.add(_textos.recordingStartError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> detenerGrabacion() => grabacion.detener();
|
||||
|
||||
Future<void> detenerReproduccion() async {
|
||||
if (grabacion.estado.activa) {
|
||||
if (grabacion.activa) {
|
||||
await grabacion.detener();
|
||||
}
|
||||
await audio.detener();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> cambiarMaxBytesGrabacion(int bytes) async {
|
||||
await grabacion.guardarMaxBytes(bytes);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<bool> abrirDirectorioGrabacion() async {
|
||||
final ruta = await directorioGrabacionEfectivo();
|
||||
await Directory(ruta).create(recursive: true);
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
final abierto = await _fileActionsChannel.invokeMethod<bool>(
|
||||
'viewDirectory',
|
||||
{'path': ruta},
|
||||
);
|
||||
return abierto ?? false;
|
||||
}
|
||||
final uri = Uri.directory(ruta);
|
||||
return launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Future<bool> abrirUltimaGrabacion() async {
|
||||
final archivo = ultimaGrabacion;
|
||||
if (archivo == null || !await archivo.exists()) {
|
||||
debugPrint('[PluriWave][recordings] last recording missing');
|
||||
return false;
|
||||
}
|
||||
debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}');
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', {
|
||||
'path': archivo.path,
|
||||
'mimeType': 'audio/*',
|
||||
});
|
||||
return abierto ?? false;
|
||||
}
|
||||
return launchUrl(
|
||||
Uri.file(archivo.path),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
if (audio.estaSonando && grabacion.estado.activa) {
|
||||
if (audio.estaSonando && grabacion.activa) {
|
||||
await grabacion.detener();
|
||||
}
|
||||
await audio.togglePlay();
|
||||
@@ -709,7 +471,7 @@ class EstadoRadio extends ChangeNotifier {
|
||||
Future<bool> toggleFavorito(Emisora emisora) async {
|
||||
final esFav = await favoritos.toggleFavorito(emisora);
|
||||
if (!esFav) {
|
||||
await deshabilitarPresetEcualizadorPorEmisora(
|
||||
await ecualizador.deshabilitarPresetPorEmisora(
|
||||
emisora.uuid,
|
||||
notificar: false,
|
||||
);
|
||||
@@ -720,68 +482,6 @@ class EstadoRadio extends ChangeNotifier {
|
||||
|
||||
Future<bool> esFavorito(String uuid) => favoritos.esFavorito(uuid);
|
||||
|
||||
// ── Ecualizador ───────────────────────────────────────────────────────────
|
||||
// Transition bridge (S4a): EQ state lives in EstadoEcualizador; these
|
||||
// delegating members keep legacy call sites compiling. They do NOT notify
|
||||
// EstadoRadio listeners (S4-R1-A).
|
||||
|
||||
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||
bool tienePresetEcualizadorPorEmisora(String uuid) =>
|
||||
ecualizador.tienePresetPorEmisora(uuid);
|
||||
|
||||
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||
PresetEcualizador? presetEcualizadorPorEmisora(String uuid) =>
|
||||
ecualizador.presetPorEmisora(uuid);
|
||||
|
||||
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||
Future<void> cambiarPresetPrincipalEcualizador(
|
||||
PresetEcualizador preset, {
|
||||
bool notificar = true,
|
||||
}) => ecualizador.cambiarPresetPrincipal(preset, notificar: notificar);
|
||||
|
||||
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||
Future<void> guardarPresetEcualizadorPorEmisora(
|
||||
String uuid,
|
||||
PresetEcualizador preset, {
|
||||
bool notificar = true,
|
||||
}) => ecualizador.guardarPresetPorEmisora(uuid, preset, notificar: notificar);
|
||||
|
||||
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||
Future<void> habilitarPresetEcualizadorPorEmisora(
|
||||
String uuid, {
|
||||
PresetEcualizador? base,
|
||||
bool notificar = true,
|
||||
}) => ecualizador.habilitarPresetPorEmisora(
|
||||
uuid,
|
||||
base: base,
|
||||
notificar: notificar,
|
||||
);
|
||||
|
||||
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||
Future<void> deshabilitarPresetEcualizadorPorEmisora(
|
||||
String uuid, {
|
||||
bool notificar = true,
|
||||
}) => ecualizador.deshabilitarPresetPorEmisora(uuid, notificar: notificar);
|
||||
|
||||
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||
Future<void> cambiarModoEcualizadorEmisoraActual({
|
||||
required bool usarPropio,
|
||||
}) => ecualizador.cambiarModoEmisoraActual(usarPropio: usarPropio);
|
||||
|
||||
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||
Future<void> cambiarEcualizadorActivo(bool activo) =>
|
||||
ecualizador.cambiarActivo(activo);
|
||||
|
||||
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||
Future<void> cambiarPresetEcualizador(
|
||||
PresetEcualizador preset, {
|
||||
bool guardarPorEmisora = true,
|
||||
}) => ecualizador.cambiarPreset(preset, guardarPorEmisora: guardarPorEmisora);
|
||||
|
||||
// TODO(S4b): remove getter — consumers migrate to EstadoEcualizador.
|
||||
Future<void> cambiarBandaEcualizador(int index, double db) =>
|
||||
ecualizador.cambiarBanda(index, db);
|
||||
|
||||
// ── Emisoras personalizadas ───────────────────────────────────────────────
|
||||
|
||||
Future<File> _archivoCustom() async {
|
||||
@@ -820,8 +520,11 @@ class EstadoRadio extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> agregarEmisoraCustom(Emisora emisora) async {
|
||||
_emisorasCustom.removeWhere((e) => e.uuid == emisora.uuid);
|
||||
_emisorasCustom.add(emisora);
|
||||
// Reassign (not mutate) so identity-memoized views refresh (S4-R5).
|
||||
_emisorasCustom = [
|
||||
..._emisorasCustom.where((e) => e.uuid != emisora.uuid),
|
||||
emisora,
|
||||
];
|
||||
await _guardarEmisorasCustom();
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -831,7 +534,7 @@ class EstadoRadio extends ChangeNotifier {
|
||||
agregarEmisoraCustom(emisora);
|
||||
|
||||
Future<void> eliminarEmisoraCustom(String uuid) async {
|
||||
_emisorasCustom.removeWhere((e) => e.uuid == uuid);
|
||||
_emisorasCustom = _emisorasCustom.where((e) => e.uuid != uuid).toList();
|
||||
await _guardarEmisorasCustom();
|
||||
notifyListeners();
|
||||
}
|
||||
@@ -1039,11 +742,11 @@ class EstadoRadio extends ChangeNotifier {
|
||||
@override
|
||||
void dispose() {
|
||||
_suscripcionEstadoAudio?.cancel();
|
||||
_suscripcionGrabacion?.cancel();
|
||||
_errorController.close();
|
||||
ecualizador.dispose();
|
||||
busqueda.dispose();
|
||||
grabacion.dispose();
|
||||
audio.dispose();
|
||||
unawaited(grabacion.dispose());
|
||||
timer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import '../modelos/emisora.dart';
|
||||
|
||||
/// User-selectable ordering for every station list in the app.
|
||||
enum OrdenEmisoras { nombre, calidad }
|
||||
|
||||
/// Returns a sorted COPY of [emisoras] according to [orden].
|
||||
List<Emisora> ordenarEmisoras(List<Emisora> emisoras, OrdenEmisoras orden) {
|
||||
final ordenadas = List<Emisora>.from(emisoras);
|
||||
switch (orden) {
|
||||
case OrdenEmisoras.nombre:
|
||||
ordenadas.sort(
|
||||
(a, b) => a.nombre.toLowerCase().compareTo(b.nombre.toLowerCase()),
|
||||
);
|
||||
case OrdenEmisoras.calidad:
|
||||
ordenadas.sort((a, b) {
|
||||
final porBitrate = (b.bitrate ?? 0).compareTo(a.bitrate ?? 0);
|
||||
if (porBitrate != 0) return porBitrate;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
return ordenadas;
|
||||
}
|
||||
|
||||
/// Identity-memoized derived list (S4-R5).
|
||||
///
|
||||
/// Derived-list getters used to return a fresh copy on every read, which made
|
||||
/// `context.select` rebuild on EVERY notification (lists compare by identity).
|
||||
/// This memo recomputes only when one of the source [claves] changes identity,
|
||||
/// so unrelated notifications (e.g. audio buffer events) stop rebuilding the
|
||||
/// screens that select these lists.
|
||||
class MemoLista<T> {
|
||||
List<Object?>? _claves;
|
||||
List<T>? _resultado;
|
||||
|
||||
List<T> obtener(List<Object?> claves, List<T> Function() calcular) {
|
||||
final anteriores = _claves;
|
||||
final resultado = _resultado;
|
||||
if (anteriores != null &&
|
||||
resultado != null &&
|
||||
anteriores.length == claves.length) {
|
||||
var iguales = true;
|
||||
for (var i = 0; i < claves.length; i++) {
|
||||
if (!identical(anteriores[i], claves[i])) {
|
||||
iguales = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (iguales) return resultado;
|
||||
}
|
||||
final nuevo = calcular();
|
||||
_claves = List<Object?>.of(claves);
|
||||
_resultado = nuevo;
|
||||
return nuevo;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user