Files
pluriwave/openspec/changes/android-auto-local-music-phase3/design.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

190 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`).