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

Maquina de estados del transporte

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

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

Tier gratuito en el coche

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

Localizacion

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

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

175 lines
7.1 KiB
Dart

import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/emisora.dart';
/// The last-played station's persistence key.
///
/// MUST stay byte-identical to `EstadoRadio._keyUltimaEmisora`
/// (`lib/estado/estado_radio.dart`), which is the only writer. It is
/// duplicated here rather than exported from there on purpose: this file has
/// to be readable from the headless Android Auto engine, where `EstadoRadio`
/// is never constructed, and importing a `ChangeNotifier` that pulls in the
/// whole app-state graph just to read one string constant would drag the
/// entire phone-side stack into a car bind. `emisoras_destacadas_test.dart`
/// pins the literal so a rename on either side fails loudly.
const claveUltimaEmisora = 'ultima_emisora_v1';
/// The stations a FREE-tier driver can browse and play in the car
/// (fix/auto-quality-guidelines, item 6).
///
/// Compiled into the binary, on purpose. Everything else the car could show
/// is empty on the bind a Play reviewer actually performs: a fresh install
/// is free tier (`esPremiumPersistido` is `getBool(...) ?? false`, no trial
/// key), `FuenteEmisorasAutoLocal.todas()` is literally
/// `_snapshotTodas ?? const []` until `EstadoRadio` pushes a network
/// snapshot that a headless bind never fetches, favourites and custom
/// stations are empty, and `ultima_emisora_v1` is absent. A curated const
/// list is the ONLY thing that can put real, playable rows in front of that
/// reviewer.
///
/// Deliberately small. This is not a catalogue — the catalogue is the
/// premium feature. Six rows is enough to prove the app works and short
/// enough to read at a glance from a driving position.
///
/// `favicon` is null for every entry on purpose: `artUriPara` then resolves
/// the on-brand bundled `station_art_*` drawable, so a browse row needs no
/// network at all to render its artwork.
///
/// `uuid`s are app-owned (`pw-destacada-*`), not Radio Browser uuids: these
/// rows must resolve identically whether or not the catalogue is reachable,
/// and a Radio Browser uuid we cannot re-fetch would be a promise this file
/// cannot keep.
const List<Emisora> emisorasDestacadas = [
Emisora(
uuid: 'pw-destacada-fip',
nombre: 'FIP',
url: 'https://icecast.radiofrance.fr/fip-midfi.mp3',
pais: 'France',
codigoPais: 'FR',
idioma: 'french',
),
Emisora(
uuid: 'pw-destacada-france-inter',
nombre: 'France Inter',
url: 'https://icecast.radiofrance.fr/franceinter-midfi.mp3',
pais: 'France',
codigoPais: 'FR',
idioma: 'french',
),
Emisora(
uuid: 'pw-destacada-deutschlandfunk',
nombre: 'Deutschlandfunk',
url: 'https://st01.sslstream.dlf.de/dlf/01/128/mp3/stream.mp3',
pais: 'Germany',
codigoPais: 'DE',
idioma: 'german',
),
Emisora(
uuid: 'pw-destacada-kexp',
nombre: 'KEXP 90.3 FM',
url: 'https://kexp-mp3-128.streamguys1.com/kexp128.mp3',
pais: 'United States',
codigoPais: 'US',
idioma: 'english',
),
Emisora(
uuid: 'pw-destacada-radio-paradise',
nombre: 'Radio Paradise',
url: 'https://stream.radioparadise.com/mp3-128',
pais: 'United States',
codigoPais: 'US',
idioma: 'english',
),
Emisora(
uuid: 'pw-destacada-soma-groove-salad',
nombre: 'SomaFM Groove Salad',
url: 'https://ice1.somafm.com/groovesalad-128-mp3',
pais: 'United States',
codigoPais: 'US',
idioma: 'english',
),
];
/// The free tier's complete, ordered station set: the last station the user
/// actually played (when one is persisted) first, then [emisorasDestacadas],
/// deduplicated by `uuid`.
///
/// Last-played goes first because it is the single row a returning driver is
/// most likely to want, and because it is the only entry that can make the
/// free folder feel like *their* app rather than a demo. It is NOT appended
/// a second time when it already belongs to the curated set.
///
/// Follows `esPremiumPersistido({SharedPreferences? prefs})`'s
/// inject-or-`getInstance()` convention (`estado_entitlement.dart`), so a
/// test can pin prefs without a platform channel.
///
/// Never throws: a corrupt/foreign `ultima_emisora_v1` payload, or a
/// `SharedPreferences` failure, degrades to the curated set alone. This runs
/// inside `getChildren`, and a browse call that throws is a dead folder.
Future<List<Emisora>> resolverEmisorasDestacadas({
SharedPreferences? prefs,
}) async {
final ultima = await _ultimaEmisora(prefs: prefs);
if (ultima == null) return emisorasDestacadas;
return [
ultima,
...emisorasDestacadas.where((e) => e.uuid != ultima.uuid),
];
}
/// Whether [uuid] belongs to [destacadas] — the predicate every play-path
/// gate reads to tell "free content" from "the premium catalogue".
///
/// Pure, and takes the free universe rather than resolving it, so a caller
/// that already holds the list (every one of them does — it also needs it to
/// build the response) asks the question without a second prefs round trip.
///
/// A `null` or empty [uuid] is never free: `emisora:` with no tail is a
/// malformed id, and matching it against an entry with an empty uuid would be
/// a resolution hole rather than a feature.
bool esEmisoraGratuita(String? uuid, List<Emisora> destacadas) =>
uuid != null && uuid.isNotEmpty && destacadas.any((e) => e.uuid == uuid);
/// [esEmisoraGratuita] against the CURRENT free set, resolved here. For
/// callers that do not already hold the list.
Future<bool> esEmisoraGratuitaPorUuid(
String uuid, {
SharedPreferences? prefs,
}) async =>
esEmisoraGratuita(uuid, await resolverEmisorasDestacadas(prefs: prefs));
/// Reads the persisted last-played station, or `null` when there is none.
///
/// Public because the Android Auto "recent" browse root
/// (`AudioService.recentRootId`) needs exactly this one station and nothing
/// else: `onGetRoot` (`AudioService.java:817-821`) answers `recent` whenever
/// the head unit sends `EXTRA_RECENT`, which Android Auto does on every
/// reconnect, and the platform expects a SINGLE resume item there — not a
/// station list, and not an empty folder.
///
/// Tier-independent on purpose: this station is by definition one the user
/// has already played on this device, so offering to resume it is never
/// leaking premium content they have not already had.
Future<Emisora?> ultimaEmisoraPersistida({SharedPreferences? prefs}) =>
_ultimaEmisora(prefs: prefs);
/// Reads the persisted last-played station, or `null` when there is none,
/// the payload is unreadable, or prefs themselves fail.
Future<Emisora?> _ultimaEmisora({SharedPreferences? prefs}) async {
try {
final resueltas = prefs ?? await SharedPreferences.getInstance();
final raw = resueltas.getString(claveUltimaEmisora);
if (raw == null) return null;
final emisora = Emisora.fromMap(jsonDecode(raw) as Map<String, dynamic>);
// A record with no uuid or no url cannot be turned into a playable
// `emisora:<uuid>` row, so it is worse than absent: it would occupy the
// first slot with a row that does nothing when tapped.
if (emisora.uuid.isEmpty || emisora.url.isEmpty) return null;
return emisora;
} catch (_) {
return null;
}
}