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.
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user