Files
pluriwave/lib/servicios/navegacion_auto.dart
T
FreeTLab 8e155cc0ac fix: cumplir las guias de calidad de Android Auto y localizar el arbol del coche
Google Play devolvio "Approved with Issues" en el codigo 157: "clicking on
stop button makes the entire app useless", citado contra las Android for Cars
App Quality Guidelines. La causa no era el boton de parar.

Maquina de estados del transporte

_cambiarFuente publicaba mediaItem y loading ANTES de su primer await y solo
comprobaba su revision despues de que _recrearPlayer retornase. Los cambios de
fuente se encolan incrementando la revision al encolar, no al ejecutar, asi
que tocar una emisora, tocar otra antes de que cargue y pulsar Stop dejaba que
las entradas obsoletas reescribieran loading sobre el idle que stop() acababa
de publicar. Estado final: loading para siempre sobre una sesion que
audio_service ya habia desactivado. Ahora la guarda de revision es la primera
sentencia del metodo.

pause() no invalidaba una carga en vuelo, asi que la emisora arrancaba igual
despues de pulsar pausa; se revalida la intencion antes de llamar a play().
Se anade un suelo de estado que cierra cualquier loading o buffering sin carga
viva, exento cuando el reproductor ya entrego audio y solo esta rebufferando,
para no convertir un tunel en un error. El presupuesto hasta el primer mensaje
baja a menos de diez segundos y los reintentos ya no borran el mensaje visible.

Tier gratuito en el coche

El arbol devolvia una unica fila no reproducible para cualquier carpeta cuando
no habia premium, y un revisor con instalacion limpia siempre es tier
gratuito. Ademas skipToNext, skipToPrevious, playFromSearch y playFromMediaId
retornaban en silencio. La raiz gratuita pasa a ofrecer una sola carpeta con
emisoras reales y reproducibles, compiladas en el binario para que existan en
frio, y la puerta de entitlement acota contenido en vez de bloquear acciones.
Se elimina la fila "Funcion Premium". Una consulta de voz vacia arranca la
ultima emisora, que fallaba tambien a los clientes de pago.

Localizacion

El locale del handler solo lo fijaba un widget que el motor headless nunca
construye, asi que todo error del coche salia en castellano. Se resuelve desde
el locale de plataforma. Se traducen las once etiquetas del arbol que estaban
a fuego y se retira la convencion que lo justificaba. Un test nuevo falla si
vuelve a aparecer texto visible fuera del sistema de traduccion.

Suite completa: 1455 pasan, 2 omitidos. Los mecanismos se verificaron por
mutacion: borrar cada uno pone la suite en rojo. flutter analyze mantiene los
5 avisos preexistentes.
2026-09-04 13:26:00 +02:00

