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

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:

  • 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 == idEcualizadorconstructor.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 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 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":

  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 cambiarPresetPrincipalguardarPrincipal, 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.