feat(auto): expose EQ presets as a browsable Android Auto folder
Adds an Ecualizador folder listing the 6 fixed presets; selecting one applies and persists it through the existing headless-safe seam without touching playback or the now-playing media item.
This commit is contained in:
@@ -7,11 +7,22 @@ import 'package:path_provider/path_provider.dart';
|
||||
import '../estado/orden_emisoras.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import 'persistencia_tolerante.dart';
|
||||
import 'servicio_favoritos.dart';
|
||||
|
||||
const _prefijoEmisora = 'emisora:';
|
||||
|
||||
/// EQ preset media-id prefix (Design ADR-1), collision-free against
|
||||
/// [_prefijoEmisora], `grupo:` and the bare folder id constants.
|
||||
const _prefijoPresetEq = 'eq_preset:';
|
||||
|
||||
/// Whether [id] identifies an EQ preset leaf item (Design ADR-1). A bare
|
||||
/// prefix (`'eq_preset:'`, empty name) is still `true` here — the empty-name
|
||||
/// case is rejected downstream by [resolverPresetEq], not by this routing
|
||||
/// predicate.
|
||||
bool esPresetMediaId(String id) => id.startsWith(_prefijoPresetEq);
|
||||
|
||||
/// Canonical on-brand fallback-art names and rotation order, ported
|
||||
/// **verbatim** (same formula, same order) from
|
||||
/// `lib/widgets/tarjeta_emisora.dart`'s `_fallbackArtFor` (lines 363-367)
|
||||
@@ -124,6 +135,12 @@ class ConstructorArbolAuto {
|
||||
static const idTodas = 'todas';
|
||||
static const idMisEmisoras = 'mis_emisoras';
|
||||
|
||||
/// Root folder id for the EQ presets folder (Design "media-id scheme").
|
||||
/// Deliberately NOT added to [_idsCarpetas] — it has its own dedicated
|
||||
/// branch in `getChildren`/`ConstructorArbolAuto.presetsEq`, not the
|
||||
/// generic station-list `hijos()` path.
|
||||
static const idEcualizador = 'ecualizador';
|
||||
|
||||
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
|
||||
static const _maxItemsPorCarpeta = 50;
|
||||
|
||||
@@ -147,12 +164,15 @@ class ConstructorArbolAuto {
|
||||
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
|
||||
};
|
||||
|
||||
/// The 3 root folders (Favoritos, Todas las emisoras, Mis emisoras), all
|
||||
/// non-playable.
|
||||
/// The 4 root folders (Favoritos, Todas las emisoras, Mis emisoras,
|
||||
/// Ecualizador), all non-playable. `Ecualizador` is deliberately LAST
|
||||
/// (Design ADR-2): content-browsing folders are the primary car task and
|
||||
/// stay first, the EQ tool trails them.
|
||||
List<MediaItem> raiz() => [
|
||||
_carpeta(idFavoritos, 'Favoritos'),
|
||||
_carpeta(idTodas, 'Todas las emisoras'),
|
||||
_carpeta(idMisEmisoras, 'Mis emisoras'),
|
||||
_carpeta(idEcualizador, 'Ecualizador'),
|
||||
];
|
||||
|
||||
MediaItem _carpeta(String id, String titulo) => MediaItem(
|
||||
@@ -209,6 +229,20 @@ class ConstructorArbolAuto {
|
||||
MediaItem itemGrupo(GrupoFavoritos g) =>
|
||||
_carpeta('$_prefijoGrupo${g.id}', g.nombre);
|
||||
|
||||
/// Maps a [PresetEcualizador] to a playable `MediaItem` with id
|
||||
/// `eq_preset:<nombre>` (Design ADR-1).
|
||||
MediaItem itemPresetEq(PresetEcualizador preset) => MediaItem(
|
||||
id: '$_prefijoPresetEq${preset.nombre}',
|
||||
title: preset.nombre,
|
||||
playable: true,
|
||||
extras: _contentStyleGrid,
|
||||
);
|
||||
|
||||
/// The 6 fixed EQ preset leaf items for the `Ecualizador` folder (Spec
|
||||
/// "Car requests the Ecualizador folder").
|
||||
List<MediaItem> presetsEq(List<PresetEcualizador> presets) =>
|
||||
presets.map(itemPresetEq).toList();
|
||||
|
||||
/// Children of the `Favoritos` folder (Design "Ungrouped favorites stay as
|
||||
/// direct leaves at the Favoritos root"): non-empty custom-group folders
|
||||
/// (phone order, capped at [_maxGruposPorFavoritos]), followed by
|
||||
@@ -290,6 +324,62 @@ Future<void> reproducirPorMediaId(
|
||||
await reproducir(item);
|
||||
}
|
||||
|
||||
/// Resolves an `eq_preset:<nombre>` [id] to the matching [PresetEcualizador]
|
||||
/// in [presets] by exact name (Design ADR-1, mirrors
|
||||
/// [ConstructorArbolAuto.resolver]'s shape). Any other shape (no prefix,
|
||||
/// empty name, unmatched name) returns `null` instead of throwing (Spec
|
||||
/// "Unknown or stale preset id").
|
||||
PresetEcualizador? resolverPresetEq(String id, List<PresetEcualizador> presets) {
|
||||
if (!esPresetMediaId(id)) return null;
|
||||
final nombre = id.substring(_prefijoPresetEq.length);
|
||||
if (nombre.isEmpty) return null;
|
||||
for (final preset in presets) {
|
||||
if (preset.nombre == nombre) return preset;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Pure per-station apply gate (Design ADR-5), mirroring
|
||||
/// `EstadoEcualizador.cambiarPresetPrincipal`'s exact logic
|
||||
/// (`estado_ecualizador.dart:302-304`): the new principal preset is applied
|
||||
/// live when there is no current station ([uuidActual] is `null`) or the
|
||||
/// current station has no per-station preset override in
|
||||
/// [clavesPorEmisora].
|
||||
bool debeAplicarPrincipalAhora({
|
||||
required String? uuidActual,
|
||||
required Set<String> clavesPorEmisora,
|
||||
}) => uuidActual == null || !clavesPorEmisora.contains(uuidActual);
|
||||
|
||||
/// Orchestrates an `eq_preset:<nombre>` selection from the car (Design
|
||||
/// "Data flow — a preset tap", ADR-3): resolves [id] via [resolverPresetEq],
|
||||
/// persists it as principal via [persistirPrincipal], and conditionally
|
||||
/// applies it live via [aplicar] when [debeAplicarPrincipalAhora] allows it.
|
||||
///
|
||||
/// This function's signature exposes ONLY the EQ persist/apply seams — it
|
||||
/// has NO parameter for `playMediaItem`, `mediaItem`, or `playbackState`, so
|
||||
/// there is no code path from a preset tap to playback (Design ADR-3,
|
||||
/// non-playback invariant enforced structurally, not by discipline). An
|
||||
/// unknown/stale [id] is a no-op: neither seam is invoked and no exception
|
||||
/// propagates (Spec "Unknown or stale preset id").
|
||||
Future<void> aplicarPresetPorMediaId(
|
||||
String id, {
|
||||
required List<PresetEcualizador> presets,
|
||||
required String? uuidActual,
|
||||
required Future<Set<String>> Function() clavesPorEmisora,
|
||||
required Future<void> Function(PresetEcualizador) persistirPrincipal,
|
||||
required Future<void> Function(PresetEcualizador) aplicar,
|
||||
}) async {
|
||||
final preset = resolverPresetEq(id, presets);
|
||||
if (preset == null) return;
|
||||
await persistirPrincipal(preset);
|
||||
if (debeAplicarPrincipalAhora(
|
||||
uuidActual: uuidActual,
|
||||
clavesPorEmisora: await clavesPorEmisora(),
|
||||
)) {
|
||||
await aplicar(preset);
|
||||
}
|
||||
}
|
||||
|
||||
/// Local, cold-start-safe implementation of [FuenteEmisorasAuto] (Design
|
||||
/// "getChildren data source"). Reads favourites from SQLite and custom
|
||||
/// stations from the tolerant JSON file directly — both loadable without
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../modelos/preset_ecualizador.dart';
|
||||
import 'controlador_reconexion.dart';
|
||||
import 'navegacion_auto.dart';
|
||||
import 'servicio_audio_session.dart';
|
||||
import 'servicio_ecualizador.dart';
|
||||
|
||||
/// Estado de reproducción expuesto al UI.
|
||||
enum EstadoReproduccion {
|
||||
@@ -737,6 +738,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
if (parentMediaId == AudioService.browsableRootId) {
|
||||
return constructor.raiz();
|
||||
}
|
||||
if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
|
||||
return constructor.presetsEq(PresetEcualizador.presets);
|
||||
}
|
||||
final fuente = _fuenteNavegacionGlobal;
|
||||
if (fuente == null) return const [];
|
||||
if (parentMediaId == ConstructorArbolAuto.idFavoritos) {
|
||||
@@ -779,9 +783,27 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
String mediaId, [
|
||||
Map<String, dynamic>? extras,
|
||||
]) async {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user