Files
pluriwave/lib/estado/estado_ecualizador.dart
T
Javier Bautista Fernández 7daa6cfdb6
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m40s
fix(auto): clear stuck bluetooth EQ selection on device disconnect
The advanced EQ device list kept the green-dot selection on the last
connected Bluetooth device after it disconnected, instead of falling
back to the default preset.

- MainActivity.kt: onAudioDevicesRemoved recomputed the active output
  device via AudioManager.getDevices(), which can still momentarily
  report the just-removed sink (observed on Bluetooth A2DP). Removed
  device ids are now excluded explicitly instead of trusting
  getDevices() to already be current.
- estado_ecualizador.dart: cambiarMultiDeviceEnabled() re-seeds
  dispositivoActualId from a fresh query when the toggle turns back
  on, matching cargarPersistido(), so a stale id from before the
  toggle flip can't leave the dot pinned to a disconnected device.
2026-07-20 12:03:59 +02:00

508 lines
18 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 {
/// Composite-placeholder id prefix (bt-device-identity ADR-6): marks a
/// device whose real Bluetooth MAC is not yet known (BLUETOOTH_CONNECT
/// denied or unresolved). Devices with this prefix still update the
/// platform-name cache but never auto-create a `presetsDispositivo` entry.
static const _prefijoPlaceholderCompuesto = 'bt_a2dp:name:';
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;
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 _sembrarDispositivoActual();
}
} catch (_) {
_presetPrincipal = PresetEcualizador.flat;
_presetActual = PresetEcualizador.flat;
_activo = true;
_eqMultiDeviceEnabled = false;
_presetsEmisoraMap.clear();
_presetsDispositivo.clear();
_presetsMatriz.clear();
_nombresDispositivos.clear();
}
}
/// 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';
}
}
/// 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.
final esBase = dispositivo.tipo == TipoDispositivo.altavozInterno;
final esPlaceholderCompuesto = dispositivo.id.startsWith(
_prefijoPlaceholderCompuesto,
);
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();
}
/// 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;
}
Future<void> cambiarActivo(bool activo) async {
_activo = activo;
await servicio.guardarActivo(activo);
await audio.setEcualizadorActivo(activo);
if (activo) {
await audio.aplicarPreset(_presetActual);
}
notifyListeners();
}
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();
}
}