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).
10 KiB
Design: Android Auto Local Music — Phase 2 (Metadata, Sort, Name Buckets)
Technical Approach
Compose real metadata onto the Phase 1 lazy-paging browse tree WITHOUT regressing the
itemsLocales slice-cheap-then-map invariant. Raw SAF enumeration (hijos →
NodoLocal list) stays cheap and eager-free. Metadata (expensive, async) is resolved
via ONE new batched native call on the existing pluriwave/file_actions channel
(readAudioMetadataBatch, MediaMetadataRetriever), and ONLY for the exact docIds of
the page being returned — mirroring how art/MediaItem construction is already page-scoped.
All real logic (LRU cache eviction, quality comparator, bucket partitioning, media-id
codec) lives in pure Dart; native surface stays a thin per-file extract-and-return loop.
Architecture Decisions
ADR-1: Embedded-art delivery via existing FileProvider cache
Choice: Native writes getEmbeddedPicture() bytes to cacheDir/pluriwave_art/<hash(docId)> and returns a content://${applicationId}.fileprovider/cache/pluriwave_art/<hash> URI (the manifest ALREADY declares <cache-path path="."/> under authority ${applicationId}.fileprovider — zero new native/manifest surface). MediaItem.artUri gets that URI.
Alternatives: base64 data-URI (Auto art loader won't fetch it); a second art-only channel call at render time (extra round trip); a custom ContentProvider (new surface).
Rationale: reuses the proven FileProvider path (openDirectory/viewDirectory precedent). Cache key = hash(documentId) (docId contains ://, illegal in filenames); stable so re-parsing the same track reuses the file (if (file.exists()) skip extract). Eviction: after each write, native trims the art subdir by lastModified while count > 256 OR bytes > 32MB (LRU-by-mtime) — the ONE unavoidable native-side eviction, kept to a trivial reviewable loop because the files are native-owned and round-tripping names to Dart to pick deletions adds channel chatter for no testability gain (the delete() is native regardless). Cache-miss at render: art URI is resolved at MediaItem-build time inside the same getChildren call, so the file exists when the item ships; no embedded picture → native returns artUri: null → Dart falls back to Phase 1's artUriLocal(documentId) placeholder rotation. Never crashes.
ADR-2: Parsed-metadata session cache — pure-Dart flat LRU
Choice: CacheMetadatosSesion — an in-memory LinkedHashMap<String docId, MetadatosPista> bounded to 256 entries, LRU by access order, pure Dart, unit-tested. In-memory only (dies with the process → always fresh, no persistence staleness).
Alternatives: folder-scoped cache cleared on navigate-away (thrashes when paging a huge folder); unbounded (OOM risk on large libraries); persisted store (staleness, the proposal rejected eager-scan for this reason).
Rationale: 256 ≈ 5 pages of 50 → paging page 2 does NOT evict page 1; leaving and re-entering a recently-seen folder stays warm. A flat LRU keeps the natural working set without folder-boundary thrash. Batch resolution consults the cache first; only misses hit native.
ADR-3: Quality-sort — threshold-capped, batched, blocking parse-then-sort-then-page
Choice: Quality-sort resolves bitrate for EVERY audio file in the folder via a SINGLE batched readAudioMetadataBatch call, sorts desc (mirroring ordenarEmisoras(..., calidad)), caches results, then applies the existing paging. Offered ONLY when the folder's audio-file count ≤ _maxPistasParaOrdenCalidad (150); above that the quality entry is omitted (name-sort + buckets only).
Alternatives: background-prefetch + progressive-reveal (legacy MediaBrowserService can't stream partial nor re-sort in place — proposal already notes this); no cap (hundreds of MMR.setDataSource calls ≈ many seconds → head-unit "content not loading").
Rationale: onLoadChildren has a de-facto "be snappy" expectation; 150 files × ~30-50ms batched ≈ worst-case ~5-7s paid ONCE (cached for re-paging), only when the user opts into the quality entry. The cap is the sane safety valve; buckets cover big folders instead.
ADR-4: Browse-tree shape — mode entries prepended on page 0
Choice: On page 0 of a local folder, prepend non-playable "view" folders BEFORE the default name-sorted track list (same precedent as carpetasFavoritos prepending group folders before stations): (1) "Ordenar por calidad" IF audio count ≤ 150; (2) alphabetical bucket folders IF track count > 50 (buckets add no value for small folders). Name-sort stays the DEFAULT view (no explicit "name" entry — Auto's back button returns from any sub-view). New media-id families:
| Family | Format | View |
|---|---|---|
carpeta_local_ord: |
carpeta_local_ord:<modo>:<pagina>:<docId> |
sorted (modo = calidad) |
carpeta_local_bucket: |
carpeta_local_bucket:<idxBucket>:<pagina>:<docId> |
name-bucket slice |
Alternatives: sibling entries mixed into the track page (clutter, paging collisions); a wrapping "Ver/Ordenar" intermediate folder (extra tap for the common case).
Rationale: Collision-free — both diverge from carpeta_local: at index 13 (: vs _) and from each other/carpeta_local_pag: at the char after carpeta_local_ (o/b/p), so no startsWith false-match (same proof the existing _pag prefix documents); routing order is irrelevant. Fixed-arity fields (modo/idxBucket, then pagina) precede the free-form docId, decoded by the proven split-on-first-colon chain from paginaCarpetaLocalDesde — a docId containing :// survives verbatim. Buckets are name-only (partition the already-cheap name-sorted list → NO metadata) so they compose with slice-cheap-then-map untouched; only modo=calidad pays the metadata cost.
ADR-5: MMR API-level degradation
Choice: METADATA_KEY_SAMPLERATE (key 38) is API 31+; guard with Build.VERSION.SDK_INT >= 31, else sampleRate = null. METADATA_KEY_BITRATE, _TITLE, _ARTIST, getEmbeddedPicture() are all ≥ API 10 → always read. Missing field → null, degraded gracefully (subtitle omits the kHz fragment), reusing Phase 1's "unknown → omit" subtitle discipline.
Rationale: only sample-rate needs gating; everything else the proposal wants is universally available.
Data Flow
getChildren(carpeta_local[_ord|_bucket]:...:docId)
│ decode view+page+docId (pure Dart codec)
▼
fuente.hijos(docId) ── cheap NodoLocal[] (unchanged, no metadata)
│
├─ name (default) : sort by nombre ─┐
├─ bucket:<i> : name-sort → filter bucket ─┤ CHEAP, no metadata
└─ ord:calidad : batch-parse ALL (≤150, │
cache) → sort bitrate desc ┘ metadata for sort key only
▼
paginaDe(...) ── slice page (cheap)
▼
metadatosDe(slice.trackDocIds) ── CacheMetadatosSesion hit? else
│ readAudioMetadataBatch (native, page-scoped)
▼
build MediaItem per node: titulo/artista/artUri from meta, filename/placeholder fallback
▼
append "Más…" if hayPaginaSiguiente
File Changes
| File | Action | Description |
|---|---|---|
android/.../MainActivity.kt |
Modify | readAudioMetadataBatch case + MediaMetadataRetriever extract loop + art-file write/trim (static-review-only, thin) |
lib/modelos/pista_local.dart |
Modify | Add MetadatosPista DTO (titulo/artista/bitrate/sampleRate/artUri); extend PistaLocal with same fields |
lib/servicios/musica_local_auto.dart |
Modify | metadatosDe(docIds) on FuenteMusicaLocalAuto + channel call; CacheMetadatosSesion |
lib/servicios/navegacion_auto.dart |
Modify | mode/bucket prefixes + codec, bucketsDe, quality comparator, metadata-backed itemsLocales, subtitle |
lib/estado/orden_emisoras.dart |
(reuse) | OrdenEmisoras.calidad comparator shape mirrored for local tracks |
lib/l10n/*.arb (13) |
Modify | Sort-mode + bucket + "unknown metadata" labels |
Interfaces / Contracts
Native (pluriwave/file_actions), never throws across the boundary:
readAudioMetadataBatch(treeUri: String, documentIds: List<String>) -> List<Map>
// one map per requested docId, in order; fields null when absent/unparseable
{ documentId: String, titulo: String?, artista: String?,
bitrate: Int?/*bps*/, sampleRate: Int?/*API31+ else null*/, artUri: String?/*content://*/ }
Per-file try/catch → all-null entry (docId echoed); whole call try/catch → [];
MediaMetadataRetriever.release() in finally.
Dart:
class MetadatosPista { final String? titulo, artista, artUri; final int? bitrate, sampleRate; }
abstract FuenteMusicaLocalAuto {
Future<Map<String, MetadatosPista>> metadatosDe(List<String> documentIds); // batched, never throws
}
class CacheMetadatosSesion { MetadatosPista? obtener(String); void guardar(String, MetadatosPista); } // LRU 256
Testing Strategy
| Layer | What | Approach |
|---|---|---|
| Unit | CacheMetadatosSesion LRU eviction/order |
pure Dart |
| Unit | media-id encode/decode (_ord/_bucket, docId with ://), collision guards |
pure Dart |
| Unit | bucketsDe partitioning + labels; quality comparator |
pure Dart |
| Unit | itemsLocales metadata-backed build: resolves ONLY sliced page's docIds (spy call-count invariant) + fallbacks |
pure Dart, injected metadata map |
| Unit | subtitle format (bitrate/kHz known/unknown, no literal "null") | pure Dart |
| Static review | readAudioMetadataBatch, art write/trim, API-31 sample-rate guard |
Kotlin review (no build/DHU) |
Migration / Rollout
No migration. Purely additive over Phase 1. Rollback = remove readAudioMetadataBatch,
drop _ord/_bucket prefixes + metadatosDe, restore filename title + placeholder art.
Phase 1 browse/play/paging untouched.
Open Questions
_maxPistasParaOrdenCalidad = 150and art budget (256 files / 32 MB) are first-pass; validate on-device in a later hardware pass (no DHU here).