Files
pluriwave/lib/estado/estado_ecualizador.dart
T
FreeTLab 4632d53eb8 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
2026-06-27 11:33:53 +02:00

373 lines
12 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 {
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;
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);
// 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();
} 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 {
_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();
}
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);
}
}
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();
}
}