ExoPlayer assigns a new audio session id after transient audio-focus interruptions (navigation prompts, radar warnings), leaving the AndroidEqualizer attached to the dead session so playback resumed without equalization until the next station switch. The session-id listener now detects genuine rotations through a dedicated guard and re-activates the equalizer with the current preset, gated on EQ availability to stay clear of player teardown/rebuild.
9.0 KiB
Design: EQ Re-Apply After Audio-Focus Ducking
Technical Approach
Extend the existing _androidAudioSessionIdSub listener in PluriWaveAudioHandler
(lib/servicios/servicio_audio.dart:258-265) so that, after it broadcasts a new
native session id, it re-attaches the equalizer and re-pushes the CURRENT gains
when the id genuinely rotated mid-playback. The fix is a single conditional call
to the already-idempotent _activarEcualizador() choke-point, guarded by a
dedicated change-guard field and the _eqDisponible lifecycle flag. Mirrors the
proven in-repo pattern VisualizadorAudio._onSessionId
(lib/widgets/visualizador_audio.dart:82-88). Entirely service-layer; no
ServicioAudioSession, EstadoEcualizador, or Kotlin changes. Realizes spec
eq-audiofocus-reapply.
Architecture Decisions
Decision: Where the re-apply logic lives
| Option | Tradeoff | Decision |
|---|---|---|
New listener on androidAudioSessionIdStream |
Duplicate subscription lifecycle, extra teardown in _recrearPlayer/onTaskRemoved |
Rejected |
Hook into ServicioAudioSession.interruptionEventStream |
Couples EQ to app-focus events, not the actual native session signal; misses non-focus rotations | Rejected |
Add conditional inside the EXISTING _androidAudioSessionIdSub listener |
Zero new subscriptions; reuses teardown already in place; precise native signal | Chosen |
Rationale: The listener already fires on every native session id and already
owns its cancel path in _recrearPlayer() (L492) and onTaskRemoved() (L671).
Adding one branch there is the smallest, lowest-risk surface and uses the most
precise trigger (the session id itself, per the just_audio internals traced in
exploration).
Decision: Change-guard field separate from the broadcast field
| Option | Tradeoff | Decision |
|---|---|---|
Reuse _androidAudioSessionId as the guard |
Guard would need to read the field AFTER it is overwritten for broadcast; entangles EQ semantics with external-consumer broadcast state | Rejected |
New private int? _ultimaSessionIdEq dedicated to the EQ guard |
One extra field; keeps broadcast semantics untouched for ServicioAudio/VisualizadorAudio consumers |
Chosen |
Rationale: Per proposal note, _androidAudioSessionId must keep pure
broadcast semantics so external consumers are unaffected. A dedicated
_ultimaSessionIdEq lets the EQ compare-and-swap independently. It is reset to
null inside _recrearPlayer() alongside _androidAudioSessionId so a rebuilt
player's first id is treated as fresh, not a rotation.
Decision: Re-apply call target and current-gains source
| Option | Tradeoff | Decision |
|---|---|---|
aplicarPreset(_presetActual) only |
Skips _eq.parameters re-probe / setEnabled; assumes params already valid on the new session |
Rejected as sole call |
_activarEcualizador() |
Re-probes _eq.parameters, re-sets enabled state, then calls aplicarPreset(_presetActual) |
Chosen |
Rationale: _activarEcualizador() (L526) is the exact activate/attach
choke-point used by the working station-switch path; on a rotated session the
native effect must be re-probed and re-enabled before gains land, which
aplicarPreset alone does not do. Both paths read gains from the single source
of truth _presetActual (field L194), which is also mutated by aplicarPreset
(L541), setBanda (L568), and setEcualizadorActivo (L600) — so re-apply always
uses LIVE state, never a stale snapshot. No gain value is captured or copied at
subscribe time.
Data Flow
Another app ducks/interrupts
│
ExoPlayer rotates native session id (issue #5302)
│
_player.androidAudioSessionIdStream emits new id
│
_androidAudioSessionIdSub listener:
1. _androidAudioSessionId = id (broadcast state, unchanged)
2. controller.add(id) (external consumers: unchanged)
3. re-apply gate ↓
│
id == null ? ──yes──► return (no-op)
│ no
id == _ultimaSessionIdEq ? ──yes──► return (redundant, guard)
│ no
_eqDisponible == false ? ──yes──► return (mid-teardown / not attached)
│ no
_ultimaSessionIdEq = id
unawaited(_activarEcualizador()) ──► re-probe + setEnabled + aplicarPreset(_presetActual)
│
Native EQ re-attached to new session → music stays equalized
Race Analysis (_recrearPlayer, L488-508)
Teardown order makes the guard safe. _recrearPlayer():
await _androidAudioSessionIdSub?.cancel()(L492) — old listener silenced._eq = AndroidEqualizer()(L502) — fresh effect, empty params._eqDisponible = false(L503) — gate closes BEFORE any new id can arrive._androidAudioSessionId = null(L504) + reset_ultimaSessionIdEq = null._player = _crearPlayer()(L505) then_conectarStreamsPlayer()(L507) re-subscribes.
Because _eqDisponible is set false (step 3) before the new subscription
exists (step 5), any id the rebuilt player emits is dropped by the gate until the
station-switch path's own _activarEcualizador() (L449) flips _eqDisponible
back to true. Once true, that same call sets _ultimaSessionIdEq, so the very
first post-rebuild rotation does not double-fire.
Rapid consecutive rotations: the compare-and-swap on _ultimaSessionIdEq
collapses duplicate emissions of the same id. Distinct ids each trigger one
_activarEcualizador(); the call is unawaited and internally guarded/idempotent
(aplicarPreset returns early if !_eqDisponible), so overlapping in-flight
re-applies converge on _presetActual without corruption. No lock needed.
File Changes
| File | Action | Description |
|---|---|---|
lib/servicios/servicio_audio.dart |
Modify | Add int? _ultimaSessionIdEq; extend _androidAudioSessionIdSub listener (after L263) with the guarded re-apply; reset guard in _recrearPlayer (near L504); extract the pure decision predicate for tests |
test/servicios/servicio_audio_eq_reapply_test.dart |
Create | Unit-test the extracted predicate: rotation, first-activation no-op, null id, teardown gate, rapid duplicates |
Explicitly NOT touched: servicio_audio_session.dart, estado_ecualizador.dart,
Kotlin. Fix is orthogonal to _eqMultiDeviceEnabled (an EstadoEcualizador
concern absent from this file) — it operates on the single live _presetActual,
so it applies regardless of that toggle.
Interfaces / Contracts
Testable seam (minimal refactor — the handler cannot be constructed in unit
tests because its constructor builds a real AudioPlayer needing MethodChannels,
confirmed by servicio_audio_source_switch_test.dart:13-18). Extract the pure
decision so tests exercise it directly, mirroring how that test isolates
ControladorReconexion:
/// Pure re-apply decision for a native session-id emission. No side effects.
/// Returns true only when the EQ must re-attach + re-push current gains.
@visibleForTesting
static bool debeReaplicarEcualizador({
required int? sessionId,
required int? ultimaSessionIdEq,
required bool eqDisponible,
}) =>
sessionId != null && sessionId != ultimaSessionIdEq && eqDisponible;
The listener calls this predicate; on true it sets
_ultimaSessionIdEq = sessionId and unawaited(_activarEcualizador()). Keeping
the mutation + async call in the listener (not the predicate) preserves the "no
side effects in the tested function" boundary and needs no fake player.
Testing Strategy
| Layer | What to Test | Approach |
|---|---|---|
| Unit | debeReaplicarEcualizador truth table |
Direct static calls; no handler instance |
| Unit | Rotation → true (new non-null id, eq available) | debeReaplicarEcualizador(sessionId:2, ultimaSessionIdEq:1, eqDisponible:true) == true |
| Unit | First activation no-op (same id re-emitted) | sessionId:1, ultimaSessionIdEq:1 → false |
| Unit | Null id → false | sessionId:null → false |
| Unit | Teardown gate (_eqDisponible==false) → false |
eqDisponible:false, sessionId:9 → false |
| Unit | Rapid duplicate emissions | Two calls same id: second sees updated guard → false |
| Regression | Station-switch + manual slider paths intact | Full flutter test (Strict TDD) — call sites unchanged |
Strict TDD: write each predicate case RED first, then implement the predicate and
wire the listener GREEN. Integration-level "gains actually re-pushed to native"
is not unit-testable without platform channels; it is covered by the predicate
contract plus the unchanged, already-tested _activarEcualizador/aplicarPreset
idempotency.
Migration / Rollout
No migration required. No persistence, schema, or public API change. Revert the single commit to restore prior behavior.
Open Questions
- None blocking. Predicate seam chosen because full-handler construction is not unit-testable in this repo; if a future integration harness fakes the player, an end-to-end re-apply assertion could be added then.