fix: el coche recuerda la ultima emisora y deja de publicar una sesion fantasma

Tres defectos preexistentes alrededor de la reanudacion en Android Auto. Ninguno
es una regresion: el consumidor (la raiz `recent`) se añadio en septiembre y es
lo que dejo el hueco a la vista.

La ultima emisora solo la escribia el telefono

La clave `ultima_emisora_v1` tenia como unico escritor a
`EstadoRadio._persistirUltimaEmisora`, y `EstadoRadio` solo existe si hay arbol
de widgets. El motor que arranca Android Auto es headless de verdad, asi que una
sesion que ocurriera solo en el coche jamas actualizaba la clave y al reconectar
se ofrecia la emisora de la ultima vez que se uso el movil.

El handler recibe ahora sus puertos de lectura y escritura, con la misma forma
que los del ecualizador y el contexto de salto, y escribe desde `_cambiarFuente`:
el cuello de botella por el que pasan todas las rutas -- telefono, toque en el
coche, voz, saltos, avance de cola y la propia reanudacion.

Se ELIMINA el escritor del telefono en vez de sumar un segundo. Dos escritores
independientes de la misma clave acaban divergiendo siempre; es exactamente lo
que ya costo varias rondas con el flag del ecualizador.

Las pistas locales quedan excluidas: un `content://` guardado como ultima
emisora seria una fila de reanudacion que no resuelve a nada.

play() sin fuente levantaba un servicio en primer plano vacio

just_audio publica `playing:true` antes de comprobar si hay fuente, asi que un
`play()` en frio no tocaba la plataforma pero si emitia ese estado sobre
`processingState: idle`. audio_service entraba en estado de reproduccion
mientras el estado nativo seguia en NONE: notificacion con boton de pausa, cero
audio, sin titulo ni caratula, y un Future que no se completaba nunca. El coche
enruta su tecla de play directamente ahi.

Ahora `play()` sin fuente abierta restaura la ultima emisora por la ruta normal,
y si no hay nada que restaurar no toca el reproductor ni publica nada.

En frio no habia metadatos que enseñar

El unico `mediaItem.add` util vivia dentro de `_cambiarFuente`, asi que en un
motor recien arrancado el lado nativo nunca recibia metadatos. Se siembra el
`mediaItem` de la emisora persistida sin cargar ni reproducir nada, con guarda
antes y despues de la lectura de disco para no pisar una emisora ya sonando.

`getMediaItem` resolvia solo contra el universo completo -- vacio en el motor del
coche -- mientras `porUuid` si caia en las destacadas. El coche podia navegar una
emisora destacada y luego no resolver su ficha. Ambos usan ahora la misma ruta.

