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
+115
View File
@@ -18,6 +18,7 @@ import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/preset_ecualizador.dart';
import '../servicios/musica_local_auto.dart';
import '../widgets/ecualizador_widget.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
@@ -66,6 +67,8 @@ class _AjustesContent extends StatelessWidget {
SizedBox(height: 12),
_SeccionGrabaciones(),
SizedBox(height: 12),
_SeccionMusicaLocal(),
SizedBox(height: 12),
_SeccionTimerSueno(),
SizedBox(height: 12),
_SeccionIdioma(),
@@ -278,6 +281,118 @@ class _SeccionGrabaciones extends StatelessWidget {
}
}
/// Local-music root-folder picker (android-auto-local-music task 9),
/// mirroring [_SeccionGrabaciones]'s shape: `PluriGlassSurface` card,
/// `FutureBuilder`-driven current-folder display, a single action button and
/// snackbar feedback. Deliberately does NOT use `FilePicker.platform` (see
/// tasks.md "Grounding corrections") — [FuenteMusicaLocalAutoImpl.elegirCarpeta]
/// calls the NEW native `pickMusicFolder` channel method directly, since it
/// needs a persistable-grant SAF tree URI, not a plain filesystem path.
class _SeccionMusicaLocal extends StatefulWidget {
const _SeccionMusicaLocal();
@override
State<_SeccionMusicaLocal> createState() => _SeccionMusicaLocalState();
}
class _SeccionMusicaLocalState extends State<_SeccionMusicaLocal> {
final _fuente = FuenteMusicaLocalAutoImpl();
late Future<String?> _carpetaActual;
@override
void initState() {
super.initState();
_carpetaActual = _fuente.carpetaActual();
}
Future<void> _elegirCarpeta(BuildContext context) async {
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
try {
final uri = await _fuente.elegirCarpeta();
if (uri == null) return; // Cancelled — no snackbar, matches the SAF
// picker's own "nothing changed" affordance.
if (!context.mounted) return;
setState(() {
_carpetaActual = Future.value(uri);
});
messenger.showSnackBar(
SnackBar(content: Text(l10n.localMusicFolderUpdated)),
);
} catch (e) {
if (!context.mounted) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.localMusicFolderSaveError(e.toString()))),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.library_music_outlined),
const SizedBox(width: 12),
Text(
l10n.localMusicSectionTitle,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 8),
Text(
l10n.localMusicSectionDescription,
style: Theme.of(context).textTheme.bodySmall,
),
FutureBuilder<String?>(
future: _carpetaActual,
builder: (ctx, snap) {
final carpeta = snap.data;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.folder_outlined),
title: Text(l10n.localMusicFolderTitle),
subtitle: Text(
(carpeta == null || carpeta.isEmpty)
? l10n.localMusicFolderNotConfigured
: carpeta,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
},
),
const SizedBox(height: 8),
FutureBuilder<String?>(
future: _carpetaActual,
builder: (ctx, snap) {
final configurada = (snap.data ?? '').isNotEmpty;
return Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
icon: const Icon(Icons.folder_open_rounded),
label: Text(
configurada
? l10n.localMusicChangePath
: l10n.localMusicChoosePath,
),
onPressed: () => _elegirCarpeta(context),
),
);
},
),
],
),
);
}
}
class _SeccionTimerSueno extends StatelessWidget {
const _SeccionTimerSueno();