From 6822432a51cf5ae3648c9e1dc379cdb0ef99a488 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 00:43:20 +0200 Subject: [PATCH] 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. --- lib/servicios/navegacion_auto.dart | 185 ++++++++-- test/servicios/navegacion_auto_test.dart | 411 ++++++++++++++++++++++- 2 files changed, 569 insertions(+), 27 deletions(-) diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 8c0a3ae..84a78ef 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -447,6 +447,12 @@ class ConstructorArbolAuto { int tamano = _maxItemsCarpetaLocal, @visibleForTesting MediaItem Function(NodoLocal, Map)? construirItem, + // Item 2 (recursive folder play): optional so every pre-existing call + // site/test that has no need for the recursive gate keeps working + // unchanged. Only used on page 0, and only when [nodos] has zero + // DIRECT tracks (a direct track already makes the gate cheaply true + // without it) — see the `hayContenidoReproducible` computation below. + FuenteMusicaLocalAuto? fuente, }) async { final construir = construirItem ?? _itemLocal; final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion); @@ -462,14 +468,26 @@ class ConstructorArbolAuto { } if (pagina == 0) { final totalPistas = nodos.where((n) => !n.esDirectorio).length; + // Item 2: a folder plays everything beneath it, recursively -- so + // the play actions must be offered whenever the RECURSIVE count is + // > 0, not just the direct count. `totalPistas > 0` short-circuits + // the bounded recursive walk entirely for the common case (a direct + // track already answers the question); only a folder with ZERO + // direct tracks but at least one subfolder pays the recursive-check + // cost, and only up to [profundidadMaximaRecursivaLocal] levels. + final hayContenidoReproducible = + totalPistas > 0 || + (fuente != null && + await _haySubcarpetaConPistas(nodos, fuente: fuente)); final prepend = [ - // 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), + // Folder-play actions (Design ADR-5, Phase 3; recursive gate item + // 2): prepended BEFORE the sort/bucket nav entries, present iff + // the folder has at least one playable track anywhere beneath it + // (direct or nested), absent for a folder that is genuinely empty + // even recursively (Spec "Folder has no tracks"). + if (hayContenidoReproducible) _itemReproducirCarpeta(documentIdPadre), + if (hayContenidoReproducible) + _itemReproducirAleatorio(documentIdPadre), if (ofreceOrdenCalidad(totalPistas)) _itemModoOrdenCalidad(documentIdPadre), if (ofreceBuckets(totalPistas)) @@ -481,6 +499,32 @@ class ConstructorArbolAuto { return items; } + /// Whether at least one subfolder within [nodos] recursively contains a + /// playable track (Design "recursive folder play, gate", item 2): called + /// ONLY when the folder has zero DIRECT tracks (the caller already + /// checked that cheaply) — descends into each direct subfolder via + /// [pistasRecursivas] with `limite: 1`, stopping at the very first + /// match so a folder with an early hit costs as little as possible. + /// [nodos] is assumed already resolved by the caller (its own + /// `fuente.hijos(...)` result), so this folder's own children are never + /// re-fetched. + Future _haySubcarpetaConPistas( + List nodos, { + required FuenteMusicaLocalAuto fuente, + }) async { + for (final nodo in nodos) { + if (!nodo.esDirectorio) continue; + final encontradas = await pistasRecursivas( + nodo.documentId, + fuente: fuente, + profundidadMaxima: profundidadMaximaRecursivaLocal - 1, + limite: 1, + ); + if (encontradas.isNotEmpty) return true; + } + return false; + } + /// Whether the "Ordenar por calidad" mode entry should be offered for a /// folder with [totalPistas] audio files (Design ADR-3): present for /// `0 < totalPistas <= 150`, omitted otherwise (empty folder or above the @@ -946,17 +990,115 @@ List mezclarFisherYates(List nodos, Random rng) { List pistasEnOrdenAleatorio(List nodos, Random rng) => mezclarFisherYates(pistasEnOrdenNombre(nodos), rng); +/// Maximum recursion depth for "play folder recursively" (Design "recursive +/// folder play, cost bound", item 2): SAF directory listing is a native +/// round-trip PER folder, so unbounded recursion could turn a single tap +/// into dozens of channel calls for a pathologically deep tree. 4 levels +/// below the tapped folder covers virtually every real music-library +/// layout (even `Artist/Album/Disc/track.mp3` is only 3 levels deep) while +/// keeping a worst-case tree's native-call count bounded. A subfolder +/// beyond this depth is simply never explored — its tracks are not +/// collected, exactly like content beyond the browse tree's own page cap +/// is never listed. +const profundidadMaximaRecursivaLocal = 4; + +/// Maximum number of tracks collected by a recursive folder walk (Design +/// "recursive folder play, cost bound", item 2): a folder-play/shuffle +/// queue beyond a few hundred tracks has no practical benefit, and an +/// unbounded collection risks an extremely long queue AND an extremely +/// long recursive walk over a huge library. 500 is an order of magnitude +/// above the existing quality-sort cap +/// ([ConstructorArbolAuto._maxPistasParaOrdenCalidad], 150) — generous for +/// a "play everything" action, while still bounded. +const limitePistasRecursivasLocal = 500; + +/// Recursively collects every audio-file [NodoLocal] reachable from +/// [documentId] (Design "recursive folder play", item 2): [documentId]'s +/// own direct audio children, plus — for every direct subfolder — that +/// subfolder's own recursive result. Walked depth-first, sorted by +/// [NodoLocal.nombre] at each level (the SAME comparator the sequential/ +/// shuffle play actions already used pre-recursion), so the collected +/// order is deterministic and reproducible under a fixed shuffle seed. +/// +/// Bounded on two independent axes so a pathological tree (very deep, or +/// very wide-and-deep) can never turn a single tap into an unbounded +/// number of native SAF round-trips or an unbounded in-memory list: +/// - [profundidadMaxima] caps how many folder levels BELOW [documentId] +/// are ever descended into (`0` = only [documentId]'s own direct +/// children, no descent at all). +/// - [limite] caps the TOTAL number of tracks collected across the whole +/// walk; collection stops (mid-folder if needed) the instant this many +/// have been gathered. +/// +/// Never throws: a [fuente.hijos] failure on any one subfolder (revoked +/// permission, a race with the OS SAF layer) is swallowed for that +/// subfolder only — sibling folders already queued for traversal are +/// still visited — mirroring this file's existing no-throw contract +/// (Design "no-op on empty/unresolvable folder"). +Future> pistasRecursivas( + String documentId, { + required FuenteMusicaLocalAuto fuente, + int profundidadMaxima = profundidadMaximaRecursivaLocal, + int limite = limitePistasRecursivasLocal, +}) async { + final resultado = []; + await _recolectarPistasRecursivas( + documentId, + fuente: fuente, + profundidadRestante: profundidadMaxima, + limite: limite, + resultado: resultado, + ); + return resultado; +} + +Future _recolectarPistasRecursivas( + String documentId, { + required FuenteMusicaLocalAuto fuente, + required int profundidadRestante, + required int limite, + required List resultado, +}) async { + if (resultado.length >= limite) return; + final List hijos; + try { + hijos = await fuente.hijos(documentId); + } catch (_) { + return; + } + final ordenados = [...hijos]..sort((a, b) => a.nombre.compareTo(b.nombre)); + for (final nodo in ordenados) { + if (resultado.length >= limite) return; + if (nodo.esDirectorio) { + if (profundidadRestante <= 0) continue; + await _recolectarPistasRecursivas( + nodo.documentId, + fuente: fuente, + profundidadRestante: profundidadRestante - 1, + limite: limite, + resultado: resultado, + ); + } else { + resultado.add(nodo); + } + } +} + /// 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]. +/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2; recursive collection item +/// 2): resolves whichever of the two action prefixes matches [id] +/// (ignoring [aleatorio] for the STRIP — the prefix itself is +/// authoritative), RECURSIVELY collects every track beneath that folder +/// via [pistasRecursivas] (direct children AND every nested subfolder, up +/// to its depth/count bounds), orders them ([aleatorio] picks shuffled vs +/// the recursive walk's own name-sorted 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"). +/// prefix; the folder (or everything beneath it, within the recursion +/// bounds) is unresolvable/empty (Design "no-op on empty/unresolvable +/// folder") — [pistasRecursivas] never throws, so this never propagates an +/// exception either. Future reproducirCarpetaLocal( String id, { required bool aleatorio, @@ -974,16 +1116,10 @@ Future reproducirCarpetaLocal( return; } - final List nodos; - try { - nodos = await fuente.hijos(documentId); - } catch (_) { - return; - } - + final recolectadas = await pistasRecursivas(documentId, fuente: fuente); final pistas = aleatorio - ? pistasEnOrdenAleatorio(nodos, rng ?? Random()) - : pistasEnOrdenNombre(nodos); + ? mezclarFisherYates(recolectadas, rng ?? Random()) + : recolectadas; if (pistas.isEmpty) return; await iniciarCola(pistas); @@ -1129,6 +1265,7 @@ Future?> hijosMusicaLocal( documentIdPadre: documentId, pagina: pagina, metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente), + fuente: fuente, ); } catch (_) { return const []; diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index 666698b..f7f6abc 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -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? 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? 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> _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 _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> hijos(String documentId) async { if (_errorEnHijos != null) throw _errorEnHijos; + if (_idsConErrorEnHijos.contains(documentId)) { + throw Exception('permiso revocado para $documentId'); + } return _hijosPorDocId[documentId] ?? const []; }