feat(eq): add per-device equalizer with 4-level preset resolution

Introduce multi-device EQ support allowing each audio output device
(built-in speaker, wired, USB, individual Bluetooth by MAC) to have
its own equalizer preset, combined with existing per-station presets
for a full station×device matrix.

- Add DispositivoAudio model and ServicioDispositivoAudio interface
- Add Android platform channel (AudioDeviceCallback) for device detection
- Add iOS AudioDevicesPlugin (AVAudioSession route tracking)
- Extend ServicioEcualizador with device and matrix persistence keys
- Implement 4-level resolution: matrix > station > device > global
- Add advanced EQ settings section with feature toggle (off by default)
- Extend export/import to v3 with backward compatibility
- 184 tests passing, zero analyzer issues
This commit is contained in:
2026-06-27 11:33:53 +02:00
parent 8f42e67b48
commit 4632d53eb8
36 changed files with 2850 additions and 64 deletions
+51 -4
View File
@@ -17,6 +17,7 @@ import 'estado_ecualizador.dart';
import 'estado_grabacion.dart';
import 'orden_emisoras.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_dispositivo_audio.dart';
import '../servicios/servicio_ecualizador.dart';
import '../servicios/servicio_export_import.dart';
import '../servicios/servicio_favoritos.dart';
@@ -38,6 +39,7 @@ class EstadoRadio extends ChangeNotifier {
ServicioFavoritos? favoritos,
ServicioRadio? radio,
ServicioEcualizador? servicioEcualizador,
ServicioDispositivoAudio? dispositivoAudio,
ServicioGrabacionRadio? servicioGrabacion,
SharedPreferences? prefs,
Future<File> Function()? resolverArchivoCustom,
@@ -47,11 +49,13 @@ class EstadoRadio extends ChangeNotifier {
radio = radio ?? ServicioRadio(),
servicioEcualizador =
servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
_dispositivoAudio = dispositivoAudio,
_prefs = prefs,
_resolverArchivoCustom = resolverArchivoCustom {
ecualizador = EstadoEcualizador(
audio: this.audio,
servicio: this.servicioEcualizador,
dispositivoAudio: _dispositivoAudio,
emisoraActualUuid: () => emisoraActual?.uuid,
);
grabacion = EstadoGrabacion(
@@ -77,6 +81,10 @@ class EstadoRadio extends ChangeNotifier {
final ServicioRadio radio;
final ServicioEcualizador servicioEcualizador;
/// Optional device service — null means no device subscription established.
/// Wired in production from app.dart; defaults to null in tests.
final ServicioDispositivoAudio? _dispositivoAudio;
/// Domain notifiers extracted from this class (S4). Created and disposed
/// here (they need EstadoRadio's services and callbacks at construction);
/// exposed app-wide through ListenableProviders in app.dart.
@@ -547,8 +555,9 @@ class EstadoRadio extends ChangeNotifier {
static const _keyAlarmasConfig = 'alarmas_musicales_v1';
/// Genera el JSON de toda la configuración (v2 — portabilidad completa).
/// La forma del sobre v2 vive en [ServicioExportImport] (S4-R4).
/// Genera el JSON de toda la configuración (v3 — portabilidad completa
/// con presets por dispositivo y matriz multi-device).
/// La forma del sobre v3 vive en [ServicioExportImport] (S4-R4).
Future<Map<String, dynamic>> exportarConfig() async {
final favs = await favoritos.obtenerTodos();
final grupos = await favoritos.obtenerGrupos();
@@ -572,6 +581,10 @@ class EstadoRadio extends ChangeNotifier {
emisoraPreferidaUuid: _emisoraPreferidaUuid,
ordenListas: _ordenListas.name,
timerSuenoPresetsSegundos: _timerSuenoPresetsSegundos,
// v3 extensions — multi-device EQ fields.
presetsPorDispositivo: ecualizador.presetsDispositivo,
presetsMatriz: ecualizador.presetsMatriz,
eqMultiDeviceEnabled: ecualizador.eqMultiDeviceEnabled,
);
}
@@ -584,10 +597,11 @@ class EstadoRadio extends ChangeNotifier {
_exportImport.importar(raw);
/// Importa configuración desde un JSON exportado previamente.
/// Soporta v1 (sin grupos, sin alarmas) y v2 (portabilidad completa).
/// Soporta v1 (sin grupos, sin alarmas), v2 (portabilidad completa),
/// y v3 (+ presets por dispositivo, presets matriz, toggle multi-device).
Future<void> importarConfig(Map<String, dynamic> data) async {
final version = data['version'] as int? ?? 1;
if (version > 2) throw Exception(_textos.unsupportedConfigVersion);
if (version > 3) throw Exception(_textos.unsupportedConfigVersion);
final prefs = await _resolverPrefs();
@@ -637,9 +651,41 @@ class EstadoRadio extends ChangeNotifier {
),
);
// v3 extensions: per-device and matrix presets.
Map<String, PresetEcualizador>? presetsDispositivo;
Map<String, PresetEcualizador>? presetsMatriz;
bool? eqMultiDeviceEnabled;
if (version >= 3) {
final dispositivoRaw = data['presetsPorDispositivo'] as Map? ?? {};
presetsDispositivo = dispositivoRaw.map<String, PresetEcualizador>(
(deviceId, presetJson) => MapEntry(
deviceId as String,
PresetEcualizador.desdeJson(
Map<String, dynamic>.from(presetJson as Map),
),
),
);
final matrizRaw = data['presetsMatriz'] as Map? ?? {};
presetsMatriz = matrizRaw.map<String, PresetEcualizador>(
(clave, presetJson) => MapEntry(
clave as String,
PresetEcualizador.desdeJson(
Map<String, dynamic>.from(presetJson as Map),
),
),
);
eqMultiDeviceEnabled = data['eqMultiDeviceEnabled'] as bool?;
}
await ecualizador.importarConfiguracion(
principal: presetPrincipal,
porEmisora: presetsPorEmisora,
presetsDispositivo: presetsDispositivo,
presetsMatriz: presetsMatriz,
eqMultiDeviceEnabled: eqMultiDeviceEnabled,
);
// ── Alarmas (v2) ──────────────────────────────────────────────────────
@@ -748,6 +794,7 @@ class EstadoRadio extends ChangeNotifier {
grabacion.dispose();
audio.dispose();
timer.dispose();
_dispositivoAudio?.dispose();
super.dispose();
}
}