feat(auto): add an Ecualizador browsable folder with preset selection

On-device feedback showed the equalizer's preset-cycling custom action
looked dead: many head units render custom actions icon-first, and a
monochrome icon cannot legibly encode "which of six presets" the way a
browsable list's text rows can.

This adds an "Ecualizador" folder to the car's browse tree, listing
"Desactivar" first, then the six factory presets by name, with the
currently-active one marked. Selecting a preset routes through the same
playFromMediaId seam every other browse-tree leaf already uses; picking
a preset while the equalizer is off turns it on and applies that preset.

Supersedes the earlier "no equalizer folder" rule (commit 2403da3),
which predated this feedback -- see decision auto/ecualizador-diseno.

The preset-cycling custom action still coexists with the folder in this
commit; it is removed in the next one.
This commit is contained in:
2026-07-31 19:11:01 +02:00
parent 25405564ee
commit 8423ccdd0c
31 changed files with 2686 additions and 2223 deletions
+179 -59
View File
@@ -10,6 +10,7 @@ import '../estado/orden_emisoras.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/pista_local.dart';
import '../modelos/preset_ecualizador.dart';
import 'musica_local_auto.dart';
import 'persistencia_tolerante.dart';
import 'servicio_favoritos.dart';
@@ -19,16 +20,22 @@ import 'servicio_favoritos.dart';
/// no [NodoLocal] coupling — so a future paged folder type can reuse the
/// slice arithmetic directly. An empty [items] or a [pagina] beyond the
/// list's range returns `[]`, never throws.
List<T> paginaDe<T>(List<T> items, {required int pagina, required int tamano}) =>
items.skip(pagina * tamano).take(tamano).toList();
List<T> paginaDe<T>(
List<T> items, {
required int pagina,
required int tamano,
}) => items.skip(pagina * tamano).take(tamano).toList();
/// Whether a page after [pagina] exists for a list of [total] elements
/// (Design ADR-6): `true` iff at least one element remains beyond the
/// current page's slice. The exact-boundary case
/// (`total == (pagina + 1) * tamano`) is `false` — nothing remains to
/// reveal.
bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) =>
total > (pagina + 1) * tamano;
bool hayPaginaSiguiente(
int total, {
required int pagina,
required int tamano,
}) => total > (pagina + 1) * tamano;
/// Browse-tree ordering comparator for a local-music folder's children
/// (Design "Directories before files", item 1): directories sort before
@@ -83,8 +90,7 @@ bool faviconUsable(String? favicon) {
// even with an empty host (e.g. `Uri.parse('http://').hasAuthority` is
// `true`) — check `host.isNotEmpty` explicitly to actually require a
// non-empty authority host.
return (uri.scheme == 'http' || uri.scheme == 'https') &&
uri.host.isNotEmpty;
return (uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty;
}
/// Deterministic rotation index over the 4 on-brand fallback arts, same
@@ -98,10 +104,11 @@ int indiceArtePara(String seed) =>
/// drawable URI selected via [indiceArtePara] over `e.uuid` — the same
/// on-brand art the phone UI would pick for this station (per-station
/// parity), never a launcher-icon lookalike.
String artUriPara(Emisora e) => faviconUsable(e.favicon)
? e.favicon!
: 'android.resource://es.freetimelab.pluriwave/drawable/'
'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
String artUriPara(Emisora e) =>
faviconUsable(e.favicon)
? e.favicon!
: 'android.resource://es.freetimelab.pluriwave/drawable/'
'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
/// Formats a human-readable audio-quality hint for the browse row's
/// `displaySubtitle` (Design Decision "`displaySubtitle` quality format"):
@@ -212,6 +219,17 @@ class ConstructorArbolAuto {
/// (Design "Local root hidden until a folder is configured").
static const idMusicaLocal = 'musica_local';
/// Root folder id for the "Ecualizador" browsable folder (decision
/// `auto/ecualizador-diseno`): lists "Desactivar" plus the six factory
/// presets, the currently-active one marked. Deliberately NOT added to
/// [_idsCarpetas] -- like [idMusicaLocal], it has its own dedicated
/// children, built by `itemsEcualizadorAuto` in `servicio_audio.dart`
/// (which needs `AppLocalizations` -- this pure builder class does not
/// depend on it), not the generic station-list [hijos] path. Unlike
/// [idMusicaLocal], it is ALWAYS present in [raiz], never conditionally
/// hidden.
static const idEcualizador = 'ecualizador';
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
static const _maxItemsPorCarpeta = 50;
@@ -295,12 +313,23 @@ class ConstructorArbolAuto {
};
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras,
/// optionally Música Local), all non-playable.
/// optionally Música Local, Ecualizador), all non-playable.
///
/// There is deliberately no equalizer folder: EQ is configured on the phone
/// only. The car still gets the right sound, because the per-device preset
/// is applied automatically when the output device changes — that lives in
/// `EstadoEcualizador`, not in this tree.
/// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
/// folder" rule that used to live in this doc comment (commit `2403da3`,
/// mirroring the redesign mockup's "sin carpeta de ecualizador", turn t4
/// line 40). That rule was sound when written, but predated on-device
/// feedback showing that Android Auto custom actions don't surface
/// enough state for choosing among six presets: a monochrome icon cannot
/// legibly encode "which preset", and many head units render a custom
/// action icon-first, hiding its label. `Ecualizador` is a real
/// browsable folder again: "Desactivar" first, then the six factory
/// presets, the active one marked (children built by
/// `itemsEcualizadorAuto` in `servicio_audio.dart` -- this class stays
/// free of any `AppLocalizations` dependency, unlike that builder).
/// Always present, and LAST in the list (after Música Local, when
/// included) -- unlike [idMusicaLocal] it is never conditionally hidden.
/// Do not "restore" the no-folder rule without re-reading that decision.
///
/// `Música Local` is OMITTED entirely (not just empty) unless
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
@@ -311,6 +340,7 @@ class ConstructorArbolAuto {
_carpeta(idTodas, 'Todas las emisoras'),
_carpeta(idMisEmisoras, 'Mis emisoras'),
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
_carpeta(idEcualizador, 'Ecualizador'),
];
MediaItem _carpeta(String id, String titulo) => MediaItem(
@@ -457,10 +487,11 @@ class ConstructorArbolAuto {
final construir = construirItem ?? _itemLocal;
final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion);
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final docIds = paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final docIds =
paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final metadatos = await metadatosDe(docIds);
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
@@ -486,8 +517,7 @@ class ConstructorArbolAuto {
// (direct or nested), absent for a folder that is genuinely empty
// even recursively (Spec "Folder has no tracks").
if (hayContenidoReproducible) _itemReproducirCarpeta(documentIdPadre),
if (hayContenidoReproducible)
_itemReproducirAleatorio(documentIdPadre),
if (hayContenidoReproducible) _itemReproducirAleatorio(documentIdPadre),
if (ofreceOrdenCalidad(totalPistas))
_itemModoOrdenCalidad(documentIdPadre),
if (ofreceBuckets(totalPistas))
@@ -725,10 +755,11 @@ class ConstructorArbolAuto {
final ordenados = [...buckets[idxBucket].nodos]
..sort((a, b) => a.nombre.compareTo(b.nombre));
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final docIds = paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final docIds =
paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final metadatos = await metadatosDe(docIds);
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
@@ -743,20 +774,21 @@ class ConstructorArbolAuto {
}
final meta = metadatos[nodo.documentId];
final tituloMeta = meta?.titulo?.trim();
final titulo = (tituloMeta != null && tituloMeta.isNotEmpty)
? tituloMeta
: _tituloDesdeNombre(nodo.nombre);
final titulo =
(tituloMeta != null && tituloMeta.isNotEmpty)
? tituloMeta
: _tituloDesdeNombre(nodo.nombre);
final artUriMeta = meta?.artUri?.trim();
final artUri = (artUriMeta != null && artUriMeta.isNotEmpty)
? artUriMeta
: artUriLocal(nodo.documentId);
final artUri =
(artUriMeta != null && artUriMeta.isNotEmpty)
? artUriMeta
: artUriLocal(nodo.documentId);
final artistaMeta = meta?.artista?.trim();
return MediaItem(
id: '$_prefijoPista${nodo.documentId}',
title: titulo,
artist: (artistaMeta != null && artistaMeta.isNotEmpty)
? artistaMeta
: null,
artist:
(artistaMeta != null && artistaMeta.isNotEmpty) ? artistaMeta : null,
playable: true,
artUri: Uri.parse(artUri),
displaySubtitle: subtituloCalidadLocal(meta),
@@ -777,15 +809,17 @@ class ConstructorArbolAuto {
required List<GrupoFavoritos> grupos,
required List<Emisora> favoritos,
}) {
final carpetas = grupos
.where((g) => !g.esSinAsignar)
.where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
.take(_maxGruposPorFavoritos)
.map(itemGrupo)
.toList();
final sinAsignar = favoritos
.where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
.toList();
final carpetas =
grupos
.where((g) => !g.esSinAsignar)
.where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
.take(_maxGruposPorFavoritos)
.map(itemGrupo)
.toList();
final sinAsignar =
favoritos
.where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
.toList();
return [...carpetas, ...hijos(idFavoritos, emisoras: sinAsignar)];
}
@@ -801,13 +835,62 @@ class ConstructorArbolAuto {
if (!esCarpetaGrupo(grupoMediaId)) return const [];
final id = grupoMediaId.substring(_prefijoGrupo.length);
if (id.isEmpty) return const [];
final miembros = favoritos
.where((e) => e.grupoFavoritosId == id)
.toList();
final miembros = favoritos.where((e) => e.grupoFavoritosId == id).toList();
if (miembros.isEmpty) return const [];
final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad);
return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
}
/// Equalizer preset-selection media-id prefix (decision
/// `auto/ecualizador-diseno`): `eq_preset:<rawPresetName>` for the six
/// factory presets, plus the reserved [_valorDesactivarEq] sentinel for
/// the "Desactivar" item ([idDesactivarEq]). Collision-free against every
/// other prefix/bare id in this class -- diverges from every sibling
/// prefix at the very first character ('e' vs 'g'/'c'/'p') and from every
/// bare folder id (none of which starts with "eq_preset:").
static const _prefijoPresetEq = 'eq_preset:';
/// Reserved sentinel raw value for the "Desactivar" item under
/// [_prefijoPresetEq] (decision `auto/ecualizador-diseno`) -- never
/// collides with a real [PresetEcualizador.nombre]; none of the six
/// factory presets is named this.
static const _valorDesactivarEq = '_off_';
/// The "Desactivar" item's media id: the reserved [_valorDesactivarEq]
/// sentinel under [_prefijoPresetEq].
static const idDesactivarEq = '$_prefijoPresetEq$_valorDesactivarEq';
/// Whether [id] identifies an item under the Ecualizador folder (a
/// factory preset OR "Desactivar").
bool esPresetEqMediaId(String id) => id.startsWith(_prefijoPresetEq);
/// Whether [id] is specifically the "Desactivar" item (not a factory
/// preset). Only meaningful alongside [esPresetEqMediaId].
bool esDesactivarEqMediaId(String id) => id == idDesactivarEq;
/// Builds a factory preset's selection media id, matched by raw
/// (untranslated) [PresetEcualizador.nombre] -- the SAME identity
/// [PresetEcualizador.presets] already uses for equality, so a locale
/// change never breaks resolution.
String idPresetEq(String nombrePreset) => '$_prefijoPresetEq$nombrePreset';
/// Resolves an `eq_preset:<nombre>` [id] to the matching factory
/// [PresetEcualizador] from [presets] (defaults to
/// [PresetEcualizador.presets]), comparing by raw `nombre`. Returns
/// `null` for the [_valorDesactivarEq] sentinel, an unresolvable name, or
/// any id that doesn't match [esPresetEqMediaId] -- never throws.
PresetEcualizador? resolverPresetEq(
String id, {
List<PresetEcualizador>? presets,
}) {
if (!esPresetEqMediaId(id) || esDesactivarEqMediaId(id)) return null;
final nombre = id.substring(_prefijoPresetEq.length);
final lista = presets ?? PresetEcualizador.presets;
for (final preset in lista) {
if (preset.nombre == nombre) return preset;
}
return null;
}
}
/// Routing seam between a car-tapped `emisora:<uuid>` media id and the
@@ -847,6 +930,43 @@ Future<void> reproducirPorMediaId(
await reproducir(item);
}
/// Routing seam for a car-tapped `eq_preset:<...>` media id (decision
/// `auto/ecualizador-diseno`, mirrors [reproducirPorMediaId]'s seam
/// shape): dispatches "Desactivar" to [activarEcualizador]`(false)`, and a
/// resolved factory preset to [aplicarPreset] -- turning the equalizer
/// back ON via [activarEcualizador]`(true)` AFTERWARDS whenever [activo]
/// is currently `false`, so tapping a preset while the equalizer is off
/// both re-enables it AND applies the tapped preset's gains (Spec
/// "selecting a preset while disabled enables it and applies it"), never
/// silently just remembering the preset for later. [aplicarPreset] runs
/// BEFORE the enable check so the native engine only ever pushes gains
/// once, for the NEW preset -- never once for whatever was active before,
/// then again for the new one.
///
/// A stale/unresolvable id, or any id that doesn't match
/// [ConstructorArbolAuto.esPresetEqMediaId], is a no-op: neither callback
/// runs and no exception propagates.
Future<void> seleccionarPresetEqPorMediaId(
String id, {
required bool activo,
required Future<void> Function(PresetEcualizador) aplicarPreset,
required Future<void> Function(bool) activarEcualizador,
}) async {
final constructor = ConstructorArbolAuto();
if (!constructor.esPresetEqMediaId(id)) return;
if (constructor.esDesactivarEqMediaId(id)) {
await activarEcualizador(false);
return;
}
final preset = constructor.resolverPresetEq(id);
if (preset == null) return;
await aplicarPreset(preset);
if (!activo) await activarEcualizador(true);
}
/// Fallback title (Design "Title = filename minus extension") for a blank
/// or otherwise empty-after-stripping local filename — hardcoded Spanish,
/// matching every other car-tree label in this file (`'Favoritos'`,
@@ -904,10 +1024,8 @@ List<NodoLocal> ordenarPorCalidadLocal(
) {
final ordenados = List<NodoLocal>.from(nodos);
ordenados.sort(
(a, b) => compararCalidadLocal(
metadatos[a.documentId],
metadatos[b.documentId],
),
(a, b) =>
compararCalidadLocal(metadatos[a.documentId], metadatos[b.documentId]),
);
return ordenados;
}
@@ -948,12 +1066,13 @@ List<BucketLocal> bucketsDe(List<NodoLocal> nodos) {
final pistas = nodos.where((n) => !n.esDirectorio).toList();
return _rangosBucket.map((rango) {
final (etiqueta, desde, hasta) = rango;
final coincidencias = pistas.where((n) {
final recortado = n.nombre.trim();
if (recortado.isEmpty) return false;
final letra = recortado[0].toLowerCase();
return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
}).toList();
final coincidencias =
pistas.where((n) {
final recortado = n.nombre.trim();
if (recortado.isEmpty) return false;
final letra = recortado[0].toLowerCase();
return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
}).toList();
return BucketLocal(etiqueta: etiqueta, nodos: coincidencias);
}).toList();
}
@@ -1119,9 +1238,10 @@ Future<void> reproducirCarpetaLocal(
}
final recolectadas = await pistasRecursivas(documentId, fuente: fuente);
final pistas = aleatorio
? mezclarFisherYates(recolectadas, rng ?? Random())
: recolectadas;
final pistas =
aleatorio
? mezclarFisherYates(recolectadas, rng ?? Random())
: recolectadas;
if (pistas.isEmpty) return;
await iniciarCola(pistas);