Suite completa: 1529 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
This commit is contained in:
2026-09-06 00:08:09 +02:00
parent a0fae57219
commit 8fc3d99fbd
6 changed files with 815 additions and 69 deletions
+13 -25
View File
@@ -338,24 +338,6 @@ class EstadoRadio extends ChangeNotifier {
}
}
/// 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.
void _escucharErroresReproduccion() {
_suscripcionEstadoAudio = audio.estadoStream.listen((estado) {
@@ -375,9 +357,12 @@ class EstadoRadio extends ChangeNotifier {
final actual = audio.emisoraActual;
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
_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));
// Issue 4's write used to live here as well. It is gone: the handler
// persists every station itself from `_cambiarFuente`, which is the
// same source change that moved `audio.emisoraActual` and is the
// reason this branch runs at all. Writing again here would make the
// key's final value depend on how two independent fire-and-forget
// chains interleave on a fast station switch.
}
notifyListeners();
});
@@ -588,10 +573,13 @@ class EstadoRadio extends ChangeNotifier {
}
_emisoraSeleccionada = emisora;
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));
// Issue 4's `ultima_emisora_v1` write used to be here. It now happens
// once, inside the handler's `_cambiarFuente`, which `audio.reproducir`
// below reaches for this very station — see
// [GuardarUltimaEmisoraPersistida]. Persisting here as well would have
// left the key with TWO fire-and-forget writers whose relative order
// decides the value after a fast A -> B switch, and this one cannot see
// the revision guard that already cancels a superseded change.
try {
await audio.reproducir(emisora);
if (revision != _revisionReproduccion) return;
+10
View File
@@ -11,6 +11,7 @@ import 'app.dart';
import 'estado/estado_entitlement.dart';
import 'servicios/arranque_audio.dart';
import 'servicios/contexto_reproduccion.dart';
import 'servicios/emisoras_destacadas.dart';
import 'servicios/musica_local_auto.dart';
import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_audio.dart';
@@ -210,6 +211,15 @@ Future<void> main() async {
// context the car could never have.
leerContextoSalto: contextoSaltoPersistido,
guardarContextoSalto: guardarContextoSalto,
// Last played station (`ultima_emisora_v1`). Bound here for the SAME
// reason as the skip context: `EstadoRadio` — which used to be its only
// writer — belongs to the widget tree, and the Android Auto engine
// builds none, so a session that happened only in the car never updated
// the key and the head unit was offered whatever the PHONE last played.
// The write port is now the key's single writer; the read port feeds the
// cold-start metadata seed and the bare-`play()` resume.
leerUltimaEmisora: ultimaEmisoraPersistida,
guardarUltimaEmisora: guardarUltimaEmisoraPersistida,
);
// The handler is the only thing this app ever tears down
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
+22
View File
@@ -155,6 +155,28 @@ Future<bool> esEmisoraGratuitaPorUuid(
Future<Emisora?> 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<void> 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<Emisora?> _ultimaEmisora({SharedPreferences? prefs}) async {
+224 -15
View File
@@ -65,6 +65,25 @@ typedef LeerPresetPersistido = Future<PresetEcualizador?> Function();
typedef GuardarContextoSaltoPersistido =
Future<void> Function(ContextoSalto contexto);
/// Read port for the persisted last-played station (`ultima_emisora_v1`).
/// Bound to `ultimaEmisoraPersistida` in `main.dart`; `null` for any caller
/// with no disk (widget tests, fakes), which then neither seeds the cold-start
/// metadata nor resumes anything from a bare `play()`.
typedef LeerUltimaEmisoraPersistida = Future<Emisora?> Function();
/// Write port for the same key, and — since this seam exists — its ONLY
/// writer.
///
/// It had none: `EstadoRadio._persistirUltimaEmisora` was the sole writer and
/// `EstadoRadio` is built by the lazy `ChangeNotifierProvider` in `app.dart`,
/// which a headless Android Auto engine (`AudioServicePlugin.java:75-111`
/// builds `new FlutterEngine(applicationContext)` with no Activity) never
/// reaches. So a session that happened ONLY in the car never updated the key,
/// and on the next connect the head unit was offered the station from the
/// last time the PHONE was used — the same stale record
/// `resolverEmisorasDestacadas` puts first in the free tier's featured folder.
typedef GuardarUltimaEmisoraPersistida = Future<void> Function(Emisora emisora);
/// Last value read from disk for the equalizer on/off flag, or `null` while
/// nothing has been read yet.
///
@@ -178,6 +197,8 @@ void registrarHandler(
LeerPresetPersistido? leerPresetPersistido,
LeerContextoSaltoPersistido? leerContextoSalto,
GuardarContextoSaltoPersistido? guardarContextoSalto,
LeerUltimaEmisoraPersistida? leerUltimaEmisora,
GuardarUltimaEmisoraPersistida? guardarUltimaEmisora,
}) {
_handlerGlobal = handler;
// Registered BEFORE the seeding below is awaited so that a toggle arriving
@@ -192,6 +213,20 @@ void registrarHandler(
leer: leerContextoSalto,
guardar: guardarContextoSalto,
);
// Same seam shape again for the last-played station. The WRITE half is
// registered before anything is awaited for the same reason the equalizer's
// is: a station change arriving during the read below must still be
// persisted.
handler.registrarPersistenciaUltimaEmisora(
leer: leerUltimaEmisora,
guardar: guardarUltimaEmisora,
);
// Cold-start metadata (A3). Seeded eagerly, like the equalizer flag and
// unlike the skip context: a head unit asks for the now-playing metadata
// the moment it binds, and `audio_service` cannot send any while
// `mediaItem` is null. Fire-and-forget and internally guarded, so it is a
// no-op without a read port and never clobbers a live station.
unawaited(handler.sembrarUltimaEmisoraDesdeDisco());
if (leerEqActivoPersistido != null) {
unawaited(_sembrarEcualizadorDesdeDisco(handler, leerEqActivoPersistido));
}
@@ -1373,6 +1408,127 @@ class PluriWaveAudioHandler extends BaseAudioHandler
_guardarContextoSalto = guardar;
}
LeerUltimaEmisoraPersistida? _leerUltimaEmisora;
GuardarUltimaEmisoraPersistida? _guardarUltimaEmisora;
/// Injects the last-played station's persistence ports (see
/// [GuardarUltimaEmisoraPersistida]). Both accept `null` — a handler with no
/// disk simply never remembers and never restores, exactly as before this
/// seam existed.
void registrarPersistenciaUltimaEmisora({
LeerUltimaEmisoraPersistida? leer,
GuardarUltimaEmisoraPersistida? guardar,
}) {
_leerUltimaEmisora = leer;
_guardarUltimaEmisora = guardar;
}
/// Whether [item] is a RADIO STATION rather than a local track.
///
/// `ultima_emisora_v1` is read back as an `emisora:<uuid>` row by the car's
/// recent root and by `resolverEmisorasDestacadas`, so a `content://` local
/// track written there would occupy that slot with a row that resolves to
/// nothing when tapped. Every station path builds its item through
/// [mediaItemParaEmisora] or `reproducirPorMediaId`, both of which stamp
/// `extras['uuid']`; `construirMediaItemColaLocal`/`reproducirPistaLocal`
/// stamp `extras['documentId']` instead. Private: it is asserted through
/// the real source-change path (a local track must leave the record
/// untouched), not as a predicate in isolation.
static bool _esMediaItemDeEmisora(MediaItem item) {
final uuid = item.extras?['uuid'];
return uuid is String && uuid.isNotEmpty;
}
/// Best-effort write of the last-played station through the injected port.
///
/// Never throws and never blocks the source change: a persistence failure
/// must cost the driver a stale resume row, never the station they just
/// asked for. Traced rather than swallowed, so a dead write channel is
/// visible in a car logcat instead of looking exactly like a working one.
Future<void> _persistirUltimaEmisora(MediaItem item) async {
if (!_esMediaItemDeEmisora(item)) return;
final guardar = _guardarUltimaEmisora;
if (guardar == null) return;
try {
await guardar(emisoraDesdeMediaItem(item));
} catch (e) {
debugPrint(
'[PluriWave][ServicioAudio] no se pudo guardar la ultima emisora: $e',
);
}
}
/// The persisted last-played station, or `null` when there is no port, no
/// record, or the read failed. Never throws — an unreadable record must
/// mean "nothing to resume", not a dead Play button.
Future<Emisora?> _ultimaEmisoraRecordada() async {
final leer = _leerUltimaEmisora;
if (leer == null) return null;
try {
return await leer();
} catch (e) {
debugPrint(
'[PluriWave][ServicioAudio] no se pudo leer la ultima emisora: $e',
);
return null;
}
}
/// Whether a source has actually been opened on this handler — set by
/// [_cambiarFuente] once it is past its revision guard, cleared by [stop].
///
/// Deliberately NOT `mediaItem.value != null`: since
/// [sembrarUltimaEmisoraDesdeDisco] publishes metadata on a cold start
/// WITHOUT loading anything, the two questions stopped being the same one.
/// Reading the metadata there would send a bare `play()` straight into
/// `_player.play()` on a player with no source, which is defect A2 all over
/// again.
bool _fuenteAbierta = false;
/// Publishes the persisted station's metadata on a COLD start, without
/// touching the player.
///
/// The handler constructor only wires streams, and the only `mediaItem.add`
/// sites are the duration update (which needs an item to already exist),
/// [_cambiarFuente] and [stop] (which publishes `null`). So on a headless
/// bind `mediaItem` was null, `audio_service.dart:1029-1033` returned before
/// `setMediaItem`, and the head unit received no metadata at all — no title,
/// no artwork, nothing to put on the now-playing surface.
///
/// Checked before AND after the disk read: a station that started while the
/// read was in flight owns the metadata, and renaming what the driver is
/// actually listening to would be far worse than a blank tile.
Future<void> sembrarUltimaEmisoraDesdeDisco() async {
if (mediaItem.value != null || _fuenteAbierta) return;
final ultima = await _ultimaEmisoraRecordada();
if (ultima == null) return;
if (mediaItem.value != null || _fuenteAbierta) return;
mediaItem.add(mediaItemParaEmisora(ultima, l10n: _textos));
}
/// Resolves the persisted station and starts it through the ordinary play
/// path. Returns `false` when there was nothing to resume.
///
/// Routed through [playMediaItem] on purpose — the revision guard, the
/// queue clearing, the skip-context recording and the terminal-state floor
/// all live behind that choke point, and a parallel path would have to
/// re-earn every one of them.
Future<bool> _reanudarUltimaEmisora() async {
final ultima = await _ultimaEmisoraRecordada();
if (ultima == null) return false;
try {
await playMediaItem(mediaItemParaEmisora(ultima, l10n: _textos));
} catch (e) {
// The failure is already published to `playbackState` by
// `_cambiarFuente`; a transport button must not additionally throw out
// of the handler (Spec "never propagate from the handler").
debugPrint(
'[PluriWave][ServicioAudio] no se pudo reanudar la ultima emisora: $e',
);
}
return true;
}
/// The remembered context: memory first, then the read port ONCE.
///
/// Never throws — an unreadable context must mean "derive it again", not a
@@ -2330,6 +2486,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler
if (revision != _revisionFuente) return;
this.mediaItem.add(mediaItem);
emisoraActual = _emisoraDesdeMediaItem(mediaItem);
// A source is now genuinely open on this handler — see [_fuenteAbierta].
_fuenteAbierta = true;
// THE SINGLE WRITER of `ultima_emisora_v1`. Placed here, past the
// revision guard and beside the `mediaItem` publish, because this is the
// one point EVERY play path funnels through: the phone (`EstadoRadio.
// reproducir` -> `ServicioAudio.reproducir` -> `playMediaItem`), a car
// browse tap (`playFromMediaId`), voice (`playFromSearch`), a skip, a
// queue advance and the bare-`play()` resume below.
//
// `EstadoRadio._persistirUltimaEmisora` was deleted rather than kept
// alongside this. Two writers of one key is exactly the shape that
// produced the equalizer divergence twice: both wrote fire-and-forget, so
// on a fast A -> B station switch the interleaving of two independent
// unawaited chains decided the final value, and the phone's copy could
// not see the revision guard that already cancels a superseded change.
// One writer behind one serialized queue has neither problem, and it is
// the only writer that exists on the engine Android Auto starts.
unawaited(_persistirUltimaEmisora(mediaItem));
// A new source is being opened, so no previous terminal error owns the
// screen any more (see [_errorTerminal]).
_errorTerminal = false;
@@ -2868,7 +3042,31 @@ class PluriWaveAudioHandler extends BaseAudioHandler
Future<void> reaplicarEcualizador() => _activarEcualizador();
@override
Future<void> play() {
Future<void> play() async {
// NO SOURCE LOADED — the cold-engine case, and the reason this override
// is no longer a one-liner.
//
// `AudioService.java:920` routes the car's `KEYCODE_MEDIA_PLAY` straight
// in here, and there is no `prepare`/`onPrepare`/`prepareFromMediaId`
// override anywhere in this app to have loaded anything first. Handed to
// `_player.play()`, `just_audio.dart:937-967` publishes
// `_playingSubject.add(true)` BEFORE its `_audioSource != null` gate: the
// platform is never touched, the returned Future NEVER completes, and yet
// `playing: true` is forwarded by [manejarEstadoPlayer] over
// `processingState: idle`. `AudioService.java:559-560` then runs
// `enterPlayingState()` while `getPlaybackState()` is `STATE_NONE` — a
// PluriWave notification with a pause button, no audio, no title and no
// artwork, or a `ForegroundServiceStartNotAllowedException` on API 31+.
//
// So: resolve the persisted station and go through the ordinary play
// path, and when there is nothing to resume touch neither the player nor
// `playbackState` and complete immediately. Doing nothing is the correct
// answer there — a phantom foreground session is strictly worse than a
// Play button that did not find anything to play.
if (!_fuenteAbierta) {
await _reanudarUltimaEmisora();
return;
}
_intencionReproducir = true;
// Fresh user intent: whatever terminal error was standing no longer owns
// the screen, so stop masking the player's `idle` (see [_errorTerminal]).
@@ -2918,6 +3116,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler
// The session is over: whatever this run proved about the mount does not
// carry into the next one (see [_reproduccionEstablecida]).
_reproduccionEstablecida = false;
// The session is over and `mediaItem` is cleared below, so the next bare
// `play()` — a car transport button on a torn-down session — must resolve
// a station again instead of calling `_player.play()` on nothing (see
// [_fuenteAbierta] and [play]).
_fuenteAbierta = false;
_revisionFuente++;
await _player.stop();
// Publish `idle` OURSELVES rather than trusting the player to emit it.
@@ -3394,12 +3597,26 @@ class PluriWaveAudioHandler extends BaseAudioHandler
@override
Future<MediaItem?> getMediaItem(String mediaId) async {
try {
final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return null;
final universo = await _universoCompleto(fuente);
final constructor = ConstructorArbolAuto();
final emisora = constructor.resolver(mediaId, universo);
return emisora == null ? null : constructor.itemEmisora(emisora);
final uuid = uuidDeMediaIdEmisora(mediaId);
// Not a station id at all (`pista:`, `carpeta_local_*:`, `eq_preset:`,
// a folder, or `emisora:` with an empty tail) — unchanged behaviour.
if (uuid == null) return null;
// Was `_universoCompleto` (favoritos + misEmisoras + todas) alone, which
// is EMPTY on a headless bind, while `porUuid` has always also fallen
// back to the featured set. The car could therefore BROWSE a featured
// station and then fail to resolve its media item — an asymmetry, not a
// policy. Delegating to `porUuid` removes it (and short-circuits on the
// first list that matches instead of always awaiting all three), and the
// `FuenteEmisorasAutoDestacadas` stand-in covers the window before
// `main.dart` registers the real source, exactly as [playFromMediaId]
// already does.
final fuente =
_fuenteNavegacionGlobal ??
FuenteEmisorasAutoDestacadas(await resolverEmisorasDestacadas());
final emisora = await fuente.porUuid(uuid);
return emisora == null
? null
: ConstructorArbolAuto().itemEmisora(emisora);
} catch (_) {
return null;
}
@@ -3693,12 +3910,4 @@ class PluriWaveAudioHandler extends BaseAudioHandler
_ => Future.value(const []),
};
Future<List<Emisora>> _universoCompleto(FuenteEmisorasAuto fuente) async {
final listas = await Future.wait([
fuente.favoritos(),
fuente.misEmisoras(),
fuente.todas(),
]);
return listas.expand((lista) => lista).toList();
}
}
+39 -29
View File
@@ -7,6 +7,7 @@ import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -792,21 +793,16 @@ void main() {
'(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(
esPremium: () => true,
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);
// The record is now written by the audio handler's `_cambiarFuente`
// (`GuardarUltimaEmisoraPersistida`), which is the SINGLE writer of
// `ultima_emisora_v1` and the only one that also exists on the headless
// Android Auto engine — `EstadoRadio` used to write it too and no
// longer does. Seeded through that same production function here, so
// this test covers what `EstadoRadio` actually owns (the RESTORE) with
// a real payload instead of one a fake invented. The write itself is
// covered end to end in
// `test/servicios/servicio_audio_ultima_emisora_test.dart`.
await guardarUltimaEmisoraPersistida(emisora);
final audioDos = FakeServicioAudio();
final estadoDos = EstadoRadio(
@@ -848,9 +844,18 @@ void main() {
});
test('una emisora seleccionada desde el auto (fuera de reproducir()) '
'también se recuerda para la próxima instancia', () async {
'deja de estar ensombrecida por la seleccion previa del telefono',
() async {
// The PERSISTENCE half of this scenario moved to the handler, which is
// the only writer that exists on a car-only session — it is covered by
// «playFromMediaId desde el coche persiste ESA emisora» in
// `test/servicios/servicio_audio_ultima_emisora_test.dart`. What
// `EstadoRadio` still owns here, and what this test now pins, is the
// shadowing fix: a car selection bypasses `reproducir()`, so without
// the `estadoStream` listener `_emisoraSeleccionada` would keep
// shadowing the car's station on the `emisoraActual` getter.
final audio = _AudioControlado();
final estadoUno = EstadoRadio(
final estado = EstadoRadio(
esPremium: () => true,
audio: audio,
favoritos: FakeServicioFavoritos(),
@@ -859,7 +864,16 @@ void main() {
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoUno.inicializar();
await estado.inicializar();
final desdeElTelefono = emisoraDemo(
uuid: 'phone-picked',
nombre: 'Elegida en el telefono',
);
unawaited(estado.reproducir(desdeElTelefono));
audio.completar(desdeElTelefono.uuid);
await Future<void>.delayed(Duration.zero);
expect(estado.emisoraActual?.uuid, desdeElTelefono.uuid);
final desdeCoche = emisoraDemo(
uuid: 'auto-remembered',
nombre: 'Recordada desde el auto',
@@ -867,18 +881,14 @@ void main() {
audio.seleccionarDesdeAuto(desdeCoche);
await Future<void>.delayed(Duration.zero);
final estadoDos = EstadoRadio(
esPremium: () => true,
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
expect(
estado.emisoraActual?.uuid,
desdeCoche.uuid,
reason:
'the car changed the station without going through reproducir(); '
'the phone UI must follow it instead of keeping the previous '
'selection on screen',
);
await estadoDos.inicializar();
expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid);
});
});
}
@@ -0,0 +1,507 @@
import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:just_audio/just_audio.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/handlers_audio.dart';
/// Resuming the last station in Android Auto — the three defects that made a
/// car-only session unable to remember, restart or even NAME what it was
/// playing.
///
/// Every test here runs with NO widget tree and NO browse source registered:
/// that is the engine Android Auto actually starts
/// (`AudioServicePlugin.java:75-111` builds `new FlutterEngine(context)` with
/// no Activity), so `EstadoRadio` — the only thing that used to write
/// `ultima_emisora_v1` — is never constructed there.
///
/// A1. The last station was written EXCLUSIVELY by `EstadoRadio`, so a
/// session that happened only in the car never updated the key and the
/// head unit was offered the station from the last time the PHONE was
/// used. The same key feeds `resolverEmisorasDestacadas`, so the free
/// tier's featured folder was stale too.
///
/// A2. `play()` with no source called `_player.play()`, and
/// `just_audio.dart:937-967` publishes `_playingSubject.add(true)`
/// BEFORE the `_audioSource != null` gate — so the platform was never
/// touched, the returned Future never completed, and `playing: true`
/// was forwarded over `processingState: idle`.
/// `AudioService.java:559-560` then runs `enterPlayingState()` while
/// `getPlaybackState()` is `STATE_NONE`: a notification with a pause
/// button, no audio, no title and no artwork (or a
/// `ForegroundServiceStartNotAllowedException` on API 31+).
///
/// A3. `mediaItem` was null on a cold start — the only `mediaItem.add` sites
/// are the duration update, `_cambiarFuente` and `stop` — so
/// `audio_service.dart:1029-1033` returned before `setMediaItem` and the
/// native side got no metadata at all.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final crearHandler = registrarHandlersLiberables();
late _GuionReproductor guion;
/// The free set's first station: resolvable from the binary alone, so it
/// works on a bind where no browse source was ever registered — which is
/// the whole point of these tests.
const emisoraFip = Emisora(
uuid: 'pw-destacada-fip',
nombre: 'FIP',
url: 'https://icecast.radiofrance.fr/fip-midfi.mp3',
pais: 'France',
codigoPais: 'FR',
idioma: 'french',
);
setUp(() {
guion = _GuionReproductor();
PluriWaveAudioHandler.fabricaReproductorPrueba =
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
// Fresh install = free tier (`esPremiumPersistido` is `getBool(...) ??
// false`) and no `ultima_emisora_v1`.
SharedPreferences.setMockInitialValues({});
});
tearDown(() {
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
});
group('A1 — el coche escribe la ultima emisora', () {
test(
'playFromMediaId desde el coche persiste ESA emisora por el puerto '
'inyectado, sin arbol de widgets',
() async {
final prefs = await SharedPreferences.getInstance();
final handler = crearHandler();
final guardadas = <Emisora>[];
registrarHandler(
handler,
guardarUltimaEmisora: (emisora) async {
guardadas.add(emisora);
await guardarUltimaEmisoraPersistida(emisora, prefs: prefs);
},
);
await handler.playFromMediaId('emisora:${emisoraFip.uuid}');
await pumpEventQueue();
expect(
guardadas.map((e) => e.uuid),
[emisoraFip.uuid],
reason:
'a car-only session must update `ultima_emisora_v1` itself — '
'`EstadoRadio` is never built on a headless engine',
);
final persistida = await ultimaEmisoraPersistida(prefs: prefs);
expect(persistida?.uuid, emisoraFip.uuid);
expect(
persistida?.url,
emisoraFip.url,
reason:
'the record has to be PLAYABLE: it is what the recent root and '
'`resolverEmisorasDestacadas` hand back to the head unit',
);
},
);
test('playMediaItem directo (voz, telefono) persiste igual', () async {
final handler = crearHandler();
final guardadas = <Emisora>[];
registrarHandler(
handler,
guardarUltimaEmisora: (emisora) async => guardadas.add(emisora),
);
await handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
);
await pumpEventQueue();
expect(guardadas.map((e) => e.uuid), ['uuid-ejemplo']);
expect(guardadas.single.url, 'https://ejemplo/stream');
});
test(
'una pista local NO se persiste como ultima emisora',
() async {
final handler = crearHandler();
final guardadas = <Emisora>[];
registrarHandler(
handler,
guardarUltimaEmisora: (emisora) async => guardadas.add(emisora),
);
await handler.playMediaItem(
const MediaItem(
id: 'content://media/audio/7',
title: 'Pista local',
extras: {'documentId': 'doc-7'},
),
);
await pumpEventQueue();
expect(
guardadas,
isEmpty,
reason:
'`ultima_emisora_v1` feeds the recent root and the featured '
'folder as an `emisora:<uuid>` row — a `content://` track '
'there is a row that does nothing when tapped',
);
},
);
test('un fallo del puerto se traza y NUNCA propaga', () async {
final handler = crearHandler();
registrarHandler(
handler,
guardarUltimaEmisora: (_) async => throw StateError('sin disco'),
);
await expectLater(
handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
),
completes,
);
await pumpEventQueue();
});
test('sin puerto (tests de widget, fakes) no pasa nada', () async {
final handler = crearHandler();
registrarHandler(handler);
await expectLater(
handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
),
completes,
);
});
});
group('A2 — play() sin fuente no publica una sesion fantasma', () {
test(
'con una emisora persistida, play() resuelve y arranca ESA emisora: el '
'reproductor recibe su url',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await pumpEventQueue();
unawaited(handler.play().catchError((_) {}));
await pumpEventQueue();
expect(
guion.urlsSolicitadas,
contains(emisoraFip.url),
reason:
'`AudioService.java:920` routes the car KEYCODE_MEDIA_PLAY '
'straight into play(); on a cold engine there is no source, so '
'it has to resolve the persisted station instead',
);
},
);
test(
'y NINGUN estado publicado lleva playing:true sobre processingState '
'idle',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await pumpEventQueue();
final fantasmas = <PlaybackState>[];
final sub = handler.playbackState.listen((estado) {
if (estado.playing &&
estado.processingState == AudioProcessingState.idle) {
fantasmas.add(estado);
}
});
unawaited(handler.play().catchError((_) {}));
await pumpEventQueue();
await sub.cancel();
expect(
fantasmas,
isEmpty,
reason:
'playing:true over idle is what makes `AudioService.java:559` '
'call enterPlayingState() with STATE_NONE — a PluriWave '
'notification with a pause button, no audio and no title',
);
},
);
test(
'sin nada persistido: no se toca el reproductor, no hay estado '
'fantasma y play() no se queda colgado',
() async {
final handler = crearHandler();
registrarHandler(handler, leerUltimaEmisora: () async => null);
await pumpEventQueue();
final fantasmas = <PlaybackState>[];
final sub = handler.playbackState.listen((estado) {
if (estado.playing &&
estado.processingState == AudioProcessingState.idle) {
fantasmas.add(estado);
}
});
await expectLater(
handler.play().timeout(const Duration(seconds: 2)),
completes,
);
await pumpEventQueue();
await sub.cancel();
expect(
guion.llamadasPlay,
0,
reason:
'with nothing to restore the player must not be touched at '
'all: `just_audio` publishes playing:true before its source '
'gate and never completes the future it returns',
);
expect(guion.llamadasSetUrl, 0);
expect(fantasmas, isEmpty);
},
);
test(
'con una fuente ya abierta, play() sigue siendo la reanudacion de '
'siempre (pausa -> play no reabre nada)',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await handler.playMediaItem(
const MediaItem(
id: 'https://ejemplo/stream',
title: 'Ejemplo',
extras: {'uuid': 'uuid-ejemplo'},
),
);
await pumpEventQueue();
await handler.pause();
final urlsAntes = List<String>.from(guion.urlsSolicitadas);
await handler.play();
await pumpEventQueue();
expect(
guion.urlsSolicitadas,
urlsAntes,
reason:
'a resume must NOT re-open the source, and must never replace '
'the live station with the persisted one',
);
expect(handler.intencionReproducir, isTrue);
},
);
});
group('A3 — arranque en frio: el coche recibe metadatos', () {
test(
'con una emisora persistida se publica su mediaItem SIN arrancar '
'reproduccion',
() async {
final handler = crearHandler();
registrarHandler(
handler,
leerUltimaEmisora: () async => emisoraFip,
);
await pumpEventQueue();
expect(
handler.mediaItem.value,
isNotNull,
reason:
'`audio_service.dart:1029-1033` returns before setMediaItem '
'when mediaItem is null, so a cold engine sent the head unit '
'no metadata whatsoever',
);
expect(handler.mediaItem.value?.id, emisoraFip.url);
expect(handler.playbackState.value.playing, isFalse);
expect(
guion.llamadasSetUrl,
0,
reason:
'publishing metadata must not open a stream: a cold bind '
'happens on every reconnect and must stay silent',
);
},
);
test('sin nada persistido el mediaItem sigue vacio', () async {
final handler = crearHandler();
registrarHandler(handler, leerUltimaEmisora: () async => null);
await pumpEventQueue();
expect(handler.mediaItem.value, isNull);
});
test(
'una emisora que ya empezo a sonar NO es pisada por la siembra',
() async {
final handler = crearHandler();
final lectura = Completer<Emisora?>();
registrarHandler(handler, leerUltimaEmisora: () => lectura.future);
await handler.playMediaItem(
const MediaItem(
id: 'https://enVivo/stream',
title: 'En vivo',
extras: {'uuid': 'uuid-en-vivo'},
),
);
lectura.complete(emisoraFip);
await pumpEventQueue();
expect(
handler.mediaItem.value?.id,
'https://enVivo/stream',
reason:
'the seed exists to fill a VOID; clobbering the live station '
'would rename what the driver is listening to',
);
},
);
});
group('getMediaItem resuelve tambien el set destacado', () {
test(
'sin fuente de navegacion registrada, una emisora destacada resuelve',
() async {
final handler = crearHandler();
registrarHandler(handler);
final item = await handler.getMediaItem('emisora:${emisoraFip.uuid}');
expect(
item,
isNotNull,
reason:
'`porUuid` already falls back to the featured set, so the car '
'could BROWSE a featured station and not resolve its media '
'item — the asymmetry is the bug',
);
expect(item?.id, 'emisora:${emisoraFip.uuid}');
expect(item?.title, emisoraFip.nombre);
},
);
test('un id que no es de emisora sigue devolviendo null', () async {
final handler = crearHandler();
registrarHandler(handler);
expect(await handler.getMediaItem('pista:doc-1'), isNull);
expect(await handler.getMediaItem('emisora:'), isNull);
});
});
}
/// Shared script/observation record for every [_ReproductorFalso] the handler
/// builds (it rebuilds its player on every source change, so counters cannot
/// live on the instance).
class _GuionReproductor {
int llamadasPlay = 0;
int llamadasSetUrl = 0;
final urlsSolicitadas = <String>[];
}
/// An [AudioPlayer] double that reproduces the ONE `just_audio` behaviour
/// defect A2 is about: `play()` (`just_audio.dart:937-967`) publishes
/// `playing: true` BEFORE the `_audioSource != null` gate, and with no source
/// it never touches the platform and never completes the future it returned.
class _ReproductorFalso extends AudioPlayer {
_ReproductorFalso(
this._guion,
AudioPipeline pipeline,
AudioLoadConfiguration carga,
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga);
final _GuionReproductor _guion;
final _estados = StreamController<PlayerState>.broadcast();
/// A fresh player has no source, exactly like the real one.
bool _fuenteCargada = false;
@override
Stream<PlayerState> get playerStateStream => _estados.stream;
@override
Future<Duration?> setUrl(
String url, {
Map<String, String>? headers,
Duration? initialPosition,
bool preload = true,
dynamic tag,
}) async {
_guion.llamadasSetUrl++;
_guion.urlsSolicitadas.add(url);
_fuenteCargada = true;
return null;
}
@override
Future<void> play() {
_guion.llamadasPlay++;
if (!_estados.isClosed) {
_estados.add(
PlayerState(
true,
_fuenteCargada ? ProcessingState.ready : ProcessingState.idle,
),
);
}
// The dangling future: with no source, upstream `play()` awaits a
// `_playingSubject` transition the platform will never produce.
if (!_fuenteCargada) return Completer<void>().future;
return Future<void>.value();
}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> dispose() async {
await _estados.close();
}
}