Files
FreeTLab bccc5c48b8
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
docs(openspec): add SDD artifact trail for recent alarm and EQ changes
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.
2026-07-04 12:42:11 +02:00

214 lines
11 KiB
Markdown
Raw Permalink 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.
# 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:
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:<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
```dart
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
```dart
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_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<String, dynamic>.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.)