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).
123 lines
10 KiB
Markdown
123 lines
10 KiB
Markdown
# 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 = 150` and art budget (256 files / 32 MB) are first-pass; validate on-device in a later hardware pass (no DHU here).
|