Files
pluriwave/lib/estado/estado_ecualizador.dart
T
FreeTLab b183b3f3e5 fix(eq): stop the enable toggle from landing behind a disk write
cambiarActivo persisted BEFORE telling the audio engine, so two quick taps
raced on a SharedPreferences write. When the first write resolved last, the
engine received the FIRST tap's value after the second one: the checkbox read
enabled while the sound stayed flat, and toggling again could invert it the
other way. Reported as the equalizer connecting and disconnecting at random
and the checkbox disagreeing with what is audible.

Reorder to engine first, disk last. The engine call is now issued before any
await, so overlapping taps reach it in tap order and the last tap wins. Each
subsequent step re-checks _activo, so a call that a newer tap superseded
mid-flight neither applies a preset nor persists a value the user has already
changed their mind about. Persisting last also puts what the user HEARS ahead
of what is merely stored.

The regression test drives two opposite taps through a persistence fake whose
FIRST write is the slow one — the exact ordering hazard — and asserts the
engine ends matching the state the UI shows. It fails on the previous
ordering and passes on this one.

An earlier attempt serialized every engine mutation through a shared Future
lane. It fixed this case and deadlocked four widget tests: the lane field
outlived a tester.runAsync block, so a future created in the real async zone
was later chained from the fake-async zone that never advances it. Reverted
in favour of the ordering fix, which needs no cross-zone state.

Only the enable toggle is addressed here. The other reported symptom —
equalization seeming to come and go while playing — is not explained by this
race and is still open; the handler rebuilds the whole AndroidEqualizer on
every player recreation, which is the next place to look.
2026-07-28 13:32:57 +02:00

615 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
/// 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,
String? Function()? emisoraActualUuid,
}) : servicio = servicio ?? ServicioEcualizador(),
_dispositivoAudio = dispositivoAudio,
_emisoraActualUuid = emisoraActualUuid ?? (() => null);
final ServicioAudio audio;
final ServicioEcualizador servicio;
final ServicioDispositivoAudio? _dispositivoAudio;
/// 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 = {};
PresetEcualizador _presetPrincipal = PresetEcualizador.flat;
PresetEcualizador _presetActual = PresetEcualizador.flat;
bool _activo = true;
bool _eqMultiDeviceEnabled = false;
String? _dispositivoActualId;
StreamSubscription<DispositivoAudio>? _deviceSub;
Future<void>? _refrescoEnCurso;
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);
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();
}
/// 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);
}
}
/// 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.
Future<void> importarConfiguracion({
required PresetEcualizador principal,
required Map<String, PresetEcualizador> porEmisora,
Map<String, PresetEcualizador>? presetsDispositivo,
Map<String, PresetEcualizador>? presetsMatriz,
bool? eqMultiDeviceEnabled,
}) 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);
notifyListeners();
}
@override
void dispose() {
_deviceSub?.cancel();
super.dispose();
}
}