Folders over the 50-item cap now show a "Mas..." item that reveals the next page on tap, instead of silently dropping the rest. Paging slices the cheap raw list before building any MediaItem, so items beyond the requested page are never resolved (art, title) -- proven by a call-count test. Also swaps the raw SAF content:// URI shown in settings for a parsed, human-readable folder name with a localized fallback across all 13 locales. servicio_audio.dart is untouched; this stays entirely within the local-music tree/dispatch layer.
789 lines
34 KiB
Dart
789 lines
34 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import '../estado/orden_emisoras.dart';
|
|
import '../modelos/emisora.dart';
|
|
import '../modelos/grupo_favoritos.dart';
|
|
import '../modelos/pista_local.dart';
|
|
import '../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;
|
|
|
|
const _prefijoEmisora = 'emisora:';
|
|
|
|
/// EQ preset media-id prefix (Design ADR-1), collision-free against
|
|
/// [_prefijoEmisora], `grupo:` and the bare folder id constants.
|
|
const _prefijoPresetEq = 'eq_preset:';
|
|
|
|
/// Whether [id] identifies an EQ preset leaf item (Design ADR-1). A bare
|
|
/// prefix (`'eq_preset:'`, empty name) is still `true` here — the empty-name
|
|
/// case is rejected downstream by [resolverPresetEq], not by this routing
|
|
/// predicate.
|
|
bool esPresetMediaId(String id) => id.startsWith(_prefijoPresetEq);
|
|
|
|
/// Local-track media-id prefix (Design "media-id scheme"), collision-free
|
|
/// against [_prefijoEmisora], [_prefijoPresetEq], `grupo:` and the bare
|
|
/// folder id constants. Top-level (not a [ConstructorArbolAuto] member),
|
|
/// mirroring [_prefijoPresetEq]/[esPresetMediaId]'s shape — used directly
|
|
/// from `playFromMediaId`'s dispatch in `servicio_audio.dart`.
|
|
const _prefijoPista = 'pista:';
|
|
|
|
/// Whether [id] identifies a local-track playable leaf item (Design
|
|
/// "media-id scheme").
|
|
bool esPistaMediaId(String id) => id.startsWith(_prefijoPista);
|
|
|
|
/// Canonical on-brand fallback-art names and rotation order, ported
|
|
/// **verbatim** (same formula, same order) from
|
|
/// `lib/widgets/tarjeta_emisora.dart`'s `_fallbackArtFor` (lines 363-367)
|
|
/// 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;
|
|
}
|
|
|
|
/// 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 EQ presets folder (Design "media-id scheme").
|
|
/// Deliberately NOT added to [_idsCarpetas] — it has its own dedicated
|
|
/// branch in `getChildren`/`ConstructorArbolAuto.presetsEq`, not the
|
|
/// generic station-list `hijos()` path.
|
|
static const idEcualizador = 'ecualizador';
|
|
|
|
/// Root folder id for the local-music browsable root (Design "media-id
|
|
/// scheme"). Deliberately NOT added to [_idsCarpetas] — it has its own
|
|
/// dedicated branch (`hijosMusicaLocal`), not the generic station-list
|
|
/// [hijos] path. Hidden from [raiz] until a folder has been picked
|
|
/// (Design "Local root hidden until a folder is configured").
|
|
static const idMusicaLocal = 'musica_local';
|
|
|
|
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
|
|
static const _maxItemsPorCarpeta = 50;
|
|
|
|
/// 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:';
|
|
|
|
/// Separate cap for favorite-group folders under `Favoritos` (Design
|
|
/// "group-folder ordering and cap"): a folder tap costs more driver
|
|
/// attention than a station scroll, so this is tunable independently of
|
|
/// [_maxItemsPorCarpeta].
|
|
static const _maxGruposPorFavoritos = 50;
|
|
|
|
/// Dedicated cap for local-music folders (Design "Dedicated 50-item cap,
|
|
/// alphabetical truncation"), tunable independently of
|
|
/// [_maxItemsPorCarpeta]/[_maxGruposPorFavoritos] — the extension point
|
|
/// for a future native page-offset parameter.
|
|
static const _maxItemsCarpetaLocal = 50;
|
|
|
|
/// Content-style extras (Design "content style", optional polish): list
|
|
/// (1) for the root's folders, grid (2) for playable station items.
|
|
static const _contentStyleLista = {
|
|
'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. `Ecualizador`
|
|
/// is deliberately LAST (Design ADR-2): content-browsing folders are the
|
|
/// primary car task and stay first, the EQ tool trails them. `Música
|
|
/// Local` is OMITTED entirely (not just empty) unless [incluirMusicaLocal]
|
|
/// is `true` (Design "Local root hidden until a folder is configured") —
|
|
/// the caller passes `fuente.hayCarpetaConfigurada()`, keeping this
|
|
/// builder itself synchronous and side-effect free. When `false`, the
|
|
/// result is byte-identical to the pre-local-music 4-folder tree
|
|
/// (regression guard).
|
|
List<MediaItem> raiz({required bool incluirMusicaLocal}) => [
|
|
_carpeta(idFavoritos, 'Favoritos'),
|
|
_carpeta(idTodas, 'Todas las emisoras'),
|
|
_carpeta(idMisEmisoras, 'Mis emisoras'),
|
|
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
|
|
_carpeta(idEcualizador, 'Ecualizador'),
|
|
];
|
|
|
|
MediaItem _carpeta(String id, String titulo) => MediaItem(
|
|
id: id,
|
|
title: titulo,
|
|
playable: false,
|
|
extras: _contentStyleLista,
|
|
);
|
|
|
|
/// Leaf items for [parentId], sorted via [ordenarEmisoras] and capped at
|
|
/// [_maxItemsPorCarpeta] (Design "which stations surface & ordering" —
|
|
/// avoids driver distraction and Auto list limits). Unknown [parentId]
|
|
/// (or an empty [emisoras]) returns an empty list instead of throwing.
|
|
List<MediaItem> hijos(String parentId, {required List<Emisora> emisoras}) {
|
|
if (!_idsCarpetas.contains(parentId)) return const [];
|
|
if (emisoras.isEmpty) return const [];
|
|
final ordenadas = ordenarEmisoras(emisoras, OrdenEmisoras.calidad);
|
|
return ordenadas.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
|
}
|
|
|
|
/// Maps a single [Emisora] to a playable `MediaItem`: id `emisora:<uuid>`
|
|
/// (Design "media-id scheme"), title, on-brand-fallback-aware `artUri`
|
|
/// (Design "Case B detection" + "Fallback-art selection") and a
|
|
/// quality-hint `displaySubtitle` (Design "`displaySubtitle` quality
|
|
/// format") when codec/bitrate are known.
|
|
MediaItem itemEmisora(Emisora e) => MediaItem(
|
|
id: '$_prefijoEmisora${e.uuid}',
|
|
title: e.nombre,
|
|
playable: true,
|
|
artUri: Uri.parse(artUriPara(e)),
|
|
displaySubtitle: subtituloCalidad(e),
|
|
extras: _contentStyleGrid,
|
|
);
|
|
|
|
/// Resolves `emisora:<uuid>` ids to the matching [Emisora] in [universo].
|
|
/// Any other shape (no prefix, empty uuid, unmatched uuid) returns `null`
|
|
/// instead of throwing (Spec "Media Item Resolution by ID").
|
|
Emisora? resolver(String id, List<Emisora> universo) {
|
|
if (!id.startsWith(_prefijoEmisora)) return null;
|
|
final uuid = id.substring(_prefijoEmisora.length);
|
|
if (uuid.isEmpty) return null;
|
|
for (final emisora in universo) {
|
|
if (emisora.uuid == uuid) return emisora;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Whether [id] identifies a favorite-group folder (Design "media-id
|
|
/// scheme").
|
|
bool esCarpetaGrupo(String id) => id.startsWith(_prefijoGrupo);
|
|
|
|
/// Maps a [GrupoFavoritos] to a non-playable folder `MediaItem` with id
|
|
/// `grupo:<id>` (Design "media-id scheme").
|
|
MediaItem itemGrupo(GrupoFavoritos g) =>
|
|
_carpeta('$_prefijoGrupo${g.id}', g.nombre);
|
|
|
|
/// Whether [id] identifies a local-music subfolder (Design "media-id
|
|
/// scheme").
|
|
bool esCarpetaLocalMediaId(String id) => id.startsWith(_prefijoCarpetaLocal);
|
|
|
|
/// Strips the `carpeta_local:` prefix from [id] by length (Design "Prefix
|
|
/// stripped by length" — survives a raw SAF documentId containing `:`/`/`
|
|
/// verbatim). Only meaningful when [esCarpetaLocalMediaId] is `true`.
|
|
String idCarpetaLocalDesde(String id) =>
|
|
id.substring(_prefijoCarpetaLocal.length);
|
|
|
|
/// Whether [id] identifies a paged "load more" local-music request
|
|
/// (Design ADR-1).
|
|
bool esCarpetaLocalPaginadaMediaId(String id) =>
|
|
id.startsWith(_prefijoCarpetaLocalPaginada);
|
|
|
|
/// Parses a `carpeta_local_pag:<page>:<docId>` [id] into its
|
|
/// `(documentId, pagina)` pair (Design ADR-1): the prefix is stripped by
|
|
/// length, then the remainder is split on the FIRST `:` only —
|
|
/// everything left of it is the page integer (never contains a colon),
|
|
/// everything right of it (including any further colons/slashes) is the
|
|
/// raw SAF documentId verbatim, so a docId containing `:` or `/` survives
|
|
/// intact. Root paging is expressible: an empty documentId round-trips as
|
|
/// `carpeta_local_pag:<n>:` (empty tail). Only meaningful when
|
|
/// [esCarpetaLocalPaginadaMediaId] is `true`.
|
|
(String documentId, int pagina) paginaCarpetaLocalDesde(String id) {
|
|
final resto = id.substring(_prefijoCarpetaLocalPaginada.length);
|
|
final indice = resto.indexOf(':');
|
|
final pagina = int.parse(resto.substring(0, indice));
|
|
final documentId = resto.substring(indice + 1);
|
|
return (documentId, pagina);
|
|
}
|
|
|
|
/// Hardcoded-Spanish car-tree label for the trailing "load more" item
|
|
/// (Design ADR-5) — matches every other car-tree label in this file
|
|
/// (`'Favoritos'`, `'Música Local'`, [_tituloLocalFallback]), none of
|
|
/// which go through `AppLocalizations`. Deliberately NOT an arb key.
|
|
static const _tituloMasLocal = 'Más…';
|
|
|
|
/// The trailing "load more" `MediaItem` (Design ADR-5): non-playable, no
|
|
/// `artUri` (the label alone is the affordance, like [_carpeta]), id
|
|
/// `carpeta_local_pag:<siguientePagina>:<documentIdPadre>` — round-trips
|
|
/// via [paginaCarpetaLocalDesde] back to the parent folder's next page.
|
|
MediaItem _itemMasLocal(String documentIdPadre, int siguientePagina) =>
|
|
MediaItem(
|
|
id: '$_prefijoCarpetaLocalPaginada$siguientePagina:$documentIdPadre',
|
|
title: _tituloMasLocal,
|
|
playable: false,
|
|
extras: _contentStyleLista,
|
|
);
|
|
|
|
/// Maps native [NodoLocal]s to browse-tree `MediaItem`s, paged (Design
|
|
/// "Lazy per-folder enumeration" + ADR-3 pagination): the full [nodos]
|
|
/// list is sorted alphabetically by [NodoLocal.nombre] — cheap, no
|
|
/// `MediaItem` built yet — then sliced to [pagina] via [paginaDe] BEFORE
|
|
/// any `MediaItem` is constructed, and only that slice (at most [tamano]
|
|
/// entries) is mapped through [construirItem] (Design "slice the cheap
|
|
/// list, then map — never map-then-slice", the memory-efficiency
|
|
/// invariant). 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.
|
|
List<MediaItem> itemsLocales(
|
|
List<NodoLocal> nodos, {
|
|
required String documentIdPadre,
|
|
int pagina = 0,
|
|
int tamano = _maxItemsCarpetaLocal,
|
|
@visibleForTesting MediaItem Function(NodoLocal)? construirItem,
|
|
}) {
|
|
final construir = construirItem ?? _itemLocal;
|
|
final ordenados = [...nodos]..sort((a, b) => a.nombre.compareTo(b.nombre));
|
|
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
|
|
final items = paginaActual.map(construir).toList();
|
|
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
|
|
items.add(_itemMasLocal(documentIdPadre, pagina + 1));
|
|
}
|
|
return items;
|
|
}
|
|
|
|
MediaItem _itemLocal(NodoLocal nodo) {
|
|
if (nodo.esDirectorio) {
|
|
return _carpeta('$_prefijoCarpetaLocal${nodo.documentId}', nodo.nombre);
|
|
}
|
|
return MediaItem(
|
|
id: '$_prefijoPista${nodo.documentId}',
|
|
title: _tituloDesdeNombre(nodo.nombre),
|
|
playable: true,
|
|
artUri: Uri.parse(artUriLocal(nodo.documentId)),
|
|
extras: _contentStyleGrid,
|
|
);
|
|
}
|
|
|
|
/// Maps a [PresetEcualizador] to a playable `MediaItem` with id
|
|
/// `eq_preset:<nombre>` (Design ADR-1).
|
|
MediaItem itemPresetEq(PresetEcualizador preset) => MediaItem(
|
|
id: '$_prefijoPresetEq${preset.nombre}',
|
|
title: preset.nombre,
|
|
playable: true,
|
|
extras: _contentStyleGrid,
|
|
);
|
|
|
|
/// The 6 fixed EQ preset leaf items for the `Ecualizador` folder (Spec
|
|
/// "Car requests the Ecualizador folder").
|
|
List<MediaItem> presetsEq(List<PresetEcualizador> presets) =>
|
|
presets.map(itemPresetEq).toList();
|
|
|
|
/// Children of the `Favoritos` folder (Design "Ungrouped favorites stay as
|
|
/// direct leaves at the Favoritos root"): non-empty custom-group folders
|
|
/// (phone order, capped at [_maxGruposPorFavoritos]), followed by
|
|
/// `sin_asignar` stations mapped through the existing [hijos] path so the
|
|
/// no-custom-groups case is byte-identical to the pre-groups tree
|
|
/// (regression guard — Spec "Ungrouped station appears exactly as
|
|
/// before"). Empty custom groups are omitted (Design "Empty groups hidden
|
|
/// from the car tree"); the `sin_asignar` pseudo-group is never rendered
|
|
/// as its own folder.
|
|
List<MediaItem> carpetasFavoritos({
|
|
required List<GrupoFavoritos> grupos,
|
|
required List<Emisora> favoritos,
|
|
}) {
|
|
final carpetas = grupos
|
|
.where((g) => !g.esSinAsignar)
|
|
.where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
|
|
.take(_maxGruposPorFavoritos)
|
|
.map(itemGrupo)
|
|
.toList();
|
|
final sinAsignar = favoritos
|
|
.where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
|
|
.toList();
|
|
return [...carpetas, ...hijos(idFavoritos, emisoras: sinAsignar)];
|
|
}
|
|
|
|
/// Members of the favorite group identified by [grupoMediaId] (a
|
|
/// `grupo:<id>` id), sorted and capped like every other folder (Spec "Car
|
|
/// requests a group folder's stations"). An unknown/stale/malformed id
|
|
/// returns an empty list instead of throwing (Spec "Car requests an
|
|
/// unknown or stale group id").
|
|
List<MediaItem> hijosGrupo(
|
|
String grupoMediaId, {
|
|
required List<Emisora> favoritos,
|
|
}) {
|
|
if (!esCarpetaGrupo(grupoMediaId)) return const [];
|
|
final id = grupoMediaId.substring(_prefijoGrupo.length);
|
|
if (id.isEmpty) return const [];
|
|
final miembros = favoritos
|
|
.where((e) => e.grupoFavoritosId == id)
|
|
.toList();
|
|
if (miembros.isEmpty) return const [];
|
|
final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad);
|
|
return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
|
}
|
|
}
|
|
|
|
/// Routing seam between a car-tapped `emisora:<uuid>` media id and the
|
|
/// existing internal playback path (Design "playback coherence" — reuse
|
|
/// over duplication). Resolves the uuid via [fuente], builds the same
|
|
/// phone-shaped `MediaItem` (`id` = station url, `extras['uuid']`) that
|
|
/// [ServicioAudio.reproducir] builds, and delegates to [reproducir].
|
|
///
|
|
/// A stale/unknown id (or a malformed one) is a no-op: [reproducir] is
|
|
/// never called and no exception propagates (Spec "Unknown or stale media
|
|
/// id").
|
|
Future<void> reproducirPorMediaId(
|
|
String id, {
|
|
required FuenteEmisorasAuto fuente,
|
|
required Future<void> Function(MediaItem) reproducir,
|
|
}) async {
|
|
if (!id.startsWith(_prefijoEmisora)) return;
|
|
final uuid = id.substring(_prefijoEmisora.length);
|
|
if (uuid.isEmpty) return;
|
|
|
|
final emisora = await fuente.porUuid(uuid);
|
|
if (emisora == null) return;
|
|
|
|
final item = MediaItem(
|
|
id: emisora.url,
|
|
title: emisora.nombre,
|
|
artist: emisora.pais ?? '',
|
|
album: 'PluriWave',
|
|
artUri:
|
|
emisora.favicon != null && emisora.favicon!.isNotEmpty
|
|
? Uri.tryParse(emisora.favicon!)
|
|
: null,
|
|
extras: {'uuid': emisora.uuid},
|
|
);
|
|
await reproducir(item);
|
|
}
|
|
|
|
/// Fallback title (Design "Title = filename minus extension") for a blank
|
|
/// or otherwise empty-after-stripping local filename — hardcoded Spanish,
|
|
/// matching every other car-tree label in this file (`'Favoritos'`,
|
|
/// `'Ecualizador'`, etc.), none of which go through `AppLocalizations`.
|
|
const _tituloLocalFallback = 'Pista sin nombre';
|
|
|
|
/// Filename → display title (Design "Title = filename minus extension"):
|
|
/// strips the LAST `.ext` (the whole trimmed name is kept when there is no
|
|
/// dot, or the dot is the first character — e.g. a hidden file like
|
|
/// `.mp3`), falling back to [_tituloLocalFallback] when the result would be
|
|
/// blank.
|
|
String _tituloDesdeNombre(String nombre) {
|
|
final recortado = nombre.trim();
|
|
if (recortado.isEmpty) return _tituloLocalFallback;
|
|
final ultimoPunto = recortado.lastIndexOf('.');
|
|
final sinExtension =
|
|
ultimoPunto > 0 ? recortado.substring(0, ultimoPunto) : recortado;
|
|
final resultado = sinExtension.trim();
|
|
return resultado.isEmpty ? _tituloLocalFallback : resultado;
|
|
}
|
|
|
|
/// Resolves the on-brand fallback `artUri` for a local track (Design "art =
|
|
/// reused station_art_* rotation"): reuses the EXACT rotation
|
|
/// ([indiceArtePara]/`_nombresArte`) [artUriPara] uses for stations, seeded
|
|
/// by [documentId] instead of a station uuid — zero new assets, same
|
|
/// deterministic per-item mapping.
|
|
String artUriLocal(String documentId) =>
|
|
'android.resource://es.freetimelab.pluriwave/drawable/'
|
|
'station_art_${_nombresArte[indiceArtePara(documentId)]}';
|
|
|
|
/// Local-music `getChildren` dispatch (Design "Data Flow"): resolves
|
|
/// [parentMediaId] against the `musica_local` root (`fuente.hijos('')`) or a
|
|
/// `carpeta_local:<id>` subfolder (`fuente.hijos(id)`), mapping the result
|
|
/// through [ConstructorArbolAuto.itemsLocales]. Returns `null` when
|
|
/// [parentMediaId] matches NEITHER shape, so the caller
|
|
/// (`ServicioAudio.getChildren`) can fall through to its other branches
|
|
/// unmodified. A `null` [fuente] (local source never registered — headless
|
|
/// cold bind) or any thrown error degrades to `[]`, never a crash (Design
|
|
/// "cold-start safe", mirrors `FuenteEmisorasAutoLocal`'s pattern; Spec
|
|
/// "Browse requested before app state is loaded" / "Permission revoked or
|
|
/// never granted").
|
|
Future<List<MediaItem>?> hijosMusicaLocal(
|
|
String parentMediaId, {
|
|
required FuenteMusicaLocalAuto? fuente,
|
|
}) async {
|
|
final constructor = ConstructorArbolAuto();
|
|
final String documentId;
|
|
var pagina = 0;
|
|
if (parentMediaId == ConstructorArbolAuto.idMusicaLocal) {
|
|
documentId = '';
|
|
} else if (constructor.esCarpetaLocalPaginadaMediaId(parentMediaId)) {
|
|
final resuelto = constructor.paginaCarpetaLocalDesde(parentMediaId);
|
|
documentId = resuelto.$1;
|
|
pagina = resuelto.$2;
|
|
} else if (constructor.esCarpetaLocalMediaId(parentMediaId)) {
|
|
documentId = constructor.idCarpetaLocalDesde(parentMediaId);
|
|
} else {
|
|
return null;
|
|
}
|
|
if (fuente == null) return const [];
|
|
try {
|
|
final nodos = await fuente.hijos(documentId);
|
|
return constructor.itemsLocales(
|
|
nodos,
|
|
documentIdPadre: documentId,
|
|
pagina: pagina,
|
|
);
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
/// Best-effort title for a played local track (Design "Local Track Playback
|
|
/// Reuses Existing Pipeline"): `FuenteMusicaLocalAuto.uriContenidoDePista`
|
|
/// only returns a content URI, not the original filename (Design's
|
|
/// Interfaces/Contracts — no metadata fields in Phase 1), so this derives a
|
|
/// title from the trailing path segment of the SAF [documentId] itself
|
|
/// (`primary:Music/Local/song.mp3` → `song.mp3` → title-stripped), applying
|
|
/// the SAME [_tituloDesdeNombre] rule the browse tree uses. This keeps the
|
|
/// Now Playing title consistent with what the user tapped without requiring
|
|
/// a second native round trip.
|
|
String _tituloDesdeDocumentId(String documentId) {
|
|
final ultimaBarra = documentId.lastIndexOf('/');
|
|
final segmento =
|
|
ultimaBarra >= 0 ? documentId.substring(ultimaBarra + 1) : documentId;
|
|
return _tituloDesdeNombre(segmento);
|
|
}
|
|
|
|
/// Routing seam between a car-tapped `pista:<docId>` media id and the
|
|
/// existing playback pipeline (Design "Local Track Playback Reuses Existing
|
|
/// Pipeline" — same seam shape as [reproducirPorMediaId], Spec "User selects
|
|
/// a local track"). Resolves the content URI via [fuente], builds a
|
|
/// `MediaItem` and delegates to [reproducir] — the SAME injection point
|
|
/// stations use, so the shared EQ signal chain applies identically (Spec
|
|
/// "EQ still applies to local track playback", regression guard: no
|
|
/// separate/bypassed path exists here).
|
|
///
|
|
/// A stale/unknown [id] (or a malformed one) is a no-op: [reproducir] is
|
|
/// never called and no exception propagates (Spec "Unknown or stale track
|
|
/// id").
|
|
Future<void> reproducirPistaLocal(
|
|
String id, {
|
|
required FuenteMusicaLocalAuto fuente,
|
|
required Future<void> Function(MediaItem) reproducir,
|
|
}) async {
|
|
if (!esPistaMediaId(id)) return;
|
|
final documentId = id.substring(_prefijoPista.length);
|
|
if (documentId.isEmpty) return;
|
|
|
|
final contentUri = await fuente.uriContenidoDePista(documentId);
|
|
if (contentUri == null || contentUri.isEmpty) return;
|
|
|
|
final pista = PistaLocal(
|
|
documentId: documentId,
|
|
titulo: _tituloDesdeDocumentId(documentId),
|
|
contentUri: contentUri,
|
|
);
|
|
|
|
final item = MediaItem(
|
|
id: pista.contentUri,
|
|
title: pista.titulo,
|
|
album: 'PluriWave',
|
|
extras: {'documentId': pista.documentId},
|
|
);
|
|
await reproducir(item);
|
|
}
|
|
|
|
/// Resolves an `eq_preset:<nombre>` [id] to the matching [PresetEcualizador]
|
|
/// in [presets] by exact name (Design ADR-1, mirrors
|
|
/// [ConstructorArbolAuto.resolver]'s shape). Any other shape (no prefix,
|
|
/// empty name, unmatched name) returns `null` instead of throwing (Spec
|
|
/// "Unknown or stale preset id").
|
|
PresetEcualizador? resolverPresetEq(String id, List<PresetEcualizador> presets) {
|
|
if (!esPresetMediaId(id)) return null;
|
|
final nombre = id.substring(_prefijoPresetEq.length);
|
|
if (nombre.isEmpty) return null;
|
|
for (final preset in presets) {
|
|
if (preset.nombre == nombre) return preset;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Pure per-station apply gate (Design ADR-5), mirroring
|
|
/// `EstadoEcualizador.cambiarPresetPrincipal`'s exact logic
|
|
/// (`estado_ecualizador.dart:302-304`): the new principal preset is applied
|
|
/// live when there is no current station ([uuidActual] is `null`) or the
|
|
/// current station has no per-station preset override in
|
|
/// [clavesPorEmisora].
|
|
bool debeAplicarPrincipalAhora({
|
|
required String? uuidActual,
|
|
required Set<String> clavesPorEmisora,
|
|
}) => uuidActual == null || !clavesPorEmisora.contains(uuidActual);
|
|
|
|
/// Orchestrates an `eq_preset:<nombre>` selection from the car (Design
|
|
/// "Data flow — a preset tap", ADR-3): resolves [id] via [resolverPresetEq],
|
|
/// persists it as principal via [persistirPrincipal], and conditionally
|
|
/// applies it live via [aplicar] when [debeAplicarPrincipalAhora] allows it.
|
|
///
|
|
/// This function's signature exposes ONLY the EQ persist/apply seams — it
|
|
/// has NO parameter for `playMediaItem`, `mediaItem`, or `playbackState`, so
|
|
/// there is no code path from a preset tap to playback (Design ADR-3,
|
|
/// non-playback invariant enforced structurally, not by discipline). An
|
|
/// unknown/stale [id] is a no-op: neither seam is invoked and no exception
|
|
/// propagates (Spec "Unknown or stale preset id").
|
|
Future<void> aplicarPresetPorMediaId(
|
|
String id, {
|
|
required List<PresetEcualizador> presets,
|
|
required String? uuidActual,
|
|
required Future<Set<String>> Function() clavesPorEmisora,
|
|
required Future<void> Function(PresetEcualizador) persistirPrincipal,
|
|
required Future<void> Function(PresetEcualizador) aplicar,
|
|
}) async {
|
|
final preset = resolverPresetEq(id, presets);
|
|
if (preset == null) return;
|
|
await persistirPrincipal(preset);
|
|
if (debeAplicarPrincipalAhora(
|
|
uuidActual: uuidActual,
|
|
clavesPorEmisora: await clavesPorEmisora(),
|
|
)) {
|
|
await aplicar(preset);
|
|
}
|
|
}
|
|
|
|
/// 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';
|
|
}
|
|
}
|