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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user