# 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()`: 1. `await _androidAudioSessionIdSub?.cancel()` (L492) — old listener silenced. 2. `_eq = AndroidEqualizer()` (L502) — fresh effect, empty params. 3. `_eqDisponible = false` (L503) — **gate closes BEFORE any new id can arrive**. 4. `_androidAudioSessionId = null` (L504) + reset `_ultimaSessionIdEq = null`. 5. `_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`: ```dart /// 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.