Files
pluriwave/lib/servicios/servicio_audio.dart
T
FreeTLab 8fc3d99fbd 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.
2026-09-06 00:08:09 +02:00

3914 lines
180 KiB
Dart

import 'dart:async';
import 'dart:ui' show Locale, PlatformDispatcher;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
import 'package:just_audio/just_audio.dart';
import 'package:rxdart/rxdart.dart';
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';
import 'navegacion_auto.dart';
import 'servicio_audio_session.dart';
/// Estado de reproducción expuesto al UI.
enum EstadoReproduccion {
detenido,
cargando,
reproduciendo,
pausado,
/// Transient network stall: the handler is retrying with backoff (S7-R2).
/// UI surfaces it as a loading indicator, never as an error dialog (S7-R3).
reconectando,
error,
}
// ─────────────────────────────────────────────────────────────────────────────
// Handler global — inicializado en main.dart con AudioService.init
// ─────────────────────────────────────────────────────────────────────────────
PluriWaveAudioHandler? _handlerGlobal;
/// Read port for the persisted equalizer on/off flag (eq-estado-unico item A).
/// In production `main.dart` binds it to `ServicioEcualizador.leerActivo`,
/// which needs nothing but the `SharedPreferences` instance already resolved
/// before `AudioService.init`. `null` for any caller that has no disk (widget
/// tests, fakes) — seeding is then skipped entirely.
typedef LeerEqActivoPersistido = Future<bool?> Function();
/// Write port for the same flag (eq-estado-unico item B). Bound to
/// `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();
/// Read port for the equalizer's persisted PRESET, the exact sibling of
/// [LeerEqActivoPersistido]. Bound to `ServicioEcualizador.leerPresetPrincipal`
/// in `main.dart`; `null` for any caller with no disk (widget tests, fakes).
typedef LeerPresetPersistido = Future<PresetEcualizador?> Function();
/// Write port for the same context. Bound to `guardarContextoSalto`.
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.
///
/// This exists purely to close the construction window: `AudioService.init`
/// builds the handler through its `builder` callback, and only AFTER that
/// future resolves does `main.dart` reach [registrarHandler]. A car tap
/// landing inside that window would otherwise hit a handler whose flag had
/// never seen disk. Once one engine has read the value, any handler built
/// afterwards starts from it instead of from a hardcoded default.
bool? _eqActivoPersistido;
/// The equalizer's initial on/off state for a freshly started engine.
///
/// Pure seam (eq-estado-unico item A): [PluriWaveAudioHandler] used to
/// hardcode `_ecualizadorActivo = true`, so a process started HEADLESSLY by
/// Android Auto — no Activity, no Provider tree, so no
/// `EstadoEcualizador.cargarPersistido()` — played with the equalizer forced
/// on while disk and the phone UI both said off. That is the reported «suena
/// muy alto con el boton desactivado».
///
/// `null` means "nothing was ever persisted" (first install, or a wiped
/// preference) and keeps the historical default of ON. It must NOT be
/// confused with "off": a user who has never touched the toggle expects the
/// equalizer on, and the app has always behaved that way.
bool estadoEqInicial({required bool? persistido}) => persistido ?? true;
/// The two native operations an equalizer on/off transition is made of, as
/// values so their ORDER is a testable fact rather than the incidental shape
/// of a method body.
///
/// Off-device neither operation is observable (`_eqDisponible` is `false`, and
/// `AndroidEqualizer.parameters` never completes without an attached player),
/// so before this enum the sequence could only be asserted by reading the
/// source — which is how the wrong one shipped.
enum PasoEcualizador {
/// Write the current preset's band levels into the native effect.
ganancias,
/// Flip the native effect on or off (`AudioEffect.setEnabled`).
habilitacion,
}
/// Reads the persisted equalizer flag through [leer] exactly once and seeds
/// [handler] with it, without ever writing back.
///
/// Never throws: an unreadable preference store leaves the handler on
/// [estadoEqInicial]'s default rather than taking down the audio bootstrap.
Future<void> _sembrarEcualizadorDesdeDisco(
PluriWaveAudioHandler handler,
LeerEqActivoPersistido leer,
) async {
bool? persistido;
try {
persistido = await leer();
} catch (e) {
debugPrint(
'[PluriWave][ServicioAudio] no se pudo leer el estado EQ persistido: $e',
);
persistido = null;
}
_eqActivoPersistido = persistido;
await handler.sembrarEcualizadorActivo(
estadoEqInicial(persistido: persistido),
);
}
/// Reads the persisted equalizer PRESET through [leer] exactly once and seeds
/// [handler] with it.
///
/// The exact sibling of [_sembrarEcualizadorDesdeDisco], and it exists for the
/// exact same reason. eq-estado-unico gave the on/off FLAG a UI-independent
/// link to disk; the preset never got one, so `_presetActual` stayed on its
/// hardcoded `PresetEcualizador.flat`. On a phone that is invisible —
/// `EstadoEcualizador` owns the real preset and pushes it into the handler as
/// soon as the widget tree exists. On the headless engine Android Auto starts
/// there is no widget tree and no `EstadoEcualizador`, so a car toggle
/// enabled the equalizer and applied FLAT.
///
/// Never throws: an unreadable preference store leaves the handler on the
/// historical default rather than taking down the audio bootstrap.
Future<void> _sembrarPresetDesdeDisco(
PluriWaveAudioHandler handler,
LeerPresetPersistido leer,
) async {
PresetEcualizador? persistido;
try {
persistido = await leer();
} catch (e) {
debugPrint(
'[PluriWave][ServicioAudio] no se pudo leer el preset EQ persistido: $e',
);
persistido = null;
}
if (persistido == null) return;
await handler.sembrarPresetEcualizador(persistido);
}
/// Wires the freshly built handler into the module-level seams.
///
/// [leerEqActivoPersistido] and [guardarEqActivoPersistido] give the handler
/// its own, UI-independent link to the equalizer's persisted on/off flag
/// (eq-estado-unico items A and B). Before them the flag reached the handler
/// only through `EstadoEcualizador.cargarPersistido()`, i.e. only on an
/// engine that had actually built the widget tree — which a headless Android
/// Auto bind never does. Both are optional so every existing caller (widget
/// tests, fakes) keeps compiling and behaving exactly as before.
void registrarHandler(
PluriWaveAudioHandler handler, {
LeerEqActivoPersistido? leerEqActivoPersistido,
GuardarEqActivoPersistido? guardarEqActivoPersistido,
LeerPresetPersistido? leerPresetPersistido,
LeerContextoSaltoPersistido? leerContextoSalto,
GuardarContextoSaltoPersistido? guardarContextoSalto,
LeerUltimaEmisoraPersistida? leerUltimaEmisora,
GuardarUltimaEmisoraPersistida? guardarUltimaEmisora,
}) {
_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,
);
// 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));
}
if (leerPresetPersistido != null) {
unawaited(_sembrarPresetDesdeDisco(handler, leerPresetPersistido));
}
// iap-freemium-unlock (design.md Open Questions, orchestrator-resolved),
// generalizado en fix/android-auto-musica-local item 4: invalida
// activamente todo id de nivel raíz que un head unit pueda tener cacheado
// en vez de esperar a su propio re-bind — ver [registrarInvalidacionArbolAuto].
registrarInvalidacionArbolAuto(() {
handler.notificarHijosCambiaron(AudioService.browsableRootId);
handler.notificarHijosCambiaron(ConstructorArbolAuto.idFavoritos);
handler.notificarHijosCambiaron(ConstructorArbolAuto.idTodas);
handler.notificarHijosCambiaron(ConstructorArbolAuto.idMisEmisoras);
handler.notificarHijosCambiaron(ConstructorArbolAuto.idMusicaLocal);
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Android Auto browse source — registered from main.dart, mirrors
// registrarHandler above (Design "getChildren data source registration").
// ─────────────────────────────────────────────────────────────────────────────
FuenteEmisorasAuto? _fuenteNavegacionGlobal;
void registrarFuenteNavegacion(FuenteEmisorasAuto fuente) {
_fuenteNavegacionGlobal = fuente;
}
/// Local-music browse source — registered from main.dart, mirrors
/// [registrarFuenteNavegacion] above (Design "getChildren data source
/// registration"). `null` until registered (headless cold bind before
/// main.dart's registration line runs) — every consumer below treats a
/// `null` fuente as "not configured" rather than throwing.
FuenteMusicaLocalAuto? _fuenteMusicaLocalGlobal;
void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) {
_fuenteMusicaLocalGlobal = fuente;
}
/// User-saved EQ presets browse source — registered from main.dart, mirrors
/// the two registrations above.
///
/// On-device feedback: the car's `Ecualizador` folder only ever listed the
/// six FACTORY presets, so a driver who had carefully saved their own could
/// not reach it from the car at all — the one place a preset picker is most
/// useful. The handler owns no persistence, so the list arrives through this
/// seam exactly like stations and local music do.
///
/// A function rather than the service object: the folder needs a fresh read
/// on every browse (a preset saved on the phone must appear in the car
/// without an app restart), and this keeps `servicio_audio.dart` from
/// importing the persistence layer. `null` until registered (headless cold
/// bind) — consumers fall back to factory presets only, never throw.
Future<List<PresetEcualizador>> Function()? _fuentePresetsPersonalizadosGlobal;
void registrarFuentePresetsPersonalizados(
Future<List<PresetEcualizador>> Function() fuente,
) {
_fuentePresetsPersonalizadosGlobal = fuente;
}
/// Reads the registered custom-preset source, tolerating both "never
/// registered" and "the read blew up" as the same empty result: a
/// diagnostics-grade failure must degrade the folder to its factory presets,
/// never make browsing fail.
Future<List<PresetEcualizador>> _leerPresetsPersonalizados() async {
final fuente = _fuentePresetsPersonalizadosGlobal;
if (fuente == null) return const [];
try {
return await fuente();
} catch (_) {
return const [];
}
}
/// The full ordered preset universe the car's `Ecualizador` folder offers:
/// the six factory presets first, then [personalizados] in save order.
///
/// A custom preset whose `nombre` matches a factory preset is DROPPED, not
/// appended. Identity here is the raw name — [ConstructorArbolAuto.idPresetEq]
/// builds `eq_preset:<nombre>` from it and
/// [ConstructorArbolAuto.resolverPresetEq] resolves by first name match — so
/// two entries sharing a name would produce one media id that can only ever
/// reach the first of them. Rendering an item that silently applies a
/// different preset than the one whose name it shows is worse than not
/// rendering it, and the factory entry is the one the id is guaranteed to
/// resolve to.
///
/// Single source of truth on purpose: [itemsEcualizadorAuto] builds the items
/// from this list and the tap dispatch resolves against this same list, so
/// the folder can never show an item that resolution then refuses.
List<PresetEcualizador> presetsEcualizadorAuto({
required List<PresetEcualizador> personalizados,
List<PresetEcualizador>? deFabrica,
}) {
final fabrica = deFabrica ?? PresetEcualizador.presets;
final nombresFabrica = fabrica.map((p) => p.nombre).toSet();
return [
...fabrica,
...personalizados.where((p) => !nombresFabrica.contains(p.nombre)),
];
}
/// Teardown hook for whatever `main.dart` wired around the handler and must
/// be undone when the handler itself dies — today only the
/// `AudioService.asyncError` subscription (`observarErroresAudio`). Registered
/// from `main.dart`, mirroring [registrarHandler] and the two browse-source
/// registrations above; run exactly once from
/// [PluriWaveAudioHandler.onTaskRemoved].
///
/// The direction of the dependency matters: the bootstrap layer injects its
/// cleanup INTO the service layer, so `servicio_audio.dart` never has to
/// import `arranque_audio.dart` (nor the plugin's static error stream) just to
/// be able to close it.
Future<void> Function()? _limpiezaArranqueGlobal;
void registrarLimpiezaArranque(Future<void> Function() limpieza) {
_limpiezaArranqueGlobal = limpieza;
}
/// Android Auto browse-cache invalidation hook (design.md Open Questions,
/// orchestrator-resolved): registered from [registrarHandler] so callers
/// can trigger it WITHOUT ever touching `PluriWaveAudioHandler` directly (a
/// layering choice — the entitlement layer has no business knowing the
/// handler type; it is not, as this doc used to claim, because the handler
/// cannot be constructed in a unit test, which is false — see
/// [construirControlesTransporte]). `null` until a handler registers
/// (headless cold bind, or a widget-only test that never wires audio) —
/// [invalidarArbolAuto] tolerates that silently.
///
/// GENERALIZADO (fix/android-auto-musica-local, item 4): nació atado a la
/// transición free -> premium, y ese nombre escondía para qué sirve de
/// verdad. Android Auto CACHEA la raíz, así que hay que invalidarla cada
/// vez que el árbol pasa a poder mostrar algo que antes no podía. Hoy lo
/// disparan tres sitios: la compra premium (`estado_entitlement.dart`), la
/// primera vez que existe una View de verdad — es decir, cuando por fin hay
/// Activity y con ella el handler nativo de `pluriwave/file_actions`
/// (`main.dart`) — y la elección de carpeta de música local
/// (`pantalla_ajustes_musica_local.dart`).
void Function()? _invalidarArbolAutoGlobal;
/// Registers the hook [invalidarArbolAuto] invokes. Exposed at module
/// level (like every other `registrar*` seam in this file) purely so tests
/// can inject a fake hook and assert it fires, without instantiating a real
/// [PluriWaveAudioHandler].
void registrarInvalidacionArbolAuto(void Function() alInvalidar) {
_invalidarArbolAutoGlobal = alInvalidar;
}
/// Fires the registered Android Auto browse-cache invalidation hook, if
/// any. A no-op before a handler ever registers — never throws.
void invalidarArbolAuto() {
_invalidarArbolAutoGlobal?.call();
}
/// Whether a head unit has actually SUBSCRIBED to at least one browse id on
/// the live handler (fix/android-auto-musica-local, item 4 — corrected).
///
/// This is the precondition that makes [invalidarArbolAuto] worth firing at
/// all: [PluriWaveAudioHandler.notificarHijosCambiaron] is
/// `_childrenSubjects[id]?.add(...)`, so invalidating before the car has
/// subscribed to ANYTHING is provably a silent no-op — which is exactly how
/// the old `View.maybeOf(context) != null` trigger managed to burn its
/// one-shot latch during the headless cold start and never fire again.
///
/// Module-level, like every other seam in this file, so `main.dart` can ask
/// the question without importing the handler type, and `false` when no
/// handler has registered yet (headless cold bind, widget-only tests).
bool hayCocheSuscritoAlArbol() => _handlerGlobal?.hayCocheSuscrito ?? false;
/// Pure Android Auto play-path gate decision: whether a station-switch
/// dispatch (`playFromMediaId`, `playFromSearch`) must be refused.
///
/// NARROWED (fix/auto-quality-guidelines, item 11) from `!premium`. It used
/// to refuse EVERY switch for a free-tier user, which is what made the car
/// surface useless for the only tier a Play reviewer can ever be in: the
/// browse tree offered rows, and tapping any of them did nothing at all.
///
/// The original safety property survives intact and is the whole reason this
/// takes [esEmisoraGratuita] rather than being deleted: a stale
/// `emisora:<uuid>` from a head unit's CACHED tree, fetched before a
/// downgrade or from another device, still cannot play, because its uuid is
/// not in the free set. `getChildren` gating alone cannot stop that tap.
///
/// Deliberately does NOT gate `play`/`pause`/`stop` — transport control of
/// whatever is ALREADY loaded stays free for every tier.
bool debeBloquearCambioDeEmisora({
required bool premium,
required bool esEmisoraGratuita,
}) => !premium && !esEmisoraGratuita;
/// `PlaybackStateCompat.ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED` (4) — the exact
/// platform code for "this content needs a paid account", forwarded verbatim
/// by the plugin alongside `errorMessage`.
///
/// Only the `ERROR_RESOLUTION_*` extras (which would render a tappable
/// "upgrade" button on the head unit) are unreachable from Dart on
/// audio_service 0.18.18, and Google's Android for Cars errors documentation
/// explicitly accepts putting the "open the app on your phone" instruction in
/// the message text instead — which is what `autoErrorEmisoraPremium` does.
const codigoErrorEmisoraPremium = 4;
/// `PlaybackStateCompat.ERROR_CODE_APP_ERROR` (1) — a requested media id the
/// app could not turn into anything playable (stale/unknown uuid, or a voice
/// query that matched nothing).
const codigoErrorEmisoraNoDisponible = 1;
/// Whether an Android Auto ACTION refusal (a premium tap, a voice query that
/// matched nothing) may be published as a full [AudioProcessingState.error],
/// given the state the session is already in.
///
/// This is the guard on the one-way door in `getPlaybackState()`
/// (`AudioService.java:601-611`): `error` maps to `STATE_ERROR`
/// UNCONDITIONALLY there — the `playing` flag is not consulted — so an
/// `error` published over live audio replaces the entire now-playing screen,
/// play/pause/stop included, while the station is still audibly playing.
///
/// And it cannot self-heal. `just_audio`'s `playerStateStream` is
/// `.distinct()` (`just_audio.dart:279-285`), so a steadily playing ExoPlayer
/// emits nothing further and [manejarEstadoPlayer] never runs again;
/// `_bufferedSub` then re-asserts the same `error` ~2x/second through
/// `copyWith`; and the transient-state floor deliberately excludes `error`
/// (see [_esEstadoTransitorio]). Nothing left in the process would ever clear
/// it. The free candidate set is only the six compiled-in stations, so almost
/// any spoken station name misses — one voice miss and the car is stranded.
///
/// So: only a session with nothing to lose (`idle`, or an error already on
/// screen) may be moved to `error`. A LOADED session — playing, paused,
/// buffering or loading — keeps its state, and the refusal is carried by
/// `errorCode`/`errorMessage` alone, which `setState` forwards to
/// `PlaybackStateCompat.setErrorMessage` regardless of the state
/// (`AudioService.java:541-544`). The driver is told; the session survives.
///
/// Pure and top-level so the whole matrix is testable without a handler,
/// exactly like [mapearEstadoProceso].
bool puedePublicarErrorTerminalAuto(AudioProcessingState estado) =>
estado == AudioProcessingState.idle || estado == AudioProcessingState.error;
/// Builds the phone-initiated "play a station" `MediaItem` (item 3, Android
/// Auto fallback artwork): reuses [artUriPara] (`navegacion_auto.dart`) so a
/// station with no usable favicon gets the SAME on-brand rotating fallback
/// the browse tree and the car-tap path already show, instead of a blank
/// tile on the car/lockscreen/notification. Pure — no [PluriWaveAudioHandler]
/// dependency — so it is unit-testable without instantiating the handler.
MediaItem mediaItemParaEmisora(
Emisora emisora, {
required AppLocalizations l10n,
}) {
return MediaItem(
id: emisora.url,
title: localizedStationName(l10n, emisora.nombre),
artist: emisora.pais ?? '',
album: 'PluriWave',
artUri: Uri.parse(artUriPara(emisora)),
extras: {'uuid': emisora.uuid},
);
}
/// Reconstructs the phone-side [Emisora] from the handler's current
/// [MediaItem] (item 3): gates `favicon` through [faviconUsable]
/// (`navegacion_auto.dart`) so a car/car-tap "now playing" item's on-brand
/// FALLBACK `artUri` (an `android.resource://` drawable, never a real
/// favicon) is never misread as a genuine station favicon — the phone UI's
/// `CachedNetworkImage` widgets gate only on `favicon != null && isNotEmpty`
/// (not on `faviconUsable`'s scheme check), so without this guard they would
/// attempt a doomed network fetch of the fallback's non-http URI before
/// falling back to [PluriStationArtFallback] themselves. A genuine http(s)
/// favicon still round-trips exactly as before. Pure — no handler
/// dependency — unit-testable directly.
Emisora emisoraDesdeMediaItem(MediaItem mediaItem) {
final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id;
final artUriTexto = mediaItem.artUri?.toString();
return Emisora(
uuid: uuid,
nombre: mediaItem.title,
url: mediaItem.id,
pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null,
favicon: faviconUsable(artUriTexto) ? artUriTexto : null,
);
}
/// Maps a `just_audio` [ProcessingState] to the `audio_service`
/// [AudioProcessingState] pushed into `playbackState`. Identical to the
/// previous private `_mapProcState` in every case EXCEPT one:
/// [ProcessingState.idle] maps to [AudioProcessingState.loading] while
/// [cambiandoFuente] is `true`.
///
/// Why that single exception exists — this is the media-notification
/// regression, not a cosmetic tweak:
///
/// `audio_service`'s `_observePlaybackState` (`audio_service.dart:1131-1136`)
/// calls `AudioService._stop()` — which reaches `stopService()` and cancels
/// the notification through `deactivateMediaSession()` — on ANY transition
/// into `idle` from a non-idle state. The notification is posted at exactly
/// one place, `internalStartForeground()`, reachable only from the
/// `!wasPlaying && playing` edge, and its FIRST statement is
/// `ContextCompat.startForegroundService(...)`, which throws
/// `ForegroundServiceStartNotAllowedException` on API 31+ whenever the
/// process is not in a foreground state.
///
/// Every station change walked straight into that: `_cambiarFuente` pushes
/// `loading`, then `_recrearPlayer` disposes the old [AudioPlayer] and builds
/// a FRESH one, and a fresh player's first `playerStateStream` event is
/// always `idle`. Forwarded verbatim, that is a `loading -> idle` transition,
/// so the foreground service was torn down mid-source-change and the app then
/// depended on the following `playing: true` edge to restart it. With the
/// screen off, on the lock screen, or on an Android Auto / Bluetooth-initiated
/// start, that restart is exactly the case the platform refuses — audio keeps
/// playing, the notification never comes back. Self-inflicted, on every API
/// level, no plugin patch needed: just stop emitting the transient `idle`.
///
/// A genuine user stop is unaffected: `stop()` clears the flag BEFORE
/// `_player.stop()`, so its `idle` still reaches `playbackState` as a real
/// `idle` and still tears the service down. Pure — no handler dependency — so
/// the full [ProcessingState] x [cambiandoFuente] matrix is unit-testable
/// directly.
///
/// [errorTerminal] is the SECOND masked `idle`, and it exists for the same
/// class of bug as the first. After a terminal playback error this file
/// publishes `error` + a message and then calls `_player.stop()`, which
/// switches `just_audio` to the idle dummy platform
/// (`just_audio.dart:1016-1025`); `playerStateStream` emits a distinct
/// `(playing: false, idle)`, this function forwarded it verbatim, and
/// `audio_service`'s `_observePlaybackState` (`audio_service.dart:1131-1135`)
/// answered that non-idle -> idle transition with `AudioService._stop()` —
/// `deactivateMediaSession()` + `stopSelf()` (`AudioService.java:355-357`).
/// The error message therefore survived on the car screen for at most one
/// event-loop turn before PluriWave dropped off the Android Auto playback
/// surface entirely, with nothing left to explain why. This is a world-radio
/// app; dead streams are routine, and the reviewer is explicitly told to try
/// one. Holding `error` keeps the session (and the message, and a route back
/// to browse) alive; a genuine user `stop()` clears the latch BEFORE
/// `_player.stop()`, exactly like [cambiandoFuente], so the Stop button never
/// becomes unkillable.
AudioProcessingState mapearEstadoProceso(
ProcessingState proc, {
required bool cambiandoFuente,
bool errorTerminal = false,
}) {
if (cambiandoFuente && proc == ProcessingState.idle) {
return AudioProcessingState.loading;
}
if (errorTerminal && proc == ProcessingState.idle) {
return AudioProcessingState.error;
}
return switch (proc) {
ProcessingState.idle => AudioProcessingState.idle,
ProcessingState.loading => AudioProcessingState.loading,
ProcessingState.buffering => AudioProcessingState.buffering,
ProcessingState.ready => AudioProcessingState.ready,
ProcessingState.completed => AudioProcessingState.completed,
};
}
/// Resolves the localizations to use when no widget tree has ever handed the
/// handler an [AppLocalizations] — i.e. on the headless Android Auto engine.
///
/// Matches on `languageCode` only: a device reporting `en_GB`, `pt_BR` or
/// `zh_Hans_CN` must still get English, Portuguese and Chinese rather than the
/// Spanish fallback. Anything we do not ship falls back to `es`, which is the
/// exact behaviour this file had before — unchanged for every locale that
/// could not be served anyway.
///
/// Pure and top-level so the whole supported/unsupported matrix is testable
/// without a handler (same convention as [mapearEstadoProceso]).
AppLocalizations resolverLocalizacionesRespaldo(Locale plataforma) {
for (final soportado in AppLocalizations.supportedLocales) {
if (soportado.languageCode == plataforma.languageCode) {
return lookupAppLocalizations(soportado);
}
}
return lookupAppLocalizations(const Locale('es'));
}
/// Bridges [AppLocalizations] into the `AppLocalizations`-free browse-tree
/// builder (`navegacion_auto.dart`), exactly like [itemsEcualizadorAuto]
/// bridges it into the equalizer folder.
///
/// THE RULE this exists to enforce: anything a user can read in the car gets
/// translated. Every label the browse tree stamps onto a `MediaItem` comes
/// through here, so a new car-tree label cannot ship untranslated without
/// first getting an ARB key — and `test/l10n/etiquetas_arbol_auto_test.dart`
/// fails the build if one tries.
///
/// Top-level and pure so the mapping is testable without a handler (same
/// convention as [resolverLocalizacionesRespaldo]).
EtiquetasArbolAuto etiquetasArbolAutoDesde(AppLocalizations l10n) =>
EtiquetasArbolAuto(
escuchar: l10n.autoCarpetaEscuchar,
favoritos: l10n.autoCarpetaFavoritos,
todasLasEmisoras: l10n.autoCarpetaTodas,
misEmisoras: l10n.autoCarpetaMisEmisoras,
musicaLocal: l10n.autoCarpetaMusicaLocal,
musicaLocalNoDisponible: l10n.autoMusicaLocalNoDisponible,
cargarMas: l10n.autoCargarMas,
ordenarPorCalidad: l10n.autoOrdenarPorCalidad,
reproducirCarpeta: l10n.autoReproducirCarpeta,
reproducirAleatorio: l10n.autoReproducirAleatorio,
pistaSinNombre: l10n.autoPistaSinNombre,
);
/// Custom-action names for the equalizer's `PlaybackStateCompat` custom
/// actions on the now-playing screen (Design "EQ custom actions", item 4).
/// Public consts so tests and this file's own `customAction` dispatch share
/// the exact same literals; distinct from every browse-tree media-id prefix
/// in `navegacion_auto.dart` (they live in a completely different
/// `MediaControl`/`customAction` namespace, never compared against a
/// media id).
const accionEqToggle = 'eq_toggle';
/// What an [accionEqToggle] tap resolves to (eq-estado-unico item C).
class DecisionToggleEq {
const DecisionToggleEq({
required this.nuevoActivo,
required this.requiereLlamadaNativa,
});
/// The on/off value the handler must end up holding.
final bool nuevoActivo;
/// Whether the native `AndroidEqualizer` effect must also be told. `false`
/// on a device with no usable Equalizer effect: the flag still flips (so
/// the car button never looks inert and the label still updates) but
/// nothing is pushed to the platform.
final bool requiereLlamadaNativa;
@override
bool operator ==(Object other) =>
other is DecisionToggleEq &&
other.nuevoActivo == nuevoActivo &&
other.requiereLlamadaNativa == requiereLlamadaNativa;
@override
int get hashCode => Object.hash(nuevoActivo, requiereLlamadaNativa);
@override
String toString() =>
'DecisionToggleEq(nuevoActivo: $nuevoActivo, '
'requiereLlamadaNativa: $requiereLlamadaNativa)';
}
/// The equalizer toggle decision, extracted out of `customAction` and
/// `setEcualizadorActivo` so it can be tested on its own (eq-estado-unico
/// item C — this dispatch had ZERO tests: `rg "customAction\(" test/`
/// returned nothing).
///
/// Reported: «pulsando sobre el boton de ecualizar en Android Auto tampoco
/// activaba ni desactivaba». Note what this function deliberately does NOT
/// do: gate the flip on [eqDisponible]. The flag always flips, because the
/// notification/car label is built from it — a tap that changed nothing at
/// all is exactly the "the button does nothing" symptom.
DecisionToggleEq decidirToggleEq({
required bool activoActual,
required bool eqDisponible,
}) => DecisionToggleEq(
nuevoActivo: !activoActual,
requiereLlamadaNativa: eqDisponible,
);
/// Translates a gain on the app's fixed ±12 dB slider scale to the range the
/// device's native equalizer actually reports
/// (`AndroidEqualizerParameters.min/maxDecibels`, itself derived from
/// `Equalizer.getBandLevelRange()`).
///
/// Top-level and pure so the mapping is testable without a device.
///
/// THE DEFECT THIS REPLACES, and the likely source of the reported «suena muy
/// alto»: the previous implementation normalised across the whole range and
/// interpolated linearly,
///
/// minDecibels + ((db + 12) / 24) * (maxDecibels - minDecibels)
///
/// which puts 0 dB at the MIDPOINT of the native range. That is only 0 when
/// the range is symmetric, and Android guarantees no such thing — the
/// Equalizer contract only promises a min/max pair. On a device reporting,
/// say, [-12, +19] dB, every band of a FLAT preset was pushed to +3.5 dB of
/// real boost: audibly louder, with the on/off button still reading "off"
/// and nothing in the UI to explain it.
///
/// The contract here instead: 0 dB is always exactly 0, and each side of the
/// scale is stretched independently against its own end of the native range,
/// so a cut can never become a boost. A range with no headroom on one side
/// (or none at all) collapses that side to 0 rather than inverting it.
double mapearGananciaNativa(
double db, {
required double minDecibels,
required double maxDecibels,
}) {
final limitado = db.clamp(-12.0, 12.0);
if (limitado == 0) return 0;
if (limitado > 0) {
// Only genuine headroom above unity counts as boost.
final techo = maxDecibels > 0 ? maxDecibels : 0.0;
return (limitado / 12.0) * techo;
}
final suelo = minDecibels < 0 ? minDecibels : 0.0;
return (limitado.abs() / 12.0) * suelo;
}
/// Advances to the NEXT factory preset after [actual] in [presets] order
/// (Design "EQ custom actions — cycling presets", item 4): wraps around
/// after the last one. When [actual] is not found in [presets] (e.g. a
/// user-tweaked "Personalizado" preset from `EstadoEcualizador.cambiarBanda`),
/// starts from the FIRST preset rather than throwing — cycling from an
/// unknown state always lands somewhere sane. Pure, no I/O.
///
/// [presets] defaults to [PresetEcualizador.presets] — not a literal default
/// value, since that field is `static final` (not `const`) and Dart default
/// parameter values must be compile-time constants.
PresetEcualizador presetSiguiente(
PresetEcualizador actual, {
List<PresetEcualizador>? presets,
}) {
final lista = presets ?? PresetEcualizador.presets;
final indice = lista.indexWhere((p) => p == actual);
if (indice == -1) return lista.first;
return lista[(indice + 1) % lista.length];
}
/// Localizes a preset's raw `nombre` for the equalizer custom action's
/// label (Design "EQ custom actions", item 4) — mirrors
/// `ecualizador_widget.dart`'s private `_nombrePreset` mapping (duplicated
/// rather than shared: that file is UI-widget layer, this one is the
/// service/handler layer, and the mapping is a single small switch, not
/// worth a cross-layer import for). An unrecognized name (e.g. a future
/// user-named custom preset) falls through to the raw name verbatim.
String nombrePresetVisible(AppLocalizations l10n, String nombre) {
return switch (nombre) {
'Flat' => l10n.equalizerPresetFlat,
'Rock' => l10n.equalizerPresetRock,
'Pop' => l10n.equalizerPresetPop,
'Bass Boost' => l10n.equalizerPresetBassBoost,
'Jazz' => l10n.equalizerPresetJazz,
'Voz' => l10n.equalizerPresetVoice,
'Personalizado' => l10n.equalizerPresetCustom,
_ => nombre,
};
}
/// Builds the equalizer's custom-action `MediaControl`s for the now-playing
/// screen (decision `auto/ecualizador-diseno`) — exactly 1: an on/off
/// toggle. The previous design paired this with a SECOND action that cycled
/// through the six factory presets; that action is REMOVED. On-device
/// feedback: many head units render custom actions icon-first, so two
/// static, non-parametrized glyphs sitting side by side looked identical/
/// dead even though the toggle's own icon DID change and the cycle action
/// DID work — a monochrome icon simply cannot legibly encode "which of six
/// presets" the way a browsable list's text rows can. Preset selection now
/// lives in the "Ecualizador" browsable folder instead (see
/// [itemsEcualizadorAuto]), which also frees this scarce custom-action
/// slot. Do NOT re-add a preset-cycling custom action; extend the folder
/// instead.
/// Empty when [disponible] is false (gate on EQ availability, mirrors the
/// existing `debeReaplicarEcualizador`/`_eqDisponible` gate) — a device
/// without the native Equalizer effect gets no EQ actions at all, not
/// broken ones.
///
/// On-device feedback follow-up: this action used to reuse the SAME
/// `ic_stat_pluriwave` drawable as everything else and was visually
/// indistinguishable on a car head unit, which foregrounds the icon over
/// the label. It now gets its own dedicated drawables
/// (`ic_auto_eq_on`/`ic_auto_eq_off`), and the icon itself reflects
/// [activo] (not just its label) so on/off is legible at a glance. Pure, no
/// handler dependency.
List<MediaControl> controlesEcualizadorPersonalizados({
required bool disponible,
required bool activo,
required AppLocalizations l10n,
}) {
if (!disponible) return const [];
return [
MediaControl.custom(
androidIcon:
activo ? 'drawable/ic_auto_eq_on' : 'drawable/ic_auto_eq_off',
label:
activo
? l10n.eqCustomActionDisableLabel
: l10n.eqCustomActionEnableLabel,
name: accionEqToggle,
),
];
}
/// The handler's full transport `controls` list for a `playbackState` push.
///
/// Top-level and public so tests exercise THIS function rather than a copy of
/// its shape. `servicio_audio_controles_notificacion_test.dart` used to
/// re-declare the list inline, which meant it stayed green while asserting a
/// shape `lib/` no longer produced — a guard that cannot see the thing it
/// guards.
///
/// This doc used to add that `PluriWaveAudioHandler` "cannot be instantiated
/// in a unit test (a real `just_audio.AudioPlayer` needs platform
/// MethodChannels)". That is NOT true with just_audio 0.9.46:
/// `AudioPlayer`'s constructor resolves its platform lazily and only becomes
/// `_active` on a `setUrl`, so the handler constructs fine under
/// `flutter test` and `servicio_audio_eq_estado_unico_test.dart` drives its
/// real `customAction` dispatch. Only calls that reach the native effect stay
/// out of reach (they sit behind `_eqDisponible`, `false` off-device).
/// Extracting the pure part is still worth it — it is cheaper and states the
/// contract explicitly — but it is no longer the ONLY way.
///
/// ORDER MATTERS, and only for the car.
///
/// On Android 13+ `createCustomAction` (AudioService.java:466-469) turns
/// [MediaControl.stop] into a `CUSTOM_ACTION_STOP` custom action too. So on a
/// modern phone the car receives TWO custom actions, in list order, and a head
/// unit that exposes a single custom-action slot shows only the first and
/// buries the rest in an overflow menu — which is why the equalizer toggle
/// stayed invisible on the playback screen even once it was back in this list
/// (reported on v1.2.14+136, which does contain it).
///
/// The equalizer therefore goes BEFORE `stop`, and wins that slot on purpose:
/// the car already has its own path to stop playback and Auto's template
/// renders play/pause itself, while the equalizer is reachable no other way
/// from this screen.
///
/// The phone notification is untouched by that ordering, on every API level.
/// `setState` (AudioService.java:513-521) splits this list by whether a
/// control carries a `customAction`: on 13+ `stop` goes to `customActions`
/// (never the notification) and the equalizer was never in `nativeActions`
/// anyway; below 13 the equalizer is the only custom action and `stop` stays
/// native. Either way `nativeActions` comes out as
/// `[prev?, play/pause, stop, next?]`, and `androidCompactActionIndices`
/// (`[colaActiva ? 1 : 0]`) still lands on play/pause.
List<MediaControl> construirControlesTransporte({
required bool colaActiva,
required bool playing,
required bool eqDisponible,
required bool eqActivo,
required AppLocalizations l10n,
}) => [
if (colaActiva) MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
...controlesEcualizadorPersonalizados(
disponible: eqDisponible,
activo: eqActivo,
l10n: l10n,
),
MediaControl.stop,
if (colaActiva) MediaControl.skipToNext,
];
/// Content-style extras for the Ecualizador folder's items (decision
/// `auto/ecualizador-diseno`), mirrors `ConstructorArbolAuto
/// ._contentStyleLista` in `navegacion_auto.dart` — duplicated rather than
/// exposed publicly (see [nombrePresetVisible]'s doc for why small pieces
/// are deliberately duplicated across this handler/service layer and the
/// pure browse-tree builder layer rather than cross-layer-shared). List
/// style, not grid: these items are short text options with no artwork of
/// their own, unlike a station or local-track tile.
const _contentStyleListaEq = {
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 1,
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1,
};
/// Marks the active Ecualizador-folder item by prefixing [titulo] with a
/// checkmark glyph (decision `auto/ecualizador-diseno`, spec "the active
/// preset must be visibly marked").
///
/// A `MediaItem.extras` completion-status flag (`androidx.media.utils.
/// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS`) was considered
/// and REJECTED as the marking mechanism: this project's `audio_service`
/// version (0.18.18) has no Dart wrapper for it — only `AndroidContentStyle`
/// 's list/grid hints are exposed — and the raw platform key itself is
/// designed for playback-COMPLETION tracking (e.g. "this podcast episode
/// was already listened to"), not item SELECTION; repurposing it here could
/// render as "already played" on some head units, which would be actively
/// misleading for a preset picker, and there is no way to verify its actual
/// rendering on a real head unit from this environment. A plain-text
/// marker renders identically and unambiguously on every head unit, which
/// an unverifiable, semantically-mismatched extras key cannot guarantee.
String _marcarActivoEq(String titulo, {required bool activo}) =>
activo ? '✓ $titulo' : titulo;
/// Builds the "Ecualizador" folder's children for the Android Auto browse
/// tree (decision `auto/ecualizador-diseno`): "Desactivar" FIRST, then the
/// six factory presets in [PresetEcualizador.presets] order, each localized
/// via [nombrePresetVisible] — the SAME helper the toggle's custom-action
/// label already uses, so a preset's name reads identically whether the
/// driver sees it in the now-playing screen's tooltip or in this folder.
/// All items are playable: tapping one is dispatched through
/// `playFromMediaId` -> `seleccionarPresetEqPorMediaId` (`navegacion_auto.
/// dart`), the same seam every other browse-tree leaf already uses; this
/// folder has no sub-browsing. Exactly one item is marked active via
/// [_marcarActivoEq]: "Desactivar" when [activo] is `false`, otherwise
/// whichever preset equals [presetActual] — never both at once, and never
/// zero once this function is reached (an unresolvable [presetActual] with
/// [activo] `true` simply marks nothing, which cannot happen in practice
/// since [presetActual] always originates from [PresetEcualizador.presets]
/// or a "Personalizado" tweak that would just leave every item unmarked
/// rather than mis-marking one).
///
/// [presetsPersonalizados] are the user's own saved presets, appended after
/// the factory six by [presetsEcualizadorAuto] (which also settles name
/// collisions). They render through the same [nombrePresetVisible] call as
/// everything else: that helper passes an unrecognized name through
/// verbatim, which is exactly right for a name the user typed themselves.
List<MediaItem> itemsEcualizadorAuto({
required bool activo,
required PresetEcualizador presetActual,
required AppLocalizations l10n,
List<PresetEcualizador> presetsPersonalizados = const [],
}) {
final constructor = ConstructorArbolAuto();
final items = <MediaItem>[
MediaItem(
id: ConstructorArbolAuto.idDesactivarEq,
title: _marcarActivoEq(l10n.autoEqDisableOption, activo: !activo),
playable: true,
extras: _contentStyleListaEq,
),
];
for (final preset in presetsEcualizadorAuto(
personalizados: presetsPersonalizados,
)) {
items.add(
MediaItem(
id: constructor.idPresetEq(preset.nombre),
title: _marcarActivoEq(
nombrePresetVisible(l10n, preset.nombre),
activo: activo && preset == presetActual,
),
playable: true,
extras: _contentStyleListaEq,
),
);
}
return items;
}
/// Wrapper de alto nivel para el UI.
class ServicioAudio {
PluriWaveAudioHandler get _handler {
assert(
_handlerGlobal != null,
'registrarHandler() no fue llamado en main.dart',
);
return _handlerGlobal!;
}
Emisora? get emisoraActual => _handler.emisoraActual;
void configurarLocalizaciones(AppLocalizations l10n) {
_handler.configurarLocalizaciones(l10n);
}
Stream<EstadoReproduccion> get estadoStream =>
_handler.playbackState.map((s) {
if (s.processingState == AudioProcessingState.error) {
return EstadoReproduccion.error;
}
if (_handler.reconectando) return EstadoReproduccion.reconectando;
if (s.processingState == AudioProcessingState.loading ||
s.processingState == AudioProcessingState.buffering) {
return EstadoReproduccion.cargando;
}
if (s.playing) return EstadoReproduccion.reproduciendo;
if (s.processingState == AudioProcessingState.idle) {
return EstadoReproduccion.detenido;
}
return EstadoReproduccion.pausado;
});
Future<void> reproducir(Emisora emisora) async {
final item = mediaItemParaEmisora(
emisora,
l10n: lookupAppLocalizations(const Locale('es')),
);
await _handler.playMediaItem(item);
}
Future<void> pausar() => _handler.pause();
Future<void> reanudar() => _handler.play();
Future<void> togglePlay() async {
if (_handler.playbackState.value.playing) {
await pausar();
} else {
await reanudar();
}
}
Future<void> detener() => _handler.stop();
Future<void> setVolumen(double vol) => _handler.setVolumen(vol);
double get volumen => _handler.volumen;
bool get estaSonando => _handler.playbackState.value.playing;
Stream<int?> get androidAudioSessionIdStream async* {
yield _handler.androidAudioSessionId;
yield* _handler.androidAudioSessionIdStream;
}
Future<void> dispose() async {}
// ── Ecualizador ───────────────────────────────────────────────────────────
AndroidEqualizer? get ecualizador => _handler.ecualizador;
bool get ecualizadorDisponible => _handler.ecualizadorDisponible;
PresetEcualizador get presetActual => _handler.presetActual;
/// Forwards the handler's own on/off flag, which since eq-estado-unico is
/// the flag's SINGLE in-memory owner: `EstadoEcualizador._activo` is a
/// display mirror of this getter and `ServicioEcualizador` is its durable
/// copy.
///
/// Corrects a stale claim that stood here: a car/notification toggle does
/// NOT bypass [setEcualizadorActivo]. `PluriWaveAudioHandler.customAction`
/// resolves `accionEqToggle` through `decidirToggleEq` and then calls
/// `setEcualizadorActivo` — the same entry point the phone settings screen
/// uses — so every surface shares one write path, and that path is what
/// persists the value. [EstadoEcualizador] still polls this getter on every
/// [estadoStream] tick, but only to keep its own display in sync.
bool get ecualizadorActivo => _handler.ecualizadorActivo;
Future<void> aplicarPreset(PresetEcualizador preset) =>
_handler.aplicarPreset(preset);
Future<void> setEcualizadorActivo(bool activo) =>
_handler.setEcualizadorActivo(activo);
Future<void> setBanda(int index, double db) => _handler.setBanda(index, db);
}
// ─────────────────────────────────────────────────────────────────────────────
// AudioHandler
// ─────────────────────────────────────────────────────────────────────────────
class PluriWaveAudioHandler extends BaseAudioHandler
with SeekHandler
implements ObjetivoAudioInterrumpible {
/// Per-attempt source-change timeout.
///
/// Was 12 s, which blew the Android for Cars ten-second budget on the FIRST
/// attempt alone: the driver tapped a station and got a silent spinner for
/// twelve seconds before anything at all happened, then five more silent
/// attempts behind 1/2/4/8/16 s of backoff — roughly 100 s of nothing per
/// tap. 8 s leaves two seconds of headroom for the message to be published
/// and rendered, and the retries continue behind it (see
/// [_intentarReconexion]), so a slow-but-alive station still recovers.
static const timeoutCambioFuentePorDefecto = Duration(seconds: 8);
/// See [timeoutCambioFuentePorDefecto]. Mutable ONLY so a test can shrink
/// the window to milliseconds; production never writes it.
@visibleForTesting
static Duration timeoutCambioFuente = timeoutCambioFuentePorDefecto;
static const _timeoutCierrePlayer = Duration(seconds: 3);
/// How long a `loading`/`buffering` published state may stand before the
/// terminal-state floor forces it to a terminal one.
///
/// Android for Cars App Quality Guidelines: a media app must never leave the
/// head unit on an indefinite spinner. The only exits from a transient state
/// today are player events, and `playerStateStream` is `.distinct()`, so a
/// repeat of a state we already hold emits NOTHING — a stalled state machine
/// stays on the spinner forever with no event left to rescue it.
///
/// 8 s, not the 20 s this shipped with. For the failure mode this floor is
/// the ONLY exit from — an icecast mount whose socket opens but never
/// delivers data, one of the most common failures in this catalogue — the
/// window IS the time to the first user-visible message: `setUrl` returns
/// inside [timeoutCambioFuente], so no `TimeoutException` and no
/// `PlayerException` is ever raised, `_esErrorDeRed` never fires, the
/// reconnect machine is never entered, and `_cambiosEnVuelo` is already
/// back to 0 (the `finally` runs as soon as the non-blocking
/// `_iniciarPlaySinBloquear` returns). Twenty seconds was double the
/// ten-second budget the paragraph above cites. Same value as
/// [timeoutCambioFuentePorDefecto], for the same reason: two seconds of
/// headroom for the message to be published and rendered.
static const vigilanciaTransitoriaPorDefecto = Duration(seconds: 8);
/// See [vigilanciaTransitoriaPorDefecto]. Mutable ONLY so a test can shrink
/// the window to milliseconds; production never writes it.
@visibleForTesting
static Duration vigilanciaTransitoria = vigilanciaTransitoriaPorDefecto;
/// How long an Android Auto ACTION refusal published over a LIVE session
/// (see [_publicarErrorAuto]) keeps its `errorCode`/`errorMessage` on the
/// head unit before they are cleared again.
///
/// A refusal over live audio describes the REQUESTED ACTION, not the
/// session, so it has to expire: `_bufferedSub` republishes
/// `playbackState.value.copyWith(...)` roughly twice a second and
/// `copyWith` carries every omitted field forward
/// (`audio_service.dart:400-427`), so `AudioService.java:541-544` re-calls
/// `setErrorMessage(code, msg)` on every one of those pushes. Left
/// unbounded, a single voice miss makes the session advertise an error
/// code and message for the rest of that station's playback, over audio
/// that is playing perfectly.
///
/// Six seconds: long enough to be read at a glance from a head unit while
/// driving (the Cars guidelines' own budget for telling the driver
/// something at all is ten), short enough that it cannot be mistaken for a
/// description of the session it is riding on.
static const ventanaErrorAccionAutoPorDefecto = Duration(seconds: 6);
/// See [ventanaErrorAccionAutoPorDefecto]. Mutable ONLY so a test can
/// shrink the window to milliseconds; production never writes it.
@visibleForTesting
static Duration ventanaErrorAccionAuto = ventanaErrorAccionAutoPorDefecto;
static const _factorAtenuacion = 0.3;
// ── Live-stream buffer (Design 7.1, S7-R1) ────────────────────────────────
// Forward jitter cushion for live radio: there is no rewind history, so the
// buffer only absorbs short drops (up to roughly what was buffered when the
// drop hit); on reconnect we rejoin the live edge.
static const bufferMinimo = Duration(seconds: 15);
static const bufferMaximo = Duration(seconds: 50);
static const bufferParaIniciar = Duration(milliseconds: 2500);
static const bufferTrasRebuffer = Duration(seconds: 5);
/// Buffer configuration applied at [AudioPlayer] construction. Exposed so
/// tests can assert the values without touching platform channels (S7-R1).
static const configuracionCargaAndroid = AudioLoadConfiguration(
androidLoadControl: AndroidLoadControl(
minBufferDuration: bufferMinimo,
maxBufferDuration: bufferMaximo,
bufferForPlaybackDuration: bufferParaIniciar,
bufferForPlaybackAfterRebufferDuration: bufferTrasRebuffer,
prioritizeTimeOverSizeThresholds: true,
),
);
AndroidEqualizer _eq = _crearEq();
late AudioPlayer _player = _crearPlayer();
StreamSubscription<PlayerState>? _estadoPlayerSub;
StreamSubscription<Duration>? _bufferedSub;
StreamSubscription<Duration?>? _duracionSub;
StreamSubscription<PlaybackEvent>? _eventosSub;
StreamSubscription<int?>? _androidAudioSessionIdSub;
final _androidAudioSessionIdController = StreamController<int?>.broadcast();
int? _androidAudioSessionId;
/// Last session id processed for EQ re-apply purposes (Design "Change-guard
/// field separate from broadcast field"). Kept apart from
/// [_androidAudioSessionId] so external broadcast semantics on
/// [androidAudioSessionIdStream] stay untouched by the EQ re-apply guard.
int? _ultimaSessionIdEq;
Future<void> _colaCambioFuente = Future<void>.value();
int _revisionFuente = 0;
/// `true` only for the window inside [_cambiarFuente] where the OLD player
/// has been disposed and the FRESH one has not loaded its URL yet — the
/// window in which `playerStateStream` unavoidably emits a transient
/// `idle` that is NOT a stop. [mapearEstadoProceso] masks that one `idle`
/// as `loading` so `audio_service` does not tear the foreground service
/// (and with it the media notification) down mid-source-change; see that
/// function's doc for the full mechanism.
///
/// A value stuck at `true` is the ONLY risk this flag introduces: a real
/// user stop would then be masked away from `idle` and the service would
/// never stop, leaving an unkillable notification. It is therefore cleared
/// by a `finally` in [_cambiarFuente] (which covers normal completion,
/// both revision-mismatch `return`s, every `rethrow`, and any non-`Exception`
/// `Error` that no catch clause matches), AND eagerly at the top of every
/// catch clause, AND at the start of [stop] and [_gestionarErrorReproduccion]
/// — i.e. before every single `_player.stop()` call in this class.
bool _cambiandoFuente = false;
/// How many [_cambiarFuente] bodies are currently between their entry guard
/// and their `finally`. A counter rather than a bool because
/// `_colaCambioFuente` is a chain, and a reconnect retry can be enqueued
/// while the previous attempt is still unwinding.
///
/// Read ONLY by the terminal-state floor, to tell «a station is genuinely
/// still opening» apart from «the state machine stalled on a spinner».
int _cambiosEnVuelo = 0;
/// Terminal-state floor (see [vigilanciaTransitoriaPorDefecto]).
Timer? _vigilanciaTimer;
/// The `errorCode`/`errorMessage` pair the LAST non-terminal
/// [_publicarErrorAuto] put on the head unit, and the timer that takes it
/// back off again. `null` when no action refusal is standing.
///
/// Both halves are remembered so the clear can verify it is removing its
/// OWN fields: anything else may have published over them in the meantime
/// (a reconnect status message, a terminal playback error), and clearing
/// those would blank the car screen for a reason that is still true.
({int codigo, String mensaje})? _errorAccionAuto;
Timer? _temporizadorErrorAccionAuto;
/// True once the CURRENT source has actually produced audio — i.e. the
/// player reached `ready` while `playing`. Reset at the entry of every
/// [_cambiarFuente] (a reconnect attempt included) and by [stop].
///
/// This is the terminal-state floor's discriminator, and it exists because
/// the obvious one does not work: `just_audio`'s `PlayerState.playing` is
/// the play-when-ready INTENT flag, set the instant `play()` is called
/// (`just_audio.dart` `_playInterrupted`/`playing`), so the stalled icecast
/// mount the floor was built for — socket accepted, not one byte delivered
/// — publishes `buffering` with `playing: true` exactly like a healthy
/// stream refilling its buffer. Only "did this run ever reach `ready`?"
/// separates them.
bool _reproduccionEstablecida = false;
/// A terminal playback error is standing on the car screen, so the `idle`
/// that follows it must NOT be forwarded — see [mapearEstadoProceso]'s
/// `errorTerminal` parameter for the teardown chain that idle triggers.
///
/// Cleared by every path that represents a fresh user intent: [play],
/// [pause], [stop] and the entry of [_cambiarFuente]. [stop] in particular
/// clears it BEFORE `_player.stop()`, so a genuine user stop still tears
/// the session down.
bool _errorTerminal = false;
/// Active local-music queue (Design "single load-bearing invariant"):
/// `null` means "not local-queue playback" — the ONLY gate the
/// auto-advance/skip/isolation logic reads. Radio never sets this field.
ColaLocal? _colaLocal;
/// Re-entry latch (Design ADR-3): armed synchronously the instant an
/// auto-advance is triggered, cleared when the next track reaches
/// `playing && ready`, or on deactivate/stop/external play. Guards
/// against a double-advance from repeated `completed` emissions during
/// the async URI-resolve gap.
bool _avanzandoCola = false;
Emisora? emisoraActual;
double _volumen = 1.0;
double get volumen => _volumen;
AppLocalizations? _l10n;
/// Intent-to-play flag (Designs 3.1/7.2): reflects the LAST explicit
/// intent (play/pause/stop, including audio-session interruptions, which
/// pause through [pausar]). The S7 reconnect state machine reads it to
/// distinguish a network stall from an intentional pause.
bool _intencionReproducir = false;
/// Ducked state requested by the audio session (transient focus loss).
bool _atenuado = false;
/// Reconnect-on-stall state machine (Design 7.2, S7-R2).
final ControladorReconexion _reconexion = ControladorReconexion();
/// Per-`parentMediaId` "children changed" subjects (iap-freemium-unlock,
/// design.md Open Questions): `audio_service`'s OWN internal listener
/// (registered once `AudioService.init` completes) subscribes to
/// [subscribeToChildren] and forwards every new value to the platform's
/// `notifyChildrenChanged` — the plugin's top-level `notifyChildrenChanged`
/// helper is deprecated precisely in favor of this stream-based path. A
/// `BehaviorSubject` per id, created lazily on first subscription;
/// [notificarHijosCambiaron] pushes a fresh (empty, content-agnostic)
/// value to trigger the platform notification for that id.
///
/// SIN semilla (fix/android-auto-musica-local, item 5). Antes se creaba
/// con `.seeded(<String, dynamic>{})`, y un `BehaviorSubject` reenvía su
/// valor actual a cada nuevo suscriptor: el listener interno de
/// `audio_service` se suscribe la primera vez que el head unit navega un
/// id, recibía esa semilla al instante y la reenviaba como
/// `notifyChildrenChanged` — o sea, el primer browse de CADA id disparaba
/// un `getChildren` extra que nadie pidió. En la raíz eso era un segundo
/// round trip de permisos por `pluriwave/file_actions`, justo en la ruta
/// que ya estaba fallando en el motor sin Activity. Sin semilla no hay
/// nada que reenviar y la invalidación explícita sigue igual.
final _childrenSubjects = <String, BehaviorSubject<Map<String, dynamic>>>{};
@override
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) =>
_childrenSubjects.putIfAbsent(
parentMediaId,
BehaviorSubject<Map<String, dynamic>>.new,
);
/// Invalidates a head unit's cached browse listing for [parentMediaId]
/// (Design "Open Questions" — actively invalidate on the free -> premium
/// transition rather than waiting for the head unit's own re-bind). A
/// no-op if nothing ever subscribed to this id.
void notificarHijosCambiaron(String parentMediaId) {
_childrenSubjects[parentMediaId]?.add(<String, dynamic>{});
}
/// True once a head unit has subscribed to at least one browse id, i.e.
/// once [notificarHijosCambiaron] can actually reach the car. Read through
/// the module-level [hayCocheSuscritoAlArbol]; see its doc for why the
/// browse-tree invalidation is gated on it.
bool get hayCocheSuscrito => _childrenSubjects.isNotEmpty;
/// True while the handler is inside the reconnect window. [ServicioAudio]
/// maps it to [EstadoReproduccion.reconectando] so the UI shows a loading
/// indicator instead of an error during retries (S7-R3).
bool _reconectando = false;
bool get reconectando => _reconectando;
AndroidEqualizer? get ecualizador => _eq;
bool _eqDisponible = false;
bool get ecualizadorDisponible => _eqDisponible;
/// Last [AndroidEqualizerParameters] resolved by [_activarEcualizador].
///
/// Cached rather than re-awaited because `AndroidEqualizer.parameters` is a
/// `Completer` future that only completes when the platform player attaches
/// (`just_audio.dart` `AndroidEqualizer._activate`). Awaiting it from a
/// toggle path therefore does not "read the device", it BLOCKS until the
/// next successful load — potentially forever if that load fails — which
/// would leave the car's equalizer button pending and its icon stale.
/// `null` means "not resolved yet on this player": the gains are skipped and
/// [_activarEcualizador] pushes them as soon as the player attaches.
AndroidEqualizerParameters? _paramsEq;
/// The [PasoEcualizador]s the LAST on/off transition actually executed, in
/// execution order. Reset at the start of every transition, so it stays
/// bounded and says exactly what the most recent toggle did.
///
/// This is the only way a test can see the order: both operations are
/// invisible off-device. Asserting "both happened" would have stayed green
/// against the very bug this exists for.
@visibleForTesting
List<PasoEcualizador> get pasosEcualizadorEjecutados =>
List.unmodifiable(_pasosEqEjecutados);
final _pasosEqEjecutados = <PasoEcualizador>[];
/// How many native equalizer calls have thrown.
///
/// The native effect is write-only (`just_audio` exposes no
/// `Equalizer.getEnabled()`), so a failure used to be indistinguishable
/// from success both in a logcat and in a test.
@visibleForTesting
int get fallosNativosEcualizador => _fallosNativosEq;
int _fallosNativosEq = 0;
/// The equalizer's on/off state — and, since eq-estado-unico, its SINGLE
/// in-memory owner. `EstadoEcualizador._activo` is now a pure display
/// mirror of this field, and `ServicioEcualizador` is its durable copy.
///
/// It used to be an unconditional `true`, which is exactly why a headless
/// Android Auto engine played with the equalizer on while both the phone
/// UI and disk said off. It now starts from whatever the last disk read
/// produced ([_eqActivoPersistido]); [registrarHandler] then seeds it
/// again from the read port, which is the authoritative path.
bool _ecualizadorActivo = estadoEqInicial(persistido: _eqActivoPersistido);
bool get ecualizadorActivo => _ecualizadorActivo;
/// Write port for [_ecualizadorActivo] (eq-estado-unico item B). Injected
/// by [registrarHandler] so a car/notification toggle is persisted even
/// when no `EstadoEcualizador` has ever been built — which is precisely
/// the headless-bind case where the divergence used to be created.
GuardarEqActivoPersistido? _persistirEqActivo;
/// See [_persistirEqActivo]. Accepts `null` to clear the port (the default
/// for every caller that has no disk).
void registrarPersistenciaEq(GuardarEqActivoPersistido? guardar) {
_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;
}
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
/// 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.
Duration get posicionActual => _player.position;
/// True while the platform player is attached, i.e. while `just_audio`
/// actually forwards `AudioEffect.setEnabled` to the device
/// (`just_audio.dart:3842-3848` gates it on `_player._active`). Tracked so
/// [debeReasertarEcualizadorNativo] can spot the idle -> active edge.
bool _reproductorActivo = false;
PresetEcualizador _presetActual = PresetEcualizador.flat;
PresetEcualizador get presetActual => _presetActual;
/// True once anybody has chosen a preset on this handler. Guards the disk
/// seed against clobbering a live choice — see [_sembrarPresetDesdeDisco].
bool _presetElegido = false;
/// The ordered native steps an on/off transition performs.
///
/// Pure and public so the ORDER is asserted directly.
@visibleForTesting
static List<PasoEcualizador> pasosEcualizador({required bool activo}) =>
activo
// GAINS FIRST. `AudioEffect.setEnabled(true)` re-activates the
// native `android.media.audiofx.Equalizer`, which still holds the
// band levels the PREVIOUS preset left in it — so enabling first
// means the driver hears the old equalization and then, one native
// round trip per band, the new one sliding in over it. That is the
// «doubled equalization» the owner reports from the car. Writing
// the levels while the effect is still bypassed makes the
// transition a single audible step.
? const [PasoEcualizador.ganancias, PasoEcualizador.habilitacion]
// DISABLING DOES NOT RESET THE GAINS, on purpose.
// `AudioEffect.setEnabled(false)` (just_audio's
// `AudioPlayer.java:820-822` → `AudioEffect.setEnabled`) BYPASSES
// the effect; it neither releases it nor clears its band levels,
// and a bypassed effect is inaudible whatever they hold. Zeroing
// them would be one `setBandLevel` IPC per band for no audible
// difference, and the enable path above rewrites them all before
// re-enabling anyway — so there is no stale-gain window left for a
// reset to close.
: const [PasoEcualizador.habilitacion];
int? get androidAudioSessionId => _androidAudioSessionId;
Stream<int?> get androidAudioSessionIdStream =>
_androidAudioSessionIdController.stream;
PluriWaveAudioHandler() {
_conectarStreamsPlayer();
_vigilarEstadosTransitorios();
}
/// Arms the terminal-state floor by watching our OWN published stream
/// rather than any single push site: `playbackState.add` is called from a
/// dozen places in this file, and a floor that only covered some of them
/// would be exactly as good as no floor at all on the path it missed.
///
/// The subscription is deliberately not held: it is on the handler's OWN
/// `playbackState` subject, so it lives and dies with the handler — exactly
/// like the subject itself — and there is no teardown that could cancel it
/// without also ending the object it belongs to.
void _vigilarEstadosTransitorios() {
playbackState.listen((estado) {
if (!_esEstadoTransitorio(estado.processingState)) {
_vigilanciaTimer?.cancel();
_vigilanciaTimer = null;
return;
}
// Already armed for THIS transient run: do not restart the window.
// `bufferedPositionStream` republishes ~2/s while buffering, and
// re-arming on each of those would push the deadline out forever —
// the spinner would once again have no bound.
if (_vigilanciaTimer?.isActive ?? false) return;
_vigilanciaTimer = Timer(
vigilanciaTransitoria,
_cerrarEstadoTransitorio,
);
});
}
static bool _esEstadoTransitorio(AudioProcessingState estado) =>
estado == AudioProcessingState.loading ||
estado == AudioProcessingState.buffering;
/// Fires [vigilanciaTransitoria] after the first transient publish of a run.
void _cerrarEstadoTransitorio() {
_vigilanciaTimer = null;
if (!_esEstadoTransitorio(playbackState.value.processingState)) return;
if (_cambiosEnVuelo > 0 || _reconexion.reintentoPendiente) {
// A station really is still opening, or a backoff retry is already
// scheduled to end this. The floor exists for a STALLED machine, not to
// cap how long a slow stream may take — re-arm and look again.
_vigilanciaTimer = Timer(
vigilanciaTransitoria,
_cerrarEstadoTransitorio,
);
return;
}
// ORDINARY MID-STREAM RE-BUFFER — never a floor case.
//
// The rule: the floor only fires while NOTHING is known to be happening.
// A run that already reached `ready` while playing has proven the mount
// delivers audio, and `bufferForPlaybackAfterRebufferDuration` is 5 s, so
// a tunnel or an LTE handover routinely parks this session in
// `buffering` for longer than [vigilanciaTransitoria] — with
// `_cambiosEnVuelo` at 0 (the `finally` ran the moment the non-blocking
// `_iniciarPlaySinBloquear` returned) and `reintentoPendiente` false
// (ExoPlayer raised no error, so `_intentarReconexion` never ran). The
// floor as first written turned every one of those into a latched
// `error` over a stream that was still alive: the single worst outcome
// in a car, and unrecoverable, because `_errorTerminal` then suppresses
// the player's own recovery events.
//
// Deferring here does NOT reopen the unbounded spinner the guidelines
// forbid. This branch is reachable only after audio actually played, and
// that path keeps two independent exits the stalled-mount path lacks:
// ExoPlayer's own source read timeout raises a `PlayerException` into
// `_eventosSub`, which enters `_gestionarErrorReproduccion` and the
// reconnect machine, and any real transition re-publishes and disarms
// this timer. Re-arm rather than return so the floor stays authoritative
// if the session later drops back to a state nothing owns: the flag is
// cleared by [_cambiarFuente] and [stop], so the very next attempt to
// reopen the source is floored normally.
//
// `playing` alone is deliberately NOT the test: it is `just_audio`'s
// play-when-ready intent flag, true for a mount that never delivered a
// byte. See [_reproduccionEstablecida].
if (_reproduccionEstablecida && playbackState.value.playing) {
_vigilanciaTimer = Timer(
vigilanciaTransitoria,
_cerrarEstadoTransitorio,
);
return;
}
debugPrint(
'[PluriWave][ServicioAudio] suelo de estado: '
'${playbackState.value.processingState.name} sin carga viva -> error',
);
// CORRECTED: this used to publish a BARE `idle` — no message, no code —
// on the theory that "nothing we can name actually failed" and that idle
// at least let `audio_service` tear the session down. Both halves were
// wrong for the case that actually reaches here.
//
// Something DID fail and it has a name: a mount that accepted the socket
// and then delivered nothing. `setUrl` returns inside
// [timeoutCambioFuente], so no exception is raised, `_esErrorDeRed` never
// fires and the reconnect machine is never entered; this floor is the
// only exit. And the teardown was not a mercy: a bare idle routes into
// `AudioService._stop()` (`audio_service.dart:1131-1135`), so the driver
// got a wordless spinner followed by silence, a dead session and no
// explanation whatsoever.
//
// `error` + `audioErrorTimeout` instead. It keeps the session (and the
// route back to browse) alive, and it says the one true thing we know:
// the connection never produced audio. The latch stops the player's own
// `idle`, if one ever arrives, from undoing it.
_errorTerminal = true;
playbackState.add(
playbackState.value.copyWith(
processingState: AudioProcessingState.error,
playing: false,
errorCode: codigoErrorEmisoraNoDisponible,
errorMessage: _textos.audioErrorTimeout,
),
);
}
/// Production reader for the device locale.
static Locale lectorLocalePlataformaPorDefecto() =>
PlatformDispatcher.instance.locale;
/// See [lectorLocalePlataformaPorDefecto]. Mutable ONLY so a test can pin a
/// locale; production never writes it.
@visibleForTesting
static Locale Function() lectorLocalePlataforma =
lectorLocalePlataformaPorDefecto;
AppLocalizations get _textos {
final actual = _l10n;
if (actual != null) return actual;
// The headless Android Auto engine NEVER reaches
// [configurarLocalizaciones]: its only production caller chain is
// `EstadoRadio.configurarLocalizaciones` <- `mini_reproductor.dart`'s
// `didChangeDependencies`, and a car bind has no Activity and no widget
// tree. So `_l10n` stays null there and every message the car ever
// showed — including the error text the whole compliance story rests
// on — came out in Spanish no matter what language the driver uses.
return resolverLocalizacionesRespaldo(lectorLocalePlataforma());
}
void configurarLocalizaciones(AppLocalizations l10n) {
_l10n = l10n;
}
/// Test seam for the player factory. `null` in production, where
/// [_crearPlayer] builds the real `just_audio.AudioPlayer` exactly as it
/// always has.
///
/// It exists because the car transport state machine (stop during an
/// in-flight source change, pause during a load, the transient-state
/// watchdog, the time-to-first-message budget) is a sequence of
/// `playbackState` pushes, and asserting a SEQUENCE requires driving the
/// real handler — the pure-predicate style used elsewhere in this file
/// cannot see the order in which those pushes land, which is precisely
/// where the "Stop leaves the app useless" defect lived.
///
/// Static rather than a constructor parameter because `_player` is a
/// `late` field initialized by the constructor itself (through
/// `_conectarStreamsPlayer`), so the factory must already be installed
/// before `PluriWaveAudioHandler()` runs. Tests clear it in `tearDown`.
@visibleForTesting
static AudioPlayer Function(
AudioPipeline pipeline,
AudioLoadConfiguration carga,
)?
fabricaReproductorPrueba;
/// Same seam as [fabricaReproductorPrueba], for the native equalizer effect.
///
/// `AudioEffect.setEnabled` is a silent no-op while the player is detached
/// (`just_audio.dart` gates it on `_player._active`), so off-device a
/// failing native equalizer cannot otherwise be simulated at all — which is
/// why the silent `catch (_) {}` on that path shipped with zero coverage.
/// Static for the same reason as [fabricaReproductorPrueba]: `_eq` is a
/// field initializer, so the factory must already be installed before
/// `PluriWaveAudioHandler()` runs. Tests clear it in `tearDown`.
@visibleForTesting
static AndroidEqualizer Function()? fabricaEcualizadorPrueba;
static AndroidEqualizer _crearEq() =>
fabricaEcualizadorPrueba?.call() ?? AndroidEqualizer();
AudioPlayer _crearPlayer() {
final pipeline = AudioPipeline(androidAudioEffects: [_eq]);
final fabrica = fabricaReproductorPrueba;
if (fabrica != null) return fabrica(pipeline, configuracionCargaAndroid);
return AudioPlayer(
audioPipeline: pipeline,
audioLoadConfiguration: configuracionCargaAndroid,
);
}
/// The `playerStateStream` listener's whole body, as a named method.
///
/// Extracted verbatim so a test can drive a real player-state transition
/// through the REAL handler. It used to be an anonymous closure, which is
/// why the equalizer's idle -> active re-assert below shipped with
/// producer-only coverage: [debeReasertarEcualizadorNativo] had five tests
/// and not one of them could reach this wiring, so deleting the re-assert
/// block left the suite green. The only thing left outside a test's reach
/// is the one-line `.listen(manejarEstadoPlayer)` subscription in
/// [_conectarStreamsPlayer].
@visibleForTesting
void manejarEstadoPlayer(PlayerState state) {
final playing = state.playing;
final proc = state.processingState;
// A real player transition supersedes a refusal about an ACTION: the
// session just moved, so the code/message describing something the
// driver asked for a moment ago must not ride along on the new state.
// Done BEFORE the publish below so its `copyWith` carries the cleared
// fields forward rather than the stale pair (`audio_service.dart`
// :400-427 keeps every omitted field).
_limpiarErrorAccionAuto();
// First line of the listener (Design ADR-3, Phase 3 task 3.3):
// double-gated on `completed` + an active local queue, so this is a
// no-op for radio (which never emits `completed`) and for
// single-track local playback (which never sets `_colaLocal`).
_manejarFinPista(proc);
// eq-estado-unico item D: `AudioEffect.setEnabled` is a no-op while
// the platform player is detached, so any toggle made while stopped
// never landed natively. Re-assert the value we own on the idle ->
// active edge. See [debeReasertarEcualizadorNativo].
if (debeReasertarEcualizadorNativo(
estado: proc,
reproductorActivoAntes: _reproductorActivo,
eqDisponible: _eqDisponible,
)) {
unawaited(_reasertarEcualizadorNativo());
}
// Turns a stream of many events into a single idle -> active EDGE: the
// re-assert above fires once per activation, not on every event.
_reproductorActivo = proc != ProcessingState.idle;
if (playing && proc == ProcessingState.ready) {
// The mount has proven it delivers audio on THIS run. Read only by the
// terminal-state floor, to tell an ordinary re-buffer apart from a
// source that never produced a byte — see [_reproduccionEstablecida].
_reproduccionEstablecida = true;
// Successful (re)connection: reset the backoff so the next stall
// starts over, and leave the reconnect window (S7-R7).
_reconexion.restablecer();
_reconectando = false;
// Local queue (Design ADR-3): the next queued track reached a
// stable playing state — clear the re-entry latch so a LATER
// completion can advance again. A no-op for radio, which never
// sets `_avanzandoCola`.
_avanzandoCola = false;
}
// Local queue transport (Design "Transport wiring"): skip controls
// are only offered while a queue is active — when `_colaLocal` is
// `null` this list/set/index is byte-identical to the pre-change
// radio behavior (regression guard).
final colaActiva = _colaLocal != null;
playbackState.add(
playbackState.value.copyWith(
controls: _controlesTransporte(
colaActiva: colaActiva,
playing: playing,
),
// Android for Cars, "Enable playback control": «Android Auto and
// AAOS display playback controls based on the actions that are
// enabled in the PlaybackStateCompat object. By default, your app
// must support the following actions: ACTION_PLAY, ACTION_PAUSE,
// ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH.»
//
// This set had carried only `seek` + `stop` since the very first
// commit, so the required transport actions were never advertised.
// The car got away with it for a long time — but Android Auto is a
// separate app that updates itself, so a tolerance it used to have
// can disappear without a single line changing on our side. That
// matches the report exactly: "it used to work, and in the latest
// versions it doesn't", with no audio commit in between that could
// explain it.
//
// The phone notification never depended on any of this: it builds
// its play/pause button from `controls`, which is why the symptom
// is car-only.
systemActions: {
MediaAction.play,
MediaAction.pause,
MediaAction.playPause,
MediaAction.stop,
MediaAction.playFromMediaId,
MediaAction.playFromSearch,
MediaAction.seek,
// Previous/next are advertised ALWAYS now, not only for a local
// queue. Android Auto reserves those two slots and only hands the
// space to custom actions when the app declares no support, so
// this is what puts prev/next on the car's transport row -- and
// `skipToNext`/`skipToPrevious` fall back to station-to-station
// skipping when there is no queue, so neither button is inert.
MediaAction.skipToPrevious,
MediaAction.skipToNext,
},
androidCompactActionIndices: [colaActiva ? 1 : 0],
processingState: mapearEstadoProceso(
proc,
cambiandoFuente: _cambiandoFuente,
errorTerminal: _errorTerminal,
),
playing: playing,
// Reported: in Android Auto the progress bar and the time labels of
// a local track never move. `updatePosition` was NEVER set anywhere
// in this file, so it stayed at its `Duration.zero` default while
// `copyWith` refreshed `updateTime` to now on every push
// (audio_service.dart:411-413, :256). A client extrapolates
// `updatePosition + (now - updateTime) * speed`, so it was told
// "position 0, as of right now" over and over — a bar pinned at the
// start. The phone UI never noticed because it reads
// `_player.positionStream` directly.
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
),
);
_trazarEstadoPublicado();
}
void _conectarStreamsPlayer() {
_estadoPlayerSub = _player.playerStateStream.listen(
manejarEstadoPlayer,
);
_bufferedSub = _player.bufferedPositionStream.listen((pos) {
playbackState.add(
playbackState.value.copyWith(
bufferedPosition: pos,
// Must ride along: `copyWith` stamps a fresh `updateTime` but keeps
// the old `updatePosition`, so a push without it actively tells the
// client the PREVIOUS position is current NOW — freezing the bar
// between player-state events. This stream ticks ~2/s, which is
// what keeps the car's bar smooth.
updatePosition: _player.position,
),
);
});
// Duration arrives asynchronously once the source is parsed, and Android
// Auto draws no progress bar for a MediaItem without one. Radio streams
// report null (correct: live audio has no length) and are left alone.
_duracionSub = _player.durationStream.listen((duracion) {
final actual = mediaItem.value;
if (duracion == null || actual == null) return;
if (actual.duration == duracion) return;
mediaItem.add(actual.copyWith(duration: duracion));
});
_eventosSub = _player.playbackEventStream.listen(
(_) {},
onError: (Object error, StackTrace stackTrace) {
_gestionarErrorReproduccion(error);
},
);
_androidAudioSessionIdSub = _player.androidAudioSessionIdStream.listen((
sessionId,
) {
_androidAudioSessionId = sessionId;
if (!_androidAudioSessionIdController.isClosed) {
_androidAudioSessionIdController.add(sessionId);
}
if (debeReaplicarEcualizador(
sessionId: sessionId,
ultimaSessionIdEq: _ultimaSessionIdEq,
eqDisponible: _eqDisponible,
)) {
_ultimaSessionIdEq = sessionId;
unawaited(_activarEcualizador());
}
});
}
String? _ultimaTrazaEstado;
/// Logs the state actually handed to `AudioService.setState`, once per real
/// change (this fires on every player event, so unconditional logging would
/// bury the signal).
///
/// Exists for one open question that static reading could not settle: the
/// Android Auto playback screen shows PLAY while a station is audibly
/// playing. The car does NOT take that icon from `controls` — it takes it
/// from `PlaybackStateCompat.getState()` (AudioService.java:601-611), where
/// `ready` + `playing` is the only combination that yields `STATE_PLAYING`;
/// `idle` gives `STATE_NONE`, which is what a freshly created session
/// carries (:319) and what a car would render as a play button. Every
/// `playbackState.add` in this file was audited and none publishes
/// `playing: false` while audio runs, so the failing input is unknown and
/// any fix would be guesswork.
///
/// `eqDisponible` rides along because the equalizer custom action is gated
/// on it and the flag is otherwise unobservable — one car session answers
/// both questions at once:
///
/// adb logcat | grep PluriWave
///
/// It uses [debugPrint] and NOT `dart:developer`'s `log`, and that is not a
/// style choice. `log()` writes to the VM service, which a RELEASE build
/// does not have — so this trace, and every error line in this file, was
/// invisible in the only build that ever runs in a car. Weeks of "no
/// evidence" were this, not a quiet app. Do not convert these back.
void _trazarEstadoPublicado() {
final s = playbackState.value;
final traza =
'proc=${s.processingState.name} playing=${s.playing} '
'eqDisponible=$_eqDisponible eqActivo=$_ecualizadorActivo '
'custom=${s.controls.where((c) => c.customAction != null).length} '
'controles=${s.controls.length}';
if (traza == _ultimaTrazaEstado) return;
_ultimaTrazaEstado = traza;
debugPrint('[PluriWave][ServicioAudio] estado $traza');
}
/// Binds [construirControlesTransporte] — which holds the whole contract,
/// including why the equalizer must precede `stop` — to this handler's live
/// equalizer state.
List<MediaControl> _controlesTransporte({
required bool colaActiva,
required bool playing,
}) => construirControlesTransporte(
colaActiva: colaActiva,
playing: playing,
eqDisponible: _eqDisponible,
eqActivo: _ecualizadorActivo,
l10n: _textos,
);
/// Re-pushes `playbackState` with a freshly built controls list (item 4):
/// called whenever EQ availability/enabled state changes outside a
/// player-state transition (a custom-action tap, or a phone-side toggle),
/// so the equalizer action's icon and label stay in sync on the car's
/// now-playing screen without waiting for an unrelated player event.
/// Idempotent and cheap (no native calls) — safe to call from any EQ
/// state-changing path.
void _actualizarControlesEq() {
playbackState.add(
playbackState.value.copyWith(
controls: _controlesTransporte(
colaActiva: _colaLocal != null,
playing: playbackState.value.playing,
),
// Must ride along, exactly as in the two sibling emissions in
// `_conectarStreamsPlayer`: `copyWith` stamps a fresh `updateTime`
// but keeps the OLD `updatePosition`, so a push without it tells the
// client "you are at <stale position>, as of right now". Every
// equalizer tap therefore snapped the car's progress bar backwards
// to wherever it stood at the last real player event.
updatePosition: _player.position,
),
);
}
/// Re-states [_ecualizadorActivo] (and the current preset's gains) on the
/// native effect now that the platform player is attached again
/// (eq-estado-unico item D). Delegates to [_activarEcualizador], which is
/// already idempotent and already re-asserts the CURRENT value rather than
/// forcing the equalizer on.
Future<void> _reasertarEcualizadorNativo() async {
_reasercionesEcualizador++;
debugPrint(
'[PluriWave][ServicioAudio] reasertando EQ nativo '
'activo=$_ecualizadorActivo',
);
await _activarEcualizador();
}
/// Gestiona cualquier error de reproducción de ExoPlayer.
///
/// Network-class failures while the user still intends to play enter the
/// reconnect state machine (S7-R2) instead of surfacing a terminal error;
/// only retry exhaustion (or non-network errors) falls through to the
/// existing error path, so the user sees a single error — no spam per retry.
void _gestionarErrorReproduccion(Object error) {
// Terminal-error path also ends in `_player.stop()` below, and it is
// reachable from `_eventosSub`'s `onError` WHILE a source change is still
// in flight. Dropping the mask here keeps the invariant total: the flag
// is `false` before every `_player.stop()` call in this class.
_cambiandoFuente = false;
if (_intentarReconexion(error)) return;
String mensaje;
String codigoLog;
if (error is PlayerException) {
codigoLog = 'PlayerException(code=${error.code}): ${error.message}';
mensaje = _mensajeAmigable(error);
} else if (error is TimeoutException) {
codigoLog = 'TimeoutException: $error';
mensaje = _textos.audioErrorTimeout;
} else {
codigoLog = 'Error desconocido: $error';
mensaje = _textos.audioErrorGeneric;
}
debugPrint('[PluriWave][ServicioAudio] Error reproducción: $codigoLog');
_detenerReconexion();
// Armed BEFORE the publish and BEFORE `_player.stop()`: the stop below
// makes `playerStateStream` emit a distinct `idle`, and forwarding that
// idle is what used to tear the whole media session down one event-loop
// turn after the message appeared. See [mapearEstadoProceso]'s
// `errorTerminal` parameter for the full chain.
_errorTerminal = true;
playbackState.add(
playbackState.value.copyWith(
processingState: AudioProcessingState.error,
playing: false,
errorMessage: mensaje,
),
);
// The failed item and station are KEPT, deliberately.
//
// This used to do `emisoraActual = null; mediaItem.add(null);`, which
// left the media session in STATE_ERROR with no metadata at all.
//
// CORRECTED: an earlier revision of this comment claimed that keeping
// `mediaItem`/`emisoraActual` was what stopped PluriWave vanishing from
// the car pane when a station failed. It was not, and the app kept
// vanishing. The teardown is driven by the `idle` `_player.stop()`
// produces, not by missing metadata: `audio_service` calls
// `AudioService._stop()` on any non-idle -> idle transition
// (`audio_service.dart:1131-1135`) whatever the media item holds. The
// `_errorTerminal` latch above is the actual fix; keeping the metadata is
// still worth doing for two smaller reasons — the screen can name the
// station that failed instead of going blank, and previous/next stay
// usable so the driver skips out of a dead station instead of being
// stranded (`_saltarEmisora` needs `emisoraActual` to know where it is).
//
// `_player.stop()` itself stays: it releases the decoders behind a stream
// that is already dead, and its `idle` is now suppressed rather than
// forwarded, so the error state stands until the driver does something.
_player.stop().catchError((_) {});
}
/// Network-class failures: ExoPlayer 2xxx source errors (no internet, bad
/// URL/host, timeout) and our own source-change timeout guard.
bool _esErrorDeRed(Object error) =>
(error is PlayerException && error.code >= 2000 && error.code < 3000) ||
error is TimeoutException;
/// Attempts to enter (or stay in) the reconnect window. Returns true when a
/// retry was scheduled and the terminal error path must be skipped.
bool _intentarReconexion(Object error) {
if (!_esErrorDeRed(error)) return false;
final item = mediaItem.value;
if (item == null) return false;
final decision = _reconexion.registrarFallo(
intencionReproducir: _intencionReproducir,
alReintentar: () => _reintentarFuente(item),
);
if (decision != DecisionReconexion.reintentar) {
// ignorar (user pause/stop or interruption) keeps the player quiet;
// agotado falls through to the single terminal error (S7-R2-C).
if (decision == DecisionReconexion.ignorar) {
_reconectando = false;
}
return decision == DecisionReconexion.ignorar;
}
_reconectando = true;
debugPrint(
'[PluriWave][ServicioAudio] Stall de red, reintento ${_reconexion.intentos}/'
'${_reconexion.maxReintentos} en '
'${_reconexion.retrasoParaIntento(_reconexion.intentos).inSeconds}s',
);
playbackState.add(
playbackState.value.copyWith(
processingState: AudioProcessingState.buffering,
playing: false,
// NOT `null` any more. `copyWith` treats an explicit `null` as
// "clear" (audio_service.dart:419-420), so the whole reconnect window
// used to be a spinner with nothing written on it — up to ~100 s in
// which the driver was told exactly nothing. `buffering` + a message
// is the shape the Cars guidelines ask for: say what is happening,
// and keep trying behind it.
errorMessage: _textos.playbackStatusReconnecting,
),
);
return true;
}
/// Re-issues the live source through the revision-guarded source-change
/// queue, so a user source switch or stop during the retry cancels it.
void _reintentarFuente(MediaItem item) {
if (!_intencionReproducir) {
_detenerReconexion();
return;
}
final revision = ++_revisionFuente;
_colaCambioFuente = _colaCambioFuente
.catchError((_) {})
.then((_) => _cambiarFuente(item, revision))
// Failures already routed through _gestionarErrorReproduccion, which
// schedules the next backoff retry or surfaces the terminal error.
.catchError((_) {});
}
void _detenerReconexion() {
_reconexion.cancelar();
_reconectando = false;
}
/// Traduce códigos de error de ExoPlayer a mensajes para el usuario.
String _mensajeAmigable(PlayerException e) {
final code = e.code;
if (code >= 2000 && code < 3000) {
if (code == 2001) return _textos.audioErrorNoInternet;
if (code == 2002) return _textos.audioErrorInvalidUrl;
if (code == 2003) return _textos.audioErrorNotFound;
if (code == 2004) return _textos.audioErrorTimeout;
return _textos.audioErrorCannotConnect;
}
if (code >= 3000 && code < 4000) {
return _textos.audioErrorUnsupportedFormat;
}
if (code >= 4000 && code < 5000) {
return _textos.audioErrorDecode;
}
final msg = e.message ?? '';
if (msg.contains('Cleartext') || msg.contains('cleartext')) {
return _textos.audioErrorCleartext;
}
if (msg.contains('CERTIFICATE') || msg.contains('HandshakeException')) {
return _textos.audioErrorSsl;
}
return _textos.audioErrorCannotPlay;
}
/// Public entry point for EVERY external play (phone `reproducir`, car
/// `emisora:`/`grupo:`/`pista:` non-path). ALWAYS clears the
/// local queue FIRST (Design ADR-2, the single load-bearing invariant:
/// "external play = leave queue mode") so a stale auto-advance can never
/// fire after an external source switch, then delegates to the private
/// [_encolarCambioFuente] — the SAME source-change path queue playback
/// uses via [_reproducirEntradaCola].
@override
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);
}
/// The revision-guarded source-change queue (Design ADR-1/ADR-2), shared
/// by public [playMediaItem] (which clears `_colaLocal` first) and
/// [_reproducirEntradaCola] (queue play, which does NOT clear it) — the
/// body is exactly [playMediaItem]'s previous implementation, unchanged.
Future<void> _encolarCambioFuente(MediaItem mediaItem) async {
_intencionReproducir = true;
// Fresh user play/source switch: restart the backoff from scratch and
// leave any previous reconnect window (S7-R2).
_reconexion.restablecer();
_reconectando = false;
final revision = ++_revisionFuente;
_colaCambioFuente = _colaCambioFuente
.catchError((_) {})
.then((_) => _cambiarFuente(mediaItem, revision));
return _colaCambioFuente;
}
/// Plays a single local-queue track WITHOUT clearing `_colaLocal` (Design
/// ADR-2's "queue play" transition) — the ONLY other caller of
/// [_encolarCambioFuente] besides public [playMediaItem]. Callers are
/// responsible for setting/keeping `_colaLocal` themselves before calling
/// this (queue start, auto-advance, skip) — this method itself never
/// touches the field on the happy path.
///
/// [colaEsperada], when provided, is the mid-await race guard (Design
/// ADR-3's advance flow): after resolving [nodo]'s content URI (the
/// async gap where a user action could replace `_colaLocal`), playback
/// only proceeds when `_colaLocal` is still IDENTICAL to [colaEsperada].
/// Skip/queue-start callers omit it (no prior async gap to guard).
Future<void> _reproducirEntradaCola(
NodoLocal nodo, {
ColaLocal? colaEsperada,
}) async {
final fuente = _fuenteMusicaLocalGlobal;
if (fuente == null) {
if (colaEsperada != null) _avanzandoCola = false;
return;
}
final item = await construirMediaItemColaLocal(
nodo,
fuente: fuente,
etiquetas: etiquetasArbolAutoDesde(_textos),
);
if (colaEsperada != null && !avanceEsValido(_colaLocal, colaEsperada)) {
// Stale: a user action (external play, stop, another skip) replaced
// `_colaLocal` during the await — abort this advance without
// touching whatever the newer action already set.
return;
}
if (item == null) {
// The local track's content URI could not be resolved (revoked
// permission, moved/deleted file). Only an in-flight advance owns
// the latch here (a direct skip/queue-start never armed it) — end
// the queue cleanly instead of leaving `_avanzandoCola` stuck.
if (colaEsperada != null) {
_avanzandoCola = false;
_desactivarCola();
unawaited(stop());
}
return;
}
await _encolarCambioFuente(item);
}
/// Clears the local queue (Design ADR-2/ADR-4): shared by [stop], the
/// end-of-queue path, and a resolve failure mid-advance.
void _desactivarCola() {
_colaLocal = null;
_avanzandoCola = false;
}
/// Sets up a fresh local-music queue and plays its first track (Design
/// "Data Flow" — the `iniciarCola` seam [playFromMediaId] passes to
/// [reproducirCarpetaLocal]).
Future<void> _iniciarColaLocal(List<NodoLocal> pistas) async {
final cola = ColaLocal(pistas: pistas);
_colaLocal = cola;
await _reproducirEntradaCola(cola.actual);
}
/// First line of the `playerStateStream` listener (Design ADR-3, Phase 3
/// task 3.3): pure-decision-driven queue-advance handling. A no-op for
/// radio (never emits `completed`) and single-track local playback
/// (never sets `_colaLocal`) — see [decidirAvanceCola]'s doc comment for
/// the full gating contract.
void _manejarFinPista(ProcessingState proc) {
final decision = decidirAvanceCola(
colaLocal: _colaLocal,
avanzandoCola: _avanzandoCola,
trackCompletado: proc == ProcessingState.completed,
);
// Reported: a local track sometimes jumped to another one on its own,
// without reaching the end and without anyone pressing a thing. Reading
// the code cannot settle it -- an advance here requires a genuine
// `completed` from just_audio, so either the player reports the end
// early (plausible for a `content://` SAF source, whose duration is not
// always exact) or something else moved the track. Rather than guess,
// log the transition with the state that caused it, so the next report
// arrives with the reason attached instead of another hypothesis.
if (decision != DecisionAvanceCola.ninguna) {
debugPrint(
'[PluriWave][ServicioAudio] avance de cola decision=${decision.name} '
'proc=${proc.name} pos=${_player.position} dur=${_player.duration} '
'pista=${mediaItem.value?.title}',
);
}
switch (decision) {
case DecisionAvanceCola.ninguna:
return;
case DecisionAvanceCola.desactivar:
_desactivarCola();
unawaited(stop());
return;
case DecisionAvanceCola.avanzar:
final siguiente = _colaLocal!.conSiguiente();
if (siguiente == null) {
// Structurally unreachable — decidirAvanceCola already proved
// conSiguiente() != null for `avanzar` — guarded defensively.
_desactivarCola();
unawaited(stop());
return;
}
// Set the re-entry latch synchronously, before any await, so a
// second rapid `completed` emission is caught by
// decidirAvanceCola's `avanzandoCola == true` gate (Design
// ADR-3).
_avanzandoCola = true;
_colaLocal = siguiente;
unawaited(
_reproducirEntradaCola(siguiente.actual, colaEsperada: siguiente),
);
}
}
Future<void> _cambiarFuente(MediaItem mediaItem, int revision) async {
// Revision guard ON ENTRY — the P0 "Stop makes the app useless" fix.
//
// `_encolarCambioFuente` bumps `_revisionFuente` when a change is
// ENQUEUED, not when it runs, so several changes can be waiting on
// `_colaCambioFuente` at once. `stop()` bumps the revision again and
// publishes `idle` itself. Every queued change then woke up, REWROTE
// `loading` over that `idle`, and only afterwards (past `_recrearPlayer`)
// discovered its revision was stale. The last thing Android Auto saw was
// `loading` on a session `audio_service` had already torn down: a
// permanent spinner with a dead Stop button.
//
// Nothing below this line can strand `_cambiandoFuente`: the flag is only
// set AFTER this return, so an early exit here leaves it exactly as the
// caller found it.
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;
// A fresh source (a reconnect ATTEMPT included) has proven nothing yet:
// re-arm the terminal-state floor for it. Without this reset, a station
// that played and then died would inherit the previous run's "audio is
// flowing" verdict and its spinner would never be floored.
_reproduccionEstablecida = false;
playbackState.add(
playbackState.value.copyWith(
processingState: AudioProcessingState.loading,
playing: false,
// A FRESH user source change clears whatever was on screen; a
// reconnect ATTEMPT does not. Retries run behind the "reconnecting"
// message [_intentarReconexion] published, and blanking it here on
// every attempt would put the driver back on a wordless spinner for
// most of the backoff window.
errorMessage: _reconectando ? playbackState.value.errorMessage : null,
// ALWAYS cleared, unlike the message. `copyWith` carries an OMITTED
// field forward (`audio_service.dart:419-420`), and this used to omit
// `errorCode`, so a code published by an earlier refusal (a premium
// tap, a voice miss) rode along on every later state indefinitely —
// pairing a stale `ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED` with whatever
// message came next. It is cleared even while reconnecting because
// "Reconectando..." is a STATUS, not an error: `setState` still
// forwards a message with a null code
// (`AudioService.java:541-544`).
errorCode: null,
),
);
// Opens the masking window BEFORE `_recrearPlayer`, which is what
// disposes the old player and constructs the fresh one whose first
// `playerStateStream` event is the transient `idle` we must not forward
// (see [mapearEstadoProceso]).
_cambiandoFuente = true;
// Paired with the `finally` below: tells the terminal-state floor that a
// station is genuinely still opening, so it re-arms instead of forcing a
// slow-but-healthy load to `idle`.
_cambiosEnVuelo++;
try {
await _recrearPlayer();
if (revision != _revisionFuente) return;
await _player.setUrl(mediaItem.id).timeout(timeoutCambioFuente);
// Source swap complete: the fresh player's transient `idle` is behind
// us, so stop masking immediately — before anything below can await —
// and let a real `idle` through again from here on.
_cambiandoFuente = false;
if (revision != _revisionFuente) return;
_iniciarPlaySinBloquear(mediaItem, revision);
unawaited(_activarEcualizador());
} on PlayerException catch (e) {
// Cleared BEFORE `_gestionarErrorReproduccion`, not just by the
// `finally`: that method calls `_player.stop()` without awaiting it, so
// the resulting `idle` could otherwise land while the mask was still
// up and be rewritten to `loading`. Same reason in the two clauses
// below.
_cambiandoFuente = false;
if (revision == _revisionFuente) {
_gestionarErrorReproduccion(e);
// Reconnect engaged: complete normally so callers do not surface a
// snackbar/dialog while the handler keeps retrying (S7-R3).
if (_reconectando) return;
}
throw Exception(_mensajeAmigable(e));
} on TimeoutException catch (e) {
_cambiandoFuente = false;
// A real network drop usually surfaces as our 12s source timeout:
// route it through the reconnect machine instead of a terminal error.
if (revision == _revisionFuente) {
_gestionarErrorReproduccion(e);
if (_reconectando) return;
}
rethrow;
} on Exception catch (e, stackTrace) {
_cambiandoFuente = false;
debugPrint(
'[PluriWave][ServicioAudio] Error inesperado en playMediaItem: $e',
);
if (revision == _revisionFuente) {
// Same shape as [_gestionarErrorReproduccion]'s terminal path, and
// for the same reasons — this clause is the THIRD way a station can
// die and it used to be the least survivable of them.
//
// The latch stops the `idle` that follows (`_recrearPlayer` and the
// player's own teardown both produce one) from being forwarded into
// `AudioService._stop()` (`audio_service.dart:1131-1135`).
//
// And the metadata is now KEPT. Clearing it here contradicted the
// sibling path outright: Android Auto drops a session with nothing to
// show, so `mediaItem.add(null)` made PluriWave disappear from the
// car pane the moment an unexpected error hit, and `emisoraActual =
// null` additionally left `_saltarEmisora` with no idea where it was,
// so previous/next stopped working — stranding the driver on the one
// failure they most need to skip out of.
_errorTerminal = true;
playbackState.add(
playbackState.value.copyWith(
processingState: AudioProcessingState.error,
playing: false,
errorMessage: _textos.audioErrorUnexpectedPlayback,
),
);
}
rethrow;
} finally {
// Leak-proof backstop. Dart runs `finally` on EVERY exit from the
// block above: normal completion, both `revision != _revisionFuente`
// early returns, every `throw`/`rethrow` out of a catch clause, and —
// crucially — any `Error` (as opposed to `Exception`) that none of the
// three clauses catches. The flag must never depend on a single
// hand-audited exit path, because a `_cambiandoFuente` stuck at `true`
// would mask a REAL stop's `idle` and leave the notification unkillable.
_cambiandoFuente = false;
_cambiosEnVuelo--;
}
}
Future<void> _recrearPlayer() async {
await _estadoPlayerSub?.cancel();
await _bufferedSub?.cancel();
await _duracionSub?.cancel();
await _eventosSub?.cancel();
await _androidAudioSessionIdSub?.cancel();
final anterior = _player;
try {
await anterior.stop().timeout(_timeoutCierrePlayer);
} catch (_) {}
try {
await anterior.dispose().timeout(_timeoutCierrePlayer);
} catch (_) {}
_eq = _crearEq();
// `_eqDisponible` is deliberately NOT reset here. It answers "does this
// DEVICE have a usable native Equalizer effect", which no station change
// can alter — and resetting it on every source change is what made a car
// toggle land in a window where every native EQ path was gated off (the
// reported «does nothing») and made the EQ custom action disappear from
// the now-playing screen and come back seconds later
// (`controlesEcualizadorPersonalizados` returns `const []` when
// unavailable). [_activarEcualizador] is the only writer now: it sets it
// true when the fresh effect reports bands, false when it throws.
//
// Keeping it true across the rebuild cannot lie or throw, and that was
// verified against just_audio 0.9.46 rather than assumed:
// - `AudioEffect.setEnabled` short-circuits on `_player._active`, so on
// the detached fresh player it only records the Dart-side intent and
// never reaches the platform — no throw, no native call.
// - that recorded intent is NOT lost: the effect's `_toMessage()` is
// only read when the player attaches (`AudioPlayer._setPlatformActive`
// → `InitRequest.androidAudioEffects`), so a toggle made inside this
// window is carried into the new native pipeline verbatim.
// - the one call that WOULD hang is `await AndroidEqualizer.parameters`:
// its `Completer` only completes in `_activate`, i.e. when the player
// attaches. No toggle path awaits it any more — they read the
// [_paramsEq] cache cleared just below and skip while it is null.
_paramsEq = null;
// Resets alongside its siblings above: the fresh player starts detached,
// so the next non-idle event is a genuine idle -> active edge that
// [debeReasertarEcualizadorNativo] must see. A value stuck at `true`
// across the rebuild would swallow exactly the re-assert this exists for.
_reproductorActivo = false;
_androidAudioSessionId = null;
_ultimaSessionIdEq = null;
_player = _crearPlayer();
await _player.setVolume(_volumenEfectivo);
_conectarStreamsPlayer();
}
void _iniciarPlaySinBloquear(MediaItem mediaItem, int revision) {
// Re-validate the user's intent, not just the source revision.
//
// Only `stop()` bumps `_revisionFuente`, so a `pause()` landing WHILE a
// station was still loading did not cancel anything: the load finished,
// this method ran, and the station started playing right after the user
// had asked for silence. `_intencionReproducir` is the flag `pause()`
// already clears (and `play()` already sets), so reading it here is the
// narrow fix.
//
// Deliberately NOT done by bumping `_revisionFuente` inside `pause()`:
// that would abort the whole source change, and a pause landing before
// `_recrearPlayer()` returned would leave a fresh player with NO source
// loaded — the subsequent resume would then call `play()` on nothing and
// produce silence. Letting the load COMPLETE and only withholding the
// `play()` keeps resume working: the source is already there.
if (!_intencionReproducir) {
// The source IS loaded, the user just does not want it playing. Say so
// explicitly: without this the car keeps the `loading` spinner the
// source change opened with, and nothing else would ever replace it —
// the player makes no transition when `play()` is never called.
playbackState.add(
playbackState.value.copyWith(
processingState: AudioProcessingState.ready,
playing: false,
),
);
return;
}
unawaited(
_player.play().catchError((Object error, StackTrace stackTrace) {
debugPrint(
'[PluriWave][ServicioAudio] Error al iniciar ${mediaItem.title}: $error',
);
if (revision == _revisionFuente) {
_gestionarErrorReproduccion(error);
}
}),
);
}
Future<void> _activarEcualizador() async {
try {
final params = await _eq.parameters;
_eqDisponible = params.bands.isNotEmpty;
// eq-estado-unico item E: the ONE number that decides whether
// [mapearGananciaNativa] can be silently boosting a FLAT preset on
// this device. `Equalizer.getBandLevelRange()` is not required to be
// symmetric, and nothing else in the app can observe what it returned.
// `debugPrint` (never `dart:developer`'s `log`) so it reaches logcat in
// the release build, which is the only one that ever runs in a car:
//
// adb logcat | grep PluriWave
debugPrint(
'[PluriWave][ServicioAudio] eq rango bandas=${params.bands.length} '
'minDecibels=${params.minDecibels} maxDecibels=${params.maxDecibels} '
'activo=$_ecualizadorActivo preset=${_presetActual.nombre}',
);
if (_eqDisponible) {
_paramsEq = params;
await _conmutarEcualizadorNativo(_ecualizadorActivo);
}
} catch (_) {
_eqDisponible = false;
}
// Item 4: an availability flip (e.g. a station switch that lands on a
// device without the native Equalizer effect) must show/hide the EQ
// custom actions immediately, not wait for a coincidental later
// player-state event.
_actualizarControlesEq();
}
/// Pure re-apply decision for a native session-id emission. No side effects.
///
/// Returns `true` when the native audio session id genuinely rotated
/// mid-playback (audio-focus ducking by another app) and the equalizer is
/// currently attached, meaning the caller should re-attach the EQ and
/// re-push the current preset's gains via [_activarEcualizador].
@visibleForTesting
static bool debeReaplicarEcualizador({
required int? sessionId,
required int? ultimaSessionIdEq,
required bool eqDisponible,
}) => sessionId != null && sessionId != ultimaSessionIdEq && eqDisponible;
/// Pure decision for re-asserting the on/off state on the NATIVE effect
/// when the platform player becomes active again (eq-estado-unico item D).
/// No side effects.
///
/// Why it is needed: `just_audio`'s `AudioEffect.setEnabled`
/// (`just_audio.dart:3842-3848`) only reaches the platform while
/// `_player._active` is true. After a `stop()` — or any transition to
/// `idle` — the Dart-side intent is updated but the native effect is not.
/// A user who turns the equalizer off while stopped, then presses play,
/// would get audio that is still equalized with the button reading "off".
///
/// The native effect is treated as WRITE-ONLY throughout: `just_audio`
/// exposes no read-back of `Equalizer.getEnabled()`, so this never
/// compares against the device — it simply re-states the value the app
/// already owns, which is idempotent and cheap.
///
/// [reproductorActivoAntes] is the tracked state BEFORE [estado] arrived,
/// so only the idle -> active edge fires; a player already active does not
/// re-assert on every one of its many events.
@visibleForTesting
static bool debeReasertarEcualizadorNativo({
required ProcessingState estado,
required bool reproductorActivoAntes,
required bool eqDisponible,
}) =>
eqDisponible &&
!reproductorActivoAntes &&
estado != ProcessingState.idle;
/// Forces [_eqDisponible] for a test.
///
/// `_eqDisponible` is only ever set from `AndroidEqualizer.parameters`
/// (see [_activarEcualizador]), whose future only completes on a real
/// device, so off-device it is permanently `false` — and every EQ path
/// worth testing is gated on it. Without this seam
/// [manejarEstadoPlayer]'s re-assert can only ever be exercised on its
/// false branch.
@visibleForTesting
void simularEcualizadorDisponible(bool disponible) {
_eqDisponible = disponible;
}
/// How many times [_reasertarEcualizadorNativo] has actually run.
///
/// The native call it makes is unobservable off-device (see
/// [simularEcualizadorDisponible]), so this counter is the only evidence a
/// test can assert on that the re-assert HAPPENED, rather than that the
/// predicate would have said yes.
@visibleForTesting
int get reasercionesEcualizador => _reasercionesEcualizador;
int _reasercionesEcualizador = 0;
/// Aplica un preset al ecualizador nativo Android.
Future<void> aplicarPreset(PresetEcualizador preset) async {
_presetActual = preset;
// A preset chosen by anyone (car folder, phone screen) claims ownership:
// a disk seed still in flight must not overwrite it. See
// [_sembrarPresetDesdeDisco].
_presetElegido = true;
if (_eqDisponible) {
try {
// Enable-then-gains here does NOT contradict [pasosEcualizador]'s
// gains-then-enable. That order matters only on an on/off TRANSITION,
// where enabling first un-bypasses an effect still holding the
// previous preset. Choosing a preset is not a transition: the effect
// is already in its final on/off state, so this `setEnabled` is the
// idempotent re-assert that keeps the native effect honest after a
// `stop()` (see [debeReasertarEcualizadorNativo]) and opens no
// stale-gain window of its own.
await _eq.setEnabled(_ecualizadorActivo);
if (_ecualizadorActivo) {
await _empujarGananciasNativas(preset);
}
} catch (e) {
_registrarFalloEq('aplicarPreset(${preset.nombre})', e);
}
}
// Item 4: keeps the EQ custom action's preset-cycle label in sync
// regardless of WHO changed the preset (a car customAction tap or the
// phone settings screen via EstadoEcualizador) — single chokepoint.
_actualizarControlesEq();
}
/// Ajusta una banda individual.
Future<void> setBanda(int index, double db) async {
final bandas = List<double>.from(_presetActual.bandas);
if (index >= 0 && index < bandas.length) {
bandas[index] = db;
_presetActual = _presetActual.copyWithBandas(bandas);
}
_presetElegido = true;
if (!_eqDisponible || !_ecualizadorActivo) return;
final params = _paramsEq;
if (params == null) return;
try {
if (index < params.bands.length) {
await params.bands[index].setGain(
mapearGananciaNativa(
db,
minDecibels: params.minDecibels,
maxDecibels: params.maxDecibels,
),
);
}
} catch (e) {
_registrarFalloEq('setBanda($index)', e);
}
}
/// Writes [preset]'s band levels into the native effect.
///
/// Skips silently while [_paramsEq] is `null` (the player has not attached
/// since the last rebuild): the gains have nowhere to go yet and
/// [_activarEcualizador] pushes them the moment it does.
Future<void> _empujarGananciasNativas(PresetEcualizador preset) async {
final params = _paramsEq;
if (params == null) return;
for (int i = 0; i < params.bands.length && i < preset.bandas.length; i++) {
await params.bands[i].setGain(
mapearGananciaNativa(
preset.bandas[i],
minDecibels: params.minDecibels,
maxDecibels: params.maxDecibels,
),
);
}
}
/// The native operations an on/off transition performs, in
/// [pasosEcualizador] order.
///
/// Returns `false` when the [PasoEcualizador.habilitacion] step itself
/// threw, i.e. when the device did NOT adopt [activo]. A failed gains step
/// does not make the transition dishonest: the effect really is in the
/// requested on/off state, just carrying stale band levels.
Future<bool> _conmutarEcualizadorNativo(bool activo) async {
_pasosEqEjecutados.clear();
var conmutado = true;
for (final paso in pasosEcualizador(activo: activo)) {
try {
switch (paso) {
case PasoEcualizador.ganancias:
await _empujarGananciasNativas(_presetActual);
case PasoEcualizador.habilitacion:
await _eq.setEnabled(activo);
}
_pasosEqEjecutados.add(paso);
} catch (e) {
_registrarFalloEq('$paso(activo=$activo)', e);
if (paso == PasoEcualizador.habilitacion) conmutado = false;
}
}
return conmutado;
}
/// Single trace/count point for every native equalizer failure.
///
/// [debugPrint] and never `dart:developer`'s `log`, for the same reason as
/// the rest of this file: `log()` writes to the VM service, which the
/// RELEASE build a car runs does not have.
void _registrarFalloEq(String operacion, Object error) {
_fallosNativosEq++;
debugPrint(
'[PluriWave][ServicioAudio] fallo nativo del ecualizador en '
'$operacion: $error',
);
}
/// Sets the equalizer on/off state AND persists it — the single entry
/// point every surface goes through (phone settings via
/// `EstadoEcualizador`, the notification, and the car's [accionEqToggle]).
Future<void> setEcualizadorActivo(bool activo) =>
_aplicarEcualizadorActivo(activo, persistir: true);
/// Adopts a PRESET that came from disk, the sibling of
/// [sembrarEcualizadorActivo]. Bound through
/// `registrarHandler(leerPresetPersistido: ...)`.
///
/// Unlike the on/off flag's seed this one YIELDS to a live choice. The flag
/// has exactly one persisted value and the handler owns writing it, so
/// seeding it can never contradict anybody. The preset does not: the phone
/// UI resolves a richer value (per-station, and per-Bluetooth-device when
/// the multi-device toggle is on) that this narrow "principal preset" read
/// knows nothing about. The seed's disk read is `unawaited`, so without the
/// [_presetElegido] guard a slow read could land after `EstadoEcualizador`
/// had already pushed the right preset and silently replace it with the
/// principal one. The seed exists to fill a VOID, never to overrule.
Future<void> sembrarPresetEcualizador(PresetEcualizador preset) async {
if (_presetElegido) return;
await aplicarPreset(preset);
}
/// Adopts a value that came FROM disk (eq-estado-unico item A). Identical
/// to [setEcualizadorActivo] except that it does not write back — seeding
/// is a read, and echoing it to disk would only add a pointless write on
/// every engine start.
Future<void> sembrarEcualizadorActivo(bool activo) =>
_aplicarEcualizadorActivo(activo, persistir: false);
Future<void> _aplicarEcualizadorActivo(
bool activo, {
required bool persistir,
}) async {
final anterior = _ecualizadorActivo;
_ecualizadorActivo = activo;
if (_eqDisponible && !await _conmutarEcualizadorNativo(activo)) {
// The device REFUSED the on/off call. Publishing `activo` anyway would
// put an icon on the car's now-playing screen claiming a state the
// audio does not have — and persisting it would resurrect that lie on
// the next engine start. Rolling back is cheap here because
// `_ecualizadorActivo` is the single in-memory owner (eq-estado-unico)
// and the controls are rebuilt from it one line below; the toggle then
// honestly reads "unchanged" and the failure is in the logcat.
_ecualizadorActivo = anterior;
_actualizarControlesEq();
return;
}
// Item 4: keeps the EQ custom action's on/off label in sync regardless
// of WHO toggled it (a car customAction tap or the phone settings
// screen via EstadoEcualizador).
_actualizarControlesEq();
if (!persistir) return;
_eqActivoPersistido = activo;
final guardar = _persistirEqActivo;
if (guardar == null) return;
// eq-estado-unico item B: the handler owns this write now. It used to
// be `EstadoEcualizador._resincronizarConHandler`'s job, which meant a
// toggle made in the car or from the notification was only saved if a
// phone UI object happened to exist — on a headless Android Auto engine
// it never did, so the car toggle was silently lost on every restart.
//
// Failures are swallowed on purpose: a full disk must not turn the
// equalizer button into a crash.
try {
await guardar(activo);
} catch (e) {
debugPrint(
'[PluriWave][ServicioAudio] no se pudo persistir el estado EQ: $e',
);
}
}
Future<void> setVolumen(double vol) async {
_volumen = vol.clamp(0.0, 1.0);
await _player.setVolume(_volumenEfectivo);
}
double get _volumenEfectivo =>
_atenuado ? _volumen * _factorAtenuacion : _volumen;
// ── ObjetivoAudioInterrumpible (audio-session seam, S3-R1) ───────────────
@override
bool get intencionReproducir => _intencionReproducir;
@override
bool get estaReproduciendo => playbackState.value.playing;
@override
Future<void> pausar() => pause();
@override
Future<void> reanudar() => play();
@override
Future<void> setAtenuado(bool atenuado) async {
if (_atenuado == atenuado) return;
_atenuado = atenuado;
await _player.setVolume(_volumenEfectivo);
}
/// Fix "EQ Re-Apply After Audio-Focus Interruption": thin delegate to the
/// existing [_activarEcualizador] (already does the correct idempotent
/// `setEnabled` + re-push-gains work, already re-asserts the CURRENT
/// [_ecualizadorActivo] rather than forcing it on). Called by
/// [ServicioAudioSession] on resume-from-pause and on un-duck — see that
/// interface member's doc for why the existing session-id-change trigger
/// misses this case.
@override
Future<void> reaplicarEcualizador() => _activarEcualizador();
@override
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]).
_errorTerminal = false;
return _player.play();
}
@override
Future<void> pause() {
// User (or audio-session interruption) pause: disarm any pending retry —
// a stall must never fight an intentional pause (S7-R2-B, S7-R6).
_intencionReproducir = false;
_errorTerminal = false;
_detenerReconexion();
return _player.pause();
}
@override
Future<void> stop() async {
// User stop (including the sleep-timer fade-out stop): cancel reconnect
// so retries never restart playback after a stop (S7-R6).
_intencionReproducir = false;
_detenerReconexion();
// Disarm the action-refusal window before it can fire against the idle
// this method is about to publish (see [_limpiarErrorAccionAuto]).
_limpiarErrorAccionAuto();
// Local queue (Design ADR-2): clears alongside the reconnect-cancel
// logic above — `onTaskRemoved` (which calls stop()) inherits this for
// free (Phase 3 task 3.5).
_desactivarCola();
// Genuine user stop: drop the source-change mask BEFORE `_player.stop()`,
// so its `idle` reaches `playbackState` as a REAL `idle` and
// `audio_service` tears the foreground service down as it always has.
// `stop()` never pushes `idle` itself — `BaseAudioHandler.stop()` is
// empty and the teardown is driven entirely by the player's emission —
// so a stop landing while a station change is still in flight would
// otherwise be masked to `loading` and the notification would become
// unkillable. Paired with `_revisionFuente++` below, which invalidates
// that in-flight change; its `finally` only re-clears the flag, and
// nothing re-arms it (the single `= true` assignment already ran).
_cambiandoFuente = false;
// Same reason and the same ordering as `_cambiandoFuente` above: a mask
// still armed here would swallow the real `idle` and leave the
// notification unkillable with a dead Stop button — the exact citation
// this branch exists to answer.
_errorTerminal = false;
// 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.
// `just_audio`'s `playerStateStream` is `.distinct()` over a value-equal
// `PlayerState`, so a stop landing on an already-idle player (a station
// change stopped before its native init finished pushing `loading`)
// emits NOTHING. Combined with the source-change mask above — which
// WRITES `loading` into `playbackState` rather than filtering at read
// time — that would leave the state stuck at `loading` forever:
// `audio_service` only tears the service down on a non-idle -> idle
// transition (`audio_service.dart:1131-1136`), so the notification would
// survive as an unkillable "cargando" with a dead Stop button. Strictly
// worse than the bug this branch fixes. Additive and idempotent: when
// the player DOES emit its own `idle`, this simply lands first and the
// duplicate is a no-op transition.
playbackState.add(
playbackState.value.copyWith(
processingState: AudioProcessingState.idle,
playing: false,
errorMessage: null,
// Paired with `errorMessage` above, which shipped alone. `copyWith`
// carries an OMITTED field forward (`audio_service.dart:400-427`),
// so a stopped, idle session went on advertising the code of the
// last refusal — an ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED (4) with the
// message deliberately blanked out, i.e. an error the head unit
// could show but never explain.
errorCode: null,
),
);
emisoraActual = null;
mediaItem.add(null);
await super.stop();
}
@override
Future<void> seek(Duration position) => _player.seek(position);
/// Moves to the next queued track (Design ADR-3/ADR-4, Phase 3 task
/// 3.6). Past the last track, clears the queue and stops — mirroring
/// auto-advance's end-of-queue behavior (no wraparound).
///
/// With NO local queue this now moves to the next STATION instead of doing
/// nothing: the car's transport row offers previous/next for radio too,
/// and a button that is present but inert is worse than no button.
@override
Future<void> skipToNext() async {
// fix/auto-quality-guidelines item 14: the free-tier no-op is GONE. The
// buttons stay advertised for every tier and CYCLE WITHIN the free set
// instead — see [_saltarEmisora]. Withdrawing them from `controls` and
// `systemActions` was the alternative and was rejected: those two lists
// are rebuilt inside a SYNCHRONOUS `playerStateStream` listener, so
// making them tier-dependent would mean either awaiting a prefs read on
// every player event or caching entitlement in a second place; and a
// button that works is better UX than a hole, on a surface where Auto
// reserves the slots anyway.
final cola = _colaLocal;
if (cola == null) {
if (_reproduciendoRadio) await _saltarEmisora(haciaAtras: false);
return;
}
final siguiente = cola.conSiguiente();
if (siguiente == null) {
_desactivarCola();
await stop();
return;
}
_colaLocal = siguiente;
await _reproducirEntradaCola(siguiente.actual);
}
/// Moves to the previous queued track (Design ADR-4, Phase 3 task 3.6).
/// Clamps at the first track (restarts it) instead of wrapping to the last
/// one. With no local queue, moves to the previous STATION — see
/// [skipToNext].
@override
Future<void> skipToPrevious() async {
// Mirrors [skipToNext] — see its comment for why the free-tier gate is
// gone (fix/auto-quality-guidelines, item 14).
final cola = _colaLocal;
if (cola == null) {
if (_reproduciendoRadio) await _saltarEmisora(haciaAtras: true);
return;
}
final anterior = cola.conAnterior();
_colaLocal = anterior;
await _reproducirEntradaCola(anterior.actual);
}
/// Whether what is playing right now is a RADIO STREAM, as opposed to a
/// local file.
///
/// Reported: playing a single local track and pressing next jumped to a
/// radio station. `emisoraActual` cannot answer this — `_cambiarFuente`
/// fills it in for every source, so a local MP3 arrives as an `Emisora`
/// whose `url` is its `content://` document URI. The scheme of the media
/// id is what actually distinguishes them, and it is the same test
/// `esEmisoraGrabable` uses to keep the recorder off local files.
///
/// Only a queue-less local track reaches this: folder playback sets
/// `_colaLocal` and skips within the queue, which is why the report said
/// "at least the first time" — tapping one track never builds a queue.
bool get _reproduciendoRadio {
final id = mediaItem.value?.id;
if (id == null) return false;
final esquema = Uri.tryParse(id)?.scheme.toLowerCase();
return esquema == 'http' || esquema == 'https';
}
/// Station-to-station skipping for the car's transport row.
///
/// 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 {
try {
final actual = emisoraActual;
if (actual == null) return;
// fix/auto-quality-guidelines item 14: the list is SCOPED by tier
// 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 (`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;
}
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
// that is in a list of one are indistinguishable from outside.
debugPrint(
'[PluriWave][ServicioAudio] salto emisora atras=$haciaAtras '
'actual=${actual.nombre} uuid=${actual.uuid} '
'contexto=$contexto lista=${lista.length} '
'destino=${destino?.nombre ?? "NINGUNO"}',
);
if (destino == null) return;
// 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');
}
}
/// Dispatches the equalizer's only custom action (decision
/// `auto/ecualizador-diseno`): `accionEqToggle` flips on/off, delegating
/// to the existing [setEcualizadorActivo] — the SAME entry point the
/// phone settings screen uses via `EstadoEcualizador` — so a car tap and a
/// phone tap have identical effects and both refresh the action's label
/// via `_actualizarControlesEq()` (already wired into that method). The
/// preset-cycling action that used to live here is REMOVED — preset
/// selection now goes through the "Ecualizador" browsable folder (see
/// `seleccionarPresetEqPorMediaId` in `navegacion_auto.dart`, dispatched
/// from [playFromMediaId] below). Any other [name] is a no-op — never
/// throws.
@override
Future<dynamic> customAction(
String name, [
Map<String, dynamic>? extras,
]) async {
switch (name) {
case accionEqToggle:
final decision = decidirToggleEq(
activoActual: _ecualizadorActivo,
eqDisponible: _eqDisponible,
);
debugPrint(
'[PluriWave][ServicioAudio] customAction $name -> '
'activo=${decision.nuevoActivo} '
'nativo=${decision.requiereLlamadaNativa}',
);
await setEcualizadorActivo(decision.nuevoActivo);
}
}
@override
Future<void> onTaskRemoved() async {
await stop();
await liberar();
}
/// Releases everything this handler owns that can still run on its own:
/// the terminal-state floor timer, the action-refusal window, the
/// reconnect backoff (1/2/4/8/16 s — easily longer than whatever created
/// the handler lives for), anything still queued on `_colaCambioFuente`,
/// every player subscription, the player itself and the browse subjects.
///
/// The revision bump is the load-bearing part: a queued `_cambiarFuente`
/// body that outlives its owner calls `_crearPlayer()`, which reads the
/// CURRENT [fabricaReproductorPrueba]. In a test suite that means a
/// released handler building a double bound to a LATER test's script and
/// driving it. Bumping the revision makes every queued body return at its
/// entry guard, before it can publish or build anything.
///
/// Idempotent, and never throws: teardown that can fail halfway is not
/// teardown. Called by [onTaskRemoved] in production and by every test
/// that constructs a handler.
Future<void> liberar() async {
// Invalidates every queued and in-flight source change (see above).
_revisionFuente++;
_intencionReproducir = false;
_cambiandoFuente = false;
_errorTerminal = false;
_reproduccionEstablecida = false;
_vigilanciaTimer?.cancel();
_vigilanciaTimer = null;
_detenerReconexion();
_limpiarErrorAccionAuto();
_desactivarCola();
await _estadoPlayerSub?.cancel();
_estadoPlayerSub = null;
await _bufferedSub?.cancel();
_bufferedSub = null;
await _duracionSub?.cancel();
_duracionSub = null;
await _eventosSub?.cancel();
_eventosSub = null;
await _androidAudioSessionIdSub?.cancel();
_androidAudioSessionIdSub = null;
// A player built without the test factory talks to platform channels that
// do not exist under `flutter test`, so disposal is allowed to fail —
// the subscriptions above are already gone either way.
try {
await _player.dispose();
} catch (_) {}
if (!_androidAudioSessionIdController.isClosed) {
await _androidAudioSessionIdController.close();
}
for (final subject in _childrenSubjects.values) {
await subject.close();
}
_childrenSubjects.clear();
// Handler teardown: release the bootstrap-owned `AudioService.asyncError`
// subscription too, so it cannot outlive the handler it was instrumenting.
// Never throws out of teardown — a failing cleanup hook must not prevent
// the rest of this method from having completed above.
try {
await _limpiezaArranqueGlobal?.call();
} catch (_) {}
}
Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) {
// Item 3: delegates to the top-level, unit-testable function so the
// `faviconUsable` guard (never reflect the on-brand fallback artUri
// back as a real favicon) is covered without instantiating the handler.
return emisoraDesdeMediaItem(mediaItem);
}
// ── Android Auto browsing (thin delegation to navegacion_auto.dart's
// already-tested pure logic — Design "getChildren data source") ─────────
/// One-shot device-query channel (feature auto-custom-eq): the SAME
/// method channel `ServicioDispositivoAudioReal` talks to, but method
/// calls only — opening a second EventChannel subscription here would
/// steal the phone-side service's Dart stream handler.
@override
Future<List<MediaItem>> getChildren(
String parentMediaId, [
Map<String, dynamic>? options,
]) async {
try {
// Every user-readable label the car tree stamps onto a `MediaItem`
// comes from here. `_textos` resolves headlessly through
// [resolverLocalizacionesRespaldo], so this works on the engine
// Android Auto starts without an Activity -- which is the only engine
// a Play reviewer ever gets.
final etiquetas = etiquetasArbolAutoDesde(_textos);
final constructor = ConstructorArbolAuto(etiquetas: etiquetas);
// The "recent" root, resolved BEFORE the entitlement gate.
//
// `onGetRoot` (`AudioService.java:817-821`) hands back `RECENT_ROOT_ID`
// whenever the head unit sets `BrowserRoot.EXTRA_RECENT`, which Android
// Auto does on every reconnect. There was no branch for it, so it fell
// through: free tier hit `respuestaBloqueadaPorEntitlement` and got all
// six stations where the platform expects exactly ONE resume item, and
// premium fell past every branch to `const []` — a dead resume tile.
//
// Tier-independent on purpose (see [ultimaEmisoraPersistida]): the
// station offered here is by definition one this device has already
// played, so resuming it leaks nothing. An absent/corrupt record yields
// an empty list rather than a placeholder — a non-playable row is the
// thing Play cited.
if (parentMediaId == AudioService.recentRootId) {
final ultima = await ultimaEmisoraPersistida();
return ultima == null
? const <MediaItem>[]
: [constructor.itemEmisora(ultima)];
}
// The AUTHORITATIVE entitlement gate, resolved ONCE per call and
// checked BEFORE any other resolution — the backstop against a
// stale/deep-linked non-root id from a head unit's cached tree.
//
// fix/auto-quality-guidelines item 10: it now SCOPES content instead of
// blocking actions, so its answer for a premium id is the free tier's
// own playable stations, never a non-playable row.
final premium = await esPremiumPersistido();
final destacadas =
premium ? const <Emisora>[] : await resolverEmisorasDestacadas();
final bloqueada = respuestaBloqueadaPorEntitlement(
parentMediaId: parentMediaId,
premium: premium,
destacadas: destacadas,
);
if (bloqueada != null) return bloqueada;
// The free tier's only folder (item 9). Resolved HERE, before the
// `_fuenteNavegacionGlobal` gate below, exactly like the local-music and
// equalizer branches: its content comes from the binary, so it must
// survive a bind where no browse source has been registered yet — which
// is precisely the bind a Play reviewer's first launch performs.
if (parentMediaId == ConstructorArbolAuto.idDestacadas) {
return constructor.hijosDestacadas(
premium ? await resolverEmisorasDestacadas() : destacadas,
);
}
final fuenteLocal = _fuenteMusicaLocalGlobal;
if (parentMediaId == AudioService.browsableRootId) {
// fix/android-auto-musica-local: la RAÍZ ya no se decide con el
// round trip de permisos. Ese round trip viaja por
// `pluriwave/file_actions`, cuyo handler nativo solo se registra en
// `MainActivity.configureFlutterEngine` — en el motor headless que
// Android Auto levanta sin Activity no existe, la llamada lanzaba
// `MissingPluginException` y el nodo desaparecía del árbol. Y como
// el head unit CACHEA la raíz, seguía desaparecido toda la sesión.
//
// Ahora solo `noConfigurada` (sin URI persistida, o permiso
// revocado confirmado por el nativo) oculta el nodo;
// `canalNoDisponible` lo mantiene, y es el SUBÁRBOL quien explica
// el problema (`hijosMusicaLocal`) en vez de dejar una carpeta
// vacía.
//
// fix/auto-quality-guidelines item 9: `premium &&` comes FIRST on
// purpose. Dart short-circuits `&&`, so a free-tier root never makes
// the `estadoCarpeta()` call at all — it cannot show Música Local
// anyway, and that call travels over a native channel whose handler
// only exists when an Activity has registered it, so skipping it
// removes a failure mode from the one browse call that must never
// fail.
final incluirMusicaLocal =
premium &&
fuenteLocal != null &&
await fuenteLocal.estadoCarpeta() !=
EstadoCarpetaLocal.noConfigurada;
return constructor.raiz(
incluirMusicaLocal: incluirMusicaLocal,
premium: premium,
);
}
final musicaLocal = await hijosMusicaLocal(
parentMediaId,
fuente: fuenteLocal,
etiquetas: etiquetas,
);
if (musicaLocal != null) return musicaLocal;
// Ecualizador folder (decision `auto/ecualizador-diseno`): needs no
// external data source, unlike every branch below it -- checked
// before the `_fuenteNavegacionGlobal` gate, mirroring how the
// local-music branch above is also resolved before that gate.
// The Ecualizador folder is no longer offered by `raiz()` (owner
// decision: the car keeps only the on/off toggle on the playback
// screen). This branch stays as a TRANSITIONAL courtesy: Android Auto
// caches browse trees on the head unit, so a stale "Ecualizador" entry
// can survive the update for a session or two. Answering it keeps that
// leftover working instead of opening an empty dead folder. Delete
// once no head unit can still be holding the old tree.
if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
return itemsEcualizadorAuto(
activo: _ecualizadorActivo,
presetActual: _presetActual,
l10n: _textos,
// Read per browse, not cached: a preset saved on the phone must
// show up in the car on the next open, with no app restart.
presetsPersonalizados: await _leerPresetsPersonalizados(),
);
}
final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return const [];
if (parentMediaId == ConstructorArbolAuto.idFavoritos) {
return constructor.carpetasFavoritos(
grupos: await fuente.grupos(),
favoritos: await fuente.favoritos(),
);
}
if (constructor.esCarpetaGrupo(parentMediaId)) {
return constructor.hijosGrupo(
parentMediaId,
favoritos: await fuente.favoritos(),
);
}
final emisoras = await _listaParaCarpeta(fuente, parentMediaId);
return constructor.hijos(parentMediaId, emisoras: emisoras);
} catch (_) {
// Spec "Browse requested before app state is loaded": never throw out
// of a browse call, even on an unexpected failure.
return const [];
}
}
@override
Future<MediaItem?> getMediaItem(String mediaId) async {
try {
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;
}
}
/// Publishes a terminal, EXPLAINED refusal to the car
/// (fix/auto-quality-guidelines, item 12).
///
/// Every dispatch path that used to `return;` in silence now ends here
/// instead. A silent return is the worst possible answer on a head unit:
/// the driver taps a row (or speaks a command), the assistant accepts it,
/// and absolutely nothing happens with nothing on screen to say why —
/// which is the shape of the defect Google Play cited.
/// `ACTION_PLAY_FROM_SEARCH` in particular is forced into the plugin's
/// `AUTO_ENABLED_ACTIONS`, so a mute handler stays advertised forever and
/// cannot be withdrawn from Dart.
///
/// `playing`, `mediaItem` and the player itself are deliberately untouched:
/// this error describes the requested ACTION that could not be carried out,
/// not the session. Whatever was already playing keeps playing.
///
/// CORRECTED: it used to publish [AudioProcessingState.error]
/// unconditionally, which was worse than the silent return it replaced —
/// see [puedePublicarErrorTerminalAuto] for the full mechanism. The
/// processing state now only moves when there is no live session to
/// destroy; otherwise the refusal travels as `errorCode`/`errorMessage`
/// alone, which still reaches `PlaybackStateCompat.setErrorMessage`
/// (`AudioService.java:541-544`) whatever the state is.
void _publicarErrorAuto(int codigo, String mensaje) {
final actual = playbackState.value;
final terminal = puedePublicarErrorTerminalAuto(actual.processingState);
debugPrint(
'[PluriWave][ServicioAudio] rechazo auto codigo=$codigo '
'terminal=$terminal sobre ${actual.processingState.name}: $mensaje',
);
// A previous refusal's window must not outlive the refusal it belonged
// to: this publish takes ownership of the two fields.
_temporizadorErrorAccionAuto?.cancel();
_temporizadorErrorAccionAuto = null;
_errorAccionAuto = null;
playbackState.add(
actual.copyWith(
processingState: terminal
? AudioProcessingState.error
: actual.processingState,
errorCode: codigo,
errorMessage: mensaje,
),
);
// A TERMINAL refusal stands until something else happens: it IS the
// session state now, the driver has nothing playing to go back to, and
// `error` is not a field that can be quietly withdrawn.
//
// A refusal over a LIVE session is the opposite — it describes an action,
// not the session, and nothing in this class would ever have taken it
// back: `_bufferedSub` republishes through `copyWith` about twice a
// second and `copyWith` carries omitted fields forward
// (`audio_service.dart:400-427`), so `AudioService.java:541-544` re-armed
// `setErrorMessage(code, msg)` on every push for the rest of the
// station's playback. Bound it here.
if (terminal) return;
_errorAccionAuto = (codigo: codigo, mensaje: mensaje);
_temporizadorErrorAccionAuto = Timer(
ventanaErrorAccionAuto,
_limpiarErrorAccionAuto,
);
}
/// Takes a standing action refusal back off the head unit (see
/// [ventanaErrorAccionAutoPorDefecto]). Idempotent, and a no-op unless the
/// fields on screen are still the exact pair this handler published.
///
/// Called by the window timer, by [manejarEstadoPlayer] (a real player
/// transition supersedes a refusal about an action) and by [stop].
void _limpiarErrorAccionAuto() {
_temporizadorErrorAccionAuto?.cancel();
_temporizadorErrorAccionAuto = null;
final pendiente = _errorAccionAuto;
if (pendiente == null) return;
_errorAccionAuto = null;
final actual = playbackState.value;
// Someone else owns these fields now — a reconnect status message, or a
// terminal playback error that moved the state itself. Blanking those
// would remove a message that is still true.
if (actual.processingState == AudioProcessingState.error) return;
if (actual.errorCode != pendiente.codigo ||
actual.errorMessage != pendiente.mensaje) {
return;
}
playbackState.add(actual.copyWith(errorCode: null, errorMessage: null));
}
/// The stations a voice query is matched against, and the station an EMPTY
/// query starts, for the tier resolved by [premium].
///
/// Free tier searches ONLY the free set — not favourites + my stations +
/// the catalogue — so a match can never resolve to something the tier
/// cannot then play.
Future<List<Emisora>> _candidatasBusqueda({
required bool premium,
required List<Emisora> destacadas,
}) async {
if (!premium) return destacadas;
final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return destacadas;
return [
...await fuente.favoritos(),
...await fuente.misEmisoras(),
...await fuente.todas(),
// Appended last so they only ever win a query nothing else matched.
...destacadas,
];
}
/// Voice search from the car ("pon Radio Clásica").
///
/// `ACTION_PLAY_FROM_SEARCH` is one of the actions Android for Cars
/// documents as required, and it is advertised in `systemActions` — so it
/// has to actually do something. Advertising it unimplemented would be
/// worse than omitting it: the assistant would accept the command and
/// nothing would play, with no error to explain it.
///
/// An EMPTY query ("Reproduce PluriWave") starts the last played station,
/// or the first featured one (fix/auto-quality-guidelines, item 13). This
/// used to do NOTHING for either tier — `emisoraParaBusqueda` returns
/// `null` on an empty query and the caller just returned — so the single
/// most likely voice command failed even for a paying customer. Google's
/// voice-actions documentation requires an empty query to start playback.
///
/// Never throws. A miss now publishes an explained error rather than
/// returning silently; it still never plays something arbitrary.
@override
Future<void> playFromSearch(
String query, [
Map<String, dynamic>? extras,
]) async {
try {
final premium = await esPremiumPersistido();
final destacadas = await resolverEmisorasDestacadas();
if (query.trim().isEmpty) {
// `resolverEmisorasDestacadas` puts the last played station first
// when one is persisted, so this is "resume what I was listening
// to", falling back to the first featured station on a fresh install.
final arranque = destacadas.isEmpty ? null : destacadas.first;
if (arranque == null) {
_publicarErrorAuto(
codigoErrorEmisoraNoDisponible,
_textos.autoErrorBusquedaSinResultados,
);
return;
}
await playMediaItem(mediaItemParaEmisora(arranque, l10n: _textos));
return;
}
final candidatas = await _candidatasBusqueda(
premium: premium,
destacadas: destacadas,
);
final emisora = emisoraParaBusqueda(query, candidatas);
if (emisora == null) {
_publicarErrorAuto(
codigoErrorEmisoraNoDisponible,
_textos.autoErrorBusquedaSinResultados,
);
return;
}
await playMediaItem(mediaItemParaEmisora(emisora, l10n: _textos));
} catch (e) {
debugPrint(
'[PluriWave][ServicioAudio] Error en playFromSearch($query): $e',
);
}
}
@override
Future<void> playFromMediaId(
String mediaId, [
Map<String, dynamic>? extras,
]) async {
try {
// The mandatory backstop against a head unit's CACHED browse tree:
// `getChildren` alone cannot stop a stale `emisora:<uuid>`/`pista:`/
// `eq_preset:` tap from a tree fetched before a downgrade (or from
// another device). Checked BEFORE every branch below, including local
// tracks and the equalizer.
//
// fix/auto-quality-guidelines item 11/12: the gate is now content
// scoped, and its refusal is EXPLAINED instead of silent. Only an
// `emisora:<uuid>` whose uuid is in the free set is free content —
// every other id shape (local tracks, folder-play actions, equalizer
// presets, catalogue stations) resolves `uuid` to `null` here and is
// therefore premium, exactly as before.
final premium = await esPremiumPersistido();
final destacadas = await resolverEmisorasDestacadas();
final uuid = uuidDeMediaIdEmisora(mediaId);
if (debeBloquearCambioDeEmisora(
premium: premium,
esEmisoraGratuita: esEmisoraGratuita(uuid, destacadas),
)) {
_publicarErrorAuto(
codigoErrorEmisoraPremium,
_textos.autoErrorEmisoraPremium,
);
return;
}
// Local-track playback (Design "Local Track Playback Reuses Existing
// Pipeline", Spec "User selects a local track"): FIRST branch,
// unconditional `return` — a `pista:` id never falls through to the
// station routing below.
if (esPistaMediaId(mediaId)) {
final fuenteLocal = _fuenteMusicaLocalGlobal;
if (fuenteLocal == null) return;
await reproducirPistaLocal(
mediaId,
fuente: fuenteLocal,
reproducir: playMediaItem,
etiquetas: etiquetasArbolAutoDesde(_textos),
);
return;
}
// Folder-play actions (Design ADR-5, Phase 3 task 4.3): SECOND
// branch, after `pista:`, before the station fallthrough — mirrors the
// branch above's unconditional-return shape so neither new action id
// can fall through to station routing.
final constructorArbol = ConstructorArbolAuto();
final esAccionCarpeta =
constructorArbol.esCarpetaLocalReproducirMediaId(mediaId) ||
constructorArbol.esCarpetaLocalAleatorioMediaId(mediaId);
if (esAccionCarpeta) {
final fuenteLocal = _fuenteMusicaLocalGlobal;
if (fuenteLocal == null) return;
await reproducirCarpetaLocal(
mediaId,
aleatorio: constructorArbol.esCarpetaLocalAleatorioMediaId(mediaId),
fuente: fuenteLocal,
iniciarCola: _iniciarColaLocal,
);
return;
}
// Equalizer preset selection (decision `auto/ecualizador-diseno`):
// THIRD branch, same unconditional-return shape as the two above --
// an `eq_preset:` id never falls through to station routing.
if (constructorArbol.esPresetEqMediaId(mediaId)) {
await seleccionarPresetEqPorMediaId(
mediaId,
activo: _ecualizadorActivo,
aplicarPreset: aplicarPreset,
activarEcualizador: setEcualizadorActivo,
// The SAME list `itemsEcualizadorAuto` rendered from, so a tapped
// custom preset resolves instead of silently doing nothing.
presets: presetsEcualizadorAuto(
personalizados: await _leerPresetsPersonalizados(),
),
);
return;
}
// Station ids. `_fuenteNavegacionGlobal` is null until `main.dart`
// registers it, and used to make this branch return in silence — so a
// tap that arrived before registration did nothing, with nothing said.
// The free set is resolvable straight from the binary, so it stands in
// as the browse source instead (item 12).
final fuente =
_fuenteNavegacionGlobal ?? FuenteEmisorasAutoDestacadas(destacadas);
final reproducida = await reproducirPorMediaId(
mediaId,
fuente: fuente,
reproducir: playMediaItem,
);
if (!reproducida) {
_publicarErrorAuto(
codigoErrorEmisoraNoDisponible,
_textos.autoErrorBusquedaSinResultados,
);
}
} catch (e) {
// Spec "Unknown or stale media id": never propagate from the handler.
debugPrint(
'[PluriWave][ServicioAudio] Error en playFromMediaId($mediaId): $e',
);
}
}
Future<List<Emisora>> _listaParaCarpeta(
FuenteEmisorasAuto fuente,
String parentId,
) => switch (parentId) {
ConstructorArbolAuto.idMisEmisoras => fuente.misEmisoras(),
ConstructorArbolAuto.idTodas => fuente.todas(),
_ => Future.value(const []),
};
}