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
+115
View File
@@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -547,6 +548,111 @@ void main() {
);
},
);
group(
'EstadoRadio — Android Auto: snapshot en vivo y reconciliación '
'(android-auto-media)',
() {
test(
'empuja un snapshot actualizado a la fuente registrada cuando '
'cambian favoritos/custom/populares',
() async {
final fuenteAuto = _FuenteEmisorasAutoEspia();
final archivo = await _crearArchivoCustom([
emisoraDemo(uuid: 'custom-auto-1', nombre: 'Custom Auto'),
]);
final emisoraFav = emisoraDemo(uuid: 'fav-auto-1', nombre: 'Fav Auto');
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(
populares: [emisoraDemo(uuid: 'pop-auto-1', nombre: 'Pop Auto')],
),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(
fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid),
contains('custom-auto-1'),
);
expect(
fuenteAuto.ultimoTodas?.map((e) => e.uuid),
contains('pop-auto-1'),
);
await estado.toggleFavorito(emisoraFav);
expect(
fuenteAuto.ultimoFavoritos?.map((e) => e.uuid),
contains('fav-auto-1'),
);
},
);
test(
'reconcilia _emisoraSeleccionada cuando la selección viene desde '
'el auto (no via reproducir())',
() async {
final audio = _AudioControlado();
final estado = EstadoRadio(
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
final desdeCoche = emisoraDemo(
uuid: 'auto-selected',
nombre: 'Desde el auto',
);
audio.seleccionarDesdeAuto(desdeCoche);
await Future<void>.delayed(Duration.zero);
expect(estado.emisoraActual?.uuid, desdeCoche.uuid);
},
);
},
);
}
/// Spy [FuenteEmisorasAuto] that only records the last snapshot pushed by
/// [EstadoRadio] via [actualizarSnapshot] — used to assert the live-snapshot
/// wiring (Design "live snapshot the source prefers") without touching the
/// real local data source.
class _FuenteEmisorasAutoEspia implements FuenteEmisorasAuto {
List<Emisora>? ultimoFavoritos;
List<Emisora>? ultimoMisEmisoras;
List<Emisora>? ultimoTodas;
void actualizarSnapshot({
List<Emisora>? favoritos,
List<Emisora>? misEmisoras,
List<Emisora>? todas,
}) {
if (favoritos != null) ultimoFavoritos = favoritos;
if (misEmisoras != null) ultimoMisEmisoras = misEmisoras;
if (todas != null) ultimoTodas = todas;
}
@override
Future<List<Emisora>> favoritos() async => ultimoFavoritos ?? const [];
@override
Future<List<Emisora>> misEmisoras() async => ultimoMisEmisoras ?? const [];
@override
Future<List<Emisora>> todas() async => ultimoTodas ?? const [];
@override
Future<Emisora?> porUuid(String uuid) async => null;
}
/// [File] spy: only the members `_cargarEmisorasCustom`/
@@ -630,6 +736,15 @@ class _AudioControlado extends ServicioAudio {
_pendientes.remove(uuid)?.complete();
}
/// Simulates a car-initiated selection (android-auto-media task 4.3):
/// changes [emisoraActual] WITHOUT going through [reproducir], then pushes
/// an `estadoStream` event, exactly like the real handler would after
/// `playFromMediaId` bypasses `EstadoRadio.reproducir()`.
void seleccionarDesdeAuto(Emisora emisora) {
_actual = emisora;
_estadoController.add(EstadoReproduccion.reproduciendo);
}
@override
Future<void> aplicarPreset(PresetEcualizador preset) async {}