Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.
1. Ecualizador: el estado no tenia dueño unico
El handler arrancaba con `_ecualizadorActivo = true` a fuego. El valor
persistido solo llegaba por EstadoEcualizador.cargarPersistido(), alcanzable
unicamente desde el arbol de widgets, que un arranque headless de Android Auto
nunca construye. Resultado: el coche reproducia con el EQ forzado a ON mientras
disco e interfaz decian OFF.
Ahora registrarHandler siembra el flag desde disco en todos los motores y
setEcualizadorActivo persiste por su cuenta, asi que un toggle desde el coche o
la notificacion sobrevive sin EstadoEcualizador. _resincronizarConHandler pasa
a ser adopcion pura de interfaz.
Ademas mapearGananciaNativa enviaba 0 dB al punto MEDIO del rango nativo. Con
un getBandLevelRange() asimetrico, un preset plano metia varios dB de boost
real: la causa del "suena muy alto con el boton apagado". Reescrito para
escalar cada lado contra su propio extremo, de modo que 0 dB es siempre 0.
El dispatch de customAction no tenia ningun test. Se extrae decidirToggleEq y
se cubre contra el handler real. El efecto nativo se re-asierta al reactivarse
el reproductor, porque AudioEffect.setEnabled de just_audio es un no-op
mientras la plataforma esta desacoplada.
2. Musica Local no aparecia en el arbol de Android Auto
hayCarpetaConfigurada() consultaba MethodChannel('pluriwave/file_actions'),
registrado solo en MainActivity.configureFlutterEngine. Sin Activity no hay
handler, invokeMethod lanza MissingPluginException y el catch la confundia con
"permiso revocado", omitiendo el nodo. No dependia del entitlement.
La logica SAF sale a packages/pluriwave_file_actions, un paquete plugin local.
El motor headless que crea audio_service ejecuta GeneratedPluginRegistrant en
su constructor, asi que el canal queda registrado en ambos motores. Repuntar el
manifest a una subclase de AudioService no era viable: AudioServicePlugin
enlaza por ComponentName explicito y la app perderia el audio.
EstadoCarpetaLocal de tres valores separa "sin carpeta" de "canal no
disponible"; la raiz decide por la URI persistida y el subarbol muestra un item
explicativo en vez de una carpeta vacia. La invalidacion del arbol cacheado se
dispara al reanudar con el coche ya suscrito; el guardia anterior miraba
View.maybeOf, que bajo runApp siempre existe, por lo que se gastaba en el
arranque headless y no volvia a dispararse.
3. El paywall bloqueaba las compras
restorePurchases() de in_app_purchase_android emite siempre, y con lista vacia
si no hay nada que restaurar. El `if (compras.isEmpty) return;` se la tragaba,
noEncontrada nunca se emitia y la rama que limpia _compraEnCurso estaba muerta
en produccion. Como comprar y restaurar comparten ese flag, un usuario sin
compras que pulsaba restaurar se quedaba sin poder comprar.
Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
774 lines
30 KiB
Dart
774 lines
30 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter/foundation.dart';
|
||
|
||
import '../modelos/dispositivo_audio.dart';
|
||
import '../modelos/preset_ecualizador.dart';
|
||
import '../servicios/servicio_audio.dart';
|
||
import '../servicios/servicio_dispositivo_audio.dart';
|
||
import '../servicios/servicio_ecualizador.dart';
|
||
import '../servicios/servicio_presets_personalizados.dart';
|
||
|
||
/// Equalizer state extracted from `EstadoRadio` (S4-R1).
|
||
///
|
||
/// Owns the main preset, the per-station preset map, the current (applied)
|
||
/// preset and the enabled flag, plus their persistence through
|
||
/// [ServicioEcualizador] and their application through [ServicioAudio].
|
||
/// Notifies ONLY its own listeners — EQ changes must not rebuild
|
||
/// `EstadoRadio` consumers (S4-R1-A, S4-R5).
|
||
///
|
||
/// Multi-device EQ extension (Phase 5): when [eqMultiDeviceEnabled] is true,
|
||
/// resolves the active preset through a 4-level hierarchy:
|
||
/// 1. presetsMatriz["stationUuid:deviceId"]
|
||
/// 2. presetsEmisoraMap[stationUuid]
|
||
/// 3. presetsDispositivo[deviceId]
|
||
/// 4. presetPrincipal
|
||
///
|
||
/// When the toggle is false, resolution falls back to the original 2-level
|
||
/// hierarchy (station → global) — zero behavioral change vs. prior releases.
|
||
class EstadoEcualizador extends ChangeNotifier {
|
||
EstadoEcualizador({
|
||
required this.audio,
|
||
ServicioEcualizador? servicio,
|
||
ServicioDispositivoAudio? dispositivoAudio,
|
||
ServicioPresetsPersonalizados? presetsPersonalizadosService,
|
||
String? Function()? emisoraActualUuid,
|
||
}) : servicio = servicio ?? ServicioEcualizador(),
|
||
_presetsPersonalizadosService =
|
||
presetsPersonalizadosService ?? ServicioPresetsPersonalizados(),
|
||
_dispositivoAudio = dispositivoAudio,
|
||
_emisoraActualUuid = emisoraActualUuid ?? (() => null) {
|
||
_escucharCambiosEqDesdeHandler();
|
||
}
|
||
|
||
final ServicioAudio audio;
|
||
final ServicioEcualizador servicio;
|
||
final ServicioDispositivoAudio? _dispositivoAudio;
|
||
|
||
/// Persistence for user-named custom presets (design ADR-5 hazard box).
|
||
/// Deliberately a SEPARATE service/key from [servicio] — see
|
||
/// [cargarPresetsPersonalizados] for why its load is not folded into
|
||
/// [cargarPersistido].
|
||
final ServicioPresetsPersonalizados _presetsPersonalizadosService;
|
||
|
||
/// Callback into the owner (EstadoRadio) for the currently playing station;
|
||
/// keeps this notifier free of any station-list coupling.
|
||
final String? Function() _emisoraActualUuid;
|
||
|
||
final Map<String, PresetEcualizador> _presetsEmisoraMap = {};
|
||
|
||
/// Per-device presets: deviceId → PresetEcualizador.
|
||
final Map<String, PresetEcualizador> _presetsDispositivo = {};
|
||
|
||
/// Matrix presets: "stationUuid:deviceId" → PresetEcualizador.
|
||
final Map<String, PresetEcualizador> _presetsMatriz = {};
|
||
|
||
/// Custom display names for devices: deviceId → custom name.
|
||
final Map<String, String> _nombresDispositivos = {};
|
||
|
||
/// Last-seen platform (Bluetooth/productName) name per deviceId.
|
||
///
|
||
/// In-memory only (bt-device-identity ADR-4) — NOT persisted. Devices
|
||
/// re-report their name on every enumeration, so this cache self-heals
|
||
/// every session without needing a SharedPreferences key or migration.
|
||
final Map<String, String> _nombresPlataforma = {};
|
||
|
||
/// User-named custom presets (WU13). Loaded explicitly via
|
||
/// [cargarPresetsPersonalizados], not as part of [cargarPersistido] —
|
||
/// see that method's doc for why.
|
||
List<PresetEcualizador> _presetsPersonalizados = [];
|
||
|
||
PresetEcualizador _presetPrincipal = PresetEcualizador.flat;
|
||
PresetEcualizador _presetActual = PresetEcualizador.flat;
|
||
bool _activo = true;
|
||
bool _eqMultiDeviceEnabled = false;
|
||
String? _dispositivoActualId;
|
||
StreamSubscription<DispositivoAudio>? _deviceSub;
|
||
Future<void>? _refrescoEnCurso;
|
||
|
||
/// Catches a car/notification-initiated EQ change that bypasses this
|
||
/// class entirely (eq-sync-superficies): `accionEqToggle` calls
|
||
/// `PluriWaveAudioHandler.setEcualizadorActivo` directly, and
|
||
/// `seleccionarPresetEqPorMediaId` calls `aplicarPreset` directly — both
|
||
/// mutate ONLY the handler's own `_ecualizadorActivo`/`_presetActual`
|
||
/// fields, never [audio]'s owner ([EstadoEcualizador]). Mirrors the exact
|
||
/// shape `EstadoRadio._escucharErroresReproduccion` already uses for the
|
||
/// equivalent `playFromMediaId` gap: on every [ServicioAudio.estadoStream]
|
||
/// tick (which the handler already re-emits on any EQ change via
|
||
/// `_actualizarControlesEq()`, regardless of who triggered it), compare
|
||
/// the handler's current EQ state against our cached copy and adopt it on
|
||
/// divergence.
|
||
///
|
||
/// Since eq-estado-unico this is a DISPLAY concern only. The handler owns
|
||
/// the flag and persists it itself, so this subscription no longer closes
|
||
/// a persistence gap — it just keeps the phone's toggle showing what the
|
||
/// engine is really doing. It also cannot be the fix on its own: it exists
|
||
/// only while an [EstadoEcualizador] does, and the headless Android Auto
|
||
/// engine that produced the bug report never builds one.
|
||
StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudioEq;
|
||
|
||
PresetEcualizador get presetActual => _presetActual;
|
||
PresetEcualizador get presetPrincipal => _presetPrincipal;
|
||
bool get activo => _activo;
|
||
bool get disponible => audio.ecualizadorDisponible;
|
||
bool get eqMultiDeviceEnabled => _eqMultiDeviceEnabled;
|
||
String? get dispositivoActualId => _dispositivoActualId;
|
||
|
||
Map<String, String> get nombresDispositivos =>
|
||
Map.unmodifiable(_nombresDispositivos);
|
||
|
||
Map<String, PresetEcualizador> get presetsPorEmisora =>
|
||
Map.unmodifiable(_presetsEmisoraMap);
|
||
|
||
Map<String, PresetEcualizador> get presetsDispositivo =>
|
||
Map.unmodifiable(_presetsDispositivo);
|
||
|
||
Map<String, PresetEcualizador> get presetsMatriz =>
|
||
Map.unmodifiable(_presetsMatriz);
|
||
|
||
List<PresetEcualizador> get presetsPersonalizados =>
|
||
List.unmodifiable(_presetsPersonalizados);
|
||
|
||
bool get emisoraActualTienePresetPropio {
|
||
final uuid = _emisoraActualUuid();
|
||
if (uuid == null) return false;
|
||
return tienePresetPorEmisora(uuid);
|
||
}
|
||
|
||
bool tienePresetPorEmisora(String uuid) =>
|
||
_presetsEmisoraMap.containsKey(uuid);
|
||
|
||
PresetEcualizador? presetPorEmisora(String uuid) => _presetsEmisoraMap[uuid];
|
||
|
||
PresetEcualizador presetParaEmisora(String uuid) =>
|
||
_presetsEmisoraMap[uuid] ?? _presetPrincipal;
|
||
|
||
/// 4-level resolution hierarchy (ADR-4, spec requirement).
|
||
///
|
||
/// When [eqMultiDeviceEnabled] is false, falls back to 2-level (station → global).
|
||
PresetEcualizador presetEfectivo({
|
||
required String stationUuid,
|
||
required String deviceId,
|
||
}) {
|
||
if (!_eqMultiDeviceEnabled) {
|
||
// Original 2-level behavior: station → global.
|
||
return _presetsEmisoraMap[stationUuid] ?? _presetPrincipal;
|
||
}
|
||
|
||
// Level 1: station × device matrix.
|
||
final matrizKey = '$stationUuid:$deviceId';
|
||
if (_presetsMatriz.containsKey(matrizKey)) {
|
||
return _presetsMatriz[matrizKey]!;
|
||
}
|
||
// Level 2: station-only.
|
||
if (_presetsEmisoraMap.containsKey(stationUuid)) {
|
||
return _presetsEmisoraMap[stationUuid]!;
|
||
}
|
||
// Level 3: device-only.
|
||
if (_presetsDispositivo.containsKey(deviceId)) {
|
||
return _presetsDispositivo[deviceId]!;
|
||
}
|
||
// Level 4: global fallback.
|
||
return _presetPrincipal;
|
||
}
|
||
|
||
/// Resolves the effective preset for the current station and device.
|
||
PresetEcualizador _resolverPresetActivo() {
|
||
final uuid = _emisoraActualUuid();
|
||
if (!_eqMultiDeviceEnabled) {
|
||
return uuid != null
|
||
? (_presetsEmisoraMap[uuid] ?? _presetPrincipal)
|
||
: _presetPrincipal;
|
||
}
|
||
final deviceId = _dispositivoActualId;
|
||
if (uuid == null || deviceId == null) {
|
||
return _presetPrincipal;
|
||
}
|
||
return presetEfectivo(stationUuid: uuid, deviceId: deviceId);
|
||
}
|
||
|
||
/// Loads the persisted EQ configuration and applies it to the audio engine.
|
||
Future<void> cargarPersistido() async {
|
||
try {
|
||
final config = await servicio.cargar();
|
||
_presetPrincipal = config.principal;
|
||
_activo = config.activo;
|
||
_eqMultiDeviceEnabled = config.eqMultiDeviceEnabled;
|
||
_presetsEmisoraMap
|
||
..clear()
|
||
..addAll(config.porEmisora);
|
||
_presetsDispositivo
|
||
..clear()
|
||
..addAll(config.presetsDispositivo);
|
||
_presetsMatriz
|
||
..clear()
|
||
..addAll(config.presetsMatriz);
|
||
_nombresDispositivos
|
||
..clear()
|
||
..addAll(config.nombresDispositivos);
|
||
|
||
// Resolve active preset and apply it.
|
||
_presetActual = _resolverPresetActivo();
|
||
await audio.setEcualizadorActivo(_activo);
|
||
await audio.aplicarPreset(_presetActual);
|
||
|
||
// Subscribe to device changes only when toggle is on.
|
||
_configurarSuscripcionDispositivo();
|
||
|
||
// Seed current device immediately so presets resolve before first event.
|
||
if (_eqMultiDeviceEnabled) {
|
||
await _sembrarNombresEmparejados();
|
||
await _sembrarDispositivoActual();
|
||
}
|
||
} catch (_) {
|
||
_presetPrincipal = PresetEcualizador.flat;
|
||
_presetActual = PresetEcualizador.flat;
|
||
_activo = true;
|
||
_eqMultiDeviceEnabled = false;
|
||
_presetsEmisoraMap.clear();
|
||
_presetsDispositivo.clear();
|
||
_presetsMatriz.clear();
|
||
_nombresDispositivos.clear();
|
||
}
|
||
}
|
||
|
||
/// Fills [_nombresPlataforma] from the system's paired-device list.
|
||
///
|
||
/// A Bluetooth device only reports its own name while it is connected, so
|
||
/// without this a device the user never renamed shows its raw id whenever it
|
||
/// is switched off — which is most of the time. The bond list is the system's
|
||
/// own record and survives disconnection.
|
||
///
|
||
/// Seeded BEFORE [_sembrarDispositivoActual] so a live enumeration name (the
|
||
/// fresher of the two) overwrites the paired one rather than the reverse.
|
||
/// Never throws: a device with no resolvable name just falls back to its id.
|
||
Future<void> _sembrarNombresEmparejados() async {
|
||
final svc = _dispositivoAudio;
|
||
if (svc == null) return;
|
||
try {
|
||
final emparejados = await svc.obtenerNombresEmparejados();
|
||
for (final entry in emparejados.entries) {
|
||
if (entry.value.isEmpty) continue;
|
||
_nombresPlataforma['bt_a2dp:${entry.key}'] = entry.value;
|
||
}
|
||
} catch (_) {
|
||
// Permission denied or no adapter: keep whatever names we already have.
|
||
}
|
||
}
|
||
|
||
/// Queries the current device and seeds [_dispositivoActualId] without
|
||
/// waiting for a stream event. Falls back to `'builtin_speaker'` on error.
|
||
Future<void> _sembrarDispositivoActual() async {
|
||
final svc = _dispositivoAudio;
|
||
if (svc == null) return;
|
||
try {
|
||
final dispositivo = await svc.obtenerDispositivoActual();
|
||
await _onDispositivoCambiado(dispositivo);
|
||
} catch (_) {
|
||
_dispositivoActualId = 'builtin_speaker';
|
||
}
|
||
}
|
||
|
||
/// Re-syncs the active device after a possibly-missed native resync
|
||
/// (activity recreation over the cached engine, return to foreground,
|
||
/// opening the settings section): re-subscribes the platform event channel
|
||
/// via [ServicioDispositivoAudio.resubscribir] — re-registering the native
|
||
/// callback on the CURRENT activity — and re-seeds [_dispositivoActualId]
|
||
/// with a fresh query. No-op when the multi-device toggle is off or no
|
||
/// device service is injected; safe to call repeatedly. Concurrent calls
|
||
/// (app-resume observer + settings initState) share the same in-flight
|
||
/// refresh instead of racing [ServicioDispositivoAudio.resubscribir],
|
||
/// which would leak a native AudioDeviceCallback.
|
||
Future<void> refrescarDispositivoActual() {
|
||
final enCurso = _refrescoEnCurso;
|
||
if (enCurso != null) return enCurso;
|
||
final refresco = _refrescarDispositivoActual().whenComplete(() {
|
||
_refrescoEnCurso = null;
|
||
});
|
||
_refrescoEnCurso = refresco;
|
||
return refresco;
|
||
}
|
||
|
||
Future<void> _refrescarDispositivoActual() async {
|
||
if (!_eqMultiDeviceEnabled) return;
|
||
final svc = _dispositivoAudio;
|
||
if (svc == null) return;
|
||
try {
|
||
await svc.resubscribir();
|
||
} catch (_) {
|
||
// A failed resubscribe must never block the fresh-query re-seed below.
|
||
}
|
||
// Re-read the bond list too: the user may have paired or renamed a device
|
||
// in system settings since the app started, and this runs right as the
|
||
// device list becomes visible.
|
||
await _sembrarNombresEmparejados();
|
||
await _sembrarDispositivoActual();
|
||
}
|
||
|
||
/// Subscribes to the device change stream if multi-device is enabled.
|
||
void _configurarSuscripcionDispositivo() {
|
||
_deviceSub?.cancel();
|
||
_deviceSub = null;
|
||
|
||
final svc = _dispositivoAudio;
|
||
if (!_eqMultiDeviceEnabled || svc == null) return;
|
||
|
||
_deviceSub = svc.onDispositivoCambiado.listen(_onDispositivoCambiado);
|
||
}
|
||
|
||
/// Called when a device change event arrives.
|
||
Future<void> _onDispositivoCambiado(DispositivoAudio dispositivo) async {
|
||
if (!_eqMultiDeviceEnabled) return;
|
||
|
||
// Cache updates on every event regardless of whether a preset entry
|
||
// gets created below (bt-device-identity ADR-4).
|
||
_nombresPlataforma[dispositivo.id] = dispositivo.nombre;
|
||
_dispositivoActualId = dispositivo.id;
|
||
|
||
// First-seen device: copy the current resolved preset as its starting
|
||
// point. builtin_speaker is excluded: it must always fall through to
|
||
// the hierarchy (L4 global) instead of being pinned by a forced L3
|
||
// device-level copy, otherwise a later global-preset change would be
|
||
// masked by this stale entry for the base device. Composite-placeholder
|
||
// ids are also excluded (bt-device-identity ADR-6): they are transient
|
||
// fallback ids for a device whose real MAC is not yet known, so
|
||
// persisting a preset entry for them would create dead noise that never
|
||
// resolves to the eventual real-MAC id.
|
||
// Matched by id AND by type: the native layer historically reported the
|
||
// phone-speaker id for output types it could not name (LE Audio, car bus,
|
||
// dock), which arrive here as `desconocido` and would otherwise slip past a
|
||
// type-only check and persist an entry that hijacks the active-device
|
||
// indicator forever.
|
||
final esBase =
|
||
dispositivo.tipo == TipoDispositivo.altavozInterno ||
|
||
dispositivo.id == idAltavozInterno;
|
||
final esPlaceholderCompuesto = dispositivo.id.startsWith(
|
||
prefijoPlaceholderBtName,
|
||
);
|
||
if (!esBase &&
|
||
!esPlaceholderCompuesto &&
|
||
!_presetsDispositivo.containsKey(dispositivo.id)) {
|
||
final presetBase = _resolverPresetActivo();
|
||
_presetsDispositivo[dispositivo.id] = presetBase;
|
||
await servicio.guardarPresetDispositivo(dispositivo.id, presetBase);
|
||
}
|
||
|
||
// Re-resolve and apply the preset for the new device.
|
||
final resuelto = _resolverPresetActivo();
|
||
_presetActual = resuelto;
|
||
await audio.aplicarPreset(resuelto);
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Subscribes to [ServicioAudio.estadoStream] to catch a
|
||
/// car/notification-initiated EQ change (see [_suscripcionEstadoAudioEq]
|
||
/// doc for the full rationale).
|
||
void _escucharCambiosEqDesdeHandler() {
|
||
_suscripcionEstadoAudioEq = audio.estadoStream.listen((_) {
|
||
unawaited(_resincronizarConHandler());
|
||
});
|
||
}
|
||
|
||
/// Compares the handler's live EQ state ([ServicioAudio.ecualizadorActivo],
|
||
/// [ServicioAudio.presetActual]) against our cached [_activo]/
|
||
/// [_presetActual] and adopts the handler's value on divergence.
|
||
///
|
||
/// Deliberately never calls back into [audio] here (no
|
||
/// `setEcualizadorActivo`/`aplicarPreset`): doing so would re-trigger the
|
||
/// handler's own `_actualizarControlesEq()` re-push, which would tick
|
||
/// [ServicioAudio.estadoStream] again and re-enter this method forever.
|
||
/// Only a local field write and [notifyListeners] happen here, so a
|
||
/// divergence is resolved in a single pass.
|
||
///
|
||
/// It is now a PURE UI ADOPT — it does not persist (eq-estado-unico item
|
||
/// B). `PluriWaveAudioHandler` writes its own toggle through the port
|
||
/// `registrarHandler` injects, so the value is saved on every engine
|
||
/// rather than only on one that happens to have built a widget tree. This
|
||
/// method could never have been the owner of that fact: it only runs while
|
||
/// an [EstadoEcualizador] exists, and on the headless Android Auto engine
|
||
/// behind the bug report none ever does.
|
||
///
|
||
/// Wrapped in try/catch like every other handler-facing read in this
|
||
/// class (e.g. [_sembrarDispositivoActual]): a test double or an
|
||
/// unexpected platform state that makes [audio]'s EQ getters unavailable
|
||
/// must never crash the stream subscription — it just skips this tick.
|
||
Future<void> _resincronizarConHandler() async {
|
||
try {
|
||
final activoHandler = audio.ecualizadorActivo;
|
||
final presetHandler = audio.presetActual;
|
||
|
||
final activoDiverge = activoHandler != _activo;
|
||
final presetDiverge = presetHandler != _presetActual;
|
||
if (!activoDiverge && !presetDiverge) return;
|
||
|
||
if (activoDiverge) {
|
||
// Display-only adopt: the handler already persisted this value
|
||
// through its own write port before it ever reached us. See the
|
||
// doc above.
|
||
_activo = activoHandler;
|
||
}
|
||
if (presetDiverge) {
|
||
_presetActual = presetHandler;
|
||
}
|
||
|
||
notifyListeners();
|
||
} catch (_) {
|
||
// See doc above — never let a resync failure crash the app.
|
||
}
|
||
}
|
||
|
||
/// Applies [preset] to the audio engine and tracks it as current
|
||
/// WITHOUT persisting it (used when switching stations).
|
||
Future<void> aplicarPresetActivo(PresetEcualizador preset) async {
|
||
_presetActual = preset;
|
||
await audio.aplicarPreset(preset);
|
||
}
|
||
|
||
/// Enables or disables the multi-device EQ feature toggle.
|
||
///
|
||
/// When disabled, the device stream subscription is cancelled and resolution
|
||
/// immediately collapses to 2-level (station → global).
|
||
Future<void> cambiarMultiDeviceEnabled(
|
||
bool habilitado, {
|
||
bool notificar = true,
|
||
}) async {
|
||
_eqMultiDeviceEnabled = habilitado;
|
||
await servicio.guardarToggleMultiDispositivo(habilitado);
|
||
_configurarSuscripcionDispositivo();
|
||
|
||
// Re-seed the active device from a fresh query instead of leaving a
|
||
// stale _dispositivoActualId from before the toggle flip (it would
|
||
// otherwise only self-correct on the next native device-change event).
|
||
if (_eqMultiDeviceEnabled) {
|
||
await _sembrarDispositivoActual();
|
||
}
|
||
|
||
if (notificar) notifyListeners();
|
||
}
|
||
|
||
/// Requests `BLUETOOTH_CONNECT` (API 31+) at the point the
|
||
/// device-management UI is opened (bt-device-identity ADR-1).
|
||
///
|
||
/// Thin passthrough to the injected device service so callers don't need
|
||
/// [ServicioDispositivoAudio] wired as its own top-level `Provider` (Task
|
||
/// 4.8 — `ServicioDispositivoAudio` is only ever constructed inside
|
||
/// `EstadoRadio` today, not exposed via the provider tree; routing through
|
||
/// here avoids adding wiring the tree doesn't already have). Returns false
|
||
/// when no device service is injected.
|
||
Future<bool> solicitarPermisoBluetooth() async {
|
||
final svc = _dispositivoAudio;
|
||
if (svc == null) return false;
|
||
return svc.solicitarPermisoBluetooth();
|
||
}
|
||
|
||
Future<void> cambiarPresetPrincipal(
|
||
PresetEcualizador preset, {
|
||
bool notificar = true,
|
||
}) async {
|
||
_presetPrincipal = preset;
|
||
await servicio.guardarPrincipal(preset);
|
||
|
||
final uuid = _emisoraActualUuid();
|
||
final puedeAplicarAhora =
|
||
uuid == null || !_presetsEmisoraMap.containsKey(uuid);
|
||
if (puedeAplicarAhora) {
|
||
await aplicarPresetActivo(preset);
|
||
}
|
||
|
||
if (notificar) notifyListeners();
|
||
}
|
||
|
||
Future<void> guardarPresetPorEmisora(
|
||
String uuid,
|
||
PresetEcualizador preset, {
|
||
bool notificar = true,
|
||
}) async {
|
||
_presetsEmisoraMap[uuid] = preset;
|
||
await servicio.guardarPorEmisora(uuid, preset);
|
||
if (_emisoraActualUuid() == uuid) {
|
||
await aplicarPresetActivo(preset);
|
||
}
|
||
if (notificar) notifyListeners();
|
||
}
|
||
|
||
Future<void> habilitarPresetPorEmisora(
|
||
String uuid, {
|
||
PresetEcualizador? base,
|
||
bool notificar = true,
|
||
}) async {
|
||
final presetBase = base ?? _presetsEmisoraMap[uuid] ?? _presetPrincipal;
|
||
await guardarPresetPorEmisora(uuid, presetBase, notificar: notificar);
|
||
}
|
||
|
||
Future<void> deshabilitarPresetPorEmisora(
|
||
String uuid, {
|
||
bool notificar = true,
|
||
}) async {
|
||
_presetsEmisoraMap.remove(uuid);
|
||
await servicio.eliminarPorEmisora(uuid);
|
||
if (_emisoraActualUuid() == uuid) {
|
||
await aplicarPresetActivo(_presetPrincipal);
|
||
}
|
||
if (notificar) notifyListeners();
|
||
}
|
||
|
||
Future<void> cambiarModoEmisoraActual({required bool usarPropio}) async {
|
||
final uuid = _emisoraActualUuid();
|
||
if (uuid == null) return;
|
||
if (usarPropio) {
|
||
await habilitarPresetPorEmisora(uuid);
|
||
} else {
|
||
await deshabilitarPresetPorEmisora(uuid);
|
||
}
|
||
}
|
||
|
||
/// Loads the persisted custom-preset list (WU13, `eq-custom-presets`
|
||
/// spec — the Settings EQ screen's preset chip row).
|
||
///
|
||
/// Deliberately NOT part of [cargarPersistido]: that method is exercised
|
||
/// roughly 30 times by `estado_ecualizador_test.dart` — one of this
|
||
/// change's protected EQ test files, required to pass **unmodified** —
|
||
/// via Fakes for [servicio]/[_dispositivoAudio] only, with no
|
||
/// SharedPreferences awareness anywhere in that file. Folding a third,
|
||
/// always-real-by-default collaborator into [cargarPersistido] would
|
||
/// introduce a real SharedPreferences call into every one of those
|
||
/// cases. Called explicitly by the Settings EQ screen instead, the same
|
||
/// way `refrescarDispositivoActual` is already called from screen
|
||
/// `initState`, not from [cargarPersistido].
|
||
Future<void> cargarPresetsPersonalizados() async {
|
||
_presetsPersonalizados = await _presetsPersonalizadosService.listar();
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Saves the CURRENT effective preset's bands (`presetActual`) as a new
|
||
/// named custom preset (spec "Custom Preset Save").
|
||
///
|
||
/// Returns `false` — and persists nothing — when [nombre] is empty or
|
||
/// whitespace-only (spec "Custom Preset Naming Validates Non-Empty
|
||
/// Input"), the same non-crashing validate-before-persist shape
|
||
/// [renombrarDispositivo] already uses elsewhere in this class.
|
||
Future<bool> guardarPresetPersonalizado(String nombre) async {
|
||
final nombreValido = nombre.trim();
|
||
if (nombreValido.isEmpty) return false;
|
||
|
||
final preset = PresetEcualizador(
|
||
nombre: nombreValido,
|
||
bandas: List<double>.from(_presetActual.bandas),
|
||
);
|
||
await _presetsPersonalizadosService.guardar(preset);
|
||
_presetsPersonalizados = await _presetsPersonalizadosService.listar();
|
||
notifyListeners();
|
||
return true;
|
||
}
|
||
|
||
/// Removes the custom preset named [nombre], if present.
|
||
Future<void> eliminarPresetPersonalizado(String nombre) async {
|
||
await _presetsPersonalizadosService.eliminar(nombre);
|
||
_presetsPersonalizados = await _presetsPersonalizadosService.listar();
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Persists a custom display name for [deviceId].
|
||
///
|
||
/// Empty names are silently ignored so the existing name is preserved.
|
||
/// No-op when the multi-device toggle is off.
|
||
Future<void> renombrarDispositivo(String deviceId, String nombre) async {
|
||
if (!_eqMultiDeviceEnabled) return;
|
||
final nombreTrimmed = nombre.trim();
|
||
if (nombreTrimmed.isEmpty) return;
|
||
_nombresDispositivos[deviceId] = nombreTrimmed;
|
||
await servicio.guardarNombresDispositivos(
|
||
Map.unmodifiable(_nombresDispositivos),
|
||
);
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Persists a per-device EQ preset for [deviceId].
|
||
///
|
||
/// Guards on [_eqMultiDeviceEnabled]: no-op when toggle is off.
|
||
/// If [deviceId] is the currently active device, re-resolves and
|
||
/// applies the effective preset immediately.
|
||
Future<void> guardarPresetDispositivo(
|
||
String deviceId,
|
||
PresetEcualizador preset,
|
||
) async {
|
||
if (!_eqMultiDeviceEnabled) return;
|
||
_presetsDispositivo[deviceId] = preset;
|
||
await servicio.guardarPresetDispositivo(deviceId, preset);
|
||
if (_dispositivoActualId == deviceId) {
|
||
// Re-resolve using the full hierarchy (station/matrix may override).
|
||
final resuelto = _resolverPresetActivo();
|
||
_presetActual = resuelto;
|
||
await audio.aplicarPreset(resuelto);
|
||
}
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Forgets [deviceId] completely: its device preset, its custom name and
|
||
/// every matrix entry that targets it.
|
||
///
|
||
/// Lets the user clear stale or duplicate rows from the known-devices list.
|
||
/// The device is NOT prevented from coming back: if it connects again it is
|
||
/// re-registered from scratch, which is exactly how a user recovers from a
|
||
/// bad entry. When the removed device is the active one, the effective preset
|
||
/// is re-resolved so playback immediately follows the remaining hierarchy
|
||
/// instead of keeping the deleted preset applied.
|
||
Future<void> eliminarDispositivo(String deviceId) async {
|
||
_presetsDispositivo.remove(deviceId);
|
||
_nombresDispositivos.remove(deviceId);
|
||
_nombresPlataforma.remove(deviceId);
|
||
_presetsMatriz.removeWhere((clave, _) {
|
||
final separador = clave.indexOf(':');
|
||
if (separador == -1) return false;
|
||
return clave.substring(separador + 1) == deviceId;
|
||
});
|
||
|
||
await servicio.eliminarDispositivo(deviceId);
|
||
|
||
if (_dispositivoActualId == deviceId) {
|
||
final resuelto = _resolverPresetActivo();
|
||
_presetActual = resuelto;
|
||
await audio.aplicarPreset(resuelto);
|
||
}
|
||
notifyListeners();
|
||
}
|
||
|
||
/// Returns the stored custom name for [deviceId], or an empty string if none.
|
||
String obtenerNombreDispositivo(String deviceId) =>
|
||
_nombresDispositivos[deviceId] ?? '';
|
||
|
||
/// Returns the last-seen platform name for [deviceId], or an empty string
|
||
/// if none has been observed yet (bt-device-identity ADR-4).
|
||
String nombrePlataforma(String deviceId) =>
|
||
_nombresPlataforma[deviceId] ?? '';
|
||
|
||
/// Resolves the display name for [deviceId] using the fallback chain:
|
||
/// custom name → [platformName] → raw [deviceId].
|
||
String nombreVisible(String deviceId, String platformName) {
|
||
final custom = _nombresDispositivos[deviceId];
|
||
if (custom != null && custom.isNotEmpty) return custom;
|
||
if (platformName.isNotEmpty) return platformName;
|
||
return deviceId;
|
||
}
|
||
|
||
/// Enables or disables the equalizer.
|
||
///
|
||
/// Engine FIRST, disk last. The previous order persisted before telling the
|
||
/// engine, so two quick taps raced on a disk write: when the first write
|
||
/// resolved last, the engine received the FIRST tap's value after the second
|
||
/// one and the checkbox read enabled while the sound stayed flat. Issuing the
|
||
/// engine call before any `await` means overlapping taps reach the engine in
|
||
/// tap order, so the last tap always wins.
|
||
///
|
||
/// Each step then re-checks [_activo]: a newer tap that landed mid-flight
|
||
/// owns the outcome, and this superseded call must not apply a preset or
|
||
/// persist a value the user has already changed their mind about.
|
||
Future<void> cambiarActivo(bool activo) async {
|
||
_activo = activo;
|
||
notifyListeners();
|
||
|
||
await audio.setEcualizadorActivo(activo);
|
||
if (_activo != activo) return;
|
||
if (activo) {
|
||
await audio.aplicarPreset(_presetActual);
|
||
if (_activo != activo) return;
|
||
}
|
||
await servicio.guardarActivo(activo);
|
||
}
|
||
|
||
Future<void> cambiarPreset(
|
||
PresetEcualizador preset, {
|
||
bool guardarPorEmisora = true,
|
||
}) async {
|
||
final uuid = _emisoraActualUuid();
|
||
final usarPresetPropio =
|
||
guardarPorEmisora &&
|
||
uuid != null &&
|
||
_presetsEmisoraMap.containsKey(uuid);
|
||
|
||
if (usarPresetPropio) {
|
||
await guardarPresetPorEmisora(uuid, preset);
|
||
return;
|
||
}
|
||
await cambiarPresetPrincipal(preset);
|
||
}
|
||
|
||
Future<void> cambiarBanda(int index, double db) async {
|
||
final bandas = List<double>.from(_presetActual.bandas);
|
||
if (index < 0 || index >= bandas.length) return;
|
||
|
||
bandas[index] = db;
|
||
final modificado = PresetEcualizador(
|
||
nombre: 'Personalizado',
|
||
bandas: bandas,
|
||
);
|
||
await cambiarPreset(modificado);
|
||
}
|
||
|
||
/// Replaces the whole EQ configuration (backup import path): persists it,
|
||
/// re-applies the preset effective for the current station and notifies.
|
||
///
|
||
/// [activo] is the imported on/off toggle (S4-R4/eq-export-toggle). When
|
||
/// `null` — an old backup with no `ecualizadorActivo` field — the CURRENT
|
||
/// toggle is left untouched: an absent flag must never flip the user's live
|
||
/// setting to an arbitrary value. When non-null, applies it through
|
||
/// [cambiarActivo], the same path a manual toggle uses, so the import
|
||
/// persists it AND pushes it to the live audio engine instead of just
|
||
/// updating [_activo] in memory.
|
||
Future<void> importarConfiguracion({
|
||
required PresetEcualizador principal,
|
||
required Map<String, PresetEcualizador> porEmisora,
|
||
Map<String, PresetEcualizador>? presetsDispositivo,
|
||
Map<String, PresetEcualizador>? presetsMatriz,
|
||
bool? eqMultiDeviceEnabled,
|
||
bool? activo,
|
||
}) async {
|
||
_presetPrincipal = principal;
|
||
_presetsEmisoraMap
|
||
..clear()
|
||
..addAll(porEmisora);
|
||
if (presetsDispositivo != null) {
|
||
_presetsDispositivo
|
||
..clear()
|
||
..addAll(presetsDispositivo);
|
||
}
|
||
if (presetsMatriz != null) {
|
||
_presetsMatriz
|
||
..clear()
|
||
..addAll(presetsMatriz);
|
||
}
|
||
if (eqMultiDeviceEnabled != null) {
|
||
_eqMultiDeviceEnabled = eqMultiDeviceEnabled;
|
||
}
|
||
|
||
await servicio.guardarConfiguracion(
|
||
ConfiguracionEcualizador(
|
||
principal: _presetPrincipal,
|
||
porEmisora: _presetsEmisoraMap,
|
||
activo: _activo,
|
||
eqMultiDeviceEnabled: _eqMultiDeviceEnabled,
|
||
presetsDispositivo: _presetsDispositivo,
|
||
presetsMatriz: _presetsMatriz,
|
||
),
|
||
);
|
||
|
||
final uuid = _emisoraActualUuid();
|
||
final presetEfectivoActual =
|
||
uuid == null ? _presetPrincipal : _resolverPresetActivo();
|
||
await aplicarPresetActivo(presetEfectivoActual);
|
||
|
||
if (activo != null) {
|
||
await cambiarActivo(activo);
|
||
}
|
||
|
||
notifyListeners();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_deviceSub?.cancel();
|
||
_suscripcionEstadoAudioEq?.cancel();
|
||
super.dispose();
|
||
}
|
||
}
|