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