feat(auto): play a local-music folder's subfolders recursively too
totalPistas counted only DIRECT audio children, so "Reproducir carpeta"/ "Aleatorio" were hidden for a folder that contains only subfolders, and playing a folder queued only its direct tracks. Add a bounded recursive walk (pistasRecursivas) that collects every track beneath a folder, depth-first, sorted by name at each level. Bounded on two independent axes to keep a single tap's native SAF round-trips and in-memory list size predictable on a deep or wide library: - depth: 4 levels below the tapped folder (profundidadMaximaRecursivaLocal) - count: 500 tracks total (limitePistasRecursivasLocal) The folder-play/shuffle actions are now offered whenever the recursive count is > 0, and "Reproducir carpeta"/"Aleatorio" queue everything found, not just direct children.
This commit is contained in:
@@ -2334,8 +2334,9 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'carpeta con solo subcarpetas (sin pistas de audio directas) es un '
|
||||
'no-op: iniciarCola nunca se llama',
|
||||
'carpeta con solo subcarpetas (sin pistas de audio directas), y esa '
|
||||
'subcarpeta TAMBIÉN está vacía incluso recursivamente, es un no-op: '
|
||||
'iniciarCola nunca se llama',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
@@ -2361,6 +2362,43 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'item 2: carpeta con SOLO una subcarpeta que sí tiene pistas -- las '
|
||||
'recolecta RECURSIVAMENTE y llama iniciarCola con ellas (antes era '
|
||||
'un no-op)',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'carpeta1': const [
|
||||
NodoLocal(
|
||||
documentId: 'd-sub',
|
||||
nombre: 'Subcarpeta',
|
||||
esDirectorio: true,
|
||||
),
|
||||
],
|
||||
'd-sub': const [
|
||||
NodoLocal(
|
||||
documentId: 'd-honda',
|
||||
nombre: 'honda.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
List<NodoLocal>? recibidas;
|
||||
|
||||
await reproducirCarpetaLocal(
|
||||
'carpeta_local_reproducir:carpeta1',
|
||||
aleatorio: false,
|
||||
fuente: fuente,
|
||||
iniciarCola: (pistas) async => recibidas = pistas,
|
||||
);
|
||||
|
||||
expect(recibidas, isNotNull);
|
||||
expect(recibidas!.map((n) => n.documentId).toList(), ['d-honda']);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'carpeta irresoluble (fuente.hijos lanza) es un no-op, sin propagar '
|
||||
'la excepción',
|
||||
@@ -3000,6 +3038,363 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Item 2: Android Auto recursive folder play ─────────────────────────
|
||||
// totalPistas used to count only DIRECT audio children, so "Reproducir
|
||||
// carpeta"/"Aleatorio" were hidden for a folder that contains only
|
||||
// subfolders, and playing a folder queued only its direct tracks. Fix: a
|
||||
// bounded recursive walk collects every track beneath a folder, and the
|
||||
// play actions are offered whenever that recursive count is > 0.
|
||||
group('pistasRecursivas (Design "recursive folder play", item 2)', () {
|
||||
test(
|
||||
'flat folder (no subfolders): same tracks as the direct-children-'
|
||||
'only view, sorted by name',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'root': const [
|
||||
NodoLocal(documentId: 'd-c', nombre: 'c.mp3', esDirectorio: false),
|
||||
NodoLocal(documentId: 'd-a', nombre: 'a.mp3', esDirectorio: false),
|
||||
NodoLocal(documentId: 'd-b', nombre: 'b.mp3', esDirectorio: false),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
final resultado = await pistasRecursivas('root', fuente: fuente);
|
||||
|
||||
expect(
|
||||
resultado.map((n) => n.documentId).toList(),
|
||||
['d-a', 'd-b', 'd-c'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a folder that contains ONLY subfolders: recursively collects the '
|
||||
'tracks from every one of them',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'root': const [
|
||||
NodoLocal(documentId: 'sub-a', nombre: 'A', esDirectorio: true),
|
||||
NodoLocal(documentId: 'sub-b', nombre: 'B', esDirectorio: true),
|
||||
],
|
||||
'sub-a': const [
|
||||
NodoLocal(documentId: 't1', nombre: 'uno.mp3', esDirectorio: false),
|
||||
],
|
||||
'sub-b': const [
|
||||
NodoLocal(documentId: 't2', nombre: 'dos.mp3', esDirectorio: false),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
final resultado = await pistasRecursivas('root', fuente: fuente);
|
||||
|
||||
expect(resultado.map((n) => n.documentId).toSet(), {'t1', 't2'});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a nested tree deeper than one level: finds a track 3 levels below '
|
||||
'the tapped folder',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'root': const [
|
||||
NodoLocal(
|
||||
documentId: 'nivel1',
|
||||
nombre: 'Nivel1',
|
||||
esDirectorio: true,
|
||||
),
|
||||
],
|
||||
'nivel1': const [
|
||||
NodoLocal(
|
||||
documentId: 'nivel2',
|
||||
nombre: 'Nivel2',
|
||||
esDirectorio: true,
|
||||
),
|
||||
],
|
||||
'nivel2': const [
|
||||
NodoLocal(
|
||||
documentId: 'nivel3',
|
||||
nombre: 'Nivel3',
|
||||
esDirectorio: true,
|
||||
),
|
||||
],
|
||||
'nivel3': const [
|
||||
NodoLocal(
|
||||
documentId: 't-hondo',
|
||||
nombre: 'hondo.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
final resultado = await pistasRecursivas('root', fuente: fuente);
|
||||
|
||||
expect(resultado.map((n) => n.documentId).toList(), ['t-hondo']);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'depth bound: a track beyond profundidadMaxima levels is NOT '
|
||||
'collected',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'root': const [
|
||||
NodoLocal(documentId: 'n1', nombre: 'N1', esDirectorio: true),
|
||||
],
|
||||
'n1': const [
|
||||
NodoLocal(documentId: 'n2', nombre: 'N2', esDirectorio: true),
|
||||
],
|
||||
'n2': const [
|
||||
NodoLocal(
|
||||
documentId: 't-hondo',
|
||||
nombre: 'hondo.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// profundidadMaxima: 1 allows root -> n1 (1 hop) but NOT n1 -> n2
|
||||
// (a 2nd hop), so the track inside n2 is never reached.
|
||||
final resultado = await pistasRecursivas(
|
||||
'root',
|
||||
fuente: fuente,
|
||||
profundidadMaxima: 1,
|
||||
);
|
||||
|
||||
expect(resultado, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'count bound: stops collecting once limite tracks are gathered, '
|
||||
'even if more exist',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'root': List.generate(
|
||||
10,
|
||||
(i) => NodoLocal(
|
||||
documentId: 't$i',
|
||||
nombre: 'cancion_$i.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
final resultado = await pistasRecursivas(
|
||||
'root',
|
||||
fuente: fuente,
|
||||
limite: 3,
|
||||
);
|
||||
|
||||
expect(resultado, hasLength(3));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a subfolder that fails to resolve (revoked permission) is skipped; '
|
||||
'sibling subfolders are still visited, never throws',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'root': const [
|
||||
NodoLocal(
|
||||
documentId: 'sub-mala',
|
||||
nombre: 'A-Mala',
|
||||
esDirectorio: true,
|
||||
),
|
||||
NodoLocal(
|
||||
documentId: 'sub-buena',
|
||||
nombre: 'B-Buena',
|
||||
esDirectorio: true,
|
||||
),
|
||||
],
|
||||
'sub-buena': const [
|
||||
NodoLocal(documentId: 't-ok', nombre: 'ok.mp3', esDirectorio: false),
|
||||
],
|
||||
},
|
||||
idsConErrorEnHijos: const {'sub-mala'},
|
||||
);
|
||||
|
||||
final resultado = await pistasRecursivas('root', fuente: fuente);
|
||||
|
||||
expect(resultado.map((n) => n.documentId).toList(), ['t-ok']);
|
||||
},
|
||||
);
|
||||
|
||||
test('carpeta vacía (sin hijos en absoluto) devuelve lista vacía', () async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto();
|
||||
|
||||
final resultado = await pistasRecursivas('root', fuente: fuente);
|
||||
|
||||
expect(resultado, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group(
|
||||
'ConstructorArbolAuto.itemsLocales: recursive folder-play gate '
|
||||
'(item 2)',
|
||||
() {
|
||||
test(
|
||||
'folder with ONLY subfolders, but a subfolder recursively '
|
||||
'contains a track (fuente injected): DOES prepend both play '
|
||||
'actions',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'sub1': const [
|
||||
NodoLocal(
|
||||
documentId: 't-hondo',
|
||||
nombre: 'hondo.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
final nodos = [
|
||||
const NodoLocal(
|
||||
documentId: 'sub1',
|
||||
nombre: 'Subcarpeta',
|
||||
esDirectorio: true,
|
||||
),
|
||||
];
|
||||
|
||||
final pagina0 = await ConstructorArbolAuto().itemsLocales(
|
||||
nodos,
|
||||
documentIdPadre: 'padre1',
|
||||
metadatosDe: _metadatosVacio,
|
||||
fuente: fuente,
|
||||
);
|
||||
|
||||
expect(
|
||||
pagina0.where(
|
||||
(i) => i.id.startsWith('carpeta_local_reproducir:'),
|
||||
),
|
||||
hasLength(1),
|
||||
);
|
||||
expect(
|
||||
pagina0.where((i) => i.id.startsWith('carpeta_local_aleatorio:')),
|
||||
hasLength(1),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'folder with only subfolders, nested 2 levels deep, track only '
|
||||
'at the deepest level (fuente injected): still prepends both '
|
||||
'actions',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'nivel1': const [
|
||||
NodoLocal(
|
||||
documentId: 'nivel2',
|
||||
nombre: 'N2',
|
||||
esDirectorio: true,
|
||||
),
|
||||
],
|
||||
'nivel2': const [
|
||||
NodoLocal(
|
||||
documentId: 't-hondo',
|
||||
nombre: 'hondo.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
final nodos = [
|
||||
const NodoLocal(
|
||||
documentId: 'nivel1',
|
||||
nombre: 'Nivel1',
|
||||
esDirectorio: true,
|
||||
),
|
||||
];
|
||||
|
||||
final pagina0 = await ConstructorArbolAuto().itemsLocales(
|
||||
nodos,
|
||||
documentIdPadre: 'padre1',
|
||||
metadatosDe: _metadatosVacio,
|
||||
fuente: fuente,
|
||||
);
|
||||
|
||||
expect(
|
||||
pagina0.where(
|
||||
(i) => i.id.startsWith('carpeta_local_reproducir:'),
|
||||
),
|
||||
hasLength(1),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'folder with only subfolders that are genuinely empty even '
|
||||
'recursively (fuente injected): still does NOT prepend',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto();
|
||||
final nodos = [
|
||||
const NodoLocal(
|
||||
documentId: 'd-sub',
|
||||
nombre: 'Subcarpeta',
|
||||
esDirectorio: true,
|
||||
),
|
||||
];
|
||||
|
||||
final pagina0 = await ConstructorArbolAuto().itemsLocales(
|
||||
nodos,
|
||||
documentIdPadre: 'padre1',
|
||||
metadatosDe: _metadatosVacio,
|
||||
fuente: fuente,
|
||||
);
|
||||
|
||||
expect(
|
||||
pagina0.where(
|
||||
(i) => i.id.startsWith('carpeta_local_reproducir:'),
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
pagina0.where((i) => i.id.startsWith('carpeta_local_aleatorio:')),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'without a fuente (omitted): falls back to direct-count-only '
|
||||
'gating -- folder with only subfolders never prepends, matching '
|
||||
'the pre-item-2 behaviour',
|
||||
() async {
|
||||
final nodos = [
|
||||
const NodoLocal(
|
||||
documentId: 'd-sub',
|
||||
nombre: 'Subcarpeta',
|
||||
esDirectorio: true,
|
||||
),
|
||||
];
|
||||
|
||||
final pagina0 = await ConstructorArbolAuto().itemsLocales(
|
||||
nodos,
|
||||
documentIdPadre: 'padre1',
|
||||
metadatosDe: _metadatosVacio,
|
||||
);
|
||||
|
||||
expect(
|
||||
pagina0.where(
|
||||
(i) => i.id.startsWith('carpeta_local_reproducir:'),
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Fake `metadatosDe` that always resolves to an empty map — used by every
|
||||
@@ -3075,13 +3470,15 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
|
||||
Object? errorEnHijos,
|
||||
Object? errorEnUriContenido,
|
||||
Object? errorEnMetadatosDe,
|
||||
Set<String>? idsConErrorEnHijos,
|
||||
}) : _configurada = configurada,
|
||||
_hijosPorDocId = hijosPorDocId ?? const {},
|
||||
_uriPorDocId = uriPorDocId ?? const {},
|
||||
_metadatosPorDocId = metadatosPorDocId ?? const {},
|
||||
_errorEnHijos = errorEnHijos,
|
||||
_errorEnUriContenido = errorEnUriContenido,
|
||||
_errorEnMetadatosDe = errorEnMetadatosDe;
|
||||
_errorEnMetadatosDe = errorEnMetadatosDe,
|
||||
_idsConErrorEnHijos = idsConErrorEnHijos ?? const {};
|
||||
|
||||
final bool _configurada;
|
||||
final Map<String, List<NodoLocal>> _hijosPorDocId;
|
||||
@@ -3091,6 +3488,11 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
|
||||
final Object? _errorEnUriContenido;
|
||||
final Object? _errorEnMetadatosDe;
|
||||
|
||||
/// Item 2 (recursive folder play): documentIds whose `hijos` call throws
|
||||
/// -- used to prove a single unresolvable subfolder does not abort the
|
||||
/// whole recursive walk, unlike [_errorEnHijos] (which fails EVERY call).
|
||||
final Set<String> _idsConErrorEnHijos;
|
||||
|
||||
/// Records every `metadatosDe` call's documentIds list, in call order —
|
||||
/// used by tests asserting the page-scoped resolution invariant at the
|
||||
/// `hijosMusicaLocal` level.
|
||||
@@ -3102,6 +3504,9 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
|
||||
@override
|
||||
Future<List<NodoLocal>> hijos(String documentId) async {
|
||||
if (_errorEnHijos != null) throw _errorEnHijos;
|
||||
if (_idsConErrorEnHijos.contains(documentId)) {
|
||||
throw Exception('permiso revocado para $documentId');
|
||||
}
|
||||
return _hijosPorDocId[documentId] ?? const [];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user