Files
pluriwave/lib/servicios/navegacion_auto.dart
FreeTLab 2403da3c2e
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m22s
refactor(auto): drop the in-car equalizer, keep EQ on the phone
The car tree carried a whole equalizer feature: an `Ecualizador` root folder
with the six factory presets, a browsable `Personalizado` folder, five band
folders and thirteen gain leaves each, plus the media-id namespaces, routing
predicates, persistence-targeting and children-changed plumbing that fed
them. Equalization is a phone task; the driver should not be tuning 5 bands
from a car screen.

Removed: the `eq_preset:`/`eq_banda:`/`eq_gain:` namespaces and their
predicates and parsers, the `ecualizador`/`eq_custom` folder ids and their
getChildren branches, itemPresetEq/presetsEq/itemEqPersonalizado/bandasEq/
gananciasBandaEq, resolverPresetEq, presetPersonalizadoEfectivo,
dispositivoDestinoEq, debeAplicarPrincipalAhora/debeAplicarSeleccionAhora,
aplicarPresetPorMediaId, aplicarGananciaPorMediaId, and in the handler the
playFromMediaId branches, _presetPersonalizadoAuto, _dispositivoActivoAuto,
_dispositivoDestinoEqAuto and the subscribeToChildren/_hijosSubjects
notification machinery that existed only to refresh band titles after a gain
tap.

Deliberately KEPT: automatic per-device EQ. Reaching the car still applies
that device's preset, because that lives in EstadoEcualizador and the
output-device detection, not in this tree — it works with Android Auto or
without it. Configuring is what moves to the phone; applying stays automatic.

Also kept: the `eq_preset_*_v1` SharedPreferences keys in
ServicioEcualizador, which share a name with the deleted media-id prefix by
coincidence only and hold the phone's own presets.

The root folder set goes from five entries to four (three without local
music); its test now asserts no equalizer folder is offered at all, so a
reintroduction has to be deliberate.
2026-07-27 15:50:24 +02:00

