fix(radio): persist the last-played station across restarts

EstadoRadio.emisoraActual only ever reflected in-memory state
(_emisoraSeleccionada or the live audio service), so stopping playback
and reopening the app left the Escuchar hero empty even though the
user had a station selected right before closing it.

Persist the station whenever it changes (reproducir(), and the
Android-Auto out-of-band reconciliation path) and restore it as
_emisoraSeleccionada on the next cold start, only when nothing is
already selected. This never touches the audio service directly: no
playback starts and estadoStream/estaSonando stay at their stopped
default, matching how every consumer already gates "is it playing" on
the playback-status stream rather than on emisoraActual itself.
This commit is contained in:
2026-07-30 19:18:14 +02:00
parent c6ab295c54
commit 727e18737a
2 changed files with 339 additions and 229 deletions
+55
View File
@@ -173,6 +173,10 @@ class EstadoRadio extends ChangeNotifier {
static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1'; static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1';
static const _keyOrdenListas = 'orden_listas_emisoras_v1'; static const _keyOrdenListas = 'orden_listas_emisoras_v1';
static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1'; static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1';
// Issue 4 (feedback-pruebas): last-played station, so the Escuchar hero
// keeps showing "what I was listening to" (stopped, not playing) after a
// full app restart instead of going empty.
static const _keyUltimaEmisora = 'ultima_emisora_v1';
static const _timerSuenoPresetsDefecto = <int>[ static const _timerSuenoPresetsDefecto = <int>[
180, 180,
300, 300,
@@ -300,6 +304,50 @@ class EstadoRadio extends ChangeNotifier {
_cargarEmisorasCustom(), _cargarEmisorasCustom(),
]); ]);
await _normalizarEmisoraPreferida(); await _normalizarEmisoraPreferida();
await _restaurarUltimaEmisora();
}
/// Issue 4 (feedback-pruebas): restores the last-played station as a
/// STOPPED `emisoraActual` on a cold start. Only fills the gap — if
/// something is ALREADY selected (a real play already ran concurrently),
/// this is a no-op. Never touches `audio`: no playback starts, no network
/// request is made, `estadoStream`/`estaSonando` stay at their fresh
/// "detenido" default, exactly like every other consumer of
/// `emisoraActual` already expects (they gate "is it playing" on the
/// separate playback-status stream, never on `emisoraActual != null`).
Future<void> _restaurarUltimaEmisora() async {
if (_emisoraSeleccionada != null || audio.emisoraActual != null) return;
try {
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyUltimaEmisora);
if (raw == null) return;
final mapa = jsonDecode(raw) as Map<String, dynamic>;
_emisoraSeleccionada = Emisora.fromMap(mapa);
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ultima_emisora',
detalle: 'restaurar',
razon: e.toString(),
);
}
}
/// Best-effort remembers [emisora] as the last used station (issue 4) so
/// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and-
/// forget, same treatment [reproducir] already gives other non-critical
/// side effects (e.g. `radio.registrarClick`) — a failed write here must
/// never block or fail actual playback.
Future<void> _persistirUltimaEmisora(Emisora emisora) async {
try {
final prefs = await _resolverPrefs();
await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap()));
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ultima_emisora',
detalle: 'persistir ${emisora.uuid}',
razon: e.toString(),
);
}
} }
/// Escucha el stream de estado del audio y gestiona errores de reproducción. /// Escucha el stream de estado del audio y gestiona errores de reproducción.
@@ -321,6 +369,9 @@ class EstadoRadio extends ChangeNotifier {
final actual = audio.emisoraActual; final actual = audio.emisoraActual;
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) { if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
_emisoraSeleccionada = actual; _emisoraSeleccionada = actual;
// Issue 4: an Android-Auto-initiated selection is a real station
// change too — remember it the same way `reproducir` does.
unawaited(_persistirUltimaEmisora(actual));
} }
notifyListeners(); notifyListeners();
}); });
@@ -508,6 +559,10 @@ class EstadoRadio extends ChangeNotifier {
} }
_emisoraSeleccionada = emisora; _emisoraSeleccionada = emisora;
notifyListeners(); notifyListeners();
// Issue 4: remembers the station the user just picked so it survives a
// restart — fire-and-forget, same treatment as `radio.registrarClick`
// below (a persistence failure here must never block playback).
unawaited(_persistirUltimaEmisora(emisora));
try { try {
await audio.reproducir(emisora); await audio.reproducir(emisora);
if (revision != _revisionReproduccion) return; if (revision != _revisionReproduccion) return;
+122 -67
View File
@@ -412,14 +412,10 @@ void main() {
); );
}); });
group( group('EstadoRadio — emisoras custom: lectura tolerante y guardia de '
'EstadoRadio — emisoras custom: lectura tolerante y guardia de ' 'degradacion (persistence-resilience)', () {
'degradacion (persistence-resilience)', test('entradas invalidas se omiten sin perder las validas ni fabricar '
() { 'uuid (D5 parcial)', () async {
test(
'entradas invalidas se omiten sin perder las validas ni fabricar '
'uuid (D5 parcial)',
() async {
final archivo = await _crearArchivoCustomRaw( final archivo = await _crearArchivoCustomRaw(
jsonEncode([ jsonEncode([
{'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'}, {'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'},
@@ -446,13 +442,10 @@ void main() {
'custom-1', 'custom-1',
'custom-2', 'custom-2',
}); });
}, });
);
test( test('si resolver la ruta del archivo custom falla, la inicializacion '
'si resolver la ruta del archivo custom falla, la inicializacion ' 'sobrevive y las cargas hermanas no se pierden (D5 IO-fail)', () async {
'sobrevive y las cargas hermanas no se pierden (D5 IO-fail)',
() async {
final estado = EstadoRadio( final estado = EstadoRadio(
audio: FakeServicioAudio(), audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(), favoritos: FakeServicioFavoritos(),
@@ -470,13 +463,10 @@ void main() {
await estado.inicializar(); await estado.inicializar();
expect(estado.emisorasCustom, isEmpty); expect(estado.emisorasCustom, isEmpty);
}, });
);
test( test('JSON invalido al nivel superior pone en cuarentena el archivo '
'JSON invalido al nivel superior pone en cuarentena el archivo ' 'original (D5 parse-fail)', () async {
'original (D5 parse-fail)',
() async {
final archivo = await _crearArchivoCustomRaw('{bad'); final archivo = await _crearArchivoCustomRaw('{bad');
final estado = EstadoRadio( final estado = EstadoRadio(
audio: FakeServicioAudio(), audio: FakeServicioAudio(),
@@ -494,13 +484,10 @@ void main() {
expect(await sidecar.exists(), isTrue); expect(await sidecar.exists(), isTrue);
expect(await sidecar.readAsString(), '{bad'); expect(await sidecar.readAsString(), '{bad');
expect(await archivo.exists(), isFalse); expect(await archivo.exists(), isFalse);
}, });
);
test( test('agregar tras la cuarentena escribe solo la nueva emisora y no '
'agregar tras la cuarentena escribe solo la nueva emisora y no ' 'toca el sidecar (D5, autoridad de escritura restaurada)', () async {
'toca el sidecar (D5, autoridad de escritura restaurada)',
() async {
final archivo = await _crearArchivoCustomRaw('{bad'); final archivo = await _crearArchivoCustomRaw('{bad');
final estado = EstadoRadio( final estado = EstadoRadio(
audio: FakeServicioAudio(), audio: FakeServicioAudio(),
@@ -518,25 +505,20 @@ void main() {
await estado.agregarEmisoraCustom(nueva); await estado.agregarEmisoraCustom(nueva);
expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']); expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']);
final contenidoVivo = final contenidoVivo = jsonDecode(await archivo.readAsString()) as List;
jsonDecode(await archivo.readAsString()) as List;
expect(contenidoVivo, hasLength(1)); expect(contenidoVivo, hasLength(1));
expect((contenidoVivo.single as Map)['uuid'], 'nueva-1'); expect((contenidoVivo.single as Map)['uuid'], 'nueva-1');
expect(await sidecar.readAsString(), sidecarPrevio); expect(await sidecar.readAsString(), sidecarPrevio);
}, });
);
test( test('fallo de IO al leer suprime la escritura y no se restaura con un '
'fallo de IO al leer suprime la escritura y no se restaura con un ' 'alta explicita (D5 IO-fail)', () async {
'alta explicita (D5 IO-fail)',
() async {
final espia = _ArchivoEspia( final espia = _ArchivoEspia(
path: '/fake/emisoras_custom.json', path: '/fake/emisoras_custom.json',
exists: () async => true, exists: () async => true,
readAsString: readAsString:
() async => throw const FileSystemException( () async =>
'fallo simulado de lectura', throw const FileSystemException('fallo simulado de lectura'),
),
); );
final estado = EstadoRadio( final estado = EstadoRadio(
audio: FakeServicioAudio(), audio: FakeServicioAudio(),
@@ -554,18 +536,12 @@ void main() {
emisoraDemo(uuid: 'nueva-x', nombre: 'X'), emisoraDemo(uuid: 'nueva-x', nombre: 'X'),
); );
expect( expect(estado.emisorasCustom.map((e) => e.uuid), contains('nueva-x'));
estado.emisorasCustom.map((e) => e.uuid),
contains('nueva-x'),
);
expect(espia.writeAsStringCalls, 0); expect(espia.writeAsStringCalls, 0);
}, });
);
test( test('si ya existe un sidecar .corrupt no lo pisa, solo limpia el '
'si ya existe un sidecar .corrupt no lo pisa, solo limpia el ' 'archivo vivo (D5)', () async {
'archivo vivo (D5)',
() async {
final archivo = await _crearArchivoCustomRaw('{bad-nuevo'); final archivo = await _crearArchivoCustomRaw('{bad-nuevo');
final sidecar = File('${archivo.path}.corrupt'); final sidecar = File('${archivo.path}.corrupt');
await sidecar.writeAsString('contenido-previo-X'); await sidecar.writeAsString('contenido-previo-X');
@@ -584,19 +560,13 @@ void main() {
expect(await sidecar.readAsString(), 'contenido-previo-X'); expect(await sidecar.readAsString(), 'contenido-previo-X');
expect(await archivo.exists(), isFalse); expect(await archivo.exists(), isFalse);
expect(estado.emisorasCustom, isEmpty); expect(estado.emisorasCustom, isEmpty);
}, });
); });
},
);
group( group('EstadoRadio — Android Auto: snapshot en vivo y reconciliación '
'EstadoRadio — Android Auto: snapshot en vivo y reconciliación ' '(android-auto-media)', () {
'(android-auto-media)', test('empuja un snapshot actualizado a la fuente registrada cuando '
() { 'cambian favoritos/custom/populares', () async {
test(
'empuja un snapshot actualizado a la fuente registrada cuando '
'cambian favoritos/custom/populares',
() async {
final fuenteAuto = _FuenteEmisorasAutoEspia(); final fuenteAuto = _FuenteEmisorasAutoEspia();
final archivo = await _crearArchivoCustom([ final archivo = await _crearArchivoCustom([
emisoraDemo(uuid: 'custom-auto-1', nombre: 'Custom Auto'), emisoraDemo(uuid: 'custom-auto-1', nombre: 'Custom Auto'),
@@ -635,13 +605,10 @@ void main() {
fuenteAuto.ultimoFavoritos?.map((e) => e.uuid), fuenteAuto.ultimoFavoritos?.map((e) => e.uuid),
contains('fav-auto-1'), contains('fav-auto-1'),
); );
}, });
);
test( test('reconcilia _emisoraSeleccionada cuando la selección viene desde '
'reconcilia _emisoraSeleccionada cuando la selección viene desde ' 'el auto (no via reproducir())', () async {
'el auto (no via reproducir())',
() async {
final audio = _AudioControlado(); final audio = _AudioControlado();
final estado = EstadoRadio( final estado = EstadoRadio(
audio: audio, audio: audio,
@@ -661,10 +628,98 @@ void main() {
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
expect(estado.emisoraActual?.uuid, desdeCoche.uuid); expect(estado.emisoraActual?.uuid, desdeCoche.uuid);
}, });
});
group('EstadoRadio — última emisora reproducida (feedback-pruebas #4)', () {
test('la última emisora reproducida sobrevive a una nueva instancia '
'(reinicio) como emisoraActual DETENIDA -- no arranca audio, no '
'reproduce, sólo queda seleccionada', () async {
final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM');
final estadoUno = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
); );
}, await estadoUno.inicializar();
await estadoUno.reproducir(emisora);
await estadoUno.detenerReproduccion();
// Lets the fire-and-forget persistence write settle before
// spinning up the "restart" instance.
await Future<void>.delayed(Duration.zero);
final audioDos = FakeServicioAudio();
final estadoDos = EstadoRadio(
audio: audioDos,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
); );
await estadoDos.inicializar();
expect(estadoDos.emisoraActual?.uuid, emisora.uuid);
expect(estadoDos.emisoraActual?.nombre, emisora.nombre);
expect(
audioDos.estaSonando,
isFalse,
reason: 'restoring the last station must never auto-start audio',
);
});
test('sin ninguna emisora previamente reproducida, emisoraActual sigue '
'siendo null tras inicializar (instalación nueva)', () async {
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(estado.emisoraActual, isNull);
});
test('una emisora seleccionada desde el auto (fuera de reproducir()) '
'también se recuerda para la próxima instancia', () async {
final audio = _AudioControlado();
final estadoUno = EstadoRadio(
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoUno.inicializar();
final desdeCoche = emisoraDemo(
uuid: 'auto-remembered',
nombre: 'Recordada desde el auto',
);
audio.seleccionarDesdeAuto(desdeCoche);
await Future<void>.delayed(Duration.zero);
final estadoDos = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoDos.inicializar();
expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid);
});
});
} }
/// Spy [FuenteEmisorasAuto] that only records the last snapshot pushed by /// Spy [FuenteEmisorasAuto] that only records the last snapshot pushed by