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