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:
2026-07-19 14:12:39 +02:00
parent 066fedb7bc
commit 90cd232ad2
9 changed files with 1092 additions and 7 deletions
+92 -2
View File
@@ -7,11 +7,22 @@ import 'package:path_provider/path_provider.dart';
import '../estado/orden_emisoras.dart'; import '../estado/orden_emisoras.dart';
import '../modelos/emisora.dart'; import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart'; import '../modelos/grupo_favoritos.dart';
import '../modelos/preset_ecualizador.dart';
import 'persistencia_tolerante.dart'; import 'persistencia_tolerante.dart';
import 'servicio_favoritos.dart'; import 'servicio_favoritos.dart';
const _prefijoEmisora = 'emisora:'; 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 /// Canonical on-brand fallback-art names and rotation order, ported
/// **verbatim** (same formula, same order) from /// **verbatim** (same formula, same order) from
/// `lib/widgets/tarjeta_emisora.dart`'s `_fallbackArtFor` (lines 363-367) /// `lib/widgets/tarjeta_emisora.dart`'s `_fallbackArtFor` (lines 363-367)
@@ -124,6 +135,12 @@ class ConstructorArbolAuto {
static const idTodas = 'todas'; static const idTodas = 'todas';
static const idMisEmisoras = 'mis_emisoras'; 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 _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
static const _maxItemsPorCarpeta = 50; static const _maxItemsPorCarpeta = 50;
@@ -147,12 +164,15 @@ class ConstructorArbolAuto {
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2, 'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
}; };
/// The 3 root folders (Favoritos, Todas las emisoras, Mis emisoras), all /// The 4 root folders (Favoritos, Todas las emisoras, Mis emisoras,
/// non-playable. /// 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() => [ List<MediaItem> raiz() => [
_carpeta(idFavoritos, 'Favoritos'), _carpeta(idFavoritos, 'Favoritos'),
_carpeta(idTodas, 'Todas las emisoras'), _carpeta(idTodas, 'Todas las emisoras'),
_carpeta(idMisEmisoras, 'Mis emisoras'), _carpeta(idMisEmisoras, 'Mis emisoras'),
_carpeta(idEcualizador, 'Ecualizador'),
]; ];
MediaItem _carpeta(String id, String titulo) => MediaItem( MediaItem _carpeta(String id, String titulo) => MediaItem(
@@ -209,6 +229,20 @@ class ConstructorArbolAuto {
MediaItem itemGrupo(GrupoFavoritos g) => MediaItem itemGrupo(GrupoFavoritos g) =>
_carpeta('$_prefijoGrupo${g.id}', g.nombre); _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 /// Children of the `Favoritos` folder (Design "Ungrouped favorites stay as
/// direct leaves at the Favoritos root"): non-empty custom-group folders /// direct leaves at the Favoritos root"): non-empty custom-group folders
/// (phone order, capped at [_maxGruposPorFavoritos]), followed by /// (phone order, capped at [_maxGruposPorFavoritos]), followed by
@@ -290,6 +324,62 @@ Future<void> reproducirPorMediaId(
await reproducir(item); 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 /// Local, cold-start-safe implementation of [FuenteEmisorasAuto] (Design
/// "getChildren data source"). Reads favourites from SQLite and custom /// "getChildren data source"). Reads favourites from SQLite and custom
/// stations from the tolerant JSON file directly — both loadable without /// stations from the tolerant JSON file directly — both loadable without
+23 -1
View File
@@ -13,6 +13,7 @@ import '../modelos/preset_ecualizador.dart';
import 'controlador_reconexion.dart'; import 'controlador_reconexion.dart';
import 'navegacion_auto.dart'; import 'navegacion_auto.dart';
import 'servicio_audio_session.dart'; import 'servicio_audio_session.dart';
import 'servicio_ecualizador.dart';
/// Estado de reproducción expuesto al UI. /// Estado de reproducción expuesto al UI.
enum EstadoReproduccion { enum EstadoReproduccion {
@@ -737,6 +738,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler
if (parentMediaId == AudioService.browsableRootId) { if (parentMediaId == AudioService.browsableRootId) {
return constructor.raiz(); return constructor.raiz();
} }
if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
return constructor.presetsEq(PresetEcualizador.presets);
}
final fuente = _fuenteNavegacionGlobal; final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return const []; if (fuente == null) return const [];
if (parentMediaId == ConstructorArbolAuto.idFavoritos) { if (parentMediaId == ConstructorArbolAuto.idFavoritos) {
@@ -779,9 +783,27 @@ class PluriWaveAudioHandler extends BaseAudioHandler
String mediaId, [ String mediaId, [
Map<String, dynamic>? extras, Map<String, dynamic>? extras,
]) async { ]) 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; final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return; if (fuente == null) return;
try {
await reproducirPorMediaId( await reproducirPorMediaId(
mediaId, mediaId,
fuente: fuente, 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).
+223 -3
View File
@@ -2,6 +2,7 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart'; import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart'; import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart'; import 'package:pluriwave/servicios/navegacion_auto.dart';
void main() { void main() {
@@ -218,11 +219,11 @@ void main() {
}); });
group('ConstructorArbolAuto.raiz', () { group('ConstructorArbolAuto.raiz', () {
test('devuelve exactamente 3 carpetas no reproducibles con los ids ' test('devuelve exactamente 4 carpetas no reproducibles con los ids '
'esperados', () { 'esperados, terminando en Ecualizador', () {
final raiz = ConstructorArbolAuto().raiz(); final raiz = ConstructorArbolAuto().raiz();
expect(raiz, hasLength(3)); expect(raiz, hasLength(4));
final ids = raiz.map((item) => item.id).toSet(); final ids = raiz.map((item) => item.id).toSet();
expect( expect(
ids, ids,
@@ -230,12 +231,63 @@ void main() {
ConstructorArbolAuto.idFavoritos, ConstructorArbolAuto.idFavoritos,
ConstructorArbolAuto.idTodas, ConstructorArbolAuto.idTodas,
ConstructorArbolAuto.idMisEmisoras, ConstructorArbolAuto.idMisEmisoras,
ConstructorArbolAuto.idEcualizador,
}), }),
); );
for (final item in raiz) { for (final item in raiz) {
expect(item.playable, isFalse); expect(item.playable, isFalse);
expect(item.title, isNotEmpty); 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', () { group('reproducirPorMediaId', () {
test( test(
'resuelve el id y delega a reproducir con un MediaItem con forma de ' 'resuelve el id y delega a reproducir con un MediaItem con forma de '