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
+103
View File
@@ -0,0 +1,103 @@
import '../modelos/pista_local.dart';
/// Immutable snapshot of a local-music play queue (Design ADR-1): an ordered
/// list of a folder's direct audio children plus the currently-playing
/// index. Pure Dart, no side effects — every "move" method returns a NEW
/// instance rather than mutating in place, mirroring how
/// `ControladorReconexion` was extracted from `PluriWaveAudioHandler`
/// (`controlador_reconexion.dart`) so this stays fully unit-testable without
/// the handler (which cannot be instantiated in unit tests — see this
/// module's sibling test file's doc comment).
class ColaLocal {
const ColaLocal({required this.pistas, this.indice = 0});
/// The folder's direct audio children, in play order — already
/// name-sorted or Fisher-Yates shuffled by the caller before
/// construction (Design ADR-6). This class itself never re-orders them.
final List<NodoLocal> pistas;
/// The currently-playing position within [pistas].
final int indice;
/// Whether [indice] points at a real entry in [pistas] (Design "empty
/// list" edge case: an empty queue has no current track).
bool get hayActual => indice >= 0 && indice < pistas.length;
/// The track at [indice]. Only meaningful when [hayActual] is `true` —
/// callers must check it first; this getter does not guard the read
/// itself.
NodoLocal get actual => pistas[indice];
/// The queue advanced one position (Design ADR-3's advance flow):
/// `null` when [indice] is already the last position (or [pistas] is
/// empty) — end-of-queue, per Design ADR-4 (no wraparound to track 1).
ColaLocal? conSiguiente() {
final siguiente = indice + 1;
if (siguiente >= pistas.length) return null;
return ColaLocal(pistas: pistas, indice: siguiente);
}
/// The queue moved back one position, CLAMPED at 0 (Design ADR-4:
/// `skipToPrevious` at the first track restarts it instead of throwing or
/// wrapping to the last track).
ColaLocal conAnterior() {
final anterior = indice > 0 ? indice - 1 : 0;
return ColaLocal(pistas: pistas, indice: anterior);
}
}
/// Outcome of [decidirAvanceCola] (Design ADR-3): a pure decision the caller
/// (`PluriWaveAudioHandler._manejarFinPista`) acts on with
/// static-review-only field mutations — this function itself has no side
/// effects.
enum DecisionAvanceCola {
/// No advance: not a completion event, no active local queue (radio
/// isolation), or the re-entry latch is already armed (an advance is
/// already resolving its URI).
ninguna,
/// Advance to the next track — the caller marks the re-entry latch and
/// resolves/plays `colaLocal.conSiguiente()`.
avanzar,
/// The queue reached its end — the caller deactivates the queue and stops
/// playback (Design ADR-4, no wraparound).
desactivar,
}
/// Pure queue-advance decision (Design ADR-3), extracted so it is
/// unit-testable without instantiating `PluriWaveAudioHandler` — the
/// established `ControladorReconexion` precedent (see this file's class
/// doc comment). Deliberately takes NO `just_audio` type: the caller maps
/// `ProcessingState.completed` to [trackCompletado] before calling this.
///
/// Double-gated: [trackCompletado] must be `true` AND [colaLocal] must be
/// non-null (radio never sets `_colaLocal`, so a `null` queue is a
/// structural proxy for "not local-queue playback" — Design "single
/// load-bearing invariant"). [avanzandoCola] is the re-entry latch: a
/// second `completed` emission while an earlier advance is still resolving
/// its URI must be a no-op, not a double-advance.
DecisionAvanceCola decidirAvanceCola({
required ColaLocal? colaLocal,
required bool avanzandoCola,
required bool trackCompletado,
}) {
if (!trackCompletado) return DecisionAvanceCola.ninguna;
if (colaLocal == null) return DecisionAvanceCola.ninguna;
if (avanzandoCola) return DecisionAvanceCola.ninguna;
return colaLocal.conSiguiente() == null
? DecisionAvanceCola.desactivar
: DecisionAvanceCola.avanzar;
}
/// Mid-await race guard (Design ADR-3's advance flow): `true` only when
/// [siguienteEsperado] is the EXACT SAME instance still installed as
/// [colaLocalActual] — a deliberate `identical()` check, NOT `==`, so a
/// user action that replaced `_colaLocal` with a structurally-equal-but
/// distinct `ColaLocal` during the async URI-resolve gap is correctly
/// detected as stale and aborts the advance, instead of silently racing an
/// external play/stop.
bool avanceEsValido(
ColaLocal? colaLocalActual,
ColaLocal? siguienteEsperado,
) => identical(colaLocalActual, siguienteEsperado);
+167
View File
@@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math' show Random;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
@@ -248,6 +249,22 @@ class ConstructorArbolAuto {
/// [_prefijoCarpetaLocalOrd]'s doc for the divergence proof).
static const _prefijoCarpetaLocalBucket = 'carpeta_local_bucket:';
/// "Reproducir carpeta" (sequential-play) action media-id prefix (Design
/// ADR-5, Phase 3): `carpeta_local_reproducir:<docId>`. PLAYABLE (unlike
/// every other `carpeta_local_*` prefix in this class, which are
/// non-playable browse folders) — routed through `playFromMediaId`, not
/// `getChildren`. Collision-free against every other prefix here:
/// diverges from [_prefijoCarpetaLocalPaginada]/[_prefijoCarpetaLocalOrd]/
/// [_prefijoCarpetaLocalBucket] at index 14 (`r` vs `p`/`o`/`b`), same
/// divergence-point family as those siblings' doc comments.
static const _prefijoCarpetaLocalReproducir = 'carpeta_local_reproducir:';
/// "Reproducir aleatorio" (shuffled-play) action media-id prefix (Design
/// ADR-5, Phase 3): `carpeta_local_aleatorio:<docId>`. PLAYABLE, mirrors
/// [_prefijoCarpetaLocalReproducir]. Diverges from every sibling prefix
/// at index 14 (`a` vs `r`/`p`/`o`/`b`).
static const _prefijoCarpetaLocalAleatorio = 'carpeta_local_aleatorio:';
/// Separate cap for favorite-group folders under `Favoritos` (Design
/// "group-folder ordering and cap"): a folder tap costs more driver
/// attention than a station scroll, so this is tunable independently of
@@ -448,6 +465,13 @@ class ConstructorArbolAuto {
if (pagina == 0) {
final totalPistas = nodos.where((n) => !n.esDirectorio).length;
final prepend = <MediaItem>[
// Folder-play actions (Design ADR-5, Phase 3): prepended BEFORE
// the sort/bucket nav entries, guarded the same shape as
// ofreceOrdenCalidad(totalPistas > 0) — present iff the folder has
// at least one direct audio child, absent for a folder with only
// subfolders (Spec "Folder has no tracks").
if (totalPistas > 0) _itemReproducirCarpeta(documentIdPadre),
if (totalPistas > 0) _itemReproducirAleatorio(documentIdPadre),
if (ofreceOrdenCalidad(totalPistas))
_itemModoOrdenCalidad(documentIdPadre),
if (ofreceBuckets(totalPistas))
@@ -490,6 +514,26 @@ class ConstructorArbolAuto {
MediaItem _itemBucket(String documentIdPadre, int idx, String etiqueta) =>
_carpeta('$_prefijoCarpetaLocalBucket$idx:0:$documentIdPadre', etiqueta);
/// The "Reproducir carpeta" playable action item (Design ADR-5): id
/// `carpeta_local_reproducir:<documentIdPadre>`. Hardcoded Spanish label,
/// matching every other car-tree label in this file — never routed
/// through `AppLocalizations`.
MediaItem _itemReproducirCarpeta(String documentIdPadre) => MediaItem(
id: '$_prefijoCarpetaLocalReproducir$documentIdPadre',
title: 'Reproducir carpeta',
playable: true,
extras: _contentStyleGrid,
);
/// The "Reproducir aleatorio" playable action item (Design ADR-5),
/// mirrors [_itemReproducirCarpeta].
MediaItem _itemReproducirAleatorio(String documentIdPadre) => MediaItem(
id: '$_prefijoCarpetaLocalAleatorio$documentIdPadre',
title: 'Reproducir aleatorio',
playable: true,
extras: _contentStyleGrid,
);
/// Whether [id] identifies a sort-mode local-music request (Design
/// ADR-4, Phase 2).
bool esCarpetaLocalOrdMediaId(String id) =>
@@ -500,6 +544,30 @@ class ConstructorArbolAuto {
bool esCarpetaLocalBucketMediaId(String id) =>
id.startsWith(_prefijoCarpetaLocalBucket);
/// Whether [id] identifies the "Reproducir carpeta" sequential-play
/// folder action (Design ADR-5, Phase 3).
bool esCarpetaLocalReproducirMediaId(String id) =>
id.startsWith(_prefijoCarpetaLocalReproducir);
/// Whether [id] identifies the "Reproducir aleatorio" shuffled-play
/// folder action (Design ADR-5, Phase 3).
bool esCarpetaLocalAleatorioMediaId(String id) =>
id.startsWith(_prefijoCarpetaLocalAleatorio);
/// Strips the [_prefijoCarpetaLocalReproducir] prefix from [id] by length
/// (Design ADR-5 "strip prefix by length" — no split needed, the single
/// tail is the raw SAF documentId verbatim; an empty tail means the local
/// root). Only meaningful when [esCarpetaLocalReproducirMediaId] is
/// `true`.
String idCarpetaLocalReproducirDesde(String id) =>
id.substring(_prefijoCarpetaLocalReproducir.length);
/// Strips the [_prefijoCarpetaLocalAleatorio] prefix from [id] by length,
/// mirrors [idCarpetaLocalReproducirDesde]. Only meaningful when
/// [esCarpetaLocalAleatorioMediaId] is `true`.
String idCarpetaLocalAleatorioDesde(String id) =>
id.substring(_prefijoCarpetaLocalAleatorio.length);
/// Parses a `carpeta_local_ord:<modo>:<pagina>:<docId>` [id] into its
/// `(modo, documentId, pagina)` triple (Design ADR-4): the prefix is
/// stripped by length, then the remainder is split on the FIRST two `:`
@@ -860,6 +928,105 @@ List<BucketLocal> bucketsDe(List<NodoLocal> nodos) {
}).toList();
}
/// The canonical name-sorted audio-children list a folder-play action
/// queues (Design ADR-6): directories excluded, sorted by
/// `NodoLocal.nombre` — the SAME comparator [ConstructorArbolAuto.itemsLocales]
/// already applies to the browse-tree page-0 view, so "Reproducir
/// carpeta"'s play order matches what the driver sees when browsing
/// normally. Returns a NEW list; never mutates [nodos].
List<NodoLocal> pistasEnOrdenNombre(List<NodoLocal> nodos) {
final pistas = nodos.where((n) => !n.esDirectorio).toList();
pistas.sort((a, b) => a.nombre.compareTo(b.nombre));
return pistas;
}
/// Fisher-Yates shuffle (Design ADR-6) over a COPY of [nodos] — never
/// mutates the input list. [rng] is injected so tests can pass a
/// fixed-seed `Random` for deterministic permutation assertions;
/// production callers pass `Random()`.
List<NodoLocal> mezclarFisherYates(List<NodoLocal> nodos, Random rng) {
final resultado = List<NodoLocal>.from(nodos);
for (var i = resultado.length - 1; i > 0; i--) {
final j = rng.nextInt(i + 1);
final tmp = resultado[i];
resultado[i] = resultado[j];
resultado[j] = tmp;
}
return resultado;
}
/// The shuffled audio-children list "Reproducir aleatorio" queues (Design
/// ADR-6): Fisher-Yates over [pistasEnOrdenNombre]'s canonical order — NOT
/// the native enumeration order (not guaranteed stable) — so the resulting
/// permutation is reproducible under a fixed [rng] seed.
List<NodoLocal> pistasEnOrdenAleatorio(List<NodoLocal> nodos, Random rng) =>
mezclarFisherYates(pistasEnOrdenNombre(nodos), rng);
/// Orchestrates a "Reproducir carpeta"/"Reproducir aleatorio" tap (Design
/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2): resolves whichever of the
/// two action prefixes matches [id] (ignoring [aleatorio] for the STRIP —
/// the prefix itself is authoritative), fetches [fuente]'s direct children
/// for that folder, filters to audio files, orders them ([aleatorio] picks
/// shuffled vs name order), and hands the resulting list to [iniciarCola].
///
/// A no-op (never calls [iniciarCola]) when: [id] matches neither action
/// prefix; [fuente.hijos] throws or returns only directories (an
/// unresolvable/empty folder — Design "no-op on empty/unresolvable
/// folder").
Future<void> reproducirCarpetaLocal(
String id, {
required bool aleatorio,
required FuenteMusicaLocalAuto fuente,
Random? rng,
required Future<void> Function(List<NodoLocal> pistas) iniciarCola,
}) async {
final constructor = ConstructorArbolAuto();
final String documentId;
if (constructor.esCarpetaLocalReproducirMediaId(id)) {
documentId = constructor.idCarpetaLocalReproducirDesde(id);
} else if (constructor.esCarpetaLocalAleatorioMediaId(id)) {
documentId = constructor.idCarpetaLocalAleatorioDesde(id);
} else {
return;
}
final List<NodoLocal> nodos;
try {
nodos = await fuente.hijos(documentId);
} catch (_) {
return;
}
final pistas = aleatorio
? pistasEnOrdenAleatorio(nodos, rng ?? Random())
: pistasEnOrdenNombre(nodos);
if (pistas.isEmpty) return;
await iniciarCola(pistas);
}
/// Resolves [nodo]'s playable content URI via [fuente] and builds the
/// `MediaItem` the local-queue layer plays (Design Data Flow
/// "construirMediaItemColaLocal (resolve URI)"), reusing the SAME
/// title-derivation [reproducirPistaLocal] uses ([_tituloDesdeDocumentId])
/// so a queue track's Now Playing title matches what a directly-tapped
/// single track would show. Returns `null` when the content URI cannot be
/// resolved (stale id, revoked permission, moved file) — the caller treats
/// that as "cannot play this entry", never a crash.
Future<MediaItem?> construirMediaItemColaLocal(
NodoLocal nodo, {
required FuenteMusicaLocalAuto fuente,
}) async {
final contentUri = await fuente.uriContenidoDePista(nodo.documentId);
if (contentUri == null || contentUri.isEmpty) return null;
return MediaItem(
id: contentUri,
title: _tituloDesdeDocumentId(nodo.documentId),
album: 'PluriWave',
extras: {'documentId': nodo.documentId},
);
}
/// Local-music `getChildren` dispatch (Design "Data Flow"): resolves
/// [parentMediaId] against the `musica_local` root (`fuente.hijos('')`) or a
/// `carpeta_local:<id>` subfolder (`fuente.hijos(id)`), mapping the result
+208 -2
View File
@@ -9,7 +9,9 @@ import 'package:just_audio/just_audio.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/pista_local.dart';
import '../modelos/preset_ecualizador.dart';
import 'cola_local.dart';
import 'controlador_reconexion.dart';
import 'musica_local_auto.dart';
import 'navegacion_auto.dart';
@@ -193,6 +195,18 @@ class PluriWaveAudioHandler extends BaseAudioHandler
Future<void> _colaCambioFuente = Future<void>.value();
int _revisionFuente = 0;
/// Active local-music queue (Design "single load-bearing invariant"):
/// `null` means "not local-queue playback" — the ONLY gate the
/// auto-advance/skip/isolation logic reads. Radio never sets this field.
ColaLocal? _colaLocal;
/// Re-entry latch (Design ADR-3): armed synchronously the instant an
/// auto-advance is triggered, cleared when the next track reaches
/// `playing && ready`, or on deactivate/stop/external play. Guards
/// against a double-advance from repeated `completed` emissions during
/// the async URI-resolve gap.
bool _avanzandoCola = false;
Emisora? emisoraActual;
double _volumen = 1.0;
double get volumen => _volumen;
@@ -253,20 +267,42 @@ class PluriWaveAudioHandler extends BaseAudioHandler
_estadoPlayerSub = _player.playerStateStream.listen((state) {
final playing = state.playing;
final proc = state.processingState;
// First line of the listener (Design ADR-3, Phase 3 task 3.3):
// double-gated on `completed` + an active local queue, so this is a
// no-op for radio (which never emits `completed`) and for
// single-track local playback (which never sets `_colaLocal`).
_manejarFinPista(proc);
if (playing && proc == ProcessingState.ready) {
// Successful (re)connection: reset the backoff so the next stall
// starts over, and leave the reconnect window (S7-R7).
_reconexion.restablecer();
_reconectando = false;
// Local queue (Design ADR-3): the next queued track reached a
// stable playing state — clear the re-entry latch so a LATER
// completion can advance again. A no-op for radio, which never
// sets `_avanzandoCola`.
_avanzandoCola = false;
}
// Local queue transport (Design "Transport wiring"): skip controls
// are only offered while a queue is active — when `_colaLocal` is
// `null` this list/set/index is byte-identical to the pre-change
// radio behavior (regression guard).
final colaActiva = _colaLocal != null;
playbackState.add(
playbackState.value.copyWith(
controls: [
if (colaActiva) MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
if (colaActiva) MediaControl.skipToNext,
],
systemActions: const {MediaAction.seek, MediaAction.stop},
androidCompactActionIndices: const [0],
systemActions: {
MediaAction.seek,
MediaAction.stop,
if (colaActiva) MediaAction.skipToPrevious,
if (colaActiva) MediaAction.skipToNext,
},
androidCompactActionIndices: [colaActiva ? 1 : 0],
processingState: _mapProcState(proc),
playing: playing,
bufferedPosition: _player.bufferedPosition,
@@ -453,8 +489,25 @@ class PluriWaveAudioHandler extends BaseAudioHandler
};
}
/// Public entry point for EVERY external play (phone `reproducir`, car
/// `emisora:`/`grupo:`/`pista:`/`eq_preset:` non-path). ALWAYS clears the
/// local queue FIRST (Design ADR-2, the single load-bearing invariant:
/// "external play = leave queue mode") so a stale auto-advance can never
/// fire after an external source switch, then delegates to the private
/// [_encolarCambioFuente] — the SAME source-change path queue playback
/// uses via [_reproducirEntradaCola].
@override
Future<void> playMediaItem(MediaItem mediaItem) async {
_colaLocal = null;
_avanzandoCola = false;
return _encolarCambioFuente(mediaItem);
}
/// The revision-guarded source-change queue (Design ADR-1/ADR-2), shared
/// by public [playMediaItem] (which clears `_colaLocal` first) and
/// [_reproducirEntradaCola] (queue play, which does NOT clear it) — the
/// body is exactly [playMediaItem]'s previous implementation, unchanged.
Future<void> _encolarCambioFuente(MediaItem mediaItem) async {
_intencionReproducir = true;
// Fresh user play/source switch: restart the backoff from scratch and
// leave any previous reconnect window (S7-R2).
@@ -467,6 +520,104 @@ class PluriWaveAudioHandler extends BaseAudioHandler
return _colaCambioFuente;
}
/// Plays a single local-queue track WITHOUT clearing `_colaLocal` (Design
/// ADR-2's "queue play" transition) — the ONLY other caller of
/// [_encolarCambioFuente] besides public [playMediaItem]. Callers are
/// responsible for setting/keeping `_colaLocal` themselves before calling
/// this (queue start, auto-advance, skip) — this method itself never
/// touches the field on the happy path.
///
/// [colaEsperada], when provided, is the mid-await race guard (Design
/// ADR-3's advance flow): after resolving [nodo]'s content URI (the
/// async gap where a user action could replace `_colaLocal`), playback
/// only proceeds when `_colaLocal` is still IDENTICAL to [colaEsperada].
/// Skip/queue-start callers omit it (no prior async gap to guard).
Future<void> _reproducirEntradaCola(
NodoLocal nodo, {
ColaLocal? colaEsperada,
}) async {
final fuente = _fuenteMusicaLocalGlobal;
if (fuente == null) {
if (colaEsperada != null) _avanzandoCola = false;
return;
}
final item = await construirMediaItemColaLocal(nodo, fuente: fuente);
if (colaEsperada != null && !avanceEsValido(_colaLocal, colaEsperada)) {
// Stale: a user action (external play, stop, another skip) replaced
// `_colaLocal` during the await — abort this advance without
// touching whatever the newer action already set.
return;
}
if (item == null) {
// The local track's content URI could not be resolved (revoked
// permission, moved/deleted file). Only an in-flight advance owns
// the latch here (a direct skip/queue-start never armed it) — end
// the queue cleanly instead of leaving `_avanzandoCola` stuck.
if (colaEsperada != null) {
_avanzandoCola = false;
_desactivarCola();
unawaited(stop());
}
return;
}
await _encolarCambioFuente(item);
}
/// Clears the local queue (Design ADR-2/ADR-4): shared by [stop], the
/// end-of-queue path, and a resolve failure mid-advance.
void _desactivarCola() {
_colaLocal = null;
_avanzandoCola = false;
}
/// Sets up a fresh local-music queue and plays its first track (Design
/// "Data Flow" — the `iniciarCola` seam [playFromMediaId] passes to
/// [reproducirCarpetaLocal]).
Future<void> _iniciarColaLocal(List<NodoLocal> pistas) async {
final cola = ColaLocal(pistas: pistas);
_colaLocal = cola;
await _reproducirEntradaCola(cola.actual);
}
/// First line of the `playerStateStream` listener (Design ADR-3, Phase 3
/// task 3.3): pure-decision-driven queue-advance handling. A no-op for
/// radio (never emits `completed`) and single-track local playback
/// (never sets `_colaLocal`) — see [decidirAvanceCola]'s doc comment for
/// the full gating contract.
void _manejarFinPista(ProcessingState proc) {
final decision = decidirAvanceCola(
colaLocal: _colaLocal,
avanzandoCola: _avanzandoCola,
trackCompletado: proc == ProcessingState.completed,
);
switch (decision) {
case DecisionAvanceCola.ninguna:
return;
case DecisionAvanceCola.desactivar:
_desactivarCola();
unawaited(stop());
return;
case DecisionAvanceCola.avanzar:
final siguiente = _colaLocal!.conSiguiente();
if (siguiente == null) {
// Structurally unreachable — decidirAvanceCola already proved
// conSiguiente() != null for `avanzar` — guarded defensively.
_desactivarCola();
unawaited(stop());
return;
}
// Set the re-entry latch synchronously, before any await, so a
// second rapid `completed` emission is caught by
// decidirAvanceCola's `avanzandoCola == true` gate (Design
// ADR-3).
_avanzandoCola = true;
_colaLocal = siguiente;
unawaited(
_reproducirEntradaCola(siguiente.actual, colaEsperada: siguiente),
);
}
}
Future<void> _cambiarFuente(MediaItem mediaItem, int revision) async {
this.mediaItem.add(mediaItem);
emisoraActual = _emisoraDesdeMediaItem(mediaItem);
@@ -705,6 +856,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler
// so retries never restart playback after a stop (S7-R6).
_intencionReproducir = false;
_detenerReconexion();
// Local queue (Design ADR-2): clears alongside the reconnect-cancel
// logic above — `onTaskRemoved` (which calls stop()) inherits this for
// free (Phase 3 task 3.5).
_desactivarCola();
_revisionFuente++;
await _player.stop();
emisoraActual = null;
@@ -715,6 +870,36 @@ class PluriWaveAudioHandler extends BaseAudioHandler
@override
Future<void> seek(Duration position) => _player.seek(position);
/// Moves to the next queued track (Design ADR-3/ADR-4, Phase 3 task
/// 3.6): a no-op when no local queue is active. Past the last track,
/// clears the queue and stops — mirroring auto-advance's end-of-queue
/// behavior (no wraparound).
@override
Future<void> skipToNext() async {
final cola = _colaLocal;
if (cola == null) return;
final siguiente = cola.conSiguiente();
if (siguiente == null) {
_desactivarCola();
await stop();
return;
}
_colaLocal = siguiente;
await _reproducirEntradaCola(siguiente.actual);
}
/// Moves to the previous queued track (Design ADR-4, Phase 3 task 3.6):
/// a no-op when no local queue is active. Clamps at the first track
/// (restarts it) instead of wrapping to the last one.
@override
Future<void> skipToPrevious() async {
final cola = _colaLocal;
if (cola == null) return;
final anterior = cola.conAnterior();
_colaLocal = anterior;
await _reproducirEntradaCola(anterior.actual);
}
@override
Future<void> onTaskRemoved() async {
await stop();
@@ -836,6 +1021,27 @@ class PluriWaveAudioHandler extends BaseAudioHandler
);
return;
}
// Folder-play actions (Design ADR-5, Phase 3 task 4.3): THIRD
// branch, after eq_preset/pista, before the station fallthrough —
// mirrors both branches above's unconditional-return shape so
// neither new action id can fall through to station routing.
final constructorArbol = ConstructorArbolAuto();
final esAccionCarpeta =
constructorArbol.esCarpetaLocalReproducirMediaId(mediaId) ||
constructorArbol.esCarpetaLocalAleatorioMediaId(mediaId);
if (esAccionCarpeta) {
final fuenteLocal = _fuenteMusicaLocalGlobal;
if (fuenteLocal == null) return;
await reproducirCarpetaLocal(
mediaId,
aleatorio: constructorArbol.esCarpetaLocalAleatorioMediaId(
mediaId,
),
fuente: fuenteLocal,
iniciarCola: _iniciarColaLocal,
);
return;
}
final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return;
await reproducirPorMediaId(
@@ -0,0 +1,237 @@
# Apply Progress: Android Auto Local Music — Phase 3 (Folder Queue + Shuffle)
## Status: DONE — all executable tasks complete in a single pass (single PR, `size:exception` per user's explicit choice)
Batch: FIRST and ONLY batch. All phases (0-5) implemented in one pass per
the user's explicit delivery-strategy choice (single PR with `size:exception`,
acknowledged as the highest-risk change of the session). Phase 6 items are
manual follow-up (not executable by this agent) or confirmed not applicable.
## Tasks completed: 24 / 26 executable checklist items (Phase 6.1 is
explicitly manual follow-up per tasks.md; Phase 6.2 confirmed not applicable,
no code change needed)
- Phase 0 (Regression Baseline): 0.1, 0.2 — DONE
- Phase 1 (Pure Foundations — ColaLocal/decidirAvanceCola/avanceEsValido): 1.1, 1.2, 1.3 — DONE
- Phase 2 (Shuffle & Collision): 2.1, 2.2, 2.3 — DONE
- Phase 3 (Handler Isolation Wiring, static-review-only): 3.1-3.8 — DONE
- Phase 4 (Browsable/Playable Actions): 4.1, 4.2, 4.3 — DONE
- Phase 5 (Open Question + Final Regression): 5.1-5.5 — DONE
- Phase 6 (Deviations/Manual Follow-up): 6.1 deferred (manual, `flutter analyze`/`--coverage`/`gen-l10n` not run per strict-TDD scope); 6.2 confirmed N/A (no native/manifest change)
## Files changed
### New
- `lib/servicios/cola_local.dart``ColaLocal` (immutable queue holder),
`DecisionAvanceCola` enum, `decidirAvanceCola`, `avanceEsValido`. Pure
Dart, no `just_audio` import, mirrors the `ControladorReconexion`
extraction pattern so it is fully unit-testable without instantiating
`PluriWaveAudioHandler`.
- `test/servicios/cola_local_test.dart` — 18 tests covering `ColaLocal`
nav/boundaries, `decidirAvanceCola`'s 3-way gate, `avanceEsValido`'s
`identical()` guard.
- `test/servicios/controlador_reconexion_local_test.dart` — 3 tests proving
the design's open-question resolution: a dead local track retries up to
the DEFAULT `maxReintentos: 5` via the existing `ControladorReconexion`
(source-agnostic, no special-casing), fails cleanly on the 6th failure
(no 6th retry, no hang, no crash). Deliberately kept as a SEPARATE file
from `servicio_audio_reconnect_test.dart` so that protected suite's exact
pass count stays byte-identical (see "Regression discipline" below).
### Modified
- `lib/servicios/navegacion_auto.dart`
- `pistasEnOrdenNombre`, `mezclarFisherYates`, `pistasEnOrdenAleatorio`
(Fisher-Yates over the canonical name-sorted order, injected `Random`).
- Two new `ConstructorArbolAuto` prefixes:
`carpeta_local_reproducir:`/`carpeta_local_aleatorio:`, both
PLAYABLE (unlike every other `carpeta_local_*` prefix), with
predicates + strip-by-length codec, collision-proven at index 14
against the 3 sibling `carpeta_local_*` prefixes and index 13 against
`carpeta_local:`.
- `itemsLocales`' page-0 prepend block extended: "Reproducir
carpeta"/"Reproducir aleatorio" prepended BEFORE the sort/bucket nav
entries (per Design ADR-5's explicit ordering), guarded by
`totalPistas > 0` — absent for a folder with only subfolders.
- `reproducirCarpetaLocal` (fetches children, filters/orders, calls
`iniciarCola`; no-op on empty/unresolvable folder) and
`construirMediaItemColaLocal` (resolves a queue track's content URI +
MediaItem, reusing `reproducirPistaLocal`'s title derivation).
- Test file `test/servicios/navegacion_auto_test.dart`: 139 tests total
(was ~120 before this change) — added new groups for the above, PLUS
updated 4 pre-existing tests whose exact item counts/positions shifted
because the new page-0 prepend items are real production behavior
changes (not a regression — this file is not one of the 4 protected
suites):
- "exactamente 50 items": `hasLength(51)``hasLength(53)`.
- "51 a 100 items" pagina0: `hasLength(56)``hasLength(58)`.
- "page-0 mode/bucket prepend" (>150-pistas case): first-5-non-playable
loop replaced with explicit checks (index 0/1 = the two new playable
action items, index 2-6 = calidad/buckets).
- `lib/servicios/servicio_audio.dart` (HIGHEST RISK, static-review-only —
no handler instantiation possible in unit tests) —
- New fields: `ColaLocal? _colaLocal`, `bool _avanzandoCola`.
- `playMediaItem` split: public method now ALWAYS clears
`_colaLocal`/`_avanzandoCola` FIRST, then delegates to new private
`_encolarCambioFuente` (body is the previous `playMediaItem`
implementation, byte-identical).
- New `_reproducirEntradaCola(NodoLocal, {ColaLocal? colaEsperada})`
the ONLY other caller of `_encolarCambioFuente` besides public
`playMediaItem`. `colaEsperada`, when provided, applies the
`avanceEsValido` mid-await race guard AFTER resolving the content URI
and BEFORE playing — functionally identical to the design's advance-flow
pseudocode, refactored so the guard lives inside the sole resolve+play
entry point (avoids a second, redundant URI resolve).
- `_manejarFinPista(ProcessingState)` wired as the FIRST line of the
`playerStateStream` listener, calling `decidirAvanceCola`; sets the
`_avanzandoCola` latch synchronously before any await for the
`avanzar` case; the radio `playing && ready` reset branch gained one
additional line (`_avanzandoCola = false`) alongside its two existing
untouched statements.
- `_desactivarCola()`, `_iniciarColaLocal(List<NodoLocal>)` (the
`iniciarCola` seam for `reproducirCarpetaLocal`).
- `stop()` now also calls `_desactivarCola()``onTaskRemoved` inherits
this for free (it calls `stop()`).
- `skipToNext`/`skipToPrevious` overrides added: no-op when
`_colaLocal == null`; `skipToNext` past the last track deactivates +
stops (Design ADR-4, no wraparound); `skipToPrevious` at index 0
clamps to 0 (restarts the track).
- `playerStateStream`'s `playbackState.add(...)` gated: `controls`/
`systemActions`/`androidCompactActionIndices` only add
skip-previous/skip-next when `_colaLocal != null`; the `null` branch is
byte-identical to the pre-change list (verified: same elements, same
order, same compact index).
- `playFromMediaId` gained a THIRD branch (after `eq_preset:`/`pista:`,
before the station fallthrough) for the two new action prefixes,
calling `reproducirCarpetaLocal` with `iniciarCola: _iniciarColaLocal`.
- Confirmed UNTOUCHED by this change (verified via diff read): `_cambiarFuente`,
`ControladorReconexion` usage/`_reconexion` field, `_esErrorDeRed`,
`_gestionarErrorReproduccion`, `_intentarReconexion`, `_reintentarFuente`.
- `openspec/changes/android-auto-local-music-phase3/tasks.md` — all 24
executable checklist items marked `[x]` with brief inline notes; 6.1 left
unchecked (manual follow-up, explicitly out of this agent's scope).
- `openspec/changes/android-auto-local-music-phase3/design.md` — both Open
Questions marked `[x]` confirmed, with the apply-time resolution recorded
inline.
## Test results (independently re-confirmed via a single combined run)
Combined run of every touched/new test file
(`cola_local_test.dart` + `navegacion_auto_test.dart` +
`controlador_reconexion_local_test.dart` + the 4 protected regression
suites), `flutter test ... --concurrency=1 --timeout=60s`:
```
+181: All tests passed!
```
Per-file breakdown (from the same run, cumulative counters):
| File | Tests | Pass | Fail |
|---|---|---|---|
| `cola_local_test.dart` (new) | 18 | 18 | 0 |
| `navegacion_auto_test.dart` (modified) | 139 | 139 | 0 |
| `controlador_reconexion_local_test.dart` (new) | 3 | 3 | 0 |
| `servicio_audio_reconnect_test.dart` (protected) | 8 | 8 | 0 |
| `servicio_audio_session_test.dart` (protected) | 5 | 5 | 0 |
| `servicio_audio_source_switch_test.dart` (protected) | 3 | 3 | 0 |
| `servicio_audio_eq_reapply_test.dart` (protected) | 5 | 5 | 0 |
| **Total** | **181** | **181** | **0** |
## Regression discipline (Phase 0 baseline vs Phase 5.4 final — the
non-negotiable gate)
Baseline (Phase 0.1, captured BEFORE any code change):
`servicio_audio_reconnect_test.dart` = 8, `servicio_audio_session_test.dart`
= 5, `servicio_audio_source_switch_test.dart` = 3,
`servicio_audio_eq_reapply_test.dart` = 5. Total 21, 0 failures.
Final (Phase 5.4, re-run after ALL code changes, same command, same order):
8 / 5 / 3 / 5 = 21, 0 failures. **EXACT MATCH — same test names, same
order, same counts, byte-identical.** No regression in any of the 4
protected suites. Verified twice independently (once right after the
`servicio_audio.dart` wiring, once as the final Phase 5.4 gate) with
identical results both times.
One deliberate, disclosed exception to "nothing about the 4 protected files
changes": Phase 5.2 required a "5 failures → agotado, no 6th retry, no
hang/crash" test using the DEFAULT `maxReintentos: 5` (the existing test at
`servicio_audio_reconnect_test.dart:115-137` uses an abbreviated
`maxReintentos: 2`). Rather than add a 9th test into the protected file
(which would have changed its pass count from 8 to 9 and technically
violated the "exact pass count" gate), this was added to a NEW,
purpose-built file (`controlador_reconexion_local_test.dart`) instead,
testing the same `ControladorReconexion` class (already fully
unit-testable, source-agnostic by construction). This satisfies task 5.2's
substance without touching the protected file's contents or count at all.
## Diff size
Estimated: prod ~470 lines (`cola_local.dart` ~100 new,
`navegacion_auto.dart` ~180 added, `servicio_audio.dart` ~190 added/changed
net of the split), tests ~950 lines across 3 new/modified test files. Total
~1400+ lines — over the original ~850-950 estimate in tasks.md's Review
Workload Forecast, primarily because of the number of existing
`navegacion_auto_test.dart` assertions that needed explicit updates once
the page-0 prepend behavior changed, plus the dedicated open-question
regression file. Delivered as a single PR with `size:exception` per the
user's explicit, informed choice this session (told this was the
highest-risk change of the day, chose to proceed as one PR anyway).
## Deviations from design (disclosed)
1. **Mid-await race guard placement**: design's ADR-3 pseudocode describes
the `avanceEsValido` guard as gating whether `_reproducirEntradaCola`
gets called at all (guard, THEN call). Implemented instead as an
optional `colaEsperada` parameter checked INSIDE
`_reproducirEntradaCola`, immediately after its own URI resolve and
before calling `_encolarCambioFuente`. This is functionally identical
(same causal order: capture → await → guard → play) and avoids a
redundant second `uriContenidoDePista` channel call, while preserving
task 3.2's stricter acceptance criterion ("`_reproducirEntradaCola` is
the ONLY other caller of `_encolarCambioFuente`").
2. **Resolve-failure handling not explicit in design's happy-path
pseudocode**: when an auto-advance's `construirMediaItemColaLocal`
resolves to `null` (dead/moved/permission-revoked local file, distinct
from a `PlayerException` during actual playback), the queue is
deactivated and stopped rather than left with a stuck `_avanzandoCola`
latch. This is a defensive addition consistent with ADR-4's "no
inconsistent state" principle, not explicitly scripted in the design's
pseudocode.
3. **`androidCompactActionIndices` dynamic index**: not explicitly called
out in tasks.md's acceptance criteria for 3.7, but necessary for
correctness — when `_colaLocal != null`, `skipToPrevious` is prepended
at index 0, so the compact-view highlighted action (play/pause) index
shifts to `1` (`colaActiva ? 1 : 0`) to keep pointing at play/pause
instead of skip-previous. The `_colaLocal == null` branch remains `[0]`,
unchanged.
4. **Phase 5.2 test placement** — see "Regression discipline" above: added
to a new file, not the protected `servicio_audio_reconnect_test.dart`,
to preserve that suite's exact pass count.
## Risks / follow-ups for verify phase
- Phase 3's handler wiring (the highest-risk part of this change) is
static-review-only by design — `PluriWaveAudioHandler` cannot be
instantiated in unit tests. Correctness there rests on (a) the pure
`cola_local.dart` functions being fully unit-tested and (b) careful
manual diff review of the wiring, both completed in this pass, but a
human/verify-phase re-read of `servicio_audio.dart`'s diff is still
warranted given the stakes.
- Phase 6.1 (`flutter analyze`, `flutter test --coverage`, `flutter
gen-l10n`) was explicitly out of scope for this apply pass (strict-TDD
mode: only target test files were run) and remains a manual follow-up
before merge.
- On-device / DHU manual QA for real car transport buttons and a genuine
`ProcessingState.completed` emission is out of scope here (session
precedent, per design's Testing Strategy table) and should be scheduled
separately.
## Skill compliance
- `literal-encoding`: scan run over all touched/new `.dart` files
(`cola_local.dart`, `navegacion_auto.dart`, `servicio_audio.dart`,
`cola_local_test.dart`, `navegacion_auto_test.dart`,
`controlador_reconexion_local_test.dart`) for `Ã.|â€|` — 0 matches,
passed.
- `work-unit-commits`: working tree left UNSTAGED per instruction — the
orchestrator handles commit/push with a `[size:exception]` tag. No
`git commit` was run by this agent.
@@ -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`).
@@ -0,0 +1,232 @@
# Proposal: Android Auto Local Music — Phase 3 (Folder-Scoped Queue + Shuffle)
## Status of the local-music feature (context)
This is the FINAL planned phase of local music in Android Auto. Shipped and
archived already:
- **Phase 1**: SAF folder pick, persisted URI permission, lazy per-level
DocumentFile traversal, `PistaLocal`/`NodoLocal`, filename titles, placeholder
art, browse + play of a single track.
- **Paging fast-follow**: on-demand `carpeta_local_pag:` "Más…" pagination
replacing silent 50-item truncation.
- **Phase 2**: real embedded metadata (native `readAudioMetadataBatch` /
`MediaMetadataRetriever`), `CacheMetadatosSesion` LRU, quality-sort
(`carpeta_local_ord:`), alphabetical name-buckets (`carpeta_local_bucket:`),
embedded album art.
The original Phase-1 proposal sketched Phase 3 as: **"subfolder scoping
refinements + shuffle + transport-control polish."** This proposal traces that
label against the live code and defines precisely what actually remains.
## Honest scope reconciliation (the key finding — read this first)
The original label reads like "two small UX toggles." It is not. Reading the
live playback pipeline shows the real gap:
- **Browsing nested subfolders is already done** (Phase 1's SAF tree traversal).
So "subfolder scoping refinements" is NOT about navigation — that works.
- **There is no multi-track playback of any kind.** `reproducirPistaLocal`
(`navegacion_auto.dart`) builds exactly ONE `MediaItem` and hands it to
`playMediaItem`. `PluriWaveAudioHandler._cambiarFuente`
(`servicio_audio.dart`) does `_recrearPlayer()` + a single
`_player.setUrl(mediaItem.id)`. There is NO `just_audio`
`ConcatenatingAudioSource`, NO `audio_service` `queue`, NO `skipToNext`, NO
auto-advance on track completion. The handler was built for RADIO — one live
stream at a time. **Tapping a local track plays that ONE track and then
stops.** There is no "play this folder", no "next track", nothing.
Therefore both original Phase-3 items collapse onto a single missing
foundation:
- **"subfolder scoping"** honestly resolves to: *play the audio tracks of a
chosen (sub)folder as a scoped queue* — a sequence bounded to that folder.
- **"shuffle"** is the randomized ordering of that same folder-scoped queue.
- **"transport-control polish"** is wiring next/previous so Android Auto's
transport controls advance within that local queue.
**Honest verdict: Phase 3 is ONE foundational feature — a local multi-track
queue inside the audio handler — with two browsable entry points (play folder /
shuffle folder) and transport wiring on top. Shuffle without a queue is
meaningless, so the queue is the real, unavoidable work. It is larger than the
label implies and it touches the app's most sensitive, most heavily-tested
component (the audio handler with its reconnect state machine and EQ chain).**
## Intent
**Problem**: Local music in Android Auto is browse-and-tap-one-track only. A
driver cannot start a folder playing and let it run; every track requires a new
tap, there is no continuous listening, and "shuffle my music" — the single most
common car-music expectation — is impossible because there is no queue to
shuffle.
**Why now**: This is the last planned phase; the browse/metadata/sort surface is
complete and the only remaining promised capability (shuffle, committed in the
very first proposal) requires the queue foundation this phase introduces.
**Success looks like**: From any local (sub)folder in Android Auto, the driver
can tap "Reproducir carpeta" to play that folder's tracks in order, or
"Reproducir aleatorio" to play them shuffled, with playback auto-advancing
track-to-track and the car's next/previous controls moving within the queue —
all reusing the existing EQ signal chain and buffering, with no regression to
radio playback.
## Scope — In (Phase 3)
- **Local multi-track queue foundation** in `PluriWaveAudioHandler`: an ordered
list of pending local tracks + current index, auto-advance to the next track
on completion, and `skipToNext`/`skipToPrevious` wired to move within it. A
mode boundary keeps this active ONLY for local-queue playback, never for
radio (live streams never "complete").
- **Two browsable, folder-scoped play actions**, prepended on page 0 of a local
folder (same prepend precedent as Phase 2's sort/bucket entries):
- **"Reproducir carpeta"** — the folder's direct audio children, name-ordered.
- **"Reproducir aleatorio"** — the same set, shuffled.
- **New playable media-id families** for those actions (design settles exact
strings; must be collision-free against the existing `pista:` / `emisora:` /
`grupo:` / `eq_preset:` / `carpeta_local[_pag|_ord|_bucket]:` prefixes,
reusing the proven strip-by-length + split-on-first-colon codec so raw SAF
documentIds with `:` / `/` survive verbatim).
- **Queue building** scoped to the tapped folder's DIRECT audio children
(non-directory `NodoLocal`s), sorted by name for sequential and randomized
(seeded, deterministically testable) for shuffle.
- **Content-URI resolution per advance** via the existing
`resolvePlayableUri` channel method — lazy, one track at a time as the queue
advances (no eager whole-folder resolution, no new native method).
- Pure-Dart unit tests for shuffle ordering (seeded), queue construction, action
media-id encode/decode + collision guards, and page-0 action prepend; handler
tests for completion-advance and skip within the local-queue mode boundary.
## Scope — Out (explicitly deferred / rejected)
- **Recursive whole-subtree queue.** The queue is scoped to the tapped folder's
direct audio children only — consistent with the lazy per-level enumeration
model and every prior phase's rejection of eager subtree scans. Enqueuing a
whole nested subtree would require recursive native traversal; out of scope.
- **OS-level shuffle toggle** (`AudioService.setShuffleMode` /
`AudioServiceShuffleMode`). The package supports it, but it presupposes a
persistent OS play-queue with a shuffle affordance in the transport UI — a
model this app's browse-tree, tap-to-select integration does not use (the
notification exposes only play/pause/stop). Shuffle is therefore exposed as a
browsable ACTION (the EQ-preset-folder precedent), not an OS toggle. Design
may revisit, but the browsable action is the recommended fit.
- **`ConcatenatingAudioSource` rebuild of the player pipeline** — see Approach;
recommended AGAINST in favor of the lighter app-managed queue.
- **Cross-folder / persisted / resumable queues**, queue reordering UI, repeat
modes. Not promised, not in scope.
- **New phone-UI strings / l10n work.** Car-tree labels are hardcoded Spanish
by established precedent ("Favoritos", "Música Local", "Más…", "Ordenar por
calidad"). "Reproducir carpeta" / "Reproducir aleatorio" are hardcoded Spanish
car labels — **NO new `.arb` keys, NO 13-locale change.** If design surfaces a
genuinely new PHONE-UI string, it must be scoped into all 13 locales
explicitly; none is anticipated.
## Approach (with rationale)
**Recommended: an app-managed local queue that reuses the existing single-source
pipeline — do NOT rebuild the player around `ConcatenatingAudioSource`.**
The handler recreates the player + EQ on every source change
(`_recrearPlayer()` inside `_cambiarFuente`) and wraps source switches in a
revision-guarded queue built for live-stream reconnect. That machinery is the
app's most delicate, most-tested code. Bolting a `just_audio`
`ConcatenatingAudioSource` + `audio_service` `queue` + `setShuffleMode` onto it
fights the recreate-per-source model and the reconnect state machine head-on —
high blast radius on the one component we least want to destabilize.
Instead, keep the single-source player and add a thin queue LAYER in the
handler: hold the ordered local-track list + index, listen for
`ProcessingState.completed` (which only fires for finite local files, never for
radio) to advance by re-driving the existing `_cambiarFuente`, and override
`skipToNext`/`skipToPrevious` to move the index and re-drive the same path. A
mode flag scopes all of this to local-queue playback so radio behavior is
byte-identical. This mirrors Phase-2/EQ philosophy: expose the capability
through the existing seams and a browsable action, rather than adopting a
platform model the app's integration doesn't actually use.
All ordering, queue-building, media-id codec, and action-item logic stays pure
Dart in `navegacion_auto.dart` (fully unit-testable, seeded shuffle). The
handler change is the only stateful/coupled part and gets its own focused tests
behind the mode boundary. No new native method (lazy `resolvePlayableUri` per
advance), no new dependency, no manifest/pubspec change anticipated.
The design phase must settle: (1) app-managed queue vs `ConcatenatingAudioSource`
(recommendation: app-managed); (2) exact completion/advance and skip semantics
against the reconnect machine; (3) the two new media-id prefixes and their
collision proof; (4) shuffle seed strategy for deterministic tests.
## Affected areas (anticipated — design confirms)
- `lib/servicios/servicio_audio.dart` (Modify): local-queue state, completion
auto-advance, `skipToNext`/`skipToPrevious`, mode boundary, `playFromMediaId`
branch for the new action ids. **The sensitive change — main risk surface.**
- `lib/servicios/navegacion_auto.dart` (Modify): action media-id prefixes +
predicates + codec, queue-building (name-order / seeded-shuffle) from
`NodoLocal`s, page-0 action-item prepend, play orchestration.
- `lib/servicios/musica_local_auto.dart` (Possibly): a folder-tracks helper if
the orchestration needs one; likely reuses existing `hijos` + `resolvePlayableUri`.
- `android/.../MainActivity.kt`: **no change anticipated** (lazy per-advance
resolution reuses `resolvePlayableUri`).
- `AndroidManifest.xml` / `pubspec.yaml` / `lib/l10n/*.arb`: **no change
anticipated.**
## Risks
- **Audio-handler blast radius (HIGH)**: the queue layer lives in the most
sensitive, most-tested component. Mitigation: strict mode boundary so radio is
provably untouched; app-managed layer over pipeline rebuild; focused
regression tests. This is the dominant risk.
- **Completion-advance vs reconnect machine (MED-HIGH)**: `ProcessingState.completed`
handling must not collide with the live-stream stall/retry logic. Design must
prove the two paths are disjoint (mode flag).
- **Not runtime-verifiable here (HIGH, session precedent)**: no DHU/on-device
Android Auto in this environment; queue/skip/completion behavior is
static-review + unit-test only, same as all prior native/handler work this
session. Real transport-control behavior in the car cannot be exercised here.
- **Scope/size (MED)**: honestly larger than the "subfolder + shuffle" label.
See estimate below.
- **Shuffle determinism (LOW)**: seed the shuffle so tests are stable; a
fixed/injectable seed avoids flaky ordering assertions.
## Size estimate & delivery (flag early, per session pattern)
Comparable to or larger than Phase 2 (~2000 lines) because it modifies the audio
handler core and adds handler-level tests on top of the pure-Dart surface.
**Realistically a single `size:exception` PR, consistent with every prior local-music
change this session (Phase 1 ~1300, paging ~800, Phase 2 ~2000, all
single size:exception PRs).**
Natural work-unit split if chaining is preferred: (1) **queue foundation +
"Reproducir carpeta" sequential playback + transport wiring** (the bulk / the
real foundation), then (2) **"Reproducir aleatorio" shuffle** (a thin
ordering variant once the queue exists). Shuffle is small once the foundation
lands. Surface this to the delivery-strategy guard before apply.
## Rollback plan
Additive. Rollback = remove the new action media-id prefixes/predicates/codec,
the queue-building and page-0 action prepend in `navegacion_auto.dart`, and the
queue layer (state, completion-advance, skip overrides, mode flag) in
`servicio_audio.dart`. Single-track `pista:` playback and all radio playback
revert untouched.
## Success criteria
- [ ] "Reproducir carpeta" and "Reproducir aleatorio" appear on page 0 of a
local folder that has audio tracks; absent when it has none.
- [ ] Tapping "Reproducir carpeta" plays the folder's direct audio children in
name order, auto-advancing track to track.
- [ ] Tapping "Reproducir aleatorio" plays the same set shuffled; ordering is
deterministic under a fixed seed in tests.
- [ ] Car next/previous transport controls move within the local queue.
- [ ] Radio playback, reconnect, and EQ behavior are provably unchanged (mode
boundary; regression tests green).
- [ ] Pure-Dart logic fully unit-tested; handler queue behavior tested behind
the mode boundary; native (if any) static-reviewed. No new `.arb` keys.
## Next recommended
`sdd-spec` and `sdd-design` can run in parallel from this proposal. Design must
settle the app-managed-queue vs `ConcatenatingAudioSource` decision, the
completion/skip semantics against the reconnect machine, and the two new
media-id prefixes with a collision proof before `sdd-tasks`.
@@ -0,0 +1,115 @@
# Delta for Android Auto Media — Local Music Phase 3 (Folder Queue + Shuffle)
## ADDED Requirements
### Requirement: Folder-Scoped Sequential Play Action
The local-music browse tree MUST prepend a "Reproducir carpeta" playable item on page 0 of any local folder that has at least one direct audio-file child (`NodoLocal` non-directory). Selecting it MUST build a queue of that folder's direct audio children in filename order and start sequential playback.
#### Scenario: Folder has tracks
- GIVEN a local folder has one or more direct audio-file children
- WHEN page 0 is browsed
- THEN "Reproducir carpeta" is prepended, and selecting it plays those tracks in name order
#### Scenario: Folder has no tracks
- GIVEN a local folder has zero direct audio-file children
- WHEN page 0 is browsed
- THEN no "Reproducir carpeta" item is returned
### Requirement: Folder-Scoped Shuffled Play Action
The local-music browse tree MUST prepend a "Reproducir aleatorio" playable item alongside "Reproducir carpeta", under the same folder-has-tracks condition. Selecting it MUST build a queue of the same direct-audio-children set in shuffled order and start playback.
#### Scenario: Folder has tracks
- GIVEN a local folder has one or more direct audio-file children
- WHEN page 0 is browsed
- THEN "Reproducir aleatorio" is prepended, and selecting it plays the same set shuffled
#### Scenario: Deterministic under seed
- GIVEN a fixed shuffle seed is injected for testing
- WHEN the queue is built for a given track set
- THEN the resulting order is reproducible across runs with that seed
### Requirement: Local Queue Auto-Advance and Transport Skip
While a local-music queue (from either action) is active, `PluriWaveAudioHandler` MUST auto-advance to the next queued track when the current track finishes, and MUST move within the queue when `skipToNext`/`skipToPrevious` is invoked.
#### Scenario: Track completes mid-queue
- GIVEN the current track is not the last one in the queue
- WHEN it finishes
- THEN the handler advances to and plays the next track automatically
#### Scenario: Skip within queue
- GIVEN a local-music queue is active
- WHEN `skipToNext`/`skipToPrevious` is invoked, including at the first or last track
- THEN the queue index moves accordingly and the corresponding track plays without throwing
### Requirement: Local Queue Mode Isolation From Radio Playback (Regression Guard — CRITICAL)
The local-music queue mechanism (auto-advance, skip-within-queue, queue state) MUST be scoped by an explicit mode boundary and MUST NEVER activate for, interfere with, or alter radio playback, reconnection, or any other non-local-music playback path. Switching between an active local queue and radio playback, in either direction, MUST cleanly stop/replace the previously active mode. This is the highest-priority regression requirement of this change.
#### Scenario: Radio starts while a local queue is active
- GIVEN a local-music queue is actively auto-advancing
- WHEN the user selects a station (`emisora:<uuid>`) or a radio-group entry
- THEN the local queue mode stops cleanly (no further auto-advance or skip behavior leaks)
- AND radio playback starts and behaves exactly as before this feature, including reconnection
#### Scenario: Local queue starts while radio is active
- GIVEN a station is currently playing
- WHEN the user selects "Reproducir carpeta" or "Reproducir aleatorio"
- THEN radio playback and its reconnect state exit cleanly
- AND the local-music queue starts playing with auto-advance/skip active
#### Scenario: Radio reconnect logic is provably untouched
- GIVEN no local-music queue has been started in the current session
- WHEN a live stream stalls and reconnects, or errors
- THEN reconnect behavior is byte-identical to pre-feature behavior, since `ProcessingState.completed` (the queue-advance trigger) never fires for radio streams
#### Scenario: Single-track local playback (`pista:<id>`) is unaffected
- GIVEN the user taps a single track directly, not via a folder-play action
- WHEN that track finishes
- THEN no queue auto-advance occurs, matching existing Phase 1/2 single-track behavior
### Requirement: Local Queue End-of-Queue Behavior
When the last track in an active local-music queue finishes, playback MUST stop cleanly rather than loop back to the first track or leave the handler in an inconsistent state, consistent with repeat modes being out of scope for this phase.
#### Scenario: Last track finishes
- GIVEN the current track is the last one in the queue
- WHEN it finishes
- THEN playback stops with no wraparound to the first track
- AND handler state remains consistent for a subsequent, independent play action
### Requirement: New Action Media-IDs Are Collision-Free
The media-ids for "Reproducir carpeta" and "Reproducir aleatorio" MUST use prefixes distinct from every existing media-id family: `emisora:`, `grupo:`, `eq_preset:`, `carpeta_local:`, `carpeta_local_pag:`, `carpeta_local_ord:`, `carpeta_local_bucket:`, `pista:`.
#### Scenario: Prefix uniqueness
- GIVEN the full set of existing prefixes plus the two new action prefixes
- WHEN each prefix is compared against every other
- THEN none duplicates or substring-collides with another
#### Scenario: Raw documentIds with `:`/`/` survive round-trip
- GIVEN a folder's SAF documentId contains `:` or `/` characters
- WHEN an action media-id is encoded and later decoded via `playFromMediaId`
- THEN the original documentId is recovered verbatim, reusing the existing strip-by-length + split-on-first-colon codec
## Out of Scope (explicit boundary — not requirements of this change)
- Recursive whole-subtree queueing; only a folder's direct audio children are queued.
- OS-level shuffle-mode toggle (`AudioService.setShuffleMode`); shuffle is exposed only as a browsable action.
- Repeat modes.
- Cross-folder, persisted, or resumable queues; queue-reordering UI.
@@ -0,0 +1,192 @@
# Tasks: Android Auto Local Music — Phase 3 (Folder Queue + Shuffle)
## CRITICAL — Testability Gap vs Design (must-read before Phase 2/3)
`PluriWaveAudioHandler` **cannot be instantiated in unit tests** (its constructor
builds a real `just_audio.AudioPlayer` needing platform `MethodChannel`s —
confirmed by `servicio_audio_eq_reapply_test.dart:12-15` and
`servicio_audio_source_switch_test.dart:13`; no test in the repo constructs it
directly). Design's "Handler (integration)" test row ("mocked player/fuente")
is therefore not achievable as written. Fix: extract the queue-advance
decisions into a NEW pure module (`lib/servicios/cola_local.dart`), mirroring
how `ControladorReconexion` was extracted from the same file — pure logic gets
real unit tests; the actual field mutation on `PluriWaveAudioHandler` is
**static-review-only**, verified against explicit acceptance criteria below
(same precedent as the EQ re-apply listener wiring).
## Review Workload Forecast
| Field | Value |
|-------|-------|
| Estimated changed lines | ~850-950 (prod ~440: cola_local.dart ~90, navegacion_auto.dart ~150, servicio_audio.dart ~150; tests ~470) |
| 400-line budget risk | High |
| Chained PRs recommended | Yes |
| Suggested split | PR 1 (queue foundation + sequential play + transport) → PR 2 (shuffle variant) |
| Delivery strategy | ask-on-risk |
| Chain strategy | pending (ask user) |
Decision needed before apply: Yes
Chained PRs recommended: Yes
Chain strategy: pending
400-line budget risk: High
**Split recommendation overrides line-count alone**: even if the line count
came in lower, split PR 1/PR 2 anyway — PR 1 is the only unit that touches
`PluriWaveAudioHandler`'s shared error/completion listener; isolating it lets
a reviewer verify the radio-regression guarantees without shuffle-logic noise.
### Suggested Work Units
| Unit | Goal | Likely PR | Notes |
|------|------|-----------|-------|
| 1 | `ColaLocal` + pure advance-decision + handler isolation/auto-advance/transport wiring + "Reproducir carpeta" + full regression re-run | PR 1 | Gate: all 4 existing suites byte-identical pass count before merge |
| 2 | Fisher-Yates shuffle + "Reproducir aleatorio" + collision test | PR 2, base = PR 1 branch | No handler changes beyond routing the new prefix through the existing seam |
## Phase 0: Regression Baseline (run BEFORE any code change)
- [x] 0.1 Run `test/servicios/servicio_audio_reconnect_test.dart`,
`servicio_audio_session_test.dart`, `servicio_audio_source_switch_test.dart`,
`servicio_audio_eq_reapply_test.dart`; record exact pass counts per file.
Baseline: 8 / 5 / 3 / 5 = 21 total, 0 failures.
- [x] 0.2 Saved the baseline pass counts as the comparison target for Phase 5.4
(see apply-progress.md).
## Phase 1: Pure Foundations (`lib/servicios/cola_local.dart`, new)
- [x] 1.1 RED: write `ColaLocal` tests — `conSiguiente`/`conAnterior` on empty,
single-item, mid-list, and boundary (first/last index) cases.
GREEN: implement immutable `ColaLocal` per design's contract. REFACTOR.
`lib/servicios/cola_local.dart`, `test/servicios/cola_local_test.dart`.
- [x] 1.2 RED: write `decidirAvanceCola` tests — pure function
`(colaLocal, avanzandoCola, trackCompletado) -> DecisionAvanceCola
{ninguna|avanzar|desactivar}`. Cover: `colaLocal==null` → `ninguna`
(radio-isolation proxy, since radio never sets `_colaLocal`);
`avanzandoCola==true``ninguna` (double-advance latch, simulate two
rapid `trackCompletado` calls); `conSiguiente()==null``desactivar`.
GREEN: implement in `cola_local.dart` (no `just_audio` import — caller
maps `ProcessingState.completed` to the bool). REFACTOR.
- [x] 1.3 RED: write `avanceEsValido(colaLocalActual, siguienteEsperado)` tests
`identical()` on two structurally-equal-but-distinct `ColaLocal`
instances must return `false` (guards against an accidental `==` swap
in the mid-await race guard). GREEN: implement as thin `identical()`
wrapper. REFACTOR. Gotcha found: `const` instances canonicalize in
Dart, must use non-const `ColaLocal(...)` for the distinct-instance
test case (documented in the test and saved to engram).
## Phase 2: Pure Foundations — Shuffle & Collision (do NOT block Phase 3-5)
- [x] 2.1 RED: `mezclarFisherYates(List<NodoLocal>, Random)` tests —
determinism under fixed seed (`Random(42)` twice → identical output
order) and distribution sanity (1000 runs of a 5-item list, assert
every item appears in every position at least once). GREEN: implement
Fisher-Yates over the name-sorted list. REFACTOR.
- [x] 2.2 RED: `pistasEnOrdenAleatorio`/`pistasEnOrdenNombre` tests. GREEN.
- [x] 2.3 RED: collision-freedom test — for each of `carpeta_local_reproducir:`
and `carpeta_local_aleatorio:`, assert `startsWith` is false against
ALL 8 existing prefixes (`emisora:`, `grupo:`, `eq_preset:`,
`carpeta_local:`, `carpeta_local_pag:`, `carpeta_local_ord:`,
`carpeta_local_bucket:`, `pista:`) and vice-versa; assert first
diverging char at index 14 (`r`/`a` vs `p`/`o`/`b`). GREEN: add the two
`const` prefixes + predicates + strip-by-length codec in
`navegacion_auto.dart`.
## Phase 3: Handler Isolation Wiring — `servicio_audio.dart` (HIGHEST RISK)
Static-review-only (no handler instantiation possible). Verify each item by
reading the diff against its acceptance criterion; do not mark done without
the read.
- [x] 3.1 Split `playMediaItem`: public method clears
`_colaLocal = null; _avanzandoCola = false;` FIRST, then delegates to
new private `_encolarCambioFuente(item)` (current body of
`playMediaItem` minus the two new clear lines).
**Acceptance**: every existing external call site (`ServicioAudio.reproducir`,
`reproducirPorMediaId`, `reproducirPistaLocal`, `eq_preset` non-path)
still routes through the public method unchanged.
- [x] 3.2 Add `_reproducirEntradaCola(NodoLocal)`: sets/keeps `_colaLocal`,
calls `_encolarCambioFuente` WITHOUT clearing. **Acceptance**: this is
the only other caller of `_encolarCambioFuente` besides public
`playMediaItem`.
- [x] 3.3 Wire `_manejarFinPista(ProcessingState proc)` as the first line of
the existing `playerStateStream` listener (`servicio_audio.dart:253`),
calling `decidirAvanceCola` (1.2) with
`trackCompletado: proc == ProcessingState.completed`. **Acceptance**:
for every decision, `avanzar` sets `_avanzandoCola = true` synchronously
before any `await`; `desactivar` calls `_desactivarCola()` (clears
`_colaLocal`) + `stop()`; `ninguna` is a no-op — radio's `playing &&
ready` reset branch (line 256-260) is untouched.
- [x] 3.4 Wire the advance flow per ADR-3: compute `siguiente =
cola.conSiguiente()`; capture it; `await uriContenidoDePista`; guard
with `avanceEsValido(_colaLocal, siguiente)` (1.3) before calling
`_reproducirEntradaCola`; reset `_avanzandoCola` when the next track
reaches `playing && ready`.
- [x] 3.5 `stop()` (`servicio_audio.dart:702`) clears `_colaLocal = null;
_avanzandoCola = false;` alongside existing reconnect-cancel logic.
**Acceptance**: `onTaskRemoved` (calls `stop()`) inherits this for free.
- [x] 3.6 `skipToNext`/`skipToPrevious` overrides: no-op when
`_colaLocal == null`; otherwise call `conSiguiente`/`conAnterior` (1.1
already proves clamping at boundaries) and `_reproducirEntradaCola`.
- [x] 3.7 Gate `controls`/`systemActions` in the `playerStateStream` listener:
add `MediaControl.skipToPrevious/skipToNext` only when
`_colaLocal != null`. **Acceptance**: read the diff and confirm the
`_colaLocal == null` branch is byte-identical to the pre-change
control/action list (radio regression guard).
- [x] 3.8 `eq_preset:` branch in `playFromMediaId`
(`servicio_audio.dart:812-824`) stays FIRST, unconditional `return`,
untouched — confirm no `_colaLocal` reference was added to it.
## Phase 4: Browsable/Playable Action Items — `navegacion_auto.dart`
- [x] 4.1 RED: `itemsLocales` page-0 prepend tests — folder with ≥1 direct
audio child prepends "Reproducir carpeta" + "Reproducir aleatorio"
(both `playable: true`, prefixed ids); folder with 0 direct audio
children (only subfolders) prepends neither (empty-folder edge case).
GREEN: extend the page-0 prepend block (`navegacion_auto.dart:448-457`)
mirroring `ofreceOrdenCalidad`'s guard shape.
- [x] 4.2 RED/GREEN: `reproducirCarpetaLocal(id, {aleatorio, fuente, rng,
iniciarCola})` — fetches `fuente.hijos(docId)`, filters non-directory,
sorts/shuffles, calls `iniciarCola`; no-op on empty/unresolvable
folder (fake `FuenteMusicaLocalAuto`, no handler needed).
- [x] 4.3 Wire the two new prefixes into `playFromMediaId`
(`servicio_audio.dart:801`) as a THIRD branch (after `eq_preset:` and
`pista:`, before the station fallthrough), calling
`reproducirCarpetaLocal` with `iniciarCola` = a private helper that
sets `_colaLocal` and calls `_reproducirEntradaCola` (3.2).
Static-review-only for this wiring line.
## Phase 5: Open Question + Final Regression
- [x] 5.1 Confirm (static read, no code change): `ControladorReconexion`
(`controlador_reconexion.dart`) has no source-type parameter in
`registrarFallo` — it structurally cannot special-case a local-track
error vs a radio error, so leaving it untouched is provably safe.
Confirmed by direct read; untouched.
- [x] 5.2 Grep `servicio_audio_reconnect_test.dart` for existing
`maxReintentos`/`agotado` coverage (confirmed present: lines 115-137,
using an abbreviated `maxReintentos: 2`). The default-`maxReintentos:
5` "no 6th retry, no hang/crash" case was missing, so it was ADDED —
but in a NEW file (`test/servicios/controlador_reconexion_local_test.dart`),
not inside the protected `servicio_audio_reconnect_test.dart`, so that
suite's exact pass count (8) stays byte-identical for Phase 5.4's
regression gate. 3 new tests there prove: 5 failures schedule 5
backoff retries, the 6th returns `agotado` with no new timer created,
and all timers end cancelled (no hang).
- [x] 5.3 Updated design's Open Questions checklist (see design.md edit in
this same apply pass): both items confirmed — `ControladorReconexion`
left untouched; deactivate + stop on terminal error/unresolvable URI.
- [x] 5.4 Re-ran all 4 suites from Phase 0.1; EXACT pass counts match the
Phase 0.2 baseline byte-for-byte: reconnect 8/8, session 5/5,
source_switch 3/3, eq_reapply 5/5 — 21/21 total, 0 failures, same
test names/order. No mismatch.
- [x] 5.5 Ran the `literal-encoding` corruption scan over all touched/new
`.dart` files — no `Ã.|â€|` matches found.
## Phase 6: Deviations / Manual Follow-up (not executable here)
- [ ] 6.1 `flutter analyze`, `flutter test --coverage`, `flutter gen-l10n`
manual follow-up, not run by this agent (per strict-TDD scope: only
the target test files were run, per instruction).
- [x] 6.2 Native Kotlin: none touched by this change (confirmed — design's
File Changes table lists no native/manifest changes; no native file
was touched during apply) — static-review only, no code change
needed.
@@ -0,0 +1,77 @@
# Verification Report: android-auto-local-music-phase3
**Verdict: PASS WITH WARNINGS**
## Priority Investigation Verdict
### 1. Regression-suite byte-identical claim - VERIFIED TRUE
Independently re-ran the combined suite:
`flutter test test/servicios/cola_local_test.dart test/servicios/navegacion_auto_test.dart test/servicios/controlador_reconexion_local_test.dart test/servicios/servicio_audio_reconnect_test.dart test/servicios/servicio_audio_session_test.dart test/servicios/servicio_audio_source_switch_test.dart test/servicios/servicio_audio_eq_reapply_test.dart --concurrency=1 --timeout=60s`
Result: `+181: All tests passed!`
Per-file breakdown counted directly from the run output: cola_local=18, navegacion_auto=139, controlador_reconexion_local=3, reconnect=8, session=5, source_switch=3, eq_reapply=5, sum 181, matching apply-progress exactly, including the 4 protected suites at 8/5/3/5=21.
Stronger confirmation than requested: `git status --porcelain` and `git diff --stat` on the 4 protected test files (servicio_audio_reconnect_test.dart, servicio_audio_session_test.dart, servicio_audio_source_switch_test.dart, servicio_audio_eq_reapply_test.dart) returned completely empty - these files are byte-for-byte untouched by this change, not merely "same pass count." This is the strongest possible proof available.
### 2. Isolation mechanism - traced from actual code, not the design doc's description
- (a) playMediaItem (lib/servicios/servicio_audio.dart:499-504) unconditionally sets `_colaLocal = null; _avanzandoCola = false;` on every call before delegating to `_encolarCambioFuente`. No branch, no early return, no code path skips this.
- (b) `git diff` on servicio_audio.dart shows zero lines changed inside `_cambiarFuente`, `_esErrorDeRed`, `_gestionarErrorReproduccion`, `_intentarReconexion`, `_reintentarFuente`, `_detenerReconexion`, or the `_reconexion` (ControladorReconexion) field usage. Confirmed genuinely untouched by direct diff inspection, not by trusting the design/apply-progress claim.
- (c) Concrete scenario traced by reading the code: local queue is active (`_colaLocal != null`), user/car taps `emisora:<uuid>` leads to `playFromMediaId`, which is not `eq_preset:`/`pista:`/folder-action, falls to `reproducirPorMediaId(mediaId, fuente, reproducir: playMediaItem)` (navegacion_auto.dart:794-818), which does `await reproducir(item)` = `playMediaItem(item)`, which clears `_colaLocal`/`_avanzandoCola` then delegates to `_encolarCambioFuente`. After this, any later `_manejarFinPista` call computes `decidirAvanceCola(colaLocal: null, ...)`, which returns `ninguna` unconditionally (first two guard checks in `decidirAvanceCola`). No auto-advance can fire. Confirmed by code trace, not by assumption.
### 3. Auto-advance correctness
- (a) `ProcessingState.completed` handling gated by `decidirAvanceCola` (cola_local.dart:80-91): `!trackCompletado` returns `ninguna`; `colaLocal == null` returns `ninguna`; `avanzandoCola` returns `ninguna`; else `avanzar`/`desactivar`. No path fires when the queue is inactive.
- (b) The re-entry latch has a real test (cola_local_test.dart:105-124): it calls `decidirAvanceCola` twice, simulating two rapid completion events, first with `avanzandoCola: false` (expects `avanzar`), second with `avanzandoCola: true` (expects `ninguna`). This is a genuine state-transition test exercising the actual pure function, not a mock that always behaves nicely.
- (c) `playerStateStream` (Stream<PlayerState>, carries `processingState`) and `playbackEventStream`'s `onError` callback (fires only with Object error, StackTrace) are structurally disjoint Dart Streams from just_audio's AudioPlayer API (`_conectarStreamsPlayer`, servicio_audio.dart:266-341). `ProcessingState.completed` is a value inside `PlayerState`, never an error object, so it structurally cannot flow through the `onError` channel. Verified via code read, not via a comment's assertion.
### 4. Self-reported deviations - all verified genuine, none weaken the design's safety intent
- Deviation 1 (`avanceEsValido` folded into `_reproducirEntradaCola` via optional `colaEsperada` param instead of an external gate): code preserves the identical causal order the design intended, capture candidate, await URI resolve, `avanceEsValido` identity guard, then play. Functionally equivalent to the design's guard-then-call pseudocode, not a weaker approximation.
- Deviation 2 (defensive deactivate+stop on unresolvable local-track URI during auto-advance): `_desactivarCola()` is idempotent (always sets both `_colaLocal`/`_avanzandoCola`), and `stop()` is safe to call redundantly. No inconsistent state is reachable.
- Deviation 3 (`androidCompactActionIndices` made dynamic): verified byte-identical for radio, `colaActiva == false` gives `[0]`, matching the pre-change `const [0]`; `colaActiva == true` gives `[1]`, correctly re-pointing at play/pause since skip-previous is prepended at index 0 when the queue is active. Minor SUGGESTION: the list is no longer a compile-time const (a new 1-element list is allocated on every `playbackState.add` call), negligible perf nitpick, zero functional impact.
- Deviation 4 (5-failures-default-maxReintentos test placed in new file controlador_reconexion_local_test.dart instead of the protected servicio_audio_reconnect_test.dart): file exists, contains 3 real tests using a fake Timer injection, genuinely asserting bounded-retry behavior (5 retries scheduled, 6th returns `agotado` with no new timer, all timers end cancelled, no hang). Not a stub; independently re-run and passed. Placement choice is reasonable and does not hide a regression, the protected file has literally zero diff.
### 5. Open-question regression test
controlador_reconexion_local_test.dart genuinely exercises `ControladorReconexion.registrarFallo` with the default `maxReintentos: 5` (source-agnostic, no local-track special-casing possible since the method takes no source-type parameter), asserts `agotado` on the 6th failure, no new timer created, and all timers end cancelled. Real, not trivial, independently re-run and passing.
## Normal Verification Checklist
6. Media-id collision-freedom: both new prefixes (`carpeta_local_reproducir:`, `carpeta_local_aleatorio:`) confirmed `playable: true` in `_itemReproducirCarpeta`/`_itemReproducirAleatorio` (navegacion_auto.dart:517-535). An exhaustive collision test (navegacion_auto_test.dart:358-430) checks both directions against all 8 existing prefixes/ids (emisora:, grupo:, eq_preset:, carpeta_local:, carpeta_local_pag:, carpeta_local_ord:, carpeta_local_bucket:, pista:).
7. Fisher-Yates shuffle: `mezclarFisherYates` confirmed to run over `pistasEnOrdenNombre`'s canonical name-sorted order (not native enumeration order), with an injectable Random. Determinism test (`Random(42)` twice yields identical order) and distribution sanity test (1000 runs, every position occupied by every item at least once) both present, real, and passing.
8. End-of-queue: STOP + deactivate (no loop) implemented in `_manejarFinPista`'s `desactivar` case and `skipToNext`'s end-of-queue branch; `skipToPrevious` clamps at index 0 (no wraparound). This wiring is static-review-only, PluriWaveAudioHandler cannot be instantiated in unit tests (confirmed: no `PluriWaveAudioHandler()` construction anywhere in the test suite), a pre-existing, disclosed testability gap from tasks.md, not a new finding.
9. Browsable action items: the empty-folder-shows-no-action-item edge case (folder with only subfolders yields neither playable action prepended) is genuinely tested (navegacion_auto_test.dart:1681-1709).
10. No AI attribution, no debug prints, no dead code, no real leftover TODOs found in changed files (one grep false-positive matched the Spanish word TODOS inside a test description string).
11. Working tree state: confirmed nothing committed or staged. `git diff --cached --stat` is empty; `git log -1` shows the pre-existing HEAD (85dd043 docs archive android-auto-local-music-phase2); all changes are unstaged modified/untracked files, matching apply-progress's stated intent to leave the tree unstaged for the orchestrator.
12. Diff size: `git diff --stat` = 957 lines across 3 tracked modified files, plus 435 lines across 3 untracked new files (cola_local.dart 103, cola_local_test.dart 181, controlador_reconexion_local_test.dart 151) = approximately 1392 lines total, consistent with apply-progress's claim of roughly 1400+ lines.
13. Independently re-ran the full claimed 181/181 test count across all touched/new + protected files, confirmed exact match, zero failures.
## Issues by Severity
### CRITICAL
None found.
### WARNING
- Phase 3 handler wiring (highest-risk part of this change) is static-review-only by unavoidable environment constraint. PluriWaveAudioHandler cannot be instantiated in unit tests because its constructor builds a real just_audio.AudioPlayer requiring platform MethodChannels. This means a genuine ProcessingState.completed firing, real car transport button presses (skipToNext/skipToPrevious), and the actual runtime behavior of the mid-await avanceEsValido race guard have never been exercised by an executable test in this environment, correctness rests entirely on (1) the pure, fully-unit-tested cola_local.dart decision functions and (2) careful manual diff review, both of which this verify pass independently re-confirmed. This is a genuine residual risk requiring human on-device/DHU (Desktop Head Unit) confirmation before shipping, not a formality. The design and apply-progress both already flag this gap; this verify pass confirms it is real and has not been concealed or downplayed.
- Phase 6.1 deferred: flutter analyze, flutter test --coverage, flutter gen-l10n were explicitly not run per Strict TDD scope (only target test files were run). Must be run manually before merge.
- Single-PR delivery with size:exception (~1400+ lines, over the original ~850-950 estimate in tasks.md's Review Workload Forecast), an informed, disclosed choice by the user this session (told this was the highest-risk change of the day, chose to proceed as one PR anyway), not a new finding, restated here as a review-workload note for whoever reviews the PR.
### SUGGESTION
- androidCompactActionIndices is no longer a compile-time const list (was const [0], now [colaActiva ? 1 : 0]), allocates a new 1-element list on every playbackState.add call. Zero functional impact, negligible GC pressure; not worth blocking on.
## Test Evidence
Combined run (independently executed, not trusted from apply-progress):
```
flutter test test/servicios/cola_local_test.dart test/servicios/navegacion_auto_test.dart test/servicios/controlador_reconexion_local_test.dart test/servicios/servicio_audio_reconnect_test.dart test/servicios/servicio_audio_session_test.dart test/servicios/servicio_audio_source_switch_test.dart test/servicios/servicio_audio_eq_reapply_test.dart --concurrency=1 --timeout=60s
```
`+181: All tests passed!` (0 failures)
Literal-encoding scan (skill literal-encoding) run over all touched/new .dart files (cola_local.dart, navegacion_auto.dart, servicio_audio.dart, cola_local_test.dart, navegacion_auto_test.dart, controlador_reconexion_local_test.dart), zero corruption matches found.
## Next Recommended
sdd-archive - no CRITICAL findings block archival, but the WARNING about on-device/DHU confirmation for the static-review-only handler wiring should be explicitly carried into the archive record as an accepted, tracked risk, not silently dropped.
+181
View File
@@ -0,0 +1,181 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/pista_local.dart';
import 'package:pluriwave/servicios/cola_local.dart';
/// [PluriWaveAudioHandler] cannot be instantiated in unit tests (its
/// constructor builds a real `just_audio.AudioPlayer` needing platform
/// `MethodChannel`s — confirmed by `servicio_audio_eq_reapply_test.dart` and
/// `servicio_audio_source_switch_test.dart`). `cola_local.dart` extracts the
/// queue-advance DECISION logic into pure, side-effect-free functions —
/// mirroring how `ControladorReconexion` was extracted from
/// `servicio_audio.dart` — so it is fully unit-testable here; the actual
/// field mutation on the handler is static-review-only (Phase 3 tasks).
void main() {
const n0 = NodoLocal(documentId: 'd0', nombre: 'a.mp3', esDirectorio: false);
const n1 = NodoLocal(documentId: 'd1', nombre: 'b.mp3', esDirectorio: false);
const n2 = NodoLocal(documentId: 'd2', nombre: 'c.mp3', esDirectorio: false);
group('ColaLocal', () {
test('cola vacía: hayActual es false, conSiguiente es null', () {
const cola = ColaLocal(pistas: []);
expect(cola.hayActual, isFalse);
expect(cola.conSiguiente(), isNull);
});
test('cola vacía: conAnterior se clampa en 0 sin lanzar', () {
const cola = ColaLocal(pistas: []);
expect(cola.conAnterior().indice, 0);
});
test('un solo item: conSiguiente es null (ya es el único/último)', () {
const cola = ColaLocal(pistas: [n0]);
expect(cola.hayActual, isTrue);
expect(cola.actual, same(n0));
expect(cola.conSiguiente(), isNull);
});
test('un solo item: conAnterior se clampa en 0 (reinicia la pista)', () {
const cola = ColaLocal(pistas: [n0]);
expect(cola.conAnterior().indice, 0);
});
test('medio de la lista: conSiguiente avanza el índice y expone la '
'pista siguiente', () {
const cola = ColaLocal(pistas: [n0, n1, n2], indice: 0);
final siguiente = cola.conSiguiente();
expect(siguiente, isNotNull);
expect(siguiente!.indice, 1);
expect(siguiente.actual, same(n1));
});
test('medio de la lista: conAnterior retrocede el índice', () {
const cola = ColaLocal(pistas: [n0, n1, n2], indice: 2);
final anterior = cola.conAnterior();
expect(anterior.indice, 1);
expect(anterior.actual, same(n1));
});
test('límite: último índice, conSiguiente es null (fin de cola, sin '
'loop — Design ADR-4)', () {
const cola = ColaLocal(pistas: [n0, n1], indice: 1);
expect(cola.conSiguiente(), isNull);
});
test('límite: primer índice, conAnterior se clampa en 0 (no da '
'negativo)', () {
const cola = ColaLocal(pistas: [n0, n1], indice: 0);
expect(cola.conAnterior().indice, 0);
});
test('conSiguiente/conAnterior devuelven una instancia NUEVA, nunca '
'mutan la original', () {
const original = ColaLocal(pistas: [n0, n1, n2], indice: 0);
final siguiente = original.conSiguiente();
expect(original.indice, 0, reason: 'la instancia original no cambia');
expect(siguiente, isNot(same(original)));
});
});
group('decidirAvanceCola', () {
test('colaLocal == null => ninguna (proxy de aislamiento de radio: '
'radio nunca setea _colaLocal)', () {
expect(
decidirAvanceCola(
colaLocal: null,
avanzandoCola: false,
trackCompletado: true,
),
DecisionAvanceCola.ninguna,
);
});
test('trackCompletado == false => ninguna, sin importar el resto '
'(estado buffering/ready no dispara avance)', () {
const cola = ColaLocal(pistas: [n0, n1]);
expect(
decidirAvanceCola(
colaLocal: cola,
avanzandoCola: false,
trackCompletado: false,
),
DecisionAvanceCola.ninguna,
);
});
test('avanzandoCola == true => ninguna (latch de re-entrada: dos '
'emisiones "completado" rápidas producen un solo avance)', () {
const cola = ColaLocal(pistas: [n0, n1]);
final primera = decidirAvanceCola(
colaLocal: cola,
avanzandoCola: false,
trackCompletado: true,
);
expect(primera, DecisionAvanceCola.avanzar);
// Simula una segunda emisión "completado" mientras el primer avance
// sigue resolviendo su URI (el llamador ya seteó el latch en true).
final segunda = decidirAvanceCola(
colaLocal: cola,
avanzandoCola: true,
trackCompletado: true,
);
expect(segunda, DecisionAvanceCola.ninguna);
});
test('conSiguiente() == null => desactivar (fin de cola)', () {
const cola = ColaLocal(pistas: [n0, n1], indice: 1);
expect(
decidirAvanceCola(
colaLocal: cola,
avanzandoCola: false,
trackCompletado: true,
),
DecisionAvanceCola.desactivar,
);
});
test('hay una pista siguiente => avanzar', () {
const cola = ColaLocal(pistas: [n0, n1], indice: 0);
expect(
decidirAvanceCola(
colaLocal: cola,
avanzandoCola: false,
trackCompletado: true,
),
DecisionAvanceCola.avanzar,
);
});
});
group('avanceEsValido', () {
test('la misma instancia => true', () {
const cola = ColaLocal(pistas: [n0]);
expect(avanceEsValido(cola, cola), isTrue);
});
test('instancias distintas aunque estructuralmente iguales => false '
'(guarda contra un swap accidental de == por identical)', () {
// Non-const construction is deliberate: two `const` instances with
// identical field values are canonicalized to the SAME object by
// Dart, which would make this assertion pass for the wrong reason.
// A real mid-await race produces two genuinely distinct instances
// (e.g. one from an auto-advance, one from a user's `playMediaItem`
// during the await), so non-const `ColaLocal(...)` is the accurate
// reproduction.
final a = ColaLocal(pistas: [n0, n1], indice: 1);
final b = ColaLocal(pistas: [n0, n1], indice: 1);
expect(avanceEsValido(a, b), isFalse);
});
test('null vs no-null => false', () {
const cola = ColaLocal(pistas: [n0]);
expect(avanceEsValido(null, cola), isFalse);
expect(avanceEsValido(cola, null), isFalse);
});
test('ambos null => true (ningún lado tiene cola activa)', () {
expect(avanceEsValido(null, null), isTrue);
});
});
}
@@ -0,0 +1,151 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/controlador_reconexion.dart';
/// Local-music design open question (`android-auto-local-music-phase3`
/// design.md "Open Questions"): local-track source errors during
/// queue-advance (a dead/moved/permission-revoked file) enter the SAME
/// `ControladorReconexion` bounded-retry machine live radio uses, unchanged
/// — the design's recommendation is to leave `ControladorReconexion`
/// untouched rather than add queue-awareness to the sensitive error path
/// (Phase 5 task 5.1/5.2).
///
/// [ControladorReconexion.registrarFallo] takes NO source-type parameter
/// (confirmed by reading `controlador_reconexion.dart` — Phase 5 task 5.1's
/// static review), so it structurally CANNOT special-case a local-track
/// error vs a radio error: this file proves the bounded-retry contract
/// directly against the controller (source-agnostic by construction), with
/// the DEFAULT `maxReintentos: 5` this design decision relies on — a
/// "dead local track" retries up to 5 times then fails cleanly, with no 6th
/// retry, no hang, and no crash.
///
/// Kept as a SEPARATE file from `servicio_audio_reconnect_test.dart`
/// (rather than adding a case there) so that protected regression suite's
/// exact pass count stays byte-identical across this change (Phase 5 task
/// 5.4's exact-count re-verification gate).
class _TemporizadorFalso implements Timer {
_TemporizadorFalso(this.duracion, this.callback);
final Duration duracion;
final void Function() callback;
bool cancelado = false;
@override
void cancel() => cancelado = true;
@override
bool get isActive => !cancelado;
@override
int get tick => 0;
}
void main() {
group(
'ControladorReconexion — pista local muerta (design open question, '
'maxReintentos default = 5)',
() {
test(
'5 fallos consecutivos => reintentar x5 con backoff, luego agotado '
'en el 6to, SIN programar un 6to reintento, sin lanzar (no hang, '
'no crash)',
() {
final temporizadores = <_TemporizadorFalso>[];
final controlador = ControladorReconexion(
crearTemporizador: (duracion, callback) {
final timer = _TemporizadorFalso(duracion, callback);
temporizadores.add(timer);
return timer;
},
);
for (var i = 1; i <= 5; i++) {
final decision = controlador.registrarFallo(
intencionReproducir: true,
alReintentar: () {},
);
expect(
decision,
DecisionReconexion.reintentar,
reason: 'el intento $i de 5 debe programar un reintento',
);
}
expect(controlador.intentos, 5);
expect(temporizadores, hasLength(5));
final sexto = controlador.registrarFallo(
intencionReproducir: true,
alReintentar: () =>
fail('no debe programar un 6to reintento tras agotar'),
);
expect(sexto, DecisionReconexion.agotado);
expect(
temporizadores,
hasLength(5),
reason: 'ningún temporizador nuevo se creó para el 6to fallo',
);
expect(
controlador.reintentoPendiente,
isFalse,
reason: 'no queda ningún reintento pendiente tras agotar (sin '
'hang)',
);
},
);
test(
'tras agotar, TODOS los temporizadores (los 5 backoff + el estado '
'final) quedan cancelados — ninguno sigue activo (no hang)',
() {
final temporizadores = <_TemporizadorFalso>[];
final controlador = ControladorReconexion(
crearTemporizador: (duracion, callback) {
final timer = _TemporizadorFalso(duracion, callback);
temporizadores.add(timer);
return timer;
},
);
for (var i = 1; i <= 5; i++) {
controlador.registrarFallo(
intencionReproducir: true,
alReintentar: () {},
);
}
controlador.registrarFallo(
intencionReproducir: true,
alReintentar: () => fail('agotado: no debe reintentar'),
);
expect(
temporizadores.every((t) => t.cancelado),
isTrue,
reason: 'cada temporizador se cancela cuando el siguiente fallo '
'programa uno nuevo, y el último se cancela explícitamente '
'al agotar (ControladorReconexion.cancelar())',
);
},
);
test(
'registrarFallo no recibe ningún parámetro de tipo de fuente — '
'estructuralmente no puede distinguir una pista local de una '
'emisora de radio (Phase 5 task 5.1, revisión estática confirmada '
'por este test de firma)',
() {
// Documentación ejecutable: si esta llamada compila con
// EXACTAMENTE estos 2 named parameters, la firma no tiene (ni
// tuvo) un parámetro de tipo de fuente agregado.
final controlador = ControladorReconexion();
final decision = controlador.registrarFallo(
intencionReproducir: true,
alReintentar: () {},
);
expect(decision, DecisionReconexion.reintentar);
},
);
},
);
}
+582 -8
View File
@@ -1,3 +1,5 @@
import 'dart:math' show Random;
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
@@ -350,6 +352,125 @@ void main() {
);
});
group(
'esCarpetaLocalReproducirMediaId / esCarpetaLocalAleatorioMediaId '
'(Design ADR-5, Phase 3)',
() {
test(
'reconocen sus prefijos, no colisionan entre sí ni con NINGUNO de '
'los 8 prefijos/ids existentes (Spec "New Action Media-IDs Are '
'Collision-Free")',
() {
final builder = ConstructorArbolAuto();
expect(
builder.esCarpetaLocalReproducirMediaId(
'carpeta_local_reproducir:doc1',
),
isTrue,
);
expect(
builder.esCarpetaLocalAleatorioMediaId(
'carpeta_local_aleatorio:doc1',
),
isTrue,
);
final existentes = <String>[
'emisora:x',
'grupo:g1',
'eq_preset:Rock',
'carpeta_local:doc1',
'carpeta_local_pag:0:doc1',
'carpeta_local_ord:calidad:0:doc1',
'carpeta_local_bucket:0:0:doc1',
'pista:doc1',
];
for (final id in existentes) {
expect(
builder.esCarpetaLocalReproducirMediaId(id),
isFalse,
reason: 'esCarpetaLocalReproducirMediaId($id) debería ser '
'false',
);
expect(
builder.esCarpetaLocalAleatorioMediaId(id),
isFalse,
reason: 'esCarpetaLocalAleatorioMediaId($id) debería ser '
'false',
);
}
// Y en la dirección inversa: ninguno de los 8 predicados
// existentes reconoce las 2 nuevas acciones.
const reproducir = 'carpeta_local_reproducir:doc1';
const aleatorio = 'carpeta_local_aleatorio:doc1';
expect(builder.esCarpetaLocalMediaId(reproducir), isFalse);
expect(builder.esCarpetaLocalMediaId(aleatorio), isFalse);
expect(
builder.esCarpetaLocalPaginadaMediaId(reproducir),
isFalse,
);
expect(builder.esCarpetaLocalOrdMediaId(reproducir), isFalse);
expect(builder.esCarpetaLocalBucketMediaId(reproducir), isFalse);
expect(esPistaMediaId(reproducir), isFalse);
expect(esPresetMediaId(reproducir), isFalse);
// Divergencia en el índice 14 (justo después de "carpeta_local_"):
// 'r' (reproducir) / 'a' (aleatorio) vs 'p' (_pag) / 'o' (_ord) /
// 'b' (_bucket) — prueba de colisión de Design ADR-5.
expect(reproducir[14], 'r');
expect(aleatorio[14], 'a');
expect('carpeta_local_pag:0:doc1'[14], 'p');
expect('carpeta_local_ord:calidad:0:doc1'[14], 'o');
expect('carpeta_local_bucket:0:0:doc1'[14], 'b');
// 'carpeta_local:' diverge en el índice 13 (':' vs '_').
expect('carpeta_local:doc1'[13], ':');
expect(reproducir[13], '_');
},
);
test(
'idCarpetaLocalReproducirDesde / idCarpetaLocalAleatorioDesde '
'recortan por longitud, preservando un documentId con ":" y "/" '
'verbatim (Spec "Raw documentIds with :/ survive round-trip")',
() {
final builder = ConstructorArbolAuto();
const docId = 'primary:Music/Local Folder';
expect(
builder.idCarpetaLocalReproducirDesde(
'carpeta_local_reproducir:$docId',
),
docId,
);
expect(
builder.idCarpetaLocalAleatorioDesde(
'carpeta_local_aleatorio:$docId',
),
docId,
);
},
);
test(
'tail vacío (empty tail) hace round-trip a la raíz local',
() {
final builder = ConstructorArbolAuto();
expect(
builder.idCarpetaLocalReproducirDesde('carpeta_local_reproducir:'),
'',
);
expect(
builder.idCarpetaLocalAleatorioDesde('carpeta_local_aleatorio:'),
'',
);
},
);
},
);
group('artUriLocal', () {
test(
'reproduce la misma rotación que artUriPara/indiceArtePara, sembrada '
@@ -713,9 +834,11 @@ void main() {
);
// 50 pistas: <=150 -> entrada de calidad presente; exactamente 50
// NO dispara buckets (umbral es "> 50", Design ADR-4).
// NO dispara buckets (umbral es "> 50", Design ADR-4). totalPistas
// > 0 también antepone las 2 acciones "Reproducir carpeta"/
// "Reproducir aleatorio" (Design ADR-5, Phase 3) -> 2 + 1 + 50 = 53.
final pistas = items.where((i) => i.id.startsWith('pista:')).toList();
expect(items, hasLength(51));
expect(items, hasLength(53));
expect(pistas, hasLength(50));
expect(pistas.first.title, 'cancion_00');
expect(pistas.last.title, 'cancion_49');
@@ -742,10 +865,12 @@ void main() {
documentIdPadre: 'x',
metadatosDe: _metadatosVacio,
);
// 60 pistas: <=150 -> entrada de calidad; >50 -> 4 buckets;
// prepended antes de las 50 pistas + "Más…" (Design ADR-4).
// 60 pistas: <=150 -> entrada de calidad; >50 -> 4 buckets; y las 2
// acciones "Reproducir carpeta"/"Reproducir aleatorio" (Design
// ADR-5, Phase 3) -- todo prepended antes de las 50 pistas + "Más…"
// (Design ADR-4). 2 + 1 + 4 + 50 + 1 = 58.
final pistas0 = pagina0.where((i) => i.id.startsWith('pista:')).toList();
expect(pagina0, hasLength(56));
expect(pagina0, hasLength(58));
expect(pistas0, hasLength(50));
expect(pistas0.first.title, 'cancion_00');
expect(pistas0[49].title, 'cancion_49');
@@ -1069,6 +1194,144 @@ void main() {
});
});
group('pistasEnOrdenNombre', () {
test('excluye directorios y ordena por nombre (mismo comparador que '
'itemsLocales usa para la vista página 0)', () {
final nodos = [
const NodoLocal(documentId: 'd-c', nombre: 'c.mp3', esDirectorio: false),
const NodoLocal(
documentId: 'd-carpeta',
nombre: 'AAA Carpeta',
esDirectorio: true,
),
const NodoLocal(documentId: 'd-a', nombre: 'a.mp3', esDirectorio: false),
const NodoLocal(documentId: 'd-b', nombre: 'b.mp3', esDirectorio: false),
];
final ordenados = pistasEnOrdenNombre(nodos);
expect(
ordenados.map((n) => n.documentId).toList(),
['d-a', 'd-b', 'd-c'],
);
});
test('no muta la lista original', () {
final original = [
const NodoLocal(documentId: 'd-b', nombre: 'b.mp3', esDirectorio: false),
const NodoLocal(documentId: 'd-a', nombre: 'a.mp3', esDirectorio: false),
];
final copia = List<NodoLocal>.from(original);
pistasEnOrdenNombre(original);
expect(
original.map((n) => n.documentId).toList(),
copia.map((n) => n.documentId).toList(),
);
});
test('lista vacía devuelve lista vacía, sin lanzar', () {
expect(pistasEnOrdenNombre(const []), isEmpty);
});
});
group('mezclarFisherYates / pistasEnOrdenAleatorio (Design ADR-6)', () {
List<NodoLocal> nodosDePrueba(int n) => List.generate(
n,
(i) => NodoLocal(
documentId: 'd$i',
nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3',
esDirectorio: false,
),
);
test('determinismo bajo semilla fija: Random(42) dos veces produce el '
'MISMO orden', () {
final nodos = nodosDePrueba(10);
final orden1 = mezclarFisherYates(nodos, Random(42));
final orden2 = mezclarFisherYates(nodos, Random(42));
expect(
orden1.map((n) => n.documentId).toList(),
orden2.map((n) => n.documentId).toList(),
);
});
test('no muta la lista original y devuelve una lista distinta', () {
final original = nodosDePrueba(5);
final copia = List<NodoLocal>.from(original);
final mezclado = mezclarFisherYates(original, Random(1));
expect(
original.map((n) => n.documentId).toList(),
copia.map((n) => n.documentId).toList(),
);
expect(mezclado, isNot(same(original)));
});
test('sanidad de distribución: en 1000 corridas sobre una lista de 5, '
'cada item aparece en cada posición al menos una vez', () {
final nodos = nodosDePrueba(5);
final posicionesVistas = List.generate(5, (_) => <String>{});
for (var corrida = 0; corrida < 1000; corrida++) {
final mezclado = mezclarFisherYates(nodos, Random(corrida));
for (var pos = 0; pos < mezclado.length; pos++) {
posicionesVistas[pos].add(mezclado[pos].documentId);
}
}
for (final vistos in posicionesVistas) {
expect(vistos, hasLength(5), reason: 'cada posición debería haber '
'visto los 5 documentIds posibles a lo largo de 1000 corridas');
}
});
test('lista vacía devuelve lista vacía, sin lanzar', () {
expect(mezclarFisherYates(const [], Random(1)), isEmpty);
});
test('un solo elemento devuelve una lista de un elemento, sin lanzar', () {
final nodos = nodosDePrueba(1);
expect(
mezclarFisherYates(nodos, Random(1)).map((n) => n.documentId),
['d0'],
);
});
test('pistasEnOrdenAleatorio mezcla SOBRE el orden canónico por nombre '
'(excluye directorios primero, luego mezcla)', () {
final nodos = [
const NodoLocal(
documentId: 'd-carpeta',
nombre: 'AAA Carpeta',
esDirectorio: true,
),
...nodosDePrueba(5),
];
final resultado = pistasEnOrdenAleatorio(nodos, Random(7));
expect(resultado, hasLength(5));
expect(resultado.every((n) => !n.esDirectorio), isTrue);
});
test('pistasEnOrdenAleatorio es determinístico bajo la misma semilla', () {
final nodos = nodosDePrueba(8);
final r1 = pistasEnOrdenAleatorio(nodos, Random(99));
final r2 = pistasEnOrdenAleatorio(nodos, Random(99));
expect(
r1.map((n) => n.documentId).toList(),
r2.map((n) => n.documentId).toList(),
);
});
});
group('ConstructorArbolAuto.ofreceOrdenCalidad', () {
test(
'boundary de _maxPistasParaOrdenCalidad (150): 149 y 150 ofrecen la '
@@ -1288,9 +1551,14 @@ void main() {
pagina0.where((i) => i.id.startsWith('carpeta_local_bucket:')),
hasLength(4),
);
// Prepended ANTES de la lista ordenada: los primeros 5 items son
// modo+buckets, no pistas.
for (var i = 0; i < 5; i++) {
// Prepended ANTES de la lista ordenada: los primeros 2 items son
// las acciones de reproducción (Design ADR-5, Phase 3, PLAYABLE),
// seguidas por los 5 items modo+buckets (no playable, no pistas).
expect(pagina0[0].id, startsWith('carpeta_local_reproducir:'));
expect(pagina0[0].playable, isTrue);
expect(pagina0[1].id, startsWith('carpeta_local_aleatorio:'));
expect(pagina0[1].playable, isTrue);
for (var i = 2; i < 7; i++) {
expect(pagina0[i].playable, isFalse);
}
@@ -1372,6 +1640,105 @@ void main() {
);
});
group(
'ConstructorArbolAuto.itemsLocales: page-0 folder-play actions '
'(Design ADR-5, Phase 3 task 4.1)',
() {
test(
'folder con >=1 pista de audio directa antepone "Reproducir '
'carpeta" + "Reproducir aleatorio", ambas playable:true, con id '
'prefijado (Spec "Folder has tracks")',
() async {
final nodos = [
const NodoLocal(
documentId: 'd-a',
nombre: 'a.mp3',
esDirectorio: false,
),
];
final pagina0 = await ConstructorArbolAuto().itemsLocales(
nodos,
documentIdPadre: 'padre1',
metadatosDe: _metadatosVacio,
);
final reproducir = pagina0.where(
(i) => i.id.startsWith('carpeta_local_reproducir:'),
);
final aleatorio = pagina0.where(
(i) => i.id.startsWith('carpeta_local_aleatorio:'),
);
expect(reproducir, hasLength(1));
expect(aleatorio, hasLength(1));
expect(reproducir.single.id, 'carpeta_local_reproducir:padre1');
expect(reproducir.single.playable, isTrue);
expect(aleatorio.single.id, 'carpeta_local_aleatorio:padre1');
expect(aleatorio.single.playable, isTrue);
},
);
test(
'folder con 0 pistas de audio directas (solo subcarpetas) NO '
'antepone ninguna de las 2 acciones (Spec "Folder has no tracks", '
'edge case de carpeta vacía)',
() async {
final nodos = [
const NodoLocal(
documentId: 'd-sub',
nombre: 'Subcarpeta',
esDirectorio: true,
),
];
final pagina0 = await ConstructorArbolAuto().itemsLocales(
nodos,
documentIdPadre: 'padre1',
metadatosDe: _metadatosVacio,
);
expect(
pagina0.where((i) => i.id.startsWith('carpeta_local_reproducir:')),
isEmpty,
);
expect(
pagina0.where((i) => i.id.startsWith('carpeta_local_aleatorio:')),
isEmpty,
);
},
);
test('las acciones solo aparecen en página 0, nunca en páginas '
'siguientes', () async {
final nodos = List.generate(
60,
(i) => NodoLocal(
documentId: 'd$i',
nombre: 'cancion_${i.toString().padLeft(2, '0')}.mp3',
esDirectorio: false,
),
);
final builder = ConstructorArbolAuto();
final pagina1 = await builder.itemsLocales(
nodos,
documentIdPadre: 'padre1',
pagina: 1,
metadatosDe: _metadatosVacio,
);
expect(
pagina1.where((i) => i.id.startsWith('carpeta_local_reproducir:')),
isEmpty,
);
expect(
pagina1.where((i) => i.id.startsWith('carpeta_local_aleatorio:')),
isEmpty,
);
});
},
);
group('ConstructorArbolAuto.itemsLocalesOrdenCalidad', () {
test(
'ordena TODA la carpeta por bitrate desc vía UN batch de metadatosDe '
@@ -1908,6 +2275,213 @@ void main() {
);
});
group('reproducirCarpetaLocal (Design ADR-5/ADR-6, Phase 3 task 4.2)', () {
List<NodoLocal> nodosDePrueba() => const [
NodoLocal(documentId: 'd-c', nombre: 'c.mp3', esDirectorio: false),
NodoLocal(documentId: 'd-a', nombre: 'a.mp3', esDirectorio: false),
NodoLocal(documentId: 'd-b', nombre: 'b.mp3', esDirectorio: false),
NodoLocal(
documentId: 'd-carpeta',
nombre: 'AAA Carpeta',
esDirectorio: true,
),
];
test(
'secuencial (aleatorio: false): filtra directorios, ordena por '
'nombre y llama iniciarCola con esa lista',
() async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {'carpeta1': nodosDePrueba()},
);
List<NodoLocal>? recibidas;
await reproducirCarpetaLocal(
'carpeta_local_reproducir:carpeta1',
aleatorio: false,
fuente: fuente,
iniciarCola: (pistas) async => recibidas = pistas,
);
expect(recibidas, isNotNull);
expect(
recibidas!.map((n) => n.documentId).toList(),
['d-a', 'd-b', 'd-c'],
);
},
);
test(
'aleatorio (aleatorio: true): filtra directorios y llama iniciarCola '
'con el orden Fisher-Yates producido por el rng inyectado',
() async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {'carpeta1': nodosDePrueba()},
);
List<NodoLocal>? recibidas;
await reproducirCarpetaLocal(
'carpeta_local_aleatorio:carpeta1',
aleatorio: true,
fuente: fuente,
rng: Random(42),
iniciarCola: (pistas) async => recibidas = pistas,
);
expect(recibidas, isNotNull);
expect(recibidas, hasLength(3));
expect(recibidas!.every((n) => !n.esDirectorio), isTrue);
expect(
recibidas!.map((n) => n.documentId).toList(),
pistasEnOrdenAleatorio(nodosDePrueba(), Random(42))
.map((n) => n.documentId)
.toList(),
);
},
);
test(
'carpeta vacía (sin hijos) es un no-op: iniciarCola nunca se llama',
() async {
final fuente = _FakeFuenteMusicaLocalAuto();
var llamadas = 0;
await reproducirCarpetaLocal(
'carpeta_local_reproducir:vacia',
aleatorio: false,
fuente: fuente,
iniciarCola: (pistas) async => llamadas++,
);
expect(llamadas, 0);
},
);
test(
'carpeta con solo subcarpetas (sin pistas de audio directas) es un '
'no-op: iniciarCola nunca se llama',
() async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {
'carpeta1': const [
NodoLocal(
documentId: 'd-sub',
nombre: 'Subcarpeta',
esDirectorio: true,
),
],
},
);
var llamadas = 0;
await reproducirCarpetaLocal(
'carpeta_local_reproducir:carpeta1',
aleatorio: false,
fuente: fuente,
iniciarCola: (pistas) async => llamadas++,
);
expect(llamadas, 0);
},
);
test(
'carpeta irresoluble (fuente.hijos lanza) es un no-op, sin propagar '
'la excepción',
() async {
final fuente = _FakeFuenteMusicaLocalAuto(
errorEnHijos: Exception('permiso revocado'),
);
var llamadas = 0;
await reproducirCarpetaLocal(
'carpeta_local_reproducir:carpeta1',
aleatorio: false,
fuente: fuente,
iniciarCola: (pistas) async => llamadas++,
);
expect(llamadas, 0);
},
);
test('id sin ninguno de los 2 prefijos es un no-op', () async {
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {'carpeta1': nodosDePrueba()},
);
var llamadas = 0;
await reproducirCarpetaLocal(
'carpeta_local:carpeta1',
aleatorio: false,
fuente: fuente,
iniciarCola: (pistas) async => llamadas++,
);
expect(llamadas, 0);
});
test(
'strip por longitud preserva un documentId con ":" verbatim (round '
'trip), consultando fuente.hijos con el documentId correcto',
() async {
const docId = 'primary:Music/Local Folder';
final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: {docId: nodosDePrueba()},
);
var llamadas = 0;
await reproducirCarpetaLocal(
'carpeta_local_reproducir:$docId',
aleatorio: false,
fuente: fuente,
iniciarCola: (pistas) async => llamadas++,
);
expect(llamadas, 1);
},
);
});
group('construirMediaItemColaLocal', () {
test(
'resuelve el content uri y construye un MediaItem con el título '
'derivado del documentId',
() async {
final fuente = _FakeFuenteMusicaLocalAuto(
uriPorDocId: const {'doc1': 'content://provider/doc1'},
);
const nodo = NodoLocal(
documentId: 'doc1',
nombre: 'ignorado.mp3',
esDirectorio: false,
);
final item = await construirMediaItemColaLocal(nodo, fuente: fuente);
expect(item, isNotNull);
expect(item!.id, 'content://provider/doc1');
},
);
test(
'documentId irresoluble (uriContenidoDePista devuelve null) '
'devuelve null, sin lanzar',
() async {
final fuente = _FakeFuenteMusicaLocalAuto();
const nodo = NodoLocal(
documentId: 'doc-fantasma',
nombre: 'x.mp3',
esDirectorio: false,
);
final item = await construirMediaItemColaLocal(nodo, fuente: fuente);
expect(item, isNull);
},
);
});
group('ConstructorArbolAuto.itemPresetEq', () {
test('mapea un PresetEcualizador a un item reproducible con id '
'eq_preset:<nombre>', () {