From eea8ec31e6e1121f9d29ccf22260377d2efe77be Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 00:38:39 +0200 Subject: [PATCH] fix(auto): sort local-music subfolders before files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit itemsLocales sorted a folder's children by name only, mixing directories and files. A subfolder whose name sorted after enough tracks (e.g. "Live" behind 80 numbered tracks) landed on a later "Más..." page, making it unreachable without paging through every track first. Sort directories before files, then by name within each group -- the standard file-browser convention. Subfolders now always land on page 0. --- lib/servicios/navegacion_auto.dart | 17 ++- test/servicios/navegacion_auto_test.dart | 171 +++++++++++++++++++++++ 2 files changed, 187 insertions(+), 1 deletion(-) diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 0509862..8c0a3ae 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -30,6 +30,21 @@ List paginaDe(List items, {required int pagina, required int tamano}) = bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) => total > (pagina + 1) * tamano; +/// Browse-tree ordering comparator for a local-music folder's children +/// (Design "Directories before files", item 1): directories sort before +/// files regardless of name, and within each group, alphabetically by +/// [NodoLocal.nombre] -- the standard file-browser convention. Fixes a +/// driver-facing bug where a folder's subfolders could land on a later +/// "Más…" page whenever enough tracks sorted alphabetically ahead of them +/// (e.g. a "Live" subfolder behind 80 numbered tracks), making the +/// subfolder unreachable without paging through every track first. +int compararNodoLocalParaNavegacion(NodoLocal a, NodoLocal b) { + if (a.esDirectorio != b.esDirectorio) { + return a.esDirectorio ? -1 : 1; + } + return a.nombre.compareTo(b.nombre); +} + const _prefijoEmisora = 'emisora:'; /// Local-track media-id prefix (Design "media-id scheme"), collision-free @@ -434,7 +449,7 @@ class ConstructorArbolAuto { MediaItem Function(NodoLocal, Map)? construirItem, }) async { final construir = construirItem ?? _itemLocal; - final ordenados = [...nodos]..sort((a, b) => a.nombre.compareTo(b.nombre)); + final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion); final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano); final docIds = paginaActual .where((n) => !n.esDirectorio) diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index c1b504a..666698b 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -2829,6 +2829,177 @@ void main() { }, ); }); + + // ── Item 1: Android Auto subfolder visibility ─────────────────────────── + // `itemsLocales` used to sort a folder's children by name ONLY, mixing + // directories and files -- a subfolder whose name sorted alphabetically + // after enough tracks landed on a later "Más…" page, making it invisible + // until the driver paged through every track first. Fix: directories + // before files, then by name within each group (standard file-browser + // convention) -- subfolders always land on the first page. + group( + 'compararNodoLocalParaNavegacion (Design "Directories before files")', + () { + test('a directory always sorts before a file, regardless of name', () { + const archivo = NodoLocal( + documentId: 'f1', + nombre: 'AAA archivo.mp3', + esDirectorio: false, + ); + const carpeta = NodoLocal( + documentId: 'd1', + nombre: 'ZZZ carpeta', + esDirectorio: true, + ); + + expect( + compararNodoLocalParaNavegacion(carpeta, archivo), + lessThan(0), + ); + expect( + compararNodoLocalParaNavegacion(archivo, carpeta), + greaterThan(0), + ); + }); + + test('two directories sort alphabetically by name', () { + const a = NodoLocal( + documentId: 'd1', + nombre: 'Alpha', + esDirectorio: true, + ); + const b = NodoLocal( + documentId: 'd2', + nombre: 'Beta', + esDirectorio: true, + ); + + expect(compararNodoLocalParaNavegacion(a, b), lessThan(0)); + expect(compararNodoLocalParaNavegacion(b, a), greaterThan(0)); + }); + + test('two files sort alphabetically by name', () { + const a = NodoLocal( + documentId: 'f1', + nombre: 'Alpha.mp3', + esDirectorio: false, + ); + const b = NodoLocal( + documentId: 'f2', + nombre: 'Beta.mp3', + esDirectorio: false, + ); + + expect(compararNodoLocalParaNavegacion(a, b), lessThan(0)); + }); + + test('equal esDirectorio and equal name compares equal', () { + const a = NodoLocal( + documentId: 'f1', + nombre: 'Same.mp3', + esDirectorio: false, + ); + const b = NodoLocal( + documentId: 'f2', + nombre: 'Same.mp3', + esDirectorio: false, + ); + + expect(compararNodoLocalParaNavegacion(a, b), 0); + }); + }, + ); + + group( + 'ConstructorArbolAuto.itemsLocales: subfolders sort before files ' + '(Android Auto subfolder-visibility bug)', + () { + test( + 'a subfolder whose name sorts AFTER every one of 80 numbered ' + 'tracks (e.g. "Live") still lands on page 0 -- pure alphabetical ' + 'mixing used to push it to a later page, making it unreachable ' + 'without paging through every track first (the reported bug)', + () async { + final nodos = [ + for (var i = 1; i <= 80; i++) + NodoLocal( + documentId: 'track-$i', + nombre: '${i.toString().padLeft(3, '0')} Track.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'folder-live', + nombre: 'Live', + esDirectorio: true, + ), + ]; + + final pagina0 = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'root', + metadatosDe: _metadatosVacio, + ); + + expect( + pagina0.where((i) => i.id == 'carpeta_local:folder-live'), + hasLength(1), + reason: + 'the "Live" subfolder must be visible on the FIRST page, ' + 'not hidden behind 80 tracks on a later page', + ); + }, + ); + + test( + 'mixed directories and files: ALL directories precede ALL files, ' + 'each group still alphabetical by name', + () async { + final nodos = [ + const NodoLocal( + documentId: 'f-z', + nombre: 'zzz.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-z', + nombre: 'ZZZ Carpeta', + esDirectorio: true, + ), + const NodoLocal( + documentId: 'f-a', + nombre: 'aaa.mp3', + esDirectorio: false, + ), + const NodoLocal( + documentId: 'd-a', + nombre: 'AAA Carpeta', + esDirectorio: true, + ), + ]; + + final items = await ConstructorArbolAuto().itemsLocales( + nodos, + documentIdPadre: 'root', + metadatosDe: _metadatosVacio, + ); + final soloArbol = items + .where( + (i) => + i.id.startsWith('carpeta_local:') || + i.id.startsWith('pista:'), + ) + .toList(); + + expect(soloArbol.map((i) => i.id).toList(), [ + 'carpeta_local:d-a', + 'carpeta_local:d-z', + 'pista:f-a', + 'pista:f-z', + ]); + }, + ); + }, + ); } /// Fake `metadatosDe` that always resolves to an empty map — used by every