2031 lines
89 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 'emisoras_destacadas.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,
}) {}
}
/// Every user-readable label of the Android Auto browse tree, already
/// resolved to one locale by the caller.
///
/// THE RULE (fix/auto-quality-guidelines, l10n item): anything a user can
/// read gets translated. This bundle replaces the previous
/// "car-tree labels are hardcoded Spanish, deliberately NOT an arb key"
/// convention, which was defensible only while those labels sat deep inside
/// a premium tree and stopped being defensible the moment Google Play
/// reviewed the car surface on an English head unit.
///
/// It exists as a plain value object rather than an `AppLocalizations`
/// dependency so [ConstructorArbolAuto] stays a PURE builder — the same
/// reason `itemsEcualizadorAuto` lives in `servicio_audio.dart`. The handler,
/// which can resolve localizations headlessly through
/// `resolverLocalizacionesRespaldo`, builds one via
/// `etiquetasArbolAutoDesde` and hands it in.
///
/// NOT in here on purpose: the alphabetical bucket labels (`'A-F'`, `'G-M'`,
/// …). Those are ranges of Latin letters, not prose — translating them would
/// make them lie about which filenames they contain.
class EtiquetasArbolAuto {
const EtiquetasArbolAuto({
required this.escuchar,
required this.favoritos,
required this.todasLasEmisoras,
required this.misEmisoras,
required this.musicaLocal,
required this.musicaLocalNoDisponible,
required this.cargarMas,
required this.ordenarPorCalidad,
required this.reproducirCarpeta,
required this.reproducirAleatorio,
required this.pistaSinNombre,
});
/// Fallback bundle for callers that have no localizations to hand: pure
/// builder tests, and any future non-car consumer.
///
/// It is NOT what the car shows. `ServicioAudio` always injects a bundle
/// resolved from `AppLocalizations`, in every browse and playback path
/// that can produce a label — `etiquetas_arbol_auto_test.dart` is the
/// guard that no NEW hardcoded label can be introduced alongside these.
static const respaldo = EtiquetasArbolAuto(
escuchar: 'Escuchar',
favoritos: 'Favoritos',
todasLasEmisoras: 'Todas las emisoras',
misEmisoras: 'Mis emisoras',
musicaLocal: 'Música Local',
musicaLocalNoDisponible: 'Abre PluriWave en el móvil para leer tu música',
cargarMas: 'Más…',
ordenarPorCalidad: 'Ordenar por calidad',
reproducirCarpeta: 'Reproducir carpeta',
reproducirAleatorio: 'Reproducir aleatorio',
pistaSinNombre: 'Pista sin nombre',
);
/// The free tier's single root folder ([ConstructorArbolAuto.idDestacadas]).
final String escuchar;
/// Premium root folders.
final String favoritos;
final String todasLasEmisoras;
final String misEmisoras;
final String musicaLocal;
/// The non-playable row shown when the local-music folder cannot be read
/// from the car ([ConstructorArbolAuto.idLocalNoLista]).
final String musicaLocalNoDisponible;
/// Trailing "load more" row of every paged local-music view.
final String cargarMas;
/// Local-folder navigation and action rows.
final String ordenarPorCalidad;
final String reproducirCarpeta;
final String reproducirAleatorio;
/// Fallback title for a local file whose name is blank after stripping.
final String pistaSinNombre;
}
/// 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 {
const ConstructorArbolAuto({this.etiquetas = EtiquetasArbolAuto.respaldo});
/// The already-localized labels this builder stamps onto every
/// user-readable `MediaItem` it produces.
final EtiquetasArbolAuto etiquetas;
/// 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 FREE tier's only browsable folder
/// (fix/auto-quality-guidelines, item 8).
///
/// Deliberately NOT added to [_idsCarpetas] — like [idMusicaLocal] and
/// [idEcualizador] it has its own dedicated children ([hijosDestacadas]),
/// fed by `emisoras_destacadas.dart`'s compiled-in set rather than by the
/// generic station-list [hijos] path over a `FuenteEmisorasAuto` that is
/// empty on the bind a Play reviewer actually performs.
static const idDestacadas = 'destacadas';
/// 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, all non-playable, and TIER-DEPENDENT: Favoritos,
/// Todas las emisoras, Mis emisoras and optionally Música Local for a
/// premium driver; the single [idDestacadas] folder for a free one (see
/// [premium] below).
///
/// 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
/// `premium && fuente.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada`
/// (fix/android-auto-musica-local: un canal nativo ausente ya NO oculta el
/// nodo; fix/auto-quality-guidelines item 9: el `premium &&` va delante a
/// propósito, para que el tier gratuito ni siquiera pague ese round trip
/// nativo), keeping this builder itself synchronous and side-effect free.
///
/// [premium] (fix/auto-quality-guidelines, item 8) is finally READ. It used
/// to be accepted and ignored, on the theory that "the root keeps the same
/// visible folder labels for free users" was friendlier than a reduced
/// menu. It was not: every one of those four folders dead-ended on a single
/// non-playable "Función Premium" row, and Google Play cited exactly that
/// against the Android for Cars App Quality Guidelines.
///
/// The free root is therefore ONE browsable folder, [idDestacadas], and the
/// premium-only folders are OMITTED rather than shown-and-blocked: a folder
/// a driver cannot use is worse than a folder that is not there.
///
/// It must stay at least one BROWSABLE item, never a bare playable one:
/// `audio_service` 0.18.18 discards `rootHints`
/// (`AudioService.java:817-826`), so this code cannot detect whether the
/// head unit accepts a `FLAG_PLAYABLE` root child, and the documented
/// default of `BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS` is
/// `FLAG_BROWSABLE` alone — a root of one playable item renders EMPTY on
/// such a unit.
///
/// Every label here comes from [etiquetas], already resolved to the head
/// unit's locale — the free root's [EtiquetasArbolAuto.escuchar] AND the
/// four premium folders.
///
/// The four premium ones used to be hardcoded Spanish, on the theory that
/// they were leaf rows deep inside a tree only a user who had already
/// chosen the app would reach. That was never a rule, only an untested
/// assumption, and it is retired: anything a user can read gets
/// translated. `escuchar` was localized first (it is 100% of what a free
/// Play reviewer sees), which is exactly why the rest had to follow.
///
/// [tituloDestacadas] stays as an explicit per-call override of
/// [EtiquetasArbolAuto.escuchar]; `null` (the default) uses the bundle.
List<MediaItem> raiz({
required bool incluirMusicaLocal,
required bool premium,
String? tituloDestacadas,
}) =>
premium
? [
_carpeta(idFavoritos, etiquetas.favoritos),
_carpeta(idTodas, etiquetas.todasLasEmisoras),
_carpeta(idMisEmisoras, etiquetas.misEmisoras),
if (incluirMusicaLocal)
_carpeta(idMusicaLocal, etiquetas.musicaLocal),
]
: [_carpeta(idDestacadas, tituloDestacadas ?? etiquetas.escuchar)];
/// The free tier's playable station rows (fix/auto-quality-guidelines,
/// items 8/9): [emisoras] mapped through the SAME [itemEmisora] the premium
/// folders use, capped like every other folder.
///
/// Separate from [hijos] because that path is gated on [_idsCarpetas] and
/// fed by a `FuenteEmisorasAuto` whose lists are all empty on a cold
/// headless bind — which is precisely the bind this folder has to survive.
/// An empty [emisoras] returns `[]` rather than any placeholder row: a
/// non-playable row in the car tree is the thing Play cited.
List<MediaItem> hijosDestacadas(List<Emisora> emisoras) =>
emisoras.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
/// El item de [idLocalNoLista]. Rotulado con
/// [EtiquetasArbolAuto.musicaLocalNoDisponible], ya resuelto al idioma del
/// head unit. No reproducible — seleccionarlo es un no-op.
MediaItem itemLocalNoDisponible() => MediaItem(
id: idLocalNoLista,
title: etiquetas.musicaLocalNoDisponible,
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);
}
/// 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.
/// Rotulado con [EtiquetasArbolAuto.cargarMas].
MediaItem _itemMasLocal(String documentIdPadre, int siguientePagina) =>
MediaItem(
id: '$_prefijoCarpetaLocalPaginada$siguientePagina:$documentIdPadre',
title: etiquetas.cargarMas,
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 "sort by quality" 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].
/// Rotulado con [EtiquetasArbolAuto.ordenarPorCalidad].
MediaItem _itemModoOrdenCalidad(String documentIdPadre) => _carpeta(
'${_prefijoCarpetaLocalOrd}calidad:0:$documentIdPadre',
etiquetas.ordenarPorCalidad,
);
/// 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 an alphabetical RANGE (e.g. `'A-F'`), and it is the one
/// user-visible car-tree string that deliberately does NOT go through
/// [EtiquetasArbolAuto]: it names the Latin letters the folder's filenames
/// actually start with, so translating it would make it lie.
MediaItem _itemBucket(String documentIdPadre, int idx, String etiqueta) =>
_carpeta('$_prefijoCarpetaLocalBucket$idx:0:$documentIdPadre', etiqueta);
/// The "play folder" playable action item (Design ADR-5): id
/// `carpeta_local_reproducir:<documentIdPadre>`. Rotulado con
/// [EtiquetasArbolAuto.reproducirCarpeta].
MediaItem _itemReproducirCarpeta(String documentIdPadre) => MediaItem(
id: '$_prefijoCarpetaLocalReproducir$documentIdPadre',
title: etiquetas.reproducirCarpeta,
playable: true,
extras: _contentStyleGrid,
);
/// The "shuffle play" playable action item (Design ADR-5),
/// mirrors [_itemReproducirCarpeta].
MediaItem _itemReproducirAleatorio(String documentIdPadre) => MediaItem(
id: '$_prefijoCarpetaLocalAleatorio$documentIdPadre',
title: etiquetas.reproducirAleatorio,
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: etiquetas.cargarMas,
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: etiquetas.cargarMas,
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, etiquetas.pistaSinNombre);
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;
}
}
/// Whether [parentMediaId] is content the FREE tier is allowed to browse
/// (fix/auto-quality-guidelines, item 10): the browsable root itself, the
/// free folder [ConstructorArbolAuto.idDestacadas], and an `emisora:<uuid>`
/// whose uuid belongs to [destacadas].
///
/// Everything else — the catalogue folders, favourites, custom stations,
/// local music, the equalizer folder, group folders, local tracks, and any
/// station uuid that is not in the free set — is premium content.
///
/// Pure and id-shaped, with the free universe INJECTED, so the whole matrix
/// is testable without prefs or a handler.
bool idPermitidoEnFree(
String parentMediaId, {
required List<Emisora> destacadas,
}) {
if (parentMediaId == AudioService.browsableRootId) return true;
if (parentMediaId == ConstructorArbolAuto.idDestacadas) return true;
if (!parentMediaId.startsWith(_prefijoEmisora)) return false;
final uuid = parentMediaId.substring(_prefijoEmisora.length);
if (uuid.isEmpty) return false;
return destacadas.any((e) => e.uuid == uuid);
}
/// Pure Android Auto browse-gate decision: the AUTHORITATIVE `getChildren`
/// choke point, called BEFORE any other resolution.
///
/// REWRITTEN (fix/auto-quality-guidelines, item 10) from action-blocking to
/// content-scoping. It used to answer ANY non-root id, for a free-tier user,
/// with a single non-playable "Función Premium" row — which is what Google
/// Play cited on version code 157 ("clicking on stop button makes the entire
/// app useless" was the headline, but the browse tree it was reviewed
/// against was four folders that each dead-ended on that row). A
/// non-playable row reachable from a head unit's CACHED tree is a citation
/// waiting to happen, so there is no longer any code path that can produce
/// one: the blocked branch returns the free tier's own playable stations.
///
/// Returns `null` when the caller should proceed with its normal resolution
/// (premium, or free-tier content the free tier owns).
///
/// [destacadas] is the free universe (`resolverEmisorasDestacadas()`); the
/// caller resolves it once per browse. Passing an empty list is legal and
/// yields an empty blocked response — still never a dead row.
List<MediaItem>? respuestaBloqueadaPorEntitlement({
required String parentMediaId,
required bool premium,
required List<Emisora> destacadas,
}) {
if (premium) return null;
if (idPermitidoEnFree(parentMediaId, destacadas: destacadas)) return null;
return ConstructorArbolAuto().hijosDestacadas(destacadas);
}
/// 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").
///
/// RETURNS whether it actually dispatched (fix/auto-quality-guidelines,
/// item 12). The caller needs to tell "played" from "resolved to nothing"
/// so the second case can publish an explained error to the car instead of
/// leaving the driver with a tap that did nothing and said nothing.
Future<bool> reproducirPorMediaId(
String id, {
required FuenteEmisorasAuto fuente,
required Future<void> Function(MediaItem) reproducir,
}) async {
final uuid = uuidDeMediaIdEmisora(id);
if (uuid == null) return false;
final emisora = await fuente.porUuid(uuid);
if (emisora == null) return false;
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);
return true;
}
/// The uuid inside an `emisora:<uuid>` media id, or `null` for any other
/// shape — no prefix (a `pista:`/`carpeta_local_*`/`eq_preset:` id, or a
/// folder id) and an empty tail both answer `null`.
///
/// Extracted (fix/auto-quality-guidelines, item 11) because the play-path
/// entitlement gate has to ask the same question `reproducirPorMediaId` asks,
/// one step earlier: "is this a station id, and which station?".
String? uuidDeMediaIdEmisora(String id) {
if (!id.startsWith(_prefijoEmisora)) return null;
final uuid = id.substring(_prefijoEmisora.length);
return uuid.isEmpty ? null : uuid;
}
/// A [FuenteEmisorasAuto] over nothing but the free tier's station set
/// (fix/auto-quality-guidelines, item 12).
///
/// Stands in for `_fuenteNavegacionGlobal` while that is still `null` — the
/// window between the headless Android Auto engine starting and `main.dart`
/// registering the real source. A tap arriving in that window used to return
/// in silence; the free set is compiled into the binary, so it can always be
/// answered.
///
/// Reports the free stations through [todas] (they are, from the car's point
/// of view, everything there is) and nothing through the curated lists, which
/// a headless bind could not populate anyway.
class FuenteEmisorasAutoDestacadas extends FuenteEmisorasAuto {
FuenteEmisorasAutoDestacadas(this._destacadas);
final List<Emisora> _destacadas;
@override
Future<List<Emisora>> favoritos() async => const [];
@override
Future<List<Emisora>> misEmisoras() async => const [];
@override
Future<List<Emisora>> todas() async => _destacadas;
@override
Future<List<GrupoFavoritos>> grupos() async => const [];
@override
Future<Emisora?> porUuid(String uuid) async {
for (final emisora in _destacadas) {
if (emisora.uuid == uuid) return emisora;
}
return null;
}
}
/// 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);
}
/// 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 [sinNombre] when the result would be blank.
///
/// [sinNombre] is [EtiquetasArbolAuto.pistaSinNombre], passed in rather than
/// hardcoded: it is a title the driver reads, so it is translated like every
/// other car-tree label.
String _tituloDesdeNombre(String nombre, String sinNombre) {
final recortado = nombre.trim();
if (recortado.isEmpty) return sinNombre;
final ultimoPunto = recortado.lastIndexOf('.');
final sinExtension =
ultimoPunto > 0 ? recortado.substring(0, ultimoPunto) : recortado;
final resultado = sinExtension.trim();
return resultado.isEmpty ? sinNombre : 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,
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
}) async {
final contentUri = await fuente.uriContenidoDePista(nodo.documentId);
if (contentUri == null || contentUri.isEmpty) return null;
return MediaItem(
id: contentUri,
title: _tituloDesdeDocumentId(nodo.documentId, etiquetas.pistaSinNombre),
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,
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
}) async {
final constructor = ConstructorArbolAuto(etiquetas: etiquetas);
// 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, String sinNombre) {
final ultimaBarra = documentId.lastIndexOf('/');
final segmento =
ultimaBarra >= 0 ? documentId.substring(ultimaBarra + 1) : documentId;
return _tituloDesdeNombre(segmento, sinNombre);
}
/// 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,
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
}) 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, etiquetas.pistaSinNombre),
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 [];
}
/// Resolves a station uuid across every list this source can reach.
///
/// The free tier's set ([resolverEmisorasDestacadas]) is searched LAST
/// (fix/auto-quality-guidelines, item 7). It has to be searched at all
/// because on a cold headless bind the three lists above are all empty —
/// `todas()` is `_snapshotTodas ?? const []`, favourites and custom
/// stations have nothing persisted on a fresh install — so a curated
/// `emisora:<uuid>` resolved to `null` and tapping the row did NOTHING.
/// It is searched last so a live catalogue/favourite record for the same
/// uuid (richer metadata, the user's own group assignment) still wins.
@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;
}
}
for (final emisora in await resolverEmisorasDestacadas()) {
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';
}
}