Files
pluriwave/openspec/changes/android-auto-local-music-phase2/design.md
T
FreeTLab 352eb9fc37 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).
2026-07-19 23:52:08 +02:00

123 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).