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'; const _prefijoEmisora = 'emisora:'; /// Canonical on-brand fallback-art names and rotation order, ported /// **verbatim** (same formula, same order) from /// `lib/widgets/tarjeta_emisora.dart`'s `_fallbackArtFor` (lines 363-367) /// to guarantee phone/car per-station art parity (Design "Fallback-art /// selection"). Keep this list in sync with that one — there is no /// structural enforcement of order, only this comment and the parity test /// in `navegacion_auto_test.dart` (group `parity: phone/auto art order`). const _nombresArte = ['aurora', 'cosmic', 'pulse', 'nova']; /// Returns whether [favicon] is usable as a remote `artUri` (Design /// Decision "Case B detection" — static validity gate, zero network): /// non-null, non-blank after trimming, and parses as an absolute /// `http`/`https` URI with a non-empty authority. Does **not** probe /// reachability — the OS art loader fetches `artUri` independently and /// later, so a build-time network check would be racy (TOCTOU); this only /// catches the deterministic malformed/non-http(s) subset (bare hosts, /// wrong scheme, `http://` with no authority, whitespace, unparseable). bool faviconUsable(String? favicon) { final trimmed = favicon?.trim(); if (trimmed == null || trimmed.isEmpty) return false; final uri = Uri.tryParse(trimmed); if (uri == null) return false; // `Uri.hasAuthority` is true whenever a `//` authority slot is present, // even with an empty host (e.g. `Uri.parse('http://').hasAuthority` is // `true`) — check `host.isNotEmpty` explicitly to actually require a // non-empty authority host. return (uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty; } /// Deterministic rotation index over the 4 on-brand fallback arts, same /// formula as `tarjeta_emisora.dart`'s `_fallbackArtFor` (Design /// "Fallback-art selection — port verbatim"): `seed` is the station uuid. int indiceArtePara(String seed) => seed.codeUnits.fold(0, (a, b) => a + b) % _nombresArte.length; /// Resolves the `artUri` for [e] (Design "Data Flow"): the favicon when it /// passes [faviconUsable], otherwise a rotating `station_art_` /// drawable URI selected via [indiceArtePara] over `e.uuid` — the same /// on-brand art the phone UI would pick for this station (per-station /// parity), never a launcher-icon lookalike. String artUriPara(Emisora e) => faviconUsable(e.favicon) ? e.favicon! : 'android.resource://es.freetimelab.pluriwave/drawable/' 'station_art_${_nombresArte[indiceArtePara(e.uuid)]}'; /// Formats a human-readable audio-quality hint for the browse row's /// `displaySubtitle` (Design Decision "`displaySubtitle` quality format"): /// `" kbps · "` when both are known, just the bitrate or /// just the codec when only one is known, and `null` (never `""`, never a /// string containing the literal `"null"`) when both are unknown. Codec is /// trimmed and upper-cased; blank-after-trim counts as unknown. `bitrate` /// `<= 0` counts as unknown (Radio Browser stores `0` for unknown). String? subtituloCalidad(Emisora e) { final codec = e.codec?.trim(); final codecConocido = codec != null && codec.isNotEmpty; final bitrate = e.bitrate; final bitrateConocido = bitrate != null && bitrate > 0; if (codecConocido && bitrateConocido) { return '$bitrate kbps · ${codec.toUpperCase()}'; } if (bitrateConocido) return '$bitrate kbps'; if (codecConocido) return codec.toUpperCase(); return null; } /// 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, on-brand-fallback-aware `artUri` /// (Design "Case B detection" + "Fallback-art selection") and a /// quality-hint `displaySubtitle` (Design "`displaySubtitle` quality /// format") when codec/bitrate are known. MediaItem itemEmisora(Emisora e) => MediaItem( id: '$_prefijoEmisora${e.uuid}', title: e.nombre, playable: true, artUri: Uri.parse(artUriPara(e)), displaySubtitle: subtituloCalidad(e), extras: _contentStyleGrid, ); /// 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'; } }