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

12 KiB
Raw Blame History

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 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.
  • "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).