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:
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user