feat(auto): browse and play local music folders in Android Auto [size:exception]

Phase 1: pick a device folder via SAF (persisted grant, no new
permission), browse its nested subfolders/tracks as a 5th Android
Auto root folder (hidden until configured), and play tracks through
the existing pipeline (EQ, art rotation, cold-start-safe source).
No metadata/sort/filter/shuffle yet -- filename is the title, generic
rotating art is the placeholder; deferred to a follow-up phase.

Adds a new pluriwave/file_actions native method (listAudioChildren)
and an onActivityResult override in MainActivity for the SAF folder
picker -- both static-review-only, no Android build available here.
This commit is contained in:
2026-07-19 20:30:50 +02:00
parent 99897ec848
commit 6ae7e378c4
28 changed files with 2425 additions and 40 deletions
+198 -5
View File
@@ -7,7 +7,9 @@ import 'package:path_provider/path_provider.dart';
import '../estado/orden_emisoras.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/pista_local.dart';
import '../modelos/preset_ecualizador.dart';
import 'musica_local_auto.dart';
import 'persistencia_tolerante.dart';
import 'servicio_favoritos.dart';
@@ -23,6 +25,17 @@ const _prefijoPresetEq = 'eq_preset:';
/// predicate.
bool esPresetMediaId(String id) => id.startsWith(_prefijoPresetEq);
/// Local-track media-id prefix (Design "media-id scheme"), collision-free
/// against [_prefijoEmisora], [_prefijoPresetEq], `grupo:` and the bare
/// folder id constants. Top-level (not a [ConstructorArbolAuto] member),
/// mirroring [_prefijoPresetEq]/[esPresetMediaId]'s shape — used directly
/// from `playFromMediaId`'s dispatch in `servicio_audio.dart`.
const _prefijoPista = 'pista:';
/// Whether [id] identifies a local-track playable leaf item (Design
/// "media-id scheme").
bool esPistaMediaId(String id) => id.startsWith(_prefijoPista);
/// Canonical on-brand fallback-art names and rotation order, ported
/// **verbatim** (same formula, same order) from
/// `lib/widgets/tarjeta_emisora.dart`'s `_fallbackArtFor` (lines 363-367)
@@ -141,6 +154,13 @@ class ConstructorArbolAuto {
/// generic station-list `hijos()` path.
static const idEcualizador = 'ecualizador';
/// Root folder id for the local-music browsable root (Design "media-id
/// scheme"). Deliberately NOT added to [_idsCarpetas] — it has its own
/// dedicated branch (`hijosMusicaLocal`), not the generic station-list
/// [hijos] path. Hidden from [raiz] until a folder has been picked
/// (Design "Local root hidden until a folder is configured").
static const idMusicaLocal = 'musica_local';
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
static const _maxItemsPorCarpeta = 50;
@@ -148,12 +168,23 @@ class ConstructorArbolAuto {
/// free against [_prefijoEmisora] and the bare folder id constants above.
static const _prefijoGrupo = 'grupo:';
/// Local-music subfolder id prefix (Design "media-id scheme"),
/// collision-free against [_prefijoEmisora], [_prefijoGrupo],
/// [_prefijoPresetEq] and the bare folder id constants above.
static const _prefijoCarpetaLocal = 'carpeta_local:';
/// 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
/// [_maxItemsPorCarpeta].
static const _maxGruposPorFavoritos = 50;
/// Dedicated cap for local-music folders (Design "Dedicated 50-item cap,
/// alphabetical truncation"), tunable independently of
/// [_maxItemsPorCarpeta]/[_maxGruposPorFavoritos] — the extension point
/// for a future native page-offset parameter.
static const _maxItemsCarpetaLocal = 50;
/// Content-style extras (Design "content style", optional polish): list
/// (1) for the root's folders, grid (2) for playable station items.
static const _contentStyleLista = {
@@ -164,14 +195,21 @@ class ConstructorArbolAuto {
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
};
/// The 4 root folders (Favoritos, Todas las emisoras, Mis emisoras,
/// Ecualizador), all non-playable. `Ecualizador` is deliberately LAST
/// (Design ADR-2): content-browsing folders are the primary car task and
/// stay first, the EQ tool trails them.
List<MediaItem> raiz() => [
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras,
/// optionally Música Local, Ecualizador), all non-playable. `Ecualizador`
/// is deliberately LAST (Design ADR-2): content-browsing folders are the
/// primary car task and stay first, the EQ tool trails them. `Música
/// Local` is OMITTED entirely (not just empty) unless [incluirMusicaLocal]
/// is `true` (Design "Local root hidden until a folder is configured") —
/// the caller passes `fuente.hayCarpetaConfigurada()`, keeping this
/// builder itself synchronous and side-effect free. When `false`, the
/// result is byte-identical to the pre-local-music 4-folder tree
/// (regression guard).
List<MediaItem> raiz({required bool incluirMusicaLocal}) => [
_carpeta(idFavoritos, 'Favoritos'),
_carpeta(idTodas, 'Todas las emisoras'),
_carpeta(idMisEmisoras, 'Mis emisoras'),
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
_carpeta(idEcualizador, 'Ecualizador'),
];
@@ -229,6 +267,43 @@ class ConstructorArbolAuto {
MediaItem itemGrupo(GrupoFavoritos g) =>
_carpeta('$_prefijoGrupo${g.id}', g.nombre);
/// Whether [id] identifies a local-music subfolder (Design "media-id
/// scheme").
bool esCarpetaLocalMediaId(String id) => id.startsWith(_prefijoCarpetaLocal);
/// Strips the `carpeta_local:` prefix from [id] by length (Design "Prefix
/// stripped by length" — survives a raw SAF documentId containing `:`/`/`
/// verbatim). Only meaningful when [esCarpetaLocalMediaId] is `true`.
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) {
final ordenados = [...nodos]..sort((a, b) => a.nombre.compareTo(b.nombre));
return ordenados.take(_maxItemsCarpetaLocal).map(_itemLocal).toList();
}
MediaItem _itemLocal(NodoLocal nodo) {
if (nodo.esDirectorio) {
return _carpeta('$_prefijoCarpetaLocal${nodo.documentId}', nodo.nombre);
}
return MediaItem(
id: '$_prefijoPista${nodo.documentId}',
title: _tituloDesdeNombre(nodo.nombre),
playable: true,
artUri: Uri.parse(artUriLocal(nodo.documentId)),
extras: _contentStyleGrid,
);
}
/// Maps a [PresetEcualizador] to a playable `MediaItem` with id
/// `eq_preset:<nombre>` (Design ADR-1).
MediaItem itemPresetEq(PresetEcualizador preset) => MediaItem(
@@ -324,6 +399,124 @@ Future<void> reproducirPorMediaId(
await reproducir(item);
}
/// Fallback title (Design "Title = filename minus extension") for a blank
/// or otherwise empty-after-stripping local filename — hardcoded Spanish,
/// matching every other car-tree label in this file (`'Favoritos'`,
/// `'Ecualizador'`, etc.), none of which go through `AppLocalizations`.
const _tituloLocalFallback = 'Pista sin nombre';
/// Filename → display title (Design "Title = filename minus extension"):
/// strips the LAST `.ext` (the whole trimmed name is kept when there is no
/// dot, or the dot is the first character — e.g. a hidden file like
/// `.mp3`), falling back to [_tituloLocalFallback] when the result would be
/// blank.
String _tituloDesdeNombre(String nombre) {
final recortado = nombre.trim();
if (recortado.isEmpty) return _tituloLocalFallback;
final ultimoPunto = recortado.lastIndexOf('.');
final sinExtension =
ultimoPunto > 0 ? recortado.substring(0, ultimoPunto) : recortado;
final resultado = sinExtension.trim();
return resultado.isEmpty ? _tituloLocalFallback : resultado;
}
/// Resolves the on-brand fallback `artUri` for a local track (Design "art =
/// reused station_art_* rotation"): reuses the EXACT rotation
/// ([indiceArtePara]/`_nombresArte`) [artUriPara] uses for stations, seeded
/// by [documentId] instead of a station uuid — zero new assets, same
/// deterministic per-item mapping.
String artUriLocal(String documentId) =>
'android.resource://es.freetimelab.pluriwave/drawable/'
'station_art_${_nombresArte[indiceArtePara(documentId)]}';
/// Local-music `getChildren` dispatch (Design "Data Flow"): resolves
/// [parentMediaId] against the `musica_local` root (`fuente.hijos('')`) or a
/// `carpeta_local:<id>` subfolder (`fuente.hijos(id)`), mapping the result
/// through [ConstructorArbolAuto.itemsLocales]. Returns `null` when
/// [parentMediaId] matches NEITHER shape, so the caller
/// (`ServicioAudio.getChildren`) can fall through to its other branches
/// unmodified. A `null` [fuente] (local source never registered — headless
/// cold bind) or any thrown error degrades to `[]`, never a crash (Design
/// "cold-start safe", mirrors `FuenteEmisorasAutoLocal`'s pattern; Spec
/// "Browse requested before app state is loaded" / "Permission revoked or
/// never granted").
Future<List<MediaItem>?> hijosMusicaLocal(
String parentMediaId, {
required FuenteMusicaLocalAuto? fuente,
}) async {
final constructor = ConstructorArbolAuto();
final String documentId;
if (parentMediaId == ConstructorArbolAuto.idMusicaLocal) {
documentId = '';
} else if (constructor.esCarpetaLocalMediaId(parentMediaId)) {
documentId = constructor.idCarpetaLocalDesde(parentMediaId);
} else {
return null;
}
if (fuente == null) return const [];
try {
final nodos = await fuente.hijos(documentId);
return constructor.itemsLocales(nodos);
} catch (_) {
return const [];
}
}
/// Best-effort title for a played local track (Design "Local Track Playback
/// Reuses Existing Pipeline"): `FuenteMusicaLocalAuto.uriContenidoDePista`
/// only returns a content URI, not the original filename (Design's
/// Interfaces/Contracts — no metadata fields in Phase 1), so this derives a
/// title from the trailing path segment of the SAF [documentId] itself
/// (`primary:Music/Local/song.mp3` → `song.mp3` → title-stripped), applying
/// the SAME [_tituloDesdeNombre] rule the browse tree uses. This keeps the
/// Now Playing title consistent with what the user tapped without requiring
/// a second native round trip.
String _tituloDesdeDocumentId(String documentId) {
final ultimaBarra = documentId.lastIndexOf('/');
final segmento =
ultimaBarra >= 0 ? documentId.substring(ultimaBarra + 1) : documentId;
return _tituloDesdeNombre(segmento);
}
/// Routing seam between a car-tapped `pista:<docId>` media id and the
/// existing playback pipeline (Design "Local Track Playback Reuses Existing
/// Pipeline" — same seam shape as [reproducirPorMediaId], Spec "User selects
/// a local track"). Resolves the content URI via [fuente], builds a
/// `MediaItem` and delegates to [reproducir] — the SAME injection point
/// stations use, so the shared EQ signal chain applies identically (Spec
/// "EQ still applies to local track playback", regression guard: no
/// separate/bypassed path exists here).
///
/// A stale/unknown [id] (or a malformed one) is a no-op: [reproducir] is
/// never called and no exception propagates (Spec "Unknown or stale track
/// id").
Future<void> reproducirPistaLocal(
String id, {
required FuenteMusicaLocalAuto fuente,
required Future<void> Function(MediaItem) reproducir,
}) async {
if (!esPistaMediaId(id)) return;
final documentId = id.substring(_prefijoPista.length);
if (documentId.isEmpty) return;
final contentUri = await fuente.uriContenidoDePista(documentId);
if (contentUri == null || contentUri.isEmpty) return;
final pista = PistaLocal(
documentId: documentId,
titulo: _tituloDesdeDocumentId(documentId),
contentUri: contentUri,
);
final item = MediaItem(
id: pista.contentUri,
title: pista.titulo,
album: 'PluriWave',
extras: {'documentId': pista.documentId},
);
await reproducir(item);
}
/// Resolves an `eq_preset:<nombre>` [id] to the matching [PresetEcualizador]
/// in [presets] by exact name (Design ADR-1, mirrors
/// [ConstructorArbolAuto.resolver]'s shape). Any other shape (no prefix,