Phase 1: pick a device folder via SAF (persisted grant, no new permission), browse its nested subfolders/tracks as a 5th Android Auto root folder (hidden until configured), and play tracks through the existing pipeline (EQ, art rotation, cold-start-safe source). No metadata/sort/filter/shuffle yet -- filename is the title, generic rotating art is the placeholder; deferred to a follow-up phase. Adds a new pluriwave/file_actions native method (listAudioChildren) and an onActivityResult override in MainActivity for the SAF folder picker -- both static-review-only, no Android build available here.
694 lines
29 KiB
Dart
694 lines
29 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
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';
|
|
|
|
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:';
|
|
|
|
/// 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);
|
|
|
|
/// Maps native [NodoLocal]s to browse-tree `MediaItem`s (Design "Lazy
|
|
/// per-folder enumeration" + "Dedicated 50-item cap, alphabetical
|
|
/// truncation"): sorted alphabetically by [NodoLocal.nombre] and capped at
|
|
/// [_maxItemsCarpetaLocal]. Folders map to non-playable
|
|
/// `carpeta_local:<id>` items with their raw name; files map to playable
|
|
/// `pista:<id>` items with the extension stripped from the title (Design
|
|
/// "Title = filename minus extension") and a rotating on-brand `artUri`
|
|
/// (Design "art = reused station_art_* rotation"). An empty [nodos]
|
|
/// returns `[]`, never an error (Spec "browsing an empty subfolder").
|
|
List<MediaItem> itemsLocales(List<NodoLocal> nodos) {
|
|
final ordenados = [...nodos]..sort((a, b) => a.nombre.compareTo(b.nombre));
|
|
return ordenados.take(_maxItemsCarpetaLocal).map(_itemLocal).toList();
|
|
}
|
|
|
|
MediaItem _itemLocal(NodoLocal nodo) {
|
|
if (nodo.esDirectorio) {
|
|
return _carpeta('$_prefijoCarpetaLocal${nodo.documentId}', nodo.nombre);
|
|
}
|
|
return MediaItem(
|
|
id: '$_prefijoPista${nodo.documentId}',
|
|
title: _tituloDesdeNombre(nodo.nombre),
|
|
playable: true,
|
|
artUri: Uri.parse(artUriLocal(nodo.documentId)),
|
|
extras: _contentStyleGrid,
|
|
);
|
|
}
|
|
|
|
/// Maps a [PresetEcualizador] to a playable `MediaItem` with id
|
|
/// `eq_preset:<nombre>` (Design ADR-1).
|
|
MediaItem itemPresetEq(PresetEcualizador preset) => MediaItem(
|
|
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;
|
|
if (parentMediaId == ConstructorArbolAuto.idMusicaLocal) {
|
|
documentId = '';
|
|
} else if (constructor.esCarpetaLocalMediaId(parentMediaId)) {
|
|
documentId = constructor.idCarpetaLocalDesde(parentMediaId);
|
|
} else {
|
|
return null;
|
|
}
|
|
if (fuente == null) return const [];
|
|
try {
|
|
final nodos = await fuente.hijos(documentId);
|
|
return constructor.itemsLocales(nodos);
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
/// Best-effort title for a played local track (Design "Local Track Playback
|
|
/// Reuses Existing Pipeline"): `FuenteMusicaLocalAuto.uriContenidoDePista`
|
|
/// only returns a content URI, not the original filename (Design's
|
|
/// Interfaces/Contracts — no metadata fields in Phase 1), so this derives a
|
|
/// title from the trailing path segment of the SAF [documentId] itself
|
|
/// (`primary:Music/Local/song.mp3` → `song.mp3` → title-stripped), applying
|
|
/// the SAME [_tituloDesdeNombre] rule the browse tree uses. This keeps the
|
|
/// Now Playing title consistent with what the user tapped without requiring
|
|
/// a second native round trip.
|
|
String _tituloDesdeDocumentId(String documentId) {
|
|
final ultimaBarra = documentId.lastIndexOf('/');
|
|
final segmento =
|
|
ultimaBarra >= 0 ? documentId.substring(ultimaBarra + 1) : documentId;
|
|
return _tituloDesdeNombre(segmento);
|
|
}
|
|
|
|
/// Routing seam between a car-tapped `pista:<docId>` media id and the
|
|
/// existing playback pipeline (Design "Local Track Playback Reuses Existing
|
|
/// Pipeline" — same seam shape as [reproducirPorMediaId], Spec "User selects
|
|
/// a local track"). Resolves the content URI via [fuente], builds a
|
|
/// `MediaItem` and delegates to [reproducir] — the SAME injection point
|
|
/// stations use, so the shared EQ signal chain applies identically (Spec
|
|
/// "EQ still applies to local track playback", regression guard: no
|
|
/// separate/bypassed path exists here).
|
|
///
|
|
/// A stale/unknown [id] (or a malformed one) is a no-op: [reproducir] is
|
|
/// never called and no exception propagates (Spec "Unknown or stale track
|
|
/// id").
|
|
Future<void> reproducirPistaLocal(
|
|
String id, {
|
|
required FuenteMusicaLocalAuto fuente,
|
|
required Future<void> Function(MediaItem) reproducir,
|
|
}) async {
|
|
if (!esPistaMediaId(id)) return;
|
|
final documentId = id.substring(_prefijoPista.length);
|
|
if (documentId.isEmpty) return;
|
|
|
|
final contentUri = await fuente.uriContenidoDePista(documentId);
|
|
if (contentUri == null || contentUri.isEmpty) return;
|
|
|
|
final pista = PistaLocal(
|
|
documentId: documentId,
|
|
titulo: _tituloDesdeDocumentId(documentId),
|
|
contentUri: contentUri,
|
|
);
|
|
|
|
final item = MediaItem(
|
|
id: pista.contentUri,
|
|
title: pista.titulo,
|
|
album: 'PluriWave',
|
|
extras: {'documentId': pista.documentId},
|
|
);
|
|
await reproducir(item);
|
|
}
|
|
|
|
/// Resolves an `eq_preset:<nombre>` [id] to the matching [PresetEcualizador]
|
|
/// in [presets] by exact name (Design ADR-1, mirrors
|
|
/// [ConstructorArbolAuto.resolver]'s shape). Any other shape (no prefix,
|
|
/// 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';
|
|
}
|
|
}
|