1296 lines
56 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'dart:math' show Random;
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';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/pista_local.dart';
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:';
/// Local-track media-id prefix (Design "media-id scheme"), collision-free
/// against [_prefijoEmisora], `grupo:` and the bare folder id constants.
/// Top-level (not a [ConstructorArbolAuto] member) — 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)
/// to guarantee phone/car per-station art parity (Design "Fallback-art
/// selection"). Keep this list in sync with that one — there is no
/// structural enforcement of order, only this comment and the parity test
/// in `navegacion_auto_test.dart` (group `parity: phone/auto art order`).
const _nombresArte = ['aurora', 'cosmic', 'pulse', 'nova'];
/// Returns whether [favicon] is usable as a remote `artUri` (Design
/// Decision "Case B detection" — static validity gate, zero network):
/// non-null, non-blank after trimming, and parses as an absolute
/// `http`/`https` URI with a non-empty authority. Does **not** probe
/// reachability — the OS art loader fetches `artUri` independently and
/// later, so a build-time network check would be racy (TOCTOU); this only
/// catches the deterministic malformed/non-http(s) subset (bare hosts,
/// wrong scheme, `http://` with no authority, whitespace, unparseable).
bool faviconUsable(String? favicon) {
final trimmed = favicon?.trim();
if (trimmed == null || trimmed.isEmpty) return false;
final uri = Uri.tryParse(trimmed);
if (uri == null) return false;
// `Uri.hasAuthority` is true whenever a `//` authority slot is present,
// even with an empty host (e.g. `Uri.parse('http://').hasAuthority` is
// `true`) — check `host.isNotEmpty` explicitly to actually require a
// non-empty authority host.
return (uri.scheme == 'http' || uri.scheme == 'https') &&
uri.host.isNotEmpty;
}
/// Deterministic rotation index over the 4 on-brand fallback arts, same
/// formula as `tarjeta_emisora.dart`'s `_fallbackArtFor` (Design
/// "Fallback-art selection — port verbatim"): `seed` is the station uuid.
int indiceArtePara(String seed) =>
seed.codeUnits.fold<int>(0, (a, b) => a + b) % _nombresArte.length;
/// Resolves the `artUri` for [e] (Design "Data Flow"): the favicon when it
/// passes [faviconUsable], otherwise a rotating `station_art_<name>`
/// drawable URI selected via [indiceArtePara] over `e.uuid` — the same
/// on-brand art the phone UI would pick for this station (per-station
/// parity), never a launcher-icon lookalike.
String artUriPara(Emisora e) => faviconUsable(e.favicon)
? e.favicon!
: 'android.resource://es.freetimelab.pluriwave/drawable/'
'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
/// Formats a human-readable audio-quality hint for the browse row's
/// `displaySubtitle` (Design Decision "`displaySubtitle` quality format"):
/// `"<bitrate> kbps · <CODEC>"` when both are known, just the bitrate or
/// just the codec when only one is known, and `null` (never `""`, never a
/// string containing the literal `"null"`) when both are unknown. Codec is
/// trimmed and upper-cased; blank-after-trim counts as unknown. `bitrate`
/// `<= 0` counts as unknown (Radio Browser stores `0` for unknown).
String? subtituloCalidad(Emisora e) {
final codec = e.codec?.trim();
final codecConocido = codec != null && codec.isNotEmpty;
final bitrate = e.bitrate;
final bitrateConocido = bitrate != null && bitrate > 0;
if (codecConocido && bitrateConocido) {
return '$bitrate kbps · ${codec.toUpperCase()}';
}
if (bitrateConocido) return '$bitrate kbps';
if (codecConocido) return codec.toUpperCase();
return null;
}
/// Formats a human-readable quality hint for a local track's
/// `displaySubtitle` (Design ADR-5 "unknown → omit" discipline, reused for
/// local tracks): `"<bitrate> kbps · <sampleRate> kHz"` when both are
/// known, just the bitrate or just the sample rate when only one is known,
/// and `null` (never `""`, never a string containing the literal `"null"`)
/// when [metadatos] is `null` or both fields are unknown. `bitrate` is
/// converted from bits/sec to kbps (rounded); `sampleRate` from Hz to kHz,
/// trimmed of a trailing `.0`.
String? subtituloCalidadLocal(MetadatosPista? metadatos) {
if (metadatos == null) return null;
final bitrate = metadatos.bitrate;
final bitrateConocido = bitrate != null && bitrate > 0;
final sampleRate = metadatos.sampleRate;
final sampleRateConocido = sampleRate != null && sampleRate > 0;
if (bitrateConocido && sampleRateConocido) {
return '${(bitrate / 1000).round()} kbps · ${_formatKhz(sampleRate)} kHz';
}
if (bitrateConocido) return '${(bitrate / 1000).round()} kbps';
if (sampleRateConocido) return '${_formatKhz(sampleRate)} kHz';
return null;
}
/// Formats [sampleRateHz] (in Hz) as a trimmed kHz string: `44100` ->
/// `'44.1'`, `48000` -> `'48'` — never a trailing `.0` or extra zeros.
String _formatKhz(int sampleRateHz) {
var texto = (sampleRateHz / 1000).toStringAsFixed(2);
while (texto.endsWith('0')) {
texto = texto.substring(0, texto.length - 1);
}
if (texto.endsWith('.')) {
texto = texto.substring(0, texto.length - 1);
}
return texto;
}
/// Browse-source abstraction for the Android Auto media tree (Design
/// "getChildren data source, cold-start safe"). Kept separate from
/// `EstadoRadio` so a headless Auto bind (`main()` runs but the widget tree
/// — and therefore the lazily-created `EstadoRadio` — never builds) still
/// gets a valid, non-throwing tree.
abstract class FuenteEmisorasAuto {
Future<List<Emisora>> favoritos();
Future<List<Emisora>> misEmisoras();
/// `populares` snapshot; may be empty on a cold bind (Design "which
/// stations surface & ordering").
Future<List<Emisora>> todas();
Future<Emisora?> porUuid(String uuid);
/// Favorite groups (`GrupoFavoritos`), cold-start safe — mirrors
/// [favoritos]'s never-throws contract (Design "Favorite Group
/// Sub-Folders").
Future<List<GrupoFavoritos>> grupos();
/// Live-snapshot push (Design "live snapshot the source prefers"):
/// `EstadoRadio`, when alive, calls this unconditionally on every
/// favorites/custom/populares mutation so a car and phone that are both
/// live see identical lists. Default no-op — only implementations that
/// actually buffer a snapshot (e.g. [FuenteEmisorasAutoLocal]) need to
/// override it; a null field on `EstadoRadio` skips the call entirely via
/// `?.`.
void actualizarSnapshot({
List<Emisora>? favoritos,
List<Emisora>? misEmisoras,
List<Emisora>? todas,
List<GrupoFavoritos>? grupos,
}) {}
}
/// Pure builder for the Android Auto browse tree: folders, leaf items, id
/// resolution. No platform dependency — fully testable without a running
/// car or a real `AudioHandler`.
class ConstructorArbolAuto {
/// Root folder ids (Design "media-id scheme"). The tree root itself is
/// identified by [AudioService.browsableRootId], not by a constant here —
/// the handler compares against it directly before calling [raiz].
static const idFavoritos = 'favoritos';
static const idTodas = 'todas';
static const idMisEmisoras = 'mis_emisoras';
/// 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;
/// Favorite-group folder id prefix (Design "media-id scheme"), collision
/// 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:';
/// 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:';
/// Sort-mode local-music id prefix (Design ADR-4, Phase 2):
/// `carpeta_local_ord:<modo>:<pagina>:<docId>`. Collision-free against
/// every other prefix/bare id in this class — diverges from
/// [_prefijoCarpetaLocal] at index 13 (`_` vs `:`) and from
/// [_prefijoCarpetaLocalBucket]/[_prefijoCarpetaLocalPaginada] at the char
/// right after `carpeta_local_` (`o` vs `b`/`p`).
static const _prefijoCarpetaLocalOrd = 'carpeta_local_ord:';
/// Alphabetical-bucket local-music id prefix (Design ADR-4, Phase 2):
/// `carpeta_local_bucket:<idxBucket>:<pagina>:<docId>`. Collision-free
/// against every other prefix/bare id in this class (see
/// [_prefijoCarpetaLocalOrd]'s doc for the divergence proof).
static const _prefijoCarpetaLocalBucket = 'carpeta_local_bucket:';
/// "Reproducir carpeta" (sequential-play) action media-id prefix (Design
/// ADR-5, Phase 3): `carpeta_local_reproducir:<docId>`. PLAYABLE (unlike
/// every other `carpeta_local_*` prefix in this class, which are
/// non-playable browse folders) — routed through `playFromMediaId`, not
/// `getChildren`. Collision-free against every other prefix here:
/// diverges from [_prefijoCarpetaLocalPaginada]/[_prefijoCarpetaLocalOrd]/
/// [_prefijoCarpetaLocalBucket] at index 14 (`r` vs `p`/`o`/`b`), same
/// divergence-point family as those siblings' doc comments.
static const _prefijoCarpetaLocalReproducir = 'carpeta_local_reproducir:';
/// "Reproducir aleatorio" (shuffled-play) action media-id prefix (Design
/// ADR-5, Phase 3): `carpeta_local_aleatorio:<docId>`. PLAYABLE, mirrors
/// [_prefijoCarpetaLocalReproducir]. Diverges from every sibling prefix
/// at index 14 (`a` vs `r`/`p`/`o`/`b`).
static const _prefijoCarpetaLocalAleatorio = 'carpeta_local_aleatorio:';
/// 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;
/// Quality-sort track-count cap (Design ADR-3): above this, the
/// "Ordenar por calidad" entry is omitted instead of paying an unbounded
/// per-file `MediaMetadataRetriever` extraction cost — first-pass value,
/// not yet hardware-validated (Design "Open Questions").
static const _maxPistasParaOrdenCalidad = 150;
/// Bucket-eligibility track-count threshold (Design ADR-4): buckets add
/// no value for small folders, so they're only offered above this count.
static const _minPistasParaBuckets = 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 = {
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 1,
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1,
};
static const _contentStyleGrid = {
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
};
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras,
/// optionally Música Local), all non-playable.
///
/// There is deliberately no equalizer folder: EQ is configured on the phone
/// only. The car still gets the right sound, because the per-device preset
/// is applied automatically when the output device changes — that lives in
/// `EstadoEcualizador`, not in this tree.
///
/// `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.
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'),
];
MediaItem _carpeta(String id, String titulo) => MediaItem(
id: id,
title: titulo,
playable: false,
extras: _contentStyleLista,
);
/// Leaf items for [parentId], sorted via [ordenarEmisoras] and capped at
/// [_maxItemsPorCarpeta] (Design "which stations surface & ordering" —
/// avoids driver distraction and Auto list limits). Unknown [parentId]
/// (or an empty [emisoras]) returns an empty list instead of throwing.
List<MediaItem> hijos(String parentId, {required List<Emisora> emisoras}) {
if (!_idsCarpetas.contains(parentId)) return const [];
if (emisoras.isEmpty) return const [];
final ordenadas = ordenarEmisoras(emisoras, OrdenEmisoras.calidad);
return ordenadas.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
}
/// Maps a single [Emisora] to a playable `MediaItem`: id `emisora:<uuid>`
/// (Design "media-id scheme"), title, on-brand-fallback-aware `artUri`
/// (Design "Case B detection" + "Fallback-art selection") and a
/// quality-hint `displaySubtitle` (Design "`displaySubtitle` quality
/// format") when codec/bitrate are known.
MediaItem itemEmisora(Emisora e) => MediaItem(
id: '$_prefijoEmisora${e.uuid}',
title: e.nombre,
playable: true,
artUri: Uri.parse(artUriPara(e)),
displaySubtitle: subtituloCalidad(e),
extras: _contentStyleGrid,
);
/// Resolves `emisora:<uuid>` ids to the matching [Emisora] in [universo].
/// Any other shape (no prefix, empty uuid, unmatched uuid) returns `null`
/// instead of throwing (Spec "Media Item Resolution by ID").
Emisora? resolver(String id, List<Emisora> universo) {
if (!id.startsWith(_prefijoEmisora)) return null;
final uuid = id.substring(_prefijoEmisora.length);
if (uuid.isEmpty) return null;
for (final emisora in universo) {
if (emisora.uuid == uuid) return emisora;
}
return null;
}
/// Whether [id] identifies a favorite-group folder (Design "media-id
/// scheme").
bool esCarpetaGrupo(String id) => id.startsWith(_prefijoGrupo);
/// Maps a [GrupoFavoritos] to a non-playable folder `MediaItem` with id
/// `grupo:<id>` (Design "media-id scheme").
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);
/// 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 AND
/// metadata-backed (Design "Lazy per-folder enumeration" + ADR-3
/// pagination + Phase 2 "Data Flow"): the full [nodos] list is sorted
/// alphabetically by [NodoLocal.nombre] — cheap, no `MediaItem` built yet
/// — then sliced to [pagina] via [paginaDe] BEFORE any metadata is
/// resolved or `MediaItem` is constructed (Design "slice the cheap list,
/// then map — never map-then-slice", the memory-efficiency invariant).
/// [metadatosDe] is then awaited for ONLY the sliced page's non-directory
/// `documentId`s — never the whole folder — and only THEN is the page
/// mapped through [construirItem] with the resolved metadata map. 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.
Future<List<MediaItem>> itemsLocales(
List<NodoLocal> nodos, {
required String documentIdPadre,
required Future<Map<String, MetadatosPista>> Function(List<String>)
metadatosDe,
int pagina = 0,
int tamano = _maxItemsCarpetaLocal,
@visibleForTesting
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
}) async {
final construir = construirItem ?? _itemLocal;
final ordenados = [...nodos]..sort((a, b) => a.nombre.compareTo(b.nombre));
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final docIds = paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final metadatos = await metadatosDe(docIds);
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
items.add(_itemMasLocal(documentIdPadre, pagina + 1));
}
if (pagina == 0) {
final totalPistas = nodos.where((n) => !n.esDirectorio).length;
final prepend = <MediaItem>[
// Folder-play actions (Design ADR-5, Phase 3): prepended BEFORE
// the sort/bucket nav entries, guarded the same shape as
// ofreceOrdenCalidad(totalPistas > 0) — present iff the folder has
// at least one direct audio child, absent for a folder with only
// subfolders (Spec "Folder has no tracks").
if (totalPistas > 0) _itemReproducirCarpeta(documentIdPadre),
if (totalPistas > 0) _itemReproducirAleatorio(documentIdPadre),
if (ofreceOrdenCalidad(totalPistas))
_itemModoOrdenCalidad(documentIdPadre),
if (ofreceBuckets(totalPistas))
for (var i = 0; i < _rangosBucket.length; i++)
_itemBucket(documentIdPadre, i, _rangosBucket[i].$1),
];
return [...prepend, ...items];
}
return items;
}
/// Whether the "Ordenar por calidad" mode entry should be offered for a
/// folder with [totalPistas] audio files (Design ADR-3): present for
/// `0 < totalPistas <= 150`, omitted otherwise (empty folder or above the
/// cap) instead of paying an unbounded metadata-extraction cost.
bool ofreceOrdenCalidad(int totalPistas) =>
totalPistas > 0 && totalPistas <= _maxPistasParaOrdenCalidad;
/// Whether alphabetical bucket entries should be offered for a folder
/// with [totalPistas] audio files (Design ADR-4): buckets add no value
/// for small folders.
bool ofreceBuckets(int totalPistas) => totalPistas > _minPistasParaBuckets;
/// The "Ordenar por calidad" mode-entry `MediaItem` (Design ADR-4):
/// non-playable, id `carpeta_local_ord:calidad:0:<documentIdPadre>` —
/// always page 0 of the sorted view, round-trips via [ordenLocalDesde].
/// Hardcoded Spanish label, matching every other car-tree label in this
/// file — never routed through `AppLocalizations` (established
/// car-tree-label precedent, see [_tituloMasLocal]).
MediaItem _itemModoOrdenCalidad(String documentIdPadre) => _carpeta(
'${_prefijoCarpetaLocalOrd}calidad:0:$documentIdPadre',
'Ordenar por calidad',
);
/// A single bucket-folder `MediaItem` (Design ADR-4): non-playable, id
/// `carpeta_local_bucket:<idx>:0:<documentIdPadre>` — always page 0,
/// round-trips via [bucketLocalDesde]. [etiqueta] is the hardcoded
/// alphabetical-range label (e.g. `'A-F'`), matching every other
/// car-tree label in this file — never routed through `AppLocalizations`.
MediaItem _itemBucket(String documentIdPadre, int idx, String etiqueta) =>
_carpeta('$_prefijoCarpetaLocalBucket$idx:0:$documentIdPadre', etiqueta);
/// The "Reproducir carpeta" playable action item (Design ADR-5): id
/// `carpeta_local_reproducir:<documentIdPadre>`. Hardcoded Spanish label,
/// matching every other car-tree label in this file — never routed
/// through `AppLocalizations`.
MediaItem _itemReproducirCarpeta(String documentIdPadre) => MediaItem(
id: '$_prefijoCarpetaLocalReproducir$documentIdPadre',
title: 'Reproducir carpeta',
playable: true,
extras: _contentStyleGrid,
);
/// The "Reproducir aleatorio" playable action item (Design ADR-5),
/// mirrors [_itemReproducirCarpeta].
MediaItem _itemReproducirAleatorio(String documentIdPadre) => MediaItem(
id: '$_prefijoCarpetaLocalAleatorio$documentIdPadre',
title: 'Reproducir aleatorio',
playable: true,
extras: _contentStyleGrid,
);
/// Whether [id] identifies a sort-mode local-music request (Design
/// ADR-4, Phase 2).
bool esCarpetaLocalOrdMediaId(String id) =>
id.startsWith(_prefijoCarpetaLocalOrd);
/// Whether [id] identifies an alphabetical-bucket local-music request
/// (Design ADR-4, Phase 2).
bool esCarpetaLocalBucketMediaId(String id) =>
id.startsWith(_prefijoCarpetaLocalBucket);
/// Whether [id] identifies the "Reproducir carpeta" sequential-play
/// folder action (Design ADR-5, Phase 3).
bool esCarpetaLocalReproducirMediaId(String id) =>
id.startsWith(_prefijoCarpetaLocalReproducir);
/// Whether [id] identifies the "Reproducir aleatorio" shuffled-play
/// folder action (Design ADR-5, Phase 3).
bool esCarpetaLocalAleatorioMediaId(String id) =>
id.startsWith(_prefijoCarpetaLocalAleatorio);
/// Strips the [_prefijoCarpetaLocalReproducir] prefix from [id] by length
/// (Design ADR-5 "strip prefix by length" — no split needed, the single
/// tail is the raw SAF documentId verbatim; an empty tail means the local
/// root). Only meaningful when [esCarpetaLocalReproducirMediaId] is
/// `true`.
String idCarpetaLocalReproducirDesde(String id) =>
id.substring(_prefijoCarpetaLocalReproducir.length);
/// Strips the [_prefijoCarpetaLocalAleatorio] prefix from [id] by length,
/// mirrors [idCarpetaLocalReproducirDesde]. Only meaningful when
/// [esCarpetaLocalAleatorioMediaId] is `true`.
String idCarpetaLocalAleatorioDesde(String id) =>
id.substring(_prefijoCarpetaLocalAleatorio.length);
/// Parses a `carpeta_local_ord:<modo>:<pagina>:<docId>` [id] into its
/// `(modo, documentId, pagina)` triple (Design ADR-4): the prefix is
/// stripped by length, then the remainder is split on the FIRST two `:`
/// only — `modo` never contains a colon, `pagina` never contains a
/// colon, and everything after the second `:` (including further
/// colons/slashes) is the raw SAF documentId verbatim, mirroring
/// [paginaCarpetaLocalDesde]'s split-on-first-colon chain extended by one
/// field. Only meaningful when [esCarpetaLocalOrdMediaId] is `true`.
(String modo, String documentId, int pagina) ordenLocalDesde(String id) {
final resto = id.substring(_prefijoCarpetaLocalOrd.length);
final primerColon = resto.indexOf(':');
final modo = resto.substring(0, primerColon);
final resto2 = resto.substring(primerColon + 1);
final segundoColon = resto2.indexOf(':');
final pagina = int.parse(resto2.substring(0, segundoColon));
final documentId = resto2.substring(segundoColon + 1);
return (modo, documentId, pagina);
}
/// Parses a `carpeta_local_bucket:<idxBucket>:<pagina>:<docId>` [id] into
/// its `(idxBucket, documentId, pagina)` triple (Design ADR-4), mirroring
/// [ordenLocalDesde]'s split-on-first-two-colons chain. Only meaningful
/// when [esCarpetaLocalBucketMediaId] is `true`.
(int idxBucket, String documentId, int pagina) bucketLocalDesde(String id) {
final resto = id.substring(_prefijoCarpetaLocalBucket.length);
final primerColon = resto.indexOf(':');
final idxBucket = int.parse(resto.substring(0, primerColon));
final resto2 = resto.substring(primerColon + 1);
final segundoColon = resto2.indexOf(':');
final pagina = int.parse(resto2.substring(0, segundoColon));
final documentId = resto2.substring(segundoColon + 1);
return (idxBucket, documentId, pagina);
}
/// The trailing "load more" item for the quality-sort view (Design ADR-4,
/// mirrors [_itemMasLocal]): id
/// `carpeta_local_ord:<modo>:<siguientePagina>:<documentIdPadre>`.
MediaItem _itemMasLocalOrd(
String documentIdPadre,
String modo,
int siguientePagina,
) => MediaItem(
id: '$_prefijoCarpetaLocalOrd$modo:$siguientePagina:$documentIdPadre',
title: _tituloMasLocal,
playable: false,
extras: _contentStyleLista,
);
/// The trailing "load more" item for a bucket view (Design ADR-4, mirrors
/// [_itemMasLocal]): id
/// `carpeta_local_bucket:<idx>:<siguientePagina>:<documentIdPadre>`.
MediaItem _itemMasLocalBucket(
String documentIdPadre,
int idxBucket,
int siguientePagina,
) => MediaItem(
id: '$_prefijoCarpetaLocalBucket$idxBucket:$siguientePagina:$documentIdPadre',
title: _tituloMasLocal,
playable: false,
extras: _contentStyleLista,
);
/// Quality-sort view (Design ADR-3, Data Flow): resolves metadata for
/// EVERY audio file in [nodos] via ONE batched [metadatosDe] call
/// (full-folder, NOT page-scoped — the sort key requires every track's
/// bitrate up front), sorts via [ordenarPorCalidadLocal], THEN applies
/// the existing [paginaDe] slicing. Directory nodes are excluded (Design
/// "quality sort applies to tracks only").
Future<List<MediaItem>> itemsLocalesOrdenCalidad(
List<NodoLocal> nodos, {
required String documentIdPadre,
required Future<Map<String, MetadatosPista>> Function(List<String>)
metadatosDe,
int pagina = 0,
int tamano = _maxItemsCarpetaLocal,
@visibleForTesting
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
}) async {
final construir = construirItem ?? _itemLocal;
final pistas = nodos.where((n) => !n.esDirectorio).toList();
final docIds = pistas.map((n) => n.documentId).toList();
final metadatos = await metadatosDe(docIds);
final ordenados = ordenarPorCalidadLocal(pistas, metadatos);
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
items.add(_itemMasLocalOrd(documentIdPadre, 'calidad', pagina + 1));
}
return items;
}
/// Alphabetical-bucket view (Design ADR-4, Data Flow): partitions
/// [nodos] via [bucketsDe] (name-only, cheap), selects [idxBucket], sorts
/// that bucket's tracks by name, slices to [pagina], then resolves
/// metadata ONLY for the sliced page's docIds (Design "only modo=calidad
/// pays the metadata cost" — bucket browsing stays page-scoped like the
/// default name-sort view). An out-of-range [idxBucket] returns `[]`,
/// never throws.
Future<List<MediaItem>> itemsLocalesBucket(
List<NodoLocal> nodos, {
required String documentIdPadre,
required int idxBucket,
required Future<Map<String, MetadatosPista>> Function(List<String>)
metadatosDe,
int pagina = 0,
int tamano = _maxItemsCarpetaLocal,
@visibleForTesting
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
}) async {
final buckets = bucketsDe(nodos);
if (idxBucket < 0 || idxBucket >= buckets.length) return const [];
final construir = construirItem ?? _itemLocal;
final ordenados = [...buckets[idxBucket].nodos]
..sort((a, b) => a.nombre.compareTo(b.nombre));
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final docIds = paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final metadatos = await metadatosDe(docIds);
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
items.add(_itemMasLocalBucket(documentIdPadre, idxBucket, pagina + 1));
}
return items;
}
MediaItem _itemLocal(NodoLocal nodo, Map<String, MetadatosPista> metadatos) {
if (nodo.esDirectorio) {
return _carpeta('$_prefijoCarpetaLocal${nodo.documentId}', nodo.nombre);
}
final meta = metadatos[nodo.documentId];
final tituloMeta = meta?.titulo?.trim();
final titulo = (tituloMeta != null && tituloMeta.isNotEmpty)
? tituloMeta
: _tituloDesdeNombre(nodo.nombre);
final artUriMeta = meta?.artUri?.trim();
final artUri = (artUriMeta != null && artUriMeta.isNotEmpty)
? artUriMeta
: artUriLocal(nodo.documentId);
final artistaMeta = meta?.artista?.trim();
return MediaItem(
id: '$_prefijoPista${nodo.documentId}',
title: titulo,
artist: (artistaMeta != null && artistaMeta.isNotEmpty)
? artistaMeta
: null,
playable: true,
artUri: Uri.parse(artUri),
displaySubtitle: subtituloCalidadLocal(meta),
extras: _contentStyleGrid,
);
}
/// Children of the `Favoritos` folder (Design "Ungrouped favorites stay as
/// direct leaves at the Favoritos root"): non-empty custom-group folders
/// (phone order, capped at [_maxGruposPorFavoritos]), followed by
/// `sin_asignar` stations mapped through the existing [hijos] path so the
/// no-custom-groups case is byte-identical to the pre-groups tree
/// (regression guard — Spec "Ungrouped station appears exactly as
/// before"). Empty custom groups are omitted (Design "Empty groups hidden
/// from the car tree"); the `sin_asignar` pseudo-group is never rendered
/// as its own folder.
List<MediaItem> carpetasFavoritos({
required List<GrupoFavoritos> grupos,
required List<Emisora> favoritos,
}) {
final carpetas = grupos
.where((g) => !g.esSinAsignar)
.where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
.take(_maxGruposPorFavoritos)
.map(itemGrupo)
.toList();
final sinAsignar = favoritos
.where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
.toList();
return [...carpetas, ...hijos(idFavoritos, emisoras: sinAsignar)];
}
/// Members of the favorite group identified by [grupoMediaId] (a
/// `grupo:<id>` id), sorted and capped like every other folder (Spec "Car
/// requests a group folder's stations"). An unknown/stale/malformed id
/// returns an empty list instead of throwing (Spec "Car requests an
/// unknown or stale group id").
List<MediaItem> hijosGrupo(
String grupoMediaId, {
required List<Emisora> favoritos,
}) {
if (!esCarpetaGrupo(grupoMediaId)) return const [];
final id = grupoMediaId.substring(_prefijoGrupo.length);
if (id.isEmpty) return const [];
final miembros = favoritos
.where((e) => e.grupoFavoritosId == id)
.toList();
if (miembros.isEmpty) return const [];
final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad);
return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
}
}
/// Routing seam between a car-tapped `emisora:<uuid>` media id and the
/// existing internal playback path (Design "playback coherence" — reuse
/// over duplication). Resolves the uuid via [fuente], builds the same
/// phone-shaped `MediaItem` (`id` = station url, `extras['uuid']`) that
/// [ServicioAudio.reproducir] builds, and delegates to [reproducir].
///
/// A stale/unknown id (or a malformed one) is a no-op: [reproducir] is
/// never called and no exception propagates (Spec "Unknown or stale media
/// id").
Future<void> reproducirPorMediaId(
String id, {
required FuenteEmisorasAuto fuente,
required Future<void> Function(MediaItem) reproducir,
}) async {
if (!id.startsWith(_prefijoEmisora)) return;
final uuid = id.substring(_prefijoEmisora.length);
if (uuid.isEmpty) return;
final emisora = await fuente.porUuid(uuid);
if (emisora == null) return;
final item = MediaItem(
id: emisora.url,
title: emisora.nombre,
artist: emisora.pais ?? '',
album: 'PluriWave',
artUri:
emisora.favicon != null && emisora.favicon!.isNotEmpty
? Uri.tryParse(emisora.favicon!)
: null,
extras: {'uuid': emisora.uuid},
);
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'`,
/// `'Música Local'`, 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)]}';
/// Bitrate-descending comparator for two resolved [MetadatosPista] (Design
/// ADR-3), mirroring [OrdenEmisoras.calidad]'s shape (`orden_emisoras.dart`)
/// — no code sharing forced, different types. An unknown bitrate (`null`
/// or `<= 0`) always sorts AFTER every known-bitrate entry; two unknowns
/// compare equal. Never throws.
int compararCalidadLocal(MetadatosPista? a, MetadatosPista? b) {
final bitrateA = a?.bitrate;
final bitrateB = b?.bitrate;
final conocidoA = bitrateA != null && bitrateA > 0;
final conocidoB = bitrateB != null && bitrateB > 0;
if (!conocidoA && !conocidoB) return 0;
if (!conocidoA) return 1;
if (!conocidoB) return -1;
return bitrateB.compareTo(bitrateA);
}
/// Returns a bitrate-descending sorted COPY of [nodos] (Design ADR-3),
/// resolving each node's bitrate via [metadatos] (keyed by `documentId`) —
/// a node absent from [metadatos] (or with a `null`/`<= 0` bitrate) is
/// treated as unknown and sorts last, via [compararCalidadLocal]. Never
/// throws.
List<NodoLocal> ordenarPorCalidadLocal(
List<NodoLocal> nodos,
Map<String, MetadatosPista> metadatos,
) {
final ordenados = List<NodoLocal>.from(nodos);
ordenados.sort(
(a, b) => compararCalidadLocal(
metadatos[a.documentId],
metadatos[b.documentId],
),
);
return ordenados;
}
/// Fixed alphabetical bucket ranges (Design "User browses name buckets"):
/// `(etiqueta, desde, hasta)`, each a contiguous, lowercase, single-letter
/// first-letter range. Shared by [bucketsDe] (partitioning) and
/// [ConstructorArbolAuto]'s page-0 prepend wiring (label text). A name
/// that doesn't start with an ASCII letter (blank, digit, symbol) never
/// matches any of these — the spec doesn't define a catch-all "other"
/// bucket.
const _rangosBucket = [
('A-F', 'a', 'f'),
('G-M', 'g', 'm'),
('N-S', 'n', 's'),
('T-Z', 't', 'z'),
];
/// One alphabetical name-bucket's result (Design "User browses name
/// buckets"): [etiqueta] is the fixed range label (e.g. `'A-F'`), [nodos]
/// is the (possibly empty) list of tracks whose first letter falls in that
/// range.
class BucketLocal {
const BucketLocal({required this.etiqueta, required this.nodos});
final String etiqueta;
final List<NodoLocal> nodos;
}
/// Partitions [nodos] into the 4 fixed [_rangosBucket] alphabetical ranges
/// (Design ADR-4), using ONLY [NodoLocal.nombre] — no metadata dependency,
/// so this function structurally cannot call `metadatosDe` (its signature
/// doesn't receive one). Directory nodes are excluded (buckets are a
/// track-only view). A bucket with zero matches is still returned with an
/// empty `nodos` list, never omitted or an error (Spec "Bucket with no
/// matching tracks").
List<BucketLocal> bucketsDe(List<NodoLocal> nodos) {
final pistas = nodos.where((n) => !n.esDirectorio).toList();
return _rangosBucket.map((rango) {
final (etiqueta, desde, hasta) = rango;
final coincidencias = pistas.where((n) {
final recortado = n.nombre.trim();
if (recortado.isEmpty) return false;
final letra = recortado[0].toLowerCase();
return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
}).toList();
return BucketLocal(etiqueta: etiqueta, nodos: coincidencias);
}).toList();
}
/// The canonical name-sorted audio-children list a folder-play action
/// queues (Design ADR-6): directories excluded, sorted by
/// `NodoLocal.nombre` — the SAME comparator [ConstructorArbolAuto.itemsLocales]
/// already applies to the browse-tree page-0 view, so "Reproducir
/// carpeta"'s play order matches what the driver sees when browsing
/// normally. Returns a NEW list; never mutates [nodos].
List<NodoLocal> pistasEnOrdenNombre(List<NodoLocal> nodos) {
final pistas = nodos.where((n) => !n.esDirectorio).toList();
pistas.sort((a, b) => a.nombre.compareTo(b.nombre));
return pistas;
}
/// Fisher-Yates shuffle (Design ADR-6) over a COPY of [nodos] — never
/// mutates the input list. [rng] is injected so tests can pass a
/// fixed-seed `Random` for deterministic permutation assertions;
/// production callers pass `Random()`.
List<NodoLocal> mezclarFisherYates(List<NodoLocal> nodos, Random rng) {
final resultado = List<NodoLocal>.from(nodos);
for (var i = resultado.length - 1; i > 0; i--) {
final j = rng.nextInt(i + 1);
final tmp = resultado[i];
resultado[i] = resultado[j];
resultado[j] = tmp;
}
return resultado;
}
/// The shuffled audio-children list "Reproducir aleatorio" queues (Design
/// ADR-6): Fisher-Yates over [pistasEnOrdenNombre]'s canonical order — NOT
/// the native enumeration order (not guaranteed stable) — so the resulting
/// permutation is reproducible under a fixed [rng] seed.
List<NodoLocal> pistasEnOrdenAleatorio(List<NodoLocal> nodos, Random rng) =>
mezclarFisherYates(pistasEnOrdenNombre(nodos), rng);
/// Orchestrates a "Reproducir carpeta"/"Reproducir aleatorio" tap (Design
/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2): resolves whichever of the
/// two action prefixes matches [id] (ignoring [aleatorio] for the STRIP —
/// the prefix itself is authoritative), fetches [fuente]'s direct children
/// for that folder, filters to audio files, orders them ([aleatorio] picks
/// shuffled vs name order), and hands the resulting list to [iniciarCola].
///
/// A no-op (never calls [iniciarCola]) when: [id] matches neither action
/// prefix; [fuente.hijos] throws or returns only directories (an
/// unresolvable/empty folder — Design "no-op on empty/unresolvable
/// folder").
Future<void> reproducirCarpetaLocal(
String id, {
required bool aleatorio,
required FuenteMusicaLocalAuto fuente,
Random? rng,
required Future<void> Function(List<NodoLocal> pistas) iniciarCola,
}) async {
final constructor = ConstructorArbolAuto();
final String documentId;
if (constructor.esCarpetaLocalReproducirMediaId(id)) {
documentId = constructor.idCarpetaLocalReproducirDesde(id);
} else if (constructor.esCarpetaLocalAleatorioMediaId(id)) {
documentId = constructor.idCarpetaLocalAleatorioDesde(id);
} else {
return;
}
final List<NodoLocal> nodos;
try {
nodos = await fuente.hijos(documentId);
} catch (_) {
return;
}
final pistas = aleatorio
? pistasEnOrdenAleatorio(nodos, rng ?? Random())
: pistasEnOrdenNombre(nodos);
if (pistas.isEmpty) return;
await iniciarCola(pistas);
}
/// Resolves [nodo]'s playable content URI via [fuente] and builds the
/// `MediaItem` the local-queue layer plays (Design Data Flow
/// "construirMediaItemColaLocal (resolve URI)"), reusing the SAME
/// title-derivation [reproducirPistaLocal] uses ([_tituloDesdeDocumentId])
/// so a queue track's Now Playing title matches what a directly-tapped
/// single track would show. Returns `null` when the content URI cannot be
/// resolved (stale id, revoked permission, moved file) — the caller treats
/// that as "cannot play this entry", never a crash.
Future<MediaItem?> construirMediaItemColaLocal(
NodoLocal nodo, {
required FuenteMusicaLocalAuto fuente,
}) async {
final contentUri = await fuente.uriContenidoDePista(nodo.documentId);
if (contentUri == null || contentUri.isEmpty) return null;
return MediaItem(
id: contentUri,
title: _tituloDesdeDocumentId(nodo.documentId),
album: 'PluriWave',
extras: {'documentId': nodo.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").
/// Session-scoped metadata cache shared across every `hijosMusicaLocal`
/// call (Design ADR-2) — module-level singleton, mirroring
/// `servicio_audio.dart`'s `_fuenteMusicaLocalGlobal` pattern: paging a
/// large folder across multiple `getChildren` calls must NOT evict an
/// earlier page's cached metadata, which requires the cache to outlive any
/// single call.
final CacheMetadatosSesion _cacheMetadatosLocal = CacheMetadatosSesion();
/// Wraps [fuente]'s raw `metadatosDe` with [_cacheMetadatosLocal] (Design
/// "Data Flow" — `metadatosDe(slice.trackDocIds) — CacheMetadatosSesion
/// hit? else readAudioMetadataBatch`): resolves cache hits locally without
/// a channel round trip, batches ONLY the cache misses through
/// [FuenteMusicaLocalAuto.metadatosDe], and stores every freshly-resolved
/// entry back into the cache before returning the combined map.
Future<Map<String, MetadatosPista>> _metadatosDeConCache(
List<String> documentIds, {
required FuenteMusicaLocalAuto fuente,
}) async {
if (documentIds.isEmpty) return const {};
final resultado = <String, MetadatosPista>{};
final faltantes = <String>[];
for (final id in documentIds) {
final cacheado = _cacheMetadatosLocal.obtener(id);
if (cacheado != null) {
resultado[id] = cacheado;
} else {
faltantes.add(id);
}
}
if (faltantes.isNotEmpty) {
final resueltos = await fuente.metadatosDe(faltantes);
resueltos.forEach((id, metadatos) {
_cacheMetadatosLocal.guardar(id, metadatos);
resultado[id] = metadatos;
});
}
return resultado;
}
Future<List<MediaItem>?> hijosMusicaLocal(
String parentMediaId, {
required FuenteMusicaLocalAuto? fuente,
}) async {
final constructor = ConstructorArbolAuto();
// Sort-mode and bucket views (Design ADR-4, Phase 2) are routed FIRST —
// routing order is irrelevant to correctness (every prefix in this file
// is collision-free, see each prefix's doc comment), but checking the
// more specific new prefixes first keeps this dispatch readable.
if (constructor.esCarpetaLocalOrdMediaId(parentMediaId)) {
final (modo, documentId, pagina) = constructor.ordenLocalDesde(
parentMediaId,
);
if (fuente == null) return const [];
try {
final nodos = await fuente.hijos(documentId);
if (modo != 'calidad') return const [];
return await constructor.itemsLocalesOrdenCalidad(
nodos,
documentIdPadre: documentId,
pagina: pagina,
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
);
} catch (_) {
return const [];
}
}
if (constructor.esCarpetaLocalBucketMediaId(parentMediaId)) {
final (idxBucket, documentId, pagina) = constructor.bucketLocalDesde(
parentMediaId,
);
if (fuente == null) return const [];
try {
final nodos = await fuente.hijos(documentId);
return await constructor.itemsLocalesBucket(
nodos,
documentIdPadre: documentId,
idxBucket: idxBucket,
pagina: pagina,
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
);
} catch (_) {
return const [];
}
}
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 {
return null;
}
if (fuente == null) return const [];
try {
final nodos = await fuente.hijos(documentId);
return await constructor.itemsLocales(
nodos,
documentIdPadre: documentId,
pagina: pagina,
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
);
} 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);
}
/// Local, cold-start-safe implementation of [FuenteEmisorasAuto] (Design
/// "getChildren data source"). Reads favourites from SQLite and custom
/// stations from the tolerant JSON file directly — both loadable without
/// the network or a built widget tree, unlike `EstadoRadio._init()` (which
/// is lazy-created by `ChangeNotifierProvider.create:` and may never run on
/// a headless Auto bind). `EstadoRadio`, when alive, overrides these reads
/// with a live snapshot via [actualizarSnapshot].
class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
FuenteEmisorasAutoLocal({
ServicioFavoritos? favoritosServicio,
Future<String> Function()? resolverRutaCustom,
}) : _favoritosServicio = favoritosServicio ?? ServicioFavoritos(),
_resolverRutaCustom = resolverRutaCustom;
final ServicioFavoritos _favoritosServicio;
final Future<String> Function()? _resolverRutaCustom;
List<Emisora>? _snapshotFavoritos;
List<Emisora>? _snapshotMisEmisoras;
List<Emisora>? _snapshotTodas;
List<GrupoFavoritos>? _snapshotGrupos;
/// Overrides the next reads with `EstadoRadio`'s live in-memory lists
/// (Design "live snapshot the source prefers"). Passing `null` for a
/// field leaves its current override (or local read) untouched.
@override
void actualizarSnapshot({
List<Emisora>? favoritos,
List<Emisora>? misEmisoras,
List<Emisora>? todas,
List<GrupoFavoritos>? grupos,
}) {
if (favoritos != null) _snapshotFavoritos = favoritos;
if (misEmisoras != null) _snapshotMisEmisoras = misEmisoras;
if (todas != null) _snapshotTodas = todas;
if (grupos != null) _snapshotGrupos = grupos;
}
@override
Future<List<Emisora>> favoritos() async {
final snapshot = _snapshotFavoritos;
if (snapshot != null) return snapshot;
try {
return await _favoritosServicio.obtenerTodos();
} catch (_) {
// Cold-start safety (Spec "Browse requested before app state is
// loaded"): never throw out of a browse call.
return const [];
}
}
@override
Future<List<GrupoFavoritos>> grupos() async {
final snapshot = _snapshotGrupos;
if (snapshot != null) return snapshot;
try {
return await _favoritosServicio.obtenerGrupos();
} catch (_) {
// Cold-start safety (Spec "Browse requested before app state is
// loaded"): never throw out of a browse call.
return const [];
}
}
@override
Future<List<Emisora>> misEmisoras() async {
final snapshot = _snapshotMisEmisoras;
if (snapshot != null) return snapshot;
return _leerEmisorasCustom();
}
@override
Future<List<Emisora>> todas() async {
// No live network snapshot on a cold bind — populated only once
// EstadoRadio pushes its populares list (Design "which stations
// surface & ordering": degrades gracefully to empty-but-valid).
return _snapshotTodas ?? const [];
}
@override
Future<Emisora?> porUuid(String uuid) async {
final listas = await Future.wait([favoritos(), misEmisoras(), todas()]);
for (final lista in listas) {
for (final emisora in lista) {
if (emisora.uuid == uuid) return emisora;
}
}
return null;
}
/// Mirrors `EstadoRadio._cargarEmisorasCustom()`'s tolerant JSON read:
/// missing/corrupt files never throw, they degrade to an empty list.
Future<List<Emisora>> _leerEmisorasCustom() async {
try {
final ruta = await _rutaArchivoCustom();
final archivo = File(ruta);
if (!await archivo.exists()) return const [];
final contenido = await archivo.readAsString();
final data = jsonDecode(contenido) as List;
final resultado = parseListaTolerante<Emisora>(
data,
Emisora.fromMap,
subsistema: 'emisoras_custom_auto',
coleccion: 'emisoras_custom',
);
return resultado.validas;
} catch (_) {
return const [];
}
}
Future<String> _rutaArchivoCustom() async {
final resolver = _resolverRutaCustom;
if (resolver != null) return resolver();
final dir = await getApplicationDocumentsDirectory();
return '${dir.path}/emisoras_custom.json';
}
}