079e19f0ee
- 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
113 lines
3.7 KiB
Dart
113 lines
3.7 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);
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
}
|