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:
@@ -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 {}
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
|
||||
void main() {
|
||||
group('ConstructorArbolAuto.raiz', () {
|
||||
test('devuelve exactamente 3 carpetas no reproducibles con los ids '
|
||||
'esperados', () {
|
||||
final raiz = ConstructorArbolAuto().raiz();
|
||||
|
||||
expect(raiz, hasLength(3));
|
||||
final ids = raiz.map((item) => item.id).toSet();
|
||||
expect(
|
||||
ids,
|
||||
equals({
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
}),
|
||||
);
|
||||
for (final item in raiz) {
|
||||
expect(item.playable, isFalse);
|
||||
expect(item.title, isNotEmpty);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('ConstructorArbolAuto.itemEmisora', () {
|
||||
test('usa el favicon remoto como artUri cuando existe', () {
|
||||
final emisora = _emisora(
|
||||
uuid: 'uuid-logo',
|
||||
nombre: 'Radio Con Logo',
|
||||
favicon: 'https://cdn.example.com/logo.png',
|
||||
);
|
||||
|
||||
final item = ConstructorArbolAuto().itemEmisora(emisora);
|
||||
|
||||
expect(item.id, 'emisora:${emisora.uuid}');
|
||||
expect(item.title, emisora.nombre);
|
||||
expect(item.artUri.toString(), emisora.favicon);
|
||||
expect(item.playable, isTrue);
|
||||
});
|
||||
|
||||
test('cae al arte por defecto cuando el favicon es null o vacío', () {
|
||||
final sinFavicon = _emisora(
|
||||
uuid: 'uuid-null',
|
||||
nombre: 'Radio Sin Logo',
|
||||
favicon: null,
|
||||
);
|
||||
final faviconVacio = _emisora(
|
||||
uuid: 'uuid-vacio',
|
||||
nombre: 'Radio Logo Vacio',
|
||||
favicon: '',
|
||||
);
|
||||
const esperado =
|
||||
'android.resource://es.freetimelab.pluriwave/drawable/default_station_art';
|
||||
|
||||
final builder = ConstructorArbolAuto();
|
||||
|
||||
expect(builder.itemEmisora(sinFavicon).artUri.toString(), esperado);
|
||||
expect(builder.itemEmisora(faviconVacio).artUri.toString(), esperado);
|
||||
});
|
||||
});
|
||||
|
||||
group('ConstructorArbolAuto.hijos', () {
|
||||
test('limita a 50 items y respeta el orden de ordenarEmisoras', () {
|
||||
final emisoras = List.generate(
|
||||
60,
|
||||
(i) => _emisora(uuid: 'uuid-$i', nombre: 'Radio $i', bitrate: i),
|
||||
);
|
||||
|
||||
final hijos = ConstructorArbolAuto().hijos(
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
emisoras: emisoras,
|
||||
);
|
||||
|
||||
expect(hijos, hasLength(50));
|
||||
// ordenarEmisoras por calidad ordena por bitrate descendente: el
|
||||
// primer item debe ser el de mayor bitrate (59) y el ultimo (el #50)
|
||||
// el de bitrate 10 (59..10 son 50 valores).
|
||||
expect(hijos.first.id, 'emisora:uuid-59');
|
||||
expect(hijos.last.id, 'emisora:uuid-10');
|
||||
});
|
||||
|
||||
test('lista vacía cuando no hay emisoras, sin lanzar', () {
|
||||
final hijos = ConstructorArbolAuto().hijos(
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
emisoras: const [],
|
||||
);
|
||||
|
||||
expect(hijos, isEmpty);
|
||||
});
|
||||
|
||||
test('parentId desconocido devuelve lista vacía', () {
|
||||
final emisoras = [_emisora(uuid: 'uuid-1', nombre: 'Radio 1')];
|
||||
|
||||
final hijos = ConstructorArbolAuto().hijos(
|
||||
'carpeta-inexistente',
|
||||
emisoras: emisoras,
|
||||
);
|
||||
|
||||
expect(hijos, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('ConstructorArbolAuto.resolver', () {
|
||||
test('resuelve un uuid conocido a su Emisora', () {
|
||||
final emisora = _emisora(uuid: 'uuid-conocido', nombre: 'Conocida');
|
||||
final universo = [emisora, _emisora(uuid: 'otra', nombre: 'Otra')];
|
||||
|
||||
final resultado = ConstructorArbolAuto().resolver(
|
||||
'emisora:uuid-conocido',
|
||||
universo,
|
||||
);
|
||||
|
||||
expect(resultado, equals(emisora));
|
||||
});
|
||||
|
||||
test(
|
||||
'devuelve null para id sin prefijo, id malformado o uuid sin match, '
|
||||
'sin lanzar',
|
||||
() {
|
||||
final universo = [_emisora(uuid: 'uuid-conocido', nombre: 'Conocida')];
|
||||
final builder = ConstructorArbolAuto();
|
||||
|
||||
expect(builder.resolver('favoritos', universo), isNull);
|
||||
expect(builder.resolver('emisora:', universo), isNull);
|
||||
expect(
|
||||
builder.resolver('emisora:uuid-desconocido', universo),
|
||||
isNull,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('reproducirPorMediaId', () {
|
||||
test(
|
||||
'resuelve el id y delega a reproducir con un MediaItem con forma de '
|
||||
'telefono (id=url, extras.uuid)',
|
||||
() async {
|
||||
final emisora = _emisora(
|
||||
uuid: 'uuid-play',
|
||||
nombre: 'Radio a reproducir',
|
||||
url: 'https://stream.demo/play',
|
||||
);
|
||||
final fuente = _FakeFuenteEmisorasAuto(
|
||||
porUuidResultado: {emisora.uuid: emisora},
|
||||
);
|
||||
MediaItem? recibido;
|
||||
|
||||
await reproducirPorMediaId(
|
||||
'emisora:${emisora.uuid}',
|
||||
fuente: fuente,
|
||||
reproducir: (item) async {
|
||||
recibido = item;
|
||||
},
|
||||
);
|
||||
|
||||
expect(recibido, isNotNull);
|
||||
expect(recibido!.id, emisora.url);
|
||||
expect(recibido!.extras?['uuid'], emisora.uuid);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'id obsoleto/desconocido no llama a reproducir ni lanza excepción',
|
||||
() async {
|
||||
final fuente = _FakeFuenteEmisorasAuto(porUuidResultado: const {});
|
||||
var llamadas = 0;
|
||||
|
||||
await reproducirPorMediaId(
|
||||
'emisora:uuid-fantasma',
|
||||
fuente: fuente,
|
||||
reproducir: (item) async {
|
||||
llamadas++;
|
||||
},
|
||||
);
|
||||
|
||||
expect(llamadas, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Emisora _emisora({
|
||||
required String uuid,
|
||||
required String nombre,
|
||||
String url = 'https://stream.demo/radio',
|
||||
String? favicon,
|
||||
int? bitrate,
|
||||
}) {
|
||||
return Emisora(
|
||||
uuid: uuid,
|
||||
nombre: nombre,
|
||||
url: url,
|
||||
favicon: favicon,
|
||||
bitrate: bitrate,
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeFuenteEmisorasAuto implements FuenteEmisorasAuto {
|
||||
_FakeFuenteEmisorasAuto({required Map<String, Emisora> porUuidResultado})
|
||||
: _porUuidResultado = porUuidResultado;
|
||||
|
||||
final Map<String, Emisora> _porUuidResultado;
|
||||
|
||||
@override
|
||||
Future<List<Emisora>> favoritos() async => const [];
|
||||
|
||||
@override
|
||||
Future<List<Emisora>> misEmisoras() async => const [];
|
||||
|
||||
@override
|
||||
Future<List<Emisora>> todas() async => const [];
|
||||
|
||||
@override
|
||||
Future<Emisora?> porUuid(String uuid) async => _porUuidResultado[uuid];
|
||||
|
||||
@override
|
||||
void actualizarSnapshot({
|
||||
List<Emisora>? favoritos,
|
||||
List<Emisora>? misEmisoras,
|
||||
List<Emisora>? todas,
|
||||
}) {}
|
||||
}
|
||||
Reference in New Issue
Block a user