# Design: Multi-Device Equalizer ## Technical Approach Add a device dimension to the existing 2-level EQ resolution (station > global) by introducing a platform channel bridge for device detection, a Dart service abstraction, and extending `EstadoEcualizador` to resolve through a 4-level hierarchy. Follows existing project patterns: ChangeNotifier state, SharedPreferences persistence via `ServicioEcualizador`, platform channels in `MainActivity.kt`, and constructor-injected fakes for testing. ## Architecture Decisions ### ADR-1: Platform Channel vs Package | Option | Tradeoff | Decision | |--------|----------|----------| | Custom platform channel `pluriwave/audio_devices` | Native code in Kotlin+Swift; full control over device ID format, BT MAC access | **Chosen** | | `flutter_audio_output` package | No native code; unmaintained (2021), no BT MAC, dependency risk | Rejected | | `audio_session` events only | Zero new code; incomplete -- no BT identity, only becoming-noisy | Rejected | **Rationale**: Project already has 3 platform channels (visualizer, alarm, file_actions). The pattern is established. BT MAC from `AudioManager.getDevices()` requires no extra permission and gives stable device keys. ### ADR-2: Device Service as Abstract Class | Option | Tradeoff | Decision | |--------|----------|----------| | Abstract `ServicioDispositivoAudio` with real + fake impls | Testable without platform channels; matches `ServicioAudio`/`ServicioEcualizador` pattern | **Chosen** | | Concrete class with `@visibleForTesting` fields | Simpler; harder to fake stream behavior in tests | Rejected | **Rationale**: `EstadoEcualizador` tests must verify device-change reactions. An abstract class with `FakeServicioDispositivoAudio` in `test/helpers/fakes.dart` follows the established fake pattern. ### ADR-3: Composite Key for Matrix Persistence | Option | Tradeoff | Decision | |--------|----------|----------| | `"stationUuid:deviceId"` string key in flat map | Simple; ~80 bytes/entry, predictable SP size | **Chosen** | | Nested map `{stationUuid: {deviceId: preset}}` | Type-safe; more complex serialization/deserialization | Rejected | **Rationale**: SharedPreferences stores a single JSON string. A flat map with composite keys is simpler to serialize, query, and migrate. Delimiter `:` is safe because station UUIDs are RFC 4122 (no colons) and device IDs use `:` only inside BT MACs which appear after the `bt_a2dp:` prefix. ### ADR-4: Resolution Wiring Point | Option | Tradeoff | Decision | |--------|----------|----------| | `EstadoEcualizador` subscribes to device stream, resolves internally | Single owner of resolution logic; matches existing pattern where `EstadoEcualizador` owns all EQ state | **Chosen** | | `PluriWaveAudioHandler` resolves via callback | Keeps resolution near the engine; requires handler to know about stations and persistence | Rejected | **Rationale**: `PluriWaveAudioHandler` is intentionally thin on state (it stores `_presetActual` only). The handler calls `aplicarPreset()` -- it should not know about resolution hierarchy. `EstadoEcualizador` already owns the station-map resolution. ### ADR-5: EQ Re-application After `_recrearPlayer()` | Option | Tradeoff | Decision | |--------|----------|----------| | `_activarEcualizador()` applies `_presetActual` (no change to handler) and `EstadoEcualizador` keeps `_presetActual` updated on device/station change | Handler stays unchanged; state layer ensures `_presetActual` is always the resolved preset | **Chosen** | | Inject resolution callback into handler | Handler becomes aware of device dimension; breaks current layering | Rejected | **Rationale**: `_activarEcualizador()` already calls `aplicarPreset(_presetActual)`. If `EstadoEcualizador` updates `_presetActual` via `aplicarPresetActivo()` whenever device or station changes, the handler needs no modification. The existing `aplicarPresetActivo` path already flows through `ServicioAudio.aplicarPreset()` to the handler. ### ADR-6: Feature Toggle Scope | Option | Tradeoff | Decision | |--------|----------|----------| | SP key `eq_multi_device_enabled_v1` read by `EstadoEcualizador`; when false, skip device subscription and 4-level resolution | Zero behavioral change when off; toggle is at the state layer | **Chosen** | | Feature flag in UI only (hide settings section) | State layer still runs device logic even when "disabled" | Rejected | **Rationale**: Toggle must fully isolate the feature. When off, `EstadoEcualizador` should behave identically to current code -- no device stream subscription, 2-level resolution only. ## Data Flow ``` Platform (Android/iOS) | AudioDeviceCallback / routeChangeNotification | +----- EventChannel ------+ | pluriwave/audio_devices | +-------------------------+ | ServicioDispositivoAudio Stream | +--- EstadoEcualizador (ChangeNotifier) ---+ | | | resolve: matrix > station > device > global | | +----→ aplicarPresetActivo(resolved) -------+ | | ServicioAudio ServicioEcualizador (apply to engine) (persist to SP) ``` Device change flow: 1. Native callback fires (connect/disconnect) 2. EventChannel pushes device event to Dart 3. `ServicioDispositivoAudio` emits `DispositivoAudio` on stream 4. `EstadoEcualizador._onDeviceChanged()` triggers 4-level resolution 5. Resolved preset applied via `aplicarPresetActivo()` (existing path) 6. If first-seen device: copy current preset as initial device preset ## File Changes | File | Action | Description | |------|--------|-------------| | `lib/modelos/dispositivo_audio.dart` | Create | `DispositivoAudio` value model + `TipoDispositivo` enum | | `lib/servicios/servicio_dispositivo_audio.dart` | Create | Abstract class + platform channel implementation | | `android/.../MainActivity.kt` | Modify | Add `pluriwave/audio_devices` EventChannel + MethodChannel | | `ios/Runner/AudioDevicesPlugin.swift` | Create | AVAudioSession route detection | | `ios/Runner/AppDelegate.swift` | Modify | Register `AudioDevicesPlugin` | | `lib/servicios/servicio_ecualizador.dart` | Modify | New SP keys, device/matrix CRUD, extended `ConfiguracionEcualizador` | | `lib/estado/estado_ecualizador.dart` | Modify | Device stream subscription, 4-level resolution, toggle logic | | `lib/servicios/servicio_export_import.dart` | Modify | v3 schema with `presetsPorDispositivo` + `presetsMatriz` fields | | `lib/pantallas/pantalla_ajustes.dart` | Modify | `_SeccionEcualizadorAvanzado` widget behind feature toggle | | `test/helpers/fakes.dart` | Modify | Add `FakeServicioDispositivoAudio` | | `test/estado/estado_ecualizador_test.dart` | Modify | Device-dimension test cases | ## Interfaces / Contracts ### DispositivoAudio Model ```dart enum TipoDispositivo { altavozInterno, // "builtin_speaker" auricularesCable, // "wired_headset" bluetoothA2dp, // "bt_a2dp:" usbAudio, // "usb_headset:
" desconocido, // fallback } class DispositivoAudio { final String id; // Stable key: "builtin_speaker", "bt_a2dp:AA:BB:CC:DD:EE:FF" final TipoDispositivo tipo; final String nombre; // Human-readable: "Galaxy Buds Pro" const DispositivoAudio({required this.id, required this.tipo, required this.nombre}); } ``` ### ServicioDispositivoAudio Contract ```dart abstract class ServicioDispositivoAudio { /// Current active output device (null before first query). DispositivoAudio? get dispositivoActual; /// Stream of active device changes. Stream get onDispositivoCambiado; /// Query current device (pull). Future obtenerDispositivoActual(); /// Clean up native resources. Future dispose(); } ``` ### Platform Channel Protocol Channel: `pluriwave/audio_devices` **MethodChannel (pull):** - `getActiveDevice` -> `Map` (`{id, type, name}`) **EventChannel (push):** - Stream of `Map` (`{id, type, name}`) on device change Type constants (int, matching Android `AudioDeviceInfo` types): - `2` = builtin_speaker, `3` = wired_headset, `8` = bt_a2dp, `14` = usb_headset ### Extended ConfiguracionEcualizador ```dart class ConfiguracionEcualizador { final PresetEcualizador principal; final Map porEmisora; final Map porDispositivo; // NEW final Map matriz; // NEW (key: "uuid:deviceId") final bool activo; final bool multiDispositivoHabilitado; // NEW } ``` ### New SP Keys ``` eq_multi_device_enabled_v1 -> bool (default false) eq_preset_por_dispositivo_v1 -> Map (JSON) eq_presets_matriz_v1 -> Map<"stationUuid:deviceId", PresetEcualizador> (JSON) ``` ## Testing Strategy | Layer | What to Test | Approach | |-------|-------------|----------| | Unit | 4-level resolution hierarchy (all combinations) | `EstadoEcualizador` with `FakeServicioDispositivoAudio` + `FakeServicioEcualizador` + `FakeServicioAudio` | | Unit | Device change triggers preset swap | Emit device events on fake stream, assert `presetsAplicados` | | Unit | Feature toggle isolation | Toggle off: verify no device subscription, 2-level resolution only | | Unit | First-seen device copies current preset | Assert persistence call on unknown device ID | | Unit | `_recrearPlayer()` re-applies correct preset | Verify `_presetActual` is device-resolved before `_activarEcualizador()` runs | | Unit | ServicioEcualizador CRUD for new SP keys | Direct persistence layer tests | | Unit | Export/import v3 round-trip + v2 backward compat | `ServicioExportImport` with device fields present/absent | | Unit | DispositivoAudio model equality and serialization | Value model tests | `FakeServicioDispositivoAudio` exposes a `StreamController` so tests can push device events synchronously. ## Migration / Rollout - Feature toggle `eq_multi_device_enabled_v1` defaults to `false` -- zero behavioral change on upgrade. - New SP keys are independent of existing keys; no migration needed. - Export v3 adds nullable fields; v2 importers ignore unknown keys (existing `importar()` uses `Map.from()`). - Import path handles missing device fields with null-safe defaults. - Native channel is additive; no existing channels modified. ## Open Questions - [x] iOS full implementation or stub? -- **Decided**: Full detection, no-op EQ application (same as current global EQ on iOS). State is tracked and persisted for UI display. - [ ] Should station × device matrix entries be cleaned up when a station is removed from favorites? (Low priority; orphaned entries are harmless and tiny.)