|
|
|
@@ -36,13 +36,93 @@ enum EstadoReproduccion {
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
PluriWaveAudioHandler? _handlerGlobal;
|
|
|
|
|
|
|
|
|
|
void registrarHandler(PluriWaveAudioHandler handler) {
|
|
|
|
|
/// Read port for the persisted equalizer on/off flag (eq-estado-unico item A).
|
|
|
|
|
/// In production `main.dart` binds it to `ServicioEcualizador.leerActivo`,
|
|
|
|
|
/// which needs nothing but the `SharedPreferences` instance already resolved
|
|
|
|
|
/// before `AudioService.init`. `null` for any caller that has no disk (widget
|
|
|
|
|
/// tests, fakes) — seeding is then skipped entirely.
|
|
|
|
|
typedef LeerEqActivoPersistido = Future<bool?> Function();
|
|
|
|
|
|
|
|
|
|
/// Write port for the same flag (eq-estado-unico item B). Bound to
|
|
|
|
|
/// `ServicioEcualizador.guardarActivo`.
|
|
|
|
|
typedef GuardarEqActivoPersistido = Future<void> Function(bool activo);
|
|
|
|
|
|
|
|
|
|
/// Last value read from disk for the equalizer on/off flag, or `null` while
|
|
|
|
|
/// nothing has been read yet.
|
|
|
|
|
///
|
|
|
|
|
/// This exists purely to close the construction window: `AudioService.init`
|
|
|
|
|
/// builds the handler through its `builder` callback, and only AFTER that
|
|
|
|
|
/// future resolves does `main.dart` reach [registrarHandler]. A car tap
|
|
|
|
|
/// landing inside that window would otherwise hit a handler whose flag had
|
|
|
|
|
/// never seen disk. Once one engine has read the value, any handler built
|
|
|
|
|
/// afterwards starts from it instead of from a hardcoded default.
|
|
|
|
|
bool? _eqActivoPersistido;
|
|
|
|
|
|
|
|
|
|
/// The equalizer's initial on/off state for a freshly started engine.
|
|
|
|
|
///
|
|
|
|
|
/// Pure seam (eq-estado-unico item A): [PluriWaveAudioHandler] used to
|
|
|
|
|
/// hardcode `_ecualizadorActivo = true`, so a process started HEADLESSLY by
|
|
|
|
|
/// Android Auto — no Activity, no Provider tree, so no
|
|
|
|
|
/// `EstadoEcualizador.cargarPersistido()` — played with the equalizer forced
|
|
|
|
|
/// on while disk and the phone UI both said off. That is the reported «suena
|
|
|
|
|
/// muy alto con el boton desactivado».
|
|
|
|
|
///
|
|
|
|
|
/// `null` means "nothing was ever persisted" (first install, or a wiped
|
|
|
|
|
/// preference) and keeps the historical default of ON. It must NOT be
|
|
|
|
|
/// confused with "off": a user who has never touched the toggle expects the
|
|
|
|
|
/// equalizer on, and the app has always behaved that way.
|
|
|
|
|
bool estadoEqInicial({required bool? persistido}) => persistido ?? true;
|
|
|
|
|
|
|
|
|
|
/// Reads the persisted equalizer flag through [leer] exactly once and seeds
|
|
|
|
|
/// [handler] with it, without ever writing back.
|
|
|
|
|
///
|
|
|
|
|
/// Never throws: an unreadable preference store leaves the handler on
|
|
|
|
|
/// [estadoEqInicial]'s default rather than taking down the audio bootstrap.
|
|
|
|
|
Future<void> _sembrarEcualizadorDesdeDisco(
|
|
|
|
|
PluriWaveAudioHandler handler,
|
|
|
|
|
LeerEqActivoPersistido leer,
|
|
|
|
|
) async {
|
|
|
|
|
bool? persistido;
|
|
|
|
|
try {
|
|
|
|
|
persistido = await leer();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
debugPrint(
|
|
|
|
|
'[PluriWave][ServicioAudio] no se pudo leer el estado EQ persistido: $e',
|
|
|
|
|
);
|
|
|
|
|
persistido = null;
|
|
|
|
|
}
|
|
|
|
|
_eqActivoPersistido = persistido;
|
|
|
|
|
await handler.sembrarEcualizadorActivo(
|
|
|
|
|
estadoEqInicial(persistido: persistido),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Wires the freshly built handler into the module-level seams.
|
|
|
|
|
///
|
|
|
|
|
/// [leerEqActivoPersistido] and [guardarEqActivoPersistido] give the handler
|
|
|
|
|
/// its own, UI-independent link to the equalizer's persisted on/off flag
|
|
|
|
|
/// (eq-estado-unico items A and B). Before them the flag reached the handler
|
|
|
|
|
/// only through `EstadoEcualizador.cargarPersistido()`, i.e. only on an
|
|
|
|
|
/// engine that had actually built the widget tree — which a headless Android
|
|
|
|
|
/// Auto bind never does. Both are optional so every existing caller (widget
|
|
|
|
|
/// tests, fakes) keeps compiling and behaving exactly as before.
|
|
|
|
|
void registrarHandler(
|
|
|
|
|
PluriWaveAudioHandler handler, {
|
|
|
|
|
LeerEqActivoPersistido? leerEqActivoPersistido,
|
|
|
|
|
GuardarEqActivoPersistido? guardarEqActivoPersistido,
|
|
|
|
|
}) {
|
|
|
|
|
_handlerGlobal = handler;
|
|
|
|
|
// iap-freemium-unlock (design.md Open Questions, orchestrator-resolved):
|
|
|
|
|
// on the free -> premium transition, actively invalidate every root-level
|
|
|
|
|
// browse id a head unit may have cached while locked, rather than waiting
|
|
|
|
|
// for its own re-bind — see [registrarNotificacionDesbloqueoAuto]'s doc.
|
|
|
|
|
registrarNotificacionDesbloqueoAuto(() {
|
|
|
|
|
// Registered BEFORE the seeding below is awaited so that a toggle arriving
|
|
|
|
|
// during the disk read is still persisted.
|
|
|
|
|
handler.registrarPersistenciaEq(guardarEqActivoPersistido);
|
|
|
|
|
if (leerEqActivoPersistido != null) {
|
|
|
|
|
unawaited(_sembrarEcualizadorDesdeDisco(handler, leerEqActivoPersistido));
|
|
|
|
|
}
|
|
|
|
|
// iap-freemium-unlock (design.md Open Questions, orchestrator-resolved),
|
|
|
|
|
// generalizado en fix/android-auto-musica-local item 4: invalida
|
|
|
|
|
// activamente todo id de nivel raíz que un head unit pueda tener cacheado
|
|
|
|
|
// en vez de esperar a su propio re-bind — ver [registrarInvalidacionArbolAuto].
|
|
|
|
|
registrarInvalidacionArbolAuto(() {
|
|
|
|
|
handler.notificarHijosCambiaron(AudioService.browsableRootId);
|
|
|
|
|
handler.notificarHijosCambiaron(ConstructorArbolAuto.idFavoritos);
|
|
|
|
|
handler.notificarHijosCambiaron(ConstructorArbolAuto.idTodas);
|
|
|
|
@@ -153,29 +233,56 @@ void registrarLimpiezaArranque(Future<void> Function() limpieza) {
|
|
|
|
|
_limpiezaArranqueGlobal = limpieza;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Free -> premium Android Auto cache-invalidation hook (design.md Open
|
|
|
|
|
/// Questions, orchestrator-resolved): registered from [registrarHandler] so
|
|
|
|
|
/// `estado_entitlement.dart` can trigger it WITHOUT ever touching
|
|
|
|
|
/// `PluriWaveAudioHandler` directly (that type cannot be constructed in a
|
|
|
|
|
/// unit test — see [PluriWaveAudioHandler]'s own doc). `null` until a
|
|
|
|
|
/// handler registers (headless cold bind, or a widget-only test that never
|
|
|
|
|
/// wires audio) — [notificarDesbloqueoAuto] tolerates that silently.
|
|
|
|
|
void Function()? _alDesbloquearAutoGlobal;
|
|
|
|
|
/// Android Auto browse-cache invalidation hook (design.md Open Questions,
|
|
|
|
|
/// orchestrator-resolved): registered from [registrarHandler] so callers
|
|
|
|
|
/// can trigger it WITHOUT ever touching `PluriWaveAudioHandler` directly (a
|
|
|
|
|
/// layering choice — the entitlement layer has no business knowing the
|
|
|
|
|
/// handler type; it is not, as this doc used to claim, because the handler
|
|
|
|
|
/// cannot be constructed in a unit test, which is false — see
|
|
|
|
|
/// [construirControlesTransporte]). `null` until a handler registers
|
|
|
|
|
/// (headless cold bind, or a widget-only test that never wires audio) —
|
|
|
|
|
/// [invalidarArbolAuto] tolerates that silently.
|
|
|
|
|
///
|
|
|
|
|
/// GENERALIZADO (fix/android-auto-musica-local, item 4): nació atado a la
|
|
|
|
|
/// transición free -> premium, y ese nombre escondía para qué sirve de
|
|
|
|
|
/// verdad. Android Auto CACHEA la raíz, así que hay que invalidarla cada
|
|
|
|
|
/// vez que el árbol pasa a poder mostrar algo que antes no podía. Hoy lo
|
|
|
|
|
/// disparan tres sitios: la compra premium (`estado_entitlement.dart`), la
|
|
|
|
|
/// primera vez que existe una View de verdad — es decir, cuando por fin hay
|
|
|
|
|
/// Activity y con ella el handler nativo de `pluriwave/file_actions`
|
|
|
|
|
/// (`main.dart`) — y la elección de carpeta de música local
|
|
|
|
|
/// (`pantalla_ajustes_musica_local.dart`).
|
|
|
|
|
void Function()? _invalidarArbolAutoGlobal;
|
|
|
|
|
|
|
|
|
|
/// Registers the hook [notificarDesbloqueoAuto] invokes. Exposed at module
|
|
|
|
|
/// Registers the hook [invalidarArbolAuto] invokes. Exposed at module
|
|
|
|
|
/// level (like every other `registrar*` seam in this file) purely so tests
|
|
|
|
|
/// can inject a fake hook and assert it fires, without instantiating a real
|
|
|
|
|
/// [PluriWaveAudioHandler].
|
|
|
|
|
void registrarNotificacionDesbloqueoAuto(void Function() alDesbloquear) {
|
|
|
|
|
_alDesbloquearAutoGlobal = alDesbloquear;
|
|
|
|
|
void registrarInvalidacionArbolAuto(void Function() alInvalidar) {
|
|
|
|
|
_invalidarArbolAutoGlobal = alInvalidar;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fires the registered free -> premium Android Auto invalidation hook, if
|
|
|
|
|
/// Fires the registered Android Auto browse-cache invalidation hook, if
|
|
|
|
|
/// any. A no-op before a handler ever registers — never throws.
|
|
|
|
|
void notificarDesbloqueoAuto() {
|
|
|
|
|
_alDesbloquearAutoGlobal?.call();
|
|
|
|
|
void invalidarArbolAuto() {
|
|
|
|
|
_invalidarArbolAutoGlobal?.call();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Whether a head unit has actually SUBSCRIBED to at least one browse id on
|
|
|
|
|
/// the live handler (fix/android-auto-musica-local, item 4 — corrected).
|
|
|
|
|
///
|
|
|
|
|
/// This is the precondition that makes [invalidarArbolAuto] worth firing at
|
|
|
|
|
/// all: [PluriWaveAudioHandler.notificarHijosCambiaron] is
|
|
|
|
|
/// `_childrenSubjects[id]?.add(...)`, so invalidating before the car has
|
|
|
|
|
/// subscribed to ANYTHING is provably a silent no-op — which is exactly how
|
|
|
|
|
/// the old `View.maybeOf(context) != null` trigger managed to burn its
|
|
|
|
|
/// one-shot latch during the headless cold start and never fire again.
|
|
|
|
|
///
|
|
|
|
|
/// Module-level, like every other seam in this file, so `main.dart` can ask
|
|
|
|
|
/// the question without importing the handler type, and `false` when no
|
|
|
|
|
/// handler has registered yet (headless cold bind, widget-only tests).
|
|
|
|
|
bool hayCocheSuscritoAlArbol() => _handlerGlobal?.hayCocheSuscrito ?? false;
|
|
|
|
|
|
|
|
|
|
/// Pure Android Auto play-path gate decision (iap-freemium-unlock, Design
|
|
|
|
|
/// ADR-4): whether a station-switch dispatch (`playFromMediaId`,
|
|
|
|
|
/// `playFromSearch`, `skipToNext`, `skipToPrevious`) must no-op for
|
|
|
|
@@ -291,6 +398,95 @@ AudioProcessingState mapearEstadoProceso(
|
|
|
|
|
/// media id).
|
|
|
|
|
const accionEqToggle = 'eq_toggle';
|
|
|
|
|
|
|
|
|
|
/// What an [accionEqToggle] tap resolves to (eq-estado-unico item C).
|
|
|
|
|
class DecisionToggleEq {
|
|
|
|
|
const DecisionToggleEq({
|
|
|
|
|
required this.nuevoActivo,
|
|
|
|
|
required this.requiereLlamadaNativa,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/// The on/off value the handler must end up holding.
|
|
|
|
|
final bool nuevoActivo;
|
|
|
|
|
|
|
|
|
|
/// Whether the native `AndroidEqualizer` effect must also be told. `false`
|
|
|
|
|
/// on a device with no usable Equalizer effect: the flag still flips (so
|
|
|
|
|
/// the car button never looks inert and the label still updates) but
|
|
|
|
|
/// nothing is pushed to the platform.
|
|
|
|
|
final bool requiereLlamadaNativa;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
bool operator ==(Object other) =>
|
|
|
|
|
other is DecisionToggleEq &&
|
|
|
|
|
other.nuevoActivo == nuevoActivo &&
|
|
|
|
|
other.requiereLlamadaNativa == requiereLlamadaNativa;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
int get hashCode => Object.hash(nuevoActivo, requiereLlamadaNativa);
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
String toString() =>
|
|
|
|
|
'DecisionToggleEq(nuevoActivo: $nuevoActivo, '
|
|
|
|
|
'requiereLlamadaNativa: $requiereLlamadaNativa)';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The equalizer toggle decision, extracted out of `customAction` and
|
|
|
|
|
/// `setEcualizadorActivo` so it can be tested on its own (eq-estado-unico
|
|
|
|
|
/// item C — this dispatch had ZERO tests: `rg "customAction\(" test/`
|
|
|
|
|
/// returned nothing).
|
|
|
|
|
///
|
|
|
|
|
/// Reported: «pulsando sobre el boton de ecualizar en Android Auto tampoco
|
|
|
|
|
/// activaba ni desactivaba». Note what this function deliberately does NOT
|
|
|
|
|
/// do: gate the flip on [eqDisponible]. The flag always flips, because the
|
|
|
|
|
/// notification/car label is built from it — a tap that changed nothing at
|
|
|
|
|
/// all is exactly the "the button does nothing" symptom.
|
|
|
|
|
DecisionToggleEq decidirToggleEq({
|
|
|
|
|
required bool activoActual,
|
|
|
|
|
required bool eqDisponible,
|
|
|
|
|
}) => DecisionToggleEq(
|
|
|
|
|
nuevoActivo: !activoActual,
|
|
|
|
|
requiereLlamadaNativa: eqDisponible,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/// Translates a gain on the app's fixed ±12 dB slider scale to the range the
|
|
|
|
|
/// device's native equalizer actually reports
|
|
|
|
|
/// (`AndroidEqualizerParameters.min/maxDecibels`, itself derived from
|
|
|
|
|
/// `Equalizer.getBandLevelRange()`).
|
|
|
|
|
///
|
|
|
|
|
/// Top-level and pure so the mapping is testable without a device.
|
|
|
|
|
///
|
|
|
|
|
/// THE DEFECT THIS REPLACES, and the likely source of the reported «suena muy
|
|
|
|
|
/// alto»: the previous implementation normalised across the whole range and
|
|
|
|
|
/// interpolated linearly,
|
|
|
|
|
///
|
|
|
|
|
/// minDecibels + ((db + 12) / 24) * (maxDecibels - minDecibels)
|
|
|
|
|
///
|
|
|
|
|
/// which puts 0 dB at the MIDPOINT of the native range. That is only 0 when
|
|
|
|
|
/// the range is symmetric, and Android guarantees no such thing — the
|
|
|
|
|
/// Equalizer contract only promises a min/max pair. On a device reporting,
|
|
|
|
|
/// say, [-12, +19] dB, every band of a FLAT preset was pushed to +3.5 dB of
|
|
|
|
|
/// real boost: audibly louder, with the on/off button still reading "off"
|
|
|
|
|
/// and nothing in the UI to explain it.
|
|
|
|
|
///
|
|
|
|
|
/// The contract here instead: 0 dB is always exactly 0, and each side of the
|
|
|
|
|
/// scale is stretched independently against its own end of the native range,
|
|
|
|
|
/// so a cut can never become a boost. A range with no headroom on one side
|
|
|
|
|
/// (or none at all) collapses that side to 0 rather than inverting it.
|
|
|
|
|
double mapearGananciaNativa(
|
|
|
|
|
double db, {
|
|
|
|
|
required double minDecibels,
|
|
|
|
|
required double maxDecibels,
|
|
|
|
|
}) {
|
|
|
|
|
final limitado = db.clamp(-12.0, 12.0);
|
|
|
|
|
if (limitado == 0) return 0;
|
|
|
|
|
if (limitado > 0) {
|
|
|
|
|
// Only genuine headroom above unity counts as boost.
|
|
|
|
|
final techo = maxDecibels > 0 ? maxDecibels : 0.0;
|
|
|
|
|
return (limitado / 12.0) * techo;
|
|
|
|
|
}
|
|
|
|
|
final suelo = minDecibels < 0 ? minDecibels : 0.0;
|
|
|
|
|
return (limitado.abs() / 12.0) * suelo;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Advances to the NEXT factory preset after [actual] in [presets] order
|
|
|
|
|
/// (Design "EQ custom actions — cycling presets", item 4): wraps around
|
|
|
|
|
/// after the last one. When [actual] is not found in [presets] (e.g. a
|
|
|
|
@@ -381,9 +577,18 @@ List<MediaControl> controlesEcualizadorPersonalizados({
|
|
|
|
|
/// its shape. `servicio_audio_controles_notificacion_test.dart` used to
|
|
|
|
|
/// re-declare the list inline, which meant it stayed green while asserting a
|
|
|
|
|
/// shape `lib/` no longer produced — a guard that cannot see the thing it
|
|
|
|
|
/// guards. `PluriWaveAudioHandler` itself cannot be instantiated in a unit
|
|
|
|
|
/// test (a real `just_audio.AudioPlayer` needs platform MethodChannels), so
|
|
|
|
|
/// pulling the pure part out is the only way to test the real thing.
|
|
|
|
|
/// guards.
|
|
|
|
|
///
|
|
|
|
|
/// This doc used to add that `PluriWaveAudioHandler` "cannot be instantiated
|
|
|
|
|
/// in a unit test (a real `just_audio.AudioPlayer` needs platform
|
|
|
|
|
/// MethodChannels)". That is NOT true with just_audio 0.9.46:
|
|
|
|
|
/// `AudioPlayer`'s constructor resolves its platform lazily and only becomes
|
|
|
|
|
/// `_active` on a `setUrl`, so the handler constructs fine under
|
|
|
|
|
/// `flutter test` and `servicio_audio_eq_estado_unico_test.dart` drives its
|
|
|
|
|
/// real `customAction` dispatch. Only calls that reach the native effect stay
|
|
|
|
|
/// out of reach (they sit behind `_eqDisponible`, `false` off-device).
|
|
|
|
|
/// Extracting the pure part is still worth it — it is cheaper and states the
|
|
|
|
|
/// contract explicitly — but it is no longer the ONLY way.
|
|
|
|
|
///
|
|
|
|
|
/// ORDER MATTERS, and only for the car.
|
|
|
|
|
///
|
|
|
|
@@ -582,11 +787,18 @@ class ServicioAudio {
|
|
|
|
|
bool get ecualizadorDisponible => _handler.ecualizadorDisponible;
|
|
|
|
|
PresetEcualizador get presetActual => _handler.presetActual;
|
|
|
|
|
|
|
|
|
|
/// Forwards the handler's own on/off flag (eq-sync-superficies): a
|
|
|
|
|
/// car/notification toggle (`accionEqToggle`) mutates
|
|
|
|
|
/// `PluriWaveAudioHandler._ecualizadorActivo` directly, bypassing
|
|
|
|
|
/// [setEcualizadorActivo] entirely. [EstadoEcualizador] polls this getter
|
|
|
|
|
/// on every [estadoStream] tick to detect and resync that divergence.
|
|
|
|
|
/// Forwards the handler's own on/off flag, which since eq-estado-unico is
|
|
|
|
|
/// the flag's SINGLE in-memory owner: `EstadoEcualizador._activo` is a
|
|
|
|
|
/// display mirror of this getter and `ServicioEcualizador` is its durable
|
|
|
|
|
/// copy.
|
|
|
|
|
///
|
|
|
|
|
/// Corrects a stale claim that stood here: a car/notification toggle does
|
|
|
|
|
/// NOT bypass [setEcualizadorActivo]. `PluriWaveAudioHandler.customAction`
|
|
|
|
|
/// resolves `accionEqToggle` through `decidirToggleEq` and then calls
|
|
|
|
|
/// `setEcualizadorActivo` — the same entry point the phone settings screen
|
|
|
|
|
/// uses — so every surface shares one write path, and that path is what
|
|
|
|
|
/// persists the value. [EstadoEcualizador] still polls this getter on every
|
|
|
|
|
/// [estadoStream] tick, but only to keep its own display in sync.
|
|
|
|
|
bool get ecualizadorActivo => _handler.ecualizadorActivo;
|
|
|
|
|
|
|
|
|
|
Future<void> aplicarPreset(PresetEcualizador preset) =>
|
|
|
|
@@ -702,13 +914,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|
|
|
|
/// `BehaviorSubject` per id, created lazily on first subscription;
|
|
|
|
|
/// [notificarHijosCambiaron] pushes a fresh (empty, content-agnostic)
|
|
|
|
|
/// value to trigger the platform notification for that id.
|
|
|
|
|
///
|
|
|
|
|
/// SIN semilla (fix/android-auto-musica-local, item 5). Antes se creaba
|
|
|
|
|
/// con `.seeded(<String, dynamic>{})`, y un `BehaviorSubject` reenvía su
|
|
|
|
|
/// valor actual a cada nuevo suscriptor: el listener interno de
|
|
|
|
|
/// `audio_service` se suscribe la primera vez que el head unit navega un
|
|
|
|
|
/// id, recibía esa semilla al instante y la reenviaba como
|
|
|
|
|
/// `notifyChildrenChanged` — o sea, el primer browse de CADA id disparaba
|
|
|
|
|
/// un `getChildren` extra que nadie pidió. En la raíz eso era un segundo
|
|
|
|
|
/// round trip de permisos por `pluriwave/file_actions`, justo en la ruta
|
|
|
|
|
/// que ya estaba fallando en el motor sin Activity. Sin semilla no hay
|
|
|
|
|
/// nada que reenviar y la invalidación explícita sigue igual.
|
|
|
|
|
final _childrenSubjects = <String, BehaviorSubject<Map<String, dynamic>>>{};
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
ValueStream<Map<String, dynamic>> subscribeToChildren(String parentMediaId) =>
|
|
|
|
|
_childrenSubjects.putIfAbsent(
|
|
|
|
|
parentMediaId,
|
|
|
|
|
() => BehaviorSubject<Map<String, dynamic>>.seeded(<String, dynamic>{}),
|
|
|
|
|
BehaviorSubject<Map<String, dynamic>>.new,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/// Invalidates a head unit's cached browse listing for [parentMediaId]
|
|
|
|
@@ -719,6 +942,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|
|
|
|
_childrenSubjects[parentMediaId]?.add(<String, dynamic>{});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// True once a head unit has subscribed to at least one browse id, i.e.
|
|
|
|
|
/// once [notificarHijosCambiaron] can actually reach the car. Read through
|
|
|
|
|
/// the module-level [hayCocheSuscritoAlArbol]; see its doc for why the
|
|
|
|
|
/// browse-tree invalidation is gated on it.
|
|
|
|
|
bool get hayCocheSuscrito => _childrenSubjects.isNotEmpty;
|
|
|
|
|
|
|
|
|
|
/// True while the handler is inside the reconnect window. [ServicioAudio]
|
|
|
|
|
/// maps it to [EstadoReproduccion.reconectando] so the UI shows a loading
|
|
|
|
|
/// indicator instead of an error during retries (S7-R3).
|
|
|
|
@@ -728,9 +957,42 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|
|
|
|
AndroidEqualizer? get ecualizador => _eq;
|
|
|
|
|
bool _eqDisponible = false;
|
|
|
|
|
bool get ecualizadorDisponible => _eqDisponible;
|
|
|
|
|
bool _ecualizadorActivo = true;
|
|
|
|
|
|
|
|
|
|
/// The equalizer's on/off state — and, since eq-estado-unico, its SINGLE
|
|
|
|
|
/// in-memory owner. `EstadoEcualizador._activo` is now a pure display
|
|
|
|
|
/// mirror of this field, and `ServicioEcualizador` is its durable copy.
|
|
|
|
|
///
|
|
|
|
|
/// It used to be an unconditional `true`, which is exactly why a headless
|
|
|
|
|
/// Android Auto engine played with the equalizer on while both the phone
|
|
|
|
|
/// UI and disk said off. It now starts from whatever the last disk read
|
|
|
|
|
/// produced ([_eqActivoPersistido]); [registrarHandler] then seeds it
|
|
|
|
|
/// again from the read port, which is the authoritative path.
|
|
|
|
|
bool _ecualizadorActivo = estadoEqInicial(persistido: _eqActivoPersistido);
|
|
|
|
|
bool get ecualizadorActivo => _ecualizadorActivo;
|
|
|
|
|
|
|
|
|
|
/// Write port for [_ecualizadorActivo] (eq-estado-unico item B). Injected
|
|
|
|
|
/// by [registrarHandler] so a car/notification toggle is persisted even
|
|
|
|
|
/// when no `EstadoEcualizador` has ever been built — which is precisely
|
|
|
|
|
/// the headless-bind case where the divergence used to be created.
|
|
|
|
|
GuardarEqActivoPersistido? _persistirEqActivo;
|
|
|
|
|
|
|
|
|
|
/// See [_persistirEqActivo]. Accepts `null` to clear the port (the default
|
|
|
|
|
/// for every caller that has no disk).
|
|
|
|
|
void registrarPersistenciaEq(GuardarEqActivoPersistido? guardar) {
|
|
|
|
|
_persistirEqActivo = guardar;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The player's live position, used to keep `updatePosition` honest on
|
|
|
|
|
/// every `playbackState` push. Exposed so tests can assert the re-push
|
|
|
|
|
/// without reaching into the private player.
|
|
|
|
|
Duration get posicionActual => _player.position;
|
|
|
|
|
|
|
|
|
|
/// True while the platform player is attached, i.e. while `just_audio`
|
|
|
|
|
/// actually forwards `AudioEffect.setEnabled` to the device
|
|
|
|
|
/// (`just_audio.dart:3842-3848` gates it on `_player._active`). Tracked so
|
|
|
|
|
/// [debeReasertarEcualizadorNativo] can spot the idle -> active edge.
|
|
|
|
|
bool _reproductorActivo = false;
|
|
|
|
|
|
|
|
|
|
PresetEcualizador _presetActual = PresetEcualizador.flat;
|
|
|
|
|
PresetEcualizador get presetActual => _presetActual;
|
|
|
|
|
int? get androidAudioSessionId => _androidAudioSessionId;
|
|
|
|
@@ -758,94 +1020,123 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _conectarStreamsPlayer() {
|
|
|
|
|
_estadoPlayerSub = _player.playerStateStream.listen((state) {
|
|
|
|
|
final playing = state.playing;
|
|
|
|
|
final proc = state.processingState;
|
|
|
|
|
// First line of the listener (Design ADR-3, Phase 3 task 3.3):
|
|
|
|
|
// double-gated on `completed` + an active local queue, so this is a
|
|
|
|
|
// no-op for radio (which never emits `completed`) and for
|
|
|
|
|
// single-track local playback (which never sets `_colaLocal`).
|
|
|
|
|
_manejarFinPista(proc);
|
|
|
|
|
if (playing && proc == ProcessingState.ready) {
|
|
|
|
|
// Successful (re)connection: reset the backoff so the next stall
|
|
|
|
|
// starts over, and leave the reconnect window (S7-R7).
|
|
|
|
|
_reconexion.restablecer();
|
|
|
|
|
_reconectando = false;
|
|
|
|
|
// Local queue (Design ADR-3): the next queued track reached a
|
|
|
|
|
// stable playing state — clear the re-entry latch so a LATER
|
|
|
|
|
// completion can advance again. A no-op for radio, which never
|
|
|
|
|
// sets `_avanzandoCola`.
|
|
|
|
|
_avanzandoCola = false;
|
|
|
|
|
}
|
|
|
|
|
// Local queue transport (Design "Transport wiring"): skip controls
|
|
|
|
|
// are only offered while a queue is active — when `_colaLocal` is
|
|
|
|
|
// `null` this list/set/index is byte-identical to the pre-change
|
|
|
|
|
// radio behavior (regression guard).
|
|
|
|
|
final colaActiva = _colaLocal != null;
|
|
|
|
|
playbackState.add(
|
|
|
|
|
playbackState.value.copyWith(
|
|
|
|
|
controls: _controlesTransporte(
|
|
|
|
|
colaActiva: colaActiva,
|
|
|
|
|
playing: playing,
|
|
|
|
|
),
|
|
|
|
|
// Android for Cars, "Enable playback control": «Android Auto and
|
|
|
|
|
// AAOS display playback controls based on the actions that are
|
|
|
|
|
// enabled in the PlaybackStateCompat object. By default, your app
|
|
|
|
|
// must support the following actions: ACTION_PLAY, ACTION_PAUSE,
|
|
|
|
|
// ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH.»
|
|
|
|
|
//
|
|
|
|
|
// This set had carried only `seek` + `stop` since the very first
|
|
|
|
|
// commit, so the required transport actions were never advertised.
|
|
|
|
|
// The car got away with it for a long time — but Android Auto is a
|
|
|
|
|
// separate app that updates itself, so a tolerance it used to have
|
|
|
|
|
// can disappear without a single line changing on our side. That
|
|
|
|
|
// matches the report exactly: "it used to work, and in the latest
|
|
|
|
|
// versions it doesn't", with no audio commit in between that could
|
|
|
|
|
// explain it.
|
|
|
|
|
//
|
|
|
|
|
// The phone notification never depended on any of this: it builds
|
|
|
|
|
// its play/pause button from `controls`, which is why the symptom
|
|
|
|
|
// is car-only.
|
|
|
|
|
systemActions: {
|
|
|
|
|
MediaAction.play,
|
|
|
|
|
MediaAction.pause,
|
|
|
|
|
MediaAction.playPause,
|
|
|
|
|
MediaAction.stop,
|
|
|
|
|
MediaAction.playFromMediaId,
|
|
|
|
|
MediaAction.playFromSearch,
|
|
|
|
|
MediaAction.seek,
|
|
|
|
|
// Previous/next are advertised ALWAYS now, not only for a local
|
|
|
|
|
// queue. Android Auto reserves those two slots and only hands the
|
|
|
|
|
// space to custom actions when the app declares no support, so
|
|
|
|
|
// this is what puts prev/next on the car's transport row -- and
|
|
|
|
|
// `skipToNext`/`skipToPrevious` fall back to station-to-station
|
|
|
|
|
// skipping when there is no queue, so neither button is inert.
|
|
|
|
|
MediaAction.skipToPrevious,
|
|
|
|
|
MediaAction.skipToNext,
|
|
|
|
|
},
|
|
|
|
|
androidCompactActionIndices: [colaActiva ? 1 : 0],
|
|
|
|
|
processingState: mapearEstadoProceso(
|
|
|
|
|
proc,
|
|
|
|
|
cambiandoFuente: _cambiandoFuente,
|
|
|
|
|
),
|
|
|
|
|
/// The `playerStateStream` listener's whole body, as a named method.
|
|
|
|
|
///
|
|
|
|
|
/// Extracted verbatim so a test can drive a real player-state transition
|
|
|
|
|
/// through the REAL handler. It used to be an anonymous closure, which is
|
|
|
|
|
/// why the equalizer's idle -> active re-assert below shipped with
|
|
|
|
|
/// producer-only coverage: [debeReasertarEcualizadorNativo] had five tests
|
|
|
|
|
/// and not one of them could reach this wiring, so deleting the re-assert
|
|
|
|
|
/// block left the suite green. The only thing left outside a test's reach
|
|
|
|
|
/// is the one-line `.listen(manejarEstadoPlayer)` subscription in
|
|
|
|
|
/// [_conectarStreamsPlayer].
|
|
|
|
|
@visibleForTesting
|
|
|
|
|
void manejarEstadoPlayer(PlayerState state) {
|
|
|
|
|
final playing = state.playing;
|
|
|
|
|
final proc = state.processingState;
|
|
|
|
|
// First line of the listener (Design ADR-3, Phase 3 task 3.3):
|
|
|
|
|
// double-gated on `completed` + an active local queue, so this is a
|
|
|
|
|
// no-op for radio (which never emits `completed`) and for
|
|
|
|
|
// single-track local playback (which never sets `_colaLocal`).
|
|
|
|
|
_manejarFinPista(proc);
|
|
|
|
|
// eq-estado-unico item D: `AudioEffect.setEnabled` is a no-op while
|
|
|
|
|
// the platform player is detached, so any toggle made while stopped
|
|
|
|
|
// never landed natively. Re-assert the value we own on the idle ->
|
|
|
|
|
// active edge. See [debeReasertarEcualizadorNativo].
|
|
|
|
|
if (debeReasertarEcualizadorNativo(
|
|
|
|
|
estado: proc,
|
|
|
|
|
reproductorActivoAntes: _reproductorActivo,
|
|
|
|
|
eqDisponible: _eqDisponible,
|
|
|
|
|
)) {
|
|
|
|
|
unawaited(_reasertarEcualizadorNativo());
|
|
|
|
|
}
|
|
|
|
|
// Turns a stream of many events into a single idle -> active EDGE: the
|
|
|
|
|
// re-assert above fires once per activation, not on every event.
|
|
|
|
|
_reproductorActivo = proc != ProcessingState.idle;
|
|
|
|
|
if (playing && proc == ProcessingState.ready) {
|
|
|
|
|
// Successful (re)connection: reset the backoff so the next stall
|
|
|
|
|
// starts over, and leave the reconnect window (S7-R7).
|
|
|
|
|
_reconexion.restablecer();
|
|
|
|
|
_reconectando = false;
|
|
|
|
|
// Local queue (Design ADR-3): the next queued track reached a
|
|
|
|
|
// stable playing state — clear the re-entry latch so a LATER
|
|
|
|
|
// completion can advance again. A no-op for radio, which never
|
|
|
|
|
// sets `_avanzandoCola`.
|
|
|
|
|
_avanzandoCola = false;
|
|
|
|
|
}
|
|
|
|
|
// Local queue transport (Design "Transport wiring"): skip controls
|
|
|
|
|
// are only offered while a queue is active — when `_colaLocal` is
|
|
|
|
|
// `null` this list/set/index is byte-identical to the pre-change
|
|
|
|
|
// radio behavior (regression guard).
|
|
|
|
|
final colaActiva = _colaLocal != null;
|
|
|
|
|
playbackState.add(
|
|
|
|
|
playbackState.value.copyWith(
|
|
|
|
|
controls: _controlesTransporte(
|
|
|
|
|
colaActiva: colaActiva,
|
|
|
|
|
playing: playing,
|
|
|
|
|
// Reported: in Android Auto the progress bar and the time labels of
|
|
|
|
|
// a local track never move. `updatePosition` was NEVER set anywhere
|
|
|
|
|
// in this file, so it stayed at its `Duration.zero` default while
|
|
|
|
|
// `copyWith` refreshed `updateTime` to now on every push
|
|
|
|
|
// (audio_service.dart:411-413, :256). A client extrapolates
|
|
|
|
|
// `updatePosition + (now - updateTime) * speed`, so it was told
|
|
|
|
|
// "position 0, as of right now" over and over — a bar pinned at the
|
|
|
|
|
// start. The phone UI never noticed because it reads
|
|
|
|
|
// `_player.positionStream` directly.
|
|
|
|
|
updatePosition: _player.position,
|
|
|
|
|
bufferedPosition: _player.bufferedPosition,
|
|
|
|
|
speed: _player.speed,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
_trazarEstadoPublicado();
|
|
|
|
|
});
|
|
|
|
|
// Android for Cars, "Enable playback control": «Android Auto and
|
|
|
|
|
// AAOS display playback controls based on the actions that are
|
|
|
|
|
// enabled in the PlaybackStateCompat object. By default, your app
|
|
|
|
|
// must support the following actions: ACTION_PLAY, ACTION_PAUSE,
|
|
|
|
|
// ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH.»
|
|
|
|
|
//
|
|
|
|
|
// This set had carried only `seek` + `stop` since the very first
|
|
|
|
|
// commit, so the required transport actions were never advertised.
|
|
|
|
|
// The car got away with it for a long time — but Android Auto is a
|
|
|
|
|
// separate app that updates itself, so a tolerance it used to have
|
|
|
|
|
// can disappear without a single line changing on our side. That
|
|
|
|
|
// matches the report exactly: "it used to work, and in the latest
|
|
|
|
|
// versions it doesn't", with no audio commit in between that could
|
|
|
|
|
// explain it.
|
|
|
|
|
//
|
|
|
|
|
// The phone notification never depended on any of this: it builds
|
|
|
|
|
// its play/pause button from `controls`, which is why the symptom
|
|
|
|
|
// is car-only.
|
|
|
|
|
systemActions: {
|
|
|
|
|
MediaAction.play,
|
|
|
|
|
MediaAction.pause,
|
|
|
|
|
MediaAction.playPause,
|
|
|
|
|
MediaAction.stop,
|
|
|
|
|
MediaAction.playFromMediaId,
|
|
|
|
|
MediaAction.playFromSearch,
|
|
|
|
|
MediaAction.seek,
|
|
|
|
|
// Previous/next are advertised ALWAYS now, not only for a local
|
|
|
|
|
// queue. Android Auto reserves those two slots and only hands the
|
|
|
|
|
// space to custom actions when the app declares no support, so
|
|
|
|
|
// this is what puts prev/next on the car's transport row -- and
|
|
|
|
|
// `skipToNext`/`skipToPrevious` fall back to station-to-station
|
|
|
|
|
// skipping when there is no queue, so neither button is inert.
|
|
|
|
|
MediaAction.skipToPrevious,
|
|
|
|
|
MediaAction.skipToNext,
|
|
|
|
|
},
|
|
|
|
|
androidCompactActionIndices: [colaActiva ? 1 : 0],
|
|
|
|
|
processingState: mapearEstadoProceso(
|
|
|
|
|
proc,
|
|
|
|
|
cambiandoFuente: _cambiandoFuente,
|
|
|
|
|
),
|
|
|
|
|
playing: playing,
|
|
|
|
|
// Reported: in Android Auto the progress bar and the time labels of
|
|
|
|
|
// a local track never move. `updatePosition` was NEVER set anywhere
|
|
|
|
|
// in this file, so it stayed at its `Duration.zero` default while
|
|
|
|
|
// `copyWith` refreshed `updateTime` to now on every push
|
|
|
|
|
// (audio_service.dart:411-413, :256). A client extrapolates
|
|
|
|
|
// `updatePosition + (now - updateTime) * speed`, so it was told
|
|
|
|
|
// "position 0, as of right now" over and over — a bar pinned at the
|
|
|
|
|
// start. The phone UI never noticed because it reads
|
|
|
|
|
// `_player.positionStream` directly.
|
|
|
|
|
updatePosition: _player.position,
|
|
|
|
|
bufferedPosition: _player.bufferedPosition,
|
|
|
|
|
speed: _player.speed,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
_trazarEstadoPublicado();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _conectarStreamsPlayer() {
|
|
|
|
|
_estadoPlayerSub = _player.playerStateStream.listen(
|
|
|
|
|
manejarEstadoPlayer,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
_bufferedSub = _player.bufferedPositionStream.listen((pos) {
|
|
|
|
|
playbackState.add(
|
|
|
|
@@ -964,10 +1255,31 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|
|
|
|
colaActiva: _colaLocal != null,
|
|
|
|
|
playing: playbackState.value.playing,
|
|
|
|
|
),
|
|
|
|
|
// Must ride along, exactly as in the two sibling emissions in
|
|
|
|
|
// `_conectarStreamsPlayer`: `copyWith` stamps a fresh `updateTime`
|
|
|
|
|
// but keeps the OLD `updatePosition`, so a push without it tells the
|
|
|
|
|
// client "you are at <stale position>, as of right now". Every
|
|
|
|
|
// equalizer tap therefore snapped the car's progress bar backwards
|
|
|
|
|
// to wherever it stood at the last real player event.
|
|
|
|
|
updatePosition: _player.position,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Re-states [_ecualizadorActivo] (and the current preset's gains) on the
|
|
|
|
|
/// native effect now that the platform player is attached again
|
|
|
|
|
/// (eq-estado-unico item D). Delegates to [_activarEcualizador], which is
|
|
|
|
|
/// already idempotent and already re-asserts the CURRENT value rather than
|
|
|
|
|
/// forcing the equalizer on.
|
|
|
|
|
Future<void> _reasertarEcualizadorNativo() async {
|
|
|
|
|
_reasercionesEcualizador++;
|
|
|
|
|
debugPrint(
|
|
|
|
|
'[PluriWave][ServicioAudio] reasertando EQ nativo '
|
|
|
|
|
'activo=$_ecualizadorActivo',
|
|
|
|
|
);
|
|
|
|
|
await _activarEcualizador();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Gestiona cualquier error de reproducción de ExoPlayer.
|
|
|
|
|
///
|
|
|
|
|
/// Network-class failures while the user still intends to play enter the
|
|
|
|
@@ -1360,6 +1672,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|
|
|
|
|
|
|
|
|
_eq = AndroidEqualizer();
|
|
|
|
|
_eqDisponible = false;
|
|
|
|
|
// Resets alongside its siblings above: the fresh player starts detached,
|
|
|
|
|
// so the next non-idle event is a genuine idle -> active edge that
|
|
|
|
|
// [debeReasertarEcualizadorNativo] must see. A value stuck at `true`
|
|
|
|
|
// across the rebuild would swallow exactly the re-assert this exists for.
|
|
|
|
|
_reproductorActivo = false;
|
|
|
|
|
_androidAudioSessionId = null;
|
|
|
|
|
_ultimaSessionIdEq = null;
|
|
|
|
|
_player = _crearPlayer();
|
|
|
|
@@ -1384,6 +1701,19 @@ 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.
|
|
|
|
|
// `debugPrint` (never `dart:developer`'s `log`) so it reaches logcat in
|
|
|
|
|
// the release build, which is the only one that ever runs in a car:
|
|
|
|
|
//
|
|
|
|
|
// adb logcat | grep PluriWave
|
|
|
|
|
debugPrint(
|
|
|
|
|
'[PluriWave][ServicioAudio] eq rango bandas=${params.bands.length} '
|
|
|
|
|
'minDecibels=${params.minDecibels} maxDecibels=${params.maxDecibels} '
|
|
|
|
|
'activo=$_ecualizadorActivo preset=${_presetActual.nombre}',
|
|
|
|
|
);
|
|
|
|
|
if (_eqDisponible) {
|
|
|
|
|
await _eq.setEnabled(_ecualizadorActivo);
|
|
|
|
|
await aplicarPreset(_presetActual);
|
|
|
|
@@ -1411,6 +1741,58 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|
|
|
|
required bool eqDisponible,
|
|
|
|
|
}) => sessionId != null && sessionId != ultimaSessionIdEq && eqDisponible;
|
|
|
|
|
|
|
|
|
|
/// Pure decision for re-asserting the on/off state on the NATIVE effect
|
|
|
|
|
/// when the platform player becomes active again (eq-estado-unico item D).
|
|
|
|
|
/// No side effects.
|
|
|
|
|
///
|
|
|
|
|
/// Why it is needed: `just_audio`'s `AudioEffect.setEnabled`
|
|
|
|
|
/// (`just_audio.dart:3842-3848`) only reaches the platform while
|
|
|
|
|
/// `_player._active` is true. After a `stop()` — or any transition to
|
|
|
|
|
/// `idle` — the Dart-side intent is updated but the native effect is not.
|
|
|
|
|
/// A user who turns the equalizer off while stopped, then presses play,
|
|
|
|
|
/// would get audio that is still equalized with the button reading "off".
|
|
|
|
|
///
|
|
|
|
|
/// The native effect is treated as WRITE-ONLY throughout: `just_audio`
|
|
|
|
|
/// exposes no read-back of `Equalizer.getEnabled()`, so this never
|
|
|
|
|
/// compares against the device — it simply re-states the value the app
|
|
|
|
|
/// already owns, which is idempotent and cheap.
|
|
|
|
|
///
|
|
|
|
|
/// [reproductorActivoAntes] is the tracked state BEFORE [estado] arrived,
|
|
|
|
|
/// so only the idle -> active edge fires; a player already active does not
|
|
|
|
|
/// re-assert on every one of its many events.
|
|
|
|
|
@visibleForTesting
|
|
|
|
|
static bool debeReasertarEcualizadorNativo({
|
|
|
|
|
required ProcessingState estado,
|
|
|
|
|
required bool reproductorActivoAntes,
|
|
|
|
|
required bool eqDisponible,
|
|
|
|
|
}) =>
|
|
|
|
|
eqDisponible &&
|
|
|
|
|
!reproductorActivoAntes &&
|
|
|
|
|
estado != ProcessingState.idle;
|
|
|
|
|
|
|
|
|
|
/// Forces [_eqDisponible] for a test.
|
|
|
|
|
///
|
|
|
|
|
/// `_eqDisponible` is only ever set from `AndroidEqualizer.parameters`
|
|
|
|
|
/// (see [_activarEcualizador]), whose future only completes on a real
|
|
|
|
|
/// device, so off-device it is permanently `false` — and every EQ path
|
|
|
|
|
/// worth testing is gated on it. Without this seam
|
|
|
|
|
/// [manejarEstadoPlayer]'s re-assert can only ever be exercised on its
|
|
|
|
|
/// false branch.
|
|
|
|
|
@visibleForTesting
|
|
|
|
|
void simularEcualizadorDisponible(bool disponible) {
|
|
|
|
|
_eqDisponible = disponible;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// How many times [_reasertarEcualizadorNativo] has actually run.
|
|
|
|
|
///
|
|
|
|
|
/// The native call it makes is unobservable off-device (see
|
|
|
|
|
/// [simularEcualizadorDisponible]), so this counter is the only evidence a
|
|
|
|
|
/// test can assert on that the re-assert HAPPENED, rather than that the
|
|
|
|
|
/// predicate would have said yes.
|
|
|
|
|
@visibleForTesting
|
|
|
|
|
int get reasercionesEcualizador => _reasercionesEcualizador;
|
|
|
|
|
int _reasercionesEcualizador = 0;
|
|
|
|
|
|
|
|
|
|
/// Aplica un preset al ecualizador nativo Android.
|
|
|
|
|
Future<void> aplicarPreset(PresetEcualizador preset) async {
|
|
|
|
|
_presetActual = preset;
|
|
|
|
@@ -1425,7 +1807,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|
|
|
|
i++
|
|
|
|
|
) {
|
|
|
|
|
await params.bands[i].setGain(
|
|
|
|
|
_mapearGananciaNativa(
|
|
|
|
|
mapearGananciaNativa(
|
|
|
|
|
preset.bandas[i],
|
|
|
|
|
minDecibels: params.minDecibels,
|
|
|
|
|
maxDecibels: params.maxDecibels,
|
|
|
|
@@ -1453,7 +1835,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|
|
|
|
final params = await _eq.parameters;
|
|
|
|
|
if (index < params.bands.length) {
|
|
|
|
|
await params.bands[index].setGain(
|
|
|
|
|
_mapearGananciaNativa(
|
|
|
|
|
mapearGananciaNativa(
|
|
|
|
|
db,
|
|
|
|
|
minDecibels: params.minDecibels,
|
|
|
|
|
maxDecibels: params.maxDecibels,
|
|
|
|
@@ -1463,16 +1845,23 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
double _mapearGananciaNativa(
|
|
|
|
|
double db, {
|
|
|
|
|
required double minDecibels,
|
|
|
|
|
required double maxDecibels,
|
|
|
|
|
}) {
|
|
|
|
|
final normalizado = ((db.clamp(-12.0, 12.0) + 12.0) / 24.0).clamp(0.0, 1.0);
|
|
|
|
|
return minDecibels + (normalizado * (maxDecibels - minDecibels));
|
|
|
|
|
}
|
|
|
|
|
/// Sets the equalizer on/off state AND persists it — the single entry
|
|
|
|
|
/// point every surface goes through (phone settings via
|
|
|
|
|
/// `EstadoEcualizador`, the notification, and the car's [accionEqToggle]).
|
|
|
|
|
Future<void> setEcualizadorActivo(bool activo) =>
|
|
|
|
|
_aplicarEcualizadorActivo(activo, persistir: true);
|
|
|
|
|
|
|
|
|
|
Future<void> setEcualizadorActivo(bool activo) async {
|
|
|
|
|
/// Adopts a value that came FROM disk (eq-estado-unico item A). Identical
|
|
|
|
|
/// to [setEcualizadorActivo] except that it does not write back — seeding
|
|
|
|
|
/// is a read, and echoing it to disk would only add a pointless write on
|
|
|
|
|
/// every engine start.
|
|
|
|
|
Future<void> sembrarEcualizadorActivo(bool activo) =>
|
|
|
|
|
_aplicarEcualizadorActivo(activo, persistir: false);
|
|
|
|
|
|
|
|
|
|
Future<void> _aplicarEcualizadorActivo(
|
|
|
|
|
bool activo, {
|
|
|
|
|
required bool persistir,
|
|
|
|
|
}) async {
|
|
|
|
|
_ecualizadorActivo = activo;
|
|
|
|
|
if (_eqDisponible) {
|
|
|
|
|
try {
|
|
|
|
@@ -1486,6 +1875,25 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|
|
|
|
// of WHO toggled it (a car customAction tap or the phone settings
|
|
|
|
|
// screen via EstadoEcualizador).
|
|
|
|
|
_actualizarControlesEq();
|
|
|
|
|
if (!persistir) return;
|
|
|
|
|
_eqActivoPersistido = activo;
|
|
|
|
|
final guardar = _persistirEqActivo;
|
|
|
|
|
if (guardar == null) return;
|
|
|
|
|
// eq-estado-unico item B: the handler owns this write now. It used to
|
|
|
|
|
// be `EstadoEcualizador._resincronizarConHandler`'s job, which meant a
|
|
|
|
|
// toggle made in the car or from the notification was only saved if a
|
|
|
|
|
// phone UI object happened to exist — on a headless Android Auto engine
|
|
|
|
|
// it never did, so the car toggle was silently lost on every restart.
|
|
|
|
|
//
|
|
|
|
|
// Failures are swallowed on purpose: a full disk must not turn the
|
|
|
|
|
// equalizer button into a crash.
|
|
|
|
|
try {
|
|
|
|
|
await guardar(activo);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
debugPrint(
|
|
|
|
|
'[PluriWave][ServicioAudio] no se pudo persistir el estado EQ: $e',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> setVolumen(double vol) async {
|
|
|
|
@@ -1717,7 +2125,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|
|
|
|
]) async {
|
|
|
|
|
switch (name) {
|
|
|
|
|
case accionEqToggle:
|
|
|
|
|
await setEcualizadorActivo(!_ecualizadorActivo);
|
|
|
|
|
final decision = decidirToggleEq(
|
|
|
|
|
activoActual: _ecualizadorActivo,
|
|
|
|
|
eqDisponible: _eqDisponible,
|
|
|
|
|
);
|
|
|
|
|
debugPrint(
|
|
|
|
|
'[PluriWave][ServicioAudio] customAction $name -> '
|
|
|
|
|
'activo=${decision.nuevoActivo} '
|
|
|
|
|
'nativo=${decision.requiereLlamadaNativa}',
|
|
|
|
|
);
|
|
|
|
|
await setEcualizadorActivo(decision.nuevoActivo);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1777,8 +2194,23 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|
|
|
|
if (bloqueada != null) return bloqueada;
|
|
|
|
|
final fuenteLocal = _fuenteMusicaLocalGlobal;
|
|
|
|
|
if (parentMediaId == AudioService.browsableRootId) {
|
|
|
|
|
// fix/android-auto-musica-local: la RAÍZ ya no se decide con el
|
|
|
|
|
// round trip de permisos. Ese round trip viaja por
|
|
|
|
|
// `pluriwave/file_actions`, cuyo handler nativo solo se registra en
|
|
|
|
|
// `MainActivity.configureFlutterEngine` — en el motor headless que
|
|
|
|
|
// Android Auto levanta sin Activity no existe, la llamada lanzaba
|
|
|
|
|
// `MissingPluginException` y el nodo desaparecía del árbol. Y como
|
|
|
|
|
// el head unit CACHEA la raíz, seguía desaparecido toda la sesión.
|
|
|
|
|
//
|
|
|
|
|
// Ahora solo `noConfigurada` (sin URI persistida, o permiso
|
|
|
|
|
// revocado confirmado por el nativo) oculta el nodo;
|
|
|
|
|
// `canalNoDisponible` lo mantiene, y es el SUBÁRBOL quien explica
|
|
|
|
|
// el problema (`hijosMusicaLocal`) en vez de dejar una carpeta
|
|
|
|
|
// vacía.
|
|
|
|
|
final incluirMusicaLocal =
|
|
|
|
|
fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada();
|
|
|
|
|
fuenteLocal != null &&
|
|
|
|
|
await fuenteLocal.estadoCarpeta() !=
|
|
|
|
|
EstadoCarpetaLocal.noConfigurada;
|
|
|
|
|
return constructor.raiz(
|
|
|
|
|
incluirMusicaLocal: incluirMusicaLocal,
|
|
|
|
|
premium: premium,
|
|
|
|
|