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 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> 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 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 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 ultimaEmisoraPersistida({SharedPreferences? prefs}) => _ultimaEmisora(prefs: prefs); /// Writes [emisora] as the last-played station — the SINGLE writer of /// [claveUltimaEmisora]. /// /// It lives beside [ultimaEmisoraPersistida] rather than in `EstadoRadio` /// because the key has to be written from the engine Android Auto starts, /// which builds no widget tree and therefore never constructs `EstadoRadio` /// at all: a session that happened only in the car used to leave the key /// holding whatever the PHONE last played, so the head unit's resume row and /// the free tier's featured folder were both stale on the next connect. /// /// Deliberately NOT swallowing failures here: the handler port that calls it /// traces and swallows (a persistence failure must never break playback), /// and a silent `catch` in BOTH places would make a dead write channel /// invisible from a car logcat. Future guardarUltimaEmisoraPersistida( Emisora emisora, { SharedPreferences? prefs, }) async { final resueltas = prefs ?? await SharedPreferences.getInstance(); await resueltas.setString(claveUltimaEmisora, jsonEncode(emisora.toMap())); } /// Reads the persisted last-played station, or `null` when there is none, /// the payload is unreadable, or prefs themselves fail. Future _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); // A record with no uuid or no url cannot be turned into a playable // `emisora:` 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; } }