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 '../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
+24 -2
View File
@@ -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 {
final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return;
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;
await reproducirPorMediaId(
mediaId,
fuente: fuente,