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 pausar(); Future reanudar(); /// Temporarily lowers ("ducks") the output volume without pausing. Future 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 Function()? obtenerSesion, }) : _objetivo = objetivo, _obtenerSesion = obtenerSesion ?? (() => AudioSession.instance); final ObjetivoAudioInterrumpible _objetivo; final Future Function() _obtenerSesion; StreamSubscription? _interrupcionesSub; StreamSubscription? _ruidoSub; /// True when WE paused because of an interruption; only then does an /// interruption end with shouldResume restart playback. bool _pausadoPorInterrupcion = false; Future 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 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 manejarDesconexionSalida() async { // Headphones unplugged: hard pause, never auto-resume afterwards. _pausadoPorInterrupcion = false; if (_objetivo.estaReproduciendo) { await _objetivo.pausar(); } } Future dispose() async { await _interrupcionesSub?.cancel(); await _ruidoSub?.cancel(); } }