# 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` + 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:` (name order) and `carpeta_local_aleatorio:` (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 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 reproducirCarpetaLocal(String id, {required bool aleatorio, required FuenteMusicaLocalAuto fuente, Random? rng, required Future Function(List 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`).