Compare commits
3
Commits
05f70af7f1
...
cbc54e915b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbc54e915b | ||
|
|
86dd20b184 | ||
|
|
6b91ad88e8 |
@@ -663,12 +663,29 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
/// Each step then re-checks [_activo]: a newer tap that landed mid-flight
|
||||
/// owns the outcome, and this superseded call must not apply a preset or
|
||||
/// persist a value the user has already changed their mind about.
|
||||
///
|
||||
/// The handler can also REFUSE the change: when the native `setEnabled`
|
||||
/// throws, `PluriWaveAudioHandler._aplicarEcualizadorActivo` rolls its own
|
||||
/// flag back and skips its persistence write, so the value we optimistically
|
||||
/// published never happened. Reading [ServicioAudio.ecualizadorActivo] back
|
||||
/// (the handler is the single owner of the flag — eq-estado-unico) is how we
|
||||
/// learn that: on divergence we adopt the handler's real value and return
|
||||
/// WITHOUT persisting, instead of showing a lie and writing a rejected value
|
||||
/// to disk that would resurrect it on the next start. The supersede check
|
||||
/// runs FIRST so a newer tap still owns the outcome; the read-back only
|
||||
/// speaks for a call nobody overtook.
|
||||
Future<void> cambiarActivo(bool activo) async {
|
||||
_activo = activo;
|
||||
notifyListeners();
|
||||
|
||||
await audio.setEcualizadorActivo(activo);
|
||||
if (_activo != activo) return;
|
||||
final aceptado = audio.ecualizadorActivo;
|
||||
if (aceptado != activo) {
|
||||
_activo = aceptado;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
if (activo) {
|
||||
await audio.aplicarPreset(_presetActual);
|
||||
if (_activo != activo) return;
|
||||
|
||||
@@ -338,24 +338,6 @@ class EstadoRadio extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort remembers [emisora] as the last used station (issue 4) so
|
||||
/// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and-
|
||||
/// forget, same treatment [reproducir] already gives other non-critical
|
||||
/// side effects (e.g. `radio.registrarClick`) — a failed write here must
|
||||
/// never block or fail actual playback.
|
||||
Future<void> _persistirUltimaEmisora(Emisora emisora) async {
|
||||
try {
|
||||
final prefs = await _resolverPrefs();
|
||||
await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap()));
|
||||
} catch (e) {
|
||||
registrarSaltoPersistencia(
|
||||
subsistema: 'ultima_emisora',
|
||||
detalle: 'persistir ${emisora.uuid}',
|
||||
razon: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Escucha el stream de estado del audio y gestiona errores de reproducción.
|
||||
void _escucharErroresReproduccion() {
|
||||
_suscripcionEstadoAudio = audio.estadoStream.listen((estado) {
|
||||
@@ -375,9 +357,12 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final actual = audio.emisoraActual;
|
||||
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
|
||||
_emisoraSeleccionada = actual;
|
||||
// Issue 4: an Android-Auto-initiated selection is a real station
|
||||
// change too — remember it the same way `reproducir` does.
|
||||
unawaited(_persistirUltimaEmisora(actual));
|
||||
// Issue 4's write used to live here as well. It is gone: the handler
|
||||
// persists every station itself from `_cambiarFuente`, which is the
|
||||
// same source change that moved `audio.emisoraActual` and is the
|
||||
// reason this branch runs at all. Writing again here would make the
|
||||
// key's final value depend on how two independent fire-and-forget
|
||||
// chains interleave on a fast station switch.
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
@@ -588,10 +573,13 @@ class EstadoRadio extends ChangeNotifier {
|
||||
}
|
||||
_emisoraSeleccionada = emisora;
|
||||
notifyListeners();
|
||||
// Issue 4: remembers the station the user just picked so it survives a
|
||||
// restart — fire-and-forget, same treatment as `radio.registrarClick`
|
||||
// below (a persistence failure here must never block playback).
|
||||
unawaited(_persistirUltimaEmisora(emisora));
|
||||
// Issue 4's `ultima_emisora_v1` write used to be here. It now happens
|
||||
// once, inside the handler's `_cambiarFuente`, which `audio.reproducir`
|
||||
// below reaches for this very station — see
|
||||
// [GuardarUltimaEmisoraPersistida]. Persisting here as well would have
|
||||
// left the key with TWO fire-and-forget writers whose relative order
|
||||
// decides the value after a fast A -> B switch, and this one cannot see
|
||||
// the revision guard that already cancels a superseded change.
|
||||
try {
|
||||
await audio.reproducir(emisora);
|
||||
if (revision != _revisionReproduccion) return;
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'app.dart';
|
||||
import 'estado/estado_entitlement.dart';
|
||||
import 'servicios/arranque_audio.dart';
|
||||
import 'servicios/contexto_reproduccion.dart';
|
||||
import 'servicios/emisoras_destacadas.dart';
|
||||
import 'servicios/musica_local_auto.dart';
|
||||
import 'servicios/navegacion_auto.dart';
|
||||
import 'servicios/servicio_audio.dart';
|
||||
@@ -197,6 +198,12 @@ Future<void> main() async {
|
||||
handler,
|
||||
leerEqActivoPersistido: ecualizador.leerActivo,
|
||||
guardarEqActivoPersistido: ecualizador.guardarActivo,
|
||||
// The PRESET's half of the same seam. Without it the handler enabled
|
||||
// the equalizer with its hardcoded FLAT preset on any engine where the
|
||||
// phone UI never ran — i.e. every headless Android Auto bind. There is
|
||||
// no write port: `EstadoEcualizador` still owns saving presets (a car
|
||||
// preset choice goes through it), so the handler only ever reads.
|
||||
leerPresetPersistido: ecualizador.leerPresetPrincipal,
|
||||
// Skip context («in which list am I»). Bound here, on the audio
|
||||
// bootstrap path of EVERY engine, precisely because the headless
|
||||
// Android Auto engine builds no widget tree and therefore no
|
||||
@@ -204,6 +211,15 @@ Future<void> main() async {
|
||||
// context the car could never have.
|
||||
leerContextoSalto: contextoSaltoPersistido,
|
||||
guardarContextoSalto: guardarContextoSalto,
|
||||
// Last played station (`ultima_emisora_v1`). Bound here for the SAME
|
||||
// reason as the skip context: `EstadoRadio` — which used to be its only
|
||||
// writer — belongs to the widget tree, and the Android Auto engine
|
||||
// builds none, so a session that happened only in the car never updated
|
||||
// the key and the head unit was offered whatever the PHONE last played.
|
||||
// The write port is now the key's single writer; the read port feeds the
|
||||
// cold-start metadata seed and the bare-`play()` resume.
|
||||
leerUltimaEmisora: ultimaEmisoraPersistida,
|
||||
guardarUltimaEmisora: guardarUltimaEmisoraPersistida,
|
||||
);
|
||||
// The handler is the only thing this app ever tears down
|
||||
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
|
||||
|
||||
@@ -155,6 +155,28 @@ Future<bool> esEmisoraGratuitaPorUuid(
|
||||
Future<Emisora?> ultimaEmisoraPersistida({SharedPreferences? prefs}) =>
|
||||
_ultimaEmisora(prefs: prefs);
|
||||
|
||||
/// Writes [emisora] as the last-played station — the SINGLE writer of
|
||||
/// [claveUltimaEmisora].
|
||||
///
|
||||
/// It lives beside [ultimaEmisoraPersistida] rather than in `EstadoRadio`
|
||||
/// because the key has to be written from the engine Android Auto starts,
|
||||
/// which builds no widget tree and therefore never constructs `EstadoRadio`
|
||||
/// at all: a session that happened only in the car used to leave the key
|
||||
/// holding whatever the PHONE last played, so the head unit's resume row and
|
||||
/// the free tier's featured folder were both stale on the next connect.
|
||||
///
|
||||
/// Deliberately NOT swallowing failures here: the handler port that calls it
|
||||
/// traces and swallows (a persistence failure must never break playback),
|
||||
/// and a silent `catch` in BOTH places would make a dead write channel
|
||||
/// invisible from a car logcat.
|
||||
Future<void> guardarUltimaEmisoraPersistida(
|
||||
Emisora emisora, {
|
||||
SharedPreferences? prefs,
|
||||
}) async {
|
||||
final resueltas = prefs ?? await SharedPreferences.getInstance();
|
||||
await resueltas.setString(claveUltimaEmisora, jsonEncode(emisora.toMap()));
|
||||
}
|
||||
|
||||
/// Reads the persisted last-played station, or `null` when there is none,
|
||||
/// the payload is unreadable, or prefs themselves fail.
|
||||
Future<Emisora?> _ultimaEmisora({SharedPreferences? prefs}) async {
|
||||
|
||||
@@ -56,10 +56,34 @@ typedef GuardarEqActivoPersistido = Future<void> Function(bool activo);
|
||||
/// 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.
|
||||
///
|
||||
@@ -86,6 +110,22 @@ bool? _eqActivoPersistido;
|
||||
/// 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.
|
||||
///
|
||||
@@ -110,6 +150,37 @@ Future<void> _sembrarEcualizadorDesdeDisco(
|
||||
);
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -123,8 +194,11 @@ 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
|
||||
@@ -139,9 +213,26 @@ void registrarHandler(
|
||||
leer: leerContextoSalto,
|
||||
guardar: guardarContextoSalto,
|
||||
);
|
||||
// Same seam shape again for the last-played station. The WRITE half is
|
||||
// registered before anything is awaited for the same reason the equalizer's
|
||||
// is: a station change arriving during the read below must still be
|
||||
// persisted.
|
||||
handler.registrarPersistenciaUltimaEmisora(
|
||||
leer: leerUltimaEmisora,
|
||||
guardar: guardarUltimaEmisora,
|
||||
);
|
||||
// Cold-start metadata (A3). Seeded eagerly, like the equalizer flag and
|
||||
// unlike the skip context: a head unit asks for the now-playing metadata
|
||||
// the moment it binds, and `audio_service` cannot send any while
|
||||
// `mediaItem` is null. Fire-and-forget and internally guarded, so it is a
|
||||
// no-op without a read port and never clobbers a live station.
|
||||
unawaited(handler.sembrarUltimaEmisoraDesdeDisco());
|
||||
if (leerEqActivoPersistido != null) {
|
||||
unawaited(_sembrarEcualizadorDesdeDisco(handler, leerEqActivoPersistido));
|
||||
}
|
||||
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
|
||||
@@ -596,44 +687,51 @@ DecisionToggleEq decidirToggleEq({
|
||||
requiereLlamadaNativa: eqDisponible,
|
||||
);
|
||||
|
||||
/// Translates a gain on the app's fixed ±12 dB slider scale to the range the
|
||||
/// device's native equalizer actually reports
|
||||
/// (`AndroidEqualizerParameters.min/maxDecibels`, itself derived from
|
||||
/// `Equalizer.getBandLevelRange()`).
|
||||
/// 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 DEFECT THIS REPLACES, and the likely source of the reported «suena muy
|
||||
/// alto»: the previous implementation normalised across the whole range and
|
||||
/// interpolated linearly,
|
||||
/// 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.
|
||||
///
|
||||
/// minDecibels + ((db + 12) / 24) * (maxDecibels - minDecibels)
|
||||
/// 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.
|
||||
///
|
||||
/// which puts 0 dB at the MIDPOINT of the native range. That is only 0 when
|
||||
/// the range is symmetric, and Android guarantees no such thing — the
|
||||
/// Equalizer contract only promises a min/max pair. On a device reporting,
|
||||
/// say, [-12, +19] dB, every band of a FLAT preset was pushed to +3.5 dB of
|
||||
/// real boost: audibly louder, with the on/off button still reading "off"
|
||||
/// and nothing in the UI to explain it.
|
||||
///
|
||||
/// The contract here instead: 0 dB is always exactly 0, and each side of the
|
||||
/// scale is stretched independently against its own end of the native range,
|
||||
/// so a cut can never become a boost. A range with no headroom on one side
|
||||
/// (or none at all) collapses that side to 0 rather than inverting it.
|
||||
/// 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);
|
||||
if (limitado == 0) return 0;
|
||||
if (limitado > 0) {
|
||||
// Only genuine headroom above unity counts as boost.
|
||||
final techo = maxDecibels > 0 ? maxDecibels : 0.0;
|
||||
return (limitado / 12.0) * techo;
|
||||
}
|
||||
// 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;
|
||||
return (limitado.abs() / 12.0) * suelo;
|
||||
final techo = maxDecibels > 0 ? maxDecibels : 0.0;
|
||||
return limitado.clamp(suelo, techo);
|
||||
}
|
||||
|
||||
/// Advances to the NEXT factory preset after [actual] in [presets] order
|
||||
@@ -1057,7 +1155,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
),
|
||||
);
|
||||
|
||||
AndroidEqualizer _eq = AndroidEqualizer();
|
||||
AndroidEqualizer _eq = _crearEq();
|
||||
late AudioPlayer _player = _crearPlayer();
|
||||
StreamSubscription<PlayerState>? _estadoPlayerSub;
|
||||
StreamSubscription<Duration>? _bufferedSub;
|
||||
@@ -1222,6 +1320,39 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
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.
|
||||
@@ -1284,6 +1415,127 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
_guardarContextoSalto = guardar;
|
||||
}
|
||||
|
||||
LeerUltimaEmisoraPersistida? _leerUltimaEmisora;
|
||||
GuardarUltimaEmisoraPersistida? _guardarUltimaEmisora;
|
||||
|
||||
/// Injects the last-played station's persistence ports (see
|
||||
/// [GuardarUltimaEmisoraPersistida]). Both accept `null` — a handler with no
|
||||
/// disk simply never remembers and never restores, exactly as before this
|
||||
/// seam existed.
|
||||
void registrarPersistenciaUltimaEmisora({
|
||||
LeerUltimaEmisoraPersistida? leer,
|
||||
GuardarUltimaEmisoraPersistida? guardar,
|
||||
}) {
|
||||
_leerUltimaEmisora = leer;
|
||||
_guardarUltimaEmisora = guardar;
|
||||
}
|
||||
|
||||
/// Whether [item] is a RADIO STATION rather than a local track.
|
||||
///
|
||||
/// `ultima_emisora_v1` is read back as an `emisora:<uuid>` row by the car's
|
||||
/// recent root and by `resolverEmisorasDestacadas`, so a `content://` local
|
||||
/// track written there would occupy that slot with a row that resolves to
|
||||
/// nothing when tapped. Every station path builds its item through
|
||||
/// [mediaItemParaEmisora] or `reproducirPorMediaId`, both of which stamp
|
||||
/// `extras['uuid']`; `construirMediaItemColaLocal`/`reproducirPistaLocal`
|
||||
/// stamp `extras['documentId']` instead. Private: it is asserted through
|
||||
/// the real source-change path (a local track must leave the record
|
||||
/// untouched), not as a predicate in isolation.
|
||||
static bool _esMediaItemDeEmisora(MediaItem item) {
|
||||
final uuid = item.extras?['uuid'];
|
||||
return uuid is String && uuid.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Best-effort write of the last-played station through the injected port.
|
||||
///
|
||||
/// Never throws and never blocks the source change: a persistence failure
|
||||
/// must cost the driver a stale resume row, never the station they just
|
||||
/// asked for. Traced rather than swallowed, so a dead write channel is
|
||||
/// visible in a car logcat instead of looking exactly like a working one.
|
||||
Future<void> _persistirUltimaEmisora(MediaItem item) async {
|
||||
if (!_esMediaItemDeEmisora(item)) return;
|
||||
final guardar = _guardarUltimaEmisora;
|
||||
if (guardar == null) return;
|
||||
try {
|
||||
await guardar(emisoraDesdeMediaItem(item));
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[PluriWave][ServicioAudio] no se pudo guardar la ultima emisora: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted last-played station, or `null` when there is no port, no
|
||||
/// record, or the read failed. Never throws — an unreadable record must
|
||||
/// mean "nothing to resume", not a dead Play button.
|
||||
Future<Emisora?> _ultimaEmisoraRecordada() async {
|
||||
final leer = _leerUltimaEmisora;
|
||||
if (leer == null) return null;
|
||||
try {
|
||||
return await leer();
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'[PluriWave][ServicioAudio] no se pudo leer la ultima emisora: $e',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a source has actually been opened on this handler — set by
|
||||
/// [_cambiarFuente] once it is past its revision guard, cleared by [stop].
|
||||
///
|
||||
/// Deliberately NOT `mediaItem.value != null`: since
|
||||
/// [sembrarUltimaEmisoraDesdeDisco] publishes metadata on a cold start
|
||||
/// WITHOUT loading anything, the two questions stopped being the same one.
|
||||
/// Reading the metadata there would send a bare `play()` straight into
|
||||
/// `_player.play()` on a player with no source, which is defect A2 all over
|
||||
/// again.
|
||||
bool _fuenteAbierta = false;
|
||||
|
||||
/// Publishes the persisted station's metadata on a COLD start, without
|
||||
/// touching the player.
|
||||
///
|
||||
/// The handler constructor only wires streams, and the only `mediaItem.add`
|
||||
/// sites are the duration update (which needs an item to already exist),
|
||||
/// [_cambiarFuente] and [stop] (which publishes `null`). So on a headless
|
||||
/// bind `mediaItem` was null, `audio_service.dart:1029-1033` returned before
|
||||
/// `setMediaItem`, and the head unit received no metadata at all — no title,
|
||||
/// no artwork, nothing to put on the now-playing surface.
|
||||
///
|
||||
/// Checked before AND after the disk read: a station that started while the
|
||||
/// read was in flight owns the metadata, and renaming what the driver is
|
||||
/// actually listening to would be far worse than a blank tile.
|
||||
Future<void> sembrarUltimaEmisoraDesdeDisco() async {
|
||||
if (mediaItem.value != null || _fuenteAbierta) return;
|
||||
final ultima = await _ultimaEmisoraRecordada();
|
||||
if (ultima == null) return;
|
||||
if (mediaItem.value != null || _fuenteAbierta) return;
|
||||
mediaItem.add(mediaItemParaEmisora(ultima, l10n: _textos));
|
||||
}
|
||||
|
||||
/// Resolves the persisted station and starts it through the ordinary play
|
||||
/// path. Returns `false` when there was nothing to resume.
|
||||
///
|
||||
/// Routed through [playMediaItem] on purpose — the revision guard, the
|
||||
/// queue clearing, the skip-context recording and the terminal-state floor
|
||||
/// all live behind that choke point, and a parallel path would have to
|
||||
/// re-earn every one of them.
|
||||
Future<bool> _reanudarUltimaEmisora() async {
|
||||
final ultima = await _ultimaEmisoraRecordada();
|
||||
if (ultima == null) return false;
|
||||
try {
|
||||
await playMediaItem(mediaItemParaEmisora(ultima, l10n: _textos));
|
||||
} catch (e) {
|
||||
// The failure is already published to `playbackState` by
|
||||
// `_cambiarFuente`; a transport button must not additionally throw out
|
||||
// of the handler (Spec "never propagate from the handler").
|
||||
debugPrint(
|
||||
'[PluriWave][ServicioAudio] no se pudo reanudar la ultima emisora: $e',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The remembered context: memory first, then the read port ONCE.
|
||||
///
|
||||
/// Never throws — an unreadable context must mean "derive it again", not a
|
||||
@@ -1401,6 +1653,36 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
|
||||
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;
|
||||
@@ -1575,6 +1857,21 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
)?
|
||||
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;
|
||||
@@ -2196,6 +2493,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
if (revision != _revisionFuente) return;
|
||||
this.mediaItem.add(mediaItem);
|
||||
emisoraActual = _emisoraDesdeMediaItem(mediaItem);
|
||||
// A source is now genuinely open on this handler — see [_fuenteAbierta].
|
||||
_fuenteAbierta = true;
|
||||
// THE SINGLE WRITER of `ultima_emisora_v1`. Placed here, past the
|
||||
// revision guard and beside the `mediaItem` publish, because this is the
|
||||
// one point EVERY play path funnels through: the phone (`EstadoRadio.
|
||||
// reproducir` -> `ServicioAudio.reproducir` -> `playMediaItem`), a car
|
||||
// browse tap (`playFromMediaId`), voice (`playFromSearch`), a skip, a
|
||||
// queue advance and the bare-`play()` resume below.
|
||||
//
|
||||
// `EstadoRadio._persistirUltimaEmisora` was deleted rather than kept
|
||||
// alongside this. Two writers of one key is exactly the shape that
|
||||
// produced the equalizer divergence twice: both wrote fire-and-forget, so
|
||||
// on a fast A -> B station switch the interleaving of two independent
|
||||
// unawaited chains decided the final value, and the phone's copy could
|
||||
// not see the revision guard that already cancels a superseded change.
|
||||
// One writer behind one serialized queue has neither problem, and it is
|
||||
// the only writer that exists on the engine Android Auto starts.
|
||||
unawaited(_persistirUltimaEmisora(mediaItem));
|
||||
// A new source is being opened, so no previous terminal error owns the
|
||||
// screen any more (see [_errorTerminal]).
|
||||
_errorTerminal = false;
|
||||
@@ -2330,8 +2645,31 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
await anterior.dispose().timeout(_timeoutCierrePlayer);
|
||||
} catch (_) {}
|
||||
|
||||
_eq = AndroidEqualizer();
|
||||
_eqDisponible = false;
|
||||
_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`
|
||||
@@ -2389,10 +2727,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
try {
|
||||
final params = await _eq.parameters;
|
||||
_eqDisponible = params.bands.isNotEmpty;
|
||||
// eq-estado-unico item E: the ONE number that decides whether
|
||||
// [mapearGananciaNativa] can be silently boosting a FLAT preset on
|
||||
// this device. `Equalizer.getBandLevelRange()` is not required to be
|
||||
// symmetric, and nothing else in the app can observe what it returned.
|
||||
// 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:
|
||||
//
|
||||
@@ -2403,8 +2743,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
'activo=$_ecualizadorActivo preset=${_presetActual.nombre}',
|
||||
);
|
||||
if (_eqDisponible) {
|
||||
await _eq.setEnabled(_ecualizadorActivo);
|
||||
await aplicarPreset(_presetActual);
|
||||
_paramsEq = params;
|
||||
await _conmutarEcualizadorNativo(_ecualizadorActivo);
|
||||
}
|
||||
} catch (_) {
|
||||
_eqDisponible = false;
|
||||
@@ -2484,26 +2824,27 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
/// 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) {
|
||||
final params = await _eq.parameters;
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
await _empujarGananciasNativas(preset);
|
||||
}
|
||||
} catch (_) {}
|
||||
} 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
|
||||
@@ -2518,9 +2859,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
bandas[index] = db;
|
||||
_presetActual = _presetActual.copyWithBandas(bandas);
|
||||
}
|
||||
_presetElegido = true;
|
||||
if (!_eqDisponible || !_ecualizadorActivo) return;
|
||||
final params = _paramsEq;
|
||||
if (params == null) return;
|
||||
try {
|
||||
final params = await _eq.parameters;
|
||||
if (index < params.bands.length) {
|
||||
await params.bands[index].setGain(
|
||||
mapearGananciaNativa(
|
||||
@@ -2530,7 +2873,68 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
} 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
|
||||
@@ -2539,6 +2943,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
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
|
||||
@@ -2550,14 +2972,19 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
bool activo, {
|
||||
required bool persistir,
|
||||
}) async {
|
||||
final anterior = _ecualizadorActivo;
|
||||
_ecualizadorActivo = activo;
|
||||
if (_eqDisponible) {
|
||||
try {
|
||||
await _eq.setEnabled(activo);
|
||||
if (activo) {
|
||||
await aplicarPreset(_presetActual);
|
||||
}
|
||||
} catch (_) {}
|
||||
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
|
||||
@@ -2624,7 +3051,31 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
Future<void> reaplicarEcualizador() => _activarEcualizador();
|
||||
|
||||
@override
|
||||
Future<void> play() {
|
||||
Future<void> play() async {
|
||||
// NO SOURCE LOADED — the cold-engine case, and the reason this override
|
||||
// is no longer a one-liner.
|
||||
//
|
||||
// `AudioService.java:920` routes the car's `KEYCODE_MEDIA_PLAY` straight
|
||||
// in here, and there is no `prepare`/`onPrepare`/`prepareFromMediaId`
|
||||
// override anywhere in this app to have loaded anything first. Handed to
|
||||
// `_player.play()`, `just_audio.dart:937-967` publishes
|
||||
// `_playingSubject.add(true)` BEFORE its `_audioSource != null` gate: the
|
||||
// platform is never touched, the returned Future NEVER completes, and yet
|
||||
// `playing: true` is forwarded by [manejarEstadoPlayer] over
|
||||
// `processingState: idle`. `AudioService.java:559-560` then runs
|
||||
// `enterPlayingState()` while `getPlaybackState()` is `STATE_NONE` — a
|
||||
// PluriWave notification with a pause button, no audio, no title and no
|
||||
// artwork, or a `ForegroundServiceStartNotAllowedException` on API 31+.
|
||||
//
|
||||
// So: resolve the persisted station and go through the ordinary play
|
||||
// path, and when there is nothing to resume touch neither the player nor
|
||||
// `playbackState` and complete immediately. Doing nothing is the correct
|
||||
// answer there — a phantom foreground session is strictly worse than a
|
||||
// Play button that did not find anything to play.
|
||||
if (!_fuenteAbierta) {
|
||||
await _reanudarUltimaEmisora();
|
||||
return;
|
||||
}
|
||||
_intencionReproducir = true;
|
||||
// Fresh user intent: whatever terminal error was standing no longer owns
|
||||
// the screen, so stop masking the player's `idle` (see [_errorTerminal]).
|
||||
@@ -2674,6 +3125,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
// The session is over: whatever this run proved about the mount does not
|
||||
// carry into the next one (see [_reproduccionEstablecida]).
|
||||
_reproduccionEstablecida = false;
|
||||
// The session is over and `mediaItem` is cleared below, so the next bare
|
||||
// `play()` — a car transport button on a torn-down session — must resolve
|
||||
// a station again instead of calling `_player.play()` on nothing (see
|
||||
// [_fuenteAbierta] and [play]).
|
||||
_fuenteAbierta = false;
|
||||
_revisionFuente++;
|
||||
await _player.stop();
|
||||
// Publish `idle` OURSELVES rather than trusting the player to emit it.
|
||||
@@ -3150,12 +3606,26 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
@override
|
||||
Future<MediaItem?> getMediaItem(String mediaId) async {
|
||||
try {
|
||||
final fuente = _fuenteNavegacionGlobal;
|
||||
if (fuente == null) return null;
|
||||
final universo = await _universoCompleto(fuente);
|
||||
final constructor = ConstructorArbolAuto();
|
||||
final emisora = constructor.resolver(mediaId, universo);
|
||||
return emisora == null ? null : constructor.itemEmisora(emisora);
|
||||
final uuid = uuidDeMediaIdEmisora(mediaId);
|
||||
// Not a station id at all (`pista:`, `carpeta_local_*:`, `eq_preset:`,
|
||||
// a folder, or `emisora:` with an empty tail) — unchanged behaviour.
|
||||
if (uuid == null) return null;
|
||||
// Was `_universoCompleto` (favoritos + misEmisoras + todas) alone, which
|
||||
// is EMPTY on a headless bind, while `porUuid` has always also fallen
|
||||
// back to the featured set. The car could therefore BROWSE a featured
|
||||
// station and then fail to resolve its media item — an asymmetry, not a
|
||||
// policy. Delegating to `porUuid` removes it (and short-circuits on the
|
||||
// first list that matches instead of always awaiting all three), and the
|
||||
// `FuenteEmisorasAutoDestacadas` stand-in covers the window before
|
||||
// `main.dart` registers the real source, exactly as [playFromMediaId]
|
||||
// already does.
|
||||
final fuente =
|
||||
_fuenteNavegacionGlobal ??
|
||||
FuenteEmisorasAutoDestacadas(await resolverEmisorasDestacadas());
|
||||
final emisora = await fuente.porUuid(uuid);
|
||||
return emisora == null
|
||||
? null
|
||||
: ConstructorArbolAuto().itemEmisora(emisora);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
@@ -3449,12 +3919,4 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
_ => Future.value(const []),
|
||||
};
|
||||
|
||||
Future<List<Emisora>> _universoCompleto(FuenteEmisorasAuto fuente) async {
|
||||
final listas = await Future.wait([
|
||||
fuente.favoritos(),
|
||||
fuente.misEmisoras(),
|
||||
fuente.todas(),
|
||||
]);
|
||||
return listas.expand((lista) => lista).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +256,27 @@ class ServicioEcualizador {
|
||||
return prefs.getBool(_keyActivo);
|
||||
}
|
||||
|
||||
/// The persisted principal preset, or `null` when the user has never saved
|
||||
/// one.
|
||||
///
|
||||
/// The exact sibling of [leerActivo] and narrow for the same reason: its
|
||||
/// caller is `registrarHandler`, on the audio bootstrap path of EVERY
|
||||
/// engine — including the headless one Android Auto starts, where there is
|
||||
/// no widget tree and `EstadoEcualizador` never exists to push a preset
|
||||
/// into the handler. It reads ONE key, runs none of [cargar]'s migrations
|
||||
/// and mutates nothing.
|
||||
///
|
||||
/// `null` (nothing saved, or an unreadable value) is preserved rather than
|
||||
/// collapsed to [PresetEcualizador.flat] so the handler's own default —
|
||||
/// not this service — decides what "never persisted" means, and so a seed
|
||||
/// with nothing to say does not overwrite anything.
|
||||
Future<PresetEcualizador?> leerPresetPrincipal() async {
|
||||
final prefs = await _resolverPrefs();
|
||||
final raw = prefs.getString(_keyPresetPrincipal);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
return _leerPresetPrincipal(prefs);
|
||||
}
|
||||
|
||||
Future<void> eliminarPorEmisora(String uuid) async {
|
||||
final prefs = await _resolverPrefs();
|
||||
final mapa = _leerPresetsPorEmisora(prefs);
|
||||
|
||||
@@ -1944,6 +1944,59 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The handler REJECTED the toggle (native setEnabled threw): the handler
|
||||
// rolls its own flag back, so this class must not keep — nor persist — a
|
||||
// value the engine refused.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('EstadoEcualizador — cambiarActivo cuando el handler rechaza', () {
|
||||
test(
|
||||
'adopta el valor real del handler y NO persiste el valor rechazado',
|
||||
() async {
|
||||
final fakeAudio = _FakeAudioEqRechazaConmutacion();
|
||||
final fakeServicio = FakeServicioEcualizador(activo: true);
|
||||
final eq = EstadoEcualizador(audio: fakeAudio, servicio: fakeServicio);
|
||||
await eq.cargarPersistido();
|
||||
fakeAudio.cambiosEcualizadorActivo.clear();
|
||||
fakeServicio.guardarActivoLlamadas = 0;
|
||||
|
||||
var avisos = 0;
|
||||
eq.addListener(() => avisos++);
|
||||
|
||||
await eq.cambiarActivo(false);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
// The native call failed, so the handler kept the equalizer ON.
|
||||
expect(fakeAudio.ecualizadorActivo, isTrue);
|
||||
expect(
|
||||
eq.activo,
|
||||
isTrue,
|
||||
reason: 'the UI must show what the engine really does',
|
||||
);
|
||||
expect(avisos, greaterThanOrEqualTo(1));
|
||||
expect(
|
||||
fakeServicio.guardarActivoLlamadas,
|
||||
equals(0),
|
||||
reason: 'a rejected value must never reach disk',
|
||||
);
|
||||
expect(fakeServicio.config.activo, isTrue);
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Fake handler that REFUSES every on/off change: it records the call (the
|
||||
/// UI-initiated path did reach the engine) but leaves [ecualizadorActivo]
|
||||
/// untouched, exactly like `PluriWaveAudioHandler._aplicarEcualizadorActivo`
|
||||
/// rolling its flag back when the native `setEnabled` throws.
|
||||
class _FakeAudioEqRechazaConmutacion extends FakeServicioAudio {
|
||||
@override
|
||||
Future<void> setEcualizadorActivo(bool activo) async {
|
||||
cambiosEcualizadorActivo.add(activo);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fake whose [guardarActivo] stays pending until released, and releases the
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -792,21 +793,16 @@ void main() {
|
||||
'(reinicio) como emisoraActual DETENIDA -- no arranca audio, no '
|
||||
'reproduce, sólo queda seleccionada', () async {
|
||||
final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM');
|
||||
final estadoUno = EstadoRadio(
|
||||
esPremium: () => true,
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estadoUno.inicializar();
|
||||
await estadoUno.reproducir(emisora);
|
||||
await estadoUno.detenerReproduccion();
|
||||
// Lets the fire-and-forget persistence write settle before
|
||||
// spinning up the "restart" instance.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
// The record is now written by the audio handler's `_cambiarFuente`
|
||||
// (`GuardarUltimaEmisoraPersistida`), which is the SINGLE writer of
|
||||
// `ultima_emisora_v1` and the only one that also exists on the headless
|
||||
// Android Auto engine — `EstadoRadio` used to write it too and no
|
||||
// longer does. Seeded through that same production function here, so
|
||||
// this test covers what `EstadoRadio` actually owns (the RESTORE) with
|
||||
// a real payload instead of one a fake invented. The write itself is
|
||||
// covered end to end in
|
||||
// `test/servicios/servicio_audio_ultima_emisora_test.dart`.
|
||||
await guardarUltimaEmisoraPersistida(emisora);
|
||||
|
||||
final audioDos = FakeServicioAudio();
|
||||
final estadoDos = EstadoRadio(
|
||||
@@ -848,9 +844,18 @@ void main() {
|
||||
});
|
||||
|
||||
test('una emisora seleccionada desde el auto (fuera de reproducir()) '
|
||||
'también se recuerda para la próxima instancia', () async {
|
||||
'deja de estar ensombrecida por la seleccion previa del telefono',
|
||||
() async {
|
||||
// The PERSISTENCE half of this scenario moved to the handler, which is
|
||||
// the only writer that exists on a car-only session — it is covered by
|
||||
// «playFromMediaId desde el coche persiste ESA emisora» in
|
||||
// `test/servicios/servicio_audio_ultima_emisora_test.dart`. What
|
||||
// `EstadoRadio` still owns here, and what this test now pins, is the
|
||||
// shadowing fix: a car selection bypasses `reproducir()`, so without
|
||||
// the `estadoStream` listener `_emisoraSeleccionada` would keep
|
||||
// shadowing the car's station on the `emisoraActual` getter.
|
||||
final audio = _AudioControlado();
|
||||
final estadoUno = EstadoRadio(
|
||||
final estado = EstadoRadio(
|
||||
esPremium: () => true,
|
||||
audio: audio,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
@@ -859,7 +864,16 @@ void main() {
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estadoUno.inicializar();
|
||||
await estado.inicializar();
|
||||
final desdeElTelefono = emisoraDemo(
|
||||
uuid: 'phone-picked',
|
||||
nombre: 'Elegida en el telefono',
|
||||
);
|
||||
unawaited(estado.reproducir(desdeElTelefono));
|
||||
audio.completar(desdeElTelefono.uuid);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(estado.emisoraActual?.uuid, desdeElTelefono.uuid);
|
||||
|
||||
final desdeCoche = emisoraDemo(
|
||||
uuid: 'auto-remembered',
|
||||
nombre: 'Recordada desde el auto',
|
||||
@@ -867,18 +881,14 @@ void main() {
|
||||
audio.seleccionarDesdeAuto(desdeCoche);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final estadoDos = EstadoRadio(
|
||||
esPremium: () => true,
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
expect(
|
||||
estado.emisoraActual?.uuid,
|
||||
desdeCoche.uuid,
|
||||
reason:
|
||||
'the car changed the station without going through reproducir(); '
|
||||
'the phone UI must follow it instead of keeping the previous '
|
||||
'selection on screen',
|
||||
);
|
||||
await estadoDos.inicializar();
|
||||
|
||||
expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
import '../helpers/handlers_audio.dart';
|
||||
|
||||
/// eq-coche — the equalizer toggle pressed FROM ANDROID AUTO.
|
||||
///
|
||||
/// Reported by the owner: the toggle behaves correctly from the phone screen
|
||||
/// but from the car it «sometimes sounds like a doubled equalization and
|
||||
/// sometimes does nothing».
|
||||
///
|
||||
/// Three independent causes, one per group below:
|
||||
///
|
||||
/// A. The handler's `_presetActual` was hardcoded to `PresetEcualizador.flat`
|
||||
/// and had NO disk seam. The on/off flag got one (`leerEqActivoPersistido`,
|
||||
/// `eq-estado-unico` item A); the preset never did. On a headless Android
|
||||
/// Auto engine — no Activity, no Provider tree, so no `EstadoEcualizador`
|
||||
/// to push the real preset — enabling the equalizer from the car applied
|
||||
/// FLAT.
|
||||
///
|
||||
/// B. `_aplicarEcualizadorActivo` called `setEnabled(activo)` BEFORE pushing
|
||||
/// the preset's gains, so the native effect was re-activated carrying
|
||||
/// whatever band levels the previous preset had left in it and only
|
||||
/// afterwards were the intended ones written, band by band. That audible
|
||||
/// gap is the «doubled equalization».
|
||||
///
|
||||
/// C. `_recrearPlayer` dropped `_eqDisponible` to `false` on EVERY station
|
||||
/// change and never restored it until the fresh player attached. Every
|
||||
/// native EQ path is gated on that flag, so a car toggle landing inside
|
||||
/// the window flipped the icon and the flag but never touched the audio —
|
||||
/// the «does nothing» — and the EQ button itself vanished from the car's
|
||||
/// now-playing screen (`controlesEcualizadorPersonalizados` returns
|
||||
/// `const []` when unavailable) and came back seconds later.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final crearHandler = registrarHandlersLiberables();
|
||||
|
||||
late _GuionReproductorEq guion;
|
||||
|
||||
setUp(() {
|
||||
guion = _GuionReproductorEq();
|
||||
PluriWaveAudioHandler.fabricaReproductorPrueba =
|
||||
(pipeline, carga) => _ReproductorFalsoEq(guion, pipeline, carga);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
|
||||
PluriWaveAudioHandler.fabricaEcualizadorPrueba = null;
|
||||
});
|
||||
|
||||
group('A — the preset is seeded from disk on a headless engine', () {
|
||||
test('registrarHandler consults the injected preset port exactly once '
|
||||
'and seeds the handler with it, with no widget tree', () async {
|
||||
final handler = crearHandler();
|
||||
var lecturas = 0;
|
||||
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerPresetPersistido: () async {
|
||||
lecturas++;
|
||||
return PresetEcualizador.jazz;
|
||||
},
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(lecturas, 1, reason: 'exactly one disk read per engine start');
|
||||
expect(
|
||||
handler.presetActual,
|
||||
PresetEcualizador.jazz,
|
||||
reason:
|
||||
'from the car the handler is the ONLY owner of the preset — '
|
||||
'nothing else ever pushes one on a headless engine',
|
||||
);
|
||||
});
|
||||
|
||||
test('a read failure leaves the historical default instead of '
|
||||
'propagating', () async {
|
||||
final handler = crearHandler();
|
||||
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerPresetPersistido: () async => throw StateError('sin disco'),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(handler.presetActual, PresetEcualizador.flat);
|
||||
});
|
||||
|
||||
test('without a preset port the handler is left untouched (widget tests, '
|
||||
'fakes)', () async {
|
||||
final handler = crearHandler();
|
||||
await handler.aplicarPreset(PresetEcualizador.rock);
|
||||
|
||||
registrarHandler(handler);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(handler.presetActual, PresetEcualizador.rock);
|
||||
});
|
||||
|
||||
test('a preset already chosen while the disk read was in flight WINS — '
|
||||
'seeding never clobbers a live choice', () async {
|
||||
final handler = crearHandler();
|
||||
final lectura = Completer<PresetEcualizador?>();
|
||||
|
||||
registrarHandler(handler, leerPresetPersistido: () => lectura.future);
|
||||
// The phone UI (`EstadoEcualizador`) resolves a per-station preset and
|
||||
// pushes it while the seed's disk read is still pending.
|
||||
await handler.aplicarPreset(PresetEcualizador.pop);
|
||||
lectura.complete(PresetEcualizador.jazz);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
handler.presetActual,
|
||||
PresetEcualizador.pop,
|
||||
reason:
|
||||
'the seed exists to fill a VOID, not to overrule the richer '
|
||||
'per-station/per-device preset the phone UI resolves',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('B — the preset is pushed BEFORE the effect is enabled', () {
|
||||
test('enabling applies the gains first and only then flips the native '
|
||||
'effect on', () {
|
||||
expect(
|
||||
PluriWaveAudioHandler.pasosEcualizador(activo: true),
|
||||
[PasoEcualizador.ganancias, PasoEcualizador.habilitacion],
|
||||
reason:
|
||||
'enabling first would re-activate the native Equalizer carrying '
|
||||
'the PREVIOUS preset gains, which is the doubled equalization '
|
||||
'the owner hears',
|
||||
);
|
||||
});
|
||||
|
||||
test('disabling only flips the effect off — the band gains are NOT '
|
||||
'reset', () {
|
||||
expect(
|
||||
PluriWaveAudioHandler.pasosEcualizador(activo: false),
|
||||
[PasoEcualizador.habilitacion],
|
||||
reason:
|
||||
'android.media.audiofx.AudioEffect.setEnabled(false) bypasses '
|
||||
'the effect and RETAINS its band levels, and the enable path '
|
||||
'rewrites them before re-enabling anyway — zeroing them would be '
|
||||
'one native round trip per band for no audible difference',
|
||||
);
|
||||
});
|
||||
|
||||
test('the real toggle path executes those steps IN THAT ORDER', () async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
|
||||
await handler.setEcualizadorActivo(true);
|
||||
|
||||
expect(
|
||||
handler.pasosEcualizadorEjecutados,
|
||||
[PasoEcualizador.ganancias, PasoEcualizador.habilitacion],
|
||||
reason:
|
||||
'the ORDER is the fix; asserting only that both happened would '
|
||||
'stay green against the exact bug being fixed',
|
||||
);
|
||||
});
|
||||
|
||||
test('the real disable path executes only the habilitacion step', () async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
|
||||
await handler.setEcualizadorActivo(false);
|
||||
|
||||
expect(handler.pasosEcualizadorEjecutados, [
|
||||
PasoEcualizador.habilitacion,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('C — a station change no longer drops the equalizer', () {
|
||||
test('once the EQ was available, no state published across a station '
|
||||
'change and a car toggle has zero custom actions', () async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
// The EQ action is on the car's now-playing screen before the station
|
||||
// changes — that is the state the driver is looking at.
|
||||
await handler.setEcualizadorActivo(true);
|
||||
|
||||
final acciones = <int>[];
|
||||
final sub = handler.playbackState.listen(
|
||||
(estado) => acciones.add(
|
||||
estado.controls.where((c) => c.customAction != null).length,
|
||||
),
|
||||
);
|
||||
|
||||
await handler.playMediaItem(
|
||||
const MediaItem(id: 'https://a', title: 'A'),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
// The car tap that used to land inside the window `_recrearPlayer`
|
||||
// opened. It republishes the controls from `_eqDisponible`, so a flag
|
||||
// reset to `false` shows up here as an EQ button that disappeared.
|
||||
await handler.customAction(accionEqToggle);
|
||||
await sub.cancel();
|
||||
|
||||
expect(
|
||||
acciones,
|
||||
isNotEmpty,
|
||||
reason: 'the station change must publish at least one state',
|
||||
);
|
||||
expect(
|
||||
acciones.every((n) => n > 0),
|
||||
isTrue,
|
||||
reason:
|
||||
'the EQ button vanished and reappeared on every station change '
|
||||
'because `_recrearPlayer` reset `_eqDisponible`; availability is '
|
||||
'a DEVICE property and does not change with the station. Got '
|
||||
'$acciones',
|
||||
);
|
||||
});
|
||||
|
||||
test('the availability flag survives the player rebuild, so a car toggle '
|
||||
'inside the window still reaches the native effect', () async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
|
||||
await handler.playMediaItem(
|
||||
const MediaItem(id: 'https://a', title: 'A'),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
handler.ecualizadorDisponible,
|
||||
isTrue,
|
||||
reason:
|
||||
'this is the flag every native EQ path is gated on; false here '
|
||||
'is exactly the reported «does nothing»',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('D — a failed native call is traced and never lies', () {
|
||||
test('a throwing setEnabled is traced instead of swallowed', () async {
|
||||
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
|
||||
() => _EcualizadorQueFalla();
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
|
||||
await handler.setEcualizadorActivo(true);
|
||||
|
||||
expect(
|
||||
handler.fallosNativosEcualizador,
|
||||
greaterThan(0),
|
||||
reason:
|
||||
'the silent `catch (_) {}` made a dead native equalizer '
|
||||
'indistinguishable from a working one in a car logcat',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed on/off call leaves the published state honest instead of '
|
||||
'claiming a state the audio does not have', () async {
|
||||
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
|
||||
() => _EcualizadorQueFalla();
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
await handler.sembrarEcualizadorActivo(false);
|
||||
|
||||
await handler.setEcualizadorActivo(true);
|
||||
|
||||
expect(
|
||||
handler.ecualizadorActivo,
|
||||
isFalse,
|
||||
reason:
|
||||
'the native effect refused, so the car icon must not read "on" '
|
||||
'over audio that is not equalized',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed on/off call is not persisted', () async {
|
||||
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
|
||||
() => _EcualizadorQueFalla();
|
||||
final handler = crearHandler();
|
||||
final escrituras = <bool>[];
|
||||
registrarHandler(
|
||||
handler,
|
||||
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
|
||||
);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
await handler.sembrarEcualizadorActivo(false);
|
||||
|
||||
await handler.setEcualizadorActivo(true);
|
||||
|
||||
expect(
|
||||
escrituras,
|
||||
isEmpty,
|
||||
reason:
|
||||
'persisting a state the device rejected would resurrect it on '
|
||||
'the next engine start',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// An `AndroidEqualizer` whose `setEnabled` always throws, standing in for a
|
||||
/// device whose native `Equalizer` effect refuses the call. Nothing else is
|
||||
/// overridden, so the rest of the handler runs unchanged.
|
||||
class _EcualizadorQueFalla extends AndroidEqualizer {
|
||||
@override
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
throw StateError('el efecto nativo rechazo la llamada');
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal script/observation record shared by every [_ReproductorFalsoEq]
|
||||
/// the handler builds (it rebuilds its player on every source change).
|
||||
class _GuionReproductorEq {
|
||||
int llamadasSetUrl = 0;
|
||||
_ReproductorFalsoEq? ultimoReproductor;
|
||||
}
|
||||
|
||||
/// An [AudioPlayer] whose platform-touching methods are replaced, so a real
|
||||
/// station change can be driven under `flutter test`. Mirrors the double in
|
||||
/// `servicio_audio_transporte_test.dart`.
|
||||
class _ReproductorFalsoEq extends AudioPlayer {
|
||||
_ReproductorFalsoEq(
|
||||
this._guion,
|
||||
AudioPipeline pipeline,
|
||||
AudioLoadConfiguration carga,
|
||||
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
|
||||
_guion.ultimoReproductor = this;
|
||||
}
|
||||
|
||||
final _GuionReproductorEq _guion;
|
||||
final _estados = StreamController<PlayerState>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<PlayerState> get playerStateStream => _estados.stream;
|
||||
|
||||
@override
|
||||
Future<Duration?> setUrl(
|
||||
String url, {
|
||||
Map<String, String>? headers,
|
||||
Duration? initialPosition,
|
||||
bool preload = true,
|
||||
dynamic tag,
|
||||
}) async {
|
||||
_guion.llamadasSetUrl++;
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
|
||||
@override
|
||||
Future<void> setVolume(double volume) async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _estados.close();
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,43 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
/// eq-estado-unico item E — `mapearGananciaNativa`, the translation from the
|
||||
/// app's fixed ±12 dB slider scale to whatever range the device's native
|
||||
/// `Equalizer.getBandLevelRange()` reports.
|
||||
/// `mapearGananciaNativa` — the hand-off from the app's ±12 dB slider to the
|
||||
/// device's native `Equalizer`, whose capability is reported as
|
||||
/// `AndroidEqualizerParameters.min/maxDecibels`
|
||||
/// (`Equalizer.getBandLevelRange()` in millibels, divided by 1000).
|
||||
///
|
||||
/// This is the only source-plausible explanation for the reported «suena muy
|
||||
/// alto» half of the bug. The original implementation normalised the input
|
||||
/// across the WHOLE range and mapped it linearly:
|
||||
/// WHY THIS CONTRACT CHANGED — the previous one stretched each side of the
|
||||
/// slider against its own end of the native range, so `+6` on a device
|
||||
/// reporting `[-12, +20]` was delivered as `+10`. Both sides of the mapping
|
||||
/// are already the SAME unit, so that multiplication was a unit error:
|
||||
///
|
||||
/// normalizado = (db.clamp(-12, 12) + 12) / 24
|
||||
/// return minDecibels + normalizado * (maxDecibels - minDecibels)
|
||||
/// * `just_audio` documents `setGain` as "Sets the gain for this band in
|
||||
/// decibels", and its Android bridge does `setBandLevel(band,
|
||||
/// round(gain * 1000.0))` — plain dB to millibels, no normalisation.
|
||||
/// `min/maxDecibels` are the device's absolute CAPABILITY in dB, i.e. a
|
||||
/// bound on the control, not a scale to normalise into.
|
||||
/// * The app makes the user a decibel promise in three places at once: the
|
||||
/// slider is hard-coded `min: -12.0, max: 12.0`, the label under each
|
||||
/// band prints `'${banda.toStringAsFixed(1)}dB'`, and TalkBack reads out
|
||||
/// `equalizerBandValue` = "{value} decibels". Stretching made that label
|
||||
/// a lie on every device whose range is not exactly ±12.
|
||||
/// * Presets are persisted and EXPORTED as those same raw slider dB
|
||||
/// (`PresetEcualizador.toJson`), so under the old mapping a backup
|
||||
/// restored on a wider-range phone showed identical numbers and played
|
||||
/// louder — and on the common asymmetric shape `[-12, +19]` boosts were
|
||||
/// multiplied by 1.58 while cuts were not, deforming the preset's SHAPE
|
||||
/// rather than merely its depth.
|
||||
///
|
||||
/// which sends 0 dB to the MIDPOINT of the native range. That is only 0 when
|
||||
/// the range happens to be symmetric. Android does not guarantee that: the
|
||||
/// AudioEffect Equalizer contract only requires a min/max pair, and real
|
||||
/// devices ship asymmetric ranges. On such a device a FLAT preset — every
|
||||
/// band 0 dB — was silently pushing a positive boost into every band, which
|
||||
/// is audibly louder while the on/off button still reads "off".
|
||||
/// So: the number the user reads is the number the device is asked for. The
|
||||
/// native range only CLAMPS it.
|
||||
///
|
||||
/// The contract asserted here: 0 dB always maps to exactly 0, and the two
|
||||
/// sides of the scale are stretched INDEPENDENTLY against their own end of
|
||||
/// the native range, so the sign of the user's intent is never inverted and
|
||||
/// the extremes still reach the device's real limits.
|
||||
/// What this deliberately KEEPS from the previous contract — every invariant
|
||||
/// the «suena muy alto» fix actually earned. 0 dB is always exactly 0 (a
|
||||
/// naive `db.clamp(minDecibels, maxDecibels)` would regress that on a wholly
|
||||
/// positive reported range, turning a FLAT preset into a boost again), 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.
|
||||
void main() {
|
||||
group('mapearGananciaNativa — 0 dB is always exactly 0', () {
|
||||
test('symmetric range (the common case) is unchanged', () {
|
||||
@@ -43,6 +58,10 @@ void main() {
|
||||
});
|
||||
|
||||
test('a wholly positive range still cannot boost a FLAT preset', () {
|
||||
// This is precisely why the mapping cannot be a plain
|
||||
// `db.clamp(minDecibels, maxDecibels)`: that would answer +3 here and
|
||||
// bring the «suena muy alto» bug straight back. The clamp window has
|
||||
// to be widened so that it always contains 0.
|
||||
expect(mapearGananciaNativa(0, minDecibels: 3, maxDecibels: 19), 0);
|
||||
});
|
||||
|
||||
@@ -51,36 +70,61 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('mapearGananciaNativa — the extremes reach the native limits', () {
|
||||
test('+12 dB maps to the native maximum', () {
|
||||
expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 19);
|
||||
group('mapearGananciaNativa — the slider dB reach the device literally', () {
|
||||
test('+12 dB is delivered as +12 dB, not stretched to the native max', () {
|
||||
// CONTRACT CHANGE: this used to assert 19, i.e. the whole of the
|
||||
// device's headroom. The slider says "12.0dB" and the accessibility
|
||||
// label says "12.0 decibels", so 12 dB is what the device must be
|
||||
// asked for. The 7 dB of extra hardware headroom is unreachable by
|
||||
// design until the slider itself is widened and says so.
|
||||
expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 12);
|
||||
});
|
||||
|
||||
test('-12 dB maps to the native minimum', () {
|
||||
test('-12 dB is delivered as -12 dB', () {
|
||||
expect(mapearGananciaNativa(-12, minDecibels: -12, maxDecibels: 19), -12);
|
||||
});
|
||||
|
||||
test('values beyond the slider scale are clamped, not extrapolated', () {
|
||||
expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 15);
|
||||
expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -15);
|
||||
test('values beyond the slider scale clamp to the slider limit', () {
|
||||
// CONTRACT CHANGE: these used to answer the NATIVE extremes (±15).
|
||||
// The slider scale is the first bound; the device range is the second.
|
||||
expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 12);
|
||||
expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -12);
|
||||
});
|
||||
});
|
||||
|
||||
group('mapearGananciaNativa — each side scales against its own end', () {
|
||||
test('half boost is half of the positive headroom', () {
|
||||
group('mapearGananciaNativa — the label is the value the device gets', () {
|
||||
test('+6 dB on a wide-range device is +6 dB, never 10', () {
|
||||
// CONTRACT CHANGE: this used to assert closeTo(10) — "half boost is
|
||||
// half of the positive headroom". A slider reading "6.0dB" that
|
||||
// produced +10 dB of real boost is exactly what made a restored backup
|
||||
// sound different on a different phone.
|
||||
expect(
|
||||
mapearGananciaNativa(6, minDecibels: -12, maxDecibels: 20),
|
||||
closeTo(10, 1e-9),
|
||||
closeTo(6, 1e-9),
|
||||
);
|
||||
});
|
||||
|
||||
test('half cut is half of the negative headroom', () {
|
||||
test('-6 dB on that same device is -6 dB', () {
|
||||
expect(
|
||||
mapearGananciaNativa(-6, minDecibels: -12, maxDecibels: 20),
|
||||
closeTo(-6, 1e-9),
|
||||
);
|
||||
});
|
||||
|
||||
test('the six factory presets keep their shape on an asymmetric device', () {
|
||||
// Jazz, authored in true dB before any scaling existed. Under the old
|
||||
// mapping [-12, +19] delivered it as [4.75, -1, -1.5, 3.17, 6.33]: a
|
||||
// different tonal curve, not merely a louder one.
|
||||
const jazz = [3.0, -1.0, -1.5, 2.0, 4.0];
|
||||
final entregado = jazz
|
||||
.map(
|
||||
(db) =>
|
||||
mapearGananciaNativa(db, minDecibels: -12, maxDecibels: 19),
|
||||
)
|
||||
.toList();
|
||||
expect(entregado, jazz);
|
||||
});
|
||||
|
||||
test('the sign of the user intent is never inverted', () {
|
||||
for (final db in [-12.0, -6.0, -1.0, 1.0, 6.0, 12.0]) {
|
||||
final nativo = mapearGananciaNativa(
|
||||
@@ -97,12 +141,44 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('mapearGananciaNativa — a device narrower than the slider', () {
|
||||
test('a request that fits is still delivered literally', () {
|
||||
// CONTRACT CHANGE: the old mapping shrank this to (3/12)*6 = 1.5 dB,
|
||||
// so a modest device silently under-delivered every request too.
|
||||
expect(mapearGananciaNativa(3, minDecibels: -6, maxDecibels: 6), 3);
|
||||
expect(mapearGananciaNativa(-3, minDecibels: -6, maxDecibels: 6), -3);
|
||||
});
|
||||
|
||||
test('a request beyond the device range clamps to the device limit', () {
|
||||
expect(mapearGananciaNativa(12, minDecibels: -6, maxDecibels: 6), 6);
|
||||
expect(mapearGananciaNativa(-12, minDecibels: -6, maxDecibels: 6), -6);
|
||||
});
|
||||
|
||||
test('a very narrow device still gets a sane, in-range value', () {
|
||||
for (final db in [-12.0, -5.0, 0.0, 5.0, 12.0]) {
|
||||
final nativo = mapearGananciaNativa(
|
||||
db,
|
||||
minDecibels: -1.5,
|
||||
maxDecibels: 1.5,
|
||||
);
|
||||
expect(nativo, greaterThanOrEqualTo(-1.5));
|
||||
expect(nativo, lessThanOrEqualTo(1.5));
|
||||
expect(nativo.sign, db.sign);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('mapearGananciaNativa — degenerate ranges reported by the device', () {
|
||||
test('a range with no headroom on one side clamps that side to 0', () {
|
||||
// A device that reports max == 0 can only cut. Asking for a boost must
|
||||
// resolve to "no change", never to a negative value.
|
||||
test('a device with no headroom above unity can never boost', () {
|
||||
expect(mapearGananciaNativa(12, minDecibels: -15, maxDecibels: 0), 0);
|
||||
expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -15);
|
||||
expect(mapearGananciaNativa(6, minDecibels: -15, maxDecibels: 0), 0);
|
||||
});
|
||||
|
||||
test('a cut the device could honour exactly is not over-delivered', () {
|
||||
// CONTRACT CHANGE: this used to answer -15, spending the device's whole
|
||||
// range on a request for -12 dB. The user asked for -12; -12 is
|
||||
// representable here, so -12 is what is sent.
|
||||
expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -12);
|
||||
});
|
||||
|
||||
test('a zero-width range collapses everything to 0', () {
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/handlers_audio.dart';
|
||||
|
||||
/// Resuming the last station in Android Auto — the three defects that made a
|
||||
/// car-only session unable to remember, restart or even NAME what it was
|
||||
/// playing.
|
||||
///
|
||||
/// Every test here runs with NO widget tree and NO browse source registered:
|
||||
/// that is the engine Android Auto actually starts
|
||||
/// (`AudioServicePlugin.java:75-111` builds `new FlutterEngine(context)` with
|
||||
/// no Activity), so `EstadoRadio` — the only thing that used to write
|
||||
/// `ultima_emisora_v1` — is never constructed there.
|
||||
///
|
||||
/// A1. The last station was written EXCLUSIVELY by `EstadoRadio`, so a
|
||||
/// session that happened only in the car never updated the key and the
|
||||
/// head unit was offered the station from the last time the PHONE was
|
||||
/// used. The same key feeds `resolverEmisorasDestacadas`, so the free
|
||||
/// tier's featured folder was stale too.
|
||||
///
|
||||
/// A2. `play()` with no source called `_player.play()`, and
|
||||
/// `just_audio.dart:937-967` publishes `_playingSubject.add(true)`
|
||||
/// BEFORE the `_audioSource != null` gate — so the platform was never
|
||||
/// touched, the returned Future never completed, and `playing: true`
|
||||
/// was forwarded over `processingState: idle`.
|
||||
/// `AudioService.java:559-560` then runs `enterPlayingState()` while
|
||||
/// `getPlaybackState()` is `STATE_NONE`: a notification with a pause
|
||||
/// button, no audio, no title and no artwork (or a
|
||||
/// `ForegroundServiceStartNotAllowedException` on API 31+).
|
||||
///
|
||||
/// A3. `mediaItem` was null on a cold start — the only `mediaItem.add` sites
|
||||
/// are the duration update, `_cambiarFuente` and `stop` — so
|
||||
/// `audio_service.dart:1029-1033` returned before `setMediaItem` and the
|
||||
/// native side got no metadata at all.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final crearHandler = registrarHandlersLiberables();
|
||||
|
||||
late _GuionReproductor guion;
|
||||
|
||||
/// The free set's first station: resolvable from the binary alone, so it
|
||||
/// works on a bind where no browse source was ever registered — which is
|
||||
/// the whole point of these tests.
|
||||
const emisoraFip = Emisora(
|
||||
uuid: 'pw-destacada-fip',
|
||||
nombre: 'FIP',
|
||||
url: 'https://icecast.radiofrance.fr/fip-midfi.mp3',
|
||||
pais: 'France',
|
||||
codigoPais: 'FR',
|
||||
idioma: 'french',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
guion = _GuionReproductor();
|
||||
PluriWaveAudioHandler.fabricaReproductorPrueba =
|
||||
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
|
||||
// Fresh install = free tier (`esPremiumPersistido` is `getBool(...) ??
|
||||
// false`) and no `ultima_emisora_v1`.
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
|
||||
});
|
||||
|
||||
group('A1 — el coche escribe la ultima emisora', () {
|
||||
test(
|
||||
'playFromMediaId desde el coche persiste ESA emisora por el puerto '
|
||||
'inyectado, sin arbol de widgets',
|
||||
() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final handler = crearHandler();
|
||||
final guardadas = <Emisora>[];
|
||||
registrarHandler(
|
||||
handler,
|
||||
guardarUltimaEmisora: (emisora) async {
|
||||
guardadas.add(emisora);
|
||||
await guardarUltimaEmisoraPersistida(emisora, prefs: prefs);
|
||||
},
|
||||
);
|
||||
|
||||
await handler.playFromMediaId('emisora:${emisoraFip.uuid}');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
guardadas.map((e) => e.uuid),
|
||||
[emisoraFip.uuid],
|
||||
reason:
|
||||
'a car-only session must update `ultima_emisora_v1` itself — '
|
||||
'`EstadoRadio` is never built on a headless engine',
|
||||
);
|
||||
final persistida = await ultimaEmisoraPersistida(prefs: prefs);
|
||||
expect(persistida?.uuid, emisoraFip.uuid);
|
||||
expect(
|
||||
persistida?.url,
|
||||
emisoraFip.url,
|
||||
reason:
|
||||
'the record has to be PLAYABLE: it is what the recent root and '
|
||||
'`resolverEmisorasDestacadas` hand back to the head unit',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('playMediaItem directo (voz, telefono) persiste igual', () async {
|
||||
final handler = crearHandler();
|
||||
final guardadas = <Emisora>[];
|
||||
registrarHandler(
|
||||
handler,
|
||||
guardarUltimaEmisora: (emisora) async => guardadas.add(emisora),
|
||||
);
|
||||
|
||||
await handler.playMediaItem(
|
||||
const MediaItem(
|
||||
id: 'https://ejemplo/stream',
|
||||
title: 'Ejemplo',
|
||||
extras: {'uuid': 'uuid-ejemplo'},
|
||||
),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guardadas.map((e) => e.uuid), ['uuid-ejemplo']);
|
||||
expect(guardadas.single.url, 'https://ejemplo/stream');
|
||||
});
|
||||
|
||||
test(
|
||||
'una pista local NO se persiste como ultima emisora',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
final guardadas = <Emisora>[];
|
||||
registrarHandler(
|
||||
handler,
|
||||
guardarUltimaEmisora: (emisora) async => guardadas.add(emisora),
|
||||
);
|
||||
|
||||
await handler.playMediaItem(
|
||||
const MediaItem(
|
||||
id: 'content://media/audio/7',
|
||||
title: 'Pista local',
|
||||
extras: {'documentId': 'doc-7'},
|
||||
),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
guardadas,
|
||||
isEmpty,
|
||||
reason:
|
||||
'`ultima_emisora_v1` feeds the recent root and the featured '
|
||||
'folder as an `emisora:<uuid>` row — a `content://` track '
|
||||
'there is a row that does nothing when tapped',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('un fallo del puerto se traza y NUNCA propaga', () async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(
|
||||
handler,
|
||||
guardarUltimaEmisora: (_) async => throw StateError('sin disco'),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
handler.playMediaItem(
|
||||
const MediaItem(
|
||||
id: 'https://ejemplo/stream',
|
||||
title: 'Ejemplo',
|
||||
extras: {'uuid': 'uuid-ejemplo'},
|
||||
),
|
||||
),
|
||||
completes,
|
||||
);
|
||||
await pumpEventQueue();
|
||||
});
|
||||
|
||||
test('sin puerto (tests de widget, fakes) no pasa nada', () async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
await expectLater(
|
||||
handler.playMediaItem(
|
||||
const MediaItem(
|
||||
id: 'https://ejemplo/stream',
|
||||
title: 'Ejemplo',
|
||||
extras: {'uuid': 'uuid-ejemplo'},
|
||||
),
|
||||
),
|
||||
completes,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('A2 — play() sin fuente no publica una sesion fantasma', () {
|
||||
test(
|
||||
'con una emisora persistida, play() resuelve y arranca ESA emisora: el '
|
||||
'reproductor recibe su url',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerUltimaEmisora: () async => emisoraFip,
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
unawaited(handler.play().catchError((_) {}));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
guion.urlsSolicitadas,
|
||||
contains(emisoraFip.url),
|
||||
reason:
|
||||
'`AudioService.java:920` routes the car KEYCODE_MEDIA_PLAY '
|
||||
'straight into play(); on a cold engine there is no source, so '
|
||||
'it has to resolve the persisted station instead',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'y NINGUN estado publicado lleva playing:true sobre processingState '
|
||||
'idle',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerUltimaEmisora: () async => emisoraFip,
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
final fantasmas = <PlaybackState>[];
|
||||
final sub = handler.playbackState.listen((estado) {
|
||||
if (estado.playing &&
|
||||
estado.processingState == AudioProcessingState.idle) {
|
||||
fantasmas.add(estado);
|
||||
}
|
||||
});
|
||||
|
||||
unawaited(handler.play().catchError((_) {}));
|
||||
await pumpEventQueue();
|
||||
await sub.cancel();
|
||||
|
||||
expect(
|
||||
fantasmas,
|
||||
isEmpty,
|
||||
reason:
|
||||
'playing:true over idle is what makes `AudioService.java:559` '
|
||||
'call enterPlayingState() with STATE_NONE — a PluriWave '
|
||||
'notification with a pause button, no audio and no title',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'sin nada persistido: no se toca el reproductor, no hay estado '
|
||||
'fantasma y play() no se queda colgado',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler, leerUltimaEmisora: () async => null);
|
||||
await pumpEventQueue();
|
||||
|
||||
final fantasmas = <PlaybackState>[];
|
||||
final sub = handler.playbackState.listen((estado) {
|
||||
if (estado.playing &&
|
||||
estado.processingState == AudioProcessingState.idle) {
|
||||
fantasmas.add(estado);
|
||||
}
|
||||
});
|
||||
|
||||
await expectLater(
|
||||
handler.play().timeout(const Duration(seconds: 2)),
|
||||
completes,
|
||||
);
|
||||
await pumpEventQueue();
|
||||
await sub.cancel();
|
||||
|
||||
expect(
|
||||
guion.llamadasPlay,
|
||||
0,
|
||||
reason:
|
||||
'with nothing to restore the player must not be touched at '
|
||||
'all: `just_audio` publishes playing:true before its source '
|
||||
'gate and never completes the future it returns',
|
||||
);
|
||||
expect(guion.llamadasSetUrl, 0);
|
||||
expect(fantasmas, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'con una fuente ya abierta, play() sigue siendo la reanudacion de '
|
||||
'siempre (pausa -> play no reabre nada)',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerUltimaEmisora: () async => emisoraFip,
|
||||
);
|
||||
await handler.playMediaItem(
|
||||
const MediaItem(
|
||||
id: 'https://ejemplo/stream',
|
||||
title: 'Ejemplo',
|
||||
extras: {'uuid': 'uuid-ejemplo'},
|
||||
),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
await handler.pause();
|
||||
final urlsAntes = List<String>.from(guion.urlsSolicitadas);
|
||||
|
||||
await handler.play();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
guion.urlsSolicitadas,
|
||||
urlsAntes,
|
||||
reason:
|
||||
'a resume must NOT re-open the source, and must never replace '
|
||||
'the live station with the persisted one',
|
||||
);
|
||||
expect(handler.intencionReproducir, isTrue);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('A3 — arranque en frio: el coche recibe metadatos', () {
|
||||
test(
|
||||
'con una emisora persistida se publica su mediaItem SIN arrancar '
|
||||
'reproduccion',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerUltimaEmisora: () async => emisoraFip,
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
handler.mediaItem.value,
|
||||
isNotNull,
|
||||
reason:
|
||||
'`audio_service.dart:1029-1033` returns before setMediaItem '
|
||||
'when mediaItem is null, so a cold engine sent the head unit '
|
||||
'no metadata whatsoever',
|
||||
);
|
||||
expect(handler.mediaItem.value?.id, emisoraFip.url);
|
||||
expect(handler.playbackState.value.playing, isFalse);
|
||||
expect(
|
||||
guion.llamadasSetUrl,
|
||||
0,
|
||||
reason:
|
||||
'publishing metadata must not open a stream: a cold bind '
|
||||
'happens on every reconnect and must stay silent',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('sin nada persistido el mediaItem sigue vacio', () async {
|
||||
final handler = crearHandler();
|
||||
|
||||
registrarHandler(handler, leerUltimaEmisora: () async => null);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(handler.mediaItem.value, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'una emisora que ya empezo a sonar NO es pisada por la siembra',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
final lectura = Completer<Emisora?>();
|
||||
|
||||
registrarHandler(handler, leerUltimaEmisora: () => lectura.future);
|
||||
await handler.playMediaItem(
|
||||
const MediaItem(
|
||||
id: 'https://enVivo/stream',
|
||||
title: 'En vivo',
|
||||
extras: {'uuid': 'uuid-en-vivo'},
|
||||
),
|
||||
);
|
||||
lectura.complete(emisoraFip);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
handler.mediaItem.value?.id,
|
||||
'https://enVivo/stream',
|
||||
reason:
|
||||
'the seed exists to fill a VOID; clobbering the live station '
|
||||
'would rename what the driver is listening to',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('getMediaItem resuelve tambien el set destacado', () {
|
||||
test(
|
||||
'sin fuente de navegacion registrada, una emisora destacada resuelve',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
final item = await handler.getMediaItem('emisora:${emisoraFip.uuid}');
|
||||
|
||||
expect(
|
||||
item,
|
||||
isNotNull,
|
||||
reason:
|
||||
'`porUuid` already falls back to the featured set, so the car '
|
||||
'could BROWSE a featured station and not resolve its media '
|
||||
'item — the asymmetry is the bug',
|
||||
);
|
||||
expect(item?.id, 'emisora:${emisoraFip.uuid}');
|
||||
expect(item?.title, emisoraFip.nombre);
|
||||
},
|
||||
);
|
||||
|
||||
test('un id que no es de emisora sigue devolviendo null', () async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
expect(await handler.getMediaItem('pista:doc-1'), isNull);
|
||||
expect(await handler.getMediaItem('emisora:'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Shared script/observation record for every [_ReproductorFalso] the handler
|
||||
/// builds (it rebuilds its player on every source change, so counters cannot
|
||||
/// live on the instance).
|
||||
class _GuionReproductor {
|
||||
int llamadasPlay = 0;
|
||||
int llamadasSetUrl = 0;
|
||||
final urlsSolicitadas = <String>[];
|
||||
}
|
||||
|
||||
/// An [AudioPlayer] double that reproduces the ONE `just_audio` behaviour
|
||||
/// defect A2 is about: `play()` (`just_audio.dart:937-967`) publishes
|
||||
/// `playing: true` BEFORE the `_audioSource != null` gate, and with no source
|
||||
/// it never touches the platform and never completes the future it returned.
|
||||
class _ReproductorFalso extends AudioPlayer {
|
||||
_ReproductorFalso(
|
||||
this._guion,
|
||||
AudioPipeline pipeline,
|
||||
AudioLoadConfiguration carga,
|
||||
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga);
|
||||
|
||||
final _GuionReproductor _guion;
|
||||
final _estados = StreamController<PlayerState>.broadcast();
|
||||
|
||||
/// A fresh player has no source, exactly like the real one.
|
||||
bool _fuenteCargada = false;
|
||||
|
||||
@override
|
||||
Stream<PlayerState> get playerStateStream => _estados.stream;
|
||||
|
||||
@override
|
||||
Future<Duration?> setUrl(
|
||||
String url, {
|
||||
Map<String, String>? headers,
|
||||
Duration? initialPosition,
|
||||
bool preload = true,
|
||||
dynamic tag,
|
||||
}) async {
|
||||
_guion.llamadasSetUrl++;
|
||||
_guion.urlsSolicitadas.add(url);
|
||||
_fuenteCargada = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() {
|
||||
_guion.llamadasPlay++;
|
||||
if (!_estados.isClosed) {
|
||||
_estados.add(
|
||||
PlayerState(
|
||||
true,
|
||||
_fuenteCargada ? ProcessingState.ready : ProcessingState.idle,
|
||||
),
|
||||
);
|
||||
}
|
||||
// The dangling future: with no source, upstream `play()` awaits a
|
||||
// `_playingSubject` transition the platform will never produce.
|
||||
if (!_fuenteCargada) return Completer<void>().future;
|
||||
return Future<void>.value();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
|
||||
@override
|
||||
Future<void> setVolume(double volume) async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _estados.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user