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
+27 -1
View File
@@ -16,6 +16,7 @@ import 'estado_busqueda.dart';
import 'estado_ecualizador.dart';
import 'estado_grabacion.dart';
import 'orden_emisoras.dart';
import '../servicios/navegacion_auto.dart';
import '../servicios/persistencia_tolerante.dart';
import '../servicios/servicio_audio.dart';
import '../servicios/servicio_dispositivo_audio.dart';
@@ -44,6 +45,7 @@ class EstadoRadio extends ChangeNotifier {
ServicioGrabacionRadio? servicioGrabacion,
SharedPreferences? prefs,
Future<File> Function()? resolverArchivoCustom,
FuenteEmisorasAuto? fuenteAuto,
bool iniciarAutomaticamente = true,
}) : audio = audio ?? ServicioAudio(),
favoritos = favoritos ?? ServicioFavoritos(),
@@ -52,7 +54,8 @@ class EstadoRadio extends ChangeNotifier {
servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
_dispositivoAudio = dispositivoAudio,
_prefs = prefs,
_resolverArchivoCustom = resolverArchivoCustom {
_resolverArchivoCustom = resolverArchivoCustom,
_fuenteAuto = fuenteAuto {
ecualizador = EstadoEcualizador(
audio: this.audio,
servicio: this.servicioEcualizador,
@@ -96,6 +99,13 @@ class EstadoRadio extends ChangeNotifier {
final SharedPreferences? _prefs;
final Future<File> Function()? _resolverArchivoCustom;
/// Android Auto browse source (Design "live snapshot the source
/// prefers"). Optional and unused by default — wired from main.dart via
/// the [FuenteEmisorasAutoLocal] instance registered into the handler.
/// When set, this instance's in-memory lists are pushed on every mutation
/// so the car sees the same data as the phone without a duplicate read.
final FuenteEmisorasAuto? _fuenteAuto;
/// Single startup instance injected from main() (S3-R4); falls back to
/// getInstance() only when nothing was injected (tests, legacy callers).
Future<SharedPreferences> _resolverPrefs() async =>
@@ -292,6 +302,14 @@ class EstadoRadio extends ChangeNotifier {
grabacion.activa) {
unawaited(grabacion.detener());
}
// Design "playback coherence with EstadoRadio": a car-initiated
// selection (Android Auto's playFromMediaId) changes audio.emisoraActual
// directly, bypassing reproducir(). Without this, _emisoraSeleccionada
// would keep shadowing the car's station on emisoraActual's getter.
final actual = audio.emisoraActual;
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
_emisoraSeleccionada = actual;
}
notifyListeners();
});
}
@@ -311,6 +329,9 @@ class EstadoRadio extends ChangeNotifier {
_errorCarga = _textos.radioApiConnectionError;
} finally {
_cargandoPopulares = false;
// Design "live snapshot the source prefers": Android Auto's `Todas`
// folder mirrors the same populares list the phone just loaded.
_fuenteAuto?.actualizarSnapshot(todas: _populares);
notifyListeners();
}
}
@@ -318,6 +339,7 @@ class EstadoRadio extends ChangeNotifier {
Future<void> cargarFavoritos() async {
_listaFavoritos = await favoritos.obtenerTodos();
await _normalizarEmisoraPreferida();
_fuenteAuto?.actualizarSnapshot(favoritos: _listaFavoritos);
notifyListeners();
}
@@ -542,6 +564,7 @@ class EstadoRadio extends ChangeNotifier {
detalle: 'resolucion de ruta',
razon: e.toString(),
);
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
notifyListeners();
return;
}
@@ -567,6 +590,7 @@ class EstadoRadio extends ChangeNotifier {
razon: e.toString(),
);
}
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
notifyListeners();
}
@@ -582,6 +606,7 @@ class EstadoRadio extends ChangeNotifier {
if (!await archivo.exists()) {
_emisorasCustom = [];
_customDegradado = false;
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
notifyListeners();
return null;
}
@@ -594,6 +619,7 @@ class EstadoRadio extends ChangeNotifier {
detalle: archivo.path,
razon: e.toString(),
);
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
notifyListeners();
return null;
}