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:
2026-06-11 16:25:09 +02:00
parent f3e9487215
commit 079e19f0ee
21 changed files with 1059 additions and 151 deletions
+112
View File
@@ -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();
}
}