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:
2026-07-19 23:52:08 +02:00
parent e030a0975d
commit 352eb9fc37
13 changed files with 2470 additions and 82 deletions
+79
View File
@@ -1,3 +1,5 @@
import 'dart:collection';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -90,6 +92,50 @@ abstract class FuenteMusicaLocalAuto {
/// `null` if it cannot be resolved (stale id, revoked permission). Never
/// throws.
Future<String?> uriContenidoDePista(String documentId);
/// Batched embedded-metadata resolution (Design "Interfaces / Contracts",
/// Phase 2) for [documentIds] — one map entry per requested id that was
/// resolvable. Never throws: an empty [documentIds], a missing root
/// folder, or any channel failure degrades to `{}`. A native row with a
/// null/missing field yields a [MetadatosPista] with that field `null`,
/// never a crash or a dropped entry.
Future<Map<String, MetadatosPista>> metadatosDe(List<String> documentIds);
}
/// In-memory, session-scoped LRU cache of resolved [MetadatosPista] (Design
/// ADR-2): a flat `LinkedHashMap`, bounded to [_capacidad] entries,
/// LRU-by-ACCESS (not just insertion) — [obtener] on a hit re-inserts the
/// entry to refresh its recency, so a hot re-visited entry survives even
/// under eviction pressure. Deliberately NOT folder-scoped: paging a large
/// folder must not evict an earlier page's cached metadata (Design ADR-2's
/// rationale — 256 ≈ 5 pages of 50). In-memory only; dies with the process,
/// so there is no persistence-staleness concern.
class CacheMetadatosSesion {
static const _capacidad = 256;
final LinkedHashMap<String, MetadatosPista> _entradas =
LinkedHashMap<String, MetadatosPista>();
/// Returns the cached [MetadatosPista] for [documentId], or `null` on a
/// miss. A hit refreshes [documentId]'s recency (moves it to the
/// most-recently-used end) so it survives longer under LRU eviction.
MetadatosPista? obtener(String documentId) {
final valor = _entradas.remove(documentId);
if (valor == null) return null;
_entradas[documentId] = valor;
return valor;
}
/// Stores [metadatos] under [documentId], refreshing its recency.
/// Evicts the least-recently-used entry (the current first key) when
/// insertion would exceed [_capacidad].
void guardar(String documentId, MetadatosPista metadatos) {
_entradas.remove(documentId);
_entradas[documentId] = metadatos;
if (_entradas.length > _capacidad) {
_entradas.remove(_entradas.keys.first);
}
}
}
/// Channel-backed [FuenteMusicaLocalAuto] implementation (Design "Hand-rolled
@@ -202,6 +248,39 @@ class FuenteMusicaLocalAutoImpl implements FuenteMusicaLocalAuto {
}
}
@override
Future<Map<String, MetadatosPista>> metadatosDe(
List<String> documentIds,
) async {
if (documentIds.isEmpty) return const {};
try {
final uri = await _uriPersistida();
if (uri == null || uri.isEmpty) return const {};
final crudos = await _canal.invokeMethod<List<Object?>>(
'readAudioMetadataBatch',
{'treeUri': uri, 'documentIds': documentIds},
);
if (crudos == null) return const {};
final resultado = <String, MetadatosPista>{};
for (final fila in crudos.whereType<Map<Object?, Object?>>()) {
final documentId = fila['documentId'] as String?;
if (documentId == null || documentId.isEmpty) continue;
resultado[documentId] = MetadatosPista(
titulo: fila['titulo'] as String?,
artista: fila['artista'] as String?,
artUri: fila['artUri'] as String?,
bitrate: (fila['bitrate'] as num?)?.toInt(),
sampleRate: (fila['sampleRate'] as num?)?.toInt(),
);
}
return resultado;
} catch (_) {
// Cold-start / revoked-permission / channel-error safety (Design
// "Interfaces / Contracts" — metadatosDe never throws).
return const {};
}
}
/// Maps a raw `listAudioChildren` row to a [NodoLocal], re-validating
/// audio files via [esArchivoAudio] (Design "Interfaces / Contracts" —
/// defense-in-depth on top of the native `audio/*` filter). Returns `null`