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 '../modelos/grupo_favoritos.dart'; import '../modelos/preset_ecualizador.dart'; import 'persistencia_tolerante.dart'; import 'servicio_favoritos.dart'; const _prefijoEmisora = 'emisora:'; /// EQ preset media-id prefix (Design ADR-1), collision-free against /// [_prefijoEmisora], `grupo:` and the bare folder id constants. const _prefijoPresetEq = 'eq_preset:'; /// Whether [id] identifies an EQ preset leaf item (Design ADR-1). A bare /// prefix (`'eq_preset:'`, empty name) is still `true` here — the empty-name /// case is rejected downstream by [resolverPresetEq], not by this routing /// predicate. bool esPresetMediaId(String id) => id.startsWith(_prefijoPresetEq); /// 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); /// Favorite groups (`GrupoFavoritos`), cold-start safe — mirrors /// [favoritos]'s never-throws contract (Design "Favorite Group /// Sub-Folders"). Future> grupos(); /// 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, List? grupos, }) {} } /// 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'; /// Root folder id for the EQ presets folder (Design "media-id scheme"). /// Deliberately NOT added to [_idsCarpetas] — it has its own dedicated /// branch in `getChildren`/`ConstructorArbolAuto.presetsEq`, not the /// generic station-list `hijos()` path. static const idEcualizador = 'ecualizador'; static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras}; static const _maxItemsPorCarpeta = 50; /// Favorite-group folder id prefix (Design "media-id scheme"), collision /// free against [_prefijoEmisora] and the bare folder id constants above. static const _prefijoGrupo = 'grupo:'; /// Separate cap for favorite-group folders under `Favoritos` (Design /// "group-folder ordering and cap"): a folder tap costs more driver /// attention than a station scroll, so this is tunable independently of /// [_maxItemsPorCarpeta]. static const _maxGruposPorFavoritos = 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 4 root folders (Favoritos, Todas las emisoras, Mis emisoras, /// Ecualizador), all non-playable. `Ecualizador` is deliberately LAST /// (Design ADR-2): content-browsing folders are the primary car task and /// stay first, the EQ tool trails them. List raiz() => [ _carpeta(idFavoritos, 'Favoritos'), _carpeta(idTodas, 'Todas las emisoras'), _carpeta(idMisEmisoras, 'Mis emisoras'), _carpeta(idEcualizador, 'Ecualizador'), ]; 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; } /// Whether [id] identifies a favorite-group folder (Design "media-id /// scheme"). bool esCarpetaGrupo(String id) => id.startsWith(_prefijoGrupo); /// Maps a [GrupoFavoritos] to a non-playable folder `MediaItem` with id /// `grupo:` (Design "media-id scheme"). MediaItem itemGrupo(GrupoFavoritos g) => _carpeta('$_prefijoGrupo${g.id}', g.nombre); /// Maps a [PresetEcualizador] to a playable `MediaItem` with id /// `eq_preset:` (Design ADR-1). MediaItem itemPresetEq(PresetEcualizador preset) => MediaItem( id: '$_prefijoPresetEq${preset.nombre}', title: preset.nombre, playable: true, extras: _contentStyleGrid, ); /// The 6 fixed EQ preset leaf items for the `Ecualizador` folder (Spec /// "Car requests the Ecualizador folder"). List presetsEq(List presets) => presets.map(itemPresetEq).toList(); /// Children of the `Favoritos` folder (Design "Ungrouped favorites stay as /// direct leaves at the Favoritos root"): non-empty custom-group folders /// (phone order, capped at [_maxGruposPorFavoritos]), followed by /// `sin_asignar` stations mapped through the existing [hijos] path so the /// no-custom-groups case is byte-identical to the pre-groups tree /// (regression guard — Spec "Ungrouped station appears exactly as /// before"). Empty custom groups are omitted (Design "Empty groups hidden /// from the car tree"); the `sin_asignar` pseudo-group is never rendered /// as its own folder. List carpetasFavoritos({ required List grupos, required List favoritos, }) { final carpetas = grupos .where((g) => !g.esSinAsignar) .where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id)) .take(_maxGruposPorFavoritos) .map(itemGrupo) .toList(); final sinAsignar = favoritos .where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId) .toList(); return [...carpetas, ...hijos(idFavoritos, emisoras: sinAsignar)]; } /// Members of the favorite group identified by [grupoMediaId] (a /// `grupo:` id), sorted and capped like every other folder (Spec "Car /// requests a group folder's stations"). An unknown/stale/malformed id /// returns an empty list instead of throwing (Spec "Car requests an /// unknown or stale group id"). List hijosGrupo( String grupoMediaId, { required List favoritos, }) { if (!esCarpetaGrupo(grupoMediaId)) return const []; final id = grupoMediaId.substring(_prefijoGrupo.length); if (id.isEmpty) return const []; final miembros = favoritos .where((e) => e.grupoFavoritosId == id) .toList(); if (miembros.isEmpty) return const []; final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad); return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList(); } } /// 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); } /// Resolves an `eq_preset:` [id] to the matching [PresetEcualizador] /// in [presets] by exact name (Design ADR-1, mirrors /// [ConstructorArbolAuto.resolver]'s shape). Any other shape (no prefix, /// empty name, unmatched name) returns `null` instead of throwing (Spec /// "Unknown or stale preset id"). PresetEcualizador? resolverPresetEq(String id, List presets) { if (!esPresetMediaId(id)) return null; final nombre = id.substring(_prefijoPresetEq.length); if (nombre.isEmpty) return null; for (final preset in presets) { if (preset.nombre == nombre) return preset; } return null; } /// Pure per-station apply gate (Design ADR-5), mirroring /// `EstadoEcualizador.cambiarPresetPrincipal`'s exact logic /// (`estado_ecualizador.dart:302-304`): the new principal preset is applied /// live when there is no current station ([uuidActual] is `null`) or the /// current station has no per-station preset override in /// [clavesPorEmisora]. bool debeAplicarPrincipalAhora({ required String? uuidActual, required Set clavesPorEmisora, }) => uuidActual == null || !clavesPorEmisora.contains(uuidActual); /// Orchestrates an `eq_preset:` selection from the car (Design /// "Data flow — a preset tap", ADR-3): resolves [id] via [resolverPresetEq], /// persists it as principal via [persistirPrincipal], and conditionally /// applies it live via [aplicar] when [debeAplicarPrincipalAhora] allows it. /// /// This function's signature exposes ONLY the EQ persist/apply seams — it /// has NO parameter for `playMediaItem`, `mediaItem`, or `playbackState`, so /// there is no code path from a preset tap to playback (Design ADR-3, /// non-playback invariant enforced structurally, not by discipline). An /// unknown/stale [id] is a no-op: neither seam is invoked and no exception /// propagates (Spec "Unknown or stale preset id"). Future aplicarPresetPorMediaId( String id, { required List presets, required String? uuidActual, required Future> Function() clavesPorEmisora, required Future Function(PresetEcualizador) persistirPrincipal, required Future Function(PresetEcualizador) aplicar, }) async { final preset = resolverPresetEq(id, presets); if (preset == null) return; await persistirPrincipal(preset); if (debeAplicarPrincipalAhora( uuidActual: uuidActual, clavesPorEmisora: await clavesPorEmisora(), )) { await aplicar(preset); } } /// 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; List? _snapshotGrupos; /// 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, List? grupos, }) { if (favoritos != null) _snapshotFavoritos = favoritos; if (misEmisoras != null) _snapshotMisEmisoras = misEmisoras; if (todas != null) _snapshotTodas = todas; if (grupos != null) _snapshotGrupos = grupos; } @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> grupos() async { final snapshot = _snapshotGrupos; if (snapshot != null) return snapshot; try { return await _favoritosServicio.obtenerGrupos(); } 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'; } }