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:
@@ -107,6 +107,11 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<!-- Android Auto discovery (android-auto-media) -->
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.car.application"
|
||||||
|
android:resource="@xml/automotive_app_desc" />
|
||||||
</application>
|
</application>
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<automotiveApp>
|
||||||
|
<uses name="media"/>
|
||||||
|
</automotiveApp>
|
||||||
+9
-1
@@ -24,16 +24,23 @@ import 'widgets/pluri_layout.dart';
|
|||||||
import 'widgets/pluri_onboarding_dialog.dart';
|
import 'widgets/pluri_onboarding_dialog.dart';
|
||||||
import 'widgets/pluri_wave_scaffold.dart';
|
import 'widgets/pluri_wave_scaffold.dart';
|
||||||
import 'package:pluriwave/widgets/mini_reproductor.dart';
|
import 'package:pluriwave/widgets/mini_reproductor.dart';
|
||||||
|
import 'servicios/navegacion_auto.dart';
|
||||||
import 'servicios/servicio_alarmas_android.dart';
|
import 'servicios/servicio_alarmas_android.dart';
|
||||||
import 'servicios/servicio_dispositivo_audio.dart';
|
import 'servicios/servicio_dispositivo_audio.dart';
|
||||||
|
|
||||||
class PluriWaveApp extends StatelessWidget {
|
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
|
/// Single SharedPreferences instance resolved in main() (S3-R4) and
|
||||||
/// injected into every state/service.
|
/// injected into every state/service.
|
||||||
final SharedPreferences? prefs;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MultiProvider(
|
return MultiProvider(
|
||||||
@@ -43,6 +50,7 @@ class PluriWaveApp extends StatelessWidget {
|
|||||||
(_) => EstadoRadio(
|
(_) => EstadoRadio(
|
||||||
prefs: prefs,
|
prefs: prefs,
|
||||||
dispositivoAudio: ServicioDispositivoAudioReal(),
|
dispositivoAudio: ServicioDispositivoAudioReal(),
|
||||||
|
fuenteAuto: fuenteAuto,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio
|
// Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import 'estado_busqueda.dart';
|
|||||||
import 'estado_ecualizador.dart';
|
import 'estado_ecualizador.dart';
|
||||||
import 'estado_grabacion.dart';
|
import 'estado_grabacion.dart';
|
||||||
import 'orden_emisoras.dart';
|
import 'orden_emisoras.dart';
|
||||||
|
import '../servicios/navegacion_auto.dart';
|
||||||
import '../servicios/persistencia_tolerante.dart';
|
import '../servicios/persistencia_tolerante.dart';
|
||||||
import '../servicios/servicio_audio.dart';
|
import '../servicios/servicio_audio.dart';
|
||||||
import '../servicios/servicio_dispositivo_audio.dart';
|
import '../servicios/servicio_dispositivo_audio.dart';
|
||||||
@@ -44,6 +45,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
ServicioGrabacionRadio? servicioGrabacion,
|
ServicioGrabacionRadio? servicioGrabacion,
|
||||||
SharedPreferences? prefs,
|
SharedPreferences? prefs,
|
||||||
Future<File> Function()? resolverArchivoCustom,
|
Future<File> Function()? resolverArchivoCustom,
|
||||||
|
FuenteEmisorasAuto? fuenteAuto,
|
||||||
bool iniciarAutomaticamente = true,
|
bool iniciarAutomaticamente = true,
|
||||||
}) : audio = audio ?? ServicioAudio(),
|
}) : audio = audio ?? ServicioAudio(),
|
||||||
favoritos = favoritos ?? ServicioFavoritos(),
|
favoritos = favoritos ?? ServicioFavoritos(),
|
||||||
@@ -52,7 +54,8 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
|
servicioEcualizador ?? ServicioEcualizador(prefs: prefs),
|
||||||
_dispositivoAudio = dispositivoAudio,
|
_dispositivoAudio = dispositivoAudio,
|
||||||
_prefs = prefs,
|
_prefs = prefs,
|
||||||
_resolverArchivoCustom = resolverArchivoCustom {
|
_resolverArchivoCustom = resolverArchivoCustom,
|
||||||
|
_fuenteAuto = fuenteAuto {
|
||||||
ecualizador = EstadoEcualizador(
|
ecualizador = EstadoEcualizador(
|
||||||
audio: this.audio,
|
audio: this.audio,
|
||||||
servicio: this.servicioEcualizador,
|
servicio: this.servicioEcualizador,
|
||||||
@@ -96,6 +99,13 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
final SharedPreferences? _prefs;
|
final SharedPreferences? _prefs;
|
||||||
final Future<File> Function()? _resolverArchivoCustom;
|
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
|
/// Single startup instance injected from main() (S3-R4); falls back to
|
||||||
/// getInstance() only when nothing was injected (tests, legacy callers).
|
/// getInstance() only when nothing was injected (tests, legacy callers).
|
||||||
Future<SharedPreferences> _resolverPrefs() async =>
|
Future<SharedPreferences> _resolverPrefs() async =>
|
||||||
@@ -292,6 +302,14 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
grabacion.activa) {
|
grabacion.activa) {
|
||||||
unawaited(grabacion.detener());
|
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();
|
notifyListeners();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -311,6 +329,9 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
_errorCarga = _textos.radioApiConnectionError;
|
_errorCarga = _textos.radioApiConnectionError;
|
||||||
} finally {
|
} finally {
|
||||||
_cargandoPopulares = false;
|
_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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,6 +339,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
Future<void> cargarFavoritos() async {
|
Future<void> cargarFavoritos() async {
|
||||||
_listaFavoritos = await favoritos.obtenerTodos();
|
_listaFavoritos = await favoritos.obtenerTodos();
|
||||||
await _normalizarEmisoraPreferida();
|
await _normalizarEmisoraPreferida();
|
||||||
|
_fuenteAuto?.actualizarSnapshot(favoritos: _listaFavoritos);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,6 +564,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
detalle: 'resolucion de ruta',
|
detalle: 'resolucion de ruta',
|
||||||
razon: e.toString(),
|
razon: e.toString(),
|
||||||
);
|
);
|
||||||
|
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -567,6 +590,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
razon: e.toString(),
|
razon: e.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,6 +606,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
if (!await archivo.exists()) {
|
if (!await archivo.exists()) {
|
||||||
_emisorasCustom = [];
|
_emisorasCustom = [];
|
||||||
_customDegradado = false;
|
_customDegradado = false;
|
||||||
|
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -594,6 +619,7 @@ class EstadoRadio extends ChangeNotifier {
|
|||||||
detalle: archivo.path,
|
detalle: archivo.path,
|
||||||
razon: e.toString(),
|
razon: e.toString(),
|
||||||
);
|
);
|
||||||
|
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'servicios/navegacion_auto.dart';
|
||||||
import 'servicios/servicio_audio.dart';
|
import 'servicios/servicio_audio.dart';
|
||||||
import 'servicios/servicio_audio_session.dart';
|
import 'servicios/servicio_audio_session.dart';
|
||||||
import 'tema/pluriwave_tokens.dart';
|
import 'tema/pluriwave_tokens.dart';
|
||||||
@@ -41,12 +42,23 @@ Future<void> main() async {
|
|||||||
);
|
);
|
||||||
registrarHandler(handler);
|
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
|
// S3-R1: audio focus — phone calls / transient losses pause or duck the
|
||||||
// radio; headphones unplugged pauses it.
|
// radio; headphones unplugged pauses it.
|
||||||
final sesionAudio = ServicioAudioSession(objetivo: handler);
|
final sesionAudio = ServicioAudioSession(objetivo: handler);
|
||||||
unawaited(sesionAudio.configurar());
|
unawaited(sesionAudio.configurar());
|
||||||
|
|
||||||
runApp(_OrientacionResponsiveApp(child: PluriWaveApp(prefs: prefs)));
|
runApp(
|
||||||
|
_OrientacionResponsiveApp(
|
||||||
|
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _aplicarPoliticaOrientacion([ui.Display? display]) async {
|
Future<void> _aplicarPoliticaOrientacion([ui.Display? display]) async {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import '../l10n/gen/app_localizations.dart';
|
|||||||
import '../modelos/emisora.dart';
|
import '../modelos/emisora.dart';
|
||||||
import '../modelos/preset_ecualizador.dart';
|
import '../modelos/preset_ecualizador.dart';
|
||||||
import 'controlador_reconexion.dart';
|
import 'controlador_reconexion.dart';
|
||||||
|
import 'navegacion_auto.dart';
|
||||||
import 'servicio_audio_session.dart';
|
import 'servicio_audio_session.dart';
|
||||||
|
|
||||||
/// Estado de reproducción expuesto al UI.
|
/// Estado de reproducción expuesto al UI.
|
||||||
@@ -35,6 +36,16 @@ void registrarHandler(PluriWaveAudioHandler handler) {
|
|||||||
_handlerGlobal = 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.
|
/// Wrapper de alto nivel para el UI.
|
||||||
class ServicioAudio {
|
class ServicioAudio {
|
||||||
PluriWaveAudioHandler get _handler {
|
PluriWaveAudioHandler get _handler {
|
||||||
@@ -712,4 +723,84 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|||||||
favicon: mediaItem.artUri?.toString(),
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:pluriwave/estado/estado_radio.dart';
|
import 'package:pluriwave/estado/estado_radio.dart';
|
||||||
import 'package:pluriwave/modelos/emisora.dart';
|
import 'package:pluriwave/modelos/emisora.dart';
|
||||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||||
|
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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`/
|
/// [File] spy: only the members `_cargarEmisorasCustom`/
|
||||||
@@ -630,6 +736,15 @@ class _AudioControlado extends ServicioAudio {
|
|||||||
_pendientes.remove(uuid)?.complete();
|
_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
|
@override
|
||||||
Future<void> aplicarPreset(PresetEcualizador preset) async {}
|
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