feat: restaurar los grupos de favoritos al importar y recordar la lista del coche
Dos fallos reportados desde el uso real en el coche. Los grupos de favoritos no volvian al importar La exportacion nunca estuvo rota: los grupos viajaban desde siempre y la asignacion de cada emisora va dentro de cada favorito como grupo_id. El fallo estaba solo al importar. La restauracion reutilizaba ServicioFavoritos.agregar, que fuerza "sin asignar" a proposito, porque una emisora recien marcada como favorita no tiene grupo. Correcto para esa ruta, destructivo como primitiva de restauracion: los grupos volvian vacios y todo aterrizaba en Sin asignar. Se separa la ruta de restaurar, que respeta el grupo_id del backup y su orden. Ningun test lo detectaba porque el doble de pruebas era infiel: el fake conservaba el grupo que la implementacion real destruia, y no tenia restaurarGrupo, asi que esa ruta estaba sin cubrir. Los tests existentes lo esquivaban pasando siempre una lista de grupos vacia. Se corrige el doble. No hace falta subir la version del formato: el envoltorio ya llevaba todo. El coche perdia la lista al reconectar El contexto de reproduccion vivia solo en memoria, y el motor que arranca Android Auto es un proceso nuevo sin interfaz ni EstadoRadio, asi que al reconectar se reproducia la ultima emisora sin saber a que lista pertenecia y siguiente/anterior no hacian nada hasta entrar a favoritos a mano. Ahora se persiste el TIPO de contexto y, cuando aplica, el id del grupo, y se resuelve contra las listas vivas en cada salto: si el grupo cambia de contenido entre sesiones, el coche ve lo actual y no una foto vieja. Cadena de repliegue, decidida por el propietario: grupo vivo con la emisora dentro -> se recorre el grupo grupo vivo sin la emisora -> se permanece en el grupo, primera grupo vivo pero vacio -> se ensancha a favoritos grupo borrado -> favoritos sin favoritos -> comportamiento actual Honrar un grupo de una sola emisora exigia levantar dos barreras, no una: el resolutor y el propio _saltarEmisora, que se negaba a nombrar un grupo con menos de dos miembros. emisoraVecina queda intacta: su contrato de no saltar a ciegas es deliberado y se usa desde mas sitios, asi que el caso de "la emisora se salio del grupo" se trata en el flujo del salto. La puerta de entitlement no cambia: un conductor sin premium sigue recorriendo solo el conjunto gratuito, y un contexto congelado mientras pagaba se descarta en vez de recorrerse. Suite completa: 1501 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos preexistentes.
This commit is contained in:
@@ -877,7 +877,12 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final favRaw = data['favoritos'] as List? ?? [];
|
||||
for (final raw in favRaw) {
|
||||
final emisora = Emisora.fromMap(Map<String, dynamic>.from(raw as Map));
|
||||
await favoritos.agregar(emisora);
|
||||
// `restaurarFavorito`, NO `agregar`: `agregar` es la primitiva de
|
||||
// «marcar como favorita» y fuerza `sin_asignar` + un `orden` al final,
|
||||
// que es justo lo que la copia trae y hay que conservar. Con `agregar`
|
||||
// los grupos restaurados arriba volvían como cascarones vacíos y todas
|
||||
// las emisoras aterrizaban en «Sin asignar».
|
||||
await favoritos.restaurarFavorito(emisora);
|
||||
}
|
||||
|
||||
// ── Emisoras custom ───────────────────────────────────────────────────
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'app.dart';
|
||||
import 'estado/estado_entitlement.dart';
|
||||
import 'servicios/arranque_audio.dart';
|
||||
import 'servicios/contexto_reproduccion.dart';
|
||||
import 'servicios/musica_local_auto.dart';
|
||||
import 'servicios/navegacion_auto.dart';
|
||||
import 'servicios/servicio_audio.dart';
|
||||
@@ -196,6 +197,13 @@ Future<void> main() async {
|
||||
handler,
|
||||
leerEqActivoPersistido: ecualizador.leerActivo,
|
||||
guardarEqActivoPersistido: ecualizador.guardarActivo,
|
||||
// Skip context («in which list am I»). Bound here, on the audio
|
||||
// bootstrap path of EVERY engine, precisely because the headless
|
||||
// Android Auto engine builds no widget tree and therefore no
|
||||
// `EstadoRadio`: a context only the phone UI could write would be a
|
||||
// context the car could never have.
|
||||
leerContextoSalto: contextoSaltoPersistido,
|
||||
guardarContextoSalto: guardarContextoSalto,
|
||||
);
|
||||
// The handler is the only thing this app ever tears down
|
||||
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
|
||||
/// The skip context's persistence key.
|
||||
///
|
||||
/// Same headless-safe shape as `emisoras_destacadas.dart`: this file imports
|
||||
/// nothing but `shared_preferences` and the models, never `EstadoRadio` nor
|
||||
/// anything that drags a `ChangeNotifier` graph in. Android Auto starts the
|
||||
/// engine WITHOUT an Activity, so there is no widget tree and `EstadoRadio` is
|
||||
/// never constructed there — a context only that class could write would be a
|
||||
/// context the car can never have.
|
||||
///
|
||||
/// `contexto_reproduccion_test.dart` pins the literal so a rename fails loudly
|
||||
/// instead of silently leaving every driver context-less after an update.
|
||||
const claveContextoSalto = 'contexto_salto_v1';
|
||||
|
||||
/// Which LIST the driver is walking with the car's previous/next buttons.
|
||||
///
|
||||
/// The type is the durable part; the members are not. A group's contents
|
||||
/// change between sessions (the phone renames it, empties it, deletes it), so
|
||||
/// remembering the members would be remembering something that expires —
|
||||
/// [resolverListaContexto] re-resolves against the LIVE lists every time.
|
||||
enum TipoContextoSalto {
|
||||
/// One favourites group. The only type that carries [ContextoSalto.grupoFavoritosId].
|
||||
grupoFavoritos,
|
||||
favoritos,
|
||||
misEmisoras,
|
||||
|
||||
/// The `populares` catalogue snapshot.
|
||||
todas,
|
||||
|
||||
/// The free tier's curated set (`emisorasDestacadas`). The ONLY type that
|
||||
/// carries [ContextoSalto.uuidsOrdenados] — see that field.
|
||||
destacadas,
|
||||
}
|
||||
|
||||
/// The remembered playback context: the smallest thing that still identifies
|
||||
/// the list on the other side of a process restart.
|
||||
class ContextoSalto {
|
||||
/// One favourites group, named by its stable id.
|
||||
const ContextoSalto.grupo(String grupoId)
|
||||
: tipo = TipoContextoSalto.grupoFavoritos,
|
||||
grupoFavoritosId = grupoId,
|
||||
uuidsOrdenados = const [];
|
||||
|
||||
const ContextoSalto.favoritos()
|
||||
: tipo = TipoContextoSalto.favoritos,
|
||||
grupoFavoritosId = null,
|
||||
uuidsOrdenados = const [];
|
||||
|
||||
const ContextoSalto.misEmisoras()
|
||||
: tipo = TipoContextoSalto.misEmisoras,
|
||||
grupoFavoritosId = null,
|
||||
uuidsOrdenados = const [];
|
||||
|
||||
const ContextoSalto.todas()
|
||||
: tipo = TipoContextoSalto.todas,
|
||||
grupoFavoritosId = null,
|
||||
uuidsOrdenados = const [];
|
||||
|
||||
/// The free tier's set, FROZEN in [uuids] order.
|
||||
const ContextoSalto.destacadas(List<String> uuids)
|
||||
: tipo = TipoContextoSalto.destacadas,
|
||||
grupoFavoritosId = null,
|
||||
uuidsOrdenados = uuids;
|
||||
|
||||
final TipoContextoSalto tipo;
|
||||
|
||||
/// Set only for [TipoContextoSalto.grupoFavoritos].
|
||||
final String? grupoFavoritosId;
|
||||
|
||||
/// The frozen order, set only for [TipoContextoSalto.destacadas].
|
||||
///
|
||||
/// Every other type resolves against a list that HAS a stable, user-owned
|
||||
/// order (the favourites' `orden` column, the custom-stations file, the
|
||||
/// catalogue snapshot), so freezing it would only mean ignoring a reorder
|
||||
/// the user just made on the phone. The free set is the exception:
|
||||
/// `resolverEmisorasDestacadas` rebuilds it as `[última reproducida,
|
||||
/// ...curadas]`, so it REORDERS ITSELF as the driver skips, and `previous`
|
||||
/// stops being the inverse of `next`. Freezing that order is the fix.
|
||||
final List<String> uuidsOrdenados;
|
||||
|
||||
Map<String, dynamic> aMapa() => {
|
||||
'tipo': tipo.name,
|
||||
if (grupoFavoritosId != null) 'grupoId': grupoFavoritosId,
|
||||
if (uuidsOrdenados.isNotEmpty) 'uuids': uuidsOrdenados,
|
||||
};
|
||||
|
||||
/// Parses a persisted map, or `null` when it is unusable.
|
||||
///
|
||||
/// Tolerant on purpose: this payload survives app updates, backups and
|
||||
/// hand-edited preference files, and it is read from a steering-wheel
|
||||
/// button. An unreadable context must mean "derive it again", never a
|
||||
/// crash.
|
||||
static ContextoSalto? desdeMapa(Map<String, dynamic> mapa) {
|
||||
final tipoRaw = mapa['tipo'];
|
||||
if (tipoRaw is! String) return null;
|
||||
final tipo = TipoContextoSalto.values
|
||||
.where((t) => t.name == tipoRaw)
|
||||
.firstOrNull;
|
||||
if (tipo == null) return null;
|
||||
switch (tipo) {
|
||||
case TipoContextoSalto.grupoFavoritos:
|
||||
final grupoId = mapa['grupoId'];
|
||||
// A group context with no group is not a context.
|
||||
if (grupoId is! String || grupoId.isEmpty) return null;
|
||||
return ContextoSalto.grupo(grupoId);
|
||||
case TipoContextoSalto.favoritos:
|
||||
return const ContextoSalto.favoritos();
|
||||
case TipoContextoSalto.misEmisoras:
|
||||
return const ContextoSalto.misEmisoras();
|
||||
case TipoContextoSalto.todas:
|
||||
return const ContextoSalto.todas();
|
||||
case TipoContextoSalto.destacadas:
|
||||
final uuids = mapa['uuids'];
|
||||
if (uuids is! List) return null;
|
||||
return ContextoSalto.destacadas(uuids.whereType<String>().toList());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is ContextoSalto &&
|
||||
other.tipo == tipo &&
|
||||
other.grupoFavoritosId == grupoFavoritosId &&
|
||||
_mismosUuids(other.uuidsOrdenados, uuidsOrdenados);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
tipo,
|
||||
grupoFavoritosId,
|
||||
Object.hashAll(uuidsOrdenados),
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ContextoSalto(${tipo.name}, grupo=$grupoFavoritosId, '
|
||||
'uuids=${uuidsOrdenados.length})';
|
||||
|
||||
static bool _mismosUuids(List<String> a, List<String> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists [contexto]. Never throws — a failed write costs the driver a
|
||||
/// re-derivation, an exception would cost them the station change.
|
||||
Future<void> guardarContextoSalto(
|
||||
ContextoSalto contexto, {
|
||||
SharedPreferences? prefs,
|
||||
}) async {
|
||||
try {
|
||||
final resueltas = prefs ?? await SharedPreferences.getInstance();
|
||||
await resueltas.setString(claveContextoSalto, jsonEncode(contexto.aMapa()));
|
||||
} catch (_) {
|
||||
// Deliberately swallowed — see the doc above.
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the persisted context, or `null` when there is none, the payload is
|
||||
/// unreadable, or prefs themselves fail.
|
||||
///
|
||||
/// Follows the same inject-or-`getInstance()` convention as
|
||||
/// `esPremiumPersistido` and `resolverEmisorasDestacadas`, so a test pins
|
||||
/// prefs without a platform channel.
|
||||
Future<ContextoSalto?> contextoSaltoPersistido({
|
||||
SharedPreferences? prefs,
|
||||
}) async {
|
||||
try {
|
||||
final resueltas = prefs ?? await SharedPreferences.getInstance();
|
||||
final raw = resueltas.getString(claveContextoSalto);
|
||||
if (raw == null) return null;
|
||||
final decodificado = jsonDecode(raw);
|
||||
if (decodificado is! Map) return null;
|
||||
return ContextoSalto.desdeMapa(Map<String, dynamic>.from(decodificado));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The LIVE, ordered list a remembered [contexto] resolves to right now, or an
|
||||
/// empty list when it no longer resolves at all.
|
||||
///
|
||||
/// Pure — no handler, no prefs — so every degradation rule below is testable
|
||||
/// on its own. An empty result means "this memory has expired": the caller
|
||||
/// derives a fresh context instead (and, failing that, leaves playback alone —
|
||||
/// never jumps somewhere arbitrary mid-drive).
|
||||
///
|
||||
/// Degradation rules, all of them deliberate. The group chain is the owner's,
|
||||
/// decided from real use in the car:
|
||||
/// * remembered group ALIVE -> it is walked, even when the playing station
|
||||
/// has LEFT it (the caller then takes the group's first station) and even
|
||||
/// when it is down to a single member (skipping there simply leaves the
|
||||
/// driver where they are — a one-station group is still a group).
|
||||
/// * remembered group DELETED, or alive but EMPTY -> widen to all
|
||||
/// favourites, whether or not the playing station is still one of them:
|
||||
/// "if the whole group is gone, pick a station from the favourites".
|
||||
/// * no favourites left -> empty, i.e. the no-stations behaviour.
|
||||
/// * every OTHER context type still expires when the playing station left
|
||||
/// its list (unfavourited, removed from the catalogue snapshot) — the
|
||||
/// owner's decision was about the group chain only.
|
||||
/// * [TipoContextoSalto.destacadas] alone honours
|
||||
/// [ContextoSalto.uuidsOrdenados] — see that field for why.
|
||||
List<Emisora> resolverListaContexto({
|
||||
required ContextoSalto contexto,
|
||||
required Emisora actual,
|
||||
required List<Emisora> favoritos,
|
||||
required List<Emisora> misEmisoras,
|
||||
required List<Emisora> todas,
|
||||
required List<Emisora> destacadas,
|
||||
required List<GrupoFavoritos> grupos,
|
||||
}) {
|
||||
bool contiene(List<Emisora> lista) =>
|
||||
lista.any((e) => e.uuid == actual.uuid);
|
||||
|
||||
switch (contexto.tipo) {
|
||||
case TipoContextoSalto.grupoFavoritos:
|
||||
final grupoId = contexto.grupoFavoritosId;
|
||||
if (grupoId == null || grupoId == GrupoFavoritos.sinAsignarId) {
|
||||
return const [];
|
||||
}
|
||||
final existe = grupos.any((g) => g.id == grupoId);
|
||||
final miembros =
|
||||
favoritos.where((e) => e.grupoFavoritosId == grupoId).toList();
|
||||
if (existe && miembros.isNotEmpty) {
|
||||
// A surviving group is honoured as-is. The station does NOT have to
|
||||
// still be in it — the caller takes the group's first station rather
|
||||
// than wandering off to another list.
|
||||
return miembros;
|
||||
}
|
||||
// Group deleted (or alive but empty, which offers no station to take):
|
||||
// widen to all favourites. Unlike the other context types this does not
|
||||
// require the station to still BE a favourite — the caller takes the
|
||||
// first one.
|
||||
return favoritos;
|
||||
case TipoContextoSalto.favoritos:
|
||||
return contiene(favoritos) ? favoritos : const [];
|
||||
case TipoContextoSalto.misEmisoras:
|
||||
return contiene(misEmisoras) ? misEmisoras : const [];
|
||||
case TipoContextoSalto.todas:
|
||||
return contiene(todas) ? todas : const [];
|
||||
case TipoContextoSalto.destacadas:
|
||||
// The frozen order is authoritative. `actual` is resolvable from itself
|
||||
// so a station frozen into the walk from a previous session still
|
||||
// resolves even when it never belonged to the curated set.
|
||||
final porUuid = <String, Emisora>{
|
||||
for (final e in destacadas) e.uuid: e,
|
||||
actual.uuid: actual,
|
||||
};
|
||||
final lista = <Emisora>[
|
||||
for (final uuid in contexto.uuidsOrdenados)
|
||||
if (porUuid[uuid] != null) porUuid[uuid]!,
|
||||
];
|
||||
return lista.any((e) => e.uuid == actual.uuid) ? lista : const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// The free tier's frozen walk order: the curated set in its compiled-in
|
||||
/// order, with [actual] prepended when it does not belong to it.
|
||||
///
|
||||
/// Prepending rather than dropping keeps both buttons alive for a station left
|
||||
/// over from a premium session (or from `ultima_emisora_v1`): a walk the
|
||||
/// playing station is not part of would make `emisoraVecina` return `null` and
|
||||
/// both buttons would be dead.
|
||||
List<String> uuidsCongeladosDestacadas({
|
||||
required Emisora actual,
|
||||
required List<Emisora> destacadas,
|
||||
}) => [
|
||||
if (!destacadas.any((e) => e.uuid == actual.uuid)) actual.uuid,
|
||||
...destacadas.map((e) => e.uuid),
|
||||
];
|
||||
@@ -10,6 +10,7 @@ import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
import '../modelos/pista_local.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import 'contexto_reproduccion.dart';
|
||||
import 'emisoras_destacadas.dart';
|
||||
import 'musica_local_auto.dart';
|
||||
import 'persistencia_tolerante.dart';
|
||||
@@ -1246,6 +1247,52 @@ List<Emisora> listaParaSaltoEmisora({
|
||||
required List<Emisora> favoritos,
|
||||
required List<Emisora> misEmisoras,
|
||||
required List<Emisora> todas,
|
||||
}) {
|
||||
final contexto = contextoParaSaltoEmisora(
|
||||
actual: actual,
|
||||
favoritos: favoritos,
|
||||
misEmisoras: misEmisoras,
|
||||
todas: todas,
|
||||
);
|
||||
if (contexto == null) return const [];
|
||||
switch (contexto.tipo) {
|
||||
case TipoContextoSalto.grupoFavoritos:
|
||||
return favoritos
|
||||
.where((e) => e.grupoFavoritosId == contexto.grupoFavoritosId)
|
||||
.toList();
|
||||
case TipoContextoSalto.favoritos:
|
||||
return favoritos;
|
||||
case TipoContextoSalto.misEmisoras:
|
||||
return misEmisoras;
|
||||
case TipoContextoSalto.todas:
|
||||
return todas;
|
||||
case TipoContextoSalto.destacadas:
|
||||
// Never produced by [contextoParaSaltoEmisora] — the free set is
|
||||
// resolved by the handler, which owns the entitlement read.
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// The same decision as [listaParaSaltoEmisora], NAMED instead of materialised
|
||||
/// — so it can be remembered across a process restart.
|
||||
///
|
||||
/// The car kills and restarts the engine on every reconnect, and a list of
|
||||
/// stations is not something that survives that: its members change while the
|
||||
/// app is dead. The NAME of the list does survive, which is what
|
||||
/// [ContextoSalto] persists and [resolverListaContexto] re-resolves against
|
||||
/// whatever the lists hold next time.
|
||||
///
|
||||
/// [listaParaSaltoEmisora] is implemented on top of this so the walked list
|
||||
/// and the remembered context can never disagree (pinned by a test that runs
|
||||
/// both over the same scenarios).
|
||||
///
|
||||
/// Returns `null` when [actual] belongs to none of the three lists — the
|
||||
/// caller then has no context to remember and leaves playback alone.
|
||||
ContextoSalto? contextoParaSaltoEmisora({
|
||||
required Emisora actual,
|
||||
required List<Emisora> favoritos,
|
||||
required List<Emisora> misEmisoras,
|
||||
required List<Emisora> todas,
|
||||
}) {
|
||||
Emisora? enLista(List<Emisora> lista) {
|
||||
for (final e in lista) {
|
||||
@@ -1263,13 +1310,13 @@ List<Emisora> listaParaSaltoEmisora({
|
||||
if (grupo != GrupoFavoritos.sinAsignarId) {
|
||||
final delGrupo =
|
||||
favoritos.where((e) => e.grupoFavoritosId == grupo).toList();
|
||||
if (delGrupo.length > 1) return delGrupo;
|
||||
if (delGrupo.length > 1) return ContextoSalto.grupo(grupo);
|
||||
}
|
||||
return favoritos;
|
||||
return const ContextoSalto.favoritos();
|
||||
}
|
||||
if (enLista(misEmisoras) != null) return misEmisoras;
|
||||
if (enLista(todas) != null) return todas;
|
||||
return const [];
|
||||
if (enLista(misEmisoras) != null) return const ContextoSalto.misEmisoras();
|
||||
if (enLista(todas) != null) return const ContextoSalto.todas();
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The station before or after [actual] in [lista], wrapping around at both
|
||||
|
||||
@@ -10,9 +10,11 @@ import '../estado/estado_entitlement.dart' show esPremiumPersistido;
|
||||
import '../l10n/display_names.dart';
|
||||
import '../l10n/gen/app_localizations.dart';
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
import '../modelos/pista_local.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import 'cola_local.dart';
|
||||
import 'contexto_reproduccion.dart';
|
||||
import 'controlador_reconexion.dart';
|
||||
import 'emisoras_destacadas.dart';
|
||||
import 'musica_local_auto.dart';
|
||||
@@ -48,6 +50,16 @@ typedef LeerEqActivoPersistido = Future<bool?> Function();
|
||||
/// `ServicioEcualizador.guardarActivo`.
|
||||
typedef GuardarEqActivoPersistido = Future<void> Function(bool activo);
|
||||
|
||||
/// Read port for the persisted skip context — «which list is the driver
|
||||
/// walking» (see [ContextoSalto]). In production `main.dart` binds it to
|
||||
/// `contextoSaltoPersistido`; `null` for any caller with no disk (widget
|
||||
/// tests, fakes), which simply falls back to deriving the context on the spot.
|
||||
typedef LeerContextoSaltoPersistido = Future<ContextoSalto?> Function();
|
||||
|
||||
/// Write port for the same context. Bound to `guardarContextoSalto`.
|
||||
typedef GuardarContextoSaltoPersistido =
|
||||
Future<void> Function(ContextoSalto contexto);
|
||||
|
||||
/// Last value read from disk for the equalizer on/off flag, or `null` while
|
||||
/// nothing has been read yet.
|
||||
///
|
||||
@@ -111,11 +123,22 @@ void registrarHandler(
|
||||
PluriWaveAudioHandler handler, {
|
||||
LeerEqActivoPersistido? leerEqActivoPersistido,
|
||||
GuardarEqActivoPersistido? guardarEqActivoPersistido,
|
||||
LeerContextoSaltoPersistido? leerContextoSalto,
|
||||
GuardarContextoSaltoPersistido? guardarContextoSalto,
|
||||
}) {
|
||||
_handlerGlobal = handler;
|
||||
// Registered BEFORE the seeding below is awaited so that a toggle arriving
|
||||
// during the disk read is still persisted.
|
||||
handler.registrarPersistenciaEq(guardarEqActivoPersistido);
|
||||
// Same seam shape for the skip context (`contexto_reproduccion.dart`).
|
||||
// NOT seeded eagerly like the equalizer flag: the equalizer has to be right
|
||||
// before the first sample plays, whereas the context is only ever needed
|
||||
// when a skip button is pressed — so it is read LAZILY, at most once per
|
||||
// handler, and a driver who never presses skip pays no disk read at all.
|
||||
handler.registrarPersistenciaContextoSalto(
|
||||
leer: leerContextoSalto,
|
||||
guardar: guardarContextoSalto,
|
||||
);
|
||||
if (leerEqActivoPersistido != null) {
|
||||
unawaited(_sembrarEcualizadorDesdeDisco(handler, leerEqActivoPersistido));
|
||||
}
|
||||
@@ -1223,6 +1246,148 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
_persistirEqActivo = guardar;
|
||||
}
|
||||
|
||||
/// The remembered skip context — «which list am I walking» (see
|
||||
/// [ContextoSalto]). `null` until something derives or reads one.
|
||||
///
|
||||
/// FROZEN across skips on purpose. Before it, `_saltarEmisora` re-derived
|
||||
/// the list from `emisoraActual` on EVERY button press, so the walk drifted
|
||||
/// under the driver whenever the underlying list changed between two
|
||||
/// presses — and on the free tier it always does, because
|
||||
/// `resolverEmisorasDestacadas` rebuilds itself as `[última reproducida,
|
||||
/// ...curadas]` and therefore reorders itself as you skip, leaving
|
||||
/// `previous` no longer the inverse of `next`.
|
||||
ContextoSalto? _contextoSalto;
|
||||
|
||||
/// Whether the read port has already been consulted for this handler. The
|
||||
/// disk read happens at most once: after it, `_contextoSalto` (set or still
|
||||
/// null) is the answer.
|
||||
bool _contextoSaltoLeido = false;
|
||||
|
||||
/// True while [_saltarEmisora] is handing its destination to
|
||||
/// [playMediaItem]. It suppresses the context re-derivation that every
|
||||
/// OTHER play path performs — a skip is a move WITHIN the remembered
|
||||
/// context, never a new choice of context, and re-deriving there is exactly
|
||||
/// what un-freezes the walk.
|
||||
bool _saltandoEmisora = false;
|
||||
|
||||
LeerContextoSaltoPersistido? _leerContextoSalto;
|
||||
GuardarContextoSaltoPersistido? _guardarContextoSalto;
|
||||
|
||||
/// Injects the skip context's persistence ports (see [registrarHandler]).
|
||||
/// Both accept `null` — a handler with no disk simply derives the context
|
||||
/// every time, exactly as before this seam existed.
|
||||
void registrarPersistenciaContextoSalto({
|
||||
LeerContextoSaltoPersistido? leer,
|
||||
GuardarContextoSaltoPersistido? guardar,
|
||||
}) {
|
||||
_leerContextoSalto = leer;
|
||||
_guardarContextoSalto = guardar;
|
||||
}
|
||||
|
||||
/// The remembered context: memory first, then the read port ONCE.
|
||||
///
|
||||
/// Never throws — an unreadable context must mean "derive it again", not a
|
||||
/// dead steering-wheel button.
|
||||
Future<ContextoSalto?> _contextoSaltoRecordado() async {
|
||||
final enMemoria = _contextoSalto;
|
||||
if (enMemoria != null) return enMemoria;
|
||||
if (_contextoSaltoLeido) return null;
|
||||
_contextoSaltoLeido = true;
|
||||
final leer = _leerContextoSalto;
|
||||
if (leer == null) return null;
|
||||
try {
|
||||
return _contextoSalto = await leer();
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[PluriWave][ServicioAudio] no se pudo leer el contexto de salto: $e',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Freezes [contexto] in memory and pushes it through the write port.
|
||||
/// Never throws, and never writes the same context twice in a row.
|
||||
Future<void> _fijarContextoSalto(ContextoSalto contexto) async {
|
||||
_contextoSaltoLeido = true;
|
||||
if (_contextoSalto == contexto) return;
|
||||
_contextoSalto = contexto;
|
||||
final guardar = _guardarContextoSalto;
|
||||
if (guardar == null) return;
|
||||
try {
|
||||
await guardar(contexto);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[PluriWave][ServicioAudio] no se pudo guardar el contexto de '
|
||||
'salto: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Derives the context [actual] belongs to RIGHT NOW, or `null` when it
|
||||
/// cannot be derived (no browse source registered yet — a real race on a
|
||||
/// cold headless bind — or a station that is in none of the lists). A
|
||||
/// `null` deliberately LEAVES the remembered context alone rather than
|
||||
/// clearing it: the memory is the only thing that still knows the answer.
|
||||
///
|
||||
/// A free-tier driver's context is ALWAYS the curated set
|
||||
/// (`emisorasDestacadas`), frozen in its compiled-in order. Entitlement is
|
||||
/// re-read here rather than cached so a purchase mid-session takes effect
|
||||
/// on the next play, exactly like every other gate in this file.
|
||||
Future<ContextoSalto?> _derivarContextoSalto(Emisora actual) async {
|
||||
if (!await esPremiumPersistido()) {
|
||||
return ContextoSalto.destacadas(
|
||||
uuidsCongeladosDestacadas(
|
||||
actual: actual,
|
||||
destacadas: emisorasDestacadas,
|
||||
),
|
||||
);
|
||||
}
|
||||
final fuente = _fuenteNavegacionGlobal;
|
||||
if (fuente == null) return null;
|
||||
return contextoParaSaltoEmisora(
|
||||
actual: actual,
|
||||
favoritos: await fuente.favoritos(),
|
||||
misEmisoras: await fuente.misEmisoras(),
|
||||
todas: await fuente.todas(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Records the skip context for a station that is STARTING.
|
||||
///
|
||||
/// Wired into [playMediaItem], which is the single choke point every
|
||||
/// external play funnels through — that is the whole point, because a
|
||||
/// context set only on the browse path is a context the bug walks straight
|
||||
/// back around. The paths that reach it:
|
||||
/// 1. `playFromMediaId` — a car browse tap (`emisora:<uuid>`), including
|
||||
/// the `recent` resume row Android Auto shows on every reconnect.
|
||||
/// 2. `playFromSearch` — a voice command, empty ("resume") or named.
|
||||
/// 3. `ServicioAudio.reproducir` — the phone UI, via `playMediaItem`.
|
||||
/// 4. `_saltarEmisora`'s own destination — SUPPRESSED by
|
||||
/// [_saltandoEmisora], because a skip moves within the context rather
|
||||
/// than choosing a new one.
|
||||
/// 5. `_reproducirEntradaCola` — local-queue playback. It does NOT go
|
||||
/// through `playMediaItem`, and a local track is filtered out below
|
||||
/// anyway.
|
||||
///
|
||||
/// Local files are ignored: their `id` is a `content://` URI, they have
|
||||
/// their own queue (`_colaLocal`), and letting one overwrite the station
|
||||
/// context would leave the radio walking a list it never chose. Same scheme
|
||||
/// test as [_reproduciendoRadio].
|
||||
Future<void> _recordarContextoSalto(MediaItem item) async {
|
||||
try {
|
||||
final esquema = Uri.tryParse(item.id)?.scheme.toLowerCase();
|
||||
if (esquema != 'http' && esquema != 'https') return;
|
||||
final contexto = await _derivarContextoSalto(emisoraDesdeMediaItem(item));
|
||||
if (contexto == null) return;
|
||||
await _fijarContextoSalto(contexto);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[PluriWave][ServicioAudio] no se pudo recordar el contexto de '
|
||||
'salto: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The player's live position, used to keep `updatePosition` honest on
|
||||
/// every `playbackState` push. Exposed so tests can assert the re-push
|
||||
/// without reaching into the private player.
|
||||
@@ -1871,6 +2036,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
Future<void> playMediaItem(MediaItem mediaItem) async {
|
||||
_colaLocal = null;
|
||||
_avanzandoCola = false;
|
||||
// The ONE place the skip context is recorded — see
|
||||
// [_recordarContextoSalto] for the enumerated paths that arrive here.
|
||||
// Fire-and-forget: it reads prefs and the browse source, and a station
|
||||
// change must not wait on either.
|
||||
if (!_saltandoEmisora) unawaited(_recordarContextoSalto(mediaItem));
|
||||
return _encolarCambioFuente(mediaItem);
|
||||
}
|
||||
|
||||
@@ -2614,10 +2784,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
|
||||
/// Station-to-station skipping for the car's transport row.
|
||||
///
|
||||
/// The list to walk is resolved by [listaParaSaltoEmisora]: the narrowest
|
||||
/// list the current station actually belongs to, favourites first. Anything
|
||||
/// unresolvable — no source, no current station, a station that is in no
|
||||
/// list, a single-entry list — leaves playback untouched. Never throws;
|
||||
/// The list to walk is the REMEMBERED context re-resolved against the live
|
||||
/// lists ([resolverListaContexto]), falling back to a fresh derivation
|
||||
/// ([listaParaSaltoEmisora]'s decision, named) only once the memory resolves
|
||||
/// to nothing at all. Anything unresolvable — no source, no current station,
|
||||
/// a station that is in no list — leaves playback untouched, and so does a
|
||||
/// single-entry list the station is already on. Never throws;
|
||||
/// this runs from a hardware/steering-wheel button and an exception here
|
||||
/// would surface as the app going silent mid-drive.
|
||||
Future<void> _saltarEmisora({required bool haciaAtras}) async {
|
||||
@@ -2628,21 +2800,75 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
// rather than the buttons being refused. A free driver cycles the free
|
||||
// set — which always has at least [emisorasDestacadas.length] entries,
|
||||
// so neither button is ever a dead end — and a premium driver keeps the
|
||||
// narrowest-context walk (`listaParaSaltoEmisora`) unchanged.
|
||||
final List<Emisora> lista;
|
||||
if (await esPremiumPersistido()) {
|
||||
final fuente = _fuenteNavegacionGlobal;
|
||||
if (fuente == null) return;
|
||||
lista = listaParaSaltoEmisora(
|
||||
actual: actual,
|
||||
favoritos: await fuente.favoritos(),
|
||||
misEmisoras: await fuente.misEmisoras(),
|
||||
todas: await fuente.todas(),
|
||||
);
|
||||
} else {
|
||||
lista = await resolverEmisorasDestacadas();
|
||||
// narrowest-context walk (`contextoParaSaltoEmisora`) unchanged.
|
||||
final premium = await esPremiumPersistido();
|
||||
// Only a premium walk ever touches the browse source; the free set is
|
||||
// compiled in, so a free driver needs no source at all (and a cold
|
||||
// headless bind may not have one yet).
|
||||
final fuente = premium ? _fuenteNavegacionGlobal : null;
|
||||
final favoritos =
|
||||
fuente == null ? const <Emisora>[] : await fuente.favoritos();
|
||||
final misEmisoras =
|
||||
fuente == null ? const <Emisora>[] : await fuente.misEmisoras();
|
||||
final todas = fuente == null ? const <Emisora>[] : await fuente.todas();
|
||||
final grupos =
|
||||
fuente == null ? const <GrupoFavoritos>[] : await fuente.grupos();
|
||||
|
||||
List<Emisora> listaDe(ContextoSalto? contexto) =>
|
||||
contexto == null
|
||||
? const []
|
||||
: resolverListaContexto(
|
||||
contexto: contexto,
|
||||
actual: actual,
|
||||
favoritos: favoritos,
|
||||
misEmisoras: misEmisoras,
|
||||
todas: todas,
|
||||
destacadas: emisorasDestacadas,
|
||||
grupos: grupos,
|
||||
);
|
||||
|
||||
// 1. The REMEMBERED context wins — that is the whole point: the car
|
||||
// restarts the engine on every reconnect, and re-deriving from
|
||||
// scratch is what lost the driver's list. It is only honoured while
|
||||
// it still resolves to a walkable list (`resolverListaContexto`
|
||||
// returns empty once it has expired).
|
||||
var contexto = await _contextoSaltoRecordado();
|
||||
// Entitlement gate: a FREE driver cycles the free set and nothing else.
|
||||
// A context frozen while the account was paying (a group, all
|
||||
// favourites, the catalogue) is DISCARDED rather than walked, so a
|
||||
// downgrade cannot keep skipping through premium content.
|
||||
if (!premium && contexto?.tipo != TipoContextoSalto.destacadas) {
|
||||
contexto = null;
|
||||
}
|
||||
final destino = emisoraVecina(actual, lista, haciaAtras: haciaAtras);
|
||||
var lista = listaDe(contexto);
|
||||
// 2. EXPIRED (or absent) memory: derive it again from what is live now.
|
||||
// Expired means "resolves to nothing at all". A list of ONE is not
|
||||
// expired: a favourites group down to a single station is still the
|
||||
// group the driver chose, and re-deriving there is exactly what used
|
||||
// to widen the walk to every favourite behind their back.
|
||||
if (lista.isEmpty) {
|
||||
contexto = await _derivarContextoSalto(actual);
|
||||
lista = listaDe(contexto);
|
||||
}
|
||||
// 3. Freeze whatever we are actually about to walk, so the NEXT press
|
||||
// (this process or the next one) walks the same list.
|
||||
if (contexto != null && lista.isNotEmpty) {
|
||||
await _fijarContextoSalto(contexto);
|
||||
}
|
||||
|
||||
// The context can resolve to a list the playing station is NOT on: the
|
||||
// owner's rule is that a surviving group (or, once the group is gone,
|
||||
// the favourites) keeps the walk even when the station left it. Taking
|
||||
// `lista.first` is that rule. `emisoraVecina` is deliberately NOT
|
||||
// loosened for it — its "not in the list means do nothing" contract is
|
||||
// a safety property other callers rely on, so the exception lives here,
|
||||
// where the context has already said which list to stay on.
|
||||
final destino =
|
||||
lista.isEmpty
|
||||
? null
|
||||
: lista.any((e) => e.uuid == actual.uuid)
|
||||
? emisoraVecina(actual, lista, haciaAtras: haciaAtras)
|
||||
: lista.first;
|
||||
// Reported: in the car these buttons did nothing for radio. Every early
|
||||
// return here is silent, so the log has to say WHICH one fired --
|
||||
// an empty list (the station matched none of the three) and a station
|
||||
@@ -2650,10 +2876,19 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
debugPrint(
|
||||
'[PluriWave][ServicioAudio] salto emisora atras=$haciaAtras '
|
||||
'actual=${actual.nombre} uuid=${actual.uuid} '
|
||||
'lista=${lista.length} destino=${destino?.nombre ?? "NINGUNO"}',
|
||||
'contexto=$contexto lista=${lista.length} '
|
||||
'destino=${destino?.nombre ?? "NINGUNO"}',
|
||||
);
|
||||
if (destino == null) return;
|
||||
await playMediaItem(mediaItemParaEmisora(destino, l10n: _textos));
|
||||
// Suppresses the re-derivation `playMediaItem` performs for every other
|
||||
// play path: a skip MOVES WITHIN the context, it does not choose a new
|
||||
// one. Without this latch the walk un-freezes on every press.
|
||||
_saltandoEmisora = true;
|
||||
try {
|
||||
await playMediaItem(mediaItemParaEmisora(destino, l10n: _textos));
|
||||
} finally {
|
||||
_saltandoEmisora = false;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[PluriWave][ServicioAudio] Error saltando de emisora: $e');
|
||||
}
|
||||
|
||||
@@ -213,6 +213,43 @@ class ServicioFavoritos {
|
||||
);
|
||||
}
|
||||
|
||||
/// Restaura un favorito tal como estaba en el dispositivo de origen,
|
||||
/// preservando su `orden` y su `grupo_id`.
|
||||
/// Usado exclusivamente por importarConfig, igual que [restaurarGrupo].
|
||||
///
|
||||
/// Existe porque [agregar] NO sirve como primitiva de restauración: es la
|
||||
/// primitiva de «marcar como favorita» y fuerza `sin_asignar` más un
|
||||
/// `orden` al final de la lista, cosa correcta para una emisora recién
|
||||
/// marcada (que de verdad no pertenece a ningún grupo) y destructiva para
|
||||
/// una copia de seguridad, que trae ambos campos. Reusarla era la causa de
|
||||
/// que los grupos volvieran vacíos tras restaurar.
|
||||
///
|
||||
/// El grupo se valida igual que en [asignarGrupo]: un `grupo_id` que no
|
||||
/// existe en `grupos_favoritos` cae a [GrupoFavoritos.sinAsignarId], de modo
|
||||
/// que una copia editada a mano o restaurada a medias no puede dejar
|
||||
/// emisoras apuntando a un grupo inexistente. `importarConfig` restaura los
|
||||
/// grupos ANTES de este bucle, así que en el camino normal siempre existen.
|
||||
Future<void> restaurarFavorito(Emisora emisora) async {
|
||||
final db = await _database;
|
||||
final existe =
|
||||
Sqflite.firstIntValue(
|
||||
await db.rawQuery(
|
||||
'SELECT COUNT(*) FROM grupos_favoritos WHERE id = ?',
|
||||
[emisora.grupoFavoritosId],
|
||||
),
|
||||
) ??
|
||||
0;
|
||||
final restaurada =
|
||||
existe > 0
|
||||
? emisora
|
||||
: emisora.copyWith(grupoFavoritosId: GrupoFavoritos.sinAsignarId);
|
||||
await db.insert(
|
||||
'favoritos',
|
||||
restaurada.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> eliminarGrupo(String id) async {
|
||||
if (id == GrupoFavoritos.sinAsignarId) return;
|
||||
final db = await _database;
|
||||
|
||||
Reference in New Issue
Block a user