feat: restaurar los grupos de favoritos al importar y recordar la lista del coche
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m27s

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:
2026-09-02 22:56:10 +02:00
parent 241f81e535
commit 5f35ab7d6a
12 changed files with 1994 additions and 27 deletions
+255 -20
View File
@@ -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');
}