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:
Javier Bautista Fernández
2026-07-16 16:28:44 +02:00
parent 43781274ce
commit 35bb180612
10 changed files with 754 additions and 3 deletions
+91
View File
@@ -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();
}
}