Files
pluriwave/lib/servicios/servicio_ecualizador.dart
T
FreeTLab 6b91ad88e8 fix: el ecualizador del coche aplica el preset real y en el orden correcto
Reportado desde el uso real: desde el movil el ecualizador va bien, pero el
boton de Android Auto a veces no hace nada y a veces suena como si se aplicara
una doble ecualizacion.

El preset del handler nunca se sembraba desde disco

`_presetActual` arrancaba en `flat` a fuego. registrarHandler sembraba el flag
de encendido pero no el preset, asi que en un motor donde la interfaz del
telefono nunca corrio -- el que arranca Android Auto -- el toggle del coche
aplicaba `flat`, o lo que hubiera quedado, en vez del preset del usuario. Es la
misma clase de fallo que ya se corrigio para el flag: aquel recibio un puerto
headless y el preset se quedo fuera. Ahora tiene el suyo, con la misma forma:
opcional, el fallo se traza y cae al valor por defecto, nunca propaga.

La siembra respeta un preset ya elegido por EstadoEcualizador, que es mas rico
que la clave principal, para que la lectura de disco en vuelo no lo pise.

El efecto se habilitaba antes de escribir las ganancias

La ruta era setEnabled -> setEnabled -> ganancias: `aplicarPreset` volvia a
llamar a setEnabled por su cuenta. Entre la habilitacion y la escritura sonaban
las ganancias anteriores, y ese hueco es lo que se percibia como doble
ecualizacion. Ahora una funcion pura devuelve los pasos en orden y ambas rutas
la recorren: ganancias primero, habilitacion despues.

Las ganancias NO se resetean al apagar, y es deliberado: setEnabled(false)
puentea el efecto sin liberarlo ni limpiar sus niveles, y la ruta de encendido
los reescribe enteros antes de habilitar, asi que no queda ninguna ventana de
ganancia rancia que un reset pudiera cerrar.

El boton desaparecia en cada cambio de emisora

`_recrearPlayer` bajaba `_eqDisponible` sin republicar controles, asi que cada
cambio de emisora emitia al menos un estado sin la accion de EQ. Peor: las
llamadas nativas estan detras de ese flag, de modo que un toggle en esa ventana
cambiaba el icono sin tocar el audio. Ahora el unico que lo escribe es
`_activarEcualizador`.

Mantenerlo optimista exigia quitar de la ruta del toggle el `await
_eq.parameters`, que es un Completer que solo se completa cuando el reproductor
se engancha: esperarlo dejaba el boton pendiente durante toda la carga, y para
siempre si la carga fallaba. Se cachean los parametros al activarse.

Los fallos nativos dejan de ser mudos

El `catch (_) {}` ocultaba que la llamada nativa habia fallado y dejaba el icono
afirmando un estado que el audio no tenia. Ahora se traza, y un fallo al
habilitar revierte el flag, republica los controles y no persiste.

EstadoEcualizador adopta lo que el motor acepto en vez de asumir que su peticion
prospero: sin eso, el telefono escribia en disco un valor que el handler acababa
de rechazar, reabriendo la divergencia que el dueño unico habia cerrado.

Suite completa: 1515 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-06 00:10:58 +02:00

509 lines
19 KiB
Dart

