Persist the exploration, proposal, spec, design, tasks, and verify/archive reports produced during the multi-device EQ, alarm-countdown, and notification-visual-polish SDD cycles.
11 KiB
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<DispositivoAudio>
|
+--- EstadoEcualizador (ChangeNotifier) ---+
| |
| resolve: matrix > station > device > global
| |
+----→ aplicarPresetActivo(resolved) -------+
| |
ServicioAudio ServicioEcualizador
(apply to engine) (persist to SP)
Device change flow:
- Native callback fires (connect/disconnect)
- EventChannel pushes device event to Dart
ServicioDispositivoAudioemitsDispositivoAudioon streamEstadoEcualizador._onDeviceChanged()triggers 4-level resolution- Resolved preset applied via
aplicarPresetActivo()(existing path) - 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
enum TipoDispositivo {
altavozInterno, // "builtin_speaker"
auricularesCable, // "wired_headset"
bluetoothA2dp, // "bt_a2dp:<MAC>"
usbAudio, // "usb_headset:<address>"
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
abstract class ServicioDispositivoAudio {
/// Current active output device (null before first query).
DispositivoAudio? get dispositivoActual;
/// Stream of active device changes.
Stream<DispositivoAudio> get onDispositivoCambiado;
/// Query current device (pull).
Future<DispositivoAudio> obtenerDispositivoActual();
/// Clean up native resources.
Future<void> dispose();
}
Platform Channel Protocol
Channel: pluriwave/audio_devices
MethodChannel (pull):
getActiveDevice->Map<String, dynamic>({id, type, name})
EventChannel (push):
- Stream of
Map<String, dynamic>({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
class ConfiguracionEcualizador {
final PresetEcualizador principal;
final Map<String, PresetEcualizador> porEmisora;
final Map<String, PresetEcualizador> porDispositivo; // NEW
final Map<String, PresetEcualizador> 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<deviceId, PresetEcualizador> (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<DispositivoAudio> so tests can push device events synchronously.
Migration / Rollout
- Feature toggle
eq_multi_device_enabled_v1defaults tofalse-- 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()usesMap<String, dynamic>.from()). - Import path handles missing device fields with null-safe defaults.
- Native channel is additive; no existing channels modified.
Open Questions
- 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.)