Files
pluriwave/openspec/changes/android-auto-local-music-phase3/proposal.md
T
FreeTLab dfd40ca937 feat(auto): queue playback and shuffle for local music folders [size:exception]
Adds "Reproducir carpeta" (sequential) and "Reproducir aleatorio"
(Fisher-Yates over the name-sorted order) as folder-scoped playable
actions, with auto-advance on track completion and skip next/prev.

Isolation from live radio is structural, not disciplinary: the
public playMediaItem always clears the local queue on any call, and
a new private _encolarCambioFuente is the only path that can advance
within it. _cambiarFuente, ControladorReconexion, and the reconnect
error path are untouched -- confirmed by a byte-for-byte empty diff
on all 4 pre-existing radio/reconnect regression suites, independently
re-run before and after (21/21 both times).

Handler wiring itself is static-review-only (PluriWaveAudioHandler
can't be unit-instantiated); the isolation/advance/race-guard
decision logic is extracted into cola_local.dart's pure functions,
which are fully unit-tested.
2026-07-20 01:08:15 +02:00

13 KiB

Proposal: Android Auto Local Music — Phase 3 (Folder-Scoped Queue + Shuffle)

Status of the local-music feature (context)

This is the FINAL planned phase of local music in Android Auto. Shipped and archived already:

  • Phase 1: SAF folder pick, persisted URI permission, lazy per-level DocumentFile traversal, PistaLocal/NodoLocal, filename titles, placeholder art, browse + play of a single track.
  • Paging fast-follow: on-demand carpeta_local_pag: "Más…" pagination replacing silent 50-item truncation.
  • Phase 2: real embedded metadata (native readAudioMetadataBatch / MediaMetadataRetriever), CacheMetadatosSesion LRU, quality-sort (carpeta_local_ord:), alphabetical name-buckets (carpeta_local_bucket:), embedded album art.

The original Phase-1 proposal sketched Phase 3 as: "subfolder scoping refinements + shuffle + transport-control polish." This proposal traces that label against the live code and defines precisely what actually remains.

Honest scope reconciliation (the key finding — read this first)

The original label reads like "two small UX toggles." It is not. Reading the live playback pipeline shows the real gap:

  • Browsing nested subfolders is already done (Phase 1's SAF tree traversal). So "subfolder scoping refinements" is NOT about navigation — that works.
  • There is no multi-track playback of any kind. reproducirPistaLocal (navegacion_auto.dart) builds exactly ONE MediaItem and hands it to playMediaItem. PluriWaveAudioHandler._cambiarFuente (servicio_audio.dart) does _recrearPlayer() + a single _player.setUrl(mediaItem.id). There is NO just_audio ConcatenatingAudioSource, NO audio_service queue, NO skipToNext, NO auto-advance on track completion. The handler was built for RADIO — one live stream at a time. Tapping a local track plays that ONE track and then stops. There is no "play this folder", no "next track", nothing.

Therefore both original Phase-3 items collapse onto a single missing foundation:

  • "subfolder scoping" honestly resolves to: play the audio tracks of a chosen (sub)folder as a scoped queue — a sequence bounded to that folder.
  • "shuffle" is the randomized ordering of that same folder-scoped queue.
  • "transport-control polish" is wiring next/previous so Android Auto's transport controls advance within that local queue.

Honest verdict: Phase 3 is ONE foundational feature — a local multi-track queue inside the audio handler — with two browsable entry points (play folder / shuffle folder) and transport wiring on top. Shuffle without a queue is meaningless, so the queue is the real, unavoidable work. It is larger than the label implies and it touches the app's most sensitive, most heavily-tested component (the audio handler with its reconnect state machine and EQ chain).

Intent

Problem: Local music in Android Auto is browse-and-tap-one-track only. A driver cannot start a folder playing and let it run; every track requires a new tap, there is no continuous listening, and "shuffle my music" — the single most common car-music expectation — is impossible because there is no queue to shuffle.

Why now: This is the last planned phase; the browse/metadata/sort surface is complete and the only remaining promised capability (shuffle, committed in the very first proposal) requires the queue foundation this phase introduces.

Success looks like: From any local (sub)folder in Android Auto, the driver can tap "Reproducir carpeta" to play that folder's tracks in order, or "Reproducir aleatorio" to play them shuffled, with playback auto-advancing track-to-track and the car's next/previous controls moving within the queue — all reusing the existing EQ signal chain and buffering, with no regression to radio playback.

Scope — In (Phase 3)

  • Local multi-track queue foundation in PluriWaveAudioHandler: an ordered list of pending local tracks + current index, auto-advance to the next track on completion, and skipToNext/skipToPrevious wired to move within it. A mode boundary keeps this active ONLY for local-queue playback, never for radio (live streams never "complete").
  • Two browsable, folder-scoped play actions, prepended on page 0 of a local folder (same prepend precedent as Phase 2's sort/bucket entries):
    • "Reproducir carpeta" — the folder's direct audio children, name-ordered.
    • "Reproducir aleatorio" — the same set, shuffled.
  • New playable media-id families for those actions (design settles exact strings; must be collision-free against the existing pista: / emisora: / grupo: / eq_preset: / carpeta_local[_pag|_ord|_bucket]: prefixes, reusing the proven strip-by-length + split-on-first-colon codec so raw SAF documentIds with : / / survive verbatim).
  • Queue building scoped to the tapped folder's DIRECT audio children (non-directory NodoLocals), sorted by name for sequential and randomized (seeded, deterministically testable) for shuffle.
  • Content-URI resolution per advance via the existing resolvePlayableUri channel method — lazy, one track at a time as the queue advances (no eager whole-folder resolution, no new native method).
  • Pure-Dart unit tests for shuffle ordering (seeded), queue construction, action media-id encode/decode + collision guards, and page-0 action prepend; handler tests for completion-advance and skip within the local-queue mode boundary.

Scope — Out (explicitly deferred / rejected)

  • Recursive whole-subtree queue. The queue is scoped to the tapped folder's direct audio children only — consistent with the lazy per-level enumeration model and every prior phase's rejection of eager subtree scans. Enqueuing a whole nested subtree would require recursive native traversal; out of scope.
  • OS-level shuffle toggle (AudioService.setShuffleMode / AudioServiceShuffleMode). The package supports it, but it presupposes a persistent OS play-queue with a shuffle affordance in the transport UI — a model this app's browse-tree, tap-to-select integration does not use (the notification exposes only play/pause/stop). Shuffle is therefore exposed as a browsable ACTION (the EQ-preset-folder precedent), not an OS toggle. Design may revisit, but the browsable action is the recommended fit.
  • ConcatenatingAudioSource rebuild of the player pipeline — see Approach; recommended AGAINST in favor of the lighter app-managed queue.
  • Cross-folder / persisted / resumable queues, queue reordering UI, repeat modes. Not promised, not in scope.
  • New phone-UI strings / l10n work. Car-tree labels are hardcoded Spanish by established precedent ("Favoritos", "Música Local", "Más…", "Ordenar por calidad"). "Reproducir carpeta" / "Reproducir aleatorio" are hardcoded Spanish car labels — NO new .arb keys, NO 13-locale change. If design surfaces a genuinely new PHONE-UI string, it must be scoped into all 13 locales explicitly; none is anticipated.

Approach (with rationale)

Recommended: an app-managed local queue that reuses the existing single-source pipeline — do NOT rebuild the player around ConcatenatingAudioSource.

The handler recreates the player + EQ on every source change (_recrearPlayer() inside _cambiarFuente) and wraps source switches in a revision-guarded queue built for live-stream reconnect. That machinery is the app's most delicate, most-tested code. Bolting a just_audio ConcatenatingAudioSource + audio_service queue + setShuffleMode onto it fights the recreate-per-source model and the reconnect state machine head-on — high blast radius on the one component we least want to destabilize.

Instead, keep the single-source player and add a thin queue LAYER in the handler: hold the ordered local-track list + index, listen for ProcessingState.completed (which only fires for finite local files, never for radio) to advance by re-driving the existing _cambiarFuente, and override skipToNext/skipToPrevious to move the index and re-drive the same path. A mode flag scopes all of this to local-queue playback so radio behavior is byte-identical. This mirrors Phase-2/EQ philosophy: expose the capability through the existing seams and a browsable action, rather than adopting a platform model the app's integration doesn't actually use.

All ordering, queue-building, media-id codec, and action-item logic stays pure Dart in navegacion_auto.dart (fully unit-testable, seeded shuffle). The handler change is the only stateful/coupled part and gets its own focused tests behind the mode boundary. No new native method (lazy resolvePlayableUri per advance), no new dependency, no manifest/pubspec change anticipated.

The design phase must settle: (1) app-managed queue vs ConcatenatingAudioSource (recommendation: app-managed); (2) exact completion/advance and skip semantics against the reconnect machine; (3) the two new media-id prefixes and their collision proof; (4) shuffle seed strategy for deterministic tests.

Affected areas (anticipated — design confirms)

  • lib/servicios/servicio_audio.dart (Modify): local-queue state, completion auto-advance, skipToNext/skipToPrevious, mode boundary, playFromMediaId branch for the new action ids. The sensitive change — main risk surface.
  • lib/servicios/navegacion_auto.dart (Modify): action media-id prefixes + predicates + codec, queue-building (name-order / seeded-shuffle) from NodoLocals, page-0 action-item prepend, play orchestration.
  • lib/servicios/musica_local_auto.dart (Possibly): a folder-tracks helper if the orchestration needs one; likely reuses existing hijos + resolvePlayableUri.
  • android/.../MainActivity.kt: no change anticipated (lazy per-advance resolution reuses resolvePlayableUri).
  • AndroidManifest.xml / pubspec.yaml / lib/l10n/*.arb: no change anticipated.

Risks

  • Audio-handler blast radius (HIGH): the queue layer lives in the most sensitive, most-tested component. Mitigation: strict mode boundary so radio is provably untouched; app-managed layer over pipeline rebuild; focused regression tests. This is the dominant risk.
  • Completion-advance vs reconnect machine (MED-HIGH): ProcessingState.completed handling must not collide with the live-stream stall/retry logic. Design must prove the two paths are disjoint (mode flag).
  • Not runtime-verifiable here (HIGH, session precedent): no DHU/on-device Android Auto in this environment; queue/skip/completion behavior is static-review + unit-test only, same as all prior native/handler work this session. Real transport-control behavior in the car cannot be exercised here.
  • Scope/size (MED): honestly larger than the "subfolder + shuffle" label. See estimate below.
  • Shuffle determinism (LOW): seed the shuffle so tests are stable; a fixed/injectable seed avoids flaky ordering assertions.

Size estimate & delivery (flag early, per session pattern)

Comparable to or larger than Phase 2 (~2000 lines) because it modifies the audio handler core and adds handler-level tests on top of the pure-Dart surface. Realistically a single size:exception PR, consistent with every prior local-music change this session (Phase 1 ~1300, paging ~800, Phase 2 ~2000, all single size:exception PRs).

Natural work-unit split if chaining is preferred: (1) queue foundation + "Reproducir carpeta" sequential playback + transport wiring (the bulk / the real foundation), then (2) "Reproducir aleatorio" shuffle (a thin ordering variant once the queue exists). Shuffle is small once the foundation lands. Surface this to the delivery-strategy guard before apply.

Rollback plan

Additive. Rollback = remove the new action media-id prefixes/predicates/codec, the queue-building and page-0 action prepend in navegacion_auto.dart, and the queue layer (state, completion-advance, skip overrides, mode flag) in servicio_audio.dart. Single-track pista: playback and all radio playback revert untouched.

Success criteria

  • "Reproducir carpeta" and "Reproducir aleatorio" appear on page 0 of a local folder that has audio tracks; absent when it has none.
  • Tapping "Reproducir carpeta" plays the folder's direct audio children in name order, auto-advancing track to track.
  • Tapping "Reproducir aleatorio" plays the same set shuffled; ordering is deterministic under a fixed seed in tests.
  • Car next/previous transport controls move within the local queue.
  • Radio playback, reconnect, and EQ behavior are provably unchanged (mode boundary; regression tests green).
  • Pure-Dart logic fully unit-tested; handler queue behavior tested behind the mode boundary; native (if any) static-reviewed. No new .arb keys.

sdd-spec and sdd-design can run in parallel from this proposal. Design must settle the app-managed-queue vs ConcatenatingAudioSource decision, the completion/skip semantics against the reconnect machine, and the two new media-id prefixes with a collision proof before sdd-tasks.