fix(eq): re-apply the equalizer after an audio-focus interruption
The equalizer stopped applying after another app interrupted audio (e.g. a navigation app's voice prompt): play a station with EQ working, let the prompt speak, resume -- the audio sounds flat until the station is re-tapped. debeReaplicarEcualizador only re-attaches the equalizer when the native player session id actually changes. A short transient interruption keeps the SAME session (no id rotation), so that trigger never fires, while Android's AudioEffect framework can let a higher-priority client silently disable this app's effect instance in the meantime. Add reaplicarEcualizador() to ObjetivoAudioInterrumpible, implemented as a thin delegate to the existing _activarEcualizador() (already the correct idempotent setEnabled + re-push-gains path). ServicioAudioSession calls it on resume-from-pause (after reanudar()) and on un-duck (after setAtenuado(false)) -- additive to the existing session-id trigger, not a replacement. The method takes no argument, so it can only re-assert whatever enabled/disabled state the handler already holds -- an interruption cycle with the equalizer OFF stays OFF.
This commit is contained in:
@@ -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<void> reaplicarEcualizador() => _activarEcualizador();
|
||||
|
||||
@override
|
||||
Future<void> play() {
|
||||
_intencionReproducir = true;
|
||||
|
||||
@@ -20,6 +20,20 @@ abstract class ObjetivoAudioInterrumpible {
|
||||
|
||||
/// Temporarily lowers ("ducks") the output volume without pausing.
|
||||
Future<void> 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<void> 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.
|
||||
|
||||
@@ -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<bool> atenuaciones = [];
|
||||
final List<bool> 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<String> eventos = [];
|
||||
|
||||
@override
|
||||
bool get intencionReproducir => intencion;
|
||||
|
||||
@override
|
||||
bool get estaReproduciendo => reproduciendo;
|
||||
|
||||
@override
|
||||
Future<void> pausar() async {
|
||||
pausas++;
|
||||
reproduciendo = false;
|
||||
intencion = false;
|
||||
eventos.add('pausar');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reanudar() async {
|
||||
reproduciendo = true;
|
||||
intencion = true;
|
||||
eventos.add('reanudar');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setAtenuado(bool atenuado) async {
|
||||
atenuaciones.add(atenuado);
|
||||
eventos.add('atenuado:$atenuado');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reaplicarEcualizador() async {
|
||||
reaplicaciones++;
|
||||
estadosReaplicados.add(eqActivo);
|
||||
eventos.add('reaplicar');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ class _ObjetivoFake implements ObjetivoAudioInterrumpible {
|
||||
Future<void> setAtenuado(bool atenuado) async {
|
||||
atenuaciones.add(atenuado);
|
||||
}
|
||||
|
||||
int reaplicaciones = 0;
|
||||
|
||||
@override
|
||||
Future<void> reaplicarEcualizador() async {
|
||||
reaplicaciones++;
|
||||
}
|
||||
}
|
||||
|
||||
/// S3-R1: audio-session interruptions (phone call, transient loss, duck) and
|
||||
|
||||
Reference in New Issue
Block a user