Tres fallos reportados en uso real, con sus causas raiz verificadas en codigo.
1. Ecualizador: el estado no tenia dueño unico
El handler arrancaba con `_ecualizadorActivo = true` a fuego. El valor
persistido solo llegaba por EstadoEcualizador.cargarPersistido(), alcanzable
unicamente desde el arbol de widgets, que un arranque headless de Android Auto
nunca construye. Resultado: el coche reproducia con el EQ forzado a ON mientras
disco e interfaz decian OFF.
Ahora registrarHandler siembra el flag desde disco en todos los motores y
setEcualizadorActivo persiste por su cuenta, asi que un toggle desde el coche o
la notificacion sobrevive sin EstadoEcualizador. _resincronizarConHandler pasa
a ser adopcion pura de interfaz.
Ademas mapearGananciaNativa enviaba 0 dB al punto MEDIO del rango nativo. Con
un getBandLevelRange() asimetrico, un preset plano metia varios dB de boost
real: la causa del "suena muy alto con el boton apagado". Reescrito para
escalar cada lado contra su propio extremo, de modo que 0 dB es siempre 0.
El dispatch de customAction no tenia ningun test. Se extrae decidirToggleEq y
se cubre contra el handler real. El efecto nativo se re-asierta al reactivarse
el reproductor, porque AudioEffect.setEnabled de just_audio es un no-op
mientras la plataforma esta desacoplada.
2. Musica Local no aparecia en el arbol de Android Auto
hayCarpetaConfigurada() consultaba MethodChannel('pluriwave/file_actions'),
registrado solo en MainActivity.configureFlutterEngine. Sin Activity no hay
handler, invokeMethod lanza MissingPluginException y el catch la confundia con
"permiso revocado", omitiendo el nodo. No dependia del entitlement.
La logica SAF sale a packages/pluriwave_file_actions, un paquete plugin local.
El motor headless que crea audio_service ejecuta GeneratedPluginRegistrant en
su constructor, asi que el canal queda registrado en ambos motores. Repuntar el
manifest a una subclase de AudioService no era viable: AudioServicePlugin
enlaza por ComponentName explicito y la app perderia el audio.
EstadoCarpetaLocal de tres valores separa "sin carpeta" de "canal no
disponible"; la raiz decide por la URI persistida y el subarbol muestra un item
explicativo en vez de una carpeta vacia. La invalidacion del arbol cacheado se
dispara al reanudar con el coche ya suscrito; el guardia anterior miraba
View.maybeOf, que bajo runApp siempre existe, por lo que se gastaba en el
arranque headless y no volvia a dispararse.
3. El paywall bloqueaba las compras
restorePurchases() de in_app_purchase_android emite siempre, y con lista vacia
si no hay nada que restaurar. El `if (compras.isEmpty) return;` se la tragaba,
noEncontrada nunca se emitia y la rama que limpia _compraEnCurso estaba muerta
en produccion. Como comprar y restaurar comparten ese flag, un usuario sin
compras que pulsaba restaurar se quedaba sin poder comprar.
Suite completa: 1366 pasan, 2 omitidos. 17 tests nuevos, todos nacidos rojos y
verificados por mutacion. flutter analyze mantiene los 5 avisos preexistentes.
1810 lines
79 KiB
Dart
1810 lines
79 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 '../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';
|
|
|
|
/// 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;
|
|
|
|
/// Browse-tree ordering comparator for a local-music folder's children
|
|
/// (Design "Directories before files", item 1): directories sort before
|
|
/// files regardless of name, and within each group, alphabetically by
|
|
/// [NodoLocal.nombre] -- the standard file-browser convention. Fixes a
|
|
/// driver-facing bug where a folder's subfolders could land on a later
|
|
/// "Más…" page whenever enough tracks sorted alphabetically ahead of them
|
|
/// (e.g. a "Live" subfolder behind 80 numbered tracks), making the
|
|
/// subfolder unreachable without paging through every track first.
|
|
int compararNodoLocalParaNavegacion(NodoLocal a, NodoLocal b) {
|
|
if (a.esDirectorio != b.esDirectorio) {
|
|
return a.esDirectorio ? -1 : 1;
|
|
}
|
|
return a.nombre.compareTo(b.nombre);
|
|
}
|
|
|
|
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';
|
|
|
|
/// Root folder id for the "Ecualizador" browsable folder (decision
|
|
/// `auto/ecualizador-diseno`): lists "Desactivar" plus the six factory
|
|
/// presets, the currently-active one marked. Deliberately NOT added to
|
|
/// [_idsCarpetas] -- like [idMusicaLocal], it has its own dedicated
|
|
/// children, built by `itemsEcualizadorAuto` in `servicio_audio.dart`
|
|
/// (which needs `AppLocalizations` -- this pure builder class does not
|
|
/// depend on it), not the generic station-list [hijos] path. Unlike
|
|
/// [idMusicaLocal], it is ALWAYS present in [raiz], never conditionally
|
|
/// hidden.
|
|
static const idEcualizador = 'ecualizador';
|
|
|
|
/// Non-playable "no puedo leer la carpeta desde aquí" item
|
|
/// (fix/android-auto-musica-local). La raíz ya no oculta [idMusicaLocal]
|
|
/// cuando el canal nativo `pluriwave/file_actions` no está disponible en
|
|
/// este motor, así que abrir la carpeta tenía que dejar de mostrar una
|
|
/// lista vacía: vacío se lee como «no tengo música», que es justo la
|
|
/// conclusión equivocada. Este item dice qué pasa de verdad.
|
|
///
|
|
/// Colisión imposible con los prefijos `carpeta_local:` / `pista:` /
|
|
/// `emisora:` / `grupo:` — no lleva ninguno de ellos.
|
|
static const idLocalNoLista = 'musica_local_no_disponible';
|
|
|
|
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, Ecualizador), all non-playable.
|
|
///
|
|
/// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
|
|
/// There is NO `Ecualizador` folder. The car's only equalizer control is
|
|
/// the on/off custom action on the playback screen
|
|
/// (`controlesEcualizadorPersonalizados` in `servicio_audio.dart`), which
|
|
/// the driver reaches from all three player views without leaving them.
|
|
///
|
|
/// The folder existed briefly (`8423ccd`) because custom actions were
|
|
/// thought unable to convey enough state for a six-preset choice. Owner
|
|
/// decision after driving with it: a browsable preset list is more
|
|
/// interaction than a driver wants, and on/off is the only equalizer
|
|
/// control that belongs in a car. Preset selection stays on the phone.
|
|
/// This lands back on the redesign mockup's original rule ("sin carpeta de
|
|
/// ecualizador", turn t4 line 40), now for a road-tested reason rather than
|
|
/// an assumed one.
|
|
///
|
|
/// `Música Local` is OMITTED entirely (not just empty) unless
|
|
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
|
|
/// is configured") — the caller lo deriva de
|
|
/// `fuente.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada`
|
|
/// (fix/android-auto-musica-local: un canal nativo ausente ya NO oculta el
|
|
/// nodo), keeping this builder itself synchronous and side-effect free.
|
|
///
|
|
/// [premium] (iap-freemium-unlock, Design ADR-4): the ROOT keeps the exact
|
|
/// same visible folder labels for every tier — "keeps the same visible
|
|
/// folder labels for free users" is the explicit design choice, so a free
|
|
/// driver still sees a real, familiar menu rather than a wall of "Función
|
|
/// Premium" rows. The lock itself is enforced one level DOWN, at the
|
|
/// `getChildren` choke point (see [itemPremiumBloqueado] and
|
|
/// [respuestaBloqueadaPorEntitlement] below) — tapping any of these
|
|
/// folders as a free user reveals the lock there, never here.
|
|
List<MediaItem> raiz({
|
|
required bool incluirMusicaLocal,
|
|
required bool premium,
|
|
}) => [
|
|
_carpeta(idFavoritos, 'Favoritos'),
|
|
_carpeta(idTodas, 'Todas las emisoras'),
|
|
_carpeta(idMisEmisoras, 'Mis emisoras'),
|
|
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
|
|
];
|
|
|
|
/// Free-tier id prefix reserved id (iap-freemium-unlock, Design ADR-4):
|
|
/// the single non-playable item every non-root folder collapses to for a
|
|
/// free-tier user. Hardcoded Spanish label, matching every other car-tree
|
|
/// label in this file (never routed through `AppLocalizations` —
|
|
/// established convention, see [_tituloMasLocal]'s doc).
|
|
static const idPremiumInfo = 'premium:info';
|
|
|
|
/// The single locked item shown for ANY non-root folder when the browsing
|
|
/// user is free tier (Design ADR-4, android-auto-media spec "Free-Tier
|
|
/// Reduced Root Browse"). Non-playable — selecting it is a no-op, never a
|
|
/// crash (Spec "Free-tier user selects a locked item").
|
|
MediaItem itemPremiumBloqueado() => MediaItem(
|
|
id: idPremiumInfo,
|
|
title: 'Función Premium',
|
|
playable: false,
|
|
extras: _contentStyleLista,
|
|
);
|
|
|
|
/// El item de [idLocalNoLista]. Etiqueta en castellano hardcodeado, como
|
|
/// TODAS las etiquetas del árbol del coche en este archivo (ver
|
|
/// [itemPremiumBloqueado]): convención establecida, nunca
|
|
/// `AppLocalizations`. No reproducible — seleccionarlo es un no-op.
|
|
MediaItem itemLocalNoDisponible() => MediaItem(
|
|
id: idLocalNoLista,
|
|
title: 'Abre PluriWave en el móvil para leer tu música',
|
|
playable: false,
|
|
extras: _contentStyleLista,
|
|
);
|
|
|
|
MediaItem _carpeta(String id, String titulo) => MediaItem(
|
|
id: id,
|
|
title: titulo,
|
|
playable: false,
|
|
extras: _contentStyleLista,
|
|
);
|
|
|
|
/// Leaf items for [parentId], PRESERVING the incoming [emisoras] order and
|
|
/// capped at [_maxItemsPorCarpeta] (Design "which stations surface &
|
|
/// ordering" — avoids driver distraction and Auto list limits).
|
|
///
|
|
/// Fix `android-auto-orden`: this used to force
|
|
/// `ordenarEmisoras(emisoras, OrdenEmisoras.calidad)` unconditionally,
|
|
/// silently discarding whatever order the caller actually wanted —
|
|
/// Favoritos' manual drag-reorder order, or the global `ordenListas`
|
|
/// setting for Todas/Mis emisoras. Every caller (`EstadoRadio.
|
|
/// cargarFavoritos`/`cargarPopulares`/`_cargarEmisorasCustom`/
|
|
/// `cambiarOrdenListas`) now pushes an already-ordered snapshot, so this
|
|
/// only slices and maps — it must never re-sort. 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 [];
|
|
return emisoras.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,
|
|
// Item 2 (recursive folder play): optional so every pre-existing call
|
|
// site/test that has no need for the recursive gate keeps working
|
|
// unchanged. Only used on page 0, and only when [nodos] has zero
|
|
// DIRECT tracks (a direct track already makes the gate cheaply true
|
|
// without it) — see the `hayContenidoReproducible` computation below.
|
|
FuenteMusicaLocalAuto? fuente,
|
|
}) async {
|
|
final construir = construirItem ?? _itemLocal;
|
|
final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion);
|
|
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;
|
|
// Item 2: a folder plays everything beneath it, recursively -- so
|
|
// the play actions must be offered whenever the RECURSIVE count is
|
|
// > 0, not just the direct count. `totalPistas > 0` short-circuits
|
|
// the bounded recursive walk entirely for the common case (a direct
|
|
// track already answers the question); only a folder with ZERO
|
|
// direct tracks but at least one subfolder pays the recursive-check
|
|
// cost, and only up to [profundidadMaximaRecursivaLocal] levels.
|
|
final hayContenidoReproducible =
|
|
totalPistas > 0 ||
|
|
(fuente != null &&
|
|
await _haySubcarpetaConPistas(nodos, fuente: fuente));
|
|
final prepend = <MediaItem>[
|
|
// Folder-play actions (Design ADR-5, Phase 3; recursive gate item
|
|
// 2): prepended BEFORE the sort/bucket nav entries, present iff
|
|
// the folder has at least one playable track anywhere beneath it
|
|
// (direct or nested), absent for a folder that is genuinely empty
|
|
// even recursively (Spec "Folder has no tracks").
|
|
if (hayContenidoReproducible) _itemReproducirCarpeta(documentIdPadre),
|
|
if (hayContenidoReproducible) _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 at least one subfolder within [nodos] recursively contains a
|
|
/// playable track (Design "recursive folder play, gate", item 2): called
|
|
/// ONLY when the folder has zero DIRECT tracks (the caller already
|
|
/// checked that cheaply) — descends into each direct subfolder via
|
|
/// [pistasRecursivas] with `limite: 1`, stopping at the very first
|
|
/// match so a folder with an early hit costs as little as possible.
|
|
/// [nodos] is assumed already resolved by the caller (its own
|
|
/// `fuente.hijos(...)` result), so this folder's own children are never
|
|
/// re-fetched.
|
|
Future<bool> _haySubcarpetaConPistas(
|
|
List<NodoLocal> nodos, {
|
|
required FuenteMusicaLocalAuto fuente,
|
|
}) async {
|
|
for (final nodo in nodos) {
|
|
if (!nodo.esDirectorio) continue;
|
|
final encontradas = await pistasRecursivas(
|
|
nodo.documentId,
|
|
fuente: fuente,
|
|
profundidadMaxima: profundidadMaximaRecursivaLocal - 1,
|
|
limite: 1,
|
|
);
|
|
if (encontradas.isNotEmpty) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// 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), PRESERVING the incoming [favoritos] order (Favoritos'
|
|
/// manual order — see [hijos]' doc, fix `android-auto-orden`) 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 [];
|
|
return miembros.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
|
}
|
|
|
|
/// Equalizer preset-selection media-id prefix (decision
|
|
/// `auto/ecualizador-diseno`): `eq_preset:<rawPresetName>` for the six
|
|
/// factory presets, plus the reserved [_valorDesactivarEq] sentinel for
|
|
/// the "Desactivar" item ([idDesactivarEq]). Collision-free against every
|
|
/// other prefix/bare id in this class -- diverges from every sibling
|
|
/// prefix at the very first character ('e' vs 'g'/'c'/'p') and from every
|
|
/// bare folder id (none of which starts with "eq_preset:").
|
|
static const _prefijoPresetEq = 'eq_preset:';
|
|
|
|
/// Reserved sentinel raw value for the "Desactivar" item under
|
|
/// [_prefijoPresetEq] (decision `auto/ecualizador-diseno`) -- never
|
|
/// collides with a real [PresetEcualizador.nombre]; none of the six
|
|
/// factory presets is named this.
|
|
static const _valorDesactivarEq = '_off_';
|
|
|
|
/// The "Desactivar" item's media id: the reserved [_valorDesactivarEq]
|
|
/// sentinel under [_prefijoPresetEq].
|
|
static const idDesactivarEq = '$_prefijoPresetEq$_valorDesactivarEq';
|
|
|
|
/// Whether [id] identifies an item under the Ecualizador folder (a
|
|
/// factory preset OR "Desactivar").
|
|
bool esPresetEqMediaId(String id) => id.startsWith(_prefijoPresetEq);
|
|
|
|
/// Whether [id] is specifically the "Desactivar" item (not a factory
|
|
/// preset). Only meaningful alongside [esPresetEqMediaId].
|
|
bool esDesactivarEqMediaId(String id) => id == idDesactivarEq;
|
|
|
|
/// Builds a factory preset's selection media id, matched by raw
|
|
/// (untranslated) [PresetEcualizador.nombre] -- the SAME identity
|
|
/// [PresetEcualizador.presets] already uses for equality, so a locale
|
|
/// change never breaks resolution.
|
|
String idPresetEq(String nombrePreset) => '$_prefijoPresetEq$nombrePreset';
|
|
|
|
/// Resolves an `eq_preset:<nombre>` [id] to the matching factory
|
|
/// [PresetEcualizador] from [presets] (defaults to
|
|
/// [PresetEcualizador.presets]), comparing by raw `nombre`. Returns
|
|
/// `null` for the [_valorDesactivarEq] sentinel, an unresolvable name, or
|
|
/// any id that doesn't match [esPresetEqMediaId] -- never throws.
|
|
PresetEcualizador? resolverPresetEq(
|
|
String id, {
|
|
List<PresetEcualizador>? presets,
|
|
}) {
|
|
if (!esPresetEqMediaId(id) || esDesactivarEqMediaId(id)) return null;
|
|
final nombre = id.substring(_prefijoPresetEq.length);
|
|
final lista = presets ?? PresetEcualizador.presets;
|
|
for (final preset in lista) {
|
|
if (preset.nombre == nombre) return preset;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Pure Android Auto browse-gate decision (iap-freemium-unlock, Design
|
|
/// ADR-4): the AUTHORITATIVE `getChildren` choke point, called BEFORE any
|
|
/// other resolution. For the root itself this NEVER blocks (the root always
|
|
/// resolves through [ConstructorArbolAuto.raiz] instead, which stays
|
|
/// visible for every tier). For any non-root [parentMediaId] and a free-tier
|
|
/// [premium], it returns the single locked item regardless of what the id
|
|
/// actually is — a stale/deep-linked `emisora:<uuid>` or folder id from
|
|
/// before a downgrade is blocked exactly the same way as a legitimate
|
|
/// current folder id (android-auto-media spec "Free-Tier Browse Never
|
|
/// Leaks Real Content (Authoritative Backstop)"). Returns `null` when the
|
|
/// caller should proceed with its normal resolution (root, or premium).
|
|
List<MediaItem>? respuestaBloqueadaPorEntitlement({
|
|
required String parentMediaId,
|
|
required bool premium,
|
|
}) {
|
|
if (parentMediaId == AudioService.browsableRootId) return null;
|
|
if (premium) return null;
|
|
return [ConstructorArbolAuto().itemPremiumBloqueado()];
|
|
}
|
|
|
|
/// 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',
|
|
// Item 3: reuses [artUriPara] (the SAME fallback the browse tree's
|
|
// itemEmisora already applies) so the "now playing" media item never
|
|
// falls back to a blank tile — a real usable favicon still wins, a
|
|
// missing/unusable one gets the on-brand rotating drawable instead of
|
|
// `null`.
|
|
artUri: Uri.parse(artUriPara(emisora)),
|
|
extras: {'uuid': emisora.uuid},
|
|
);
|
|
await reproducir(item);
|
|
}
|
|
|
|
/// Which list previous/next should walk for [actual]: the NARROWEST context
|
|
/// the station belongs to.
|
|
///
|
|
/// Tightest first:
|
|
/// 1. its FAVOURITES GROUP, when it is a favourite filed under a real group,
|
|
/// 2. all favourites,
|
|
/// 3. my stations,
|
|
/// 4. the full catalogue.
|
|
///
|
|
/// The group tier is what the owner asked for: driving with a themed group,
|
|
/// "next" should stay inside that group rather than wander across every
|
|
/// favourite. And "next" from a favourite must never land on entry 4,318 of a
|
|
/// 50,000-station catalogue that happens to sit beside it alphabetically.
|
|
/// Falling through to [todas] only when the station is in neither curated
|
|
/// list keeps the button alive for a station reached by search.
|
|
///
|
|
/// [GrupoFavoritos.sinAsignarId] is deliberately NOT treated as a group: it
|
|
/// is the ABSENCE of one, so those stations walk all favourites instead of a
|
|
/// bucket that only means "unfiled". A group with a single member also falls
|
|
/// through to all favourites — otherwise both buttons would be dead ends.
|
|
///
|
|
/// Returns an empty list when [actual] is in none of them, which
|
|
/// [emisoraVecina] turns into "do nothing".
|
|
List<Emisora> listaParaSaltoEmisora({
|
|
required Emisora actual,
|
|
required List<Emisora> favoritos,
|
|
required List<Emisora> misEmisoras,
|
|
required List<Emisora> todas,
|
|
}) {
|
|
Emisora? enLista(List<Emisora> lista) {
|
|
for (final e in lista) {
|
|
if (e.uuid == actual.uuid) return e;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// The FAVOURITE record is the authority on the group, never `actual`: the
|
|
// playing station is rebuilt from a MediaItem by `emisoraDesdeMediaItem`,
|
|
// which carries no group id and would always report "sin asignar".
|
|
final favorita = enLista(favoritos);
|
|
if (favorita != null) {
|
|
final grupo = favorita.grupoFavoritosId;
|
|
if (grupo != GrupoFavoritos.sinAsignarId) {
|
|
final delGrupo =
|
|
favoritos.where((e) => e.grupoFavoritosId == grupo).toList();
|
|
if (delGrupo.length > 1) return delGrupo;
|
|
}
|
|
return favoritos;
|
|
}
|
|
if (enLista(misEmisoras) != null) return misEmisoras;
|
|
if (enLista(todas) != null) return todas;
|
|
return const [];
|
|
}
|
|
|
|
/// The station before or after [actual] in [lista], wrapping around at both
|
|
/// ends.
|
|
///
|
|
/// Wrapping is deliberate: on a car's transport row a button that goes dead
|
|
/// at the end of a list reads as a broken app, and there is no visible list
|
|
/// position to explain it. Matching is by `uuid`, the same identity the
|
|
/// browse tree uses, so a refreshed snapshot with different object instances
|
|
/// still resolves.
|
|
///
|
|
/// Returns `null` when [lista] has fewer than two entries, or when [actual]
|
|
/// is not in it — the caller must then leave playback alone rather than jump
|
|
/// somewhere arbitrary.
|
|
Emisora? emisoraVecina(
|
|
Emisora? actual,
|
|
List<Emisora> lista, {
|
|
required bool haciaAtras,
|
|
}) {
|
|
if (actual == null || lista.length < 2) return null;
|
|
final indice = lista.indexWhere((e) => e.uuid == actual.uuid);
|
|
if (indice < 0) return null;
|
|
final destino =
|
|
haciaAtras
|
|
? (indice - 1 + lista.length) % lista.length
|
|
: (indice + 1) % lista.length;
|
|
return lista[destino];
|
|
}
|
|
|
|
/// Picks the station a spoken query refers to ("pon Radio Clásica"), over the
|
|
/// stations the car can already browse.
|
|
///
|
|
/// Pure and source-agnostic so it is testable without a handler. Ranking, best
|
|
/// first:
|
|
/// 1. exact name match (case/accent-insensitive),
|
|
/// 2. name starts with the query,
|
|
/// 3. name contains the query,
|
|
/// 4. country contains the query.
|
|
/// Ties are broken by the order [candidatas] arrives in, which the caller
|
|
/// composes as favourites → my stations → all, so a favourite always wins over
|
|
/// a stranger with the same name.
|
|
///
|
|
/// Returns `null` for an empty query or no match — the caller must then do
|
|
/// nothing rather than play something arbitrary, since a driver who asked for
|
|
/// a specific station is worse served by a random one than by silence.
|
|
Emisora? emisoraParaBusqueda(String consulta, List<Emisora> candidatas) {
|
|
final q = _normalizarBusqueda(consulta);
|
|
if (q.isEmpty) return null;
|
|
|
|
Emisora? contiene;
|
|
Emisora? empieza;
|
|
Emisora? porPais;
|
|
for (final emisora in candidatas) {
|
|
final nombre = _normalizarBusqueda(emisora.nombre);
|
|
if (nombre == q) return emisora;
|
|
if (empieza == null && nombre.startsWith(q)) {
|
|
empieza = emisora;
|
|
} else if (contiene == null && nombre.contains(q)) {
|
|
contiene = emisora;
|
|
} else if (porPais == null &&
|
|
_normalizarBusqueda(emisora.pais ?? '').contains(q)) {
|
|
porPais = emisora;
|
|
}
|
|
}
|
|
return empieza ?? contiene ?? porPais;
|
|
}
|
|
|
|
/// Lowercase, accent-stripped, collapsed whitespace — a driver saying "radio
|
|
/// clasica" must match "Radio Clásica", and voice transcription rarely gets
|
|
/// diacritics right.
|
|
String _normalizarBusqueda(String texto) {
|
|
const conAcento = 'áàäâãéèëêíìïîóòöôõúùüûñç';
|
|
const sinAcento = 'aaaaaeeeeiiiiooooouuuunc';
|
|
final buffer = StringBuffer();
|
|
for (final rune in texto.toLowerCase().runes) {
|
|
final char = String.fromCharCode(rune);
|
|
final i = conAcento.indexOf(char);
|
|
buffer.write(i >= 0 ? sinAcento[i] : char);
|
|
}
|
|
return buffer.toString().replaceAll(RegExp(r'\s+'), ' ').trim();
|
|
}
|
|
|
|
/// Routing seam for a car-tapped `eq_preset:<...>` media id (decision
|
|
/// `auto/ecualizador-diseno`, mirrors [reproducirPorMediaId]'s seam
|
|
/// shape): dispatches "Desactivar" to [activarEcualizador]`(false)`, and a
|
|
/// resolved factory preset to [aplicarPreset] -- turning the equalizer
|
|
/// back ON via [activarEcualizador]`(true)` AFTERWARDS whenever [activo]
|
|
/// is currently `false`, so tapping a preset while the equalizer is off
|
|
/// both re-enables it AND applies the tapped preset's gains (Spec
|
|
/// "selecting a preset while disabled enables it and applies it"), never
|
|
/// silently just remembering the preset for later. [aplicarPreset] runs
|
|
/// BEFORE the enable check so the native engine only ever pushes gains
|
|
/// once, for the NEW preset -- never once for whatever was active before,
|
|
/// then again for the new one.
|
|
///
|
|
/// A stale/unresolvable id, or any id that doesn't match
|
|
/// [ConstructorArbolAuto.esPresetEqMediaId], is a no-op: neither callback
|
|
/// runs and no exception propagates.
|
|
///
|
|
/// [presets] is the universe the id is resolved against, and it MUST be the
|
|
/// same list the folder was rendered from (`presetsEcualizadorAuto` in
|
|
/// `servicio_audio.dart` — factory presets plus the user's saved ones).
|
|
/// Defaulting to the factory six alone is what made a tapped custom preset a
|
|
/// silent no-op: the item was listed, but nothing here could resolve it.
|
|
Future<void> seleccionarPresetEqPorMediaId(
|
|
String id, {
|
|
required bool activo,
|
|
required Future<void> Function(PresetEcualizador) aplicarPreset,
|
|
required Future<void> Function(bool) activarEcualizador,
|
|
List<PresetEcualizador>? presets,
|
|
}) async {
|
|
final constructor = ConstructorArbolAuto();
|
|
if (!constructor.esPresetEqMediaId(id)) return;
|
|
|
|
if (constructor.esDesactivarEqMediaId(id)) {
|
|
await activarEcualizador(false);
|
|
return;
|
|
}
|
|
|
|
final preset = constructor.resolverPresetEq(id, presets: presets);
|
|
if (preset == null) return;
|
|
|
|
await aplicarPreset(preset);
|
|
if (!activo) await activarEcualizador(true);
|
|
}
|
|
|
|
/// 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);
|
|
|
|
/// Maximum recursion depth for "play folder recursively" (Design "recursive
|
|
/// folder play, cost bound", item 2): SAF directory listing is a native
|
|
/// round-trip PER folder, so unbounded recursion could turn a single tap
|
|
/// into dozens of channel calls for a pathologically deep tree. 4 levels
|
|
/// below the tapped folder covers virtually every real music-library
|
|
/// layout (even `Artist/Album/Disc/track.mp3` is only 3 levels deep) while
|
|
/// keeping a worst-case tree's native-call count bounded. A subfolder
|
|
/// beyond this depth is simply never explored — its tracks are not
|
|
/// collected, exactly like content beyond the browse tree's own page cap
|
|
/// is never listed.
|
|
const profundidadMaximaRecursivaLocal = 4;
|
|
|
|
/// Maximum number of tracks collected by a recursive folder walk (Design
|
|
/// "recursive folder play, cost bound", item 2): a folder-play/shuffle
|
|
/// queue beyond a few hundred tracks has no practical benefit, and an
|
|
/// unbounded collection risks an extremely long queue AND an extremely
|
|
/// long recursive walk over a huge library. 500 is an order of magnitude
|
|
/// above the existing quality-sort cap
|
|
/// ([ConstructorArbolAuto._maxPistasParaOrdenCalidad], 150) — generous for
|
|
/// a "play everything" action, while still bounded.
|
|
const limitePistasRecursivasLocal = 500;
|
|
|
|
/// Recursively collects every audio-file [NodoLocal] reachable from
|
|
/// [documentId] (Design "recursive folder play", item 2): [documentId]'s
|
|
/// own direct audio children, plus — for every direct subfolder — that
|
|
/// subfolder's own recursive result. Walked depth-first, sorted by
|
|
/// [NodoLocal.nombre] at each level (the SAME comparator the sequential/
|
|
/// shuffle play actions already used pre-recursion), so the collected
|
|
/// order is deterministic and reproducible under a fixed shuffle seed.
|
|
///
|
|
/// Bounded on two independent axes so a pathological tree (very deep, or
|
|
/// very wide-and-deep) can never turn a single tap into an unbounded
|
|
/// number of native SAF round-trips or an unbounded in-memory list:
|
|
/// - [profundidadMaxima] caps how many folder levels BELOW [documentId]
|
|
/// are ever descended into (`0` = only [documentId]'s own direct
|
|
/// children, no descent at all).
|
|
/// - [limite] caps the TOTAL number of tracks collected across the whole
|
|
/// walk; collection stops (mid-folder if needed) the instant this many
|
|
/// have been gathered.
|
|
///
|
|
/// Never throws: a [fuente.hijos] failure on any one subfolder (revoked
|
|
/// permission, a race with the OS SAF layer) is swallowed for that
|
|
/// subfolder only — sibling folders already queued for traversal are
|
|
/// still visited — mirroring this file's existing no-throw contract
|
|
/// (Design "no-op on empty/unresolvable folder").
|
|
Future<List<NodoLocal>> pistasRecursivas(
|
|
String documentId, {
|
|
required FuenteMusicaLocalAuto fuente,
|
|
int profundidadMaxima = profundidadMaximaRecursivaLocal,
|
|
int limite = limitePistasRecursivasLocal,
|
|
}) async {
|
|
final resultado = <NodoLocal>[];
|
|
await _recolectarPistasRecursivas(
|
|
documentId,
|
|
fuente: fuente,
|
|
profundidadRestante: profundidadMaxima,
|
|
limite: limite,
|
|
resultado: resultado,
|
|
);
|
|
return resultado;
|
|
}
|
|
|
|
Future<void> _recolectarPistasRecursivas(
|
|
String documentId, {
|
|
required FuenteMusicaLocalAuto fuente,
|
|
required int profundidadRestante,
|
|
required int limite,
|
|
required List<NodoLocal> resultado,
|
|
}) async {
|
|
if (resultado.length >= limite) return;
|
|
final List<NodoLocal> hijos;
|
|
try {
|
|
hijos = await fuente.hijos(documentId);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
final ordenados = [...hijos]..sort((a, b) => a.nombre.compareTo(b.nombre));
|
|
for (final nodo in ordenados) {
|
|
if (resultado.length >= limite) return;
|
|
if (nodo.esDirectorio) {
|
|
if (profundidadRestante <= 0) continue;
|
|
await _recolectarPistasRecursivas(
|
|
nodo.documentId,
|
|
fuente: fuente,
|
|
profundidadRestante: profundidadRestante - 1,
|
|
limite: limite,
|
|
resultado: resultado,
|
|
);
|
|
} else {
|
|
resultado.add(nodo);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Orchestrates a "Reproducir carpeta"/"Reproducir aleatorio" tap (Design
|
|
/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2; recursive collection item
|
|
/// 2): resolves whichever of the two action prefixes matches [id]
|
|
/// (ignoring [aleatorio] for the STRIP — the prefix itself is
|
|
/// authoritative), RECURSIVELY collects every track beneath that folder
|
|
/// via [pistasRecursivas] (direct children AND every nested subfolder, up
|
|
/// to its depth/count bounds), orders them ([aleatorio] picks shuffled vs
|
|
/// the recursive walk's own name-sorted order), and hands the resulting
|
|
/// list to [iniciarCola].
|
|
///
|
|
/// A no-op (never calls [iniciarCola]) when: [id] matches neither action
|
|
/// prefix; the folder (or everything beneath it, within the recursion
|
|
/// bounds) is unresolvable/empty (Design "no-op on empty/unresolvable
|
|
/// folder") — [pistasRecursivas] never throws, so this never propagates an
|
|
/// exception either.
|
|
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 recolectadas = await pistasRecursivas(documentId, fuente: fuente);
|
|
final pistas =
|
|
aleatorio
|
|
? mezclarFisherYates(recolectadas, rng ?? Random())
|
|
: recolectadas;
|
|
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',
|
|
// Item 3: a queued local track had NO artUri at all before — reuses
|
|
// [artUriLocal] (the SAME on-brand rotation the browse tree's
|
|
// `_itemLocal` already falls back to) so the car's now-playing screen
|
|
// never shows a blank tile for a track with no embedded art.
|
|
artUri: Uri.parse(artUriLocal(nodo.documentId)),
|
|
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);
|
|
final items = await constructor.itemsLocales(
|
|
nodos,
|
|
documentIdPadre: documentId,
|
|
pagina: pagina,
|
|
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
|
|
fuente: fuente,
|
|
);
|
|
// fix/android-auto-musica-local: si no salió NADA, el motivo importa.
|
|
// Con el canal nativo caído (motor sin Activity) `hijos` degrada a `[]`
|
|
// igual que una carpeta realmente vacía, y una carpeta vacía en el
|
|
// coche se lee como «no tengo música». El estado se consulta SOLO en
|
|
// ese caso vacío, así que la ruta normal no paga ningún round trip
|
|
// extra.
|
|
if (items.isEmpty &&
|
|
await fuente.estadoCarpeta() == EstadoCarpetaLocal.canalNoDisponible) {
|
|
return [constructor.itemLocalNoDisponible()];
|
|
}
|
|
return items;
|
|
} 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',
|
|
// Item 3: same fallback as construirMediaItemColaLocal, for a track
|
|
// tapped directly (not via a folder-play queue).
|
|
artUri: Uri.parse(artUriLocal(pista.documentId)),
|
|
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';
|
|
}
|
|
}
|