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);