# Design: Android Auto EQ preset selection ## Context `android-auto-eq-presets` adds a browsable `Ecualizador` folder to the existing Android Auto media tree so a driver can apply one of the 6 fixed `PresetEcualizador.presets` from the car, without touching the phone and without interrupting the current station. This design was written against the CURRENT post-favorite-groups codebase (main, after `f368bcc`/`066fedb`), reading the live files — not an assumed older layout. Two facts from the code drive every decision below: - **`EstadoEcualizador` is a plain `ChangeNotifier`** (`lib/estado/estado_ecualizador.dart:28`) with NO `BuildContext`/widget-tree dependency — but it is created lazily by a `ChangeNotifierProvider.create:` and **may never build on a headless Android Auto bind**, exactly like `EstadoRadio` for browse. So the car path MUST NOT reach into it. - **The handler already owns a headless-safe EQ seam.** `PluriWaveAudioHandler.aplicarPreset` (`lib/servicios/servicio_audio.dart:580`) mutates only `_presetActual` and the native `_eq`; it NEVER touches `mediaItem` or `playbackState`. And `ServicioEcualizador.guardarPrincipal` (`lib/servicios/servicio_ecualizador.dart:129`) writes the same SharedPreferences key `eq_preset_principal_v1` (line 39) that the phone's `EstadoEcualizador.cargarPersistido()` reads. This is the working headless bridge Android Auto already uses for play/pause — we reuse it, we do not invent a parallel one. ## Goals / Non-Goals **Goals** - Browsable `Ecualizador` root folder listing the 6 presets by name. - `eq_preset:` media-id scheme, intercepted in `playFromMediaId` BEFORE the `emisora:`/`grupo:` routing, applying + persisting the preset as principal. - Structural, testable guarantee that a preset tap NEVER disturbs current playback or now-playing metadata. - Headless-safe write path with eventual phone/car parity via SharedPreferences. **Non-Goals** - Live phone-UI refresh of the EQ screen while an Auto session mutates EQ (see ADR-4 — eventual parity only, by design). - Band-level / per-station / per-device / matrix EQ from the car. - A transport-button EQ action. - An accurate "currently-selected" checkmark on preset rows (ADR-6, scoped out). ## Architecture Approach Mirror the proven `emisora:`/`grupo:` interception pattern already in this capability: **pure, injectable free functions in `navegacion_auto.dart`** do all the logic; the untested `PluriWaveAudioHandler` dispatch layer stays a thin 3-line delegation. This is the same shape as the existing `reproducirPorMediaId` free function (`navegacion_auto.dart:267`) — a driver tap routes through `playFromMediaId`, which delegates to a fully unit-testable function with injected seams and no platform dependency. The tree-building additions live in the existing pure `ConstructorArbolAuto` class next to `raiz()`/`itemEmisora`/`itemGrupo`. ### Component map | Component | Location | Kind | Responsibility | |-----------|----------|------|----------------| | `_prefijoPresetEq = 'eq_preset:'` | `navegacion_auto.dart` (top-level const, next to `_prefijoEmisora`) | new const | Single source of the id prefix, shared by builder + free functions | | `ConstructorArbolAuto.idEcualizador = 'ecualizador'` | `navegacion_auto.dart` | new const | Root folder id for the EQ folder | | `ConstructorArbolAuto.raiz()` | `navegacion_auto.dart:152` | modified | Append the `Ecualizador` folder as the LAST root entry (ADR-2) | | `ConstructorArbolAuto.itemPresetEq(PresetEcualizador)` | `navegacion_auto.dart` | new | Map a preset → `playable:true` leaf `MediaItem` id `eq_preset:` | | `ConstructorArbolAuto.presetsEq(List)` | `navegacion_auto.dart` | new | The 6 preset leaf items for the Ecualizador folder | | `esPresetMediaId(String)` | `navegacion_auto.dart` | new free fn | Pure routing predicate: id starts with `eq_preset:` | | `resolverPresetEq(String id, List)` | `navegacion_auto.dart` | new free fn | Exact-name resolve; `null` for unknown/empty (no throw) | | `debeAplicarPrincipalAhora({uuidActual, clavesPorEmisora})` | `navegacion_auto.dart` | new pure fn | Mirrors `cambiarPresetPrincipal` apply gate (ADR-5) | | `aplicarPresetPorMediaId(...)` | `navegacion_auto.dart` | new free fn | Orchestrates resolve → persist principal → conditional native apply, via injected seams ONLY (ADR-3/ADR-4) | | `PluriWaveAudioHandler.getChildren` | `servicio_audio.dart:731` | modified | New branch: `parentMediaId == idEcualizador` → `constructor.presetsEq(...)` | | `PluriWaveAudioHandler.playFromMediaId` | `servicio_audio.dart:778` | modified | New `eq_preset:` branch BEFORE the fuente-null guard and playback routing; `return`s without falling through | | `test/servicios/navegacion_auto_test.dart:225` | test | modified | `raiz` length assertion 3 → 4 (breaks otherwise) | ### Data flow — a preset tap ``` Car head unit: driver taps "Rock" in the Ecualizador folder -> MediaBrowserService delivers eq_preset:Rock -> PluriWaveAudioHandler.playFromMediaId("eq_preset:Rock") if esPresetMediaId(id): // NEW, first branch servicio = ServicioEcualizador() // headless-safe (SP) await aplicarPresetPorMediaId( "eq_preset:Rock", presets: PresetEcualizador.presets, uuidActual: emisoraActual?.uuid, clavesPorEmisora: () async => (await servicio.cargar()).porEmisora.keys.toSet(), persistirPrincipal: servicio.guardarPrincipal, // -> SP key eq_preset_principal_v1 aplicar: aplicarPreset, // -> native _eq, NOT playback ) return; // NEVER reaches playback -> aplicarPresetPorMediaId: preset = resolverPresetEq(id, presets) // "Rock" | null if preset == null: return // unknown/stale -> no-op await persistirPrincipal(preset) // durable parity if debeAplicarPrincipalAhora(uuidActual, keys): // ADR-5 gate await aplicar(preset) // live native gains ``` Phone side (later): `EstadoEcualizador.cargarPersistido()` reads `eq_preset_principal_v1` → `_presetPrincipal = Rock`. Native sound was already Rock live. Parity achieved (eventual — ADR-4). ### Integration points - **Root tree shape changes 3 → 4 folders.** The base spec scenario "Car requests the root … returns three folder `MediaItem`s" becomes four (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador). This is a spec delta for `sdd-spec` to record, and `navegacion_auto_test.dart:225` must move from `hasLength(3)` to `hasLength(4)`. - **`getChildren` gains one branch** for `idEcualizador`; it needs NO data source (`_fuenteNavegacionGlobal`) because the preset list is a compile-time constant, so it is placed before the `fuente == null` guard's dependents. - **`playFromMediaId` gains one branch** placed FIRST (before the `fuente == null` guard) so EQ works even if the browse source was never registered. - **`ServicioEcualizador` instantiation** inside the handler branch self-resolves SharedPreferences via `getInstance()` (its `_prefs` fallback), matching how `FuenteEmisorasAutoLocal` defaults its own `ServicioFavoritos()`. ## Decisions (ADR-style) ### ADR-1 — Media-id scheme `eq_preset:` **Decision.** New top-level prefix `eq_preset:`; the leaf id is `eq_preset:` + the preset's exact `nombre` (e.g. `eq_preset:Bass Boost`). Resolution is exact-name match against `PresetEcualizador.presets`. **Why.** Collision-free against every existing id shape: `emisora:` (leaf), `grupo:` (folder), and the bare folder constants `favoritos` / `todas` / `mis_emisoras` / the new `ecualizador`. Preset names are the natural stable key (there are only 6 fixed presets, names are unique and constant). Spaces in `Bass Boost` are inert in a `MediaItem.id` string. **Rejected.** Index-based ids (`eq_preset:3`) — brittle against any future reordering of the fixed list and unreadable in logs; name is self-describing and matches how the phone identifies a preset. ### ADR-2 — `Ecualizador` folder placed LAST at the root **Decision.** `raiz()` returns `[Favoritos, Todas las emisoras, Mis emisoras, Ecualizador]` — EQ last. **Why.** Content-browsing folders are the primary car task and stay first; `Ecualizador` is a settings-like tool, not content. Trailing placement matches head-unit conventions (tools after content) and minimizes the chance a driver lands in it by accident while reaching for stations. **Rejected.** First/second position — would push the driver's primary target (favorites/stations) down and read as if EQ were content. ### ADR-3 — Non-playback invariant enforced structurally, not by discipline **Decision.** The EQ orchestration lives in the free function `aplicarPresetPorMediaId`, whose signature exposes ONLY two side-effect seams: `persistirPrincipal` and `aplicar`. It has NO parameter for `playMediaItem`, `mediaItem`, or `playbackState`, so there is literally no code path from a preset tap to playback. In `playFromMediaId` the `eq_preset:` branch is FIRST and `return`s, so it can never fall through to `reproducirPorMediaId`. **What is touched by a preset tap:** `_presetActual` (via `aplicarPreset`), the native `AndroidEqualizer` band gains, and the SharedPreferences key `eq_preset_principal_v1`. **What is NOT touched:** `mediaItem` (never `.add`-ed), `playbackState` (never `copyWith`-ed), `_player`, `emisoraActual`, `_intencionReproducir`, the reconnect machine. A station that is playing keeps playing; a stopped handler stays stopped. **How a test asserts "playback undisturbed":** 1. *Primary (structural, pure):* a unit test drives `aplicarPresetPorMediaId` with spy seams and asserts `aplicar` and `persistirPrincipal` each fire once with the resolved preset — and that the function cannot reference any playback seam because none is injected. The invariant is guaranteed by construction, not by a runtime observation that could regress. 2. *Routing:* `esPresetMediaId('eq_preset:Rock')` is `true` and `esPresetMediaId('emisora:uuid')` is `false`, proving an `eq_preset:` id is diverted before ever reaching `reproducirPorMediaId`. 3. *Handler-level (opportunistic):* if a fake-backed handler test is added, snapshot `handler.mediaItem.value` and subscribe to `handler.mediaItem` before the tap, then assert the value is identical and NO new event was emitted after `playFromMediaId('eq_preset:Rock')`. This is a belt-and-suspenders check on top of the structural guarantee; the dispatch layer's zero-coverage reality means (1) is the load-bearing assertion. **Why.** The proposal's top risk is a preset tap looking like "now playing a new track." Enforcing the invariant in the type/seam shape (not in a comment or a reviewer's vigilance) is the strongest possible mitigation in a dispatch layer with no execution coverage. ### ADR-4 — Headless persistence seam: handler + service, NOT `EstadoEcualizador` **Decision.** The car write path is `ServicioEcualizador.guardarPrincipal` (SharedPreferences `eq_preset_principal_v1`) + `PluriWaveAudioHandler.aplicarPreset` (native gains). It does NOT call `EstadoEcualizador.cambiarPresetPrincipal`. Phone/car parity is **eventual**: the phone's `EstadoEcualizador` reflects the car's choice on its next `cargarPersistido()` (init / reload). The native sound and the handler's live `_presetActual` update immediately. **Why.** Confirmed by reading the class: `EstadoEcualizador` is a plain `ChangeNotifier` (no `BuildContext`), BUT it is provider-created and may never exist on a headless Auto bind — the same reason this capability already built `FuenteEmisorasAutoLocal` instead of reaching into `EstadoRadio` for browse. Reaching a possibly-null widget-tree object from the headless handler would be fragile. Both surfaces already converge on TWO shared authorities the handler owns headlessly: the SharedPreferences key (durable) and the handler's `aplicarPreset`/`_presetActual` (live native). The phone writes to the exact same key on `cambiarPresetPrincipal` → `guardarPrincipal`, so phone→car parity is already there symmetrically. **Live phone-UI refresh is explicitly OUT of scope.** Pushing a car change into a live `EstadoEcualizador._presetPrincipal` would require a NEW handler→state registration bridge (there is no existing EQ listener to reuse — the only existing bridges are `registrarHandler` and `registrarFuenteNavegacion`, neither of which carries EQ state). The dominant real scenario (phone headless or not on the EQ screen while the driver uses the head unit) makes live refresh low value against real added surface + a widget-tree wiring change. Eventual consistency on next foreground load is the accepted behavior for this iteration. **Rejected.** (a) Calling `EstadoEcualizador` directly — may not exist headless. (b) Adding a live sync bridge now — new surface, widget-tree wiring, marginal value while driving; revisit only if a concrete need appears. ### ADR-5 — Mirror `cambiarPresetPrincipal` per-station apply semantics **Decision.** `aplicarPresetPorMediaId` always persists the principal, but only applies it to the native EQ live when `debeAplicarPrincipalAhora` is true: `uuidActual == null` (no station playing) OR the current station has no per-station preset override (`!clavesPorEmisora.contains(uuidActual)`). This is the exact gate `EstadoEcualizador.cambiarPresetPrincipal` uses (`estado_ecualizador.dart:302-307`). **Why.** The car is just another button for the SAME "change the global preset" action; it must obey the same rules as the phone so the two surfaces never diverge. If the driver's current station has a deliberately-set per-station preset, changing the GLOBAL preset from the car should not silently blow away that override's live sound — identical to phone behavior. The gate decision is a PURE function (`uuid + key set → bool`), fully unit-testable, so the parity logic is covered even though the handler dispatch is not. The one extra `servicio.cargar()` read per tap is negligible for an occasional manual action. **Rejected.** Unconditional live apply — thinner, but diverges from the phone in the per-station-override case and would momentarily override a preset the user deliberately pinned (it reasserts on next station switch anyway, so the divergence is pure inconsistency with no upside). ### ADR-6 — Active-preset marker scoped OUT this iteration **Decision.** Preset rows carry the plain preset name, no "selected" marker. **Why.** The legacy `MediaBrowserService` model has no per-row selected affordance, so the only option is a title-text prefix (e.g. `● Rock`). But the browse tree is NOT re-queried after an `eq_preset:` tap (this path deliberately does no `notifyChildrenChanged` — it must not touch playback/tree state), so a prefix marker would go stale the instant the driver picks a different preset and would actively lie about the current state. A marker that lies is worse than no marker. Ship clean rows; revisit only if a reliable tree-refresh trigger is added. ## Risks / Open Questions - **Root tree 3 → 4 folders is a base-spec delta.** `sdd-spec` must update the "Car requests the root" scenario, and `navegacion_auto_test.dart:225` (`hasLength(3)`) must become `hasLength(4)` in the same change or the suite breaks. Flagged, low risk (mechanical). - **Eventual (not live) phone parity** (ADR-4) — accepted limitation. Risk: a user with the EQ screen open on the phone AND browsing Auto EQ simultaneously sees a stale principal until reload. Judged low-value corner; documented, not fixed. - **`servicio.cargar()` on each tap** triggers `migrarClavesPlaceholder()` — a guarded one-time no-op after first run; negligible cost, no correctness risk. - **Shared-file merge drift** with the just-shipped favorite-groups work is the main integration hazard; this design adds branches ALONGSIDE the existing `grupo:`/`emisora:` dispatch in the two shared files rather than restructuring them, keeping the diff additive and the rollback a clean revert. ## Migration / Rollback Additive only — no schema/migration. Reverting the feature commits removes the `Ecualizador` folder, the `eq_preset:` branch, and the tree helpers, restoring the exact prior tree; the SharedPreferences key `eq_preset_principal_v1` is the pre-existing phone key and is untouched structurally.