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
+9 -1
View File
@@ -24,16 +24,23 @@ import 'widgets/pluri_layout.dart';
import 'widgets/pluri_onboarding_dialog.dart';
import 'widgets/pluri_wave_scaffold.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_alarmas_android.dart';
import 'servicios/servicio_dispositivo_audio.dart';
class PluriWaveApp extends StatelessWidget {
const PluriWaveApp({super.key, this.prefs});
const PluriWaveApp({super.key, this.prefs, this.fuenteAuto});
/// Single SharedPreferences instance resolved in main() (S3-R4) and
/// injected into every state/service.
final SharedPreferences? prefs;
/// Android Auto browse source (Design "Data Flow" — cold-bind local read
/// available before EstadoRadio builds). Optional: defaults to `null`,
/// same as every other existing caller/test that constructs
/// [PluriWaveApp] without it.
final FuenteEmisorasAuto? fuenteAuto;
@override
Widget build(BuildContext context) {
return MultiProvider(
@@ -43,6 +50,7 @@ class PluriWaveApp extends StatelessWidget {
(_) => EstadoRadio(
prefs: prefs,
dispositivoAudio: ServicioDispositivoAudioReal(),
fuenteAuto: fuenteAuto,
),
),
// Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio
+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;
}
+13 -1
View File
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_audio.dart';
import 'servicios/servicio_audio_session.dart';
import 'tema/pluriwave_tokens.dart';
@@ -41,12 +42,23 @@ Future<void> main() async {
);
registrarHandler(handler);
// Android Auto browse source (Design "getChildren data source, cold-start
// safe") — registered before EstadoRadio builds so a headless Auto bind
// (main() runs but the lazily-created Provider tree may never build) can
// still serve favourites/custom stations from local reads.
final fuenteAuto = FuenteEmisorasAutoLocal();
registrarFuenteNavegacion(fuenteAuto);
// S3-R1: audio focus — phone calls / transient losses pause or duck the
// radio; headphones unplugged pauses it.
final sesionAudio = ServicioAudioSession(objetivo: handler);
unawaited(sesionAudio.configurar());
runApp(_OrientacionResponsiveApp(child: PluriWaveApp(prefs: prefs)));
runApp(
_OrientacionResponsiveApp(
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto),
),
);
}
Future<void> _aplicarPoliticaOrientacion([ui.Display? display]) async {
+265
View File
@@ -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';
}
}
+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();
}
}