Four car reports, two root causes.
1. Local music vanished from the Android Auto menu. Self-inflicted, by
c1afe72 yesterday.
That commit moved registrarFuenteNavegacion above every await to keep a
headless engine from dying before it ran -- but left
registrarFuenteMusicaLocal below `await SharedPreferences.getInstance()`.
The root menu decides whether to offer "Música Local" with
`fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`, so
the car could now get a root response in the window between the two
registrations, find a null source, and be told there is no local music.
Android Auto caches the browse root, so it stayed missing for the whole
session. Before the reorder both registrations sat together after the
await and the window did not exist.
FuenteMusicaLocalAutoImpl never needed prefs to be CONSTRUCTED -- it
resolves them lazily per call, the same convention ServicioAlarmas uses
-- so it now registers beside the station source, above every await, and
the window is gone rather than narrowed.
2. PluriWave disappeared from the Auto pane mid-drive, the playback
screen sat frozen, and the equalizer was lost on every navigation
prompt. One cause for all three.
androidWillPauseWhenDucked: true made audio_session translate Android's
AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK into a full PAUSE. In a car that
fires constantly: every navigation instruction, every speed-camera
warning, every voice assistant. And a pause publishes playing:false,
which AudioService.setState turns into exitPlayingState() and, with
androidStopForegroundOnPause: true, into stopForeground(...). The
plugin's own doc for that flag says what follows: "while in this lower
priority state, the operating system will also be able to kill your
service at any time to reclaim resources". A killed service is a media
session that vanishes from the car pane -- and another media app takes
the slot.
Now the app ducks instead of pausing, so playing stays true and session,
notification and pane all survive an interruption; and the service stays
foreground even on a real pause, so a genuine one is not a death
sentence either. androidNotificationOngoing goes to false because the
plugin asserts it implies stopForegroundOnPause, and nothing is lost: a
foreground service already forces the notification to be ongoing.
A real, non-duckable focus loss (a phone call) still pauses and still
auto-resumes -- asserted, so the duck change cannot silently turn a call
into a station playing over it.
3. Previous/next on the car playback screen, for stations too.
skipToPrevious/skipToNext are now advertised unconditionally, since
Android Auto only draws those buttons when the app declares support.
They are no longer inert without a local queue: they walk the narrowest
list the current station belongs to -- favourites, then my stations,
then the catalogue -- wrapping at both ends, because a button that goes
dead at the end of a list reads as broken on a screen with no visible
list position. Matching is by uuid so a refreshed snapshot still
resolves, and a station in no list leaves playback untouched.
The equalizer toggle still fits alongside them: prev/next take their two
reserved slots and the equalizer claims the remaining custom-action room
because construirControlesTransporte places it before MediaControl.stop.
The phone notification is deliberately untouched: `controls` still gates
skip on an active queue, so nativeActions and
androidCompactActionIndices are byte-identical. Only systemActions
changed, and only the car reads those.
Tests: 1146 -> 1158.
1696 lines
74 KiB
Dart
1696 lines
74 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math' show Random;
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import '../modelos/emisora.dart';
|
|
import '../modelos/grupo_favoritos.dart';
|
|
import '../modelos/pista_local.dart';
|
|
import '../modelos/preset_ecualizador.dart';
|
|
import 'musica_local_auto.dart';
|
|
import 'persistencia_tolerante.dart';
|
|
import 'servicio_favoritos.dart';
|
|
|
|
/// Generic page slice over [items] (Design ADR-6): returns at most [tamano]
|
|
/// elements starting at `pagina * tamano`. Reusable across any list type —
|
|
/// no [NodoLocal] coupling — so a future paged folder type can reuse the
|
|
/// slice arithmetic directly. An empty [items] or a [pagina] beyond the
|
|
/// list's range returns `[]`, never throws.
|
|
List<T> paginaDe<T>(
|
|
List<T> items, {
|
|
required int pagina,
|
|
required int tamano,
|
|
}) => items.skip(pagina * tamano).take(tamano).toList();
|
|
|
|
/// Whether a page after [pagina] exists for a list of [total] elements
|
|
/// (Design ADR-6): `true` iff at least one element remains beyond the
|
|
/// current page's slice. The exact-boundary case
|
|
/// (`total == (pagina + 1) * tamano`) is `false` — nothing remains to
|
|
/// reveal.
|
|
bool hayPaginaSiguiente(
|
|
int total, {
|
|
required int pagina,
|
|
required int tamano,
|
|
}) => total > (pagina + 1) * tamano;
|
|
|
|
/// Browse-tree ordering comparator for a local-music folder's children
|
|
/// (Design "Directories before files", item 1): directories sort before
|
|
/// files regardless of name, and within each group, alphabetically by
|
|
/// [NodoLocal.nombre] -- the standard file-browser convention. Fixes a
|
|
/// driver-facing bug where a folder's subfolders could land on a later
|
|
/// "Más…" page whenever enough tracks sorted alphabetically ahead of them
|
|
/// (e.g. a "Live" subfolder behind 80 numbered tracks), making the
|
|
/// subfolder unreachable without paging through every track first.
|
|
int compararNodoLocalParaNavegacion(NodoLocal a, NodoLocal b) {
|
|
if (a.esDirectorio != b.esDirectorio) {
|
|
return a.esDirectorio ? -1 : 1;
|
|
}
|
|
return a.nombre.compareTo(b.nombre);
|
|
}
|
|
|
|
const _prefijoEmisora = 'emisora:';
|
|
|
|
/// Local-track media-id prefix (Design "media-id scheme"), collision-free
|
|
/// against [_prefijoEmisora], `grupo:` and the bare folder id constants.
|
|
/// Top-level (not a [ConstructorArbolAuto] member) — used directly from
|
|
/// `playFromMediaId`'s dispatch in `servicio_audio.dart`.
|
|
const _prefijoPista = 'pista:';
|
|
|
|
/// Whether [id] identifies a local-track playable leaf item (Design
|
|
/// "media-id scheme").
|
|
bool esPistaMediaId(String id) => id.startsWith(_prefijoPista);
|
|
|
|
/// Canonical on-brand fallback-art names and rotation order, ported
|
|
/// **verbatim** (same formula, same order) from
|
|
/// `lib/widgets/tarjeta_emisora.dart`'s `_fallbackArtFor` (lines 363-367)
|
|
/// to guarantee phone/car per-station art parity (Design "Fallback-art
|
|
/// selection"). Keep this list in sync with that one — there is no
|
|
/// structural enforcement of order, only this comment and the parity test
|
|
/// in `navegacion_auto_test.dart` (group `parity: phone/auto art order`).
|
|
const _nombresArte = ['aurora', 'cosmic', 'pulse', 'nova'];
|
|
|
|
/// Returns whether [favicon] is usable as a remote `artUri` (Design
|
|
/// Decision "Case B detection" — static validity gate, zero network):
|
|
/// non-null, non-blank after trimming, and parses as an absolute
|
|
/// `http`/`https` URI with a non-empty authority. Does **not** probe
|
|
/// reachability — the OS art loader fetches `artUri` independently and
|
|
/// later, so a build-time network check would be racy (TOCTOU); this only
|
|
/// catches the deterministic malformed/non-http(s) subset (bare hosts,
|
|
/// wrong scheme, `http://` with no authority, whitespace, unparseable).
|
|
bool faviconUsable(String? favicon) {
|
|
final trimmed = favicon?.trim();
|
|
if (trimmed == null || trimmed.isEmpty) return false;
|
|
final uri = Uri.tryParse(trimmed);
|
|
if (uri == null) return false;
|
|
// `Uri.hasAuthority` is true whenever a `//` authority slot is present,
|
|
// even with an empty host (e.g. `Uri.parse('http://').hasAuthority` is
|
|
// `true`) — check `host.isNotEmpty` explicitly to actually require a
|
|
// non-empty authority host.
|
|
return (uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty;
|
|
}
|
|
|
|
/// Deterministic rotation index over the 4 on-brand fallback arts, same
|
|
/// formula as `tarjeta_emisora.dart`'s `_fallbackArtFor` (Design
|
|
/// "Fallback-art selection — port verbatim"): `seed` is the station uuid.
|
|
int indiceArtePara(String seed) =>
|
|
seed.codeUnits.fold<int>(0, (a, b) => a + b) % _nombresArte.length;
|
|
|
|
/// Resolves the `artUri` for [e] (Design "Data Flow"): the favicon when it
|
|
/// passes [faviconUsable], otherwise a rotating `station_art_<name>`
|
|
/// drawable URI selected via [indiceArtePara] over `e.uuid` — the same
|
|
/// on-brand art the phone UI would pick for this station (per-station
|
|
/// parity), never a launcher-icon lookalike.
|
|
String artUriPara(Emisora e) =>
|
|
faviconUsable(e.favicon)
|
|
? e.favicon!
|
|
: 'android.resource://es.freetimelab.pluriwave/drawable/'
|
|
'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
|
|
|
|
/// Formats a human-readable audio-quality hint for the browse row's
|
|
/// `displaySubtitle` (Design Decision "`displaySubtitle` quality format"):
|
|
/// `"<bitrate> kbps · <CODEC>"` when both are known, just the bitrate or
|
|
/// just the codec when only one is known, and `null` (never `""`, never a
|
|
/// string containing the literal `"null"`) when both are unknown. Codec is
|
|
/// trimmed and upper-cased; blank-after-trim counts as unknown. `bitrate`
|
|
/// `<= 0` counts as unknown (Radio Browser stores `0` for unknown).
|
|
String? subtituloCalidad(Emisora e) {
|
|
final codec = e.codec?.trim();
|
|
final codecConocido = codec != null && codec.isNotEmpty;
|
|
final bitrate = e.bitrate;
|
|
final bitrateConocido = bitrate != null && bitrate > 0;
|
|
|
|
if (codecConocido && bitrateConocido) {
|
|
return '$bitrate kbps · ${codec.toUpperCase()}';
|
|
}
|
|
if (bitrateConocido) return '$bitrate kbps';
|
|
if (codecConocido) return codec.toUpperCase();
|
|
return null;
|
|
}
|
|
|
|
/// Formats a human-readable quality hint for a local track's
|
|
/// `displaySubtitle` (Design ADR-5 "unknown → omit" discipline, reused for
|
|
/// local tracks): `"<bitrate> kbps · <sampleRate> kHz"` when both are
|
|
/// known, just the bitrate or just the sample rate when only one is known,
|
|
/// and `null` (never `""`, never a string containing the literal `"null"`)
|
|
/// when [metadatos] is `null` or both fields are unknown. `bitrate` is
|
|
/// converted from bits/sec to kbps (rounded); `sampleRate` from Hz to kHz,
|
|
/// trimmed of a trailing `.0`.
|
|
String? subtituloCalidadLocal(MetadatosPista? metadatos) {
|
|
if (metadatos == null) return null;
|
|
final bitrate = metadatos.bitrate;
|
|
final bitrateConocido = bitrate != null && bitrate > 0;
|
|
final sampleRate = metadatos.sampleRate;
|
|
final sampleRateConocido = sampleRate != null && sampleRate > 0;
|
|
|
|
if (bitrateConocido && sampleRateConocido) {
|
|
return '${(bitrate / 1000).round()} kbps · ${_formatKhz(sampleRate)} kHz';
|
|
}
|
|
if (bitrateConocido) return '${(bitrate / 1000).round()} kbps';
|
|
if (sampleRateConocido) return '${_formatKhz(sampleRate)} kHz';
|
|
return null;
|
|
}
|
|
|
|
/// Formats [sampleRateHz] (in Hz) as a trimmed kHz string: `44100` ->
|
|
/// `'44.1'`, `48000` -> `'48'` — never a trailing `.0` or extra zeros.
|
|
String _formatKhz(int sampleRateHz) {
|
|
var texto = (sampleRateHz / 1000).toStringAsFixed(2);
|
|
while (texto.endsWith('0')) {
|
|
texto = texto.substring(0, texto.length - 1);
|
|
}
|
|
if (texto.endsWith('.')) {
|
|
texto = texto.substring(0, texto.length - 1);
|
|
}
|
|
return texto;
|
|
}
|
|
|
|
/// Browse-source abstraction for the Android Auto media tree (Design
|
|
/// "getChildren data source, cold-start safe"). Kept separate from
|
|
/// `EstadoRadio` so a headless Auto bind (`main()` runs but the widget tree
|
|
/// — and therefore the lazily-created `EstadoRadio` — never builds) still
|
|
/// gets a valid, non-throwing tree.
|
|
abstract class FuenteEmisorasAuto {
|
|
Future<List<Emisora>> favoritos();
|
|
Future<List<Emisora>> misEmisoras();
|
|
|
|
/// `populares` snapshot; may be empty on a cold bind (Design "which
|
|
/// stations surface & ordering").
|
|
Future<List<Emisora>> todas();
|
|
Future<Emisora?> porUuid(String uuid);
|
|
|
|
/// Favorite groups (`GrupoFavoritos`), cold-start safe — mirrors
|
|
/// [favoritos]'s never-throws contract (Design "Favorite Group
|
|
/// Sub-Folders").
|
|
Future<List<GrupoFavoritos>> grupos();
|
|
|
|
/// Live-snapshot push (Design "live snapshot the source prefers"):
|
|
/// `EstadoRadio`, when alive, calls this unconditionally on every
|
|
/// favorites/custom/populares mutation so a car and phone that are both
|
|
/// live see identical lists. Default no-op — only implementations that
|
|
/// actually buffer a snapshot (e.g. [FuenteEmisorasAutoLocal]) need to
|
|
/// override it; a null field on `EstadoRadio` skips the call entirely via
|
|
/// `?.`.
|
|
void actualizarSnapshot({
|
|
List<Emisora>? favoritos,
|
|
List<Emisora>? misEmisoras,
|
|
List<Emisora>? todas,
|
|
List<GrupoFavoritos>? grupos,
|
|
}) {}
|
|
}
|
|
|
|
/// Pure builder for the Android Auto browse tree: folders, leaf items, id
|
|
/// resolution. No platform dependency — fully testable without a running
|
|
/// car or a real `AudioHandler`.
|
|
class ConstructorArbolAuto {
|
|
/// Root folder ids (Design "media-id scheme"). The tree root itself is
|
|
/// identified by [AudioService.browsableRootId], not by a constant here —
|
|
/// the handler compares against it directly before calling [raiz].
|
|
static const idFavoritos = 'favoritos';
|
|
static const idTodas = 'todas';
|
|
static const idMisEmisoras = 'mis_emisoras';
|
|
|
|
/// Root folder id for the local-music browsable root (Design "media-id
|
|
/// scheme"). Deliberately NOT added to [_idsCarpetas] — it has its own
|
|
/// dedicated branch (`hijosMusicaLocal`), not the generic station-list
|
|
/// [hijos] path. Hidden from [raiz] until a folder has been picked
|
|
/// (Design "Local root hidden until a folder is configured").
|
|
static const idMusicaLocal = 'musica_local';
|
|
|
|
/// Root folder id for the "Ecualizador" browsable folder (decision
|
|
/// `auto/ecualizador-diseno`): lists "Desactivar" plus the six factory
|
|
/// presets, the currently-active one marked. Deliberately NOT added to
|
|
/// [_idsCarpetas] -- like [idMusicaLocal], it has its own dedicated
|
|
/// children, built by `itemsEcualizadorAuto` in `servicio_audio.dart`
|
|
/// (which needs `AppLocalizations` -- this pure builder class does not
|
|
/// depend on it), not the generic station-list [hijos] path. Unlike
|
|
/// [idMusicaLocal], it is ALWAYS present in [raiz], never conditionally
|
|
/// hidden.
|
|
static const idEcualizador = 'ecualizador';
|
|
|
|
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
|
|
static const _maxItemsPorCarpeta = 50;
|
|
|
|
/// Favorite-group folder id prefix (Design "media-id scheme"), collision
|
|
/// free against [_prefijoEmisora] and the bare folder id constants above.
|
|
static const _prefijoGrupo = 'grupo:';
|
|
|
|
/// Local-music subfolder id prefix (Design "media-id scheme"),
|
|
/// collision-free against [_prefijoEmisora], [_prefijoGrupo],
|
|
/// [_prefijoPresetEq] and the bare folder id constants above.
|
|
static const _prefijoCarpetaLocal = 'carpeta_local:';
|
|
|
|
/// Paged "load more" local-music id prefix (Design ADR-1). Collision-free
|
|
/// against [_prefijoCarpetaLocal] and every other prefix/bare id in this
|
|
/// class: at the index where `carpeta_local:` has `:`, this prefix has
|
|
/// `_`, so neither ever matches the other's `startsWith` check — routing
|
|
/// order between [esCarpetaLocalPaginadaMediaId] and [esCarpetaLocalMediaId]
|
|
/// is therefore irrelevant to correctness.
|
|
static const _prefijoCarpetaLocalPaginada = 'carpeta_local_pag:';
|
|
|
|
/// Sort-mode local-music id prefix (Design ADR-4, Phase 2):
|
|
/// `carpeta_local_ord:<modo>:<pagina>:<docId>`. Collision-free against
|
|
/// every other prefix/bare id in this class — diverges from
|
|
/// [_prefijoCarpetaLocal] at index 13 (`_` vs `:`) and from
|
|
/// [_prefijoCarpetaLocalBucket]/[_prefijoCarpetaLocalPaginada] at the char
|
|
/// right after `carpeta_local_` (`o` vs `b`/`p`).
|
|
static const _prefijoCarpetaLocalOrd = 'carpeta_local_ord:';
|
|
|
|
/// Alphabetical-bucket local-music id prefix (Design ADR-4, Phase 2):
|
|
/// `carpeta_local_bucket:<idxBucket>:<pagina>:<docId>`. Collision-free
|
|
/// against every other prefix/bare id in this class (see
|
|
/// [_prefijoCarpetaLocalOrd]'s doc for the divergence proof).
|
|
static const _prefijoCarpetaLocalBucket = 'carpeta_local_bucket:';
|
|
|
|
/// "Reproducir carpeta" (sequential-play) action media-id prefix (Design
|
|
/// ADR-5, Phase 3): `carpeta_local_reproducir:<docId>`. PLAYABLE (unlike
|
|
/// every other `carpeta_local_*` prefix in this class, which are
|
|
/// non-playable browse folders) — routed through `playFromMediaId`, not
|
|
/// `getChildren`. Collision-free against every other prefix here:
|
|
/// diverges from [_prefijoCarpetaLocalPaginada]/[_prefijoCarpetaLocalOrd]/
|
|
/// [_prefijoCarpetaLocalBucket] at index 14 (`r` vs `p`/`o`/`b`), same
|
|
/// divergence-point family as those siblings' doc comments.
|
|
static const _prefijoCarpetaLocalReproducir = 'carpeta_local_reproducir:';
|
|
|
|
/// "Reproducir aleatorio" (shuffled-play) action media-id prefix (Design
|
|
/// ADR-5, Phase 3): `carpeta_local_aleatorio:<docId>`. PLAYABLE, mirrors
|
|
/// [_prefijoCarpetaLocalReproducir]. Diverges from every sibling prefix
|
|
/// at index 14 (`a` vs `r`/`p`/`o`/`b`).
|
|
static const _prefijoCarpetaLocalAleatorio = 'carpeta_local_aleatorio:';
|
|
|
|
/// Separate cap for favorite-group folders under `Favoritos` (Design
|
|
/// "group-folder ordering and cap"): a folder tap costs more driver
|
|
/// attention than a station scroll, so this is tunable independently of
|
|
/// [_maxItemsPorCarpeta].
|
|
static const _maxGruposPorFavoritos = 50;
|
|
|
|
/// Dedicated cap for local-music folders (Design "Dedicated 50-item cap,
|
|
/// alphabetical truncation"), tunable independently of
|
|
/// [_maxItemsPorCarpeta]/[_maxGruposPorFavoritos] — the extension point
|
|
/// for a future native page-offset parameter.
|
|
static const _maxItemsCarpetaLocal = 50;
|
|
|
|
/// Quality-sort track-count cap (Design ADR-3): above this, the
|
|
/// "Ordenar por calidad" entry is omitted instead of paying an unbounded
|
|
/// per-file `MediaMetadataRetriever` extraction cost — first-pass value,
|
|
/// not yet hardware-validated (Design "Open Questions").
|
|
static const _maxPistasParaOrdenCalidad = 150;
|
|
|
|
/// Bucket-eligibility track-count threshold (Design ADR-4): buckets add
|
|
/// no value for small folders, so they're only offered above this count.
|
|
static const _minPistasParaBuckets = 50;
|
|
|
|
/// Content-style extras (Design "content style", optional polish): list
|
|
/// (1) for the root's folders, grid (2) for playable station items.
|
|
static const _contentStyleLista = {
|
|
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 1,
|
|
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1,
|
|
};
|
|
static const _contentStyleGrid = {
|
|
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
|
|
};
|
|
|
|
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras,
|
|
/// optionally Música Local, Ecualizador), all non-playable.
|
|
///
|
|
/// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
|
|
/// There is NO `Ecualizador` folder. The car's only equalizer control is
|
|
/// the on/off custom action on the playback screen
|
|
/// (`controlesEcualizadorPersonalizados` in `servicio_audio.dart`), which
|
|
/// the driver reaches from all three player views without leaving them.
|
|
///
|
|
/// The folder existed briefly (`8423ccd`) because custom actions were
|
|
/// thought unable to convey enough state for a six-preset choice. Owner
|
|
/// decision after driving with it: a browsable preset list is more
|
|
/// interaction than a driver wants, and on/off is the only equalizer
|
|
/// control that belongs in a car. Preset selection stays on the phone.
|
|
/// This lands back on the redesign mockup's original rule ("sin carpeta de
|
|
/// ecualizador", turn t4 line 40), now for a road-tested reason rather than
|
|
/// an assumed one.
|
|
///
|
|
/// `Música Local` is OMITTED entirely (not just empty) unless
|
|
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
|
|
/// is configured") — the caller passes `fuente.hayCarpetaConfigurada()`,
|
|
/// keeping this builder itself synchronous and side-effect free.
|
|
List<MediaItem> raiz({required bool incluirMusicaLocal}) => [
|
|
_carpeta(idFavoritos, 'Favoritos'),
|
|
_carpeta(idTodas, 'Todas las emisoras'),
|
|
_carpeta(idMisEmisoras, 'Mis emisoras'),
|
|
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
|
|
];
|
|
|
|
MediaItem _carpeta(String id, String titulo) => MediaItem(
|
|
id: id,
|
|
title: titulo,
|
|
playable: false,
|
|
extras: _contentStyleLista,
|
|
);
|
|
|
|
/// Leaf items for [parentId], PRESERVING the incoming [emisoras] order and
|
|
/// capped at [_maxItemsPorCarpeta] (Design "which stations surface &
|
|
/// ordering" — avoids driver distraction and Auto list limits).
|
|
///
|
|
/// Fix `android-auto-orden`: this used to force
|
|
/// `ordenarEmisoras(emisoras, OrdenEmisoras.calidad)` unconditionally,
|
|
/// silently discarding whatever order the caller actually wanted —
|
|
/// Favoritos' manual drag-reorder order, or the global `ordenListas`
|
|
/// setting for Todas/Mis emisoras. Every caller (`EstadoRadio.
|
|
/// cargarFavoritos`/`cargarPopulares`/`_cargarEmisorasCustom`/
|
|
/// `cambiarOrdenListas`) now pushes an already-ordered snapshot, so this
|
|
/// only slices and maps — it must never re-sort. Unknown [parentId] (or
|
|
/// an empty [emisoras]) returns an empty list instead of throwing.
|
|
List<MediaItem> hijos(String parentId, {required List<Emisora> emisoras}) {
|
|
if (!_idsCarpetas.contains(parentId)) return const [];
|
|
if (emisoras.isEmpty) return const [];
|
|
return emisoras.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
|
}
|
|
|
|
/// Maps a single [Emisora] to a playable `MediaItem`: id `emisora:<uuid>`
|
|
/// (Design "media-id scheme"), title, on-brand-fallback-aware `artUri`
|
|
/// (Design "Case B detection" + "Fallback-art selection") and a
|
|
/// quality-hint `displaySubtitle` (Design "`displaySubtitle` quality
|
|
/// format") when codec/bitrate are known.
|
|
MediaItem itemEmisora(Emisora e) => MediaItem(
|
|
id: '$_prefijoEmisora${e.uuid}',
|
|
title: e.nombre,
|
|
playable: true,
|
|
artUri: Uri.parse(artUriPara(e)),
|
|
displaySubtitle: subtituloCalidad(e),
|
|
extras: _contentStyleGrid,
|
|
);
|
|
|
|
/// Resolves `emisora:<uuid>` ids to the matching [Emisora] in [universo].
|
|
/// Any other shape (no prefix, empty uuid, unmatched uuid) returns `null`
|
|
/// instead of throwing (Spec "Media Item Resolution by ID").
|
|
Emisora? resolver(String id, List<Emisora> universo) {
|
|
if (!id.startsWith(_prefijoEmisora)) return null;
|
|
final uuid = id.substring(_prefijoEmisora.length);
|
|
if (uuid.isEmpty) return null;
|
|
for (final emisora in universo) {
|
|
if (emisora.uuid == uuid) return emisora;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Whether [id] identifies a favorite-group folder (Design "media-id
|
|
/// scheme").
|
|
bool esCarpetaGrupo(String id) => id.startsWith(_prefijoGrupo);
|
|
|
|
/// Maps a [GrupoFavoritos] to a non-playable folder `MediaItem` with id
|
|
/// `grupo:<id>` (Design "media-id scheme").
|
|
MediaItem itemGrupo(GrupoFavoritos g) =>
|
|
_carpeta('$_prefijoGrupo${g.id}', g.nombre);
|
|
|
|
/// Whether [id] identifies a local-music subfolder (Design "media-id
|
|
/// scheme").
|
|
bool esCarpetaLocalMediaId(String id) => id.startsWith(_prefijoCarpetaLocal);
|
|
|
|
/// Strips the `carpeta_local:` prefix from [id] by length (Design "Prefix
|
|
/// stripped by length" — survives a raw SAF documentId containing `:`/`/`
|
|
/// verbatim). Only meaningful when [esCarpetaLocalMediaId] is `true`.
|
|
String idCarpetaLocalDesde(String id) =>
|
|
id.substring(_prefijoCarpetaLocal.length);
|
|
|
|
/// Whether [id] identifies a paged "load more" local-music request
|
|
/// (Design ADR-1).
|
|
bool esCarpetaLocalPaginadaMediaId(String id) =>
|
|
id.startsWith(_prefijoCarpetaLocalPaginada);
|
|
|
|
/// Parses a `carpeta_local_pag:<page>:<docId>` [id] into its
|
|
/// `(documentId, pagina)` pair (Design ADR-1): the prefix is stripped by
|
|
/// length, then the remainder is split on the FIRST `:` only —
|
|
/// everything left of it is the page integer (never contains a colon),
|
|
/// everything right of it (including any further colons/slashes) is the
|
|
/// raw SAF documentId verbatim, so a docId containing `:` or `/` survives
|
|
/// intact. Root paging is expressible: an empty documentId round-trips as
|
|
/// `carpeta_local_pag:<n>:` (empty tail). Only meaningful when
|
|
/// [esCarpetaLocalPaginadaMediaId] is `true`.
|
|
(String documentId, int pagina) paginaCarpetaLocalDesde(String id) {
|
|
final resto = id.substring(_prefijoCarpetaLocalPaginada.length);
|
|
final indice = resto.indexOf(':');
|
|
final pagina = int.parse(resto.substring(0, indice));
|
|
final documentId = resto.substring(indice + 1);
|
|
return (documentId, pagina);
|
|
}
|
|
|
|
/// Hardcoded-Spanish car-tree label for the trailing "load more" item
|
|
/// (Design ADR-5) — matches every other car-tree label in this file
|
|
/// (`'Favoritos'`, `'Música Local'`, [_tituloLocalFallback]), none of
|
|
/// which go through `AppLocalizations`. Deliberately NOT an arb key.
|
|
static const _tituloMasLocal = 'Más…';
|
|
|
|
/// The trailing "load more" `MediaItem` (Design ADR-5): non-playable, no
|
|
/// `artUri` (the label alone is the affordance, like [_carpeta]), id
|
|
/// `carpeta_local_pag:<siguientePagina>:<documentIdPadre>` — round-trips
|
|
/// via [paginaCarpetaLocalDesde] back to the parent folder's next page.
|
|
MediaItem _itemMasLocal(String documentIdPadre, int siguientePagina) =>
|
|
MediaItem(
|
|
id: '$_prefijoCarpetaLocalPaginada$siguientePagina:$documentIdPadre',
|
|
title: _tituloMasLocal,
|
|
playable: false,
|
|
extras: _contentStyleLista,
|
|
);
|
|
|
|
/// Maps native [NodoLocal]s to browse-tree `MediaItem`s, paged AND
|
|
/// metadata-backed (Design "Lazy per-folder enumeration" + ADR-3
|
|
/// pagination + Phase 2 "Data Flow"): the full [nodos] list is sorted
|
|
/// alphabetically by [NodoLocal.nombre] — cheap, no `MediaItem` built yet
|
|
/// — then sliced to [pagina] via [paginaDe] BEFORE any metadata is
|
|
/// resolved or `MediaItem` is constructed (Design "slice the cheap list,
|
|
/// then map — never map-then-slice", the memory-efficiency invariant).
|
|
/// [metadatosDe] is then awaited for ONLY the sliced page's non-directory
|
|
/// `documentId`s — never the whole folder — and only THEN is the page
|
|
/// mapped through [construirItem] with the resolved metadata map. A
|
|
/// trailing non-playable, browsable "Más…" item is appended whenever
|
|
/// [hayPaginaSiguiente] says more items remain beyond this page; selecting
|
|
/// it feeds back into [hijosMusicaLocal] to reveal the next page, so no
|
|
/// item is ever permanently unreachable (Spec "Local Music Folder Item Cap
|
|
/// and Paging"). An empty [nodos] (or a stale [pagina] beyond the
|
|
/// folder's range) returns `[]`, never an error (Spec "browsing an empty
|
|
/// subfolder").
|
|
///
|
|
/// [construirItem] is `@visibleForTesting` — injectable ONLY so a test
|
|
/// spy can assert the exact `min(tamano, remaining)` call-count invariant
|
|
/// (Design ADR-3); production callers never pass it.
|
|
Future<List<MediaItem>> itemsLocales(
|
|
List<NodoLocal> nodos, {
|
|
required String documentIdPadre,
|
|
required Future<Map<String, MetadatosPista>> Function(List<String>)
|
|
metadatosDe,
|
|
int pagina = 0,
|
|
int tamano = _maxItemsCarpetaLocal,
|
|
@visibleForTesting
|
|
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
|
|
// Item 2 (recursive folder play): optional so every pre-existing call
|
|
// site/test that has no need for the recursive gate keeps working
|
|
// unchanged. Only used on page 0, and only when [nodos] has zero
|
|
// DIRECT tracks (a direct track already makes the gate cheaply true
|
|
// without it) — see the `hayContenidoReproducible` computation below.
|
|
FuenteMusicaLocalAuto? fuente,
|
|
}) async {
|
|
final construir = construirItem ?? _itemLocal;
|
|
final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion);
|
|
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
|
|
final docIds =
|
|
paginaActual
|
|
.where((n) => !n.esDirectorio)
|
|
.map((n) => n.documentId)
|
|
.toList();
|
|
final metadatos = await metadatosDe(docIds);
|
|
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
|
|
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
|
|
items.add(_itemMasLocal(documentIdPadre, pagina + 1));
|
|
}
|
|
if (pagina == 0) {
|
|
final totalPistas = nodos.where((n) => !n.esDirectorio).length;
|
|
// Item 2: a folder plays everything beneath it, recursively -- so
|
|
// the play actions must be offered whenever the RECURSIVE count is
|
|
// > 0, not just the direct count. `totalPistas > 0` short-circuits
|
|
// the bounded recursive walk entirely for the common case (a direct
|
|
// track already answers the question); only a folder with ZERO
|
|
// direct tracks but at least one subfolder pays the recursive-check
|
|
// cost, and only up to [profundidadMaximaRecursivaLocal] levels.
|
|
final hayContenidoReproducible =
|
|
totalPistas > 0 ||
|
|
(fuente != null &&
|
|
await _haySubcarpetaConPistas(nodos, fuente: fuente));
|
|
final prepend = <MediaItem>[
|
|
// Folder-play actions (Design ADR-5, Phase 3; recursive gate item
|
|
// 2): prepended BEFORE the sort/bucket nav entries, present iff
|
|
// the folder has at least one playable track anywhere beneath it
|
|
// (direct or nested), absent for a folder that is genuinely empty
|
|
// even recursively (Spec "Folder has no tracks").
|
|
if (hayContenidoReproducible) _itemReproducirCarpeta(documentIdPadre),
|
|
if (hayContenidoReproducible) _itemReproducirAleatorio(documentIdPadre),
|
|
if (ofreceOrdenCalidad(totalPistas))
|
|
_itemModoOrdenCalidad(documentIdPadre),
|
|
if (ofreceBuckets(totalPistas))
|
|
for (var i = 0; i < _rangosBucket.length; i++)
|
|
_itemBucket(documentIdPadre, i, _rangosBucket[i].$1),
|
|
];
|
|
return [...prepend, ...items];
|
|
}
|
|
return items;
|
|
}
|
|
|
|
/// Whether at least one subfolder within [nodos] recursively contains a
|
|
/// playable track (Design "recursive folder play, gate", item 2): called
|
|
/// ONLY when the folder has zero DIRECT tracks (the caller already
|
|
/// checked that cheaply) — descends into each direct subfolder via
|
|
/// [pistasRecursivas] with `limite: 1`, stopping at the very first
|
|
/// match so a folder with an early hit costs as little as possible.
|
|
/// [nodos] is assumed already resolved by the caller (its own
|
|
/// `fuente.hijos(...)` result), so this folder's own children are never
|
|
/// re-fetched.
|
|
Future<bool> _haySubcarpetaConPistas(
|
|
List<NodoLocal> nodos, {
|
|
required FuenteMusicaLocalAuto fuente,
|
|
}) async {
|
|
for (final nodo in nodos) {
|
|
if (!nodo.esDirectorio) continue;
|
|
final encontradas = await pistasRecursivas(
|
|
nodo.documentId,
|
|
fuente: fuente,
|
|
profundidadMaxima: profundidadMaximaRecursivaLocal - 1,
|
|
limite: 1,
|
|
);
|
|
if (encontradas.isNotEmpty) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Whether the "Ordenar por calidad" mode entry should be offered for a
|
|
/// folder with [totalPistas] audio files (Design ADR-3): present for
|
|
/// `0 < totalPistas <= 150`, omitted otherwise (empty folder or above the
|
|
/// cap) instead of paying an unbounded metadata-extraction cost.
|
|
bool ofreceOrdenCalidad(int totalPistas) =>
|
|
totalPistas > 0 && totalPistas <= _maxPistasParaOrdenCalidad;
|
|
|
|
/// Whether alphabetical bucket entries should be offered for a folder
|
|
/// with [totalPistas] audio files (Design ADR-4): buckets add no value
|
|
/// for small folders.
|
|
bool ofreceBuckets(int totalPistas) => totalPistas > _minPistasParaBuckets;
|
|
|
|
/// The "Ordenar por calidad" mode-entry `MediaItem` (Design ADR-4):
|
|
/// non-playable, id `carpeta_local_ord:calidad:0:<documentIdPadre>` —
|
|
/// always page 0 of the sorted view, round-trips via [ordenLocalDesde].
|
|
/// Hardcoded Spanish label, matching every other car-tree label in this
|
|
/// file — never routed through `AppLocalizations` (established
|
|
/// car-tree-label precedent, see [_tituloMasLocal]).
|
|
MediaItem _itemModoOrdenCalidad(String documentIdPadre) => _carpeta(
|
|
'${_prefijoCarpetaLocalOrd}calidad:0:$documentIdPadre',
|
|
'Ordenar por calidad',
|
|
);
|
|
|
|
/// A single bucket-folder `MediaItem` (Design ADR-4): non-playable, id
|
|
/// `carpeta_local_bucket:<idx>:0:<documentIdPadre>` — always page 0,
|
|
/// round-trips via [bucketLocalDesde]. [etiqueta] is the hardcoded
|
|
/// alphabetical-range label (e.g. `'A-F'`), matching every other
|
|
/// car-tree label in this file — never routed through `AppLocalizations`.
|
|
MediaItem _itemBucket(String documentIdPadre, int idx, String etiqueta) =>
|
|
_carpeta('$_prefijoCarpetaLocalBucket$idx:0:$documentIdPadre', etiqueta);
|
|
|
|
/// The "Reproducir carpeta" playable action item (Design ADR-5): id
|
|
/// `carpeta_local_reproducir:<documentIdPadre>`. Hardcoded Spanish label,
|
|
/// matching every other car-tree label in this file — never routed
|
|
/// through `AppLocalizations`.
|
|
MediaItem _itemReproducirCarpeta(String documentIdPadre) => MediaItem(
|
|
id: '$_prefijoCarpetaLocalReproducir$documentIdPadre',
|
|
title: 'Reproducir carpeta',
|
|
playable: true,
|
|
extras: _contentStyleGrid,
|
|
);
|
|
|
|
/// The "Reproducir aleatorio" playable action item (Design ADR-5),
|
|
/// mirrors [_itemReproducirCarpeta].
|
|
MediaItem _itemReproducirAleatorio(String documentIdPadre) => MediaItem(
|
|
id: '$_prefijoCarpetaLocalAleatorio$documentIdPadre',
|
|
title: 'Reproducir aleatorio',
|
|
playable: true,
|
|
extras: _contentStyleGrid,
|
|
);
|
|
|
|
/// Whether [id] identifies a sort-mode local-music request (Design
|
|
/// ADR-4, Phase 2).
|
|
bool esCarpetaLocalOrdMediaId(String id) =>
|
|
id.startsWith(_prefijoCarpetaLocalOrd);
|
|
|
|
/// Whether [id] identifies an alphabetical-bucket local-music request
|
|
/// (Design ADR-4, Phase 2).
|
|
bool esCarpetaLocalBucketMediaId(String id) =>
|
|
id.startsWith(_prefijoCarpetaLocalBucket);
|
|
|
|
/// Whether [id] identifies the "Reproducir carpeta" sequential-play
|
|
/// folder action (Design ADR-5, Phase 3).
|
|
bool esCarpetaLocalReproducirMediaId(String id) =>
|
|
id.startsWith(_prefijoCarpetaLocalReproducir);
|
|
|
|
/// Whether [id] identifies the "Reproducir aleatorio" shuffled-play
|
|
/// folder action (Design ADR-5, Phase 3).
|
|
bool esCarpetaLocalAleatorioMediaId(String id) =>
|
|
id.startsWith(_prefijoCarpetaLocalAleatorio);
|
|
|
|
/// Strips the [_prefijoCarpetaLocalReproducir] prefix from [id] by length
|
|
/// (Design ADR-5 "strip prefix by length" — no split needed, the single
|
|
/// tail is the raw SAF documentId verbatim; an empty tail means the local
|
|
/// root). Only meaningful when [esCarpetaLocalReproducirMediaId] is
|
|
/// `true`.
|
|
String idCarpetaLocalReproducirDesde(String id) =>
|
|
id.substring(_prefijoCarpetaLocalReproducir.length);
|
|
|
|
/// Strips the [_prefijoCarpetaLocalAleatorio] prefix from [id] by length,
|
|
/// mirrors [idCarpetaLocalReproducirDesde]. Only meaningful when
|
|
/// [esCarpetaLocalAleatorioMediaId] is `true`.
|
|
String idCarpetaLocalAleatorioDesde(String id) =>
|
|
id.substring(_prefijoCarpetaLocalAleatorio.length);
|
|
|
|
/// Parses a `carpeta_local_ord:<modo>:<pagina>:<docId>` [id] into its
|
|
/// `(modo, documentId, pagina)` triple (Design ADR-4): the prefix is
|
|
/// stripped by length, then the remainder is split on the FIRST two `:`
|
|
/// only — `modo` never contains a colon, `pagina` never contains a
|
|
/// colon, and everything after the second `:` (including further
|
|
/// colons/slashes) is the raw SAF documentId verbatim, mirroring
|
|
/// [paginaCarpetaLocalDesde]'s split-on-first-colon chain extended by one
|
|
/// field. Only meaningful when [esCarpetaLocalOrdMediaId] is `true`.
|
|
(String modo, String documentId, int pagina) ordenLocalDesde(String id) {
|
|
final resto = id.substring(_prefijoCarpetaLocalOrd.length);
|
|
final primerColon = resto.indexOf(':');
|
|
final modo = resto.substring(0, primerColon);
|
|
final resto2 = resto.substring(primerColon + 1);
|
|
final segundoColon = resto2.indexOf(':');
|
|
final pagina = int.parse(resto2.substring(0, segundoColon));
|
|
final documentId = resto2.substring(segundoColon + 1);
|
|
return (modo, documentId, pagina);
|
|
}
|
|
|
|
/// Parses a `carpeta_local_bucket:<idxBucket>:<pagina>:<docId>` [id] into
|
|
/// its `(idxBucket, documentId, pagina)` triple (Design ADR-4), mirroring
|
|
/// [ordenLocalDesde]'s split-on-first-two-colons chain. Only meaningful
|
|
/// when [esCarpetaLocalBucketMediaId] is `true`.
|
|
(int idxBucket, String documentId, int pagina) bucketLocalDesde(String id) {
|
|
final resto = id.substring(_prefijoCarpetaLocalBucket.length);
|
|
final primerColon = resto.indexOf(':');
|
|
final idxBucket = int.parse(resto.substring(0, primerColon));
|
|
final resto2 = resto.substring(primerColon + 1);
|
|
final segundoColon = resto2.indexOf(':');
|
|
final pagina = int.parse(resto2.substring(0, segundoColon));
|
|
final documentId = resto2.substring(segundoColon + 1);
|
|
return (idxBucket, documentId, pagina);
|
|
}
|
|
|
|
/// The trailing "load more" item for the quality-sort view (Design ADR-4,
|
|
/// mirrors [_itemMasLocal]): id
|
|
/// `carpeta_local_ord:<modo>:<siguientePagina>:<documentIdPadre>`.
|
|
MediaItem _itemMasLocalOrd(
|
|
String documentIdPadre,
|
|
String modo,
|
|
int siguientePagina,
|
|
) => MediaItem(
|
|
id: '$_prefijoCarpetaLocalOrd$modo:$siguientePagina:$documentIdPadre',
|
|
title: _tituloMasLocal,
|
|
playable: false,
|
|
extras: _contentStyleLista,
|
|
);
|
|
|
|
/// The trailing "load more" item for a bucket view (Design ADR-4, mirrors
|
|
/// [_itemMasLocal]): id
|
|
/// `carpeta_local_bucket:<idx>:<siguientePagina>:<documentIdPadre>`.
|
|
MediaItem _itemMasLocalBucket(
|
|
String documentIdPadre,
|
|
int idxBucket,
|
|
int siguientePagina,
|
|
) => MediaItem(
|
|
id: '$_prefijoCarpetaLocalBucket$idxBucket:$siguientePagina:$documentIdPadre',
|
|
title: _tituloMasLocal,
|
|
playable: false,
|
|
extras: _contentStyleLista,
|
|
);
|
|
|
|
/// Quality-sort view (Design ADR-3, Data Flow): resolves metadata for
|
|
/// EVERY audio file in [nodos] via ONE batched [metadatosDe] call
|
|
/// (full-folder, NOT page-scoped — the sort key requires every track's
|
|
/// bitrate up front), sorts via [ordenarPorCalidadLocal], THEN applies
|
|
/// the existing [paginaDe] slicing. Directory nodes are excluded (Design
|
|
/// "quality sort applies to tracks only").
|
|
Future<List<MediaItem>> itemsLocalesOrdenCalidad(
|
|
List<NodoLocal> nodos, {
|
|
required String documentIdPadre,
|
|
required Future<Map<String, MetadatosPista>> Function(List<String>)
|
|
metadatosDe,
|
|
int pagina = 0,
|
|
int tamano = _maxItemsCarpetaLocal,
|
|
@visibleForTesting
|
|
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
|
|
}) async {
|
|
final construir = construirItem ?? _itemLocal;
|
|
final pistas = nodos.where((n) => !n.esDirectorio).toList();
|
|
final docIds = pistas.map((n) => n.documentId).toList();
|
|
final metadatos = await metadatosDe(docIds);
|
|
final ordenados = ordenarPorCalidadLocal(pistas, metadatos);
|
|
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
|
|
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
|
|
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
|
|
items.add(_itemMasLocalOrd(documentIdPadre, 'calidad', pagina + 1));
|
|
}
|
|
return items;
|
|
}
|
|
|
|
/// Alphabetical-bucket view (Design ADR-4, Data Flow): partitions
|
|
/// [nodos] via [bucketsDe] (name-only, cheap), selects [idxBucket], sorts
|
|
/// that bucket's tracks by name, slices to [pagina], then resolves
|
|
/// metadata ONLY for the sliced page's docIds (Design "only modo=calidad
|
|
/// pays the metadata cost" — bucket browsing stays page-scoped like the
|
|
/// default name-sort view). An out-of-range [idxBucket] returns `[]`,
|
|
/// never throws.
|
|
Future<List<MediaItem>> itemsLocalesBucket(
|
|
List<NodoLocal> nodos, {
|
|
required String documentIdPadre,
|
|
required int idxBucket,
|
|
required Future<Map<String, MetadatosPista>> Function(List<String>)
|
|
metadatosDe,
|
|
int pagina = 0,
|
|
int tamano = _maxItemsCarpetaLocal,
|
|
@visibleForTesting
|
|
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
|
|
}) async {
|
|
final buckets = bucketsDe(nodos);
|
|
if (idxBucket < 0 || idxBucket >= buckets.length) return const [];
|
|
final construir = construirItem ?? _itemLocal;
|
|
final ordenados = [...buckets[idxBucket].nodos]
|
|
..sort((a, b) => a.nombre.compareTo(b.nombre));
|
|
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
|
|
final docIds =
|
|
paginaActual
|
|
.where((n) => !n.esDirectorio)
|
|
.map((n) => n.documentId)
|
|
.toList();
|
|
final metadatos = await metadatosDe(docIds);
|
|
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
|
|
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
|
|
items.add(_itemMasLocalBucket(documentIdPadre, idxBucket, pagina + 1));
|
|
}
|
|
return items;
|
|
}
|
|
|
|
MediaItem _itemLocal(NodoLocal nodo, Map<String, MetadatosPista> metadatos) {
|
|
if (nodo.esDirectorio) {
|
|
return _carpeta('$_prefijoCarpetaLocal${nodo.documentId}', nodo.nombre);
|
|
}
|
|
final meta = metadatos[nodo.documentId];
|
|
final tituloMeta = meta?.titulo?.trim();
|
|
final titulo =
|
|
(tituloMeta != null && tituloMeta.isNotEmpty)
|
|
? tituloMeta
|
|
: _tituloDesdeNombre(nodo.nombre);
|
|
final artUriMeta = meta?.artUri?.trim();
|
|
final artUri =
|
|
(artUriMeta != null && artUriMeta.isNotEmpty)
|
|
? artUriMeta
|
|
: artUriLocal(nodo.documentId);
|
|
final artistaMeta = meta?.artista?.trim();
|
|
return MediaItem(
|
|
id: '$_prefijoPista${nodo.documentId}',
|
|
title: titulo,
|
|
artist:
|
|
(artistaMeta != null && artistaMeta.isNotEmpty) ? artistaMeta : null,
|
|
playable: true,
|
|
artUri: Uri.parse(artUri),
|
|
displaySubtitle: subtituloCalidadLocal(meta),
|
|
extras: _contentStyleGrid,
|
|
);
|
|
}
|
|
|
|
/// Children of the `Favoritos` folder (Design "Ungrouped favorites stay as
|
|
/// direct leaves at the Favoritos root"): non-empty custom-group folders
|
|
/// (phone order, capped at [_maxGruposPorFavoritos]), followed by
|
|
/// `sin_asignar` stations mapped through the existing [hijos] path so the
|
|
/// no-custom-groups case is byte-identical to the pre-groups tree
|
|
/// (regression guard — Spec "Ungrouped station appears exactly as
|
|
/// before"). Empty custom groups are omitted (Design "Empty groups hidden
|
|
/// from the car tree"); the `sin_asignar` pseudo-group is never rendered
|
|
/// as its own folder.
|
|
List<MediaItem> carpetasFavoritos({
|
|
required List<GrupoFavoritos> grupos,
|
|
required List<Emisora> favoritos,
|
|
}) {
|
|
final carpetas =
|
|
grupos
|
|
.where((g) => !g.esSinAsignar)
|
|
.where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
|
|
.take(_maxGruposPorFavoritos)
|
|
.map(itemGrupo)
|
|
.toList();
|
|
final sinAsignar =
|
|
favoritos
|
|
.where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
|
|
.toList();
|
|
return [...carpetas, ...hijos(idFavoritos, emisoras: sinAsignar)];
|
|
}
|
|
|
|
/// Members of the favorite group identified by [grupoMediaId] (a
|
|
/// `grupo:<id>` id), PRESERVING the incoming [favoritos] order (Favoritos'
|
|
/// manual order — see [hijos]' doc, fix `android-auto-orden`) and capped
|
|
/// like every other folder (Spec "Car requests a group folder's
|
|
/// stations"). An unknown/stale/malformed id returns an empty list
|
|
/// instead of throwing (Spec "Car requests an unknown or stale group
|
|
/// id").
|
|
List<MediaItem> hijosGrupo(
|
|
String grupoMediaId, {
|
|
required List<Emisora> favoritos,
|
|
}) {
|
|
if (!esCarpetaGrupo(grupoMediaId)) return const [];
|
|
final id = grupoMediaId.substring(_prefijoGrupo.length);
|
|
if (id.isEmpty) return const [];
|
|
final miembros = favoritos.where((e) => e.grupoFavoritosId == id).toList();
|
|
if (miembros.isEmpty) return const [];
|
|
return miembros.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
|
}
|
|
|
|
/// Equalizer preset-selection media-id prefix (decision
|
|
/// `auto/ecualizador-diseno`): `eq_preset:<rawPresetName>` for the six
|
|
/// factory presets, plus the reserved [_valorDesactivarEq] sentinel for
|
|
/// the "Desactivar" item ([idDesactivarEq]). Collision-free against every
|
|
/// other prefix/bare id in this class -- diverges from every sibling
|
|
/// prefix at the very first character ('e' vs 'g'/'c'/'p') and from every
|
|
/// bare folder id (none of which starts with "eq_preset:").
|
|
static const _prefijoPresetEq = 'eq_preset:';
|
|
|
|
/// Reserved sentinel raw value for the "Desactivar" item under
|
|
/// [_prefijoPresetEq] (decision `auto/ecualizador-diseno`) -- never
|
|
/// collides with a real [PresetEcualizador.nombre]; none of the six
|
|
/// factory presets is named this.
|
|
static const _valorDesactivarEq = '_off_';
|
|
|
|
/// The "Desactivar" item's media id: the reserved [_valorDesactivarEq]
|
|
/// sentinel under [_prefijoPresetEq].
|
|
static const idDesactivarEq = '$_prefijoPresetEq$_valorDesactivarEq';
|
|
|
|
/// Whether [id] identifies an item under the Ecualizador folder (a
|
|
/// factory preset OR "Desactivar").
|
|
bool esPresetEqMediaId(String id) => id.startsWith(_prefijoPresetEq);
|
|
|
|
/// Whether [id] is specifically the "Desactivar" item (not a factory
|
|
/// preset). Only meaningful alongside [esPresetEqMediaId].
|
|
bool esDesactivarEqMediaId(String id) => id == idDesactivarEq;
|
|
|
|
/// Builds a factory preset's selection media id, matched by raw
|
|
/// (untranslated) [PresetEcualizador.nombre] -- the SAME identity
|
|
/// [PresetEcualizador.presets] already uses for equality, so a locale
|
|
/// change never breaks resolution.
|
|
String idPresetEq(String nombrePreset) => '$_prefijoPresetEq$nombrePreset';
|
|
|
|
/// Resolves an `eq_preset:<nombre>` [id] to the matching factory
|
|
/// [PresetEcualizador] from [presets] (defaults to
|
|
/// [PresetEcualizador.presets]), comparing by raw `nombre`. Returns
|
|
/// `null` for the [_valorDesactivarEq] sentinel, an unresolvable name, or
|
|
/// any id that doesn't match [esPresetEqMediaId] -- never throws.
|
|
PresetEcualizador? resolverPresetEq(
|
|
String id, {
|
|
List<PresetEcualizador>? presets,
|
|
}) {
|
|
if (!esPresetEqMediaId(id) || esDesactivarEqMediaId(id)) return null;
|
|
final nombre = id.substring(_prefijoPresetEq.length);
|
|
final lista = presets ?? PresetEcualizador.presets;
|
|
for (final preset in lista) {
|
|
if (preset.nombre == nombre) return preset;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Routing seam between a car-tapped `emisora:<uuid>` media id and the
|
|
/// existing internal playback path (Design "playback coherence" — reuse
|
|
/// over duplication). Resolves the uuid via [fuente], builds the same
|
|
/// phone-shaped `MediaItem` (`id` = station url, `extras['uuid']`) that
|
|
/// [ServicioAudio.reproducir] builds, and delegates to [reproducir].
|
|
///
|
|
/// A stale/unknown id (or a malformed one) is a no-op: [reproducir] is
|
|
/// never called and no exception propagates (Spec "Unknown or stale media
|
|
/// id").
|
|
Future<void> reproducirPorMediaId(
|
|
String id, {
|
|
required FuenteEmisorasAuto fuente,
|
|
required Future<void> Function(MediaItem) reproducir,
|
|
}) async {
|
|
if (!id.startsWith(_prefijoEmisora)) return;
|
|
final uuid = id.substring(_prefijoEmisora.length);
|
|
if (uuid.isEmpty) return;
|
|
|
|
final emisora = await fuente.porUuid(uuid);
|
|
if (emisora == null) return;
|
|
|
|
final item = MediaItem(
|
|
id: emisora.url,
|
|
title: emisora.nombre,
|
|
artist: emisora.pais ?? '',
|
|
album: 'PluriWave',
|
|
// Item 3: reuses [artUriPara] (the SAME fallback the browse tree's
|
|
// itemEmisora already applies) so the "now playing" media item never
|
|
// falls back to a blank tile — a real usable favicon still wins, a
|
|
// missing/unusable one gets the on-brand rotating drawable instead of
|
|
// `null`.
|
|
artUri: Uri.parse(artUriPara(emisora)),
|
|
extras: {'uuid': emisora.uuid},
|
|
);
|
|
await reproducir(item);
|
|
}
|
|
|
|
/// Which list previous/next should walk for [actual]: the NARROWEST list the
|
|
/// station actually belongs to, favourites first, then my stations, then the
|
|
/// full catalogue.
|
|
///
|
|
/// Narrowest-first is the point. "Next station" while playing a favourite
|
|
/// should land on the next favourite, not 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
|
|
/// working for a station reached by search.
|
|
///
|
|
/// 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,
|
|
}) {
|
|
bool contiene(List<Emisora> lista) => lista.any((e) => e.uuid == actual.uuid);
|
|
if (contiene(favoritos)) return favoritos;
|
|
if (contiene(misEmisoras)) return misEmisoras;
|
|
if (contiene(todas)) return todas;
|
|
return const [];
|
|
}
|
|
|
|
/// The station before or after [actual] in [lista], wrapping around at both
|
|
/// ends.
|
|
///
|
|
/// Wrapping is deliberate: on a car's transport row a button that goes dead
|
|
/// at the end of a list reads as a broken app, and there is no visible list
|
|
/// position to explain it. Matching is by `uuid`, the same identity the
|
|
/// browse tree uses, so a refreshed snapshot with different object instances
|
|
/// still resolves.
|
|
///
|
|
/// Returns `null` when [lista] has fewer than two entries, or when [actual]
|
|
/// is not in it — the caller must then leave playback alone rather than jump
|
|
/// somewhere arbitrary.
|
|
Emisora? emisoraVecina(
|
|
Emisora? actual,
|
|
List<Emisora> lista, {
|
|
required bool haciaAtras,
|
|
}) {
|
|
if (actual == null || lista.length < 2) return null;
|
|
final indice = lista.indexWhere((e) => e.uuid == actual.uuid);
|
|
if (indice < 0) return null;
|
|
final destino =
|
|
haciaAtras
|
|
? (indice - 1 + lista.length) % lista.length
|
|
: (indice + 1) % lista.length;
|
|
return lista[destino];
|
|
}
|
|
|
|
/// Picks the station a spoken query refers to ("pon Radio Clásica"), over the
|
|
/// stations the car can already browse.
|
|
///
|
|
/// Pure and source-agnostic so it is testable without a handler. Ranking, best
|
|
/// first:
|
|
/// 1. exact name match (case/accent-insensitive),
|
|
/// 2. name starts with the query,
|
|
/// 3. name contains the query,
|
|
/// 4. country contains the query.
|
|
/// Ties are broken by the order [candidatas] arrives in, which the caller
|
|
/// composes as favourites → my stations → all, so a favourite always wins over
|
|
/// a stranger with the same name.
|
|
///
|
|
/// Returns `null` for an empty query or no match — the caller must then do
|
|
/// nothing rather than play something arbitrary, since a driver who asked for
|
|
/// a specific station is worse served by a random one than by silence.
|
|
Emisora? emisoraParaBusqueda(String consulta, List<Emisora> candidatas) {
|
|
final q = _normalizarBusqueda(consulta);
|
|
if (q.isEmpty) return null;
|
|
|
|
Emisora? contiene;
|
|
Emisora? empieza;
|
|
Emisora? porPais;
|
|
for (final emisora in candidatas) {
|
|
final nombre = _normalizarBusqueda(emisora.nombre);
|
|
if (nombre == q) return emisora;
|
|
if (empieza == null && nombre.startsWith(q)) {
|
|
empieza = emisora;
|
|
} else if (contiene == null && nombre.contains(q)) {
|
|
contiene = emisora;
|
|
} else if (porPais == null &&
|
|
_normalizarBusqueda(emisora.pais ?? '').contains(q)) {
|
|
porPais = emisora;
|
|
}
|
|
}
|
|
return empieza ?? contiene ?? porPais;
|
|
}
|
|
|
|
/// Lowercase, accent-stripped, collapsed whitespace — a driver saying "radio
|
|
/// clasica" must match "Radio Clásica", and voice transcription rarely gets
|
|
/// diacritics right.
|
|
String _normalizarBusqueda(String texto) {
|
|
const conAcento = 'áàäâãéèëêíìïîóòöôõúùüûñç';
|
|
const sinAcento = 'aaaaaeeeeiiiiooooouuuunc';
|
|
final buffer = StringBuffer();
|
|
for (final rune in texto.toLowerCase().runes) {
|
|
final char = String.fromCharCode(rune);
|
|
final i = conAcento.indexOf(char);
|
|
buffer.write(i >= 0 ? sinAcento[i] : char);
|
|
}
|
|
return buffer.toString().replaceAll(RegExp(r'\s+'), ' ').trim();
|
|
}
|
|
|
|
/// Routing seam for a car-tapped `eq_preset:<...>` media id (decision
|
|
/// `auto/ecualizador-diseno`, mirrors [reproducirPorMediaId]'s seam
|
|
/// shape): dispatches "Desactivar" to [activarEcualizador]`(false)`, and a
|
|
/// resolved factory preset to [aplicarPreset] -- turning the equalizer
|
|
/// back ON via [activarEcualizador]`(true)` AFTERWARDS whenever [activo]
|
|
/// is currently `false`, so tapping a preset while the equalizer is off
|
|
/// both re-enables it AND applies the tapped preset's gains (Spec
|
|
/// "selecting a preset while disabled enables it and applies it"), never
|
|
/// silently just remembering the preset for later. [aplicarPreset] runs
|
|
/// BEFORE the enable check so the native engine only ever pushes gains
|
|
/// once, for the NEW preset -- never once for whatever was active before,
|
|
/// then again for the new one.
|
|
///
|
|
/// A stale/unresolvable id, or any id that doesn't match
|
|
/// [ConstructorArbolAuto.esPresetEqMediaId], is a no-op: neither callback
|
|
/// runs and no exception propagates.
|
|
///
|
|
/// [presets] is the universe the id is resolved against, and it MUST be the
|
|
/// same list the folder was rendered from (`presetsEcualizadorAuto` in
|
|
/// `servicio_audio.dart` — factory presets plus the user's saved ones).
|
|
/// Defaulting to the factory six alone is what made a tapped custom preset a
|
|
/// silent no-op: the item was listed, but nothing here could resolve it.
|
|
Future<void> seleccionarPresetEqPorMediaId(
|
|
String id, {
|
|
required bool activo,
|
|
required Future<void> Function(PresetEcualizador) aplicarPreset,
|
|
required Future<void> Function(bool) activarEcualizador,
|
|
List<PresetEcualizador>? presets,
|
|
}) async {
|
|
final constructor = ConstructorArbolAuto();
|
|
if (!constructor.esPresetEqMediaId(id)) return;
|
|
|
|
if (constructor.esDesactivarEqMediaId(id)) {
|
|
await activarEcualizador(false);
|
|
return;
|
|
}
|
|
|
|
final preset = constructor.resolverPresetEq(id, presets: presets);
|
|
if (preset == null) return;
|
|
|
|
await aplicarPreset(preset);
|
|
if (!activo) await activarEcualizador(true);
|
|
}
|
|
|
|
/// Fallback title (Design "Title = filename minus extension") for a blank
|
|
/// or otherwise empty-after-stripping local filename — hardcoded Spanish,
|
|
/// matching every other car-tree label in this file (`'Favoritos'`,
|
|
/// `'Música Local'`, etc.), none of which go through `AppLocalizations`.
|
|
const _tituloLocalFallback = 'Pista sin nombre';
|
|
|
|
/// Filename → display title (Design "Title = filename minus extension"):
|
|
/// strips the LAST `.ext` (the whole trimmed name is kept when there is no
|
|
/// dot, or the dot is the first character — e.g. a hidden file like
|
|
/// `.mp3`), falling back to [_tituloLocalFallback] when the result would be
|
|
/// blank.
|
|
String _tituloDesdeNombre(String nombre) {
|
|
final recortado = nombre.trim();
|
|
if (recortado.isEmpty) return _tituloLocalFallback;
|
|
final ultimoPunto = recortado.lastIndexOf('.');
|
|
final sinExtension =
|
|
ultimoPunto > 0 ? recortado.substring(0, ultimoPunto) : recortado;
|
|
final resultado = sinExtension.trim();
|
|
return resultado.isEmpty ? _tituloLocalFallback : resultado;
|
|
}
|
|
|
|
/// Resolves the on-brand fallback `artUri` for a local track (Design "art =
|
|
/// reused station_art_* rotation"): reuses the EXACT rotation
|
|
/// ([indiceArtePara]/`_nombresArte`) [artUriPara] uses for stations, seeded
|
|
/// by [documentId] instead of a station uuid — zero new assets, same
|
|
/// deterministic per-item mapping.
|
|
String artUriLocal(String documentId) =>
|
|
'android.resource://es.freetimelab.pluriwave/drawable/'
|
|
'station_art_${_nombresArte[indiceArtePara(documentId)]}';
|
|
|
|
/// Bitrate-descending comparator for two resolved [MetadatosPista] (Design
|
|
/// ADR-3), mirroring [OrdenEmisoras.calidad]'s shape (`orden_emisoras.dart`)
|
|
/// — no code sharing forced, different types. An unknown bitrate (`null`
|
|
/// or `<= 0`) always sorts AFTER every known-bitrate entry; two unknowns
|
|
/// compare equal. Never throws.
|
|
int compararCalidadLocal(MetadatosPista? a, MetadatosPista? b) {
|
|
final bitrateA = a?.bitrate;
|
|
final bitrateB = b?.bitrate;
|
|
final conocidoA = bitrateA != null && bitrateA > 0;
|
|
final conocidoB = bitrateB != null && bitrateB > 0;
|
|
if (!conocidoA && !conocidoB) return 0;
|
|
if (!conocidoA) return 1;
|
|
if (!conocidoB) return -1;
|
|
return bitrateB.compareTo(bitrateA);
|
|
}
|
|
|
|
/// Returns a bitrate-descending sorted COPY of [nodos] (Design ADR-3),
|
|
/// resolving each node's bitrate via [metadatos] (keyed by `documentId`) —
|
|
/// a node absent from [metadatos] (or with a `null`/`<= 0` bitrate) is
|
|
/// treated as unknown and sorts last, via [compararCalidadLocal]. Never
|
|
/// throws.
|
|
List<NodoLocal> ordenarPorCalidadLocal(
|
|
List<NodoLocal> nodos,
|
|
Map<String, MetadatosPista> metadatos,
|
|
) {
|
|
final ordenados = List<NodoLocal>.from(nodos);
|
|
ordenados.sort(
|
|
(a, b) =>
|
|
compararCalidadLocal(metadatos[a.documentId], metadatos[b.documentId]),
|
|
);
|
|
return ordenados;
|
|
}
|
|
|
|
/// Fixed alphabetical bucket ranges (Design "User browses name buckets"):
|
|
/// `(etiqueta, desde, hasta)`, each a contiguous, lowercase, single-letter
|
|
/// first-letter range. Shared by [bucketsDe] (partitioning) and
|
|
/// [ConstructorArbolAuto]'s page-0 prepend wiring (label text). A name
|
|
/// that doesn't start with an ASCII letter (blank, digit, symbol) never
|
|
/// matches any of these — the spec doesn't define a catch-all "other"
|
|
/// bucket.
|
|
const _rangosBucket = [
|
|
('A-F', 'a', 'f'),
|
|
('G-M', 'g', 'm'),
|
|
('N-S', 'n', 's'),
|
|
('T-Z', 't', 'z'),
|
|
];
|
|
|
|
/// One alphabetical name-bucket's result (Design "User browses name
|
|
/// buckets"): [etiqueta] is the fixed range label (e.g. `'A-F'`), [nodos]
|
|
/// is the (possibly empty) list of tracks whose first letter falls in that
|
|
/// range.
|
|
class BucketLocal {
|
|
const BucketLocal({required this.etiqueta, required this.nodos});
|
|
|
|
final String etiqueta;
|
|
final List<NodoLocal> nodos;
|
|
}
|
|
|
|
/// Partitions [nodos] into the 4 fixed [_rangosBucket] alphabetical ranges
|
|
/// (Design ADR-4), using ONLY [NodoLocal.nombre] — no metadata dependency,
|
|
/// so this function structurally cannot call `metadatosDe` (its signature
|
|
/// doesn't receive one). Directory nodes are excluded (buckets are a
|
|
/// track-only view). A bucket with zero matches is still returned with an
|
|
/// empty `nodos` list, never omitted or an error (Spec "Bucket with no
|
|
/// matching tracks").
|
|
List<BucketLocal> bucketsDe(List<NodoLocal> nodos) {
|
|
final pistas = nodos.where((n) => !n.esDirectorio).toList();
|
|
return _rangosBucket.map((rango) {
|
|
final (etiqueta, desde, hasta) = rango;
|
|
final coincidencias =
|
|
pistas.where((n) {
|
|
final recortado = n.nombre.trim();
|
|
if (recortado.isEmpty) return false;
|
|
final letra = recortado[0].toLowerCase();
|
|
return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
|
|
}).toList();
|
|
return BucketLocal(etiqueta: etiqueta, nodos: coincidencias);
|
|
}).toList();
|
|
}
|
|
|
|
/// The canonical name-sorted audio-children list a folder-play action
|
|
/// queues (Design ADR-6): directories excluded, sorted by
|
|
/// `NodoLocal.nombre` — the SAME comparator [ConstructorArbolAuto.itemsLocales]
|
|
/// already applies to the browse-tree page-0 view, so "Reproducir
|
|
/// carpeta"'s play order matches what the driver sees when browsing
|
|
/// normally. Returns a NEW list; never mutates [nodos].
|
|
List<NodoLocal> pistasEnOrdenNombre(List<NodoLocal> nodos) {
|
|
final pistas = nodos.where((n) => !n.esDirectorio).toList();
|
|
pistas.sort((a, b) => a.nombre.compareTo(b.nombre));
|
|
return pistas;
|
|
}
|
|
|
|
/// Fisher-Yates shuffle (Design ADR-6) over a COPY of [nodos] — never
|
|
/// mutates the input list. [rng] is injected so tests can pass a
|
|
/// fixed-seed `Random` for deterministic permutation assertions;
|
|
/// production callers pass `Random()`.
|
|
List<NodoLocal> mezclarFisherYates(List<NodoLocal> nodos, Random rng) {
|
|
final resultado = List<NodoLocal>.from(nodos);
|
|
for (var i = resultado.length - 1; i > 0; i--) {
|
|
final j = rng.nextInt(i + 1);
|
|
final tmp = resultado[i];
|
|
resultado[i] = resultado[j];
|
|
resultado[j] = tmp;
|
|
}
|
|
return resultado;
|
|
}
|
|
|
|
/// The shuffled audio-children list "Reproducir aleatorio" queues (Design
|
|
/// ADR-6): Fisher-Yates over [pistasEnOrdenNombre]'s canonical order — NOT
|
|
/// the native enumeration order (not guaranteed stable) — so the resulting
|
|
/// permutation is reproducible under a fixed [rng] seed.
|
|
List<NodoLocal> pistasEnOrdenAleatorio(List<NodoLocal> nodos, Random rng) =>
|
|
mezclarFisherYates(pistasEnOrdenNombre(nodos), rng);
|
|
|
|
/// Maximum recursion depth for "play folder recursively" (Design "recursive
|
|
/// folder play, cost bound", item 2): SAF directory listing is a native
|
|
/// round-trip PER folder, so unbounded recursion could turn a single tap
|
|
/// into dozens of channel calls for a pathologically deep tree. 4 levels
|
|
/// below the tapped folder covers virtually every real music-library
|
|
/// layout (even `Artist/Album/Disc/track.mp3` is only 3 levels deep) while
|
|
/// keeping a worst-case tree's native-call count bounded. A subfolder
|
|
/// beyond this depth is simply never explored — its tracks are not
|
|
/// collected, exactly like content beyond the browse tree's own page cap
|
|
/// is never listed.
|
|
const profundidadMaximaRecursivaLocal = 4;
|
|
|
|
/// Maximum number of tracks collected by a recursive folder walk (Design
|
|
/// "recursive folder play, cost bound", item 2): a folder-play/shuffle
|
|
/// queue beyond a few hundred tracks has no practical benefit, and an
|
|
/// unbounded collection risks an extremely long queue AND an extremely
|
|
/// long recursive walk over a huge library. 500 is an order of magnitude
|
|
/// above the existing quality-sort cap
|
|
/// ([ConstructorArbolAuto._maxPistasParaOrdenCalidad], 150) — generous for
|
|
/// a "play everything" action, while still bounded.
|
|
const limitePistasRecursivasLocal = 500;
|
|
|
|
/// Recursively collects every audio-file [NodoLocal] reachable from
|
|
/// [documentId] (Design "recursive folder play", item 2): [documentId]'s
|
|
/// own direct audio children, plus — for every direct subfolder — that
|
|
/// subfolder's own recursive result. Walked depth-first, sorted by
|
|
/// [NodoLocal.nombre] at each level (the SAME comparator the sequential/
|
|
/// shuffle play actions already used pre-recursion), so the collected
|
|
/// order is deterministic and reproducible under a fixed shuffle seed.
|
|
///
|
|
/// Bounded on two independent axes so a pathological tree (very deep, or
|
|
/// very wide-and-deep) can never turn a single tap into an unbounded
|
|
/// number of native SAF round-trips or an unbounded in-memory list:
|
|
/// - [profundidadMaxima] caps how many folder levels BELOW [documentId]
|
|
/// are ever descended into (`0` = only [documentId]'s own direct
|
|
/// children, no descent at all).
|
|
/// - [limite] caps the TOTAL number of tracks collected across the whole
|
|
/// walk; collection stops (mid-folder if needed) the instant this many
|
|
/// have been gathered.
|
|
///
|
|
/// Never throws: a [fuente.hijos] failure on any one subfolder (revoked
|
|
/// permission, a race with the OS SAF layer) is swallowed for that
|
|
/// subfolder only — sibling folders already queued for traversal are
|
|
/// still visited — mirroring this file's existing no-throw contract
|
|
/// (Design "no-op on empty/unresolvable folder").
|
|
Future<List<NodoLocal>> pistasRecursivas(
|
|
String documentId, {
|
|
required FuenteMusicaLocalAuto fuente,
|
|
int profundidadMaxima = profundidadMaximaRecursivaLocal,
|
|
int limite = limitePistasRecursivasLocal,
|
|
}) async {
|
|
final resultado = <NodoLocal>[];
|
|
await _recolectarPistasRecursivas(
|
|
documentId,
|
|
fuente: fuente,
|
|
profundidadRestante: profundidadMaxima,
|
|
limite: limite,
|
|
resultado: resultado,
|
|
);
|
|
return resultado;
|
|
}
|
|
|
|
Future<void> _recolectarPistasRecursivas(
|
|
String documentId, {
|
|
required FuenteMusicaLocalAuto fuente,
|
|
required int profundidadRestante,
|
|
required int limite,
|
|
required List<NodoLocal> resultado,
|
|
}) async {
|
|
if (resultado.length >= limite) return;
|
|
final List<NodoLocal> hijos;
|
|
try {
|
|
hijos = await fuente.hijos(documentId);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
final ordenados = [...hijos]..sort((a, b) => a.nombre.compareTo(b.nombre));
|
|
for (final nodo in ordenados) {
|
|
if (resultado.length >= limite) return;
|
|
if (nodo.esDirectorio) {
|
|
if (profundidadRestante <= 0) continue;
|
|
await _recolectarPistasRecursivas(
|
|
nodo.documentId,
|
|
fuente: fuente,
|
|
profundidadRestante: profundidadRestante - 1,
|
|
limite: limite,
|
|
resultado: resultado,
|
|
);
|
|
} else {
|
|
resultado.add(nodo);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Orchestrates a "Reproducir carpeta"/"Reproducir aleatorio" tap (Design
|
|
/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2; recursive collection item
|
|
/// 2): resolves whichever of the two action prefixes matches [id]
|
|
/// (ignoring [aleatorio] for the STRIP — the prefix itself is
|
|
/// authoritative), RECURSIVELY collects every track beneath that folder
|
|
/// via [pistasRecursivas] (direct children AND every nested subfolder, up
|
|
/// to its depth/count bounds), orders them ([aleatorio] picks shuffled vs
|
|
/// the recursive walk's own name-sorted order), and hands the resulting
|
|
/// list to [iniciarCola].
|
|
///
|
|
/// A no-op (never calls [iniciarCola]) when: [id] matches neither action
|
|
/// prefix; the folder (or everything beneath it, within the recursion
|
|
/// bounds) is unresolvable/empty (Design "no-op on empty/unresolvable
|
|
/// folder") — [pistasRecursivas] never throws, so this never propagates an
|
|
/// exception either.
|
|
Future<void> reproducirCarpetaLocal(
|
|
String id, {
|
|
required bool aleatorio,
|
|
required FuenteMusicaLocalAuto fuente,
|
|
Random? rng,
|
|
required Future<void> Function(List<NodoLocal> pistas) iniciarCola,
|
|
}) async {
|
|
final constructor = ConstructorArbolAuto();
|
|
final String documentId;
|
|
if (constructor.esCarpetaLocalReproducirMediaId(id)) {
|
|
documentId = constructor.idCarpetaLocalReproducirDesde(id);
|
|
} else if (constructor.esCarpetaLocalAleatorioMediaId(id)) {
|
|
documentId = constructor.idCarpetaLocalAleatorioDesde(id);
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
final recolectadas = await pistasRecursivas(documentId, fuente: fuente);
|
|
final pistas =
|
|
aleatorio
|
|
? mezclarFisherYates(recolectadas, rng ?? Random())
|
|
: recolectadas;
|
|
if (pistas.isEmpty) return;
|
|
|
|
await iniciarCola(pistas);
|
|
}
|
|
|
|
/// Resolves [nodo]'s playable content URI via [fuente] and builds the
|
|
/// `MediaItem` the local-queue layer plays (Design Data Flow
|
|
/// "construirMediaItemColaLocal (resolve URI)"), reusing the SAME
|
|
/// title-derivation [reproducirPistaLocal] uses ([_tituloDesdeDocumentId])
|
|
/// so a queue track's Now Playing title matches what a directly-tapped
|
|
/// single track would show. Returns `null` when the content URI cannot be
|
|
/// resolved (stale id, revoked permission, moved file) — the caller treats
|
|
/// that as "cannot play this entry", never a crash.
|
|
Future<MediaItem?> construirMediaItemColaLocal(
|
|
NodoLocal nodo, {
|
|
required FuenteMusicaLocalAuto fuente,
|
|
}) async {
|
|
final contentUri = await fuente.uriContenidoDePista(nodo.documentId);
|
|
if (contentUri == null || contentUri.isEmpty) return null;
|
|
return MediaItem(
|
|
id: contentUri,
|
|
title: _tituloDesdeDocumentId(nodo.documentId),
|
|
album: 'PluriWave',
|
|
// Item 3: a queued local track had NO artUri at all before — reuses
|
|
// [artUriLocal] (the SAME on-brand rotation the browse tree's
|
|
// `_itemLocal` already falls back to) so the car's now-playing screen
|
|
// never shows a blank tile for a track with no embedded art.
|
|
artUri: Uri.parse(artUriLocal(nodo.documentId)),
|
|
extras: {'documentId': nodo.documentId},
|
|
);
|
|
}
|
|
|
|
/// Local-music `getChildren` dispatch (Design "Data Flow"): resolves
|
|
/// [parentMediaId] against the `musica_local` root (`fuente.hijos('')`) or a
|
|
/// `carpeta_local:<id>` subfolder (`fuente.hijos(id)`), mapping the result
|
|
/// through [ConstructorArbolAuto.itemsLocales]. Returns `null` when
|
|
/// [parentMediaId] matches NEITHER shape, so the caller
|
|
/// (`ServicioAudio.getChildren`) can fall through to its other branches
|
|
/// unmodified. A `null` [fuente] (local source never registered — headless
|
|
/// cold bind) or any thrown error degrades to `[]`, never a crash (Design
|
|
/// "cold-start safe", mirrors `FuenteEmisorasAutoLocal`'s pattern; Spec
|
|
/// "Browse requested before app state is loaded" / "Permission revoked or
|
|
/// never granted").
|
|
/// Session-scoped metadata cache shared across every `hijosMusicaLocal`
|
|
/// call (Design ADR-2) — module-level singleton, mirroring
|
|
/// `servicio_audio.dart`'s `_fuenteMusicaLocalGlobal` pattern: paging a
|
|
/// large folder across multiple `getChildren` calls must NOT evict an
|
|
/// earlier page's cached metadata, which requires the cache to outlive any
|
|
/// single call.
|
|
final CacheMetadatosSesion _cacheMetadatosLocal = CacheMetadatosSesion();
|
|
|
|
/// Wraps [fuente]'s raw `metadatosDe` with [_cacheMetadatosLocal] (Design
|
|
/// "Data Flow" — `metadatosDe(slice.trackDocIds) — CacheMetadatosSesion
|
|
/// hit? else readAudioMetadataBatch`): resolves cache hits locally without
|
|
/// a channel round trip, batches ONLY the cache misses through
|
|
/// [FuenteMusicaLocalAuto.metadatosDe], and stores every freshly-resolved
|
|
/// entry back into the cache before returning the combined map.
|
|
Future<Map<String, MetadatosPista>> _metadatosDeConCache(
|
|
List<String> documentIds, {
|
|
required FuenteMusicaLocalAuto fuente,
|
|
}) async {
|
|
if (documentIds.isEmpty) return const {};
|
|
final resultado = <String, MetadatosPista>{};
|
|
final faltantes = <String>[];
|
|
for (final id in documentIds) {
|
|
final cacheado = _cacheMetadatosLocal.obtener(id);
|
|
if (cacheado != null) {
|
|
resultado[id] = cacheado;
|
|
} else {
|
|
faltantes.add(id);
|
|
}
|
|
}
|
|
if (faltantes.isNotEmpty) {
|
|
final resueltos = await fuente.metadatosDe(faltantes);
|
|
resueltos.forEach((id, metadatos) {
|
|
_cacheMetadatosLocal.guardar(id, metadatos);
|
|
resultado[id] = metadatos;
|
|
});
|
|
}
|
|
return resultado;
|
|
}
|
|
|
|
Future<List<MediaItem>?> hijosMusicaLocal(
|
|
String parentMediaId, {
|
|
required FuenteMusicaLocalAuto? fuente,
|
|
}) async {
|
|
final constructor = ConstructorArbolAuto();
|
|
|
|
// Sort-mode and bucket views (Design ADR-4, Phase 2) are routed FIRST —
|
|
// routing order is irrelevant to correctness (every prefix in this file
|
|
// is collision-free, see each prefix's doc comment), but checking the
|
|
// more specific new prefixes first keeps this dispatch readable.
|
|
if (constructor.esCarpetaLocalOrdMediaId(parentMediaId)) {
|
|
final (modo, documentId, pagina) = constructor.ordenLocalDesde(
|
|
parentMediaId,
|
|
);
|
|
if (fuente == null) return const [];
|
|
try {
|
|
final nodos = await fuente.hijos(documentId);
|
|
if (modo != 'calidad') return const [];
|
|
return await constructor.itemsLocalesOrdenCalidad(
|
|
nodos,
|
|
documentIdPadre: documentId,
|
|
pagina: pagina,
|
|
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
|
|
);
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
if (constructor.esCarpetaLocalBucketMediaId(parentMediaId)) {
|
|
final (idxBucket, documentId, pagina) = constructor.bucketLocalDesde(
|
|
parentMediaId,
|
|
);
|
|
if (fuente == null) return const [];
|
|
try {
|
|
final nodos = await fuente.hijos(documentId);
|
|
return await constructor.itemsLocalesBucket(
|
|
nodos,
|
|
documentIdPadre: documentId,
|
|
idxBucket: idxBucket,
|
|
pagina: pagina,
|
|
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
|
|
);
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
final String documentId;
|
|
var pagina = 0;
|
|
if (parentMediaId == ConstructorArbolAuto.idMusicaLocal) {
|
|
documentId = '';
|
|
} else if (constructor.esCarpetaLocalPaginadaMediaId(parentMediaId)) {
|
|
final resuelto = constructor.paginaCarpetaLocalDesde(parentMediaId);
|
|
documentId = resuelto.$1;
|
|
pagina = resuelto.$2;
|
|
} else if (constructor.esCarpetaLocalMediaId(parentMediaId)) {
|
|
documentId = constructor.idCarpetaLocalDesde(parentMediaId);
|
|
} else {
|
|
return null;
|
|
}
|
|
if (fuente == null) return const [];
|
|
try {
|
|
final nodos = await fuente.hijos(documentId);
|
|
return await constructor.itemsLocales(
|
|
nodos,
|
|
documentIdPadre: documentId,
|
|
pagina: pagina,
|
|
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
|
|
fuente: fuente,
|
|
);
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
/// Best-effort title for a played local track (Design "Local Track Playback
|
|
/// Reuses Existing Pipeline"): `FuenteMusicaLocalAuto.uriContenidoDePista`
|
|
/// only returns a content URI, not the original filename (Design's
|
|
/// Interfaces/Contracts — no metadata fields in Phase 1), so this derives a
|
|
/// title from the trailing path segment of the SAF [documentId] itself
|
|
/// (`primary:Music/Local/song.mp3` → `song.mp3` → title-stripped), applying
|
|
/// the SAME [_tituloDesdeNombre] rule the browse tree uses. This keeps the
|
|
/// Now Playing title consistent with what the user tapped without requiring
|
|
/// a second native round trip.
|
|
String _tituloDesdeDocumentId(String documentId) {
|
|
final ultimaBarra = documentId.lastIndexOf('/');
|
|
final segmento =
|
|
ultimaBarra >= 0 ? documentId.substring(ultimaBarra + 1) : documentId;
|
|
return _tituloDesdeNombre(segmento);
|
|
}
|
|
|
|
/// Routing seam between a car-tapped `pista:<docId>` media id and the
|
|
/// existing playback pipeline (Design "Local Track Playback Reuses Existing
|
|
/// Pipeline" — same seam shape as [reproducirPorMediaId], Spec "User selects
|
|
/// a local track"). Resolves the content URI via [fuente], builds a
|
|
/// `MediaItem` and delegates to [reproducir] — the SAME injection point
|
|
/// stations use, so the shared EQ signal chain applies identically (Spec
|
|
/// "EQ still applies to local track playback", regression guard: no
|
|
/// separate/bypassed path exists here).
|
|
///
|
|
/// A stale/unknown [id] (or a malformed one) is a no-op: [reproducir] is
|
|
/// never called and no exception propagates (Spec "Unknown or stale track
|
|
/// id").
|
|
Future<void> reproducirPistaLocal(
|
|
String id, {
|
|
required FuenteMusicaLocalAuto fuente,
|
|
required Future<void> Function(MediaItem) reproducir,
|
|
}) async {
|
|
if (!esPistaMediaId(id)) return;
|
|
final documentId = id.substring(_prefijoPista.length);
|
|
if (documentId.isEmpty) return;
|
|
|
|
final contentUri = await fuente.uriContenidoDePista(documentId);
|
|
if (contentUri == null || contentUri.isEmpty) return;
|
|
|
|
final pista = PistaLocal(
|
|
documentId: documentId,
|
|
titulo: _tituloDesdeDocumentId(documentId),
|
|
contentUri: contentUri,
|
|
);
|
|
|
|
final item = MediaItem(
|
|
id: pista.contentUri,
|
|
title: pista.titulo,
|
|
album: 'PluriWave',
|
|
// Item 3: same fallback as construirMediaItemColaLocal, for a track
|
|
// tapped directly (not via a folder-play queue).
|
|
artUri: Uri.parse(artUriLocal(pista.documentId)),
|
|
extras: {'documentId': pista.documentId},
|
|
);
|
|
await reproducir(item);
|
|
}
|
|
|
|
/// Local, cold-start-safe implementation of [FuenteEmisorasAuto] (Design
|
|
/// "getChildren data source"). Reads favourites from SQLite and custom
|
|
/// stations from the tolerant JSON file directly — both loadable without
|
|
/// the network or a built widget tree, unlike `EstadoRadio._init()` (which
|
|
/// is lazy-created by `ChangeNotifierProvider.create:` and may never run on
|
|
/// a headless Auto bind). `EstadoRadio`, when alive, overrides these reads
|
|
/// with a live snapshot via [actualizarSnapshot].
|
|
class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
|
|
FuenteEmisorasAutoLocal({
|
|
ServicioFavoritos? favoritosServicio,
|
|
Future<String> Function()? resolverRutaCustom,
|
|
}) : _favoritosServicio = favoritosServicio ?? ServicioFavoritos(),
|
|
_resolverRutaCustom = resolverRutaCustom;
|
|
|
|
final ServicioFavoritos _favoritosServicio;
|
|
final Future<String> Function()? _resolverRutaCustom;
|
|
|
|
List<Emisora>? _snapshotFavoritos;
|
|
List<Emisora>? _snapshotMisEmisoras;
|
|
List<Emisora>? _snapshotTodas;
|
|
List<GrupoFavoritos>? _snapshotGrupos;
|
|
|
|
/// Overrides the next reads with `EstadoRadio`'s live in-memory lists
|
|
/// (Design "live snapshot the source prefers"). Passing `null` for a
|
|
/// field leaves its current override (or local read) untouched.
|
|
@override
|
|
void actualizarSnapshot({
|
|
List<Emisora>? favoritos,
|
|
List<Emisora>? misEmisoras,
|
|
List<Emisora>? todas,
|
|
List<GrupoFavoritos>? grupos,
|
|
}) {
|
|
if (favoritos != null) _snapshotFavoritos = favoritos;
|
|
if (misEmisoras != null) _snapshotMisEmisoras = misEmisoras;
|
|
if (todas != null) _snapshotTodas = todas;
|
|
if (grupos != null) _snapshotGrupos = grupos;
|
|
}
|
|
|
|
@override
|
|
Future<List<Emisora>> favoritos() async {
|
|
final snapshot = _snapshotFavoritos;
|
|
if (snapshot != null) return snapshot;
|
|
try {
|
|
return await _favoritosServicio.obtenerTodos();
|
|
} catch (_) {
|
|
// Cold-start safety (Spec "Browse requested before app state is
|
|
// loaded"): never throw out of a browse call.
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<List<GrupoFavoritos>> grupos() async {
|
|
final snapshot = _snapshotGrupos;
|
|
if (snapshot != null) return snapshot;
|
|
try {
|
|
return await _favoritosServicio.obtenerGrupos();
|
|
} catch (_) {
|
|
// Cold-start safety (Spec "Browse requested before app state is
|
|
// loaded"): never throw out of a browse call.
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<List<Emisora>> misEmisoras() async {
|
|
final snapshot = _snapshotMisEmisoras;
|
|
if (snapshot != null) return snapshot;
|
|
return _leerEmisorasCustom();
|
|
}
|
|
|
|
@override
|
|
Future<List<Emisora>> todas() async {
|
|
// No live network snapshot on a cold bind — populated only once
|
|
// EstadoRadio pushes its populares list (Design "which stations
|
|
// surface & ordering": degrades gracefully to empty-but-valid).
|
|
return _snapshotTodas ?? const [];
|
|
}
|
|
|
|
@override
|
|
Future<Emisora?> porUuid(String uuid) async {
|
|
final listas = await Future.wait([favoritos(), misEmisoras(), todas()]);
|
|
for (final lista in listas) {
|
|
for (final emisora in lista) {
|
|
if (emisora.uuid == uuid) return emisora;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Mirrors `EstadoRadio._cargarEmisorasCustom()`'s tolerant JSON read:
|
|
/// missing/corrupt files never throw, they degrade to an empty list.
|
|
Future<List<Emisora>> _leerEmisorasCustom() async {
|
|
try {
|
|
final ruta = await _rutaArchivoCustom();
|
|
final archivo = File(ruta);
|
|
if (!await archivo.exists()) return const [];
|
|
final contenido = await archivo.readAsString();
|
|
final data = jsonDecode(contenido) as List;
|
|
final resultado = parseListaTolerante<Emisora>(
|
|
data,
|
|
Emisora.fromMap,
|
|
subsistema: 'emisoras_custom_auto',
|
|
coleccion: 'emisoras_custom',
|
|
);
|
|
return resultado.validas;
|
|
} catch (_) {
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
Future<String> _rutaArchivoCustom() async {
|
|
final resolver = _resolverRutaCustom;
|
|
if (resolver != null) return resolver();
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
return '${dir.path}/emisoras_custom.json';
|
|
}
|
|
}
|