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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user