Files
pluriwave/lib/servicios/servicio_audio.dart
T
FreeTLab cbc54e915b
Build & Deploy PluriWave / Análisis de código (push) Successful in 29s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m28s
fix(eq): entregar los decibelios que pide el usuario, sin estirarlos
La app promete decibelios en cuatro sitios y no los entregaba en ninguno. El
slider abarca un +/-12 fijo, escribe el numero con su unidad debajo de cada
banda, y `equalizerBandValue` le dice literalmente "decibelios" a TalkBack. El
modelo documenta las bandas como dB y los presets de fabrica estan escritos en
dB. just_audio documenta `setGain` en decibelios y multiplica por 1000 para
llegar a milibelios sin normalizar nada: `minDecibels`/`maxDecibels` son la
CAPACIDAD del dispositivo, no una escala a la que normalizar.

Pese a eso, la ganancia se estiraba por `maxDecibels/12`. Un +6 dB llegaba como
+10 en un movil de rango ancho.

El estiramiento nunca fue una decision de diseño

Antes de a9202c6 el codigo era `setGain(preset.bandas[i])`, decibelios
literales. Ese commit metio la normalizacion sin docstring, sin test y sin nota
de diseño, y traia un fallo: 0 dB caia en el punto medio del rango, asi que un
preset PLANO realzaba. 3449e2c corrigio exactamente eso y nada mas -- su propio
mensaje dice que el objetivo era "0 dB es siempre 0" -- heredando el
estiramiento sin discutirlo. No hay ADR, spec ni comentario que lo justifique.

Lo que de verdad rompia: la portabilidad

Lo que se persiste y se exporta son los dB del usuario, sin escalar; el escalado
ocurre solo al escribir en el efecto nativo. Asi que el mismo backup suena
distinto en cada telefono, y la interfaz informa de una restauracion perfecta
mientras el audio no lo es. Peor con el rango asimetrico habitual de Android
([-12, +19]): los realces se multiplican por 1.58 y los cortes por 1.0, de modo
que el preset no solo sube de nivel, CAMBIA DE FORMA. Jazz [3, -1, -1.5, 2, 4]
se entregaba como [4.75, -1, -1.5, 3.17, 6.33]. Con presets por dispositivo, el
mismo preset se deformaba distinto en el altavoz y en el Bluetooth.

No es un clamp a secas

`db.clamp(minDecibels, maxDecibels)` habria reintroducido el fallo de 3449e2c:
en un dispositivo que reporte [+3, +19], el cero se convierte en +3 y el preset
plano vuelve a realzar. La ventana se fuerza a contener el cero, asi que se
conservan todas las invariantes ganadas -- 0 siempre es 0, el signo nunca se
invierte, el resultado nunca escapa del rango nativo, un dispositivo sin margen
en un lado no puede realzar por ese lado -- y solo desaparece el estiramiento.

Que se oye distinto: en un movil de +/-12 dB, identico a hoy. En uno de rango
ancho los realces bajan, hasta un 40% menos en dB en uno de +/-20. Los cortes
apenas se mueven, porque la forma habitual es [-12, +N] y el lado negativo ya
iba practicamente 1:1. A cambio, los seis presets de fabrica suenan por fin
igual en cualquier telefono.

Ningun preset guardado necesita migracion: lo almacenado siempre fueron los dB
del usuario.

Suite completa: 1534 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-06 00:10:58 +02:00

3923 lines
181 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,
);
/// Delivers a gain from the app's ±12 dB slider to the device's native
/// equalizer, clamped by what the device reports it can do
/// (`AndroidEqualizerParameters.min/maxDecibels`, itself
/// `Equalizer.getBandLevelRange()` in millibels divided by 1000).
///
/// Top-level and pure so the mapping is testable without a device.
///
/// THE CONTRACT: the decibels the user reads are the decibels the device is
/// asked for. The native range BOUNDS the request; it is not a scale to
/// normalise into. Both sides are already the same unit — `just_audio`
/// documents `setGain` as taking decibels and its Android bridge does
/// `setBandLevel(band, round(gain * 1000.0))`, plain dB to millibels with no
/// normalisation — so multiplying by the device's headroom was a unit error.
///
/// WHY IT MATTERS, in the app's own terms. The slider is hard-coded
/// `min: -12.0, max: 12.0`, the label under each band prints
/// `'${banda.toStringAsFixed(1)}dB'`, and TalkBack reads `equalizerBandValue`
/// = "{value} decibels": one promise, made three ways. Presets are persisted
/// and exported as those same raw slider values (`PresetEcualizador.toJson`),
/// so scaling at this boundary made an exported backup mean a different SOUND
/// on a different phone while displaying identical numbers — and on the
/// common asymmetric shape [-12, +19] it multiplied boosts by 1.58 and cuts
/// by 1.0, deforming a preset's shape rather than just its depth.
///
/// WHAT IS DELIBERATELY KEPT from the mapping this replaces — every invariant
/// the «suena muy alto» fix earned. Note the clamp window is widened to
/// always contain 0: a naive `db.clamp(minDecibels, maxDecibels)` would, on a
/// device reporting a wholly positive range such as [+3, +19], turn a FLAT
/// preset's 0 dB into +3 dB of real boost on every band — exactly the bug
/// that was fixed. So 0 dB is always exactly 0, the sign of the user's intent
/// is never inverted, the result never escapes the native range, a device
/// with no headroom above unity can never boost, and a zero-width range
/// collapses to 0.
double mapearGananciaNativa(
double db, {
required double minDecibels,
required double maxDecibels,
}) {
final limitado = db.clamp(-12.0, 12.0);
// The clamp window is the device's range widened to include 0, so that a
// device reporting no headroom on one side collapses that side to "no
// change" instead of forcing a gain the user never asked for.
final suelo = minDecibels < 0 ? minDecibels : 0.0;
final techo = maxDecibels > 0 ? maxDecibels : 0.0;
return limitado.clamp(suelo, techo);
}
/// 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 how much of the
// ±12 dB slider [mapearGananciaNativa] can actually honour on this
// device — anything past this range is clamped, so a report of "the
// slider stops doing anything past N" is answered from this line.
// `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 []),
};
}