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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user