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.
175 lines
7.1 KiB
Dart
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;
|
|
}
|
|
}
|