Files
pluriwave/lib/estado/estado_ecualizador.dart
T
FreeTLab b17c582572
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m47s
fix(devices): cache platform device names, dedupe placeholder ids, purge collided EQ entries
Dart half of bt-device-identity. EstadoEcualizador now caches each
device's platform-reported name in memory so the settings screen
shows the device's own Bluetooth name instead of its raw id when no
custom rename exists, and skips auto-creating preset entries for the
composite-placeholder sentinel. Enabling multi-device EQ triggers the
Bluetooth permission request through the new channel contract. A
flag-guarded one-time migration purges only entries keyed by the
exact literal placeholder id from the three per-device preference
maps, since those collided entries cannot be attributed to a device.

Work unit 2/2 of bt-device-identity (Dart state + migration).
2026-07-11 00:25:00 +02:00

500 lines
17 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();
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();
}
}