feat(auto): real metadata, quality sort and name buckets for local music [size:exception]
Local tracks now show embedded title/artist/album art (via native MediaMetadataRetriever, cached through the existing FileProvider) instead of the raw filename, falling back gracefully when a file has no usable tags. Adds two navigable entry points per folder: sort by audio quality (bitrate, capped at 150 tracks per folder to bound worst-case latency) and alphabetical name buckets -- the closest realistic form of "filtering" given Android Auto has no text-search UI in this integration. Metadata resolves only for the page actually being browsed (same slice-cheap-then-map discipline as the paging change), backed by a flat 256-entry LRU session cache that survives across pages. No new permission, no new pub dependency, no l10n changes (car-tree labels stay hardcoded Spanish, matching every existing label in the tree).
This commit is contained in:
@@ -120,6 +120,42 @@ String? subtituloCalidad(Emisora e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Formats a human-readable quality hint for a local track's
|
||||
/// `displaySubtitle` (Design ADR-5 "unknown → omit" discipline, reused for
|
||||
/// local tracks): `"<bitrate> kbps · <sampleRate> 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
|
||||
@@ -198,6 +234,20 @@ class ConstructorArbolAuto {
|
||||
/// 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:<modo>:<pagina>:<docId>`. 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:<idxBucket>:<pagina>:<docId>`. 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:';
|
||||
|
||||
/// 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
|
||||
@@ -210,6 +260,16 @@ class ConstructorArbolAuto {
|
||||
/// 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 = {
|
||||
@@ -342,50 +402,254 @@ class ConstructorArbolAuto {
|
||||
extras: _contentStyleLista,
|
||||
);
|
||||
|
||||
/// Maps native [NodoLocal]s to browse-tree `MediaItem`s, paged (Design
|
||||
/// "Lazy per-folder enumeration" + ADR-3 pagination): the full [nodos]
|
||||
/// list is sorted alphabetically by [NodoLocal.nombre] — cheap, no
|
||||
/// `MediaItem` built yet — then sliced to [pagina] via [paginaDe] BEFORE
|
||||
/// any `MediaItem` is constructed, and only that slice (at most [tamano]
|
||||
/// entries) is mapped through [construirItem] (Design "slice the cheap
|
||||
/// list, then map — never map-then-slice", the memory-efficiency
|
||||
/// invariant). 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").
|
||||
/// 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.
|
||||
List<MediaItem> itemsLocales(
|
||||
Future<List<MediaItem>> itemsLocales(
|
||||
List<NodoLocal> nodos, {
|
||||
required String documentIdPadre,
|
||||
required Future<Map<String, MetadatosPista>> Function(List<String>)
|
||||
metadatosDe,
|
||||
int pagina = 0,
|
||||
int tamano = _maxItemsCarpetaLocal,
|
||||
@visibleForTesting MediaItem Function(NodoLocal)? construirItem,
|
||||
}) {
|
||||
@visibleForTesting
|
||||
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
|
||||
}) async {
|
||||
final construir = construirItem ?? _itemLocal;
|
||||
final ordenados = [...nodos]..sort((a, b) => a.nombre.compareTo(b.nombre));
|
||||
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
|
||||
final items = paginaActual.map(construir).toList();
|
||||
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;
|
||||
final prepend = <MediaItem>[
|
||||
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;
|
||||
}
|
||||
|
||||
MediaItem _itemLocal(NodoLocal nodo) {
|
||||
/// 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:<documentIdPadre>` —
|
||||
/// 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:<idx>:0:<documentIdPadre>` — 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);
|
||||
|
||||
/// 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);
|
||||
|
||||
/// Parses a `carpeta_local_ord:<modo>:<pagina>:<docId>` [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:<idxBucket>:<pagina>:<docId>` [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:<modo>:<siguientePagina>:<documentIdPadre>`.
|
||||
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:<idx>:<siguientePagina>:<documentIdPadre>`.
|
||||
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<List<MediaItem>> itemsLocalesOrdenCalidad(
|
||||
List<NodoLocal> nodos, {
|
||||
required String documentIdPadre,
|
||||
required Future<Map<String, MetadatosPista>> Function(List<String>)
|
||||
metadatosDe,
|
||||
int pagina = 0,
|
||||
int tamano = _maxItemsCarpetaLocal,
|
||||
@visibleForTesting
|
||||
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? 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<List<MediaItem>> itemsLocalesBucket(
|
||||
List<NodoLocal> nodos, {
|
||||
required String documentIdPadre,
|
||||
required int idxBucket,
|
||||
required Future<Map<String, MetadatosPista>> Function(List<String>)
|
||||
metadatosDe,
|
||||
int pagina = 0,
|
||||
int tamano = _maxItemsCarpetaLocal,
|
||||
@visibleForTesting
|
||||
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? 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<String, MetadatosPista> 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: _tituloDesdeNombre(nodo.nombre),
|
||||
title: titulo,
|
||||
artist: (artistaMeta != null && artistaMeta.isNotEmpty)
|
||||
? artistaMeta
|
||||
: null,
|
||||
playable: true,
|
||||
artUri: Uri.parse(artUriLocal(nodo.documentId)),
|
||||
artUri: Uri.parse(artUri),
|
||||
displaySubtitle: subtituloCalidadLocal(meta),
|
||||
extras: _contentStyleGrid,
|
||||
);
|
||||
}
|
||||
@@ -515,6 +779,87 @@ 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<NodoLocal> ordenarPorCalidadLocal(
|
||||
List<NodoLocal> nodos,
|
||||
Map<String, MetadatosPista> metadatos,
|
||||
) {
|
||||
final ordenados = List<NodoLocal>.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<NodoLocal> 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<BucketLocal> bucketsDe(List<NodoLocal> 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();
|
||||
}
|
||||
|
||||
/// Local-music `getChildren` dispatch (Design "Data Flow"): resolves
|
||||
/// [parentMediaId] against the `musica_local` root (`fuente.hijos('')`) or a
|
||||
/// `carpeta_local:<id>` subfolder (`fuente.hijos(id)`), mapping the result
|
||||
@@ -526,11 +871,92 @@ String artUriLocal(String documentId) =>
|
||||
/// "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<Map<String, MetadatosPista>> _metadatosDeConCache(
|
||||
List<String> documentIds, {
|
||||
required FuenteMusicaLocalAuto fuente,
|
||||
}) async {
|
||||
if (documentIds.isEmpty) return const {};
|
||||
final resultado = <String, MetadatosPista>{};
|
||||
final faltantes = <String>[];
|
||||
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<List<MediaItem>?> 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) {
|
||||
@@ -547,10 +973,11 @@ Future<List<MediaItem>?> hijosMusicaLocal(
|
||||
if (fuente == null) return const [];
|
||||
try {
|
||||
final nodos = await fuente.hijos(documentId);
|
||||
return constructor.itemsLocales(
|
||||
return await constructor.itemsLocales(
|
||||
nodos,
|
||||
documentIdPadre: documentId,
|
||||
pagina: pagina,
|
||||
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
|
||||
);
|
||||
} catch (_) {
|
||||
return const [];
|
||||
|
||||
Reference in New Issue
Block a user