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
+14 -1
View File
@@ -656,5 +656,18 @@
"eqDeviceNameLabel": "اسم الجهاز",
"eqDeviceNameHint": "مثال: مكبر صوت غرفة المعيشة",
"eqDeviceNameConfirm": "حفظ",
"eqDeviceConnected": "متصل"
"eqDeviceConnected": "متصل",
"localMusicSectionTitle": "الموسيقى المحلية (Android Auto)",
"localMusicSectionDescription": "اختر مجلدًا على هذا الجهاز لتصفح ملفاته الصوتية وتشغيلها من السيارة.",
"localMusicFolderNotConfigured": "لم يتم تحديد مجلد",
"localMusicFolderTitle": "مجلد الموسيقى المحلية",
"localMusicChoosePath": "اختيار مجلد",
"localMusicChangePath": "تغيير المجلد",
"localMusicFolderUpdated": "تم تحديث مجلد الموسيقى المحلية",
"localMusicFolderSaveError": "تعذّر حفظ المجلد: {error}",
"@localMusicFolderSaveError": {
"placeholders": {
"error": {}
}
}
}
+14 -1
View File
@@ -656,5 +656,18 @@
"eqDeviceNameLabel": "ডিভাইসের নাম",
"eqDeviceNameHint": "যেমন: লিভিং রুমের স্পিকার",
"eqDeviceNameConfirm": "সংরক্ষণ করুন",
"eqDeviceConnected": "সংযুক্ত"
"eqDeviceConnected": "সংযুক্ত",
"localMusicSectionTitle": "স্থানীয় সঙ্গীত (Android Auto)",
"localMusicSectionDescription": "গাড়িতে অডিও ফাইল ব্রাউজ ও চালানোর জন্য এই ডিভাইসের একটি ফোল্ডার বেছে নিন।",
"localMusicFolderNotConfigured": "কোনো ফোল্ডার নির্বাচিত হয়নি",
"localMusicFolderTitle": "স্থানীয় সঙ্গীত ফোল্ডার",
"localMusicChoosePath": "ফোল্ডার বেছে নিন",
"localMusicChangePath": "ফোল্ডার পরিবর্তন করুন",
"localMusicFolderUpdated": "স্থানীয় সঙ্গীত ফোল্ডার আপডেট হয়েছে",
"localMusicFolderSaveError": "ফোল্ডার সংরক্ষণ করা যায়নি: {error}",
"@localMusicFolderSaveError": {
"placeholders": {
"error": {}
}
}
}
+14 -1
View File
@@ -656,5 +656,18 @@
"eqDeviceNameLabel": "Gerätename",
"eqDeviceNameHint": "z. B. Wohnzimmer-Lautsprecher",
"eqDeviceNameConfirm": "Speichern",
"eqDeviceConnected": "Verbunden"
"eqDeviceConnected": "Verbunden",
"localMusicSectionTitle": "Lokale Musik (Android Auto)",
"localMusicSectionDescription": "Wähle einen Ordner auf diesem Gerät aus, um dessen Audiodateien im Auto zu durchsuchen und abzuspielen.",
"localMusicFolderNotConfigured": "Kein Ordner ausgewählt",
"localMusicFolderTitle": "Ordner für lokale Musik",
"localMusicChoosePath": "Ordner auswählen",
"localMusicChangePath": "Ordner ändern",
"localMusicFolderUpdated": "Ordner für lokale Musik aktualisiert",
"localMusicFolderSaveError": "Ordner konnte nicht gespeichert werden: {error}",
"@localMusicFolderSaveError": {
"placeholders": {
"error": {}
}
}
}
+14 -1
View File
@@ -656,5 +656,18 @@
"eqDeviceNameLabel": "Device name",
"eqDeviceNameHint": "e.g. Living Room Speaker",
"eqDeviceNameConfirm": "Save",
"eqDeviceConnected": "Connected"
"eqDeviceConnected": "Connected",
"localMusicSectionTitle": "Local music (Android Auto)",
"localMusicSectionDescription": "Pick a folder on this device to browse and play its audio files from the car.",
"localMusicFolderNotConfigured": "No folder selected",
"localMusicFolderTitle": "Local music folder",
"localMusicChoosePath": "Choose folder",
"localMusicChangePath": "Change folder",
"localMusicFolderUpdated": "Local music folder updated",
"localMusicFolderSaveError": "Could not save the folder: {error}",
"@localMusicFolderSaveError": {
"placeholders": {
"error": {}
}
}
}
+14 -1
View File
@@ -619,5 +619,18 @@
"eqDeviceNameLabel": "Nombre del dispositivo",
"eqDeviceNameHint": "Ej: Altavoz del living",
"eqDeviceNameConfirm": "Guardar",
"eqDeviceConnected": "Conectado"
"eqDeviceConnected": "Conectado",
"localMusicSectionTitle": "Música local (Android Auto)",
"localMusicSectionDescription": "Elegí una carpeta de este dispositivo para explorar y reproducir sus archivos de audio desde el auto.",
"localMusicFolderNotConfigured": "No hay carpeta seleccionada",
"localMusicFolderTitle": "Carpeta de música local",
"localMusicChoosePath": "Elegir carpeta",
"localMusicChangePath": "Cambiar carpeta",
"localMusicFolderUpdated": "Carpeta de música local actualizada",
"localMusicFolderSaveError": "No se pudo guardar la carpeta: {error}",
"@localMusicFolderSaveError": {
"placeholders": {
"error": {}
}
}
}
+14 -1
View File
@@ -656,5 +656,18 @@
"eqDeviceNameLabel": "Nom de l'appareil",
"eqDeviceNameHint": "ex. : Enceinte salon",
"eqDeviceNameConfirm": "Enregistrer",
"eqDeviceConnected": "Connecté"
"eqDeviceConnected": "Connecté",
"localMusicSectionTitle": "Musique locale (Android Auto)",
"localMusicSectionDescription": "Choisissez un dossier sur cet appareil pour parcourir et lire ses fichiers audio depuis la voiture.",
"localMusicFolderNotConfigured": "Aucun dossier sélectionné",
"localMusicFolderTitle": "Dossier de musique locale",
"localMusicChoosePath": "Choisir un dossier",
"localMusicChangePath": "Changer de dossier",
"localMusicFolderUpdated": "Dossier de musique locale mis à jour",
"localMusicFolderSaveError": "Impossible denregistrer le dossier : {error}",
"@localMusicFolderSaveError": {
"placeholders": {
"error": {}
}
}
}
+14 -1
View File
@@ -656,5 +656,18 @@
"eqDeviceNameLabel": "डिवाइस का नाम",
"eqDeviceNameHint": "उदा. लिविंग रूम स्पीकर",
"eqDeviceNameConfirm": "सहेजें",
"eqDeviceConnected": "कनेक्टेड"
"eqDeviceConnected": "कनेक्टेड",
"localMusicSectionTitle": "स्थानीय संगीत (Android Auto)",
"localMusicSectionDescription": "गाड़ी में ऑडियो फ़ाइलें ब्राउज़ और चलाने के लिए इस डिवाइस का एक फ़ोल्डर चुनें।",
"localMusicFolderNotConfigured": "कोई फ़ोल्डर चुना नहीं गया",
"localMusicFolderTitle": "स्थानीय संगीत फ़ोल्डर",
"localMusicChoosePath": "फ़ोल्डर चुनें",
"localMusicChangePath": "फ़ोल्डर बदलें",
"localMusicFolderUpdated": "स्थानीय संगीत फ़ोल्डर अपडेट हुआ",
"localMusicFolderSaveError": "फ़ोल्डर सहेजा नहीं जा सका: {error}",
"@localMusicFolderSaveError": {
"placeholders": {
"error": {}
}
}
}
+14 -1
View File
@@ -656,5 +656,18 @@
"eqDeviceNameLabel": "Nama perangkat",
"eqDeviceNameHint": "Mis: Speaker ruang tamu",
"eqDeviceNameConfirm": "Simpan",
"eqDeviceConnected": "Terhubung"
"eqDeviceConnected": "Terhubung",
"localMusicSectionTitle": "Musik lokal (Android Auto)",
"localMusicSectionDescription": "Pilih folder di perangkat ini untuk menjelajahi dan memutar file audio di dalamnya dari mobil.",
"localMusicFolderNotConfigured": "Belum ada folder dipilih",
"localMusicFolderTitle": "Folder musik lokal",
"localMusicChoosePath": "Pilih folder",
"localMusicChangePath": "Ubah folder",
"localMusicFolderUpdated": "Folder musik lokal diperbarui",
"localMusicFolderSaveError": "Tidak dapat menyimpan folder: {error}",
"@localMusicFolderSaveError": {
"placeholders": {
"error": {}
}
}
}
+14 -1
View File
@@ -656,5 +656,18 @@
"eqDeviceNameLabel": "Nome del dispositivo",
"eqDeviceNameHint": "Es: Cassa del salotto",
"eqDeviceNameConfirm": "Salva",
"eqDeviceConnected": "Connesso"
"eqDeviceConnected": "Connesso",
"localMusicSectionTitle": "Musica locale (Android Auto)",
"localMusicSectionDescription": "Scegli una cartella su questo dispositivo per sfogliare e riprodurre i suoi file audio dall'auto.",
"localMusicFolderNotConfigured": "Nessuna cartella selezionata",
"localMusicFolderTitle": "Cartella musica locale",
"localMusicChoosePath": "Scegli cartella",
"localMusicChangePath": "Cambia cartella",
"localMusicFolderUpdated": "Cartella musica locale aggiornata",
"localMusicFolderSaveError": "Impossibile salvare la cartella: {error}",
"@localMusicFolderSaveError": {
"placeholders": {
"error": {}
}
}
}
+14 -1
View File
@@ -656,5 +656,18 @@
"eqDeviceNameLabel": "デバイス名",
"eqDeviceNameHint": "例:リビングのスピーカー",
"eqDeviceNameConfirm": "保存",
"eqDeviceConnected": "接続中"
"eqDeviceConnected": "接続中",
"localMusicSectionTitle": "ローカル音楽(Android Auto",
"localMusicSectionDescription": "この端末のフォルダーを選択して、車内でその音声ファイルを閲覧・再生します。",
"localMusicFolderNotConfigured": "フォルダーが選択されていません",
"localMusicFolderTitle": "ローカル音楽フォルダー",
"localMusicChoosePath": "フォルダーを選択",
"localMusicChangePath": "フォルダーを変更",
"localMusicFolderUpdated": "ローカル音楽フォルダーを更新しました",
"localMusicFolderSaveError": "フォルダーを保存できませんでした: {error}",
"@localMusicFolderSaveError": {
"placeholders": {
"error": {}
}
}
}
+14 -1
View File
@@ -656,5 +656,18 @@
"eqDeviceNameLabel": "Nome do dispositivo",
"eqDeviceNameHint": "Ex: Caixa da sala",
"eqDeviceNameConfirm": "Salvar",
"eqDeviceConnected": "Conectado"
"eqDeviceConnected": "Conectado",
"localMusicSectionTitle": "Música local (Android Auto)",
"localMusicSectionDescription": "Escolha uma pasta neste dispositivo para navegar e reproduzir os arquivos de áudio dela no carro.",
"localMusicFolderNotConfigured": "Nenhuma pasta selecionada",
"localMusicFolderTitle": "Pasta de música local",
"localMusicChoosePath": "Escolher pasta",
"localMusicChangePath": "Alterar pasta",
"localMusicFolderUpdated": "Pasta de música local atualizada",
"localMusicFolderSaveError": "Não foi possível salvar a pasta: {error}",
"@localMusicFolderSaveError": {
"placeholders": {
"error": {}
}
}
}
+14 -1
View File
@@ -656,5 +656,18 @@
"eqDeviceNameLabel": "Название устройства",
"eqDeviceNameHint": "Напр.: Колонка в гостиной",
"eqDeviceNameConfirm": "Сохранить",
"eqDeviceConnected": "Подключено"
"eqDeviceConnected": "Подключено",
"localMusicSectionTitle": "Локальная музыка (Android Auto)",
"localMusicSectionDescription": "Выберите папку на этом устройстве, чтобы просматривать и воспроизводить её аудиофайлы в автомобиле.",
"localMusicFolderNotConfigured": "Папка не выбрана",
"localMusicFolderTitle": "Папка локальной музыки",
"localMusicChoosePath": "Выбрать папку",
"localMusicChangePath": "Изменить папку",
"localMusicFolderUpdated": "Папка локальной музыки обновлена",
"localMusicFolderSaveError": "Не удалось сохранить папку: {error}",
"@localMusicFolderSaveError": {
"placeholders": {
"error": {}
}
}
}
+14 -1
View File
@@ -656,5 +656,18 @@
"eqDeviceNameLabel": "设备名称",
"eqDeviceNameHint": "例:客厅音箱",
"eqDeviceNameConfirm": "保存",
"eqDeviceConnected": "已连接"
"eqDeviceConnected": "已连接",
"localMusicSectionTitle": "本地音乐(Android Auto",
"localMusicSectionDescription": "选择此设备上的一个文件夹,以便在车内浏览和播放其中的音频文件。",
"localMusicFolderNotConfigured": "未选择文件夹",
"localMusicFolderTitle": "本地音乐文件夹",
"localMusicChoosePath": "选择文件夹",
"localMusicChangePath": "更改文件夹",
"localMusicFolderUpdated": "本地音乐文件夹已更新",
"localMusicFolderSaveError": "无法保存文件夹:{error}",
"@localMusicFolderSaveError": {
"placeholders": {
"error": {}
}
}
}
+8
View File
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
import 'servicios/musica_local_auto.dart';
import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_audio.dart';
import 'servicios/servicio_audio_session.dart';
@@ -49,6 +50,13 @@ Future<void> main() async {
final fuenteAuto = FuenteEmisorasAutoLocal();
registrarFuenteNavegacion(fuenteAuto);
// Local-music browse source (Design "getChildren data source
// registration"), same injectable-prefs DI convention as every other
// startup service — required so `_fuenteMusicaLocalGlobal` is ever
// non-null; without this registration the local-music root would stay
// permanently hidden (`hayCarpetaConfigurada()` has no fuente to ask).
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl(prefs: prefs));
// S3-R1: audio focus — phone calls / transient losses pause or duck the
// radio; headphones unplugged pauses it.
final sesionAudio = ServicioAudioSession(objetivo: handler);
+46
View File
@@ -0,0 +1,46 @@
/// Local file-system node returned by the native `listAudioChildren`
/// channel call (Design "Interfaces / Contracts"): either a subfolder or an
/// audio file, one SAF tree level deep. Pure DTO — no behavior, so no unit
/// tests are warranted for it on its own (exercised indirectly through its
/// consumers, e.g. `ConstructorArbolAuto.itemsLocales`).
class NodoLocal {
const NodoLocal({
required this.documentId,
required this.nombre,
required this.esDirectorio,
});
/// Opaque SAF document id, unique within the picked tree. May itself
/// contain `:`/`/` (Design "Prefix stripped by length"), so callers must
/// never split/parse it — only wrap it verbatim in a media id.
final String documentId;
/// Raw on-device filename (or folder name), NOT yet title-stripped.
final String nombre;
/// Whether this node is a browsable subfolder (`true`) or a playable
/// audio file (`false`).
final bool esDirectorio;
}
/// Minimal playable local track (Design "Phase 1 minimal shape" — no
/// artist/album/duration metadata per spec's "Not in this delta"). Pure
/// DTO — no behavior, so no unit tests are warranted for it on its own.
class PistaLocal {
const PistaLocal({
required this.documentId,
required this.titulo,
required this.contentUri,
});
/// Opaque SAF document id for this track.
final String documentId;
/// Display title, already computed by the caller (filename minus
/// extension, or a derived fallback — see `navegacion_auto.dart`).
final String titulo;
/// Playable `content://` URI resolved via
/// `FuenteMusicaLocalAuto.uriContenidoDePista`.
final String contentUri;
}
+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();
+180
View File
@@ -0,0 +1,180 @@
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/pista_local.dart';
/// SharedPreferences key for the persisted local-music root tree URI
/// (Design "Data Flow"). Read/written exclusively by
/// [FuenteMusicaLocalAutoImpl].
const _keyUriCarpetaLocal = 'musica_local_uri';
/// Dart-side re-validation of a native-reported MIME type (Design
/// "Interfaces / Contracts" — native already filters to `audio/*`; this is
/// defense-in-depth, not the only gate). Requires a non-blank `audio/*`
/// [mime] AND a non-blank [nombre] — a blank filename is never a valid
/// audio entry regardless of MIME.
bool esArchivoAudio(String? mime, String? nombre) {
final mimeRecortado = mime?.trim();
final nombreRecortado = nombre?.trim();
if (mimeRecortado == null || mimeRecortado.isEmpty) return false;
if (nombreRecortado == null || nombreRecortado.isEmpty) return false;
return mimeRecortado.toLowerCase().startsWith('audio/');
}
/// 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
/// a separate interface from [FuenteEmisorasAuto] — local music is its own
/// browse domain, not a station source.
abstract class FuenteMusicaLocalAuto {
/// Whether a local-music root folder is picked AND its permission is
/// still valid. Never throws — a revoked/never-granted permission
/// degrades to `false` (Spec "Permission revoked or never granted").
Future<bool> hayCarpetaConfigurada();
/// Immediate children of [documentId] (`''` = the tree root itself), one
/// SAF level deep (Design "Lazy per-folder enumeration, never an eager
/// tree dump"). Never throws — any failure degrades to `[]` (Spec
/// "Permission revoked or never granted", "Browse requested before app
/// state is loaded").
Future<List<NodoLocal>> hijos(String documentId);
/// Resolves a leaf [documentId] to its playable `content://` URI, or
/// `null` if it cannot be resolved (stale id, revoked permission). Never
/// throws.
Future<String?> uriContenidoDePista(String documentId);
}
/// Channel-backed [FuenteMusicaLocalAuto] implementation (Design "Hand-rolled
/// SAF channel, not `shared_storage`"): calls the existing
/// `pluriwave/file_actions` `MethodChannel`'s native SAF methods
/// (`MainActivity.kt`, static-review-only) and the picker/persistence side
/// used by the phone settings UI. Every channel call is wrapped in
/// try/catch so a revoked permission, a missing native method (older APK on
/// a mismatched build) or any other native-side failure degrades to an
/// empty/absent result instead of throwing — mirrors
/// `FuenteEmisorasAutoLocal`'s cold-start-safe shape
/// (`navegacion_auto.dart:421-471`).
class FuenteMusicaLocalAutoImpl implements FuenteMusicaLocalAuto {
FuenteMusicaLocalAutoImpl({SharedPreferences? prefs}) : _prefs = prefs;
static const MethodChannel _canal = MethodChannel('pluriwave/file_actions');
final SharedPreferences? _prefs;
/// Injected startup instance (S3-R4 convention, mirrors
/// `ServicioEcualizador`'s DI pattern, `servicio_ecualizador.dart:37,54,57`
/// — `getInstance()` is only a fallback for call sites that don't inject
/// one, e.g. tests or a lazily-constructed settings-only instance).
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
Future<String?> _uriPersistida() async {
final prefs = await _resolverPrefs();
return prefs.getString(_keyUriCarpetaLocal);
}
/// Persists [treeUri] as the local-music root (Design "Data Flow" —
/// settings write side). Exposed separately from [elegirCarpeta] so a
/// caller that already has a URI (e.g. a future restore/import flow)
/// doesn't need to re-invoke the native picker.
Future<void> guardarCarpeta(String treeUri) async {
final prefs = await _resolverPrefs();
await prefs.setString(_keyUriCarpetaLocal, treeUri);
}
/// The currently persisted root URI, or `null` if none was ever picked.
/// Used by the settings UI to render the "current folder" state.
Future<String?> carpetaActual() => _uriPersistida();
/// Launches the native SAF folder picker (`pickMusicFolder`) and persists
/// the result on success (Spec "User picks a local music root folder").
/// Returns the picked tree URI, or `null` if the user cancelled or the
/// native call failed — never throws.
Future<String?> elegirCarpeta() async {
try {
final uri = await _canal.invokeMethod<String>('pickMusicFolder');
if (uri == null || uri.isEmpty) return null;
await guardarCarpeta(uri);
return uri;
} catch (_) {
return null;
}
}
@override
Future<bool> hayCarpetaConfigurada() async {
try {
final uri = await _uriPersistida();
if (uri == null || uri.isEmpty) return false;
final valido = await _canal.invokeMethod<bool>(
'hasPersistedPermission',
{'treeUri': uri},
);
return valido ?? false;
} catch (_) {
// Cold-start / revoked-permission safety (Spec "Permission revoked or
// never granted"): never throw, degrade to "not configured".
return false;
}
}
@override
Future<List<NodoLocal>> hijos(String documentId) async {
try {
final uri = await _uriPersistida();
if (uri == null || uri.isEmpty) return const [];
final crudos = await _canal.invokeMethod<List<Object?>>(
'listAudioChildren',
{'treeUri': uri, 'parentDocumentId': documentId},
);
if (crudos == null) return const [];
return crudos
.whereType<Map<Object?, Object?>>()
.map(_nodoDesdeMapa)
.whereType<NodoLocal>()
.toList();
} catch (_) {
// Cold-start / revoked-permission safety (Spec "Permission revoked or
// never granted", "Browse requested before app state is loaded").
return const [];
}
}
@override
Future<String?> uriContenidoDePista(String documentId) async {
try {
final uri = await _uriPersistida();
if (uri == null || uri.isEmpty) return null;
return await _canal.invokeMethod<String>('resolvePlayableUri', {
'treeUri': uri,
'documentId': documentId,
});
} catch (_) {
return null;
}
}
/// Maps a raw `listAudioChildren` row to a [NodoLocal], re-validating
/// audio files via [esArchivoAudio] (Design "Interfaces / Contracts" —
/// defense-in-depth on top of the native `audio/*` filter). Returns `null`
/// for a malformed row (missing id/name) or a file whose MIME fails
/// re-validation, so [hijos] can silently drop it instead of surfacing a
/// broken entry.
NodoLocal? _nodoDesdeMapa(Map<Object?, Object?> mapa) {
final documentId = mapa['documentId'] as String?;
final nombre = mapa['nombre'] as String?;
final esDirectorio = mapa['esDirectorio'] as bool? ?? false;
if (documentId == null || documentId.isEmpty) return null;
if (nombre == null) return null;
if (!esDirectorio) {
final mime = mapa['mime'] as String?;
if (!esArchivoAudio(mime, nombre)) return null;
}
return NodoLocal(
documentId: documentId,
nombre: nombre,
esDirectorio: esDirectorio,
);
}
}
+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,
+35 -1
View File
@@ -11,6 +11,7 @@ import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/preset_ecualizador.dart';
import 'controlador_reconexion.dart';
import 'musica_local_auto.dart';
import 'navegacion_auto.dart';
import 'servicio_audio_session.dart';
import 'servicio_ecualizador.dart';
@@ -47,6 +48,17 @@ void registrarFuenteNavegacion(FuenteEmisorasAuto fuente) {
_fuenteNavegacionGlobal = fuente;
}
/// Local-music browse source — registered from main.dart, mirrors
/// [registrarFuenteNavegacion] above (Design "getChildren data source
/// registration"). `null` until registered (headless cold bind before
/// main.dart's registration line runs) — every consumer below treats a
/// `null` fuente as "not configured" rather than throwing.
FuenteMusicaLocalAuto? _fuenteMusicaLocalGlobal;
void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) {
_fuenteMusicaLocalGlobal = fuente;
}
/// Wrapper de alto nivel para el UI.
class ServicioAudio {
PluriWaveAudioHandler get _handler {
@@ -735,12 +747,20 @@ class PluriWaveAudioHandler extends BaseAudioHandler
]) async {
try {
final constructor = ConstructorArbolAuto();
final fuenteLocal = _fuenteMusicaLocalGlobal;
if (parentMediaId == AudioService.browsableRootId) {
return constructor.raiz();
final incluirMusicaLocal =
fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada();
return constructor.raiz(incluirMusicaLocal: incluirMusicaLocal);
}
if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
return constructor.presetsEq(PresetEcualizador.presets);
}
final musicaLocal = await hijosMusicaLocal(
parentMediaId,
fuente: fuenteLocal,
);
if (musicaLocal != null) return musicaLocal;
final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return const [];
if (parentMediaId == ConstructorArbolAuto.idFavoritos) {
@@ -802,6 +822,20 @@ class PluriWaveAudioHandler extends BaseAudioHandler
);
return;
}
// Local-track playback (Design "Local Track Playback Reuses Existing
// Pipeline", Spec "User selects a local track"): SECOND branch,
// unconditional `return`, mirroring the eq_preset branch above — a
// `pista:` id never falls through to the station routing below.
if (esPistaMediaId(mediaId)) {
final fuenteLocal = _fuenteMusicaLocalGlobal;
if (fuenteLocal == null) return;
await reproducirPistaLocal(
mediaId,
fuente: fuenteLocal,
reproducir: playMediaItem,
);
return;
}
final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return;
await reproducirPorMediaId(