fix(auto): sort local-music subfolders before files

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.
This commit is contained in:
2026-07-31 00:38:39 +02:00
parent 4b89c9af07
commit eea8ec31e6
2 changed files with 187 additions and 1 deletions
+16 -1
View File
@@ -30,6 +30,21 @@ List<T> paginaDe<T>(List<T> items, {required int pagina, required int tamano}) =
bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) => bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) =>
total > (pagina + 1) * 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:'; const _prefijoEmisora = 'emisora:';
/// Local-track media-id prefix (Design "media-id scheme"), collision-free /// Local-track media-id prefix (Design "media-id scheme"), collision-free
@@ -434,7 +449,7 @@ class ConstructorArbolAuto {
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem, MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
}) async { }) async {
final construir = construirItem ?? _itemLocal; 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 paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final docIds = paginaActual final docIds = paginaActual
.where((n) => !n.esDirectorio) .where((n) => !n.esDirectorio)
+171
View File
@@ -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 /// Fake `metadatosDe` that always resolves to an empty map — used by every