import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/preset_ecualizador.dart';
import 'persistencia_tolerante.dart';
import 'servicio_dispositivo_audio.dart'
show idAltavozInterno, prefijoDispositivoOtro;
class ConfiguracionEcualizador {
const ConfiguracionEcualizador({
required this.principal,
required this.porEmisora,
this.activo = true,
this.eqMultiDeviceEnabled = false,
this.presetsDispositivo = const {},
this.presetsMatriz = const {},
this.nombresDispositivos = const {},
});
final PresetEcualizador principal;
final Map<String, PresetEcualizador> porEmisora;
final bool activo;
/// Feature toggle: when false all multi-device logic is bypassed.
final bool eqMultiDeviceEnabled;
/// 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;
}
class ServicioEcualizador {
ServicioEcualizador({SharedPreferences? prefs}) : _prefs = prefs;
static const _keyPresetPrincipal = 'eq_preset_principal_v1';
static const _keyPresetsPorEmisora = 'eq_presets_por_emisora_v1';
static const _keyActivo = 'eq_activo_v1';
static const _keyMultiDeviceEnabled = 'eq_multi_device_enabled_v1';
static const _keyPresetsPorDispositivo = 'eq_preset_por_dispositivo_v1';
static const _keyPresetsMatriz = 'eq_presets_matriz_v1';
static const _keyNombresDispositivos = 'eq_nombres_dispositivos_v1';
/// bt-device-identity ADR-5: guard flag for the one-time placeholder purge.
static const _keyPlaceholderPurgaHecha = 'eq_placeholder_purge_done_v1';
/// bt-device-identity ADR-3: the exact OS placeholder MAC, in the
/// `bt_a2dp:`-prefixed id shape used across all device-keyed SP maps.
static const _placeholderMacLiteral = 'bt_a2dp:02:00:00:00:00:00';
/// Guard flag for the one-time `builtin_speaker` collision purge.
static const _keyColisionBasePurgaHecha = 'eq_builtin_speaker_purge_done_v1';
final SharedPreferences? _prefs;
/// Injected startup instance (S3-R4); getInstance() is only a fallback.
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
Future<ConfiguracionEcualizador> cargar() async {
await migrarClavesPlaceholder();
await migrarColisionAltavozInterno();
final prefs = await _resolverPrefs();
final principal = _leerPresetPrincipal(prefs);
final porEmisora = _leerPresetsPorEmisora(prefs);
final presetsDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz);
final nombresDispositivos = _leerMapaStrings(
prefs,
_keyNombresDispositivos,
);
return ConfiguracionEcualizador(
principal: principal,
porEmisora: porEmisora,
activo: prefs.getBool(_keyActivo) ?? true,
eqMultiDeviceEnabled: prefs.getBool(_keyMultiDeviceEnabled) ?? false,
presetsDispositivo: presetsDispositivo,
presetsMatriz: presetsMatriz,
nombresDispositivos: nombresDispositivos,
);
}
/// One-time guarded migration (bt-device-identity ADR-5): purges entries
/// keyed by the exact OS placeholder MAC ([_placeholderMacLiteral]) from
/// all three device-keyed SP maps. Idempotent — short-circuits via
/// [_keyPlaceholderPurgaHecha] after the first successful run. Every other
/// entry (including near-miss keys) is preserved byte-for-byte.
Future<void> migrarClavesPlaceholder() async {
final prefs = await _resolverPrefs();
if (prefs.getBool(_keyPlaceholderPurgaHecha) ?? false) return;
await _purgarDeviceId(prefs, _placeholderMacLiteral);
await prefs.setBool(_keyPlaceholderPurgaHecha, true);
}
/// One-time guarded migration: purges entries keyed by [idAltavozInterno].
///
/// `MainActivity.deviceToMap` used to hand the phone-speaker id to ANY output
/// type it could not name (LE Audio, car bus, dock). Dart then created a
/// device entry under that id, and from then on every playback through the
/// phone's own speaker matched it — permanently flagging whatever the user
/// had renamed it to as the active device. The native id is fixed; this
/// clears what the collision already persisted. Idempotent via
/// [_keyColisionBasePurgaHecha]; every other entry survives byte-for-byte.
Future<void> migrarColisionAltavozInterno() async {
final prefs = await _resolverPrefs();
if (prefs.getBool(_keyColisionBasePurgaHecha) ?? false) return;
await _purgarDeviceId(prefs, idAltavozInterno);
await _purgarPrefijoDispositivo(prefs, prefijoDispositivoOtro);
await prefs.setBool(_keyColisionBasePurgaHecha, true);
}
/// Removes every device-keyed entry whose id starts with [prefijo].
///
/// Used for the `other:` namespace: a build that ranked every unlisted
/// `AudioDeviceInfo` type above the phone speaker selected permanent internal
/// sinks (TYPE_FM on Xiaomi, TYPE_BUILTIN_SPEAKER_SAFE elsewhere) as the
/// active output and persisted rows for them. Genuine external outputs in
/// this namespace are re-registered on their next connection, so clearing the
/// whole prefix costs nothing and needs no per-type knowledge here.
Future<void> _purgarPrefijoDispositivo(
SharedPreferences prefs,
String prefijo,
) async {
final presetsPorDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
final dispositivos =
presetsPorDispositivo.keys
.where((clave) => clave.startsWith(prefijo))
.toList();
final nombres = _leerMapaStrings(prefs, _keyNombresDispositivos);
final nombresAPurgar =
nombres.keys.where((clave) => clave.startsWith(prefijo)).toList();
final matriz = _leerMapa(prefs, _keyPresetsMatriz);
final matrizAPurgar =
matriz.keys.where((clave) {
final separador = clave.indexOf(':');
if (separador == -1) return false;
return clave.substring(separador + 1).startsWith(prefijo);
}).toList();
for (final clave in dispositivos) {
presetsPorDispositivo.remove(clave);
}
for (final clave in nombresAPurgar) {
nombres.remove(clave);
}
for (final clave in matrizAPurgar) {
matriz.remove(clave);
}
if (dispositivos.isNotEmpty) {
await _guardarMapa(
prefs,
_keyPresetsPorDispositivo,
presetsPorDispositivo,
);
}
if (nombresAPurgar.isNotEmpty) {
await _guardarMapaStrings(prefs, _keyNombresDispositivos, nombres);
}
if (matrizAPurgar.isNotEmpty) {
await _guardarMapa(prefs, _keyPresetsMatriz, matriz);
}
}
/// Removes every trace of [deviceId] from the three device-keyed SP maps.
///
/// Shared by the guarded migrations and by [eliminarDispositivo]; each map is
/// only rewritten when the key was actually present.
Future<void> _purgarDeviceId(SharedPreferences prefs, String deviceId) async {
final presetsPorDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
if (presetsPorDispositivo.remove(deviceId) != null) {
await _guardarMapa(
prefs,
_keyPresetsPorDispositivo,
presetsPorDispositivo,
);
}
// Matrix keys are "stationUuid:deviceId"; split on the FIRST colon only
// (station UUIDs are RFC4122 and contain no colons — multi-device-eq
// ADR-3), since deviceId itself may contain colons (e.g. a MAC-based id).
final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz);
final clavesMatrizAPurgar =
presetsMatriz.keys.where((clave) {
final separador = clave.indexOf(':');
if (separador == -1) return false;
return clave.substring(separador + 1) == deviceId;
}).toList();
if (clavesMatrizAPurgar.isNotEmpty) {
for (final clave in clavesMatrizAPurgar) {
presetsMatriz.remove(clave);
}
await _guardarMapa(prefs, _keyPresetsMatriz, presetsMatriz);
}
final nombresDispositivos = _leerMapaStrings(
prefs,
_keyNombresDispositivos,
);
if (nombresDispositivos.remove(deviceId) != null) {
await _guardarMapaStrings(
prefs,
_keyNombresDispositivos,
nombresDispositivos,
);
}
}
/// Forgets a device completely: its preset, its custom name and every matrix
/// entry that targets it. Used by the "remove device" action so the user can
/// clear stale or duplicate entries from the known-devices list.
Future<void> eliminarDispositivo(String deviceId) async {
final prefs = await _resolverPrefs();
await _purgarDeviceId(prefs, deviceId);
}
Future<void> guardarPrincipal(PresetEcualizador preset) async {
final prefs = await _resolverPrefs();
await prefs.setString(_keyPresetPrincipal, jsonEncode(preset.toJson()));
}
Future<void> guardarPorEmisora(String uuid, PresetEcualizador preset) async {
final prefs = await _resolverPrefs();
final mapa = _leerPresetsPorEmisora(prefs);
mapa[uuid] = preset;
await _guardarPresetsPorEmisora(prefs, mapa);
}
Future<void> guardarActivo(bool activo) async {
final prefs = await _resolverPrefs();
await prefs.setBool(_keyActivo, activo);
}
/// The persisted equalizer on/off flag, or `null` when the user has never
/// touched the toggle.
///
/// Deliberately narrower than [cargar] (eq-estado-unico item A): it reads
/// ONE key and runs none of the migrations, because its caller is
/// `registrarHandler`, on the audio bootstrap path of EVERY engine —
/// including the headless one Android Auto starts, where there is no
/// widget tree and `EstadoEcualizador` never exists. It must stay cheap
/// and it must never mutate anything.
///
/// `null` is preserved rather than collapsed to a default so that
/// `estadoEqInicial` — not this service — owns the "never persisted"
/// policy in exactly one place.
Future<bool?> leerActivo() async {
final prefs = await _resolverPrefs();
return prefs.getBool(_keyActivo);
}
/// The persisted principal preset, or `null` when the user has never saved
/// one.
///
/// The exact sibling of [leerActivo] and narrow for the same reason: its
/// caller is `registrarHandler`, on the audio bootstrap path of EVERY
/// engine — including the headless one Android Auto starts, where there is
/// no widget tree and `EstadoEcualizador` never exists to push a preset
/// into the handler. It reads ONE key, runs none of [cargar]'s migrations
/// and mutates nothing.
///
/// `null` (nothing saved, or an unreadable value) is preserved rather than
/// collapsed to [PresetEcualizador.flat] so the handler's own default —
/// not this service — decides what "never persisted" means, and so a seed
/// with nothing to say does not overwrite anything.
Future<PresetEcualizador?> leerPresetPrincipal() async {
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyPresetPrincipal);
if (raw == null || raw.isEmpty) return null;
return _leerPresetPrincipal(prefs);
}
Future<void> eliminarPorEmisora(String uuid) async {
final prefs = await _resolverPrefs();
final mapa = _leerPresetsPorEmisora(prefs);
mapa.remove(uuid);
await _guardarPresetsPorEmisora(prefs, mapa);
}
Future<void> guardarConfiguracion(ConfiguracionEcualizador config) async {
final prefs = await _resolverPrefs();
await prefs.setString(
_keyPresetPrincipal,
jsonEncode(config.principal.toJson()),
);
await _guardarPresetsPorEmisora(prefs, config.porEmisora);
await prefs.setBool(_keyActivo, config.activo);
await prefs.setBool(_keyMultiDeviceEnabled, config.eqMultiDeviceEnabled);
await _guardarMapa(
prefs,
_keyPresetsPorDispositivo,
config.presetsDispositivo,
);
await _guardarMapa(prefs, _keyPresetsMatriz, config.presetsMatriz);
await _guardarMapaStrings(
prefs,
_keyNombresDispositivos,
config.nombresDispositivos,
);
}
/// Persists the multi-device feature toggle.
Future<void> guardarToggleMultiDispositivo(bool habilitado) async {
final prefs = await _resolverPrefs();
await prefs.setBool(_keyMultiDeviceEnabled, habilitado);
}
/// Saves a per-device preset.
Future<void> guardarPresetDispositivo(
String deviceId,
PresetEcualizador preset,
) async {
final prefs = await _resolverPrefs();
final mapa = _leerMapa(prefs, _keyPresetsPorDispositivo);
mapa[deviceId] = preset;
await _guardarMapa(prefs, _keyPresetsPorDispositivo, mapa);
}
/// Saves a matrix (stationUuid:deviceId) preset entry.
Future<void> guardarPresetMatriz(
String clave,
PresetEcualizador preset,
) async {
final prefs = await _resolverPrefs();
final mapa = _leerMapa(prefs, _keyPresetsMatriz);
mapa[clave] = preset;
await _guardarMapa(prefs, _keyPresetsMatriz, mapa);
}
/// Removes a per-device preset entry.
Future<void> eliminarPresetDispositivo(String deviceId) async {
final prefs = await _resolverPrefs();
final mapa = _leerMapa(prefs, _keyPresetsPorDispositivo);
mapa.remove(deviceId);
await _guardarMapa(prefs, _keyPresetsPorDispositivo, mapa);
}
/// Removes a matrix preset entry.
Future<void> eliminarPresetMatriz(String clave) async {
final prefs = await _resolverPrefs();
final mapa = _leerMapa(prefs, _keyPresetsMatriz);
mapa.remove(clave);
await _guardarMapa(prefs, _keyPresetsMatriz, mapa);
}
/// Returns the persisted device custom names map.
Future<Map<String, String>> cargarNombresDispositivos() async {
final prefs = await _resolverPrefs();
return _leerMapaStrings(prefs, _keyNombresDispositivos);
}
/// Persists the device custom names map.
Future<void> guardarNombresDispositivos(Map<String, String> nombres) async {
final prefs = await _resolverPrefs();
await _guardarMapaStrings(prefs, _keyNombresDispositivos, nombres);
}
/// Reads a `Map<String, PresetEcualizador>` from a SharedPreferences JSON
/// key with per-entry tolerance (persistence-resilience D6): a value that
/// fails to parse is skipped and logged, its sibling entries survive and
/// every surviving key (including the matrix's colon-delimited
/// `stationUuid:deviceId` keys) is preserved byte-for-byte. A top-level
/// decode failure still degrades the whole map to empty (now logged too);
/// no flag/quarantine here — EQ presets are explicit-only writes and
/// trivially re-creatable (D6, intentional asymmetry vs. Alarms/Stations).
Map<String, PresetEcualizador> _leerMapa(
SharedPreferences prefs,
String key,
) {
final raw = prefs.getString(key);
if (raw == null || raw.isEmpty) return {};
try {
final data = Map<String, dynamic>.from(jsonDecode(raw) as Map);
final resultado = parseMapaTolerante<PresetEcualizador>(
data,
(valor) => PresetEcualizador.desdeJson(
Map<String, dynamic>.from(valor as Map),
),
subsistema: 'ecualizador',
coleccion: key,
);
return resultado.validas;
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ecualizador',
detalle: key,
razon: e.toString(),
);
return {};
}
}
Future<void> _guardarMapa(
SharedPreferences prefs,
String key,
Map<String, PresetEcualizador> mapa,
) async {
final serializado = mapa.map((k, v) => MapEntry(k, v.toJson()));
await prefs.setString(key, jsonEncode(serializado));
}
/// Reads a `Map<String, String>` from a SharedPreferences JSON key with
/// per-entry tolerance (persistence-resilience D6): a value that is not a
/// String is skipped and logged, sibling entries and keys survive
/// untouched. A top-level decode failure still degrades the whole map to
/// empty (now logged too); no flag/quarantine here (D6).
Map<String, String> _leerMapaStrings(SharedPreferences prefs, String key) {
final raw = prefs.getString(key);
if (raw == null || raw.isEmpty) return {};
try {
final data = Map<String, dynamic>.from(jsonDecode(raw) as Map);
final resultado = parseMapaTolerante<String>(
data,
(valor) => valor as String,
subsistema: 'ecualizador',
coleccion: key,
);
return resultado.validas;
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ecualizador',
detalle: key,
razon: e.toString(),
);
return {};
}
}
Future<void> _guardarMapaStrings(
SharedPreferences prefs,
String key,
Map<String, String> mapa,
) async {
await prefs.setString(key, jsonEncode(mapa));
}
/// Reads the single principal-preset key. Single-value (not a collection)
/// so there is nothing to skip per-entry; a decode failure still falls
/// back to [PresetEcualizador.flat] (unchanged behavior) but is now
/// logged too (persistence-resilience D6, diagnostics).
PresetEcualizador _leerPresetPrincipal(SharedPreferences prefs) {
final raw = prefs.getString(_keyPresetPrincipal);
if (raw == null || raw.isEmpty) {
return PresetEcualizador.flat;
}
try {
return PresetEcualizador.desdeJson(
Map<String, dynamic>.from(jsonDecode(raw) as Map),
);
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ecualizador',
detalle: _keyPresetPrincipal,
razon: e.toString(),
);
return PresetEcualizador.flat;
}
}
/// Reads the per-station preset map with per-entry tolerance
/// (persistence-resilience D6) via the same shared helper as [_leerMapa];
/// a station whose preset value fails to parse is skipped and logged, its
/// siblings survive under their original uuid keys.
Map<String, PresetEcualizador> _leerPresetsPorEmisora(
SharedPreferences prefs,
) {
final raw = prefs.getString(_keyPresetsPorEmisora);
if (raw == null || raw.isEmpty) {
return {};
}
try {
final data = Map<String, dynamic>.from(jsonDecode(raw) as Map);
final resultado = parseMapaTolerante<PresetEcualizador>(
data,
(valor) => PresetEcualizador.desdeJson(
Map<String, dynamic>.from(valor as Map),
),
subsistema: 'ecualizador',
coleccion: _keyPresetsPorEmisora,
);
return resultado.validas;
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ecualizador',
detalle: _keyPresetsPorEmisora,
razon: e.toString(),
);
return {};
}
}
Future<void> _guardarPresetsPorEmisora(
SharedPreferences prefs,
Map<String, PresetEcualizador> mapa,
) async {
final serializado = mapa.map(
(uuid, preset) => MapEntry(uuid, preset.toJson()),
);
await prefs.setString(_keyPresetsPorEmisora, jsonEncode(serializado));
}
}