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.
16 KiB
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:
EstadoEcualizadoris a plainChangeNotifier(lib/estado/estado_ecualizador.dart:28) with NOBuildContext/widget-tree dependency — but it is created lazily by aChangeNotifierProvider.create:and may never build on a headless Android Auto bind, exactly likeEstadoRadiofor 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_presetActualand the native_eq; it NEVER touchesmediaItemorplaybackState. AndServicioEcualizador.guardarPrincipal(lib/servicios/servicio_ecualizador.dart:129) writes the same SharedPreferences keyeq_preset_principal_v1(line 39) that the phone'sEstadoEcualizador.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
Ecualizadorroot folder listing the 6 presets by name. eq_preset:<nombre>media-id scheme, intercepted inplayFromMediaIdBEFORE theemisora:/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; returns 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
MediaItems" becomes four (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador). This is a spec delta forsdd-specto record, andnavegacion_auto_test.dart:225must move fromhasLength(3)tohasLength(4). getChildrengains one branch foridEcualizador; it needs NO data source (_fuenteNavegacionGlobal) because the preset list is a compile-time constant, so it is placed before thefuente == nullguard's dependents.playFromMediaIdgains one branch placed FIRST (before thefuente == nullguard) so EQ works even if the browse source was never registered.ServicioEcualizadorinstantiation inside the handler branch self-resolves SharedPreferences viagetInstance()(its_prefsfallback), matching howFuenteEmisorasAutoLocaldefaults its ownServicioFavoritos().
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
returns, 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":
- Primary (structural, pure): a unit test drives
aplicarPresetPorMediaIdwith spy seams and assertsaplicarandpersistirPrincipaleach 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. - Routing:
esPresetMediaId('eq_preset:Rock')istrueandesPresetMediaId('emisora:uuid')isfalse, proving aneq_preset:id is diverted before ever reachingreproducirPorMediaId. - Handler-level (opportunistic): if a fake-backed handler test is added, snapshot
handler.mediaItem.valueand subscribe tohandler.mediaItembefore the tap, then assert the value is identical and NO new event was emitted afterplayFromMediaId('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-specmust update the "Car requests the root" scenario, andnavegacion_auto_test.dart:225(hasLength(3)) must becomehasLength(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 triggersmigrarClavesPlaceholder()— 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.