feat(auto): page local-music folders instead of truncating at 50 [size:exception]

Folders over the 50-item cap now show a "Mas..." item that reveals
the next page on tap, instead of silently dropping the rest. Paging
slices the cheap raw list before building any MediaItem, so items
beyond the requested page are never resolved (art, title) -- proven
by a call-count test. Also swaps the raw SAF content:// URI shown in
settings for a parsed, human-readable folder name with a localized
fallback across all 13 locales.

servicio_audio.dart is untouched; this stays entirely within the
local-music tree/dispatch layer.
This commit is contained in:
2026-07-19 22:14:05 +02:00
parent 977cbcd8cc
commit 725169cd31
25 changed files with 1601 additions and 40 deletions
+2 -1
View File
@@ -669,5 +669,6 @@
"placeholders": {
"error": {}
}
}
},
"localMusicFolderGenericName": "مجلد محدد"
}
+2 -1
View File
@@ -669,5 +669,6 @@
"placeholders": {
"error": {}
}
}
},
"localMusicFolderGenericName": "নির্বাচিত ফোল্ডার"
}
+2 -1
View File
@@ -669,5 +669,6 @@
"placeholders": {
"error": {}
}
}
},
"localMusicFolderGenericName": "Ausgewählter Ordner"
}
+2 -1
View File
@@ -669,5 +669,6 @@
"placeholders": {
"error": {}
}
}
},
"localMusicFolderGenericName": "Selected folder"
}
+2 -1
View File
@@ -632,5 +632,6 @@
"placeholders": {
"error": {}
}
}
},
"localMusicFolderGenericName": "Carpeta seleccionada"
}
+2 -1
View File
@@ -669,5 +669,6 @@
"placeholders": {
"error": {}
}
}
},
"localMusicFolderGenericName": "Dossier sélectionné"
}
+2 -1
View File
@@ -669,5 +669,6 @@
"placeholders": {
"error": {}
}
}
},
"localMusicFolderGenericName": "चयनित फ़ोल्डर"
}
+2 -1
View File
@@ -669,5 +669,6 @@
"placeholders": {
"error": {}
}
}
},
"localMusicFolderGenericName": "Folder terpilih"
}
+2 -1
View File
@@ -669,5 +669,6 @@
"placeholders": {
"error": {}
}
}
},
"localMusicFolderGenericName": "Cartella selezionata"
}
+2 -1
View File
@@ -669,5 +669,6 @@
"placeholders": {
"error": {}
}
}
},
"localMusicFolderGenericName": "選択したフォルダー"
}
+2 -1
View File
@@ -669,5 +669,6 @@
"placeholders": {
"error": {}
}
}
},
"localMusicFolderGenericName": "Pasta selecionada"
}
+2 -1
View File
@@ -669,5 +669,6 @@
"placeholders": {
"error": {}
}
}
},
"localMusicFolderGenericName": "Выбранная папка"
}
+2 -1
View File
@@ -669,5 +669,6 @@
"placeholders": {
"error": {}
}
}
},
"localMusicFolderGenericName": "已选文件夹"
}
+4 -1
View File
@@ -361,7 +361,10 @@ class _SeccionMusicaLocalState extends State<_SeccionMusicaLocal> {
subtitle: Text(
(carpeta == null || carpeta.isEmpty)
? l10n.localMusicFolderNotConfigured
: carpeta,
: nombreCarpetaDesdeUri(
carpeta,
nombreGenerico: l10n.localMusicFolderGenericName,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
+47
View File
@@ -21,6 +21,53 @@ bool esArchivoAudio(String? mime, String? nombre) {
return mimeRecortado.toLowerCase().startsWith('audio/');
}
/// Pure-Dart, SAF-URI-parsing derivation of a human-readable folder name
/// (Design ADR-4) — NO native round-trip. SAF tree URIs are
/// `content://<authority>/tree/<encoded-documentId>`; `Uri.pathSegments`
/// already percent-decodes each segment, so the segment right after `tree`
/// is the decoded documentId (e.g. `primary:Music/MyFolder`,
/// `1A2B-3C4D:Music`). The trailing readable part of that documentId is
/// extracted: everything after the last `/` when present, else everything
/// after the last `:`, trimmed. An unparseable [treeUri], a missing/empty
/// `tree` segment, or an empty-after-trim result all fall back to
/// [nombreGenerico] — this function NEVER returns the raw `content://` URI
/// and NEVER returns an empty string.
///
/// [nombreGenerico] is the caller-supplied fallback text (Design ADR-4/ADR-5
/// — genuine phone UI, localized via `AppLocalizations.localMusicFolderGenericName`
/// at the call site in `pantalla_ajustes.dart`). Taking it as a plain
/// `String` parameter — rather than a `BuildContext`/`AppLocalizations`
/// dependency — keeps this function pure and unit-testable without a
/// widget tree, mirroring `pantalla_reproductor.dart`'s
/// `_formatearDuracion(AppLocalizations l10n, ...)` precedent, minus the
/// Flutter-generated-class coupling.
String nombreCarpetaDesdeUri(String treeUri, {required String nombreGenerico}) {
final uri = Uri.tryParse(treeUri);
if (uri == null) return nombreGenerico;
final segmentos = uri.pathSegments;
final indiceTree = segmentos.indexOf('tree');
if (indiceTree == -1 || indiceTree + 1 >= segmentos.length) {
return nombreGenerico;
}
final documentId = segmentos[indiceTree + 1];
if (documentId.isEmpty) return nombreGenerico;
final String segmento;
final ultimaBarra = documentId.lastIndexOf('/');
if (ultimaBarra >= 0) {
segmento = documentId.substring(ultimaBarra + 1);
} else {
final ultimosDosPuntos = documentId.lastIndexOf(':');
segmento = ultimosDosPuntos >= 0
? documentId.substring(ultimosDosPuntos + 1)
: documentId;
}
final recortado = segmento.trim();
return recortado.isEmpty ? nombreGenerico : recortado;
}
/// Browse-source abstraction for the local-music branch of the Android Auto
/// tree (Design "Interfaces / Contracts"), mirroring [FuenteEmisorasAuto]'s
/// (`navegacion_auto.dart`) cold-start-safe, never-throws contract. Kept as
+107 -12
View File
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path_provider/path_provider.dart';
import '../estado/orden_emisoras.dart';
@@ -13,6 +14,22 @@ import 'musica_local_auto.dart';
import 'persistencia_tolerante.dart';
import 'servicio_favoritos.dart';
/// Generic page slice over [items] (Design ADR-6): returns at most [tamano]
/// elements starting at `pagina * tamano`. Reusable across any list type —
/// no [NodoLocal] coupling — so a future paged folder type can reuse the
/// slice arithmetic directly. An empty [items] or a [pagina] beyond the
/// list's range returns `[]`, never throws.
List<T> paginaDe<T>(List<T> items, {required int pagina, required int tamano}) =>
items.skip(pagina * tamano).take(tamano).toList();
/// Whether a page after [pagina] exists for a list of [total] elements
/// (Design ADR-6): `true` iff at least one element remains beyond the
/// current page's slice. The exact-boundary case
/// (`total == (pagina + 1) * tamano`) is `false` — nothing remains to
/// reveal.
bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) =>
total > (pagina + 1) * tamano;
const _prefijoEmisora = 'emisora:';
/// EQ preset media-id prefix (Design ADR-1), collision-free against
@@ -173,6 +190,14 @@ class ConstructorArbolAuto {
/// [_prefijoPresetEq] and the bare folder id constants above.
static const _prefijoCarpetaLocal = 'carpeta_local:';
/// Paged "load more" local-music id prefix (Design ADR-1). Collision-free
/// against [_prefijoCarpetaLocal] and every other prefix/bare id in this
/// class: at the index where `carpeta_local:` has `:`, this prefix has
/// `_`, so neither ever matches the other's `startsWith` check — routing
/// order between [esCarpetaLocalPaginadaMediaId] and [esCarpetaLocalMediaId]
/// is therefore irrelevant to correctness.
static const _prefijoCarpetaLocalPaginada = 'carpeta_local_pag:';
/// 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
@@ -277,18 +302,79 @@ class ConstructorArbolAuto {
String idCarpetaLocalDesde(String id) =>
id.substring(_prefijoCarpetaLocal.length);
/// Maps native [NodoLocal]s to browse-tree `MediaItem`s (Design "Lazy
/// per-folder enumeration" + "Dedicated 50-item cap, alphabetical
/// truncation"): sorted alphabetically by [NodoLocal.nombre] and capped at
/// [_maxItemsCarpetaLocal]. Folders map to non-playable
/// `carpeta_local:<id>` items with their raw name; files map to playable
/// `pista:<id>` items with the extension stripped from the title (Design
/// "Title = filename minus extension") and a rotating on-brand `artUri`
/// (Design "art = reused station_art_* rotation"). An empty [nodos]
/// returns `[]`, never an error (Spec "browsing an empty subfolder").
List<MediaItem> itemsLocales(List<NodoLocal> nodos) {
/// Whether [id] identifies a paged "load more" local-music request
/// (Design ADR-1).
bool esCarpetaLocalPaginadaMediaId(String id) =>
id.startsWith(_prefijoCarpetaLocalPaginada);
/// Parses a `carpeta_local_pag:<page>:<docId>` [id] into its
/// `(documentId, pagina)` pair (Design ADR-1): the prefix is stripped by
/// length, then the remainder is split on the FIRST `:` only —
/// everything left of it is the page integer (never contains a colon),
/// everything right of it (including any further colons/slashes) is the
/// raw SAF documentId verbatim, so a docId containing `:` or `/` survives
/// intact. Root paging is expressible: an empty documentId round-trips as
/// `carpeta_local_pag:<n>:` (empty tail). Only meaningful when
/// [esCarpetaLocalPaginadaMediaId] is `true`.
(String documentId, int pagina) paginaCarpetaLocalDesde(String id) {
final resto = id.substring(_prefijoCarpetaLocalPaginada.length);
final indice = resto.indexOf(':');
final pagina = int.parse(resto.substring(0, indice));
final documentId = resto.substring(indice + 1);
return (documentId, pagina);
}
/// Hardcoded-Spanish car-tree label for the trailing "load more" item
/// (Design ADR-5) — matches every other car-tree label in this file
/// (`'Favoritos'`, `'Música Local'`, [_tituloLocalFallback]), none of
/// which go through `AppLocalizations`. Deliberately NOT an arb key.
static const _tituloMasLocal = 'Más…';
/// The trailing "load more" `MediaItem` (Design ADR-5): non-playable, no
/// `artUri` (the label alone is the affordance, like [_carpeta]), id
/// `carpeta_local_pag:<siguientePagina>:<documentIdPadre>` — round-trips
/// via [paginaCarpetaLocalDesde] back to the parent folder's next page.
MediaItem _itemMasLocal(String documentIdPadre, int siguientePagina) =>
MediaItem(
id: '$_prefijoCarpetaLocalPaginada$siguientePagina:$documentIdPadre',
title: _tituloMasLocal,
playable: false,
extras: _contentStyleLista,
);
/// Maps native [NodoLocal]s to browse-tree `MediaItem`s, paged (Design
/// "Lazy per-folder enumeration" + ADR-3 pagination): the full [nodos]
/// list is sorted alphabetically by [NodoLocal.nombre] — cheap, no
/// `MediaItem` built yet — then sliced to [pagina] via [paginaDe] BEFORE
/// any `MediaItem` is constructed, and only that slice (at most [tamano]
/// entries) is mapped through [construirItem] (Design "slice the cheap
/// list, then map — never map-then-slice", the memory-efficiency
/// invariant). A trailing non-playable, browsable "Más…" item is appended
/// whenever [hayPaginaSiguiente] says more items remain beyond this page;
/// selecting it feeds back into [hijosMusicaLocal] to reveal the next
/// page, so no item is ever permanently unreachable (Spec "Local Music
/// Folder Item Cap and Paging"). An empty [nodos] (or a stale [pagina]
/// beyond the folder's range) returns `[]`, never an error (Spec
/// "browsing an empty subfolder").
///
/// [construirItem] is `@visibleForTesting` — injectable ONLY so a test
/// spy can assert the exact `min(tamano, remaining)` call-count invariant
/// (Design ADR-3); production callers never pass it.
List<MediaItem> itemsLocales(
List<NodoLocal> nodos, {
required String documentIdPadre,
int pagina = 0,
int tamano = _maxItemsCarpetaLocal,
@visibleForTesting MediaItem Function(NodoLocal)? construirItem,
}) {
final construir = construirItem ?? _itemLocal;
final ordenados = [...nodos]..sort((a, b) => a.nombre.compareTo(b.nombre));
return ordenados.take(_maxItemsCarpetaLocal).map(_itemLocal).toList();
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final items = paginaActual.map(construir).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
items.add(_itemMasLocal(documentIdPadre, pagina + 1));
}
return items;
}
MediaItem _itemLocal(NodoLocal nodo) {
@@ -446,8 +532,13 @@ Future<List<MediaItem>?> hijosMusicaLocal(
}) async {
final constructor = ConstructorArbolAuto();
final String documentId;
var pagina = 0;
if (parentMediaId == ConstructorArbolAuto.idMusicaLocal) {
documentId = '';
} else if (constructor.esCarpetaLocalPaginadaMediaId(parentMediaId)) {
final resuelto = constructor.paginaCarpetaLocalDesde(parentMediaId);
documentId = resuelto.$1;
pagina = resuelto.$2;
} else if (constructor.esCarpetaLocalMediaId(parentMediaId)) {
documentId = constructor.idCarpetaLocalDesde(parentMediaId);
} else {
@@ -456,7 +547,11 @@ Future<List<MediaItem>?> hijosMusicaLocal(
if (fuente == null) return const [];
try {
final nodos = await fuente.hijos(documentId);
return constructor.itemsLocales(nodos);
return constructor.itemsLocales(
nodos,
documentIdPadre: documentId,
pagina: pagina,
);
} catch (_) {
return const [];
}