feat(audio): audio session integration and runtime robustness
- Integrate audio_session (new servicio_audio_session.dart): incoming calls pause the radio and resume on end, headphone unplug pauses without auto-resume, permanent focus loss never auto-resumes, duck lowers volume - Add play-intent flag to ServicioAudio so interruption handling and future reconnect logic can distinguish user pause from system-driven stops - Eliminate read-modify-write race in ServicioAlarmas with an in-memory cache and single-writer queue across all mutations; recalcularTodas persists only when state actually changed - Convert ServicioAlarmasAndroid static StreamController/handler to injectable instance fields, restoring test isolation - Inject a single cached SharedPreferences from main.dart across services and state (removes 23 inline getInstance() calls) - Move configurarLocalizaciones out of MiniReproductor.build() (was running on every rebuild during playback) - Bound the alarm fire-dedup set (cap 200 entries, 24h pruning) - 12 new tests (89 total green), flutter analyze clean
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
case AudioInterruptionType.pause:
|
||||
// Transient loss ended and the OS says we may resume.
|
||||
if (_pausadoPorInterrupcion) {
|
||||
_pausadoPorInterrupcion = false;
|
||||
await _objetivo.reanudar();
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user