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.
This commit is contained in:
2026-07-20 01:08:15 +02:00
parent 85dd043cd4
commit dfd40ca937
12 changed files with 2434 additions and 10 deletions
@@ -0,0 +1,189 @@
# 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`).