Files
pluriwave/openspec/changes/android-auto-eq-presets/design.md
T
FreeTLab 90cd232ad2 feat(auto): expose EQ presets as a browsable Android Auto folder
Adds an Ecualizador folder listing the 6 fixed presets; selecting one
applies and persists it through the existing headless-safe seam
without touching playback or the now-playing media item.
2026-07-19 14:12:39 +02:00

280 lines
16 KiB
Markdown

# 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:<nombre>` 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:<nombre>` |
| `ConstructorArbolAuto.presetsEq(List<PresetEcualizador>)` | `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<PresetEcualizador>)` | `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:<nombre>`
**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.