feat(auto): surface favorite groups as Android Auto sub-folders

Favoritos now renders non-empty custom groups as grupo:<id>
sub-folders (hidden when empty) with ungrouped stations left as
direct leaves, reusing the existing hijos() path so the zero-groups
case stays byte-identical to today's flat list.
This commit is contained in:
2026-07-19 13:42:25 +02:00
parent f9003436ea
commit f368bcc777
11 changed files with 812 additions and 1 deletions
+3
View File
@@ -345,6 +345,9 @@ class EstadoRadio extends ChangeNotifier {
Future<void> cargarGruposFavoritos() async {
_gruposFavoritos = await favoritos.obtenerGrupos();
// Design "live snapshot the source prefers": Android Auto's grouped
// Favoritos tree mirrors the same group list the phone just loaded.
_fuenteAuto?.actualizarSnapshot(grupos: _gruposFavoritos);
notifyListeners();
}
+87
View File
@@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart';
import '../estado/orden_emisoras.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import 'persistencia_tolerante.dart';
import 'servicio_favoritos.dart';
@@ -92,6 +93,11 @@ abstract class FuenteEmisorasAuto {
Future<List<Emisora>> todas();
Future<Emisora?> porUuid(String uuid);
/// Favorite groups (`GrupoFavoritos`), cold-start safe — mirrors
/// [favoritos]'s never-throws contract (Design "Favorite Group
/// Sub-Folders").
Future<List<GrupoFavoritos>> grupos();
/// Live-snapshot push (Design "live snapshot the source prefers"):
/// `EstadoRadio`, when alive, calls this unconditionally on every
/// favorites/custom/populares mutation so a car and phone that are both
@@ -103,6 +109,7 @@ abstract class FuenteEmisorasAuto {
List<Emisora>? favoritos,
List<Emisora>? misEmisoras,
List<Emisora>? todas,
List<GrupoFavoritos>? grupos,
}) {}
}
@@ -120,6 +127,16 @@ class ConstructorArbolAuto {
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
static const _maxItemsPorCarpeta = 50;
/// Favorite-group folder id prefix (Design "media-id scheme"), collision
/// free against [_prefijoEmisora] and the bare folder id constants above.
static const _prefijoGrupo = 'grupo:';
/// Separate cap for favorite-group folders under `Favoritos` (Design
/// "group-folder ordering and cap"): a folder tap costs more driver
/// attention than a station scroll, so this is tunable independently of
/// [_maxItemsPorCarpeta].
static const _maxGruposPorFavoritos = 50;
/// Content-style extras (Design "content style", optional polish): list
/// (1) for the root's folders, grid (2) for playable station items.
static const _contentStyleLista = {
@@ -182,6 +199,60 @@ class ConstructorArbolAuto {
}
return null;
}
/// Whether [id] identifies a favorite-group folder (Design "media-id
/// scheme").
bool esCarpetaGrupo(String id) => id.startsWith(_prefijoGrupo);
/// Maps a [GrupoFavoritos] to a non-playable folder `MediaItem` with id
/// `grupo:<id>` (Design "media-id scheme").
MediaItem itemGrupo(GrupoFavoritos g) =>
_carpeta('$_prefijoGrupo${g.id}', g.nombre);
/// 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
/// `sin_asignar` stations mapped through the existing [hijos] path so the
/// no-custom-groups case is byte-identical to the pre-groups tree
/// (regression guard — Spec "Ungrouped station appears exactly as
/// before"). Empty custom groups are omitted (Design "Empty groups hidden
/// from the car tree"); the `sin_asignar` pseudo-group is never rendered
/// as its own folder.
List<MediaItem> carpetasFavoritos({
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();
return [...carpetas, ...hijos(idFavoritos, emisoras: sinAsignar)];
}
/// Members of the favorite group identified by [grupoMediaId] (a
/// `grupo:<id>` id), sorted and capped like every other folder (Spec "Car
/// requests a group folder's stations"). An unknown/stale/malformed id
/// returns an empty list instead of throwing (Spec "Car requests an
/// unknown or stale group id").
List<MediaItem> hijosGrupo(
String grupoMediaId, {
required List<Emisora> favoritos,
}) {
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();
if (miembros.isEmpty) return const [];
final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad);
return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
}
}
/// Routing seam between a car-tapped `emisora:<uuid>` media id and the
@@ -239,6 +310,7 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
List<Emisora>? _snapshotFavoritos;
List<Emisora>? _snapshotMisEmisoras;
List<Emisora>? _snapshotTodas;
List<GrupoFavoritos>? _snapshotGrupos;
/// Overrides the next reads with `EstadoRadio`'s live in-memory lists
/// (Design "live snapshot the source prefers"). Passing `null` for a
@@ -248,10 +320,12 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
List<Emisora>? favoritos,
List<Emisora>? misEmisoras,
List<Emisora>? todas,
List<GrupoFavoritos>? grupos,
}) {
if (favoritos != null) _snapshotFavoritos = favoritos;
if (misEmisoras != null) _snapshotMisEmisoras = misEmisoras;
if (todas != null) _snapshotTodas = todas;
if (grupos != null) _snapshotGrupos = grupos;
}
@override
@@ -267,6 +341,19 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
}
}
@override
Future<List<GrupoFavoritos>> grupos() async {
final snapshot = _snapshotGrupos;
if (snapshot != null) return snapshot;
try {
return await _favoritosServicio.obtenerGrupos();
} catch (_) {
// Cold-start safety (Spec "Browse requested before app state is
// loaded"): never throw out of a browse call.
return const [];
}
}
@override
Future<List<Emisora>> misEmisoras() async {
final snapshot = _snapshotMisEmisoras;
+12 -1
View File
@@ -739,6 +739,18 @@ class PluriWaveAudioHandler extends BaseAudioHandler
}
final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return const [];
if (parentMediaId == ConstructorArbolAuto.idFavoritos) {
return constructor.carpetasFavoritos(
grupos: await fuente.grupos(),
favoritos: await fuente.favoritos(),
);
}
if (constructor.esCarpetaGrupo(parentMediaId)) {
return constructor.hijosGrupo(
parentMediaId,
favoritos: await fuente.favoritos(),
);
}
final emisoras = await _listaParaCarpeta(fuente, parentMediaId);
return constructor.hijos(parentMediaId, emisoras: emisoras);
} catch (_) {
@@ -789,7 +801,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler
FuenteEmisorasAuto fuente,
String parentId,
) => switch (parentId) {
ConstructorArbolAuto.idFavoritos => fuente.favoritos(),
ConstructorArbolAuto.idMisEmisoras => fuente.misEmisoras(),
ConstructorArbolAuto.idTodas => fuente.todas(),
_ => Future.value(const []),