diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 89139ce..7f53582 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -1006,6 +1006,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler await _player.setVolume(_volumenEfectivo); } + /// Fix "EQ Re-Apply After Audio-Focus Interruption": thin delegate to the + /// existing [_activarEcualizador] (already does the correct idempotent + /// `setEnabled` + re-push-gains work, already re-asserts the CURRENT + /// [_ecualizadorActivo] rather than forcing it on). Called by + /// [ServicioAudioSession] on resume-from-pause and on un-duck — see that + /// interface member's doc for why the existing session-id-change trigger + /// misses this case. + @override + Future reaplicarEcualizador() => _activarEcualizador(); + @override Future play() { _intencionReproducir = true; diff --git a/lib/servicios/servicio_audio_session.dart b/lib/servicios/servicio_audio_session.dart index 61047c9..2262166 100644 --- a/lib/servicios/servicio_audio_session.dart +++ b/lib/servicios/servicio_audio_session.dart @@ -20,6 +20,20 @@ abstract class ObjetivoAudioInterrumpible { /// Temporarily lowers ("ducks") the output volume without pausing. Future setAtenuado(bool atenuado); + + /// Re-attaches the equalizer effect and re-pushes the current preset's + /// gains (fix "EQ Re-Apply After Audio-Focus Interruption"). Called after + /// resuming from a transient interruption pause and after un-ducking, + /// because Android's AudioEffect framework can let a higher-priority + /// client silently disable this app's effect instance while the + /// underlying player session id never changes — the existing session-id + /// rotation trigger (`ServicioAudio.debeReaplicarEcualizador`) therefore + /// never fires for a SHORT interruption (e.g. a nav-app voice prompt). + /// Idempotent and cheap (a `setEnabled` plus band `setGain` calls); takes + /// no argument by design — it re-asserts whatever enabled/disabled state + /// the handler ALREADY holds, so a caller here can never force the + /// equalizer on. Never restarts or repositions playback. + Future reaplicarEcualizador(); } /// Wrapper around `package:audio_session` (S3-R1): configures the session @@ -84,11 +98,18 @@ class ServicioAudioSession { switch (evento.type) { case AudioInterruptionType.duck: await _objetivo.setAtenuado(false); + // Un-ducking never rotates the native player session id, so the + // session-id-change trigger never fires for this case — re-assert + // here too (belt-and-braces, additive to that trigger). + await _objetivo.reaplicarEcualizador(); case AudioInterruptionType.pause: // Transient loss ended and the OS says we may resume. if (_pausadoPorInterrupcion) { _pausadoPorInterrupcion = false; await _objetivo.reanudar(); + // Same rationale as the duck branch above: a short transient + // interruption keeps the SAME player session id. + await _objetivo.reaplicarEcualizador(); } case AudioInterruptionType.unknown: // Permanent focus loss: never auto-resume. diff --git a/test/servicios/servicio_audio_eq_reapply_test.dart b/test/servicios/servicio_audio_eq_reapply_test.dart index a2f5134..48469d8 100644 --- a/test/servicios/servicio_audio_eq_reapply_test.dart +++ b/test/servicios/servicio_audio_eq_reapply_test.dart @@ -1,5 +1,7 @@ +import 'package:audio_session/audio_session.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/servicios/servicio_audio.dart'; +import 'package:pluriwave/servicios/servicio_audio_session.dart'; /// EQ audio-focus re-apply — pure decision predicate truth table. /// @@ -96,4 +98,192 @@ void main() { ); }); }); + + // ── EQ re-apply after a SHORT audio-focus interruption ────────────────── + // debeReaplicarEcualizador only fires on a session-id CHANGE. A short + // transient interruption (a nav-app voice prompt) keeps the SAME player + // session id, so that trigger never fires and the equalizer stays + // silently disabled after Android lets another app's AudioEffect steal + // control. Fix: re-assert the equalizer on resume-from-pause and on + // un-duck too, via a new no-arg ObjetivoAudioInterrumpible.reaplicarEcualizador() + // that the handler implements as a thin delegate to the existing + // _activarEcualizador() (setEnabled + band gains, already correct). + // + // ServicioAudioSession is the orchestration layer under test here (the + // same layer servicio_audio_session_test.dart already covers) -- it is + // fully unit-testable, unlike PluriWaveAudioHandler itself. + group( + 'ServicioAudioSession re-applies the equalizer on interruption resume ' + '(no session-id change involved)', + () { + test( + 'a pause-interruption cycle (begin -> end/resume) calls ' + 'reaplicarEcualizador exactly once, AFTER reanudar()', + () async { + final objetivo = _ObjetivoFake() + ..reproduciendo = true + ..intencion = true; + final servicio = ServicioAudioSession(objetivo: objetivo); + + await servicio.manejarInterrupcion( + AudioInterruptionEvent(true, AudioInterruptionType.pause), + ); + await servicio.manejarInterrupcion( + AudioInterruptionEvent(false, AudioInterruptionType.pause), + ); + + expect(objetivo.reaplicaciones, 1); + expect( + objetivo.eventos, + ['pausar', 'reanudar', 'reaplicar'], + reason: + 'the re-apply must happen on RESUME, after reanudar() -- ' + 'never before, never on the begin/pause side', + ); + }, + ); + + test( + 'a duck cycle (begin -> end/un-duck) calls reaplicarEcualizador ' + 'exactly once, AFTER setAtenuado(false)', + () async { + final objetivo = _ObjetivoFake() + ..reproduciendo = true + ..intencion = true; + final servicio = ServicioAudioSession(objetivo: objetivo); + + await servicio.manejarInterrupcion( + AudioInterruptionEvent(true, AudioInterruptionType.duck), + ); + await servicio.manejarInterrupcion( + AudioInterruptionEvent(false, AudioInterruptionType.duck), + ); + + expect(objetivo.reaplicaciones, 1); + expect( + objetivo.eventos, + ['atenuado:true', 'atenuado:false', 'reaplicar'], + reason: + 'the re-apply must happen on UN-DUCK, after ' + 'setAtenuado(false)', + ); + expect(objetivo.pausas, 0, reason: 'a duck never pauses'); + }, + ); + + test( + 'with the equalizer switched OFF by the user, an interruption ' + 'cycle still only calls the SAME parameterless reassert -- ' + 'ServicioAudioSession has no way to force it on', + () async { + final objetivo = _ObjetivoFake() + ..reproduciendo = true + ..intencion = true + ..eqActivo = false; + final servicio = ServicioAudioSession(objetivo: objetivo); + + await servicio.manejarInterrupcion( + AudioInterruptionEvent(true, AudioInterruptionType.pause), + ); + await servicio.manejarInterrupcion( + AudioInterruptionEvent(false, AudioInterruptionType.pause), + ); + + expect(objetivo.reaplicaciones, 1); + expect( + objetivo.estadosReaplicados, + [false], + reason: + 'reaplicarEcualizador takes no boolean argument -- it can ' + 'only ask the handler to reassert whatever state it ' + 'ALREADY holds, never flip it on', + ); + }, + ); + + test( + 'end without a prior begin/pause never calls reaplicarEcualizador ' + '(mirrors "end sin pausa previa" -- no resume happened)', + () async { + final objetivo = _ObjetivoFake(); + final servicio = ServicioAudioSession(objetivo: objetivo); + + await servicio.manejarInterrupcion( + AudioInterruptionEvent(false, AudioInterruptionType.pause), + ); + + expect(objetivo.reaplicaciones, 0); + }, + ); + + test( + 'a permanent (unknown-type) focus loss never calls ' + 'reaplicarEcualizador -- there is no resume to re-assert after', + () async { + final objetivo = _ObjetivoFake() + ..reproduciendo = true + ..intencion = true; + final servicio = ServicioAudioSession(objetivo: objetivo); + + await servicio.manejarInterrupcion( + AudioInterruptionEvent(true, AudioInterruptionType.unknown), + ); + await servicio.manejarInterrupcion( + AudioInterruptionEvent(false, AudioInterruptionType.unknown), + ); + + expect(objetivo.reaplicaciones, 0); + }, + ); + }, + ); +} + +class _ObjetivoFake implements ObjetivoAudioInterrumpible { + bool intencion = false; + bool reproduciendo = false; + bool eqActivo = true; + int pausas = 0; + int reaplicaciones = 0; + final List atenuaciones = []; + final List estadosReaplicados = []; + + /// Ordering log shared across every method — proves reaplicarEcualizador + /// fires at the EXACT point in the sequence the fix requires (after + /// reanudar()/setAtenuado(false)), not merely "at some point". + final List eventos = []; + + @override + bool get intencionReproducir => intencion; + + @override + bool get estaReproduciendo => reproduciendo; + + @override + Future pausar() async { + pausas++; + reproduciendo = false; + intencion = false; + eventos.add('pausar'); + } + + @override + Future reanudar() async { + reproduciendo = true; + intencion = true; + eventos.add('reanudar'); + } + + @override + Future setAtenuado(bool atenuado) async { + atenuaciones.add(atenuado); + eventos.add('atenuado:$atenuado'); + } + + @override + Future reaplicarEcualizador() async { + reaplicaciones++; + estadosReaplicados.add(eqActivo); + eventos.add('reaplicar'); + } } diff --git a/test/servicios/servicio_audio_session_test.dart b/test/servicios/servicio_audio_session_test.dart index bc8798c..4f55487 100644 --- a/test/servicios/servicio_audio_session_test.dart +++ b/test/servicios/servicio_audio_session_test.dart @@ -33,6 +33,13 @@ class _ObjetivoFake implements ObjetivoAudioInterrumpible { Future setAtenuado(bool atenuado) async { atenuaciones.add(atenuado); } + + int reaplicaciones = 0; + + @override + Future reaplicarEcualizador() async { + reaplicaciones++; + } } /// S3-R1: audio-session interruptions (phone call, transient loss, duck) and