Merges its delta requirements into the android-auto-media base spec. Completes the 3-phase local-music-in-Android-Auto feature. Standing pre-release gate: on-device/DHU validation of every native surface built across all 4 phases (SAF picking, metadata extraction, art cache, queue/shuffle handler wiring) is still outstanding.
12 KiB
Design: Android Auto Local Music — Phase 3 (Folder-Scoped Queue + Shuffle)
Technical Approach
Keep the single-source just_audio player untouched and add a thin, mode-gated
local-queue layer to PluriWaveAudioHandler. All ordering, shuffle, media-id
codec, and orchestration logic lives as pure Dart in navegacion_auto.dart and a
new immutable ColaLocal holder; the handler change is a small, revision-guarded
integration seam. The queue is scoped to a folder's DIRECT audio children,
resolved lazily one track at a time via the existing resolvePlayableUri channel.
No ConcatenatingAudioSource, no OS shuffle toggle, no native/manifest/arb change.
The single load-bearing invariant: local-queue mode is active iff _colaLocal != null.
There is no second boolean that can desync. Every auto-advance and transport
override is a hard no-op when _colaLocal == null, so radio is provably untouched.
Architecture Decisions
ADR-1: App-managed queue over ConcatenatingAudioSource
Choice: Hold an ordered List<NodoLocal> + index in an immutable ColaLocal;
advance by re-driving the existing source-change path.
Alternatives: just_audio ConcatenatingAudioSource + audio_service queue.
Rationale: The handler recreates player+EQ per source (_recrearPlayer) inside a
revision-guarded queue built for live-stream reconnect. Bolting a concatenating
source onto that fights the recreate-per-source model and the reconnect state
machine head-on — maximal blast radius on the most-tested component. The
app-managed layer reuses proven seams and keeps radio byte-identical.
ADR-2: Mode isolation — _colaLocal nullability is the ONLY gate (critical)
Choice: Split the current playMediaItem into (a) public playMediaItem, which
always clears the queue (_colaLocal = null; _avanzandoCola = false) then
delegates to a new private _encolarCambioFuente(item), and (b) queue play, which
sets/keeps _colaLocal and calls _encolarCambioFuente without clearing.
| Transition | Mechanism | Result |
|---|---|---|
Radio starts (phone reproducir, car emisora:/grupo:) |
routes through public playMediaItem |
queue cleared — no zombie advance |
Local queue starts (carpeta_local_reproducir:/_aleatorio:) |
sets _colaLocal, calls _encolarCambioFuente (private) |
queue active |
| Queue auto-advance / skip | private _encolarCambioFuente, queue preserved |
queue stays active |
stop() (user/sleep-timer/onTaskRemoved) |
clears _colaLocal + _avanzandoCola |
queue ends cleanly |
pause() |
no change to _colaLocal |
resumes same track |
Terminal error (_gestionarErrorReproduccion) |
clears _colaLocal |
queue ends, no zombie |
| App backgrounded/killed | _colaLocal is in-memory only |
resets to null on restart; no crash, no persistence |
Rationale: A single choke point (public playMediaItem = "external play = leave
queue mode") makes leaks structurally impossible. The completion listener re-reads
_colaLocal; if any external play ran, it is null and advance is a no-op. This
does NOT touch _cambiarFuente, ControladorReconexion, _intentarReconexion,
_esErrorDeRed, or _gestionarErrorReproduccion's network path.
ADR-3: Auto-advance trigger — completed AND queue-active, with a re-entry latch
Choice: In the existing playerStateStream listener, add a first-line delegate
_manejarFinPista(proc) that returns immediately unless
proc == ProcessingState.completed && _colaLocal != null. Radio (infinite live
streams) NEVER emits completed, and completed never flows through
playbackEventStream.onError, so completion and the reconnect machine are disjoint
by construction. A bool _avanzandoCola latch (set synchronously on detection,
reset when the next track reaches playing && ready, or on deactivate/stop/external
play) prevents double-advance from repeated completed emissions during the
async URI-resolve gap before _recrearPlayer cancels the old stream.
Alternatives: position-poll near duration (racy); audio_service completion
callback (presupposes the queue model we rejected).
Rationale: Double-gated (completed + non-null queue) and disjoint from every
existing ProcessingState/error path. The EQ-preset non-playback invariant is
preserved: the eq_preset: branch returns first in playFromMediaId, touches only
EQ seams, never _colaLocal — a preset tap during a queue leaves it advancing.
Alarm audio is a separate native service sharing no Dart state with _colaLocal.
Advance flow (revision-safe): compute siguiente = cola.conSiguiente(); if null
→ end-of-queue. Else set _colaLocal = siguiente, capture that instance, await
uriContenidoDePista, then if (!identical(_colaLocal, siguiente)) return; (a user
action during the await aborts the stale advance), else _encolarCambioFuente(item).
ADR-4: End-of-queue → STOP + deactivate (no loop)
Choice: Past the last track (auto-advance or skipToNext), clear _colaLocal
and go idle via stop(). skipToPrevious at index 0 clamps to 0 (restart track).
Alternatives: loop to track 1; repeat modes.
Rationale: "Play this folder" is finite; looping is a surprise and battery cost.
Clean deactivation (_colaLocal = null) keeps isolation trivial. Repeat is
out-of-scope per proposal.
ADR-5: Two PLAYABLE action media-ids (not browsable folders)
Choice: carpeta_local_reproducir:<docId> (name order) and
carpeta_local_aleatorio:<docId> (shuffled), both playable: true, routed through
playFromMediaId (the eq_preset:/pista: precedent), NOT getChildren.
Codec: strip prefix by length; the single tail is the raw SAF documentId verbatim
(no embedded page/mode → no split needed). Empty tail = local root queue.
Collision proof: after the shared carpeta_local_ stem the next char is r /
a, distinct from _pag(p) / _ord(o) / _bucket(b); carpeta_local: diverges
at index 13 (: vs _). No startsWith overlap with any of the 8 existing
prefixes or bare folder ids.
Rationale: These are ACTIONS that start playback, so playable: true and
playFromMediaId dispatch — the opposite of the playable: false sort/bucket
folders. Making the distinction explicit prevents copying the wrong (non-playable)
precedent. Prepended on page 0 (before the sort/bucket nav entries) only when the
folder has ≥1 direct audio child, mirroring ofreceOrdenCalidad(totalPistas > 0).
ADR-6: Shuffle = Fisher-Yates over the name-sorted list, injected Random
Choice: pistasEnOrdenAleatorio(nodos, Random rng) runs Fisher-Yates on the
canonical name-sorted audio children. Production passes Random(); tests pass
Random(fixedSeed) for deterministic permutation assertions.
Rationale: Seeding over the name-sorted order (not the native enumeration order,
which is not guaranteed stable) makes the result reproducible under a fixed seed.
Injected Random avoids reimplementing a PRNG while staying pure and testable.
Data Flow
Tap "Reproducir carpeta"/"aleatorio" (playable action id)
→ playFromMediaId → [after eq_preset & pista branches] esColaLocalMediaId?
→ reproducirCarpetaLocal(id, aleatorio, fuente, rng)
fuente.hijos(docId) → filter audio → name-sort / Fisher-Yates
→ iniciarCola(pistas): _colaLocal = ColaLocal(pistas)
→ _reproducirActualDeCola → construirMediaItemColaLocal (resolve URI)
→ _encolarCambioFuente(item) [revision-guarded, EQ chain reused]
track completes → playerStateStream(completed) → _manejarFinPista
→ (_colaLocal != null && !_avanzandoCola) → conSiguiente()
null → _desactivarCola + stop non-null → resolve + _encolarCambioFuente
File Changes
| File | Action | Description |
|---|---|---|
lib/servicios/cola_local.dart |
Create | Immutable ColaLocal (pistas + index; actual, hayActual, conSiguiente, conAnterior). Pure, fully unit-tested. |
lib/servicios/navegacion_auto.dart |
Modify | 2 prefixes + predicates + strip; pistasEnOrdenNombre, mezclarFisherYates, pistasEnOrdenAleatorio; page-0 playable-action prepend (guarded); reproducirCarpetaLocal seam; construirMediaItemColaLocal helper. |
lib/servicios/servicio_audio.dart |
Modify | _colaLocal, _avanzandoCola; extract _encolarCambioFuente; public playMediaItem clears queue; _reproducirEntradaCola; _manejarFinPista/_avanzarCola/_reproducirActualDeCola/_desactivarCola; skipToNext/skipToPrevious overrides; queue-aware controls/systemActions gated by _colaLocal != null; stop() clears queue; playFromMediaId branches. |
Native, AndroidManifest.xml, pubspec.yaml, lib/l10n/*.arb: no change.
Interfaces / Contracts
class ColaLocal { // immutable, pure
final List<NodoLocal> pistas; // direct audio children, in play order
final int indice;
NodoLocal get actual;
bool get hayActual;
ColaLocal? conSiguiente(); // null at end
ColaLocal conAnterior(); // clamps at 0
}
Future<void> reproducirCarpetaLocal(String id, {required bool aleatorio,
required FuenteMusicaLocalAuto fuente, Random? rng,
required Future<void> Function(List<NodoLocal> pistas) iniciarCola});
Transport wiring: when _colaLocal != null, the playbackState push adds
MediaControl.skipToPrevious/skipToNext to controls and the matching
MediaActions to systemActions; when null, the control/action set is
byte-identical to today (radio regression guard).
Testing Strategy
| Layer | What | Approach |
|---|---|---|
| Unit (pure) | ColaLocal nav (next/prev/end/clamp); Fisher-Yates permutation + determinism under fixed seed; name order; media-id encode/decode + collision vs all 8 existing prefixes; page-0 prepend presence/absence by track count and playable:true; reproducirCarpetaLocal no-op on empty/unresolvable |
pure Dart, injected Random and fake FuenteMusicaLocalAuto |
| Handler (integration) | completed→advance only when queue active; completed no-op when _colaLocal==null (radio isolation); double-completed→single advance (latch); external playMediaItem clears queue (no zombie); stop() clears queue; skip next/prev move index; end-of-queue stops+deactivates; eq_preset tap during queue does not disturb it; controls byte-identical when queue inactive |
mocked player/fuente behind the mode boundary |
| Static-review only | real completed firing on device, car next/prev transport buttons, any native |
no DHU/on-device Auto here (session precedent) |
Migration / Rollout
No migration. Additive. Rollback = remove cola_local.dart, the two prefixes +
codec + page-0 prepend + orchestration in navegacion_auto.dart, and the queue
layer + mode gate in servicio_audio.dart; single-track pista: and all radio
playback revert untouched.
Open Questions
- Local-track source errors (PlayerException 2xxx / timeout on a
content://URI) currently enter the reconnect machine and retry the same URI up to 5× before failing. Isolated from radio (mode gate) but pointless. Recommend leavingControladorReconexionuntouched and accepting bounded retry rather than adding queue-awareness to the sensitive error path. Confirm at apply. Confirmed at apply (Phase 5):ControladorReconexion.registrarFallotakes no source-type parameter (static review), so it structurally cannot special-case a local-track error — left untouched. Bounded-retry contract with the defaultmaxReintentos: 5proven intest/servicios/controlador_reconexion_local_test.dart. - "Skip broken track and continue" on terminal error is out of scope (current
choice: deactivate + stop). Confirm acceptable.
Confirmed at apply (Phase 5): implemented as deactivate + stop — both
the natural end-of-queue path and an unresolvable-URI resolve failure
during an auto-advance call
_desactivarCola()+stop()(lib/servicios/servicio_audio.dart).