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:
@@ -0,0 +1,112 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../modelos/dispositivo_audio.dart';
|
||||
|
||||
/// Abstract service for audio device detection.
|
||||
///
|
||||
/// Implementations:
|
||||
/// - [ServicioDispositivoAudioReal] — platform channel (Android/iOS)
|
||||
/// - `FakeServicioDispositivoAudio` (test/helpers/fakes.dart) — for unit tests
|
||||
abstract class ServicioDispositivoAudio {
|
||||
/// The last known active audio output device, or null if none detected yet.
|
||||
DispositivoAudio? get dispositivoActual;
|
||||
|
||||
/// A broadcast stream that emits whenever the active audio output device
|
||||
/// changes (connect or disconnect event from the platform).
|
||||
Stream<DispositivoAudio> get onDispositivoCambiado;
|
||||
|
||||
/// Requests the current active device from the platform synchronously
|
||||
/// (method channel round-trip). Returns the cached value if already known.
|
||||
Future<DispositivoAudio> obtenerDispositivoActual();
|
||||
|
||||
/// Cancels the device-change subscription and releases resources.
|
||||
Future<void> dispose();
|
||||
}
|
||||
|
||||
/// Platform channel implementation of [ServicioDispositivoAudio].
|
||||
///
|
||||
/// Talks to `pluriwave/audio_devices` on Android (Kotlin) and iOS (Swift).
|
||||
/// Type int constants (from platform channel protocol):
|
||||
/// 2 → altavozInterno (builtin_speaker)
|
||||
/// 3 → auricularesCable (wired_headset)
|
||||
/// 8 → bluetoothA2dp (`bt_a2dp:<MAC>`)
|
||||
/// 14 → usbAudio (`usb_headset:<address>`)
|
||||
class ServicioDispositivoAudioReal extends ServicioDispositivoAudio {
|
||||
static const _channelName = 'pluriwave/audio_devices';
|
||||
|
||||
final MethodChannel _methodChannel;
|
||||
final EventChannel _eventChannel;
|
||||
|
||||
final _controller = StreamController<DispositivoAudio>.broadcast();
|
||||
StreamSubscription<dynamic>? _eventSub;
|
||||
DispositivoAudio? _dispositivoActual;
|
||||
|
||||
ServicioDispositivoAudioReal({
|
||||
MethodChannel? methodChannel,
|
||||
EventChannel? eventChannel,
|
||||
}) : _methodChannel = methodChannel ?? const MethodChannel(_channelName),
|
||||
_eventChannel = eventChannel ?? const EventChannel(_channelName) {
|
||||
_subscribeToEvents();
|
||||
}
|
||||
|
||||
void _subscribeToEvents() {
|
||||
_eventSub = _eventChannel.receiveBroadcastStream().listen(
|
||||
(dynamic event) {
|
||||
if (event is Map) {
|
||||
final device = _mapToDispositivo(Map<String, dynamic>.from(event));
|
||||
_dispositivoActual = device;
|
||||
_controller.add(device);
|
||||
}
|
||||
},
|
||||
onError: (_) {
|
||||
/* swallow platform errors; stream continues */
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
DispositivoAudio? get dispositivoActual => _dispositivoActual;
|
||||
|
||||
@override
|
||||
Stream<DispositivoAudio> get onDispositivoCambiado => _controller.stream;
|
||||
|
||||
@override
|
||||
Future<DispositivoAudio> obtenerDispositivoActual() async {
|
||||
final raw = await _methodChannel.invokeMethod<Map>('getActiveDevice');
|
||||
final map = Map<String, dynamic>.from(raw ?? {});
|
||||
final device = _mapToDispositivo(map);
|
||||
_dispositivoActual = device;
|
||||
return device;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _eventSub?.cancel();
|
||||
await _controller.close();
|
||||
}
|
||||
|
||||
static DispositivoAudio _mapToDispositivo(Map<String, dynamic> map) {
|
||||
final id = map['id'] as String? ?? 'builtin_speaker';
|
||||
final type = map['type'] as int? ?? 2;
|
||||
final name = map['name'] as String? ?? '';
|
||||
return DispositivoAudio(id: id, tipo: _tipoDesdeInt(type), nombre: name);
|
||||
}
|
||||
|
||||
static TipoDispositivo _tipoDesdeInt(int type) {
|
||||
switch (type) {
|
||||
case 2:
|
||||
return TipoDispositivo.altavozInterno;
|
||||
case 3:
|
||||
return TipoDispositivo.auricularesCable;
|
||||
case 8:
|
||||
return TipoDispositivo.bluetoothA2dp;
|
||||
case 14:
|
||||
return TipoDispositivo.usbAudio;
|
||||
default:
|
||||
return TipoDispositivo.desconocido;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,23 @@ class ConfiguracionEcualizador {
|
||||
required this.principal,
|
||||
required this.porEmisora,
|
||||
this.activo = true,
|
||||
this.eqMultiDeviceEnabled = false,
|
||||
this.presetsDispositivo = const {},
|
||||
this.presetsMatriz = 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;
|
||||
}
|
||||
|
||||
class ServicioEcualizador {
|
||||
@@ -22,6 +34,9 @@ class ServicioEcualizador {
|
||||
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';
|
||||
|
||||
final SharedPreferences? _prefs;
|
||||
|
||||
@@ -33,10 +48,15 @@ class ServicioEcualizador {
|
||||
final prefs = await _resolverPrefs();
|
||||
final principal = _leerPresetPrincipal(prefs);
|
||||
final porEmisora = _leerPresetsPorEmisora(prefs);
|
||||
final presetsDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
|
||||
final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz);
|
||||
return ConfiguracionEcualizador(
|
||||
principal: principal,
|
||||
porEmisora: porEmisora,
|
||||
activo: prefs.getBool(_keyActivo) ?? true,
|
||||
eqMultiDeviceEnabled: prefs.getBool(_keyMultiDeviceEnabled) ?? false,
|
||||
presetsDispositivo: presetsDispositivo,
|
||||
presetsMatriz: presetsMatriz,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +92,86 @@ class ServicioEcualizador {
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// Reads a `Map<String, PresetEcualizador>` from a SharedPreferences JSON key.
|
||||
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);
|
||||
return data.map(
|
||||
(k, v) => MapEntry(
|
||||
k,
|
||||
PresetEcualizador.desdeJson(Map<String, dynamic>.from(v as Map)),
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
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));
|
||||
}
|
||||
|
||||
PresetEcualizador _leerPresetPrincipal(SharedPreferences prefs) {
|
||||
|
||||
@@ -6,21 +6,29 @@ import '../modelos/preset_ecualizador.dart';
|
||||
|
||||
/// Owns the backup (export/import) JSON serialization (S4-R4).
|
||||
///
|
||||
/// The v2 envelope produced here is byte-compatible with the legacy format
|
||||
/// previously assembled inline by `EstadoRadio.exportarConfig` and
|
||||
/// pretty-printed by `pantalla_ajustes.dart`, so existing exports keep
|
||||
/// round-tripping. State APPLICATION (writing favorites, EQ, alarms back
|
||||
/// into the app) stays in `EstadoRadio.importarConfig` — this service only
|
||||
/// owns serialization, parsing and the envelope shape.
|
||||
/// v3 extends v2 with `presetsPorDispositivo`, `presetsMatriz`, and
|
||||
/// `eqMultiDeviceEnabled`. When those optional parameters are omitted the
|
||||
/// export stays at v2 for backward compat with the old app. State APPLICATION
|
||||
/// (writing favorites, EQ, alarms back into the app) stays in
|
||||
/// `EstadoRadio.importarConfig` — this service only owns serialization,
|
||||
/// parsing and the envelope shape.
|
||||
class ServicioExportImport {
|
||||
const ServicioExportImport();
|
||||
|
||||
/// Current backup schema version (v2 — full portability).
|
||||
static const int versionActual = 2;
|
||||
/// Current backup schema version (v3 — multi-device EQ).
|
||||
static const int versionActual = 3;
|
||||
|
||||
/// Builds the v2 export envelope. Key set and semantics must stay exactly
|
||||
/// as the legacy inline export so old backups remain importable:
|
||||
/// the `alarmas` block is the RAW JSON map persisted by ServicioAlarmas
|
||||
/// Legacy v2 version constant kept for clarity.
|
||||
static const int versionV2 = 2;
|
||||
|
||||
/// Builds the export envelope.
|
||||
///
|
||||
/// When [presetsPorDispositivo] or [presetsMatriz] are provided (non-null),
|
||||
/// [versionActual] (3) is written. When both are omitted the call behaves
|
||||
/// identically to the original v2 format (version key stays 2) so old
|
||||
/// backups keep round-tripping without version bumps.
|
||||
///
|
||||
/// The `alarmas` block is the RAW JSON map persisted by ServicioAlarmas
|
||||
/// and passes through untouched (no re-parsing here).
|
||||
Map<String, dynamic> construirExportacion({
|
||||
required List<GrupoFavoritos> gruposFavoritos,
|
||||
@@ -33,9 +41,18 @@ class ServicioExportImport {
|
||||
required String ordenListas,
|
||||
required List<int> timerSuenoPresetsSegundos,
|
||||
DateTime? exportadoEn,
|
||||
// v3 extensions — omitting these produces a v2-compatible export.
|
||||
Map<String, PresetEcualizador>? presetsPorDispositivo,
|
||||
Map<String, PresetEcualizador>? presetsMatriz,
|
||||
bool? eqMultiDeviceEnabled,
|
||||
}) {
|
||||
return {
|
||||
'version': versionActual,
|
||||
final tieneExtensionesV3 =
|
||||
presetsPorDispositivo != null ||
|
||||
presetsMatriz != null ||
|
||||
eqMultiDeviceEnabled != null;
|
||||
|
||||
final envelope = <String, dynamic>{
|
||||
'version': tieneExtensionesV3 ? versionActual : versionV2,
|
||||
'exportedAt': (exportadoEn ?? DateTime.now()).toIso8601String(),
|
||||
// Favorites + groups (preserves grupo_id assignments per station).
|
||||
// The protected "sin asignar" group is implicit and never exported.
|
||||
@@ -47,7 +64,7 @@ class ServicioExportImport {
|
||||
'favoritos': favoritos.map((e) => e.toMap()).toList(),
|
||||
// Custom stations.
|
||||
'emisorasCustom': emisorasCustom.map((e) => e.toMap()).toList(),
|
||||
// Equalizer.
|
||||
// Equalizer (base fields — present in all versions).
|
||||
'presetPrincipalEcualizador': presetPrincipal.toJson(),
|
||||
'presetsEcualizador': presetsPorEmisora.map(
|
||||
(uuid, preset) => MapEntry(uuid, preset.toJson()),
|
||||
@@ -59,6 +76,19 @@ class ServicioExportImport {
|
||||
'ordenListas': ordenListas,
|
||||
'timerSuenoPresetsSegundos': timerSuenoPresetsSegundos,
|
||||
};
|
||||
|
||||
// v3 extensions: only written when explicitly provided.
|
||||
if (tieneExtensionesV3) {
|
||||
envelope['presetsPorDispositivo'] = (presetsPorDispositivo ?? {}).map(
|
||||
(deviceId, preset) => MapEntry(deviceId, preset.toJson()),
|
||||
);
|
||||
envelope['presetsMatriz'] = (presetsMatriz ?? {}).map(
|
||||
(clave, preset) => MapEntry(clave, preset.toJson()),
|
||||
);
|
||||
envelope['eqMultiDeviceEnabled'] = eqMultiDeviceEnabled ?? false;
|
||||
}
|
||||
|
||||
return envelope;
|
||||
}
|
||||
|
||||
/// Serializes an export envelope to pretty-printed JSON (same formatting
|
||||
|
||||
Reference in New Issue
Block a user