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 '../estado/orden_emisoras.dart'; import '../modelos/emisora.dart'; import '../modelos/grupo_favoritos.dart'; import '../modelos/pista_local.dart'; import '../modelos/preset_ecualizador.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, }) {} } /// 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 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'; 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 (Favoritos, Todas las emisoras, Mis emisoras, /// optionally Música Local, Ecualizador), all non-playable. /// /// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer /// folder" rule that used to live in this doc comment (commit `2403da3`, /// mirroring the redesign mockup's "sin carpeta de ecualizador", turn t4 /// line 40). That rule was sound when written, but predated on-device /// feedback showing that Android Auto custom actions don't surface /// enough state for choosing among six presets: a monochrome icon cannot /// legibly encode "which preset", and many head units render a custom /// action icon-first, hiding its label. `Ecualizador` is a real /// browsable folder again: "Desactivar" first, then the six factory /// presets, the active one marked (children built by /// `itemsEcualizadorAuto` in `servicio_audio.dart` -- this class stays /// free of any `AppLocalizations` dependency, unlike that builder). /// Always present, and LAST in the list (after Música Local, when /// included) -- unlike [idMusicaLocal] it is never conditionally hidden. /// Do not "restore" the no-folder rule without re-reading that decision. /// /// `Música Local` is OMITTED entirely (not just empty) unless /// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder /// is configured") — the caller passes `fuente.hayCarpetaConfigurada()`, /// keeping this builder itself synchronous and side-effect free. List raiz({required bool incluirMusicaLocal}) => [ _carpeta(idFavoritos, 'Favoritos'), _carpeta(idTodas, 'Todas las emisoras'), _carpeta(idMisEmisoras, 'Mis emisoras'), if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'), _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); /// 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); } /// Hardcoded-Spanish car-tree label for the trailing "load more" item /// (Design ADR-5) — matches every other car-tree label in this file /// (`'Favoritos'`, `'Música Local'`, [_tituloLocalFallback]), none of /// which go through `AppLocalizations`. Deliberately NOT an arb key. static const _tituloMasLocal = 'Más…'; /// 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. MediaItem _itemMasLocal(String documentIdPadre, int siguientePagina) => MediaItem( id: '$_prefijoCarpetaLocalPaginada$siguientePagina:$documentIdPadre', title: _tituloMasLocal, 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 "Ordenar por calidad" 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]. /// Hardcoded Spanish label, matching every other car-tree label in this /// file — never routed through `AppLocalizations` (established /// car-tree-label precedent, see [_tituloMasLocal]). MediaItem _itemModoOrdenCalidad(String documentIdPadre) => _carpeta( '${_prefijoCarpetaLocalOrd}calidad:0:$documentIdPadre', 'Ordenar por calidad', ); /// A single bucket-folder `MediaItem` (Design ADR-4): non-playable, id /// `carpeta_local_bucket::0:` — always page 0, /// round-trips via [bucketLocalDesde]. [etiqueta] is the hardcoded /// alphabetical-range label (e.g. `'A-F'`), matching every other /// car-tree label in this file — never routed through `AppLocalizations`. MediaItem _itemBucket(String documentIdPadre, int idx, String etiqueta) => _carpeta('$_prefijoCarpetaLocalBucket$idx:0:$documentIdPadre', etiqueta); /// The "Reproducir carpeta" playable action item (Design ADR-5): id /// `carpeta_local_reproducir:`. Hardcoded Spanish label, /// matching every other car-tree label in this file — never routed /// through `AppLocalizations`. MediaItem _itemReproducirCarpeta(String documentIdPadre) => MediaItem( id: '$_prefijoCarpetaLocalReproducir$documentIdPadre', title: 'Reproducir carpeta', playable: true, extras: _contentStyleGrid, ); /// The "Reproducir aleatorio" playable action item (Design ADR-5), /// mirrors [_itemReproducirCarpeta]. MediaItem _itemReproducirAleatorio(String documentIdPadre) => MediaItem( id: '$_prefijoCarpetaLocalAleatorio$documentIdPadre', title: 'Reproducir aleatorio', 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: _tituloMasLocal, 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: _tituloMasLocal, 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); 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), 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(); } /// 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; } } /// 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', // 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); } /// 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. Future seleccionarPresetEqPorMediaId( String id, { required bool activo, required Future Function(PresetEcualizador) aplicarPreset, required Future Function(bool) activarEcualizador, }) async { final constructor = ConstructorArbolAuto(); if (!constructor.esPresetEqMediaId(id)) return; if (constructor.esDesactivarEqMediaId(id)) { await activarEcualizador(false); return; } final preset = constructor.resolverPresetEq(id); if (preset == null) return; await aplicarPreset(preset); if (!activo) await activarEcualizador(true); } /// Fallback title (Design "Title = filename minus extension") for a blank /// or otherwise empty-after-stripping local filename — hardcoded Spanish, /// matching every other car-tree label in this file (`'Favoritos'`, /// `'Música Local'`, etc.), none of which go through `AppLocalizations`. const _tituloLocalFallback = 'Pista sin nombre'; /// 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 [_tituloLocalFallback] when the result would be /// blank. String _tituloDesdeNombre(String nombre) { final recortado = nombre.trim(); if (recortado.isEmpty) return _tituloLocalFallback; final ultimoPunto = recortado.lastIndexOf('.'); final sinExtension = ultimoPunto > 0 ? recortado.substring(0, ultimoPunto) : recortado; final resultado = sinExtension.trim(); return resultado.isEmpty ? _tituloLocalFallback : 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, }) async { final contentUri = await fuente.uriContenidoDePista(nodo.documentId); if (contentUri == null || contentUri.isEmpty) return null; return MediaItem( id: contentUri, title: _tituloDesdeDocumentId(nodo.documentId), 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, }) async { final constructor = ConstructorArbolAuto(); // 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); return await constructor.itemsLocales( nodos, documentIdPadre: documentId, pagina: pagina, metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente), fuente: fuente, ); } 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) { final ultimaBarra = documentId.lastIndexOf('/'); final segmento = ultimaBarra >= 0 ? documentId.substring(ultimaBarra + 1) : documentId; return _tituloDesdeNombre(segmento); } /// 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, }) 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), 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 []; } @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'; } }