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
+170 -5
View File
@@ -1,7 +1,11 @@
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).
@@ -11,33 +15,65 @@ import '../servicios/servicio_ecualizador.dart';
/// [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 = {};
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, 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;
@@ -52,26 +88,116 @@ class EstadoEcualizador extends ChangeNotifier {
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;
_presetActual = config.principal;
_activo = config.activo;
_eqMultiDeviceEnabled = config.eqMultiDeviceEnabled;
_presetsEmisoraMap
..clear()
..addAll(config.porEmisora);
_presetsDispositivo
..clear()
..addAll(config.presetsDispositivo);
_presetsMatriz
..clear()
..addAll(config.presetsMatriz);
// Resolve active preset and apply it.
_presetActual = _resolverPresetActivo();
await audio.setEcualizadorActivo(_activo);
await audio.aplicarPreset(_presetPrincipal);
await audio.aplicarPreset(_presetActual);
// Subscribe to device changes only when toggle is on.
_configurarSuscripcionDispositivo();
} catch (_) {
_presetPrincipal = PresetEcualizador.flat;
_presetActual = PresetEcualizador.flat;
_activo = true;
_eqMultiDeviceEnabled = false;
_presetsEmisoraMap.clear();
_presetsDispositivo.clear();
_presetsMatriz.clear();
}
}
/// 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;
_dispositivoActualId = dispositivo.id;
// First-seen device: copy the current resolved preset as its starting point.
if (!_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 {
@@ -79,6 +205,20 @@ class EstadoEcualizador extends ChangeNotifier {
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();
}
Future<void> cambiarPresetPrincipal(
PresetEcualizador preset, {
bool notificar = true,
@@ -184,24 +324,49 @@ class EstadoEcualizador extends ChangeNotifier {
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();
await aplicarPresetActivo(
uuid == null ? _presetPrincipal : presetParaEmisora(uuid),
);
final presetEfectivoActual =
uuid == null ? _presetPrincipal : _resolverPresetActivo();
await aplicarPresetActivo(presetEfectivoActual);
notifyListeners();
}
@override
void dispose() {
_deviceSub?.cancel();
super.dispose();
}
}