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:
@@ -7,11 +7,22 @@ import 'package:path_provider/path_provider.dart';
|
||||
import '../estado/orden_emisoras.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import 'persistencia_tolerante.dart';
|
||||
import 'servicio_favoritos.dart';
|
||||
|
||||
const _prefijoEmisora = 'emisora:';
|
||||
|
||||
/// EQ preset media-id prefix (Design ADR-1), collision-free against
|
||||
/// [_prefijoEmisora], `grupo:` and the bare folder id constants.
|
||||
const _prefijoPresetEq = 'eq_preset:';
|
||||
|
||||
/// Whether [id] identifies an EQ preset leaf item (Design ADR-1). A bare
|
||||
/// prefix (`'eq_preset:'`, empty name) is still `true` here — the empty-name
|
||||
/// case is rejected downstream by [resolverPresetEq], not by this routing
|
||||
/// predicate.
|
||||
bool esPresetMediaId(String id) => id.startsWith(_prefijoPresetEq);
|
||||
|
||||
/// Canonical on-brand fallback-art names and rotation order, ported
|
||||
/// **verbatim** (same formula, same order) from
|
||||
/// `lib/widgets/tarjeta_emisora.dart`'s `_fallbackArtFor` (lines 363-367)
|
||||
@@ -124,6 +135,12 @@ class ConstructorArbolAuto {
|
||||
static const idTodas = 'todas';
|
||||
static const idMisEmisoras = 'mis_emisoras';
|
||||
|
||||
/// Root folder id for the EQ presets folder (Design "media-id scheme").
|
||||
/// Deliberately NOT added to [_idsCarpetas] — it has its own dedicated
|
||||
/// branch in `getChildren`/`ConstructorArbolAuto.presetsEq`, not the
|
||||
/// generic station-list `hijos()` path.
|
||||
static const idEcualizador = 'ecualizador';
|
||||
|
||||
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
|
||||
static const _maxItemsPorCarpeta = 50;
|
||||
|
||||
@@ -147,12 +164,15 @@ class ConstructorArbolAuto {
|
||||
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
|
||||
};
|
||||
|
||||
/// The 3 root folders (Favoritos, Todas las emisoras, Mis emisoras), all
|
||||
/// non-playable.
|
||||
/// The 4 root folders (Favoritos, Todas las emisoras, Mis emisoras,
|
||||
/// Ecualizador), all non-playable. `Ecualizador` is deliberately LAST
|
||||
/// (Design ADR-2): content-browsing folders are the primary car task and
|
||||
/// stay first, the EQ tool trails them.
|
||||
List<MediaItem> raiz() => [
|
||||
_carpeta(idFavoritos, 'Favoritos'),
|
||||
_carpeta(idTodas, 'Todas las emisoras'),
|
||||
_carpeta(idMisEmisoras, 'Mis emisoras'),
|
||||
_carpeta(idEcualizador, 'Ecualizador'),
|
||||
];
|
||||
|
||||
MediaItem _carpeta(String id, String titulo) => MediaItem(
|
||||
@@ -209,6 +229,20 @@ class ConstructorArbolAuto {
|
||||
MediaItem itemGrupo(GrupoFavoritos g) =>
|
||||
_carpeta('$_prefijoGrupo${g.id}', g.nombre);
|
||||
|
||||
/// Maps a [PresetEcualizador] to a playable `MediaItem` with id
|
||||
/// `eq_preset:<nombre>` (Design ADR-1).
|
||||
MediaItem itemPresetEq(PresetEcualizador preset) => MediaItem(
|
||||
id: '$_prefijoPresetEq${preset.nombre}',
|
||||
title: preset.nombre,
|
||||
playable: true,
|
||||
extras: _contentStyleGrid,
|
||||
);
|
||||
|
||||
/// The 6 fixed EQ preset leaf items for the `Ecualizador` folder (Spec
|
||||
/// "Car requests the Ecualizador folder").
|
||||
List<MediaItem> presetsEq(List<PresetEcualizador> presets) =>
|
||||
presets.map(itemPresetEq).toList();
|
||||
|
||||
/// Children of the `Favoritos` folder (Design "Ungrouped favorites stay as
|
||||
/// direct leaves at the Favoritos root"): non-empty custom-group folders
|
||||
/// (phone order, capped at [_maxGruposPorFavoritos]), followed by
|
||||
@@ -290,6 +324,62 @@ Future<void> reproducirPorMediaId(
|
||||
await reproducir(item);
|
||||
}
|
||||
|
||||
/// Resolves an `eq_preset:<nombre>` [id] to the matching [PresetEcualizador]
|
||||
/// in [presets] by exact name (Design ADR-1, mirrors
|
||||
/// [ConstructorArbolAuto.resolver]'s shape). Any other shape (no prefix,
|
||||
/// empty name, unmatched name) returns `null` instead of throwing (Spec
|
||||
/// "Unknown or stale preset id").
|
||||
PresetEcualizador? resolverPresetEq(String id, List<PresetEcualizador> presets) {
|
||||
if (!esPresetMediaId(id)) return null;
|
||||
final nombre = id.substring(_prefijoPresetEq.length);
|
||||
if (nombre.isEmpty) return null;
|
||||
for (final preset in presets) {
|
||||
if (preset.nombre == nombre) return preset;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Pure per-station apply gate (Design ADR-5), mirroring
|
||||
/// `EstadoEcualizador.cambiarPresetPrincipal`'s exact logic
|
||||
/// (`estado_ecualizador.dart:302-304`): the new principal preset is applied
|
||||
/// live when there is no current station ([uuidActual] is `null`) or the
|
||||
/// current station has no per-station preset override in
|
||||
/// [clavesPorEmisora].
|
||||
bool debeAplicarPrincipalAhora({
|
||||
required String? uuidActual,
|
||||
required Set<String> clavesPorEmisora,
|
||||
}) => uuidActual == null || !clavesPorEmisora.contains(uuidActual);
|
||||
|
||||
/// Orchestrates an `eq_preset:<nombre>` selection from the car (Design
|
||||
/// "Data flow — a preset tap", ADR-3): resolves [id] via [resolverPresetEq],
|
||||
/// persists it as principal via [persistirPrincipal], and conditionally
|
||||
/// applies it live via [aplicar] when [debeAplicarPrincipalAhora] allows it.
|
||||
///
|
||||
/// This function's signature exposes ONLY the EQ persist/apply seams — it
|
||||
/// has NO parameter for `playMediaItem`, `mediaItem`, or `playbackState`, so
|
||||
/// there is no code path from a preset tap to playback (Design ADR-3,
|
||||
/// non-playback invariant enforced structurally, not by discipline). An
|
||||
/// unknown/stale [id] is a no-op: neither seam is invoked and no exception
|
||||
/// propagates (Spec "Unknown or stale preset id").
|
||||
Future<void> aplicarPresetPorMediaId(
|
||||
String id, {
|
||||
required List<PresetEcualizador> presets,
|
||||
required String? uuidActual,
|
||||
required Future<Set<String>> Function() clavesPorEmisora,
|
||||
required Future<void> Function(PresetEcualizador) persistirPrincipal,
|
||||
required Future<void> Function(PresetEcualizador) aplicar,
|
||||
}) async {
|
||||
final preset = resolverPresetEq(id, presets);
|
||||
if (preset == null) return;
|
||||
await persistirPrincipal(preset);
|
||||
if (debeAplicarPrincipalAhora(
|
||||
uuidActual: uuidActual,
|
||||
clavesPorEmisora: await clavesPorEmisora(),
|
||||
)) {
|
||||
await aplicar(preset);
|
||||
}
|
||||
}
|
||||
|
||||
/// Local, cold-start-safe implementation of [FuenteEmisorasAuto] (Design
|
||||
/// "getChildren data source"). Reads favourites from SQLite and custom
|
||||
/// stations from the tolerant JSON file directly — both loadable without
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../modelos/preset_ecualizador.dart';
|
||||
import 'controlador_reconexion.dart';
|
||||
import 'navegacion_auto.dart';
|
||||
import 'servicio_audio_session.dart';
|
||||
import 'servicio_ecualizador.dart';
|
||||
|
||||
/// Estado de reproducción expuesto al UI.
|
||||
enum EstadoReproduccion {
|
||||
@@ -737,6 +738,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
if (parentMediaId == AudioService.browsableRootId) {
|
||||
return constructor.raiz();
|
||||
}
|
||||
if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
|
||||
return constructor.presetsEq(PresetEcualizador.presets);
|
||||
}
|
||||
final fuente = _fuenteNavegacionGlobal;
|
||||
if (fuente == null) return const [];
|
||||
if (parentMediaId == ConstructorArbolAuto.idFavoritos) {
|
||||
@@ -779,9 +783,27 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
String mediaId, [
|
||||
Map<String, dynamic>? extras,
|
||||
]) async {
|
||||
try {
|
||||
// EQ preset selection (Design ADR-3, Spec "EQ Preset Selection Applies
|
||||
// Without Disturbing Playback"): FIRST branch, unconditional `return`,
|
||||
// so an `eq_preset:` id can never fall through to the playback routing
|
||||
// below. `aplicarPresetPorMediaId`'s seams are EQ-only (persist +
|
||||
// apply) — there is no playback parameter to inject here.
|
||||
if (esPresetMediaId(mediaId)) {
|
||||
final servicio = ServicioEcualizador();
|
||||
await aplicarPresetPorMediaId(
|
||||
mediaId,
|
||||
presets: PresetEcualizador.presets,
|
||||
uuidActual: emisoraActual?.uuid,
|
||||
clavesPorEmisora: () async =>
|
||||
(await servicio.cargar()).porEmisora.keys.toSet(),
|
||||
persistirPrincipal: servicio.guardarPrincipal,
|
||||
aplicar: aplicarPreset,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final fuente = _fuenteNavegacionGlobal;
|
||||
if (fuente == null) return;
|
||||
try {
|
||||
await reproducirPorMediaId(
|
||||
mediaId,
|
||||
fuente: fuente,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Apply Progress: android-auto-eq-presets
|
||||
|
||||
**Mode**: Strict TDD
|
||||
**Batch**: First and only batch — all 21 tasks completed in one pass.
|
||||
|
||||
## Completed Tasks
|
||||
|
||||
### Phase 1: Foundation — media-id scheme + root folder
|
||||
- [x] 1.1 [RED] `esPresetMediaId` table test
|
||||
- [x] 1.2 [GREEN] `_prefijoPresetEq` const + `esPresetMediaId`
|
||||
- [x] 1.3 [RED] `raiz` test updated to `hasLength(4)` + last-position assertions
|
||||
- [x] 1.4 [GREEN] `idEcualizador` const + `raiz()` appends `Ecualizador` folder last
|
||||
- [x] 1.5 [RED] `itemPresetEq` test
|
||||
- [x] 1.6 [GREEN] `itemPresetEq`
|
||||
- [x] 1.7 [RED] `presetsEq` test
|
||||
- [x] 1.8 [GREEN] `presetsEq`
|
||||
|
||||
### Phase 2: Core Implementation — resolve, gate, orchestrate
|
||||
- [x] 2.1 [RED] `resolverPresetEq` table test
|
||||
- [x] 2.2 [GREEN] `resolverPresetEq` free function
|
||||
- [x] 2.3 [RED] `debeAplicarPrincipalAhora` table test
|
||||
- [x] 2.4 [GREEN] `debeAplicarPrincipalAhora` pure gate function
|
||||
- [x] 2.5 [RED] `aplicarPresetPorMediaId` known-preset gate-true/gate-false test
|
||||
- [x] 2.6 [RED] `aplicarPresetPorMediaId` unknown/stale-id no-op test
|
||||
- [x] 2.7 [RED] Structural non-playback invariant test (signature has no playback seam)
|
||||
- [x] 2.8 [GREEN] `aplicarPresetPorMediaId` orchestration
|
||||
|
||||
### Phase 3: Integration / Wiring
|
||||
- [x] 3.1 `getChildren` — `idEcualizador` branch returns `presetsEq(PresetEcualizador.presets)`
|
||||
- [x] 3.2 `playFromMediaId` — `eq_preset:` branch as first statement, unconditional `return`, wrapped in method's outer try/catch
|
||||
- [x] 3.3 Import check — `preset_ecualizador.dart` already imported; added new `servicio_ecualizador.dart` import (required, was not previously imported in this file)
|
||||
|
||||
### Phase 4: Testing / Verification
|
||||
- [x] 4.1 `flutter test test/servicios/navegacion_auto_test.dart` — 46/46 passing (independently re-run twice with default and compact reporters)
|
||||
- [x] 4.2 Grepped `test/` tree for other `raiz()`/root-count assertions — only `navegacion_auto_test.dart` matches, confirmed
|
||||
- [x] 4.3 [DEVIATION] `flutter build`/`run`/`analyze`/`gen-l10n` NOT run (hangs in this environment, same precedent as prior change). Manual static review performed instead.
|
||||
|
||||
### Phase 5: Cleanup
|
||||
- [x] 5.1 [REFACTOR] Doc comments added referencing ADR-1/2/3/5 on all new public/free-function members; suite confirmed green after
|
||||
- [x] 5.2 Active-preset indication — confirmed out of scope per ADR-6, no code added (tracking-only task)
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Action | What Was Done |
|
||||
|------|--------|----------------|
|
||||
| `lib/servicios/navegacion_auto.dart` | Modified | Added `_prefijoPresetEq` const, `esPresetMediaId`, `ConstructorArbolAuto.idEcualizador`, `raiz()` now returns 4 folders (Ecualizador last), `itemPresetEq`, `presetsEq`, free functions `resolverPresetEq`, `debeAplicarPrincipalAhora`, `aplicarPresetPorMediaId` |
|
||||
| `lib/servicios/servicio_audio.dart` | Modified | `getChildren` gains `idEcualizador` branch (before `fuente` guard); `playFromMediaId` restructured — single outer try/catch, `eq_preset:` branch first with unconditional return; added `servicio_ecualizador.dart` import |
|
||||
| `test/servicios/navegacion_auto_test.dart` | Modified | Updated `raiz` test to `hasLength(4)`; added groups for `esPresetMediaId`, `itemPresetEq`, `presetsEq`, `resolverPresetEq`, `debeAplicarPrincipalAhora`, `aplicarPresetPorMediaId` (4 tests including structural non-playback invariant) |
|
||||
|
||||
## TDD Cycle Evidence
|
||||
|
||||
| Task | Test File | Layer | Safety Net | RED | GREEN | TRIANGULATE | REFACTOR |
|
||||
|------|-----------|-------|------------|-----|-------|-------------|----------|
|
||||
| 1.1/1.2 | `navegacion_auto_test.dart` | Unit | ✅ 34/34 (pre-existing baseline) | ✅ Written | ✅ Passed | ✅ 5 cases (table) | ➖ None needed |
|
||||
| 1.3/1.4 | `navegacion_auto_test.dart` | Unit | ✅ (same file) | ✅ Written | ✅ Passed | ➖ Single (fixed 4-folder shape) | ➖ None needed |
|
||||
| 1.5/1.6 | `navegacion_auto_test.dart` | Unit | ✅ (same file) | ✅ Written | ✅ Passed | ➖ Single | ➖ None needed |
|
||||
| 1.7/1.8 | `navegacion_auto_test.dart` | Unit | ✅ (same file) | ✅ Written | ✅ Passed | ✅ 6 presets iterated | ➖ None needed |
|
||||
| 2.1/2.2 | `navegacion_auto_test.dart` | Unit | ✅ (same file) | ✅ Written | ✅ Passed | ✅ 3 negative cases + 1 positive | ➖ None needed |
|
||||
| 2.3/2.4 | `navegacion_auto_test.dart` | Unit | ✅ (same file) | ✅ Written | ✅ Passed | ✅ 3 cases (null/no-override/override) | ➖ None needed |
|
||||
| 2.5-2.8 | `navegacion_auto_test.dart` | Unit | ✅ (same file) | ✅ Written | ✅ Passed | ✅ 4 tests (gate-true, gate-false, unknown-id, structural) | ✅ Clean (extracted gate + resolve as separate pure fns) |
|
||||
| 3.1-3.3 | N/A (dispatch layer, zero coverage precedent per design baseline) | N/A | N/A (new branch in untested method) | N/A | N/A — manual review only, standard workflow per design's documented precedent | N/A | ➖ None needed |
|
||||
| 5.1 | `navegacion_auto_test.dart` | Unit | ✅ 46/46 (post-implementation) | N/A (doc-only) | ✅ Suite still green | N/A | ✅ Doc comments added |
|
||||
|
||||
Note on Phase 3: `servicio_audio.dart`'s `getChildren`/`playFromMediaId` dispatch layer has zero existing test coverage (confirmed in tasks.md baseline — no `test/servicios/servicio_audio_test.dart` exists). This is a pre-existing condition, not something introduced here; the design explicitly names the pure `navegacion_auto.dart` functions as the load-bearing tested layer (ADR-3), with the handler wiring as thin, structurally-safe delegation. No RED/GREEN test cycle applies to Phase 3 tasks — they are direct wiring edits verified by (a) the full Phase 1-2 pure-function suite staying green, and (b) manual static review per task 4.3.
|
||||
|
||||
### Test Summary
|
||||
- **Total tests written**: 15 new test cases (1 esPresetMediaId table [5 sub-cases], 1 itemPresetEq, 1 presetsEq, 2 resolverPresetEq, 3 debeAplicarPrincipalAhora, 4 aplicarPresetPorMediaId, plus the updated raiz test)
|
||||
- **Total tests passing**: 46/46 in `test/servicios/navegacion_auto_test.dart` (34 pre-existing baseline + 12 new test blocks, some containing multiple sub-assertions/table cases) — independently re-confirmed via two separate `flutter test` invocations (default and `--reporter=compact`)
|
||||
- **Layers used**: Unit (46), Integration (0), E2E (0)
|
||||
- **Approval tests** (refactoring): None — no refactoring tasks; Phase 5.1 was pure doc-comment addition, verified via the full suite staying green
|
||||
- **Pure functions created**: 4 (`esPresetMediaId`, `resolverPresetEq`, `debeAplicarPrincipalAhora`, plus `itemPresetEq`/`presetsEq` as pure builders) — `aplicarPresetPorMediaId` is an orchestrator with injected seams, not fully pure, by design (ADR-3)
|
||||
|
||||
## Deviations from Design
|
||||
|
||||
1. **`servicio_ecualizador.dart` import added to `servicio_audio.dart`** — not explicitly listed in task 3.3 (which only mentions verifying the `preset_ecualizador.dart` import), but required to instantiate `ServicioEcualizador()` per the design's data-flow pseudocode. This is a direct, unavoidable consequence of implementing 3.2 as specified — not a deviation from the design's intent, just an omission in the task's explicit file-diff enumeration.
|
||||
2. **`playFromMediaId` restructured to a single outer try/catch** (was: `fuente`-null guard outside try/catch, `reproducirPorMediaId` call inside try/catch). This was required because the design mandates the `eq_preset:` branch run BEFORE the `fuente`-null guard while still being covered by exception safety (Spec "Unknown or stale preset id": no unhandled exception may propagate). Functionally equivalent for the pre-existing `emisora:`/`grupo:` path — behavior for those ids is unchanged (the `fuente == null` check remains, just now inside the try block, and its `return` behavior on null is unaffected).
|
||||
|
||||
No other deviations — implementation matches design (ADR-1 through ADR-6 all satisfied as specified).
|
||||
|
||||
## Issues Found
|
||||
|
||||
None.
|
||||
|
||||
## Workload / PR Boundary
|
||||
|
||||
- Mode: single PR (no chaining needed — actual diff came in at 339 insertions + 7 deletions = 346 changed lines, within the 300-420 forecast and under the 400-line budget)
|
||||
- Current work unit: N/A — single delivery, all tasks in one pass
|
||||
- Boundary: Starts from a clean baseline (34/34 pre-existing tests green) and ends with the full `Ecualizador` folder + preset-selection feature wired end-to-end, 46/46 tests green
|
||||
- Estimated review budget impact: Medium-low — under the 400-line reviewer budget; changes are additive (new branches alongside existing `emisora:`/`grupo:` dispatch, no restructuring of unrelated logic)
|
||||
|
||||
## Status
|
||||
|
||||
21/21 tasks complete. Ready for sdd-verify.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Proposal: Android Auto EQ preset selection
|
||||
|
||||
## Intent
|
||||
|
||||
Drivers cannot change the audio equalizer while on Android Auto — EQ selection is phone-UI only today. Expose the 6 fixed presets as a browsable folder in the existing Auto media tree so a preset can be applied from the car, without reaching for the phone and without interrupting the current station.
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
- New top-level browsable `Ecualizador` folder in `ConstructorArbolAuto.raiz()`, listing the 6 fixed `PresetEcualizador.presets` by name.
|
||||
- New media-id scheme `eq_preset:<nombre>`, intercepted in `playFromMediaId` BEFORE the `emisora:`/`grupo:` routing.
|
||||
- Tapping a preset applies it through the existing headless-safe EQ seam and persists it as the principal preset (phone/car parity), with NO call to `playMediaItem` and NO `mediaItem` mutation.
|
||||
- Strict-TDD tests: tree shape, `eq_preset:` id resolution, and the "preset tap does not disturb playback / now-playing" invariant.
|
||||
- Optional (lower priority): mark the active preset in its row title (see Risks — no native selection UI exists in this model).
|
||||
|
||||
### Out of Scope
|
||||
- Custom band-level EQ editing or arbitrary EQ from the car (media-browser capability ceiling; stays phone-only).
|
||||
- Per-station / per-device / matrix EQ writes from the car (global principal only).
|
||||
- Custom transport-button EQ actions (button real-estate fits ~1-2 slots, not 6 named presets).
|
||||
- Favorite-groups browse work (separate, already-shipped `android-auto-favorite-groups`).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- None
|
||||
|
||||
### Modified Capabilities
|
||||
- `android-auto-media`: the browse tree gains an `Ecualizador` root folder plus an `eq_preset:` selection action that applies an EQ preset without starting or altering playback.
|
||||
|
||||
## Approach
|
||||
|
||||
Mirror the proven `emisora:`/`grupo:` interception pattern already in `navegacion_auto.dart` + `servicio_audio.dart`. Preset rows are `playable: true` (the only tappable non-folder item type in the legacy `MediaBrowserService` model) and therefore route through `playFromMediaId`; a new `eq_preset:` branch resolves the preset by name and calls the EQ apply seam instead of the audio player. Reuse the phone's global-preset semantics (apply via `PluriWaveAudioHandler.aplicarPreset` + persist via `ServicioEcualizador.guardarPrincipal`, matching `cambiarPresetPrincipal`) so phone and car stay in sync. The write path MUST be headless-safe (handler + service, NOT the widget-tree `EstadoEcualizador`, which may never build on an Auto bind).
|
||||
|
||||
## Affected Areas
|
||||
|
||||
| Area | Impact | Description |
|
||||
|------|--------|-------------|
|
||||
| `lib/servicios/navegacion_auto.dart` | Modified | Add `Ecualizador` root folder, `eq_preset:` id scheme, preset-row builder, resolver + selection routing helper |
|
||||
| `lib/servicios/servicio_audio.dart` | Modified | Branch `playFromMediaId` on `eq_preset:` to the apply/persist seam before `emisora:`/`grupo:` |
|
||||
| `lib/modelos/preset_ecualizador.dart` | Read-only | Source of the 6 fixed presets (name is the id key) |
|
||||
| `lib/servicios/servicio_ecualizador.dart` | Read-only reuse | `guardarPrincipal` persistence seam (headless-safe) |
|
||||
| `openspec/specs/android-auto-media/spec.md` | Delta only | Modified-capability delta in this change folder; base spec untouched |
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Likelihood | Mitigation |
|
||||
|------|------------|------------|
|
||||
| Preset tap looks like "now playing a new track" (playable item) | Med | Never call `playMediaItem`, never mutate `mediaItem`; dedicated `eq_preset:` branch + invariant test |
|
||||
| Shared-file merge drift with just-shipped favorite-groups | Med | Read CURRENT post-`f368bcc`/`066fedb` state; add branch alongside existing `grupo:`/`emisora:` dispatch |
|
||||
| No native "selected" UI for browsable rows | High | Active-preset marker only via title text (prefix); ship as optional, be explicit it is not a real checkmark |
|
||||
| Persist path unavailable on headless Auto bind | Med | Route through service + handler (SharedPreferences), not `EstadoEcualizador` |
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
Additive only — revert the feature commits. No schema/migration changes: removing the `Ecualizador` folder and the `eq_preset:` branch restores the exact prior tree (favorite-groups behavior unaffected).
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Sequenced after `android-auto-favorite-groups` (now on main) to avoid conflicts in the two shared files.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] `Ecualizador` folder appears at the Auto root with all 6 presets by name.
|
||||
- [ ] Tapping a preset applies it (native EQ gains change) and persists as principal.
|
||||
- [ ] Current station playback / now-playing metadata is unchanged by a preset tap.
|
||||
- [ ] Selecting a preset in the car is reflected in the phone EQ UI and vice versa.
|
||||
- [ ] Unknown/stale `eq_preset:` id is a no-op (no throw).
|
||||
@@ -0,0 +1,115 @@
|
||||
# Delta for android-auto-media
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Browsable Media Tree
|
||||
|
||||
`getChildren` MUST return a browsable tree rooted at `AudioService.browsableRootId`, organized into non-playable folders (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador) containing playable items. Playable station items SHOULD carry an audio-quality subtitle when known. The `Favoritos` folder additionally MAY contain non-playable favorite-group sub-folders; `Todas las emisoras` and `Mis emisoras` remain flat. The `Ecualizador` folder is flat, non-playable, and contains only the 6 fixed EQ preset items (see "EQ Preset Browsable Folder").
|
||||
(Previously: root contained exactly 3 folders — Favoritos, Todas las emisoras, Mis emisoras — with no EQ folder.)
|
||||
|
||||
#### Scenario: Car requests the root
|
||||
|
||||
- GIVEN the car head unit connects and requests the root (`AudioService.browsableRootId`)
|
||||
- WHEN `getChildren` is called with the root id
|
||||
- THEN it returns four folder `MediaItem`s (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador), each with `playable: false`
|
||||
|
||||
#### Scenario: Car requests a folder with no stations
|
||||
|
||||
- GIVEN the user has zero favorite stations
|
||||
- WHEN `getChildren` is called with the Favoritos folder id
|
||||
- THEN it returns an empty list, not an error
|
||||
|
||||
#### Scenario: Browse requested before app state is loaded
|
||||
|
||||
- GIVEN the audio handler starts cold and station/favorites Provider state has not finished loading
|
||||
- WHEN `getChildren` is called (root or any folder)
|
||||
- THEN it returns a valid, possibly empty, list without throwing and without blocking or crashing the service
|
||||
|
||||
#### Scenario: Station has known codec and bitrate
|
||||
|
||||
- GIVEN a station's `Emisora.codec` and `Emisora.bitrate` are both known
|
||||
- WHEN it is mapped to a playable `MediaItem`
|
||||
- THEN `displaySubtitle` SHALL contain a human-readable quality hint (e.g. "128 kbps · MP3")
|
||||
|
||||
#### Scenario: Station has unknown codec or bitrate
|
||||
|
||||
- GIVEN a station's `Emisora.codec` or `Emisora.bitrate` is null/unknown
|
||||
- WHEN it is mapped to a playable `MediaItem`
|
||||
- THEN `displaySubtitle` SHALL omit the quality hint gracefully, never rendering literal placeholder text
|
||||
|
||||
#### Scenario: Ungrouped station appears exactly as before (regression guard)
|
||||
|
||||
- GIVEN a station's `Emisora.grupoFavoritosId` equals `GrupoFavoritos.sinAsignarId`
|
||||
- WHEN the `Favoritos`, `Todas las emisoras`, or `Mis emisoras` folders are browsed
|
||||
- THEN that station appears as a playable `emisora:<uuid>` item in the same folder(s), position, title, art, and subtitle as before this change
|
||||
- AND its presence and shape are unaffected by the new `Ecualizador` folder
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: EQ Preset Browsable Folder
|
||||
|
||||
The Android Auto browse tree MUST expose the 6 fixed EQ presets (`PresetEcualizador.presets`) as playable items inside the `Ecualizador` root folder, each using a distinct `eq_preset:<nombre>` media-id scheme, separate from `emisora:<uuid>` and `grupo:<id>`.
|
||||
|
||||
#### Scenario: Car requests the Ecualizador folder
|
||||
|
||||
- GIVEN the `Ecualizador` folder id was returned under the root
|
||||
- WHEN `getChildren` is called with the `Ecualizador` folder id
|
||||
- THEN it returns exactly 6 playable `MediaItem`s, one per `PresetEcualizador.presets` entry, titled with the preset's name
|
||||
- AND each item's id is `eq_preset:<nombre>`, where `<nombre>` is that preset's unique name
|
||||
|
||||
### Requirement: EQ Preset Selection Applies Without Disturbing Playback
|
||||
|
||||
Selecting an `eq_preset:<nombre>` item MUST apply that preset immediately through the headless-safe EQ seam (e.g. `PluriWaveAudioHandler.aplicarPreset`), via a branch in `playFromMediaId` intercepted BEFORE the `emisora:`/`grupo:` routing. It MUST NOT call `playMediaItem`, MUST NOT mutate the now-playing `mediaItem`, and MUST NOT start, stop, restart, or otherwise alter current playback or playback position.
|
||||
|
||||
#### Scenario: User selects a preset while a station is playing
|
||||
|
||||
- GIVEN a station is currently playing and projected to the car
|
||||
- WHEN the user taps an `eq_preset:<nombre>` item, resolved via `playFromMediaId`
|
||||
- THEN the matching preset is applied via the headless-safe apply seam
|
||||
- AND the currently playing station continues uninterrupted, with no change to playback position or now-playing metadata
|
||||
|
||||
#### Scenario: User selects a preset while nothing is playing
|
||||
|
||||
- GIVEN no station is currently playing
|
||||
- WHEN the user taps an `eq_preset:<nombre>` item
|
||||
- THEN the matching preset is applied via the headless-safe apply seam
|
||||
- AND no playback starts as a result of the selection
|
||||
|
||||
#### Scenario: Unknown or stale preset id
|
||||
|
||||
- GIVEN `playFromMediaId` receives an `eq_preset:<nombre>` id whose `<nombre>` matches no entry in the current `PresetEcualizador.presets`
|
||||
- WHEN resolution fails to find a matching preset
|
||||
- THEN the selection is a no-op: no preset is applied, no playback state changes, and no unhandled exception propagates from the handler
|
||||
|
||||
### Requirement: EQ Preset Persistence and Phone/Car Parity
|
||||
|
||||
An EQ preset applied from the car MUST persist as the principal preset, with the same observable outcome as the phone's existing `ServicioEcualizador.guardarPrincipal`/`cambiarPresetPrincipal` path, and MUST be reflected on the phone. A principal preset changed on the phone MUST be observable from the car. The exact code path used to reach the headless-safe persistence seam from an Android Auto bind is deferred to `sdd-design`.
|
||||
|
||||
#### Scenario: Preset applied from the car persists for the phone
|
||||
|
||||
- GIVEN the user selects `eq_preset:<nombre>` from the car head unit
|
||||
- WHEN the selection is processed
|
||||
- THEN the preset is persisted as the principal preset
|
||||
- AND opening the phone's EQ UI afterward shows that preset as the active/principal preset
|
||||
|
||||
#### Scenario: Preset applied from the phone is reflected for the car
|
||||
|
||||
- GIVEN the user changes the principal preset from the phone UI
|
||||
- WHEN the car head unit subsequently observes EQ-related state through the browse/selection surface
|
||||
- THEN the car-observable state reflects the phone's most recently applied principal preset
|
||||
|
||||
### Requirement: Active Preset Indication (Optional)
|
||||
|
||||
The system MAY indicate the active/principal preset within the `Ecualizador` folder's rows. Because the legacy `MediaBrowserService` browsable-item model has no native "selected item" affordance, any indication, if implemented, MUST be conveyed via a row `title` text convention (e.g. a marker prefix/suffix) rather than assuming a checkmark or selection icon exists. If a reliable title-text convention is too costly to maintain, this requirement MAY instead be satisfied by explicitly not implementing active-preset indication.
|
||||
|
||||
#### Scenario: Active preset is indicated via title convention (if implemented)
|
||||
|
||||
- GIVEN the currently applied principal preset is known when `Ecualizador`'s children are built
|
||||
- WHEN the design's chosen title-text convention is applied
|
||||
- THEN exactly one of the 6 preset rows' `title` carries the active-preset marker, uniquely identifying that preset among the 6
|
||||
|
||||
#### Scenario: Active preset indication is out of scope (if not implemented)
|
||||
|
||||
- GIVEN the design opts not to implement an active-preset marker
|
||||
- WHEN the `Ecualizador` folder's children are built
|
||||
- THEN all 6 preset rows are returned with their plain preset names, with no partial or inconsistent marking applied
|
||||
@@ -0,0 +1,82 @@
|
||||
# Tasks: Android Auto EQ preset selection
|
||||
|
||||
Strict TDD active for Dart layers. Behavioral task = RED (failing test) -> GREEN
|
||||
(minimal impl) -> REFACTOR. `flutter build`/`flutter run`/`flutter analyze`/
|
||||
`flutter gen-l10n` MUST NOT be executed in this environment (hang) — marked
|
||||
**[DEVIATION]**, same precedent as
|
||||
`openspec/changes/archive/2026-07-19-android-auto-favorite-groups/tasks.md`.
|
||||
|
||||
## Review Workload Forecast
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Estimated changed lines | 300-420 |
|
||||
| 400-line budget risk | Medium |
|
||||
| Chained PRs recommended | No |
|
||||
| Suggested split | Single PR; fallback 2-way split if apply exceeds budget: PR 1 = Phase 1-2 (pure builders/functions + tests), PR 2 = Phase 3-5 (handler wiring + cleanup) |
|
||||
| Delivery strategy | ask-on-risk (default, not overridden by caller) |
|
||||
| Chain strategy | pending |
|
||||
|
||||
Decision needed before apply: No
|
||||
Chained PRs recommended: No
|
||||
Chain strategy: pending
|
||||
400-line budget risk: Medium
|
||||
|
||||
### Suggested Work Units
|
||||
|
||||
| Unit | Goal | Likely PR | Notes |
|
||||
|------|------|-----------|-------|
|
||||
| 1 | Media-id scheme + pure builders/functions (Phase 1-2) + tests | PR 1 | Fully unit-testable, no handler dependency |
|
||||
| 2 | Handler wiring (Phase 3) + broken-assertion fix + verification (Phase 4-5) | PR 2 (fallback only) | Depends on Unit 1; only split out if diff runs over 400 |
|
||||
|
||||
## Baseline (verified against live code, not spec/design prose)
|
||||
|
||||
- `lib/servicios/navegacion_auto.dart:13` — `_prefijoEmisora = 'emisora:'` top-level const; no `eq_preset:` prefix exists yet.
|
||||
- `lib/servicios/navegacion_auto.dart:119-256` `ConstructorArbolAuto` — `idFavoritos`/`idTodas`/`idMisEmisoras` (123-125), `_idsCarpetas` (127, do NOT add `idEcualizador` here — it needs its own dedicated branch, not the generic `hijos()` station path), `_prefijoGrupo` (132), `raiz()` (152-156, currently 3 folders), `_carpeta()` helper (158), `itemEmisora()` (181), `resolver()` (193), `esCarpetaGrupo()` (205), `itemGrupo()` (209).
|
||||
- `lib/servicios/servicio_audio.dart:731-761` `getChildren` — root check, then `fuente == null` guard, then `idFavoritos`/`esCarpetaGrupo`/default branches.
|
||||
- `lib/servicios/servicio_audio.dart:778-798` `playFromMediaId` — `fuente == null` guard first, then delegates to `reproducirPorMediaId`; **zero existing test coverage** for this dispatch layer (no `test/servicios/servicio_audio_test.dart` exists).
|
||||
- `lib/servicios/servicio_audio.dart:183` `emisoraActual` — public field on `PluriWaveAudioHandler`, directly readable (no getter indirection needed for `uuidActual`).
|
||||
- `lib/servicios/servicio_audio.dart:580-601` `aplicarPreset` — headless-safe seam, touches only `_presetActual`/native EQ, never `mediaItem`/`playbackState`.
|
||||
- `lib/servicios/servicio_ecualizador.dart:129-132` `guardarPrincipal` — writes SP key `eq_preset_principal_v1` (line 39).
|
||||
- `lib/estado/estado_ecualizador.dart:295-310` `cambiarPresetPrincipal` — confirmed gate: `uuid == null || !_presetsEmisoraMap.containsKey(uuid)` (302-304) is the exact logic `debeAplicarPrincipalAhora` must mirror.
|
||||
- `lib/modelos/preset_ecualizador.dart:35` — `PresetEcualizador.presets` is exactly 6 fixed entries (Flat, Rock, Pop, Bass Boost, Jazz, Voz).
|
||||
- `test/servicios/navegacion_auto_test.dart:220-239` — `hasLength(3)` at line 225 and the 3-id `equals()` set at 229-233 both need updating; no other test file references root-folder count.
|
||||
|
||||
## Phase 1: Foundation — media-id scheme + root folder
|
||||
|
||||
- [x] 1.1 [RED] `navegacion_auto_test.dart`: `esPresetMediaId` table — `'eq_preset:Rock'`→true, `'eq_preset:'`→true (prefix-only, resolves to null downstream), `'emisora:x'`/`'grupo:g1'`/`''`→false. *(Spec "EQ Preset Browsable Folder"; ADR-1)*
|
||||
- [x] 1.2 [GREEN] `navegacion_auto.dart`: add `_prefijoPresetEq = 'eq_preset:'` top-level const (next to `_prefijoEmisora`, line 13); `esPresetMediaId(id) => id.startsWith(_prefijoPresetEq)`.
|
||||
- [x] 1.3 [RED] `raiz` test (update existing `test/servicios/navegacion_auto_test.dart:220-239` group): `hasLength(3)` → `hasLength(4)`; id set gains `ConstructorArbolAuto.idEcualizador`; add explicit assertion that `raiz().last.id == ConstructorArbolAuto.idEcualizador` (ADR-2, last position) and `raiz().last.playable == false`.
|
||||
- [x] 1.4 [GREEN] `navegacion_auto.dart`: add `idEcualizador = 'ecualizador'` const (123-125 block, NOT added to `_idsCarpetas`); append `_carpeta(idEcualizador, 'Ecualizador')` as the last entry in `raiz()` (line 152-156).
|
||||
- [x] 1.5 [RED] `itemPresetEq` test — id `'eq_preset:<nombre>'`, `playable: true`, `title == preset.nombre`. *(Spec "Car requests the Ecualizador folder")*
|
||||
- [x] 1.6 [GREEN] `navegacion_auto.dart`: `itemPresetEq(preset) => MediaItem(id: '$_prefijoPresetEq${preset.nombre}', title: preset.nombre, playable: true, extras: _contentStyleGrid)`.
|
||||
- [x] 1.7 [RED] `presetsEq` test — given `PresetEcualizador.presets`, returns exactly 6 items, one per preset, ids/titles match. *(Spec scenario "Car requests the Ecualizador folder")*
|
||||
- [x] 1.8 [GREEN] `navegacion_auto.dart`: `presetsEq(presets) => presets.map(itemPresetEq).toList()`.
|
||||
|
||||
## Phase 2: Core Implementation — resolve, gate, orchestrate
|
||||
|
||||
- [x] 2.1 [RED] `resolverPresetEq` table — known name → matching preset; unknown name / empty-after-prefix / non-`eq_preset:` id → `null`, no throw. *(Spec "Unknown or stale preset id")*
|
||||
- [x] 2.2 [GREEN] `navegacion_auto.dart`: `resolverPresetEq(id, presets)` — guard `esPresetMediaId`, strip prefix, empty→null, exact-name loop match→preset else null (mirrors `resolver()` shape, line 193).
|
||||
- [x] 2.3 [RED] `debeAplicarPrincipalAhora` table — `uuidActual: null`→true; `uuidActual` not in `clavesPorEmisora`→true; `uuidActual` in `clavesPorEmisora`→false. *(ADR-5, mirrors `estado_ecualizador.dart:302-304`)*
|
||||
- [x] 2.4 [GREEN] `navegacion_auto.dart`: pure `debeAplicarPrincipalAhora({uuidActual, clavesPorEmisora}) => uuidActual == null || !clavesPorEmisora.contains(uuidActual)`.
|
||||
- [x] 2.5 [RED] `aplicarPresetPorMediaId` — known preset: `persistirPrincipal` called once with resolved preset; `aplicar` called once when gate is true; `aplicar` NOT called when gate is false (per-station override case). *(ADR-3, ADR-5)*
|
||||
- [x] 2.6 [RED] `aplicarPresetPorMediaId` — unknown/stale id: neither `persistirPrincipal` nor `aplicar` is invoked, function returns without throwing. *(Spec "Unknown or stale preset id")*
|
||||
- [x] 2.7 [RED] Structural invariant test — assert `aplicarPresetPorMediaId`'s signature exposes ONLY `persistirPrincipal`/`aplicar`/`uuidActual`/`clavesPorEmisora` seams (no `playMediaItem`/`mediaItem`/`playbackState` parameter exists to inject); document this as the load-bearing non-playback proof per ADR-3.
|
||||
- [x] 2.8 [GREEN] `navegacion_auto.dart`: implement `aplicarPresetPorMediaId(id, {required presets, required uuidActual, required Future<Set<String>> Function() clavesPorEmisora, required Future<void> Function(PresetEcualizador) persistirPrincipal, required Future<void> Function(PresetEcualizador) aplicar})` — resolve via `resolverPresetEq`; `null`→return; else `await persistirPrincipal(preset)`; `if (debeAplicarPrincipalAhora(uuidActual: uuidActual, clavesPorEmisora: await clavesPorEmisora())) await aplicar(preset);`. Satisfies 2.5-2.7.
|
||||
|
||||
## Phase 3: Integration / Wiring
|
||||
|
||||
- [x] 3.1 `servicio_audio.dart:731-761` `getChildren`: insert `if (parentMediaId == ConstructorArbolAuto.idEcualizador) return constructor.presetsEq(PresetEcualizador.presets);` immediately after the root-id check and BEFORE `final fuente = _fuenteNavegacionGlobal;` (no data source needed — compile-time constant list, per design's Integration Points).
|
||||
- [x] 3.2 `servicio_audio.dart:778-798` `playFromMediaId`: insert an `esPresetMediaId(mediaId)` branch as the FIRST statement in the method body, BEFORE `final fuente = _fuenteNavegacionGlobal; if (fuente == null) return;`. Branch body: `final servicio = ServicioEcualizador(); await aplicarPresetPorMediaId(mediaId, presets: PresetEcualizador.presets, uuidActual: emisoraActual?.uuid, clavesPorEmisora: () async => (await servicio.cargar()).porEmisora.keys.toSet(), persistirPrincipal: servicio.guardarPrincipal, aplicar: aplicarPreset); return;` — wrapped in the method's existing outer try/catch so an unexpected failure still never propagates (Spec "Unknown or stale preset id"). *(ADR-3: FIRST branch + unconditional `return` means it can never fall through to `reproducirPorMediaId`.)*
|
||||
- [x] 3.3 Add `import '../modelos/preset_ecualizador.dart';` to `servicio_audio.dart` if not already present (check before adding — `aplicarPreset`'s existing signature at line 580 already takes `PresetEcualizador`, so the import likely already exists; verify, do not duplicate).
|
||||
|
||||
## Phase 4: Testing / Verification
|
||||
|
||||
- [x] 4.1 Run `flutter test test/servicios/navegacion_auto_test.dart` — full green, including the updated `hasLength(4)` root test. (46/46 passing, independently re-run twice.)
|
||||
- [x] 4.2 Grep the full `test/` tree for any other `raiz()`/root-folder-count assertions outside `navegacion_auto_test.dart` (baseline found none as of this writing — confirm still true before merge). Confirmed: only `navegacion_auto_test.dart` matches.
|
||||
- [x] 4.3 [DEVIATION] `flutter build`/`flutter run`/`flutter analyze`/`flutter gen-l10n` — DO NOT RUN in this environment (hangs). Manual static review instead: no unused symbols, `eq_preset:` prefix non-colliding with `emisora:`/`grupo:`/bare folder ids by inspection, `playFromMediaId`'s new branch returns unconditionally with no fallthrough. Real run recommended before merge/CI.
|
||||
|
||||
## Phase 5: Cleanup
|
||||
|
||||
- [x] 5.1 [REFACTOR] `navegacion_auto.dart`: doc-comment new public members per the file's existing Design-decision-linking style (reference ADR-1/2/3/5); confirm full test suite still green. All new symbols carry ADR-linked doc comments; code was already clean at GREEN — no further extraction needed.
|
||||
- [x] 5.2 Active-preset indication (Spec "Active Preset Indication (Optional)", ADR-6): OUT OF SCOPE for this change by design decision — preset rows carry plain names only, no marker. No task implements it; this line exists so the requirement isn't silently dropped from tracking.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Verification Report: android-auto-eq-presets
|
||||
|
||||
**Mode**: Strict TDD | **Verdict**: PASS
|
||||
|
||||
## Completeness
|
||||
|
||||
All 21 tasks in `tasks.md` (Phases 1-5) are checked complete and match the live code. No task marked complete lacks a corresponding code/test change.
|
||||
|
||||
## Test Execution Evidence (independently re-run, not trusted from apply-progress)
|
||||
|
||||
| Command | Result |
|
||||
|---|---|
|
||||
| `flutter test test/servicios/navegacion_auto_test.dart --concurrency=1 --timeout=60s` (compact reporter) | 46/46 passed |
|
||||
| Same file, `-r json`, counted `testDone` events | 46 (independently confirmed via JSON reporter, not just trusting the compact-reporter tally) |
|
||||
| `flutter test test/estado/estado_radio_test.dart --concurrency=1 --timeout=60s` (favorite-groups regression suite, shared `playFromMediaId`/dispatch surface) | 22/22 passed (JSON `testDone` count also 22) |
|
||||
|
||||
Apply-progress's claimed 46/46 for `navegacion_auto_test.dart` is accurate. The extra regression pass on `estado_radio_test.dart` (not part of apply-progress's own claims) also passes clean - no regression on the favorite-groups feature that shares the touched dispatch surface.
|
||||
|
||||
## Load-Bearing Invariant: EQ selection never disturbs playback
|
||||
|
||||
Traced `playFromMediaId` (`lib/servicios/servicio_audio.dart:781-820`) directly in the live file:
|
||||
- The `esPresetMediaId(mediaId)` branch is the first statement inside the (now single) outer `try`, before `_fuenteNavegacionGlobal` is even read.
|
||||
- It ends with an unconditional `return;` - genuinely cannot fall through to `reproducirPorMediaId`/`playMediaItem`.
|
||||
- No `mediaItem` mutation, no `play()`/`setUrl()`/reconnect call anywhere in that branch - only `ServicioEcualizador()` construction and `aplicarPresetPorMediaId(...)`.
|
||||
|
||||
Traced `aplicarPresetPorMediaId`'s actual signature (`lib/servicios/navegacion_auto.dart:364-371`, read directly, not from docstring):
|
||||
|
||||
```
|
||||
Future<void> aplicarPresetPorMediaId(
|
||||
String id, {
|
||||
required List<PresetEcualizador> presets,
|
||||
required String? uuidActual,
|
||||
required Future<Set<String>> Function() clavesPorEmisora,
|
||||
required Future<void> Function(PresetEcualizador) persistirPrincipal,
|
||||
required Future<void> Function(PresetEcualizador) aplicar,
|
||||
})
|
||||
```
|
||||
|
||||
Confirmed: no `playMediaItem`, `mediaItem`, or `playbackState` parameter exists - structurally impossible to inject a playback seam. This matches the docstring claim and the dedicated structural test (`navegacion_auto_test.dart:755-784`).
|
||||
|
||||
## Root Folder Count
|
||||
|
||||
- `ConstructorArbolAuto.raiz()` (`lib/servicios/navegacion_auto.dart:171-176`) returns 4 folders, `Ecualizador` last, `playable: false`.
|
||||
- `test/servicios/navegacion_auto_test.dart:226` - `expect(raiz, hasLength(4))` - confirmed updated (line number shifted from the apply-progress's original estimate of 225 to 226 due to file growth, as anticipated by the reviewer's warning; content matches).
|
||||
- Line 241-242 additionally assert `raiz.last.id == idEcualizador` and `raiz.last.playable == false` (ADR-2 last-position + non-playable, beyond the base spec's minimum).
|
||||
- Grepped the full `test/` tree for other `raiz()`/root-count assertions: only `navegacion_auto_test.dart` matches - confirms task 4.2's claim.
|
||||
|
||||
## Persistence Seam
|
||||
|
||||
Grepped both call sites in the diff:
|
||||
- `servicio_audio.dart:800` - `persistirPrincipal: servicio.guardarPrincipal` -> `ServicioEcualizador.guardarPrincipal` (`servicio_ecualizador.dart:129-132`, writes SharedPreferences key `eq_preset_principal_v1`).
|
||||
- No call to `EstadoEcualizador.cambiarPresetPrincipal` anywhere in the diff or the new code paths (confirmed via grep across `navegacion_auto.dart`/`servicio_audio.dart`).
|
||||
- Matches ADR-4 exactly: handler + service seam, not the phone's `ChangeNotifier`.
|
||||
|
||||
## debeAplicarPrincipalAhora Gate Parity
|
||||
|
||||
Read both gates directly:
|
||||
- `estado_ecualizador.dart:302-304`: `uuid == null || !_presetsEmisoraMap.containsKey(uuid)`
|
||||
- `navegacion_auto.dart:348-351`: `uuidActual == null || !clavesPorEmisora.contains(uuidActual)`
|
||||
|
||||
Also traced that `clavesPorEmisora` is populated from `servicio.cargar().porEmisora.keys`, and `servicio_ecualizador.dart`'s `cargar()` reads `porEmisora` from the same persisted per-station map that populates `EstadoEcualizador._presetsEmisoraMap` (`estado_ecualizador.dart:161-163`, `..addAll(config.porEmisora)`). The two gates are genuinely operating over the same underlying data, not just superficially similar in shape.
|
||||
|
||||
## Unknown/Stale Preset ID
|
||||
|
||||
`resolverPresetEq` returns `null` for unknown name / empty-after-prefix / non-`eq_preset:` id (`navegacion_auto.dart:332-340`). `aplicarPresetPorMediaId` returns immediately on `null` without invoking either seam. Test (`navegacion_auto_test.dart:731-753`) asserts `persistirLlamadas == 0` and `aplicarLlamadas == 0` for `'eq_preset:Inexistente'` - the assertion is on call counts, not just a test name; genuinely proves the no-op.
|
||||
|
||||
## Active Preset Indication (Optional requirement)
|
||||
|
||||
Confirmed OUT OF SCOPE as designed (ADR-6). Grepped `navegacion_auto.dart` for marker/selection-related terms - no half-built UI, no dead marker code, no leftover scaffolding. `itemPresetEq` emits only the plain preset name as `title`.
|
||||
|
||||
## Deviations Self-Reported by apply-progress.md - Verified
|
||||
|
||||
1. servicio_ecualizador.dart import added to servicio_audio.dart, not listed in task 3.3's enumerated file list. Confirmed real: `git diff` shows `+import 'servicio_ecualizador.dart';` at the top of the file; task 3.3's text only mentions verifying the `preset_ecualizador.dart` import. This is a genuine (harmless) task-description gap, not a code defect.
|
||||
2. playFromMediaId restructured to a single outer try/catch. Confirmed real via `git diff`: previously `final fuente = _fuenteNavegacionGlobal; if (fuente == null) return;` sat OUTSIDE the try block; now both lines are inside the try, after the new `eq_preset:` branch. Behaviorally inert for the pre-existing `emisora:`/`grupo:` path - the null-check itself cannot throw, so moving it inside `try` changes nothing observable. Verified via the full `estado_radio_test.dart` (22/22) and `navegacion_auto_test.dart` (46/46, includes `reproducirPorMediaId` tests) suites, both green.
|
||||
|
||||
## Diff Size Cross-Check
|
||||
|
||||
`git diff --stat` on the three touched files:
|
||||
|
||||
```
|
||||
lib/servicios/navegacion_auto.dart | 94 ++++++++++++-
|
||||
lib/servicios/servicio_audio.dart | 26 +++-
|
||||
test/servicios/navegacion_auto_test.dart | 226 ++++++++++++++++++++++++++++++-
|
||||
3 files changed, 339 insertions(+), 7 deletions(-)
|
||||
```
|
||||
|
||||
339 + 7 = 346 changed lines - matches apply-progress's self-reported figure exactly. Within the 300-420 forecast, under the 400-line budget.
|
||||
|
||||
## Cleanliness Checks
|
||||
|
||||
- No AI attribution strings found in the diff (`Co-Authored-By`, `Generated with`, `Claude`, `ChatGPT`, etc. - none matched).
|
||||
- No stray `print`/`debugPrint` calls introduced.
|
||||
- No `TODO`/`FIXME` markers introduced.
|
||||
- No dead code found in the reviewed hunks.
|
||||
- Working tree: `navegacion_auto.dart`, `servicio_audio.dart`, `test/servicios/navegacion_auto_test.dart` are modified-but-unstaged; `openspec/changes/android-auto-eq-presets/` is untracked. Nothing has been committed - confirmed via `git log` (HEAD is still `066fedb`, predating this change) and `git status`.
|
||||
|
||||
## Spec Compliance Matrix
|
||||
|
||||
| Spec Requirement | Status | Evidence |
|
||||
|---|---|---|
|
||||
| Browsable Media Tree (4 folders, Ecualizador included) | PASS | raiz() + test at line 226 |
|
||||
| EQ Preset Browsable Folder (6 items, eq_preset:<nombre>) | PASS | presetsEq/itemPresetEq + tests |
|
||||
| EQ Preset Selection Applies Without Disturbing Playback | PASS | Structural signature proof + first-branch/unconditional-return trace |
|
||||
| EQ Preset Persistence and Phone/Car Parity | PASS | guardarPrincipal call site confirmed, SP key shared with phone |
|
||||
| Active Preset Indication (Optional) | PASS (scoped out) | ADR-6, no half-built code |
|
||||
|
||||
## Design Coherence
|
||||
|
||||
All 6 ADRs (ADR-1 through ADR-6) traced against live code - implementation matches design as documented. No undocumented deviation found beyond the two apply-progress already self-reported (both verified genuine and low-risk).
|
||||
|
||||
## Issues Found
|
||||
|
||||
None CRITICAL. None WARNING. None SUGGESTION beyond a purely cosmetic note: apply-progress's line-number references (e.g. "225" vs actual "226") drift slightly as the file grows - informational only, does not affect correctness or task completeness.
|
||||
|
||||
## Final Verdict
|
||||
|
||||
PASS - 0 CRITICAL, 0 WARNING, 0 SUGGESTION (one informational-only line-number drift note, not a defect).
|
||||
@@ -2,6 +2,7 @@ import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
|
||||
void main() {
|
||||
@@ -218,11 +219,11 @@ void main() {
|
||||
});
|
||||
|
||||
group('ConstructorArbolAuto.raiz', () {
|
||||
test('devuelve exactamente 3 carpetas no reproducibles con los ids '
|
||||
'esperados', () {
|
||||
test('devuelve exactamente 4 carpetas no reproducibles con los ids '
|
||||
'esperados, terminando en Ecualizador', () {
|
||||
final raiz = ConstructorArbolAuto().raiz();
|
||||
|
||||
expect(raiz, hasLength(3));
|
||||
expect(raiz, hasLength(4));
|
||||
final ids = raiz.map((item) => item.id).toSet();
|
||||
expect(
|
||||
ids,
|
||||
@@ -230,12 +231,63 @@ void main() {
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idEcualizador,
|
||||
}),
|
||||
);
|
||||
for (final item in raiz) {
|
||||
expect(item.playable, isFalse);
|
||||
expect(item.title, isNotEmpty);
|
||||
}
|
||||
expect(raiz.last.id, ConstructorArbolAuto.idEcualizador);
|
||||
expect(raiz.last.playable, isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('esPresetMediaId', () {
|
||||
test('reconoce ids con el prefijo eq_preset:, rechaza el resto', () {
|
||||
final casos = <String, bool>{
|
||||
'eq_preset:Rock': true,
|
||||
'eq_preset:': true,
|
||||
'emisora:x': false,
|
||||
'grupo:g1': false,
|
||||
'': false,
|
||||
};
|
||||
|
||||
casos.forEach((id, esperado) {
|
||||
expect(
|
||||
esPresetMediaId(id),
|
||||
esperado,
|
||||
reason: 'esPresetMediaId($id) debería ser $esperado',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('ConstructorArbolAuto.itemPresetEq', () {
|
||||
test('mapea un PresetEcualizador a un item reproducible con id '
|
||||
'eq_preset:<nombre>', () {
|
||||
final item = ConstructorArbolAuto().itemPresetEq(
|
||||
PresetEcualizador.rock,
|
||||
);
|
||||
|
||||
expect(item.id, 'eq_preset:${PresetEcualizador.rock.nombre}');
|
||||
expect(item.playable, isTrue);
|
||||
expect(item.title, PresetEcualizador.rock.nombre);
|
||||
});
|
||||
});
|
||||
|
||||
group('ConstructorArbolAuto.presetsEq', () {
|
||||
test('devuelve exactamente 6 items reproducibles, uno por preset', () {
|
||||
final items = ConstructorArbolAuto().presetsEq(
|
||||
PresetEcualizador.presets,
|
||||
);
|
||||
|
||||
expect(items, hasLength(6));
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
expect(items[i].id, 'eq_preset:${PresetEcualizador.presets[i].nombre}');
|
||||
expect(items[i].title, PresetEcualizador.presets[i].nombre);
|
||||
expect(items[i].playable, isTrue);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -564,6 +616,174 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('resolverPresetEq', () {
|
||||
test('resuelve un nombre conocido al preset exacto', () {
|
||||
final resultado = resolverPresetEq(
|
||||
'eq_preset:${PresetEcualizador.rock.nombre}',
|
||||
PresetEcualizador.presets,
|
||||
);
|
||||
|
||||
expect(resultado, equals(PresetEcualizador.rock));
|
||||
});
|
||||
|
||||
test(
|
||||
'devuelve null para nombre desconocido, id vacío-tras-prefijo o id sin '
|
||||
'prefijo eq_preset:, sin lanzar',
|
||||
() {
|
||||
expect(
|
||||
resolverPresetEq('eq_preset:Inexistente', PresetEcualizador.presets),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
resolverPresetEq('eq_preset:', PresetEcualizador.presets),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
resolverPresetEq('emisora:x', PresetEcualizador.presets),
|
||||
isNull,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('debeAplicarPrincipalAhora', () {
|
||||
test('true cuando no hay estación actual', () {
|
||||
expect(
|
||||
debeAplicarPrincipalAhora(
|
||||
uuidActual: null,
|
||||
clavesPorEmisora: const {'uuid-otra'},
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('true cuando la estación actual no tiene override por-emisora', () {
|
||||
expect(
|
||||
debeAplicarPrincipalAhora(
|
||||
uuidActual: 'uuid-sin-override',
|
||||
clavesPorEmisora: const {'uuid-otra'},
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('false cuando la estación actual tiene override por-emisora', () {
|
||||
expect(
|
||||
debeAplicarPrincipalAhora(
|
||||
uuidActual: 'uuid-con-override',
|
||||
clavesPorEmisora: const {'uuid-con-override'},
|
||||
),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('aplicarPresetPorMediaId', () {
|
||||
test(
|
||||
'preset conocido: persiste siempre y aplica cuando el gate es true',
|
||||
() async {
|
||||
PresetEcualizador? persistido;
|
||||
PresetEcualizador? aplicado;
|
||||
|
||||
await aplicarPresetPorMediaId(
|
||||
'eq_preset:${PresetEcualizador.rock.nombre}',
|
||||
presets: PresetEcualizador.presets,
|
||||
uuidActual: null,
|
||||
clavesPorEmisora: () async => <String>{},
|
||||
persistirPrincipal: (preset) async {
|
||||
persistido = preset;
|
||||
},
|
||||
aplicar: (preset) async {
|
||||
aplicado = preset;
|
||||
},
|
||||
);
|
||||
|
||||
expect(persistido, equals(PresetEcualizador.rock));
|
||||
expect(aplicado, equals(PresetEcualizador.rock));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'preset conocido con override por-emisora en la estación actual: '
|
||||
'persiste pero NO aplica en vivo (gate false)',
|
||||
() async {
|
||||
var persistirLlamadas = 0;
|
||||
var aplicarLlamadas = 0;
|
||||
|
||||
await aplicarPresetPorMediaId(
|
||||
'eq_preset:${PresetEcualizador.jazz.nombre}',
|
||||
presets: PresetEcualizador.presets,
|
||||
uuidActual: 'uuid-con-override',
|
||||
clavesPorEmisora: () async => {'uuid-con-override'},
|
||||
persistirPrincipal: (preset) async {
|
||||
persistirLlamadas++;
|
||||
},
|
||||
aplicar: (preset) async {
|
||||
aplicarLlamadas++;
|
||||
},
|
||||
);
|
||||
|
||||
expect(persistirLlamadas, 1);
|
||||
expect(aplicarLlamadas, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'id desconocido/obsoleto: no persiste ni aplica, no lanza excepción',
|
||||
() async {
|
||||
var persistirLlamadas = 0;
|
||||
var aplicarLlamadas = 0;
|
||||
|
||||
await aplicarPresetPorMediaId(
|
||||
'eq_preset:Inexistente',
|
||||
presets: PresetEcualizador.presets,
|
||||
uuidActual: null,
|
||||
clavesPorEmisora: () async => <String>{},
|
||||
persistirPrincipal: (preset) async {
|
||||
persistirLlamadas++;
|
||||
},
|
||||
aplicar: (preset) async {
|
||||
aplicarLlamadas++;
|
||||
},
|
||||
);
|
||||
|
||||
expect(persistirLlamadas, 0);
|
||||
expect(aplicarLlamadas, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'invariante estructural: la firma solo expone seams de '
|
||||
'persistencia/aplicación EQ, ningun seam de reproducción es '
|
||||
'inyectable (ADR-3, no-playback-by-construction)',
|
||||
() async {
|
||||
// Este test documenta y prueba por construcción que
|
||||
// aplicarPresetPorMediaId no puede tocar playback: los únicos
|
||||
// parámetros de función inyectables en su firma son
|
||||
// persistirPrincipal y aplicar (ambos EQ-only). No existe ningún
|
||||
// parámetro playMediaItem/mediaItem/playbackState que un caller
|
||||
// pueda pasar — de haberlo, este call site fallaría a compilar.
|
||||
final llamadasAplicar = <PresetEcualizador>[];
|
||||
final llamadasPersistir = <PresetEcualizador>[];
|
||||
|
||||
await aplicarPresetPorMediaId(
|
||||
'eq_preset:${PresetEcualizador.pop.nombre}',
|
||||
presets: PresetEcualizador.presets,
|
||||
uuidActual: null,
|
||||
clavesPorEmisora: () async => <String>{},
|
||||
persistirPrincipal: (preset) async => llamadasPersistir.add(preset),
|
||||
aplicar: (preset) async => llamadasAplicar.add(preset),
|
||||
// NOTE: no `playMediaItem`/`mediaItem`/`playbackState` parameter
|
||||
// exists on this function — there is nothing to pass here even
|
||||
// if a caller wanted to. That absence IS the non-playback proof.
|
||||
);
|
||||
|
||||
expect(llamadasPersistir, [PresetEcualizador.pop]);
|
||||
expect(llamadasAplicar, [PresetEcualizador.pop]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('reproducirPorMediaId', () {
|
||||
test(
|
||||
'resuelve el id y delega a reproducir con un MediaItem con forma de '
|
||||
|
||||
Reference in New Issue
Block a user