# 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/` and returns a `content://${applicationId}.fileprovider/cache/pluriwave_art/` URI (the manifest ALREADY declares `` 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` 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:::` | sorted (`modo` = `calidad`) | | `carpeta_local_bucket:` | `carpeta_local_bucket:::` | 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: : 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) -> List // 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> metadatosDe(List 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 = 150` and art budget (256 files / 32 MB) are first-pass; validate on-device in a later hardware pass (no DHU here).