Every diagnostic line in the audio path used `dart:developer`'s `log()`. That function writes to the VM service, which a RELEASE build does not have — so in the only build that ever runs in a car, all eleven of them went nowhere. `debugPrint`/`print` do reach logcat in release; `log()` does not. That includes the two channels built specifically to end the guessing: - `registrarErrorAudioService`, which subscribes to `AudioService.asyncError` so the plugin's swallowed platform exceptions stop vanishing (b0271fa). It moved them from a dropped PublishSubject to a dropped log call. - `_trazarEstadoPublicado`, the published-state trace added in7054a4cto settle why the car's play button never becomes pause. So "no evidence" was never a quiet app. It was an app writing its evidence somewhere release builds discard. Several rounds of hypotheses were argued without data that the app was already producing. All eleven now use `debugPrint` with a `[PluriWave][Tag]` prefix, so one filter catches the audio path and the existing alarm lines together: adb logcat | grep PluriWave No behaviour changes. Tests: 1158, unchanged.
148 lines
6.0 KiB
Dart
148 lines
6.0 KiB
Dart
import 'dart:async';
|
|
|
|
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);
|
|
|
|
/// 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<void> 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<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();
|
|
// 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) {
|
|
debugPrint(
|
|
'[PluriWave][ServicioAudioSession] No se pudo configurar la sesion de audio: $e',
|
|
);
|
|
}
|
|
}
|
|
|
|
@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);
|
|
// 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<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();
|
|
}
|
|
}
|