Files
pluriwave/lib/servicios/navegacion_auto.dart
T
FreeTLab f368bcc777 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.
2026-07-19 13:42:25 +02:00

411 lines
16 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:audio_service/audio_service.dart';
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';
const _prefijoEmisora = 'emisora:';
/// 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)
/// to guarantee phone/car per-station art parity (Design "Fallback-art
/// selection"). Keep this list in sync with that one — there is no
/// structural enforcement of order, only this comment and the parity test
/// in `navegacion_auto_test.dart` (group `parity: phone/auto art order`).
const _nombresArte = ['aurora', 'cosmic', 'pulse', 'nova'];
/// Returns whether [favicon] is usable as a remote `artUri` (Design
/// Decision "Case B detection" — static validity gate, zero network):
/// non-null, non-blank after trimming, and parses as an absolute
/// `http`/`https` URI with a non-empty authority. Does **not** probe
/// reachability — the OS art loader fetches `artUri` independently and
/// later, so a build-time network check would be racy (TOCTOU); this only
/// catches the deterministic malformed/non-http(s) subset (bare hosts,
/// wrong scheme, `http://` with no authority, whitespace, unparseable).
bool faviconUsable(String? favicon) {
final trimmed = favicon?.trim();
if (trimmed == null || trimmed.isEmpty) return false;
final uri = Uri.tryParse(trimmed);
if (uri == null) return false;
// `Uri.hasAuthority` is true whenever a `//` authority slot is present,
// 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;
}
/// Deterministic rotation index over the 4 on-brand fallback arts, same
/// formula as `tarjeta_emisora.dart`'s `_fallbackArtFor` (Design
/// "Fallback-art selection — port verbatim"): `seed` is the station uuid.
int indiceArtePara(String seed) =>
seed.codeUnits.fold<int>(0, (a, b) => a + b) % _nombresArte.length;
/// Resolves the `artUri` for [e] (Design "Data Flow"): the favicon when it
/// passes [faviconUsable], otherwise a rotating `station_art_<name>`
/// 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)]}';
/// Formats a human-readable audio-quality hint for the browse row's
/// `displaySubtitle` (Design Decision "`displaySubtitle` quality format"):
/// `"<bitrate> kbps · <CODEC>"` when both are known, just the bitrate or
/// just the codec when only one is known, and `null` (never `""`, never a
/// string containing the literal `"null"`) when both are unknown. Codec is
/// trimmed and upper-cased; blank-after-trim counts as unknown. `bitrate`
/// `<= 0` counts as unknown (Radio Browser stores `0` for unknown).
String? subtituloCalidad(Emisora e) {
final codec = e.codec?.trim();
final codecConocido = codec != null && codec.isNotEmpty;
final bitrate = e.bitrate;
final bitrateConocido = bitrate != null && bitrate > 0;
if (codecConocido && bitrateConocido) {
return '$bitrate kbps · ${codec.toUpperCase()}';
}
if (bitrateConocido) return '$bitrate kbps';
if (codecConocido) return codec.toUpperCase();
return null;
}
/// Browse-source abstraction for the Android Auto media tree (Design
/// "getChildren data source, cold-start safe"). Kept separate from
/// `EstadoRadio` so a headless Auto bind (`main()` runs but the widget tree
/// — and therefore the lazily-created `EstadoRadio` — never builds) still
/// gets a valid, non-throwing tree.
abstract class FuenteEmisorasAuto {
Future<List<Emisora>> favoritos();
Future<List<Emisora>> misEmisoras();
/// `populares` snapshot; may be empty on a cold bind (Design "which
/// stations surface & ordering").
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
/// live see identical lists. Default no-op — only implementations that
/// actually buffer a snapshot (e.g. [FuenteEmisorasAutoLocal]) need to
/// override it; a null field on `EstadoRadio` skips the call entirely via
/// `?.`.
void actualizarSnapshot({
List<Emisora>? favoritos,
List<Emisora>? misEmisoras,
List<Emisora>? todas,
List<GrupoFavoritos>? grupos,
}) {}
}
/// Pure builder for the Android Auto browse tree: folders, leaf items, id
/// resolution. No platform dependency — fully testable without a running
/// car or a real `AudioHandler`.
class ConstructorArbolAuto {
/// Root folder ids (Design "media-id scheme"). The tree root itself is
/// identified by [AudioService.browsableRootId], not by a constant here —
/// the handler compares against it directly before calling [raiz].
static const idFavoritos = 'favoritos';
static const idTodas = 'todas';
static const idMisEmisoras = 'mis_emisoras';
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 = {
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 1,
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1,
};
static const _contentStyleGrid = {
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
};
/// The 3 root folders (Favoritos, Todas las emisoras, Mis emisoras), all
/// non-playable.
List<MediaItem> raiz() => [
_carpeta(idFavoritos, 'Favoritos'),
_carpeta(idTodas, 'Todas las emisoras'),
_carpeta(idMisEmisoras, 'Mis emisoras'),
];
MediaItem _carpeta(String id, String titulo) => MediaItem(
id: id,
title: titulo,
playable: false,
extras: _contentStyleLista,
);
/// Leaf items for [parentId], sorted via [ordenarEmisoras] and capped at
/// [_maxItemsPorCarpeta] (Design "which stations surface & ordering" —
/// avoids driver distraction and Auto list limits). Unknown [parentId]
/// (or an empty [emisoras]) returns an empty list instead of throwing.
List<MediaItem> hijos(String parentId, {required List<Emisora> emisoras}) {
if (!_idsCarpetas.contains(parentId)) return const [];
if (emisoras.isEmpty) return const [];
final ordenadas = ordenarEmisoras(emisoras, OrdenEmisoras.calidad);
return ordenadas.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
}
/// Maps a single [Emisora] to a playable `MediaItem`: id `emisora:<uuid>`
/// (Design "media-id scheme"), title, on-brand-fallback-aware `artUri`
/// (Design "Case B detection" + "Fallback-art selection") and a
/// quality-hint `displaySubtitle` (Design "`displaySubtitle` quality
/// format") when codec/bitrate are known.
MediaItem itemEmisora(Emisora e) => MediaItem(
id: '$_prefijoEmisora${e.uuid}',
title: e.nombre,
playable: true,
artUri: Uri.parse(artUriPara(e)),
displaySubtitle: subtituloCalidad(e),
extras: _contentStyleGrid,
);
/// Resolves `emisora:<uuid>` ids to the matching [Emisora] in [universo].
/// Any other shape (no prefix, empty uuid, unmatched uuid) returns `null`
/// instead of throwing (Spec "Media Item Resolution by ID").
Emisora? resolver(String id, List<Emisora> universo) {
if (!id.startsWith(_prefijoEmisora)) return null;
final uuid = id.substring(_prefijoEmisora.length);
if (uuid.isEmpty) return null;
for (final emisora in universo) {
if (emisora.uuid == uuid) return emisora;
}
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
/// existing internal playback path (Design "playback coherence" — reuse
/// over duplication). Resolves the uuid via [fuente], builds the same
/// phone-shaped `MediaItem` (`id` = station url, `extras['uuid']`) that
/// [ServicioAudio.reproducir] builds, and delegates to [reproducir].
///
/// A stale/unknown id (or a malformed one) is a no-op: [reproducir] is
/// never called and no exception propagates (Spec "Unknown or stale media
/// id").
Future<void> reproducirPorMediaId(
String id, {
required FuenteEmisorasAuto fuente,
required Future<void> Function(MediaItem) reproducir,
}) async {
if (!id.startsWith(_prefijoEmisora)) return;
final uuid = id.substring(_prefijoEmisora.length);
if (uuid.isEmpty) return;
final emisora = await fuente.porUuid(uuid);
if (emisora == null) return;
final item = MediaItem(
id: emisora.url,
title: emisora.nombre,
artist: emisora.pais ?? '',
album: 'PluriWave',
artUri:
emisora.favicon != null && emisora.favicon!.isNotEmpty
? Uri.tryParse(emisora.favicon!)
: null,
extras: {'uuid': emisora.uuid},
);
await reproducir(item);
}
/// 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
/// the network or a built widget tree, unlike `EstadoRadio._init()` (which
/// is lazy-created by `ChangeNotifierProvider.create:` and may never run on
/// a headless Auto bind). `EstadoRadio`, when alive, overrides these reads
/// with a live snapshot via [actualizarSnapshot].
class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
FuenteEmisorasAutoLocal({
ServicioFavoritos? favoritosServicio,
Future<String> Function()? resolverRutaCustom,
}) : _favoritosServicio = favoritosServicio ?? ServicioFavoritos(),
_resolverRutaCustom = resolverRutaCustom;
final ServicioFavoritos _favoritosServicio;
final Future<String> Function()? _resolverRutaCustom;
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
/// field leaves its current override (or local read) untouched.
@override
void actualizarSnapshot({
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
Future<List<Emisora>> favoritos() async {
final snapshot = _snapshotFavoritos;
if (snapshot != null) return snapshot;
try {
return await _favoritosServicio.obtenerTodos();
} catch (_) {
// Cold-start safety (Spec "Browse requested before app state is
// loaded"): never throw out of a browse call.
return const [];
}
}
@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;
if (snapshot != null) return snapshot;
return _leerEmisorasCustom();
}
@override
Future<List<Emisora>> todas() async {
// No live network snapshot on a cold bind — populated only once
// EstadoRadio pushes its populares list (Design "which stations
// surface & ordering": degrades gracefully to empty-but-valid).
return _snapshotTodas ?? const [];
}
@override
Future<Emisora?> porUuid(String uuid) async {
final listas = await Future.wait([favoritos(), misEmisoras(), todas()]);
for (final lista in listas) {
for (final emisora in lista) {
if (emisora.uuid == uuid) return emisora;
}
}
return null;
}
/// Mirrors `EstadoRadio._cargarEmisorasCustom()`'s tolerant JSON read:
/// missing/corrupt files never throw, they degrade to an empty list.
Future<List<Emisora>> _leerEmisorasCustom() async {
try {
final ruta = await _rutaArchivoCustom();
final archivo = File(ruta);
if (!await archivo.exists()) return const [];
final contenido = await archivo.readAsString();
final data = jsonDecode(contenido) as List;
final resultado = parseListaTolerante<Emisora>(
data,
Emisora.fromMap,
subsistema: 'emisoras_custom_auto',
coleccion: 'emisoras_custom',
);
return resultado.validas;
} catch (_) {
return const [];
}
}
Future<String> _rutaArchivoCustom() async {
final resolver = _resolverRutaCustom;
if (resolver != null) return resolver();
final dir = await getApplicationDocumentsDirectory();
return '${dir.path}/emisoras_custom.json';
}
}