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> favoritos(); Future> misEmisoras(); /// `populares` snapshot; may be empty on a cold bind (Design "which /// stations surface & ordering"). Future> todas(); Future 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? favoritos, List? misEmisoras, List? 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 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 hijos(String parentId, {required List 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:` /// (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:` 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 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:` 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 reproducirPorMediaId( String id, { required FuenteEmisorasAuto fuente, required Future 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 Function()? resolverRutaCustom, }) : _favoritosServicio = favoritosServicio ?? ServicioFavoritos(), _resolverRutaCustom = resolverRutaCustom; final ServicioFavoritos _favoritosServicio; final Future Function()? _resolverRutaCustom; List? _snapshotFavoritos; List? _snapshotMisEmisoras; List? _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? favoritos, List? misEmisoras, List? todas, }) { if (favoritos != null) _snapshotFavoritos = favoritos; if (misEmisoras != null) _snapshotMisEmisoras = misEmisoras; if (todas != null) _snapshotTodas = todas; } @override Future> 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> misEmisoras() async { final snapshot = _snapshotMisEmisoras; if (snapshot != null) return snapshot; return _leerEmisorasCustom(); } @override Future> 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 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> _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( data, Emisora.fromMap, subsistema: 'emisoras_custom_auto', coleccion: 'emisoras_custom', ); return resultado.validas; } catch (_) { return const []; } } Future _rutaArchivoCustom() async { final resolver = _resolverRutaCustom; if (resolver != null) return resolver(); final dir = await getApplicationDocumentsDirectory(); return '${dir.path}/emisoras_custom.json'; } }