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(