Four car reports, two root causes.
1. Local music vanished from the Android Auto menu. Self-inflicted, by
c1afe72 yesterday.
That commit moved registrarFuenteNavegacion above every await to keep a
headless engine from dying before it ran -- but left
registrarFuenteMusicaLocal below `await SharedPreferences.getInstance()`.
The root menu decides whether to offer "Música Local" with
`fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`, so
the car could now get a root response in the window between the two
registrations, find a null source, and be told there is no local music.
Android Auto caches the browse root, so it stayed missing for the whole
session. Before the reorder both registrations sat together after the
await and the window did not exist.
FuenteMusicaLocalAutoImpl never needed prefs to be CONSTRUCTED -- it
resolves them lazily per call, the same convention ServicioAlarmas uses
-- so it now registers beside the station source, above every await, and
the window is gone rather than narrowed.
2. PluriWave disappeared from the Auto pane mid-drive, the playback
screen sat frozen, and the equalizer was lost on every navigation
prompt. One cause for all three.
androidWillPauseWhenDucked: true made 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 voice assistant. And a pause publishes playing:false,
which AudioService.setState turns into exitPlayingState() and, with
androidStopForegroundOnPause: true, into stopForeground(...). The
plugin's own doc for that flag says what follows: "while in this lower
priority state, the operating system will also be able to kill your
service at any time to reclaim resources". A killed service is a media
session that vanishes from the car pane -- and another media app takes
the slot.
Now the app ducks instead of pausing, so playing stays true and session,
notification and pane all survive an interruption; and the service stays
foreground even on a real pause, so a genuine one is not a death
sentence either. androidNotificationOngoing goes to false because the
plugin asserts it implies stopForegroundOnPause, and nothing is lost: a
foreground service already forces the notification to be ongoing.
A real, non-duckable focus loss (a phone call) still pauses and still
auto-resumes -- asserted, so the duck change cannot silently turn a call
into a station playing over it.
3. Previous/next on the car playback screen, for stations too.
skipToPrevious/skipToNext are now advertised unconditionally, since
Android Auto only draws those buttons when the app declares support.
They are no longer inert without a local queue: they walk the narrowest
list the current station belongs to -- favourites, then my stations,
then the catalogue -- wrapping at both ends, because a button that goes
dead at the end of a list reads as broken on a screen with no visible
list position. Matching is by uuid so a refreshed snapshot still
resolves, and a station in no list leaves playback untouched.
The equalizer toggle still fits alongside them: prev/next take their two
reserved slots and the equalizer claims the remaining custom-action room
because construirControlesTransporte places it before MediaControl.stop.
The phone notification is deliberately untouched: `controls` still gates
skip on an active queue, so nativeActions and
androidCompactActionIndices are byte-identical. Only systemActions
changed, and only the car reads those.
Tests: 1146 -> 1158.
151 lines
6.1 KiB
Dart
151 lines
6.1 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);
|
|
|
|
/// 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) {
|
|
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);
|
|
// 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();
|
|
}
|
|
}
|