Files
pluriwave/lib/servicios/servicio_audio_session.dart
T
FreeTLab 491585ad12 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.
2026-07-31 00:56:37 +02:00

134 lines
5.0 KiB
Dart

import 'dart:async';
import 'dart:developer' as developer;
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart';
/// Minimal playback contract this service needs from the audio handler
/// (Design 3.1). `PluriWaveAudioHandler` implements it; tests use a fake.
abstract class ObjetivoAudioInterrumpible {
/// Intent-to-play flag (Designs 3.1/7.2): true while the user wants audio
/// playing. The S7 reconnect logic reads the same flag, so an interruption
/// pause also disarms reconnection attempts.
bool get intencionReproducir;
bool get estaReproduciendo;
Future<void> pausar();
Future<void> reanudar();
/// 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
/// for music playback and translates interruption / becoming-noisy events
/// into pause, duck and auto-resume calls on the audio handler.
class ServicioAudioSession {
ServicioAudioSession({
required ObjetivoAudioInterrumpible objetivo,
Future<AudioSession> Function()? obtenerSesion,
}) : _objetivo = objetivo,
_obtenerSesion = obtenerSesion ?? (() => AudioSession.instance);
final ObjetivoAudioInterrumpible _objetivo;
final Future<AudioSession> Function() _obtenerSesion;
StreamSubscription<AudioInterruptionEvent>? _interrupcionesSub;
StreamSubscription<void>? _ruidoSub;
/// True when WE paused because of an interruption; only then does an
/// interruption end with shouldResume restart playback.
bool _pausadoPorInterrupcion = false;
Future<void> configurar() async {
try {
final sesion = await _obtenerSesion();
await sesion.configure(
const AudioSessionConfiguration.music().copyWith(
androidWillPauseWhenDucked: true,
),
);
await _interrupcionesSub?.cancel();
await _ruidoSub?.cancel();
_interrupcionesSub = sesion.interruptionEventStream.listen(
(evento) => unawaited(manejarInterrupcion(evento)),
);
_ruidoSub = sesion.becomingNoisyEventStream.listen(
(_) => unawaited(manejarDesconexionSalida()),
);
} catch (e) {
developer.log(
'[PluriWave] No se pudo configurar la sesion de audio: $e',
name: 'ServicioAudioSession',
level: 900,
);
}
}
@visibleForTesting
Future<void> manejarInterrupcion(AudioInterruptionEvent evento) async {
if (evento.begin) {
switch (evento.type) {
case AudioInterruptionType.duck:
await _objetivo.setAtenuado(true);
case AudioInterruptionType.pause:
case AudioInterruptionType.unknown:
if (_objetivo.estaReproduciendo || _objetivo.intencionReproducir) {
_pausadoPorInterrupcion = true;
await _objetivo.pausar();
}
}
return;
}
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.
_pausadoPorInterrupcion = false;
}
}
@visibleForTesting
Future<void> manejarDesconexionSalida() async {
// Headphones unplugged: hard pause, never auto-resume afterwards.
_pausadoPorInterrupcion = false;
if (_objetivo.estaReproduciendo) {
await _objetivo.pausar();
}
}
Future<void> dispose() async {
await _interrupcionesSub?.cancel();
await _ruidoSub?.cancel();
}
}