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); /// 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 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 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(); // DUCK, never pause, when another app asks for transient focus. // // `androidWillPauseWhenDucked: true` makes `audio_session` translate // Android's AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK into a full PAUSE. In a // car that fires constantly — every navigation instruction, every // speed-camera warning, every "OK Google" — and each one used to stop // the radio outright instead of dipping the volume for two seconds. // // Worse than the audio gap: a pause publishes `playing: false`, which // `AudioService.setState` turns into `exitPlayingState()` and, with // `androidStopForegroundOnPause`, into `stopForeground(...)`. A service // that is no longer in the foreground is killable, and when Android // took it the app vanished from the Android Auto pane mid-drive and // another media app took its slot. Ducking keeps `playing: true` // throughout, so the session, the notification and the car pane all // survive an interruption — which is also what keeps the equalizer // alive across it. await sesion.configure( const AudioSessionConfiguration.music().copyWith( androidWillPauseWhenDucked: false, ), ); 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); // 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 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(); } }