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.
190 lines
12 KiB
Markdown
190 lines
12 KiB
Markdown
# 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
|
||
|
||
```dart
|
||
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
|
||
`MediaAction`s 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
|
||
|
||
- [x] 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
|
||
leaving `ControladorReconexion` untouched and accepting bounded retry rather
|
||
than adding queue-awareness to the sensitive error path. Confirm at apply.
|
||
**Confirmed at apply (Phase 5)**: `ControladorReconexion.registrarFallo`
|
||
takes no source-type parameter (static review), so it structurally cannot
|
||
special-case a local-track error — left untouched. Bounded-retry contract
|
||
with the default `maxReintentos: 5` proven in
|
||
`test/servicios/controlador_reconexion_local_test.dart`.
|
||
- [x] "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`).
|