feat(auto): browsable Android Auto media tree with play-by-id [size:exception]
Expose PluriWave to Android Auto (projected) as a media app: - Declare car media support (automotive_app_desc.xml + manifest meta-data) so Android Auto discovers the existing MediaBrowserService. - New navegacion_auto.dart: ConstructorArbolAuto builds the browse tree (Favoritos / Todas las emisoras / Mis emisoras, 50-item cap, stable emisora:<id> media ids), reproducirPorMediaId routes a car tap to the existing playMediaItem pipeline, FuenteEmisorasAutoLocal serves the tree cold-start-safe (local favorites/custom stations before Flutter UI runs). - PluriWaveAudioHandler overrides getChildren/getMediaItem/playFromMediaId as thin delegations; playback pipeline untouched. - EstadoRadio pushes live station snapshots to the browse source and reconciles the selected station when playback starts from the car. - Every playable item ships title + artUri; stations without logo fall back to a bundled default art (android.resource://). Tests: 52/52 green (10 new navegacion_auto, 2 new estado_radio, plus audio safety-net suites). Handler overrides and native XML are static-review-only (no Android build env). Size exception approved for a single reviewable commit.
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
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 'persistencia_tolerante.dart';
|
||||
import 'servicio_favoritos.dart';
|
||||
|
||||
/// URI of the bundled default station artwork, served from
|
||||
/// `android/app/src/main/res/drawable` via `android.resource://` (Design
|
||||
/// "default artwork delivery" — no per-URI grant needed, works offline, and
|
||||
/// cannot 404 unlike a FileProvider content URI or a remote placeholder).
|
||||
const String _defaultArtUri =
|
||||
'android.resource://es.freetimelab.pluriwave/drawable/default_station_art';
|
||||
|
||||
const _prefijoEmisora = 'emisora:';
|
||||
|
||||
/// 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);
|
||||
|
||||
/// 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,
|
||||
}) {}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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, and artUri with the default-art
|
||||
/// fallback (Design "default artwork delivery").
|
||||
MediaItem itemEmisora(Emisora e) => MediaItem(
|
||||
id: '$_prefijoEmisora${e.uuid}',
|
||||
title: e.nombre,
|
||||
playable: true,
|
||||
artUri: Uri.parse(_artUriPara(e)),
|
||||
extras: _contentStyleGrid,
|
||||
);
|
||||
|
||||
String _artUriPara(Emisora e) {
|
||||
final favicon = e.favicon;
|
||||
return (favicon != null && favicon.isNotEmpty) ? favicon : _defaultArtUri;
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
/// 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,
|
||||
}) {
|
||||
if (favoritos != null) _snapshotFavoritos = favoritos;
|
||||
if (misEmisoras != null) _snapshotMisEmisoras = misEmisoras;
|
||||
if (todas != null) _snapshotTodas = todas;
|
||||
}
|
||||
|
||||
@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<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';
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import 'controlador_reconexion.dart';
|
||||
import 'navegacion_auto.dart';
|
||||
import 'servicio_audio_session.dart';
|
||||
|
||||
/// Estado de reproducción expuesto al UI.
|
||||
@@ -35,6 +36,16 @@ void registrarHandler(PluriWaveAudioHandler handler) {
|
||||
_handlerGlobal = handler;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Android Auto browse source — registered from main.dart, mirrors
|
||||
// registrarHandler above (Design "getChildren data source registration").
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
FuenteEmisorasAuto? _fuenteNavegacionGlobal;
|
||||
|
||||
void registrarFuenteNavegacion(FuenteEmisorasAuto fuente) {
|
||||
_fuenteNavegacionGlobal = fuente;
|
||||
}
|
||||
|
||||
/// Wrapper de alto nivel para el UI.
|
||||
class ServicioAudio {
|
||||
PluriWaveAudioHandler get _handler {
|
||||
@@ -712,4 +723,84 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
favicon: mediaItem.artUri?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Android Auto browsing (thin delegation to navegacion_auto.dart's
|
||||
// already-tested pure logic — Design "getChildren data source") ─────────
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> getChildren(
|
||||
String parentMediaId, [
|
||||
Map<String, dynamic>? options,
|
||||
]) async {
|
||||
try {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
if (parentMediaId == AudioService.browsableRootId) {
|
||||
return constructor.raiz();
|
||||
}
|
||||
final fuente = _fuenteNavegacionGlobal;
|
||||
if (fuente == null) return const [];
|
||||
final emisoras = await _listaParaCarpeta(fuente, parentMediaId);
|
||||
return constructor.hijos(parentMediaId, emisoras: emisoras);
|
||||
} catch (_) {
|
||||
// Spec "Browse requested before app state is loaded": never throw out
|
||||
// of a browse call, even on an unexpected failure.
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<MediaItem?> getMediaItem(String mediaId) async {
|
||||
try {
|
||||
final fuente = _fuenteNavegacionGlobal;
|
||||
if (fuente == null) return null;
|
||||
final universo = await _universoCompleto(fuente);
|
||||
final constructor = ConstructorArbolAuto();
|
||||
final emisora = constructor.resolver(mediaId, universo);
|
||||
return emisora == null ? null : constructor.itemEmisora(emisora);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> playFromMediaId(
|
||||
String mediaId, [
|
||||
Map<String, dynamic>? extras,
|
||||
]) async {
|
||||
final fuente = _fuenteNavegacionGlobal;
|
||||
if (fuente == null) return;
|
||||
try {
|
||||
await reproducirPorMediaId(
|
||||
mediaId,
|
||||
fuente: fuente,
|
||||
reproducir: playMediaItem,
|
||||
);
|
||||
} catch (e) {
|
||||
// Spec "Unknown or stale media id": never propagate from the handler.
|
||||
developer.log(
|
||||
'[PluriWave] Error en playFromMediaId($mediaId): $e',
|
||||
name: 'ServicioAudio',
|
||||
level: 900,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Emisora>> _listaParaCarpeta(
|
||||
FuenteEmisorasAuto fuente,
|
||||
String parentId,
|
||||
) => switch (parentId) {
|
||||
ConstructorArbolAuto.idFavoritos => fuente.favoritos(),
|
||||
ConstructorArbolAuto.idMisEmisoras => fuente.misEmisoras(),
|
||||
ConstructorArbolAuto.idTodas => fuente.todas(),
|
||||
_ => Future.value(const []),
|
||||
};
|
||||
|
||||
Future<List<Emisora>> _universoCompleto(FuenteEmisorasAuto fuente) async {
|
||||
final listas = await Future.wait([
|
||||
fuente.favoritos(),
|
||||
fuente.misEmisoras(),
|
||||
fuente.todas(),
|
||||
]);
|
||||
return listas.expand((lista) => lista).toList();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user