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:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user