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
+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