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

10 KiB
Raw Blame History

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 (hijosNodoLocal 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).