import 'dart:convert'; import 'dart:io'; import 'dart:math' show Random; import 'package:audio_service/audio_service.dart'; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path_provider/path_provider.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; import '../modelos/pista_local.dart'; import '../modelos/preset_ecualizador.dart'; import 'contexto_reproduccion.dart'; import 'emisoras_destacadas.dart'; import 'musica_local_auto.dart'; import 'persistencia_tolerante.dart'; import 'servicio_favoritos.dart'; /// Generic page slice over [items] (Design ADR-6): returns at most [tamano] /// elements starting at `pagina * tamano`. Reusable across any list type — /// no [NodoLocal] coupling — so a future paged folder type can reuse the /// slice arithmetic directly. An empty [items] or a [pagina] beyond the /// list's range returns `[]`, never throws. List paginaDe( List items, { required int pagina, required int tamano, }) => items.skip(pagina * tamano).take(tamano).toList(); /// Whether a page after [pagina] exists for a list of [total] elements /// (Design ADR-6): `true` iff at least one element remains beyond the /// current page's slice. The exact-boundary case /// (`total == (pagina + 1) * tamano`) is `false` — nothing remains to /// reveal. bool hayPaginaSiguiente( int total, { required int pagina, required int tamano, }) => total > (pagina + 1) * tamano; /// Browse-tree ordering comparator for a local-music folder's children /// (Design "Directories before files", item 1): directories sort before /// files regardless of name, and within each group, alphabetically by /// [NodoLocal.nombre] -- the standard file-browser convention. Fixes a /// driver-facing bug where a folder's subfolders could land on a later /// "Más…" page whenever enough tracks sorted alphabetically ahead of them /// (e.g. a "Live" subfolder behind 80 numbered tracks), making the /// subfolder unreachable without paging through every track first. int compararNodoLocalParaNavegacion(NodoLocal a, NodoLocal b) { if (a.esDirectorio != b.esDirectorio) { return a.esDirectorio ? -1 : 1; } return a.nombre.compareTo(b.nombre); } const _prefijoEmisora = 'emisora:'; /// Local-track media-id prefix (Design "media-id scheme"), collision-free /// against [_prefijoEmisora], `grupo:` and the bare folder id constants. /// Top-level (not a [ConstructorArbolAuto] member) — used directly from /// `playFromMediaId`'s dispatch in `servicio_audio.dart`. const _prefijoPista = 'pista:'; /// Whether [id] identifies a local-track playable leaf item (Design /// "media-id scheme"). bool esPistaMediaId(String id) => id.startsWith(_prefijoPista); /// 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; } /// Formats a human-readable quality hint for a local track's /// `displaySubtitle` (Design ADR-5 "unknown → omit" discipline, reused for /// local tracks): `" kbps · kHz"` when both are /// known, just the bitrate or just the sample rate when only one is known, /// and `null` (never `""`, never a string containing the literal `"null"`) /// when [metadatos] is `null` or both fields are unknown. `bitrate` is /// converted from bits/sec to kbps (rounded); `sampleRate` from Hz to kHz, /// trimmed of a trailing `.0`. String? subtituloCalidadLocal(MetadatosPista? metadatos) { if (metadatos == null) return null; final bitrate = metadatos.bitrate; final bitrateConocido = bitrate != null && bitrate > 0; final sampleRate = metadatos.sampleRate; final sampleRateConocido = sampleRate != null && sampleRate > 0; if (bitrateConocido && sampleRateConocido) { return '${(bitrate / 1000).round()} kbps · ${_formatKhz(sampleRate)} kHz'; } if (bitrateConocido) return '${(bitrate / 1000).round()} kbps'; if (sampleRateConocido) return '${_formatKhz(sampleRate)} kHz'; return null; } /// Formats [sampleRateHz] (in Hz) as a trimmed kHz string: `44100` -> /// `'44.1'`, `48000` -> `'48'` — never a trailing `.0` or extra zeros. String _formatKhz(int sampleRateHz) { var texto = (sampleRateHz / 1000).toStringAsFixed(2); while (texto.endsWith('0')) { texto = texto.substring(0, texto.length - 1); } if (texto.endsWith('.')) { texto = texto.substring(0, texto.length - 1); } return texto; } /// 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, }) {} } /// Every user-readable label of the Android Auto browse tree, already /// resolved to one locale by the caller. /// /// THE RULE (fix/auto-quality-guidelines, l10n item): anything a user can /// read gets translated. This bundle replaces the previous /// "car-tree labels are hardcoded Spanish, deliberately NOT an arb key" /// convention, which was defensible only while those labels sat deep inside /// a premium tree and stopped being defensible the moment Google Play /// reviewed the car surface on an English head unit. /// /// It exists as a plain value object rather than an `AppLocalizations` /// dependency so [ConstructorArbolAuto] stays a PURE builder — the same /// reason `itemsEcualizadorAuto` lives in `servicio_audio.dart`. The handler, /// which can resolve localizations headlessly through /// `resolverLocalizacionesRespaldo`, builds one via /// `etiquetasArbolAutoDesde` and hands it in. /// /// NOT in here on purpose: the alphabetical bucket labels (`'A-F'`, `'G-M'`, /// …). Those are ranges of Latin letters, not prose — translating them would /// make them lie about which filenames they contain. class EtiquetasArbolAuto { const EtiquetasArbolAuto({ required this.escuchar, required this.favoritos, required this.todasLasEmisoras, required this.misEmisoras, required this.musicaLocal, required this.musicaLocalNoDisponible, required this.cargarMas, required this.ordenarPorCalidad, required this.reproducirCarpeta, required this.reproducirAleatorio, required this.pistaSinNombre, }); /// Fallback bundle for callers that have no localizations to hand: pure /// builder tests, and any future non-car consumer. /// /// It is NOT what the car shows. `ServicioAudio` always injects a bundle /// resolved from `AppLocalizations`, in every browse and playback path /// that can produce a label — `etiquetas_arbol_auto_test.dart` is the /// guard that no NEW hardcoded label can be introduced alongside these. static const respaldo = EtiquetasArbolAuto( escuchar: 'Escuchar', favoritos: 'Favoritos', todasLasEmisoras: 'Todas las emisoras', misEmisoras: 'Mis emisoras', musicaLocal: 'Música Local', musicaLocalNoDisponible: 'Abre PluriWave en el móvil para leer tu música', cargarMas: 'Más…', ordenarPorCalidad: 'Ordenar por calidad', reproducirCarpeta: 'Reproducir carpeta', reproducirAleatorio: 'Reproducir aleatorio', pistaSinNombre: 'Pista sin nombre', ); /// The free tier's single root folder ([ConstructorArbolAuto.idDestacadas]). final String escuchar; /// Premium root folders. final String favoritos; final String todasLasEmisoras; final String misEmisoras; final String musicaLocal; /// The non-playable row shown when the local-music folder cannot be read /// from the car ([ConstructorArbolAuto.idLocalNoLista]). final String musicaLocalNoDisponible; /// Trailing "load more" row of every paged local-music view. final String cargarMas; /// Local-folder navigation and action rows. final String ordenarPorCalidad; final String reproducirCarpeta; final String reproducirAleatorio; /// Fallback title for a local file whose name is blank after stripping. final String pistaSinNombre; } /// 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 { const ConstructorArbolAuto({this.etiquetas = EtiquetasArbolAuto.respaldo}); /// The already-localized labels this builder stamps onto every /// user-readable `MediaItem` it produces. final EtiquetasArbolAuto etiquetas; /// 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 FREE tier's only browsable folder /// (fix/auto-quality-guidelines, item 8). /// /// Deliberately NOT added to [_idsCarpetas] — like [idMusicaLocal] and /// [idEcualizador] it has its own dedicated children ([hijosDestacadas]), /// fed by `emisoras_destacadas.dart`'s compiled-in set rather than by the /// generic station-list [hijos] path over a `FuenteEmisorasAuto` that is /// empty on the bind a Play reviewer actually performs. static const idDestacadas = 'destacadas'; /// Root folder id for the local-music browsable root (Design "media-id /// scheme"). Deliberately NOT added to [_idsCarpetas] — it has its own /// dedicated branch (`hijosMusicaLocal`), not the generic station-list /// [hijos] path. Hidden from [raiz] until a folder has been picked /// (Design "Local root hidden until a folder is configured"). static const idMusicaLocal = 'musica_local'; /// Root folder id for the "Ecualizador" browsable folder (decision /// `auto/ecualizador-diseno`): lists "Desactivar" plus the six factory /// presets, the currently-active one marked. Deliberately NOT added to /// [_idsCarpetas] -- like [idMusicaLocal], it has its own dedicated /// children, built by `itemsEcualizadorAuto` in `servicio_audio.dart` /// (which needs `AppLocalizations` -- this pure builder class does not /// depend on it), not the generic station-list [hijos] path. Unlike /// [idMusicaLocal], it is ALWAYS present in [raiz], never conditionally /// hidden. static const idEcualizador = 'ecualizador'; /// Non-playable "no puedo leer la carpeta desde aquí" item /// (fix/android-auto-musica-local). La raíz ya no oculta [idMusicaLocal] /// cuando el canal nativo `pluriwave/file_actions` no está disponible en /// este motor, así que abrir la carpeta tenía que dejar de mostrar una /// lista vacía: vacío se lee como «no tengo música», que es justo la /// conclusión equivocada. Este item dice qué pasa de verdad. /// /// Colisión imposible con los prefijos `carpeta_local:` / `pista:` / /// `emisora:` / `grupo:` — no lleva ninguno de ellos. static const idLocalNoLista = 'musica_local_no_disponible'; 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:'; /// Local-music subfolder id prefix (Design "media-id scheme"), /// collision-free against [_prefijoEmisora], [_prefijoGrupo], /// [_prefijoPresetEq] and the bare folder id constants above. static const _prefijoCarpetaLocal = 'carpeta_local:'; /// Paged "load more" local-music id prefix (Design ADR-1). Collision-free /// against [_prefijoCarpetaLocal] and every other prefix/bare id in this /// class: at the index where `carpeta_local:` has `:`, this prefix has /// `_`, so neither ever matches the other's `startsWith` check — routing /// order between [esCarpetaLocalPaginadaMediaId] and [esCarpetaLocalMediaId] /// is therefore irrelevant to correctness. static const _prefijoCarpetaLocalPaginada = 'carpeta_local_pag:'; /// Sort-mode local-music id prefix (Design ADR-4, Phase 2): /// `carpeta_local_ord:::`. Collision-free against /// every other prefix/bare id in this class — diverges from /// [_prefijoCarpetaLocal] at index 13 (`_` vs `:`) and from /// [_prefijoCarpetaLocalBucket]/[_prefijoCarpetaLocalPaginada] at the char /// right after `carpeta_local_` (`o` vs `b`/`p`). static const _prefijoCarpetaLocalOrd = 'carpeta_local_ord:'; /// Alphabetical-bucket local-music id prefix (Design ADR-4, Phase 2): /// `carpeta_local_bucket:::`. Collision-free /// against every other prefix/bare id in this class (see /// [_prefijoCarpetaLocalOrd]'s doc for the divergence proof). static const _prefijoCarpetaLocalBucket = 'carpeta_local_bucket:'; /// "Reproducir carpeta" (sequential-play) action media-id prefix (Design /// ADR-5, Phase 3): `carpeta_local_reproducir:`. PLAYABLE (unlike /// every other `carpeta_local_*` prefix in this class, which are /// non-playable browse folders) — routed through `playFromMediaId`, not /// `getChildren`. Collision-free against every other prefix here: /// diverges from [_prefijoCarpetaLocalPaginada]/[_prefijoCarpetaLocalOrd]/ /// [_prefijoCarpetaLocalBucket] at index 14 (`r` vs `p`/`o`/`b`), same /// divergence-point family as those siblings' doc comments. static const _prefijoCarpetaLocalReproducir = 'carpeta_local_reproducir:'; /// "Reproducir aleatorio" (shuffled-play) action media-id prefix (Design /// ADR-5, Phase 3): `carpeta_local_aleatorio:`. PLAYABLE, mirrors /// [_prefijoCarpetaLocalReproducir]. Diverges from every sibling prefix /// at index 14 (`a` vs `r`/`p`/`o`/`b`). static const _prefijoCarpetaLocalAleatorio = 'carpeta_local_aleatorio:'; /// 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; /// Dedicated cap for local-music folders (Design "Dedicated 50-item cap, /// alphabetical truncation"), tunable independently of /// [_maxItemsPorCarpeta]/[_maxGruposPorFavoritos] — the extension point /// for a future native page-offset parameter. static const _maxItemsCarpetaLocal = 50; /// Quality-sort track-count cap (Design ADR-3): above this, the /// "Ordenar por calidad" entry is omitted instead of paying an unbounded /// per-file `MediaMetadataRetriever` extraction cost — first-pass value, /// not yet hardware-validated (Design "Open Questions"). static const _maxPistasParaOrdenCalidad = 150; /// Bucket-eligibility track-count threshold (Design ADR-4): buckets add /// no value for small folders, so they're only offered above this count. static const _minPistasParaBuckets = 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 root folders, all non-playable, and TIER-DEPENDENT: Favoritos, /// Todas las emisoras, Mis emisoras and optionally Música Local for a /// premium driver; the single [idDestacadas] folder for a free one (see /// [premium] below). /// /// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer /// There is NO `Ecualizador` folder. The car's only equalizer control is /// the on/off custom action on the playback screen /// (`controlesEcualizadorPersonalizados` in `servicio_audio.dart`), which /// the driver reaches from all three player views without leaving them. /// /// The folder existed briefly (`8423ccd`) because custom actions were /// thought unable to convey enough state for a six-preset choice. Owner /// decision after driving with it: a browsable preset list is more /// interaction than a driver wants, and on/off is the only equalizer /// control that belongs in a car. Preset selection stays on the phone. /// This lands back on the redesign mockup's original rule ("sin carpeta de /// ecualizador", turn t4 line 40), now for a road-tested reason rather than /// an assumed one. /// /// `Música Local` is OMITTED entirely (not just empty) unless /// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder /// is configured") — the caller lo deriva de /// `premium && fuente.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada` /// (fix/android-auto-musica-local: un canal nativo ausente ya NO oculta el /// nodo; fix/auto-quality-guidelines item 9: el `premium &&` va delante a /// propósito, para que el tier gratuito ni siquiera pague ese round trip /// nativo), keeping this builder itself synchronous and side-effect free. /// /// [premium] (fix/auto-quality-guidelines, item 8) is finally READ. It used /// to be accepted and ignored, on the theory that "the root keeps the same /// visible folder labels for free users" was friendlier than a reduced /// menu. It was not: every one of those four folders dead-ended on a single /// non-playable "Función Premium" row, and Google Play cited exactly that /// against the Android for Cars App Quality Guidelines. /// /// The free root is therefore ONE browsable folder, [idDestacadas], and the /// premium-only folders are OMITTED rather than shown-and-blocked: a folder /// a driver cannot use is worse than a folder that is not there. /// /// It must stay at least one BROWSABLE item, never a bare playable one: /// `audio_service` 0.18.18 discards `rootHints` /// (`AudioService.java:817-826`), so this code cannot detect whether the /// head unit accepts a `FLAG_PLAYABLE` root child, and the documented /// default of `BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS` is /// `FLAG_BROWSABLE` alone — a root of one playable item renders EMPTY on /// such a unit. /// /// Every label here comes from [etiquetas], already resolved to the head /// unit's locale — the free root's [EtiquetasArbolAuto.escuchar] AND the /// four premium folders. /// /// The four premium ones used to be hardcoded Spanish, on the theory that /// they were leaf rows deep inside a tree only a user who had already /// chosen the app would reach. That was never a rule, only an untested /// assumption, and it is retired: anything a user can read gets /// translated. `escuchar` was localized first (it is 100% of what a free /// Play reviewer sees), which is exactly why the rest had to follow. /// /// [tituloDestacadas] stays as an explicit per-call override of /// [EtiquetasArbolAuto.escuchar]; `null` (the default) uses the bundle. List raiz({ required bool incluirMusicaLocal, required bool premium, String? tituloDestacadas, }) => premium ? [ _carpeta(idFavoritos, etiquetas.favoritos), _carpeta(idTodas, etiquetas.todasLasEmisoras), _carpeta(idMisEmisoras, etiquetas.misEmisoras), if (incluirMusicaLocal) _carpeta(idMusicaLocal, etiquetas.musicaLocal), ] : [_carpeta(idDestacadas, tituloDestacadas ?? etiquetas.escuchar)]; /// The free tier's playable station rows (fix/auto-quality-guidelines, /// items 8/9): [emisoras] mapped through the SAME [itemEmisora] the premium /// folders use, capped like every other folder. /// /// Separate from [hijos] because that path is gated on [_idsCarpetas] and /// fed by a `FuenteEmisorasAuto` whose lists are all empty on a cold /// headless bind — which is precisely the bind this folder has to survive. /// An empty [emisoras] returns `[]` rather than any placeholder row: a /// non-playable row in the car tree is the thing Play cited. List hijosDestacadas(List emisoras) => emisoras.take(_maxItemsPorCarpeta).map(itemEmisora).toList(); /// El item de [idLocalNoLista]. Rotulado con /// [EtiquetasArbolAuto.musicaLocalNoDisponible], ya resuelto al idioma del /// head unit. No reproducible — seleccionarlo es un no-op. MediaItem itemLocalNoDisponible() => MediaItem( id: idLocalNoLista, title: etiquetas.musicaLocalNoDisponible, playable: false, extras: _contentStyleLista, ); MediaItem _carpeta(String id, String titulo) => MediaItem( id: id, title: titulo, playable: false, extras: _contentStyleLista, ); /// Leaf items for [parentId], PRESERVING the incoming [emisoras] order and /// capped at [_maxItemsPorCarpeta] (Design "which stations surface & /// ordering" — avoids driver distraction and Auto list limits). /// /// Fix `android-auto-orden`: this used to force /// `ordenarEmisoras(emisoras, OrdenEmisoras.calidad)` unconditionally, /// silently discarding whatever order the caller actually wanted — /// Favoritos' manual drag-reorder order, or the global `ordenListas` /// setting for Todas/Mis emisoras. Every caller (`EstadoRadio. /// cargarFavoritos`/`cargarPopulares`/`_cargarEmisorasCustom`/ /// `cambiarOrdenListas`) now pushes an already-ordered snapshot, so this /// only slices and maps — it must never re-sort. 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 []; return emisoras.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); /// Whether [id] identifies a local-music subfolder (Design "media-id /// scheme"). bool esCarpetaLocalMediaId(String id) => id.startsWith(_prefijoCarpetaLocal); /// Strips the `carpeta_local:` prefix from [id] by length (Design "Prefix /// stripped by length" — survives a raw SAF documentId containing `:`/`/` /// verbatim). Only meaningful when [esCarpetaLocalMediaId] is `true`. String idCarpetaLocalDesde(String id) => id.substring(_prefijoCarpetaLocal.length); /// Whether [id] identifies a paged "load more" local-music request /// (Design ADR-1). bool esCarpetaLocalPaginadaMediaId(String id) => id.startsWith(_prefijoCarpetaLocalPaginada); /// Parses a `carpeta_local_pag::` [id] into its /// `(documentId, pagina)` pair (Design ADR-1): the prefix is stripped by /// length, then the remainder is split on the FIRST `:` only — /// everything left of it is the page integer (never contains a colon), /// everything right of it (including any further colons/slashes) is the /// raw SAF documentId verbatim, so a docId containing `:` or `/` survives /// intact. Root paging is expressible: an empty documentId round-trips as /// `carpeta_local_pag::` (empty tail). Only meaningful when /// [esCarpetaLocalPaginadaMediaId] is `true`. (String documentId, int pagina) paginaCarpetaLocalDesde(String id) { final resto = id.substring(_prefijoCarpetaLocalPaginada.length); final indice = resto.indexOf(':'); final pagina = int.parse(resto.substring(0, indice)); final documentId = resto.substring(indice + 1); return (documentId, pagina); } /// The trailing "load more" `MediaItem` (Design ADR-5): non-playable, no /// `artUri` (the label alone is the affordance, like [_carpeta]), id /// `carpeta_local_pag::` — round-trips /// via [paginaCarpetaLocalDesde] back to the parent folder's next page. /// Rotulado con [EtiquetasArbolAuto.cargarMas]. MediaItem _itemMasLocal(String documentIdPadre, int siguientePagina) => MediaItem( id: '$_prefijoCarpetaLocalPaginada$siguientePagina:$documentIdPadre', title: etiquetas.cargarMas, playable: false, extras: _contentStyleLista, ); /// Maps native [NodoLocal]s to browse-tree `MediaItem`s, paged AND /// metadata-backed (Design "Lazy per-folder enumeration" + ADR-3 /// pagination + Phase 2 "Data Flow"): the full [nodos] list is sorted /// alphabetically by [NodoLocal.nombre] — cheap, no `MediaItem` built yet /// — then sliced to [pagina] via [paginaDe] BEFORE any metadata is /// resolved or `MediaItem` is constructed (Design "slice the cheap list, /// then map — never map-then-slice", the memory-efficiency invariant). /// [metadatosDe] is then awaited for ONLY the sliced page's non-directory /// `documentId`s — never the whole folder — and only THEN is the page /// mapped through [construirItem] with the resolved metadata map. A /// trailing non-playable, browsable "Más…" item is appended whenever /// [hayPaginaSiguiente] says more items remain beyond this page; selecting /// it feeds back into [hijosMusicaLocal] to reveal the next page, so no /// item is ever permanently unreachable (Spec "Local Music Folder Item Cap /// and Paging"). An empty [nodos] (or a stale [pagina] beyond the /// folder's range) returns `[]`, never an error (Spec "browsing an empty /// subfolder"). /// /// [construirItem] is `@visibleForTesting` — injectable ONLY so a test /// spy can assert the exact `min(tamano, remaining)` call-count invariant /// (Design ADR-3); production callers never pass it. Future> itemsLocales( List nodos, { required String documentIdPadre, required Future> Function(List) metadatosDe, int pagina = 0, int tamano = _maxItemsCarpetaLocal, @visibleForTesting MediaItem Function(NodoLocal, Map)? construirItem, // Item 2 (recursive folder play): optional so every pre-existing call // site/test that has no need for the recursive gate keeps working // unchanged. Only used on page 0, and only when [nodos] has zero // DIRECT tracks (a direct track already makes the gate cheaply true // without it) — see the `hayContenidoReproducible` computation below. FuenteMusicaLocalAuto? fuente, }) async { final construir = construirItem ?? _itemLocal; final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion); final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano); final docIds = paginaActual .where((n) => !n.esDirectorio) .map((n) => n.documentId) .toList(); final metadatos = await metadatosDe(docIds); final items = paginaActual.map((n) => construir(n, metadatos)).toList(); if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) { items.add(_itemMasLocal(documentIdPadre, pagina + 1)); } if (pagina == 0) { final totalPistas = nodos.where((n) => !n.esDirectorio).length; // Item 2: a folder plays everything beneath it, recursively -- so // the play actions must be offered whenever the RECURSIVE count is // > 0, not just the direct count. `totalPistas > 0` short-circuits // the bounded recursive walk entirely for the common case (a direct // track already answers the question); only a folder with ZERO // direct tracks but at least one subfolder pays the recursive-check // cost, and only up to [profundidadMaximaRecursivaLocal] levels. final hayContenidoReproducible = totalPistas > 0 || (fuente != null && await _haySubcarpetaConPistas(nodos, fuente: fuente)); final prepend = [ // Folder-play actions (Design ADR-5, Phase 3; recursive gate item // 2): prepended BEFORE the sort/bucket nav entries, present iff // the folder has at least one playable track anywhere beneath it // (direct or nested), absent for a folder that is genuinely empty // even recursively (Spec "Folder has no tracks"). if (hayContenidoReproducible) _itemReproducirCarpeta(documentIdPadre), if (hayContenidoReproducible) _itemReproducirAleatorio(documentIdPadre), if (ofreceOrdenCalidad(totalPistas)) _itemModoOrdenCalidad(documentIdPadre), if (ofreceBuckets(totalPistas)) for (var i = 0; i < _rangosBucket.length; i++) _itemBucket(documentIdPadre, i, _rangosBucket[i].$1), ]; return [...prepend, ...items]; } return items; } /// Whether at least one subfolder within [nodos] recursively contains a /// playable track (Design "recursive folder play, gate", item 2): called /// ONLY when the folder has zero DIRECT tracks (the caller already /// checked that cheaply) — descends into each direct subfolder via /// [pistasRecursivas] with `limite: 1`, stopping at the very first /// match so a folder with an early hit costs as little as possible. /// [nodos] is assumed already resolved by the caller (its own /// `fuente.hijos(...)` result), so this folder's own children are never /// re-fetched. Future _haySubcarpetaConPistas( List nodos, { required FuenteMusicaLocalAuto fuente, }) async { for (final nodo in nodos) { if (!nodo.esDirectorio) continue; final encontradas = await pistasRecursivas( nodo.documentId, fuente: fuente, profundidadMaxima: profundidadMaximaRecursivaLocal - 1, limite: 1, ); if (encontradas.isNotEmpty) return true; } return false; } /// Whether the "Ordenar por calidad" mode entry should be offered for a /// folder with [totalPistas] audio files (Design ADR-3): present for /// `0 < totalPistas <= 150`, omitted otherwise (empty folder or above the /// cap) instead of paying an unbounded metadata-extraction cost. bool ofreceOrdenCalidad(int totalPistas) => totalPistas > 0 && totalPistas <= _maxPistasParaOrdenCalidad; /// Whether alphabetical bucket entries should be offered for a folder /// with [totalPistas] audio files (Design ADR-4): buckets add no value /// for small folders. bool ofreceBuckets(int totalPistas) => totalPistas > _minPistasParaBuckets; /// The "sort by quality" mode-entry `MediaItem` (Design ADR-4): /// non-playable, id `carpeta_local_ord:calidad:0:` — /// always page 0 of the sorted view, round-trips via [ordenLocalDesde]. /// Rotulado con [EtiquetasArbolAuto.ordenarPorCalidad]. MediaItem _itemModoOrdenCalidad(String documentIdPadre) => _carpeta( '${_prefijoCarpetaLocalOrd}calidad:0:$documentIdPadre', etiquetas.ordenarPorCalidad, ); /// A single bucket-folder `MediaItem` (Design ADR-4): non-playable, id /// `carpeta_local_bucket::0:` — always page 0, /// round-trips via [bucketLocalDesde]. /// /// [etiqueta] is an alphabetical RANGE (e.g. `'A-F'`), and it is the one /// user-visible car-tree string that deliberately does NOT go through /// [EtiquetasArbolAuto]: it names the Latin letters the folder's filenames /// actually start with, so translating it would make it lie. MediaItem _itemBucket(String documentIdPadre, int idx, String etiqueta) => _carpeta('$_prefijoCarpetaLocalBucket$idx:0:$documentIdPadre', etiqueta); /// The "play folder" playable action item (Design ADR-5): id /// `carpeta_local_reproducir:`. Rotulado con /// [EtiquetasArbolAuto.reproducirCarpeta]. MediaItem _itemReproducirCarpeta(String documentIdPadre) => MediaItem( id: '$_prefijoCarpetaLocalReproducir$documentIdPadre', title: etiquetas.reproducirCarpeta, playable: true, extras: _contentStyleGrid, ); /// The "shuffle play" playable action item (Design ADR-5), /// mirrors [_itemReproducirCarpeta]. MediaItem _itemReproducirAleatorio(String documentIdPadre) => MediaItem( id: '$_prefijoCarpetaLocalAleatorio$documentIdPadre', title: etiquetas.reproducirAleatorio, playable: true, extras: _contentStyleGrid, ); /// Whether [id] identifies a sort-mode local-music request (Design /// ADR-4, Phase 2). bool esCarpetaLocalOrdMediaId(String id) => id.startsWith(_prefijoCarpetaLocalOrd); /// Whether [id] identifies an alphabetical-bucket local-music request /// (Design ADR-4, Phase 2). bool esCarpetaLocalBucketMediaId(String id) => id.startsWith(_prefijoCarpetaLocalBucket); /// Whether [id] identifies the "Reproducir carpeta" sequential-play /// folder action (Design ADR-5, Phase 3). bool esCarpetaLocalReproducirMediaId(String id) => id.startsWith(_prefijoCarpetaLocalReproducir); /// Whether [id] identifies the "Reproducir aleatorio" shuffled-play /// folder action (Design ADR-5, Phase 3). bool esCarpetaLocalAleatorioMediaId(String id) => id.startsWith(_prefijoCarpetaLocalAleatorio); /// Strips the [_prefijoCarpetaLocalReproducir] prefix from [id] by length /// (Design ADR-5 "strip prefix by length" — no split needed, the single /// tail is the raw SAF documentId verbatim; an empty tail means the local /// root). Only meaningful when [esCarpetaLocalReproducirMediaId] is /// `true`. String idCarpetaLocalReproducirDesde(String id) => id.substring(_prefijoCarpetaLocalReproducir.length); /// Strips the [_prefijoCarpetaLocalAleatorio] prefix from [id] by length, /// mirrors [idCarpetaLocalReproducirDesde]. Only meaningful when /// [esCarpetaLocalAleatorioMediaId] is `true`. String idCarpetaLocalAleatorioDesde(String id) => id.substring(_prefijoCarpetaLocalAleatorio.length); /// Parses a `carpeta_local_ord:::` [id] into its /// `(modo, documentId, pagina)` triple (Design ADR-4): the prefix is /// stripped by length, then the remainder is split on the FIRST two `:` /// only — `modo` never contains a colon, `pagina` never contains a /// colon, and everything after the second `:` (including further /// colons/slashes) is the raw SAF documentId verbatim, mirroring /// [paginaCarpetaLocalDesde]'s split-on-first-colon chain extended by one /// field. Only meaningful when [esCarpetaLocalOrdMediaId] is `true`. (String modo, String documentId, int pagina) ordenLocalDesde(String id) { final resto = id.substring(_prefijoCarpetaLocalOrd.length); final primerColon = resto.indexOf(':'); final modo = resto.substring(0, primerColon); final resto2 = resto.substring(primerColon + 1); final segundoColon = resto2.indexOf(':'); final pagina = int.parse(resto2.substring(0, segundoColon)); final documentId = resto2.substring(segundoColon + 1); return (modo, documentId, pagina); } /// Parses a `carpeta_local_bucket:::` [id] into /// its `(idxBucket, documentId, pagina)` triple (Design ADR-4), mirroring /// [ordenLocalDesde]'s split-on-first-two-colons chain. Only meaningful /// when [esCarpetaLocalBucketMediaId] is `true`. (int idxBucket, String documentId, int pagina) bucketLocalDesde(String id) { final resto = id.substring(_prefijoCarpetaLocalBucket.length); final primerColon = resto.indexOf(':'); final idxBucket = int.parse(resto.substring(0, primerColon)); final resto2 = resto.substring(primerColon + 1); final segundoColon = resto2.indexOf(':'); final pagina = int.parse(resto2.substring(0, segundoColon)); final documentId = resto2.substring(segundoColon + 1); return (idxBucket, documentId, pagina); } /// The trailing "load more" item for the quality-sort view (Design ADR-4, /// mirrors [_itemMasLocal]): id /// `carpeta_local_ord:::`. MediaItem _itemMasLocalOrd( String documentIdPadre, String modo, int siguientePagina, ) => MediaItem( id: '$_prefijoCarpetaLocalOrd$modo:$siguientePagina:$documentIdPadre', title: etiquetas.cargarMas, playable: false, extras: _contentStyleLista, ); /// The trailing "load more" item for a bucket view (Design ADR-4, mirrors /// [_itemMasLocal]): id /// `carpeta_local_bucket:::`. MediaItem _itemMasLocalBucket( String documentIdPadre, int idxBucket, int siguientePagina, ) => MediaItem( id: '$_prefijoCarpetaLocalBucket$idxBucket:$siguientePagina:$documentIdPadre', title: etiquetas.cargarMas, playable: false, extras: _contentStyleLista, ); /// Quality-sort view (Design ADR-3, Data Flow): resolves metadata for /// EVERY audio file in [nodos] via ONE batched [metadatosDe] call /// (full-folder, NOT page-scoped — the sort key requires every track's /// bitrate up front), sorts via [ordenarPorCalidadLocal], THEN applies /// the existing [paginaDe] slicing. Directory nodes are excluded (Design /// "quality sort applies to tracks only"). Future> itemsLocalesOrdenCalidad( List nodos, { required String documentIdPadre, required Future> Function(List) metadatosDe, int pagina = 0, int tamano = _maxItemsCarpetaLocal, @visibleForTesting MediaItem Function(NodoLocal, Map)? construirItem, }) async { final construir = construirItem ?? _itemLocal; final pistas = nodos.where((n) => !n.esDirectorio).toList(); final docIds = pistas.map((n) => n.documentId).toList(); final metadatos = await metadatosDe(docIds); final ordenados = ordenarPorCalidadLocal(pistas, metadatos); final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano); final items = paginaActual.map((n) => construir(n, metadatos)).toList(); if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) { items.add(_itemMasLocalOrd(documentIdPadre, 'calidad', pagina + 1)); } return items; } /// Alphabetical-bucket view (Design ADR-4, Data Flow): partitions /// [nodos] via [bucketsDe] (name-only, cheap), selects [idxBucket], sorts /// that bucket's tracks by name, slices to [pagina], then resolves /// metadata ONLY for the sliced page's docIds (Design "only modo=calidad /// pays the metadata cost" — bucket browsing stays page-scoped like the /// default name-sort view). An out-of-range [idxBucket] returns `[]`, /// never throws. Future> itemsLocalesBucket( List nodos, { required String documentIdPadre, required int idxBucket, required Future> Function(List) metadatosDe, int pagina = 0, int tamano = _maxItemsCarpetaLocal, @visibleForTesting MediaItem Function(NodoLocal, Map)? construirItem, }) async { final buckets = bucketsDe(nodos); if (idxBucket < 0 || idxBucket >= buckets.length) return const []; final construir = construirItem ?? _itemLocal; final ordenados = [...buckets[idxBucket].nodos] ..sort((a, b) => a.nombre.compareTo(b.nombre)); final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano); final docIds = paginaActual .where((n) => !n.esDirectorio) .map((n) => n.documentId) .toList(); final metadatos = await metadatosDe(docIds); final items = paginaActual.map((n) => construir(n, metadatos)).toList(); if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) { items.add(_itemMasLocalBucket(documentIdPadre, idxBucket, pagina + 1)); } return items; } MediaItem _itemLocal(NodoLocal nodo, Map metadatos) { if (nodo.esDirectorio) { return _carpeta('$_prefijoCarpetaLocal${nodo.documentId}', nodo.nombre); } final meta = metadatos[nodo.documentId]; final tituloMeta = meta?.titulo?.trim(); final titulo = (tituloMeta != null && tituloMeta.isNotEmpty) ? tituloMeta : _tituloDesdeNombre(nodo.nombre, etiquetas.pistaSinNombre); final artUriMeta = meta?.artUri?.trim(); final artUri = (artUriMeta != null && artUriMeta.isNotEmpty) ? artUriMeta : artUriLocal(nodo.documentId); final artistaMeta = meta?.artista?.trim(); return MediaItem( id: '$_prefijoPista${nodo.documentId}', title: titulo, artist: (artistaMeta != null && artistaMeta.isNotEmpty) ? artistaMeta : null, playable: true, artUri: Uri.parse(artUri), displaySubtitle: subtituloCalidadLocal(meta), extras: _contentStyleGrid, ); } /// 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), PRESERVING the incoming [favoritos] order (Favoritos' /// manual order — see [hijos]' doc, fix `android-auto-orden`) 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 []; return miembros.take(_maxItemsPorCarpeta).map(itemEmisora).toList(); } /// Equalizer preset-selection media-id prefix (decision /// `auto/ecualizador-diseno`): `eq_preset:` for the six /// factory presets, plus the reserved [_valorDesactivarEq] sentinel for /// the "Desactivar" item ([idDesactivarEq]). Collision-free against every /// other prefix/bare id in this class -- diverges from every sibling /// prefix at the very first character ('e' vs 'g'/'c'/'p') and from every /// bare folder id (none of which starts with "eq_preset:"). static const _prefijoPresetEq = 'eq_preset:'; /// Reserved sentinel raw value for the "Desactivar" item under /// [_prefijoPresetEq] (decision `auto/ecualizador-diseno`) -- never /// collides with a real [PresetEcualizador.nombre]; none of the six /// factory presets is named this. static const _valorDesactivarEq = '_off_'; /// The "Desactivar" item's media id: the reserved [_valorDesactivarEq] /// sentinel under [_prefijoPresetEq]. static const idDesactivarEq = '$_prefijoPresetEq$_valorDesactivarEq'; /// Whether [id] identifies an item under the Ecualizador folder (a /// factory preset OR "Desactivar"). bool esPresetEqMediaId(String id) => id.startsWith(_prefijoPresetEq); /// Whether [id] is specifically the "Desactivar" item (not a factory /// preset). Only meaningful alongside [esPresetEqMediaId]. bool esDesactivarEqMediaId(String id) => id == idDesactivarEq; /// Builds a factory preset's selection media id, matched by raw /// (untranslated) [PresetEcualizador.nombre] -- the SAME identity /// [PresetEcualizador.presets] already uses for equality, so a locale /// change never breaks resolution. String idPresetEq(String nombrePreset) => '$_prefijoPresetEq$nombrePreset'; /// Resolves an `eq_preset:` [id] to the matching factory /// [PresetEcualizador] from [presets] (defaults to /// [PresetEcualizador.presets]), comparing by raw `nombre`. Returns /// `null` for the [_valorDesactivarEq] sentinel, an unresolvable name, or /// any id that doesn't match [esPresetEqMediaId] -- never throws. PresetEcualizador? resolverPresetEq( String id, { List? presets, }) { if (!esPresetEqMediaId(id) || esDesactivarEqMediaId(id)) return null; final nombre = id.substring(_prefijoPresetEq.length); final lista = presets ?? PresetEcualizador.presets; for (final preset in lista) { if (preset.nombre == nombre) return preset; } return null; } } /// Whether [parentMediaId] is content the FREE tier is allowed to browse /// (fix/auto-quality-guidelines, item 10): the browsable root itself, the /// free folder [ConstructorArbolAuto.idDestacadas], and an `emisora:` /// whose uuid belongs to [destacadas]. /// /// Everything else — the catalogue folders, favourites, custom stations, /// local music, the equalizer folder, group folders, local tracks, and any /// station uuid that is not in the free set — is premium content. /// /// Pure and id-shaped, with the free universe INJECTED, so the whole matrix /// is testable without prefs or a handler. bool idPermitidoEnFree( String parentMediaId, { required List destacadas, }) { if (parentMediaId == AudioService.browsableRootId) return true; if (parentMediaId == ConstructorArbolAuto.idDestacadas) return true; if (!parentMediaId.startsWith(_prefijoEmisora)) return false; final uuid = parentMediaId.substring(_prefijoEmisora.length); if (uuid.isEmpty) return false; return destacadas.any((e) => e.uuid == uuid); } /// Pure Android Auto browse-gate decision: the AUTHORITATIVE `getChildren` /// choke point, called BEFORE any other resolution. /// /// REWRITTEN (fix/auto-quality-guidelines, item 10) from action-blocking to /// content-scoping. It used to answer ANY non-root id, for a free-tier user, /// with a single non-playable "Función Premium" row — which is what Google /// Play cited on version code 157 ("clicking on stop button makes the entire /// app useless" was the headline, but the browse tree it was reviewed /// against was four folders that each dead-ended on that row). A /// non-playable row reachable from a head unit's CACHED tree is a citation /// waiting to happen, so there is no longer any code path that can produce /// one: the blocked branch returns the free tier's own playable stations. /// /// Returns `null` when the caller should proceed with its normal resolution /// (premium, or free-tier content the free tier owns). /// /// [destacadas] is the free universe (`resolverEmisorasDestacadas()`); the /// caller resolves it once per browse. Passing an empty list is legal and /// yields an empty blocked response — still never a dead row. List? respuestaBloqueadaPorEntitlement({ required String parentMediaId, required bool premium, required List destacadas, }) { if (premium) return null; if (idPermitidoEnFree(parentMediaId, destacadas: destacadas)) return null; return ConstructorArbolAuto().hijosDestacadas(destacadas); } /// 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"). /// /// RETURNS whether it actually dispatched (fix/auto-quality-guidelines, /// item 12). The caller needs to tell "played" from "resolved to nothing" /// so the second case can publish an explained error to the car instead of /// leaving the driver with a tap that did nothing and said nothing. Future reproducirPorMediaId( String id, { required FuenteEmisorasAuto fuente, required Future Function(MediaItem) reproducir, }) async { final uuid = uuidDeMediaIdEmisora(id); if (uuid == null) return false; final emisora = await fuente.porUuid(uuid); if (emisora == null) return false; final item = MediaItem( id: emisora.url, title: emisora.nombre, artist: emisora.pais ?? '', album: 'PluriWave', // Item 3: reuses [artUriPara] (the SAME fallback the browse tree's // itemEmisora already applies) so the "now playing" media item never // falls back to a blank tile — a real usable favicon still wins, a // missing/unusable one gets the on-brand rotating drawable instead of // `null`. artUri: Uri.parse(artUriPara(emisora)), extras: {'uuid': emisora.uuid}, ); await reproducir(item); return true; } /// The uuid inside an `emisora:` media id, or `null` for any other /// shape — no prefix (a `pista:`/`carpeta_local_*`/`eq_preset:` id, or a /// folder id) and an empty tail both answer `null`. /// /// Extracted (fix/auto-quality-guidelines, item 11) because the play-path /// entitlement gate has to ask the same question `reproducirPorMediaId` asks, /// one step earlier: "is this a station id, and which station?". String? uuidDeMediaIdEmisora(String id) { if (!id.startsWith(_prefijoEmisora)) return null; final uuid = id.substring(_prefijoEmisora.length); return uuid.isEmpty ? null : uuid; } /// A [FuenteEmisorasAuto] over nothing but the free tier's station set /// (fix/auto-quality-guidelines, item 12). /// /// Stands in for `_fuenteNavegacionGlobal` while that is still `null` — the /// window between the headless Android Auto engine starting and `main.dart` /// registering the real source. A tap arriving in that window used to return /// in silence; the free set is compiled into the binary, so it can always be /// answered. /// /// Reports the free stations through [todas] (they are, from the car's point /// of view, everything there is) and nothing through the curated lists, which /// a headless bind could not populate anyway. class FuenteEmisorasAutoDestacadas extends FuenteEmisorasAuto { FuenteEmisorasAutoDestacadas(this._destacadas); final List _destacadas; @override Future> favoritos() async => const []; @override Future> misEmisoras() async => const []; @override Future> todas() async => _destacadas; @override Future> grupos() async => const []; @override Future porUuid(String uuid) async { for (final emisora in _destacadas) { if (emisora.uuid == uuid) return emisora; } return null; } } /// Which list previous/next should walk for [actual]: the NARROWEST context /// the station belongs to. /// /// Tightest first: /// 1. its FAVOURITES GROUP, when it is a favourite filed under a real group, /// 2. all favourites, /// 3. my stations, /// 4. the full catalogue. /// /// The group tier is what the owner asked for: driving with a themed group, /// "next" should stay inside that group rather than wander across every /// favourite. And "next" from a favourite must never land on entry 4,318 of a /// 50,000-station catalogue that happens to sit beside it alphabetically. /// Falling through to [todas] only when the station is in neither curated /// list keeps the button alive for a station reached by search. /// /// [GrupoFavoritos.sinAsignarId] is deliberately NOT treated as a group: it /// is the ABSENCE of one, so those stations walk all favourites instead of a /// bucket that only means "unfiled". A group with a single member also falls /// through to all favourites — otherwise both buttons would be dead ends. /// /// Returns an empty list when [actual] is in none of them, which /// [emisoraVecina] turns into "do nothing". List listaParaSaltoEmisora({ required Emisora actual, required List favoritos, required List misEmisoras, required List todas, }) { final contexto = contextoParaSaltoEmisora( actual: actual, favoritos: favoritos, misEmisoras: misEmisoras, todas: todas, ); if (contexto == null) return const []; switch (contexto.tipo) { case TipoContextoSalto.grupoFavoritos: return favoritos .where((e) => e.grupoFavoritosId == contexto.grupoFavoritosId) .toList(); case TipoContextoSalto.favoritos: return favoritos; case TipoContextoSalto.misEmisoras: return misEmisoras; case TipoContextoSalto.todas: return todas; case TipoContextoSalto.destacadas: // Never produced by [contextoParaSaltoEmisora] — the free set is // resolved by the handler, which owns the entitlement read. return const []; } } /// The same decision as [listaParaSaltoEmisora], NAMED instead of materialised /// — so it can be remembered across a process restart. /// /// The car kills and restarts the engine on every reconnect, and a list of /// stations is not something that survives that: its members change while the /// app is dead. The NAME of the list does survive, which is what /// [ContextoSalto] persists and [resolverListaContexto] re-resolves against /// whatever the lists hold next time. /// /// [listaParaSaltoEmisora] is implemented on top of this so the walked list /// and the remembered context can never disagree (pinned by a test that runs /// both over the same scenarios). /// /// Returns `null` when [actual] belongs to none of the three lists — the /// caller then has no context to remember and leaves playback alone. ContextoSalto? contextoParaSaltoEmisora({ required Emisora actual, required List favoritos, required List misEmisoras, required List todas, }) { Emisora? enLista(List lista) { for (final e in lista) { if (e.uuid == actual.uuid) return e; } return null; } // The FAVOURITE record is the authority on the group, never `actual`: the // playing station is rebuilt from a MediaItem by `emisoraDesdeMediaItem`, // which carries no group id and would always report "sin asignar". final favorita = enLista(favoritos); if (favorita != null) { final grupo = favorita.grupoFavoritosId; if (grupo != GrupoFavoritos.sinAsignarId) { final delGrupo = favoritos.where((e) => e.grupoFavoritosId == grupo).toList(); if (delGrupo.length > 1) return ContextoSalto.grupo(grupo); } return const ContextoSalto.favoritos(); } if (enLista(misEmisoras) != null) return const ContextoSalto.misEmisoras(); if (enLista(todas) != null) return const ContextoSalto.todas(); return null; } /// The station before or after [actual] in [lista], wrapping around at both /// ends. /// /// Wrapping is deliberate: on a car's transport row a button that goes dead /// at the end of a list reads as a broken app, and there is no visible list /// position to explain it. Matching is by `uuid`, the same identity the /// browse tree uses, so a refreshed snapshot with different object instances /// still resolves. /// /// Returns `null` when [lista] has fewer than two entries, or when [actual] /// is not in it — the caller must then leave playback alone rather than jump /// somewhere arbitrary. Emisora? emisoraVecina( Emisora? actual, List lista, { required bool haciaAtras, }) { if (actual == null || lista.length < 2) return null; final indice = lista.indexWhere((e) => e.uuid == actual.uuid); if (indice < 0) return null; final destino = haciaAtras ? (indice - 1 + lista.length) % lista.length : (indice + 1) % lista.length; return lista[destino]; } /// Picks the station a spoken query refers to ("pon Radio Clásica"), over the /// stations the car can already browse. /// /// Pure and source-agnostic so it is testable without a handler. Ranking, best /// first: /// 1. exact name match (case/accent-insensitive), /// 2. name starts with the query, /// 3. name contains the query, /// 4. country contains the query. /// Ties are broken by the order [candidatas] arrives in, which the caller /// composes as favourites → my stations → all, so a favourite always wins over /// a stranger with the same name. /// /// Returns `null` for an empty query or no match — the caller must then do /// nothing rather than play something arbitrary, since a driver who asked for /// a specific station is worse served by a random one than by silence. Emisora? emisoraParaBusqueda(String consulta, List candidatas) { final q = _normalizarBusqueda(consulta); if (q.isEmpty) return null; Emisora? contiene; Emisora? empieza; Emisora? porPais; for (final emisora in candidatas) { final nombre = _normalizarBusqueda(emisora.nombre); if (nombre == q) return emisora; if (empieza == null && nombre.startsWith(q)) { empieza = emisora; } else if (contiene == null && nombre.contains(q)) { contiene = emisora; } else if (porPais == null && _normalizarBusqueda(emisora.pais ?? '').contains(q)) { porPais = emisora; } } return empieza ?? contiene ?? porPais; } /// Lowercase, accent-stripped, collapsed whitespace — a driver saying "radio /// clasica" must match "Radio Clásica", and voice transcription rarely gets /// diacritics right. String _normalizarBusqueda(String texto) { const conAcento = 'áàäâãéèëêíìïîóòöôõúùüûñç'; const sinAcento = 'aaaaaeeeeiiiiooooouuuunc'; final buffer = StringBuffer(); for (final rune in texto.toLowerCase().runes) { final char = String.fromCharCode(rune); final i = conAcento.indexOf(char); buffer.write(i >= 0 ? sinAcento[i] : char); } return buffer.toString().replaceAll(RegExp(r'\s+'), ' ').trim(); } /// Routing seam for a car-tapped `eq_preset:<...>` media id (decision /// `auto/ecualizador-diseno`, mirrors [reproducirPorMediaId]'s seam /// shape): dispatches "Desactivar" to [activarEcualizador]`(false)`, and a /// resolved factory preset to [aplicarPreset] -- turning the equalizer /// back ON via [activarEcualizador]`(true)` AFTERWARDS whenever [activo] /// is currently `false`, so tapping a preset while the equalizer is off /// both re-enables it AND applies the tapped preset's gains (Spec /// "selecting a preset while disabled enables it and applies it"), never /// silently just remembering the preset for later. [aplicarPreset] runs /// BEFORE the enable check so the native engine only ever pushes gains /// once, for the NEW preset -- never once for whatever was active before, /// then again for the new one. /// /// A stale/unresolvable id, or any id that doesn't match /// [ConstructorArbolAuto.esPresetEqMediaId], is a no-op: neither callback /// runs and no exception propagates. /// /// [presets] is the universe the id is resolved against, and it MUST be the /// same list the folder was rendered from (`presetsEcualizadorAuto` in /// `servicio_audio.dart` — factory presets plus the user's saved ones). /// Defaulting to the factory six alone is what made a tapped custom preset a /// silent no-op: the item was listed, but nothing here could resolve it. Future seleccionarPresetEqPorMediaId( String id, { required bool activo, required Future Function(PresetEcualizador) aplicarPreset, required Future Function(bool) activarEcualizador, List? presets, }) async { final constructor = ConstructorArbolAuto(); if (!constructor.esPresetEqMediaId(id)) return; if (constructor.esDesactivarEqMediaId(id)) { await activarEcualizador(false); return; } final preset = constructor.resolverPresetEq(id, presets: presets); if (preset == null) return; await aplicarPreset(preset); if (!activo) await activarEcualizador(true); } /// Filename → display title (Design "Title = filename minus extension"): /// strips the LAST `.ext` (the whole trimmed name is kept when there is no /// dot, or the dot is the first character — e.g. a hidden file like /// `.mp3`), falling back to [sinNombre] when the result would be blank. /// /// [sinNombre] is [EtiquetasArbolAuto.pistaSinNombre], passed in rather than /// hardcoded: it is a title the driver reads, so it is translated like every /// other car-tree label. String _tituloDesdeNombre(String nombre, String sinNombre) { final recortado = nombre.trim(); if (recortado.isEmpty) return sinNombre; final ultimoPunto = recortado.lastIndexOf('.'); final sinExtension = ultimoPunto > 0 ? recortado.substring(0, ultimoPunto) : recortado; final resultado = sinExtension.trim(); return resultado.isEmpty ? sinNombre : resultado; } /// Resolves the on-brand fallback `artUri` for a local track (Design "art = /// reused station_art_* rotation"): reuses the EXACT rotation /// ([indiceArtePara]/`_nombresArte`) [artUriPara] uses for stations, seeded /// by [documentId] instead of a station uuid — zero new assets, same /// deterministic per-item mapping. String artUriLocal(String documentId) => 'android.resource://es.freetimelab.pluriwave/drawable/' 'station_art_${_nombresArte[indiceArtePara(documentId)]}'; /// Bitrate-descending comparator for two resolved [MetadatosPista] (Design /// ADR-3), mirroring [OrdenEmisoras.calidad]'s shape (`orden_emisoras.dart`) /// — no code sharing forced, different types. An unknown bitrate (`null` /// or `<= 0`) always sorts AFTER every known-bitrate entry; two unknowns /// compare equal. Never throws. int compararCalidadLocal(MetadatosPista? a, MetadatosPista? b) { final bitrateA = a?.bitrate; final bitrateB = b?.bitrate; final conocidoA = bitrateA != null && bitrateA > 0; final conocidoB = bitrateB != null && bitrateB > 0; if (!conocidoA && !conocidoB) return 0; if (!conocidoA) return 1; if (!conocidoB) return -1; return bitrateB.compareTo(bitrateA); } /// Returns a bitrate-descending sorted COPY of [nodos] (Design ADR-3), /// resolving each node's bitrate via [metadatos] (keyed by `documentId`) — /// a node absent from [metadatos] (or with a `null`/`<= 0` bitrate) is /// treated as unknown and sorts last, via [compararCalidadLocal]. Never /// throws. List ordenarPorCalidadLocal( List nodos, Map metadatos, ) { final ordenados = List.from(nodos); ordenados.sort( (a, b) => compararCalidadLocal(metadatos[a.documentId], metadatos[b.documentId]), ); return ordenados; } /// Fixed alphabetical bucket ranges (Design "User browses name buckets"): /// `(etiqueta, desde, hasta)`, each a contiguous, lowercase, single-letter /// first-letter range. Shared by [bucketsDe] (partitioning) and /// [ConstructorArbolAuto]'s page-0 prepend wiring (label text). A name /// that doesn't start with an ASCII letter (blank, digit, symbol) never /// matches any of these — the spec doesn't define a catch-all "other" /// bucket. const _rangosBucket = [ ('A-F', 'a', 'f'), ('G-M', 'g', 'm'), ('N-S', 'n', 's'), ('T-Z', 't', 'z'), ]; /// One alphabetical name-bucket's result (Design "User browses name /// buckets"): [etiqueta] is the fixed range label (e.g. `'A-F'`), [nodos] /// is the (possibly empty) list of tracks whose first letter falls in that /// range. class BucketLocal { const BucketLocal({required this.etiqueta, required this.nodos}); final String etiqueta; final List nodos; } /// Partitions [nodos] into the 4 fixed [_rangosBucket] alphabetical ranges /// (Design ADR-4), using ONLY [NodoLocal.nombre] — no metadata dependency, /// so this function structurally cannot call `metadatosDe` (its signature /// doesn't receive one). Directory nodes are excluded (buckets are a /// track-only view). A bucket with zero matches is still returned with an /// empty `nodos` list, never omitted or an error (Spec "Bucket with no /// matching tracks"). List bucketsDe(List nodos) { final pistas = nodos.where((n) => !n.esDirectorio).toList(); return _rangosBucket.map((rango) { final (etiqueta, desde, hasta) = rango; final coincidencias = pistas.where((n) { final recortado = n.nombre.trim(); if (recortado.isEmpty) return false; final letra = recortado[0].toLowerCase(); return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0; }).toList(); return BucketLocal(etiqueta: etiqueta, nodos: coincidencias); }).toList(); } /// The canonical name-sorted audio-children list a folder-play action /// queues (Design ADR-6): directories excluded, sorted by /// `NodoLocal.nombre` — the SAME comparator [ConstructorArbolAuto.itemsLocales] /// already applies to the browse-tree page-0 view, so "Reproducir /// carpeta"'s play order matches what the driver sees when browsing /// normally. Returns a NEW list; never mutates [nodos]. List pistasEnOrdenNombre(List nodos) { final pistas = nodos.where((n) => !n.esDirectorio).toList(); pistas.sort((a, b) => a.nombre.compareTo(b.nombre)); return pistas; } /// Fisher-Yates shuffle (Design ADR-6) over a COPY of [nodos] — never /// mutates the input list. [rng] is injected so tests can pass a /// fixed-seed `Random` for deterministic permutation assertions; /// production callers pass `Random()`. List mezclarFisherYates(List nodos, Random rng) { final resultado = List.from(nodos); for (var i = resultado.length - 1; i > 0; i--) { final j = rng.nextInt(i + 1); final tmp = resultado[i]; resultado[i] = resultado[j]; resultado[j] = tmp; } return resultado; } /// The shuffled audio-children list "Reproducir aleatorio" queues (Design /// ADR-6): Fisher-Yates over [pistasEnOrdenNombre]'s canonical order — NOT /// the native enumeration order (not guaranteed stable) — so the resulting /// permutation is reproducible under a fixed [rng] seed. List pistasEnOrdenAleatorio(List nodos, Random rng) => mezclarFisherYates(pistasEnOrdenNombre(nodos), rng); /// Maximum recursion depth for "play folder recursively" (Design "recursive /// folder play, cost bound", item 2): SAF directory listing is a native /// round-trip PER folder, so unbounded recursion could turn a single tap /// into dozens of channel calls for a pathologically deep tree. 4 levels /// below the tapped folder covers virtually every real music-library /// layout (even `Artist/Album/Disc/track.mp3` is only 3 levels deep) while /// keeping a worst-case tree's native-call count bounded. A subfolder /// beyond this depth is simply never explored — its tracks are not /// collected, exactly like content beyond the browse tree's own page cap /// is never listed. const profundidadMaximaRecursivaLocal = 4; /// Maximum number of tracks collected by a recursive folder walk (Design /// "recursive folder play, cost bound", item 2): a folder-play/shuffle /// queue beyond a few hundred tracks has no practical benefit, and an /// unbounded collection risks an extremely long queue AND an extremely /// long recursive walk over a huge library. 500 is an order of magnitude /// above the existing quality-sort cap /// ([ConstructorArbolAuto._maxPistasParaOrdenCalidad], 150) — generous for /// a "play everything" action, while still bounded. const limitePistasRecursivasLocal = 500; /// Recursively collects every audio-file [NodoLocal] reachable from /// [documentId] (Design "recursive folder play", item 2): [documentId]'s /// own direct audio children, plus — for every direct subfolder — that /// subfolder's own recursive result. Walked depth-first, sorted by /// [NodoLocal.nombre] at each level (the SAME comparator the sequential/ /// shuffle play actions already used pre-recursion), so the collected /// order is deterministic and reproducible under a fixed shuffle seed. /// /// Bounded on two independent axes so a pathological tree (very deep, or /// very wide-and-deep) can never turn a single tap into an unbounded /// number of native SAF round-trips or an unbounded in-memory list: /// - [profundidadMaxima] caps how many folder levels BELOW [documentId] /// are ever descended into (`0` = only [documentId]'s own direct /// children, no descent at all). /// - [limite] caps the TOTAL number of tracks collected across the whole /// walk; collection stops (mid-folder if needed) the instant this many /// have been gathered. /// /// Never throws: a [fuente.hijos] failure on any one subfolder (revoked /// permission, a race with the OS SAF layer) is swallowed for that /// subfolder only — sibling folders already queued for traversal are /// still visited — mirroring this file's existing no-throw contract /// (Design "no-op on empty/unresolvable folder"). Future> pistasRecursivas( String documentId, { required FuenteMusicaLocalAuto fuente, int profundidadMaxima = profundidadMaximaRecursivaLocal, int limite = limitePistasRecursivasLocal, }) async { final resultado = []; await _recolectarPistasRecursivas( documentId, fuente: fuente, profundidadRestante: profundidadMaxima, limite: limite, resultado: resultado, ); return resultado; } Future _recolectarPistasRecursivas( String documentId, { required FuenteMusicaLocalAuto fuente, required int profundidadRestante, required int limite, required List resultado, }) async { if (resultado.length >= limite) return; final List hijos; try { hijos = await fuente.hijos(documentId); } catch (_) { return; } final ordenados = [...hijos]..sort((a, b) => a.nombre.compareTo(b.nombre)); for (final nodo in ordenados) { if (resultado.length >= limite) return; if (nodo.esDirectorio) { if (profundidadRestante <= 0) continue; await _recolectarPistasRecursivas( nodo.documentId, fuente: fuente, profundidadRestante: profundidadRestante - 1, limite: limite, resultado: resultado, ); } else { resultado.add(nodo); } } } /// Orchestrates a "Reproducir carpeta"/"Reproducir aleatorio" tap (Design /// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2; recursive collection item /// 2): resolves whichever of the two action prefixes matches [id] /// (ignoring [aleatorio] for the STRIP — the prefix itself is /// authoritative), RECURSIVELY collects every track beneath that folder /// via [pistasRecursivas] (direct children AND every nested subfolder, up /// to its depth/count bounds), orders them ([aleatorio] picks shuffled vs /// the recursive walk's own name-sorted order), and hands the resulting /// list to [iniciarCola]. /// /// A no-op (never calls [iniciarCola]) when: [id] matches neither action /// prefix; the folder (or everything beneath it, within the recursion /// bounds) is unresolvable/empty (Design "no-op on empty/unresolvable /// folder") — [pistasRecursivas] never throws, so this never propagates an /// exception either. Future reproducirCarpetaLocal( String id, { required bool aleatorio, required FuenteMusicaLocalAuto fuente, Random? rng, required Future Function(List pistas) iniciarCola, }) async { final constructor = ConstructorArbolAuto(); final String documentId; if (constructor.esCarpetaLocalReproducirMediaId(id)) { documentId = constructor.idCarpetaLocalReproducirDesde(id); } else if (constructor.esCarpetaLocalAleatorioMediaId(id)) { documentId = constructor.idCarpetaLocalAleatorioDesde(id); } else { return; } final recolectadas = await pistasRecursivas(documentId, fuente: fuente); final pistas = aleatorio ? mezclarFisherYates(recolectadas, rng ?? Random()) : recolectadas; if (pistas.isEmpty) return; await iniciarCola(pistas); } /// Resolves [nodo]'s playable content URI via [fuente] and builds the /// `MediaItem` the local-queue layer plays (Design Data Flow /// "construirMediaItemColaLocal (resolve URI)"), reusing the SAME /// title-derivation [reproducirPistaLocal] uses ([_tituloDesdeDocumentId]) /// so a queue track's Now Playing title matches what a directly-tapped /// single track would show. Returns `null` when the content URI cannot be /// resolved (stale id, revoked permission, moved file) — the caller treats /// that as "cannot play this entry", never a crash. Future construirMediaItemColaLocal( NodoLocal nodo, { required FuenteMusicaLocalAuto fuente, EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo, }) async { final contentUri = await fuente.uriContenidoDePista(nodo.documentId); if (contentUri == null || contentUri.isEmpty) return null; return MediaItem( id: contentUri, title: _tituloDesdeDocumentId(nodo.documentId, etiquetas.pistaSinNombre), album: 'PluriWave', // Item 3: a queued local track had NO artUri at all before — reuses // [artUriLocal] (the SAME on-brand rotation the browse tree's // `_itemLocal` already falls back to) so the car's now-playing screen // never shows a blank tile for a track with no embedded art. artUri: Uri.parse(artUriLocal(nodo.documentId)), extras: {'documentId': nodo.documentId}, ); } /// Local-music `getChildren` dispatch (Design "Data Flow"): resolves /// [parentMediaId] against the `musica_local` root (`fuente.hijos('')`) or a /// `carpeta_local:` subfolder (`fuente.hijos(id)`), mapping the result /// through [ConstructorArbolAuto.itemsLocales]. Returns `null` when /// [parentMediaId] matches NEITHER shape, so the caller /// (`ServicioAudio.getChildren`) can fall through to its other branches /// unmodified. A `null` [fuente] (local source never registered — headless /// cold bind) or any thrown error degrades to `[]`, never a crash (Design /// "cold-start safe", mirrors `FuenteEmisorasAutoLocal`'s pattern; Spec /// "Browse requested before app state is loaded" / "Permission revoked or /// never granted"). /// Session-scoped metadata cache shared across every `hijosMusicaLocal` /// call (Design ADR-2) — module-level singleton, mirroring /// `servicio_audio.dart`'s `_fuenteMusicaLocalGlobal` pattern: paging a /// large folder across multiple `getChildren` calls must NOT evict an /// earlier page's cached metadata, which requires the cache to outlive any /// single call. final CacheMetadatosSesion _cacheMetadatosLocal = CacheMetadatosSesion(); /// Wraps [fuente]'s raw `metadatosDe` with [_cacheMetadatosLocal] (Design /// "Data Flow" — `metadatosDe(slice.trackDocIds) — CacheMetadatosSesion /// hit? else readAudioMetadataBatch`): resolves cache hits locally without /// a channel round trip, batches ONLY the cache misses through /// [FuenteMusicaLocalAuto.metadatosDe], and stores every freshly-resolved /// entry back into the cache before returning the combined map. Future> _metadatosDeConCache( List documentIds, { required FuenteMusicaLocalAuto fuente, }) async { if (documentIds.isEmpty) return const {}; final resultado = {}; final faltantes = []; for (final id in documentIds) { final cacheado = _cacheMetadatosLocal.obtener(id); if (cacheado != null) { resultado[id] = cacheado; } else { faltantes.add(id); } } if (faltantes.isNotEmpty) { final resueltos = await fuente.metadatosDe(faltantes); resueltos.forEach((id, metadatos) { _cacheMetadatosLocal.guardar(id, metadatos); resultado[id] = metadatos; }); } return resultado; } Future?> hijosMusicaLocal( String parentMediaId, { required FuenteMusicaLocalAuto? fuente, EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo, }) async { final constructor = ConstructorArbolAuto(etiquetas: etiquetas); // Sort-mode and bucket views (Design ADR-4, Phase 2) are routed FIRST — // routing order is irrelevant to correctness (every prefix in this file // is collision-free, see each prefix's doc comment), but checking the // more specific new prefixes first keeps this dispatch readable. if (constructor.esCarpetaLocalOrdMediaId(parentMediaId)) { final (modo, documentId, pagina) = constructor.ordenLocalDesde( parentMediaId, ); if (fuente == null) return const []; try { final nodos = await fuente.hijos(documentId); if (modo != 'calidad') return const []; return await constructor.itemsLocalesOrdenCalidad( nodos, documentIdPadre: documentId, pagina: pagina, metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente), ); } catch (_) { return const []; } } if (constructor.esCarpetaLocalBucketMediaId(parentMediaId)) { final (idxBucket, documentId, pagina) = constructor.bucketLocalDesde( parentMediaId, ); if (fuente == null) return const []; try { final nodos = await fuente.hijos(documentId); return await constructor.itemsLocalesBucket( nodos, documentIdPadre: documentId, idxBucket: idxBucket, pagina: pagina, metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente), ); } catch (_) { return const []; } } final String documentId; var pagina = 0; if (parentMediaId == ConstructorArbolAuto.idMusicaLocal) { documentId = ''; } else if (constructor.esCarpetaLocalPaginadaMediaId(parentMediaId)) { final resuelto = constructor.paginaCarpetaLocalDesde(parentMediaId); documentId = resuelto.$1; pagina = resuelto.$2; } else if (constructor.esCarpetaLocalMediaId(parentMediaId)) { documentId = constructor.idCarpetaLocalDesde(parentMediaId); } else { return null; } if (fuente == null) return const []; try { final nodos = await fuente.hijos(documentId); final items = await constructor.itemsLocales( nodos, documentIdPadre: documentId, pagina: pagina, metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente), fuente: fuente, ); // fix/android-auto-musica-local: si no salió NADA, el motivo importa. // Con el canal nativo caído (motor sin Activity) `hijos` degrada a `[]` // igual que una carpeta realmente vacía, y una carpeta vacía en el // coche se lee como «no tengo música». El estado se consulta SOLO en // ese caso vacío, así que la ruta normal no paga ningún round trip // extra. if (items.isEmpty && await fuente.estadoCarpeta() == EstadoCarpetaLocal.canalNoDisponible) { return [constructor.itemLocalNoDisponible()]; } return items; } catch (_) { return const []; } } /// Best-effort title for a played local track (Design "Local Track Playback /// Reuses Existing Pipeline"): `FuenteMusicaLocalAuto.uriContenidoDePista` /// only returns a content URI, not the original filename (Design's /// Interfaces/Contracts — no metadata fields in Phase 1), so this derives a /// title from the trailing path segment of the SAF [documentId] itself /// (`primary:Music/Local/song.mp3` → `song.mp3` → title-stripped), applying /// the SAME [_tituloDesdeNombre] rule the browse tree uses. This keeps the /// Now Playing title consistent with what the user tapped without requiring /// a second native round trip. String _tituloDesdeDocumentId(String documentId, String sinNombre) { final ultimaBarra = documentId.lastIndexOf('/'); final segmento = ultimaBarra >= 0 ? documentId.substring(ultimaBarra + 1) : documentId; return _tituloDesdeNombre(segmento, sinNombre); } /// Routing seam between a car-tapped `pista:` media id and the /// existing playback pipeline (Design "Local Track Playback Reuses Existing /// Pipeline" — same seam shape as [reproducirPorMediaId], Spec "User selects /// a local track"). Resolves the content URI via [fuente], builds a /// `MediaItem` and delegates to [reproducir] — the SAME injection point /// stations use, so the shared EQ signal chain applies identically (Spec /// "EQ still applies to local track playback", regression guard: no /// separate/bypassed path exists here). /// /// A stale/unknown [id] (or a malformed one) is a no-op: [reproducir] is /// never called and no exception propagates (Spec "Unknown or stale track /// id"). Future reproducirPistaLocal( String id, { required FuenteMusicaLocalAuto fuente, required Future Function(MediaItem) reproducir, EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo, }) async { if (!esPistaMediaId(id)) return; final documentId = id.substring(_prefijoPista.length); if (documentId.isEmpty) return; final contentUri = await fuente.uriContenidoDePista(documentId); if (contentUri == null || contentUri.isEmpty) return; final pista = PistaLocal( documentId: documentId, titulo: _tituloDesdeDocumentId(documentId, etiquetas.pistaSinNombre), contentUri: contentUri, ); final item = MediaItem( id: pista.contentUri, title: pista.titulo, album: 'PluriWave', // Item 3: same fallback as construirMediaItemColaLocal, for a track // tapped directly (not via a folder-play queue). artUri: Uri.parse(artUriLocal(pista.documentId)), extras: {'documentId': pista.documentId}, ); 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; 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 []; } /// Resolves a station uuid across every list this source can reach. /// /// The free tier's set ([resolverEmisorasDestacadas]) is searched LAST /// (fix/auto-quality-guidelines, item 7). It has to be searched at all /// because on a cold headless bind the three lists above are all empty — /// `todas()` is `_snapshotTodas ?? const []`, favourites and custom /// stations have nothing persisted on a fresh install — so a curated /// `emisora:` resolved to `null` and tapping the row did NOTHING. /// It is searched last so a live catalogue/favourite record for the same /// uuid (richer metadata, the user's own group assignment) still wins. @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; } } for (final emisora in await resolverEmisorasDestacadas()) { 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'; } }