merge: duck instead of pause, keep the service foreground, restore local music

This commit is contained in:
2026-08-06 19:50:07 +02:00
8 changed files with 425 additions and 42 deletions
+44 -9
View File
@@ -26,8 +26,30 @@ const androidNotificationIconResource = 'drawable/ic_stat_pluriwave';
const configuracionAudioService = AudioServiceConfig( const configuracionAudioService = AudioServiceConfig(
androidNotificationChannelId: 'es.freetimelab.pluriwave.audio', androidNotificationChannelId: 'es.freetimelab.pluriwave.audio',
androidNotificationChannelName: 'PluriWave Radio', androidNotificationChannelName: 'PluriWave Radio',
androidNotificationOngoing: true, // Paired with `androidStopForegroundOnPause: false` below, and required to
androidStopForegroundOnPause: true, // be: the plugin asserts `androidNotificationOngoing` implies
// `androidStopForegroundOnPause`. Nothing is lost by turning it off —
// while the service is in the foreground the OS forces the notification to
// be ongoing anyway, which is now the whole time playback is alive.
androidNotificationOngoing: false,
// The service stays in the FOREGROUND while paused.
//
// With `true`, a pause called `stopForeground(...)`, and a service that is
// not in the foreground is a service Android may kill at will. In the car
// that is exactly what happened: an interruption paused playback, the
// service dropped out of the foreground, Android reclaimed it, and
// PluriWave disappeared from the Android Auto pane — another media app
// took the slot. Ducking (see `ServicioAudioSession.configurar`) removes
// most pauses, but a real pause must not be a death sentence either.
//
// The plugin's own doc for this flag says it outright: «while in this
// lower priority state, the operating system will also be able to kill
// your service at any time to reclaim resources».
//
// Cost of `false`: the notification is not swipe-dismissible while paused,
// only after Stop. That is how every serious media app behaves, and Stop
// still tears everything down.
androidStopForegroundOnPause: false,
notificationColor: PluriWaveTokens.brand, notificationColor: PluriWaveTokens.brand,
androidNotificationIcon: androidNotificationIconResource, androidNotificationIcon: androidNotificationIconResource,
); );
@@ -56,6 +78,26 @@ Future<void> main() async {
final fuenteAuto = FuenteEmisorasAutoLocal(); final fuenteAuto = FuenteEmisorasAutoLocal();
registrarFuenteNavegacion(fuenteAuto); registrarFuenteNavegacion(fuenteAuto);
// Local music registers HERE, above every await, alongside the station
// source — not after `SharedPreferences.getInstance()` where it used to
// sit.
//
// Regression this fixes, self-inflicted by the reordering above: the root
// menu decides whether to offer "Música Local" with
// `fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`.
// Moving ONLY the station source above the awaits meant the car could get
// a root response in the window before this line ran, find a null source,
// and be told there is no local music — and Android Auto caches the browse
// root, so it stayed missing for the whole session. Before the reorder
// both registrations sat together after the await, so the window did not
// exist.
//
// `FuenteMusicaLocalAutoImpl` needs no prefs to be CONSTRUCTED: it
// resolves them lazily per call (`_resolverPrefs`, falling back to
// `getInstance()`), the same convention `ServicioAlarmas` uses. So there
// was never a reason for it to wait on that await.
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl());
// Cosmetic, and deliberately NOT awaited: a display preference must never // Cosmetic, and deliberately NOT awaited: a display preference must never
// gate `runApp`. `_OrientacionResponsiveApp.didChangeDependencies` applies // gate `runApp`. `_OrientacionResponsiveApp.didChangeDependencies` applies
// it again as soon as a real view exists, which is the only moment it can // it again as soon as a real view exists, which is the only moment it can
@@ -66,13 +108,6 @@ Future<void> main() async {
// injected into every state/service below. // injected into every state/service below.
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
// Local-music browse source (Design "getChildren data source
// registration"), same injectable-prefs DI convention as every other
// startup service — required so `_fuenteMusicaLocalGlobal` is ever
// non-null; without this registration the local-music root would stay
// permanently hidden (`hayCarpetaConfigurada()` has no fuente to ask).
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl(prefs: prefs));
// User-saved EQ presets for the car's Ecualizador folder, same // User-saved EQ presets for the car's Ecualizador folder, same
// injectable-prefs DI convention and same pre-init placement as the two // injectable-prefs DI convention and same pre-init placement as the two
// registrations above (neither depends on the AudioHandler). Passed as a // registrations above (neither depends on the AudioHandler). Passed as a
+52
View File
@@ -936,6 +936,58 @@ Future<void> reproducirPorMediaId(
await reproducir(item); await reproducir(item);
} }
/// Which list previous/next should walk for [actual]: the NARROWEST list the
/// station actually belongs to, favourites first, then my stations, then the
/// full catalogue.
///
/// Narrowest-first is the point. "Next station" while playing a favourite
/// should land on the next favourite, not on entry 4,318 of a 50,000-station
/// catalogue that happens to sit beside it alphabetically. Falling through to
/// [todas] only when the station is in neither curated list keeps the button
/// working for a station reached by search.
///
/// Returns an empty list when [actual] is in none of them, which
/// [emisoraVecina] turns into "do nothing".
List<Emisora> listaParaSaltoEmisora({
required Emisora actual,
required List<Emisora> favoritos,
required List<Emisora> misEmisoras,
required List<Emisora> todas,
}) {
bool contiene(List<Emisora> lista) => lista.any((e) => e.uuid == actual.uuid);
if (contiene(favoritos)) return favoritos;
if (contiene(misEmisoras)) return misEmisoras;
if (contiene(todas)) return todas;
return const [];
}
/// The station before or after [actual] in [lista], wrapping around at both
/// ends.
///
/// Wrapping is deliberate: on a car's transport row a button that goes dead
/// at the end of a list reads as a broken app, and there is no visible list
/// position to explain it. Matching is by `uuid`, the same identity the
/// browse tree uses, so a refreshed snapshot with different object instances
/// still resolves.
///
/// Returns `null` when [lista] has fewer than two entries, or when [actual]
/// is not in it — the caller must then leave playback alone rather than jump
/// somewhere arbitrary.
Emisora? emisoraVecina(
Emisora? actual,
List<Emisora> lista, {
required bool haciaAtras,
}) {
if (actual == null || lista.length < 2) return null;
final indice = lista.indexWhere((e) => e.uuid == actual.uuid);
if (indice < 0) return null;
final destino =
haciaAtras
? (indice - 1 + lista.length) % lista.length
: (indice + 1) % lista.length;
return lista[destino];
}
/// Picks the station a spoken query refers to ("pon Radio Clásica"), over the /// Picks the station a spoken query refers to ("pon Radio Clásica"), over the
/// stations the car can already browse. /// stations the car can already browse.
/// ///
+57 -10
View File
@@ -735,8 +735,14 @@ class PluriWaveAudioHandler extends BaseAudioHandler
MediaAction.playFromMediaId, MediaAction.playFromMediaId,
MediaAction.playFromSearch, MediaAction.playFromSearch,
MediaAction.seek, MediaAction.seek,
if (colaActiva) MediaAction.skipToPrevious, // Previous/next are advertised ALWAYS now, not only for a local
if (colaActiva) MediaAction.skipToNext, // queue. Android Auto reserves those two slots and only hands the
// space to custom actions when the app declares no support, so
// this is what puts prev/next on the car's transport row -- and
// `skipToNext`/`skipToPrevious` fall back to station-to-station
// skipping when there is no queue, so neither button is inert.
MediaAction.skipToPrevious,
MediaAction.skipToNext,
}, },
androidCompactActionIndices: [colaActiva ? 1 : 0], androidCompactActionIndices: [colaActiva ? 1 : 0],
processingState: mapearEstadoProceso( processingState: mapearEstadoProceso(
@@ -1485,13 +1491,19 @@ class PluriWaveAudioHandler extends BaseAudioHandler
Future<void> seek(Duration position) => _player.seek(position); Future<void> seek(Duration position) => _player.seek(position);
/// Moves to the next queued track (Design ADR-3/ADR-4, Phase 3 task /// Moves to the next queued track (Design ADR-3/ADR-4, Phase 3 task
/// 3.6): a no-op when no local queue is active. Past the last track, /// 3.6). Past the last track, clears the queue and stops — mirroring
/// clears the queue and stops — mirroring auto-advance's end-of-queue /// auto-advance's end-of-queue behavior (no wraparound).
/// behavior (no wraparound). ///
/// With NO local queue this now moves to the next STATION instead of doing
/// nothing: the car's transport row offers previous/next for radio too,
/// and a button that is present but inert is worse than no button.
@override @override
Future<void> skipToNext() async { Future<void> skipToNext() async {
final cola = _colaLocal; final cola = _colaLocal;
if (cola == null) return; if (cola == null) {
await _saltarEmisora(haciaAtras: false);
return;
}
final siguiente = cola.conSiguiente(); final siguiente = cola.conSiguiente();
if (siguiente == null) { if (siguiente == null) {
_desactivarCola(); _desactivarCola();
@@ -1502,18 +1514,53 @@ class PluriWaveAudioHandler extends BaseAudioHandler
await _reproducirEntradaCola(siguiente.actual); await _reproducirEntradaCola(siguiente.actual);
} }
/// Moves to the previous queued track (Design ADR-4, Phase 3 task 3.6): /// Moves to the previous queued track (Design ADR-4, Phase 3 task 3.6).
/// a no-op when no local queue is active. Clamps at the first track /// Clamps at the first track (restarts it) instead of wrapping to the last
/// (restarts it) instead of wrapping to the last one. /// one. With no local queue, moves to the previous STATION — see
/// [skipToNext].
@override @override
Future<void> skipToPrevious() async { Future<void> skipToPrevious() async {
final cola = _colaLocal; final cola = _colaLocal;
if (cola == null) return; if (cola == null) {
await _saltarEmisora(haciaAtras: true);
return;
}
final anterior = cola.conAnterior(); final anterior = cola.conAnterior();
_colaLocal = anterior; _colaLocal = anterior;
await _reproducirEntradaCola(anterior.actual); await _reproducirEntradaCola(anterior.actual);
} }
/// Station-to-station skipping for the car's transport row.
///
/// The list to walk is resolved by [listaParaSaltoEmisora]: the narrowest
/// list the current station actually belongs to, favourites first. Anything
/// unresolvable — no source, no current station, a station that is in no
/// list, a single-entry list — leaves playback untouched. Never throws;
/// this runs from a hardware/steering-wheel button and an exception here
/// would surface as the app going silent mid-drive.
Future<void> _saltarEmisora({required bool haciaAtras}) async {
try {
final fuente = _fuenteNavegacionGlobal;
final actual = emisoraActual;
if (fuente == null || actual == null) return;
final lista = listaParaSaltoEmisora(
actual: actual,
favoritos: await fuente.favoritos(),
misEmisoras: await fuente.misEmisoras(),
todas: await fuente.todas(),
);
final destino = emisoraVecina(actual, lista, haciaAtras: haciaAtras);
if (destino == null) return;
await playMediaItem(mediaItemParaEmisora(destino, l10n: _textos));
} catch (e) {
developer.log(
'[PluriWave] Error saltando de emisora: $e',
name: 'ServicioAudio',
level: 900,
);
}
}
/// Dispatches the equalizer's only custom action (decision /// Dispatches the equalizer's only custom action (decision
/// `auto/ecualizador-diseno`): `accionEqToggle` flips on/off, delegating /// `auto/ecualizador-diseno`): `accionEqToggle` flips on/off, delegating
/// to the existing [setEcualizadorActivo] — the SAME entry point the /// to the existing [setEcualizadorActivo] — the SAME entry point the
+18 -1
View File
@@ -58,9 +58,26 @@ class ServicioAudioSession {
Future<void> configurar() async { Future<void> configurar() async {
try { try {
final sesion = await _obtenerSesion(); 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( await sesion.configure(
const AudioSessionConfiguration.music().copyWith( const AudioSessionConfiguration.music().copyWith(
androidWillPauseWhenDucked: true, androidWillPauseWhenDucked: false,
), ),
); );
await _interrupcionesSub?.cancel(); await _interrupcionesSub?.cancel();
+5 -4
View File
@@ -70,10 +70,11 @@ void main() {
// and showing the test still finishes -- if startup awaited it, this // and showing the test still finishes -- if startup awaited it, this
// future is exactly what would hang forever on the headless engine. // future is exactly what would hang forever on the headless engine.
var termino = false; var termino = false;
// ignore: unawaited_futures unawaited(
aplicarPoliticaOrientacion( aplicarPoliticaOrientacion(
aplicar: (_) => Completer<void>().future, aplicar: (_) => Completer<void>().future,
).then((_) => termino = true); ).then((_) => termino = true),
);
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
expect(termino, isFalse, reason: 'sigue pendiente, como debe'); expect(termino, isFalse, reason: 'sigue pendiente, como debe');
@@ -31,8 +31,8 @@ void main() {
MediaAction.playFromMediaId, MediaAction.playFromMediaId,
MediaAction.playFromSearch, MediaAction.playFromSearch,
MediaAction.seek, MediaAction.seek,
if (colaActiva) MediaAction.skipToPrevious, MediaAction.skipToPrevious,
if (colaActiva) MediaAction.skipToNext, MediaAction.skipToNext,
}; };
group('acciones que Android for Cars documenta como obligatorias', () { group('acciones que Android for Cars documenta como obligatorias', () {
@@ -55,22 +55,31 @@ void main() {
}); });
} }
test('los saltos SOLO se anuncian con cola activa', () { test('los saltos se anuncian SIEMPRE, también para radio', () {
// Android Auto reserves the prev/next slots for these actions and, when // Reversal of the previous version of this test, and deliberate.
// the app does not support them, hands the space to custom actions -- //
// which is where the equalizer toggle lives. Advertising skips for // That version withheld prev/next for radio so Android Auto would hand
// radio would take that space away for buttons that do nothing. // the two reserved slots to custom actions. But the owner asked for
expect( // prev/next on the car's playback screen for stations too, and Auto
systemActions(colaActiva: false), // only draws them when the app declares support. `skipToNext`/
isNot(contains(MediaAction.skipToPrevious)), // `skipToPrevious` now fall back to station-to-station skipping
); // (`emisoraVecina` + `listaParaSaltoEmisora`), so neither button is
expect( // inert -- which was the whole reason to withhold them before.
systemActions(colaActiva: true), //
containsAll(<MediaAction>[ // The equalizer toggle still fits: prev/next take their two reserved
MediaAction.skipToPrevious, // slots, and the remaining custom-action room is claimed by the
MediaAction.skipToNext, // equalizer because `construirControlesTransporte` places it before
]), // `MediaControl.stop`.
); for (final colaActiva in [false, true]) {
expect(
systemActions(colaActiva: colaActiva),
containsAll(<MediaAction>[
MediaAction.skipToPrevious,
MediaAction.skipToNext,
]),
reason: 'colaActiva=$colaActiva',
);
}
}); });
}); });
+102
View File
@@ -0,0 +1,102 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
/// Requested: the Android Auto playback screen must offer previous/next for
/// stations too, not only for a local-music queue. Those buttons appear
/// because `skipToPrevious`/`skipToNext` are advertised in `systemActions` —
/// so they have to actually move, or the car shows two dead buttons.
void main() {
Emisora emisora(String uuid) =>
Emisora(uuid: uuid, nombre: uuid, url: 'https://example.com/$uuid');
final a = emisora('a');
final b = emisora('b');
final c = emisora('c');
group('emisoraVecina', () {
test('avanza y retrocede dentro de la lista', () {
expect(emisoraVecina(a, [a, b, c], haciaAtras: false), b);
expect(emisoraVecina(b, [a, b, c], haciaAtras: true), a);
});
test('da la vuelta en los dos extremos', () {
// A dead button at the end of a list reads as a broken app on a car
// screen, where there is no visible list position to explain it.
expect(emisoraVecina(c, [a, b, c], haciaAtras: false), a);
expect(emisoraVecina(a, [a, b, c], haciaAtras: true), c);
});
test('identifica por uuid, no por instancia: un snapshot refrescado trae '
'objetos distintos', () {
final copiaDeB = Emisora(
uuid: 'b',
nombre: 'otro nombre',
url: 'https://example.com/cambiada',
);
expect(emisoraVecina(copiaDeB, [a, b, c], haciaAtras: false), c);
});
test('no hace nada sin contexto suficiente', () {
expect(emisoraVecina(null, [a, b], haciaAtras: false), isNull);
expect(emisoraVecina(a, [a], haciaAtras: false), isNull);
expect(emisoraVecina(a, const [], haciaAtras: false), isNull);
expect(
emisoraVecina(emisora('fuera'), [a, b], haciaAtras: false),
isNull,
reason: 'una emisora que no está en la lista no debe saltar a ciegas',
);
});
});
group('listaParaSaltoEmisora', () {
test('favoritos gana: "siguiente" desde un favorito va al siguiente '
'favorito, no a la entrada 4318 del catálogo', () {
expect(
listaParaSaltoEmisora(
actual: a,
favoritos: [a, b],
misEmisoras: [a, c],
todas: [a, b, c],
),
[a, b],
);
});
test('mis emisoras cuando no es favorita', () {
expect(
listaParaSaltoEmisora(
actual: c,
favoritos: [a, b],
misEmisoras: [c, a],
todas: [a, b, c],
),
[c, a],
);
});
test('cae al catálogo completo para una emisora llegada por búsqueda', () {
expect(
listaParaSaltoEmisora(
actual: c,
favoritos: [a],
misEmisoras: [b],
todas: [a, b, c],
),
[a, b, c],
);
});
test('vacía si no está en ninguna: el salto queda en no-op', () {
expect(
listaParaSaltoEmisora(
actual: emisora('huerfana'),
favoritos: [a],
misEmisoras: [b],
todas: [a, b],
),
isEmpty,
);
});
});
}
@@ -0,0 +1,120 @@
import 'package:audio_session/audio_session.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio_session.dart';
/// Reported from the car: PluriWave disappeared from the Android Auto pane
/// mid-drive and another media app took its slot, the playback screen sat
/// frozen, and the chosen equalizer was lost whenever navigation, a
/// speed-camera app or the car's voice assistant spoke.
///
/// One cause behind all three. `androidWillPauseWhenDucked: true` made
/// `audio_session` translate Android's AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK
/// into a full PAUSE. In a car that fires on every navigation instruction. A
/// pause publishes `playing: false`, `AudioService.setState` turns that into
/// `exitPlayingState()` and — with `androidStopForegroundOnPause` — into
/// `stopForeground(...)`. The plugin's own doc for that flag: «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.
///
/// Ducking keeps `playing: true` throughout, so session, notification and
/// car pane all survive the interruption.
class _ObjetivoFalso implements ObjetivoAudioInterrumpible {
bool atenuado = false;
int pausas = 0;
int reanudaciones = 0;
int reaplicacionesEq = 0;
@override
bool intencionReproducir = true;
@override
bool estaReproduciendo = true;
@override
Future<void> pausar() async => pausas++;
@override
Future<void> reanudar() async => reanudaciones++;
@override
Future<void> setAtenuado(bool valor) async => atenuado = valor;
@override
Future<void> reaplicarEcualizador() async => reaplicacionesEq++;
}
void main() {
test('un aviso de navegación ATENÚA, nunca pausa', () async {
final objetivo = _ObjetivoFalso();
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.duck),
);
expect(objetivo.atenuado, isTrue);
expect(
objetivo.pausas,
0,
reason:
'pausar publica playing:false -> exitPlayingState -> el servicio '
'sale de primer plano y Android puede matarlo; ahí es donde la app '
'desaparecía del panel del coche',
);
});
test('al terminar el aviso se recupera el volumen Y se reasienta el '
'ecualizador', () async {
final objetivo = _ObjetivoFalso();
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.duck),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.duck),
);
expect(objetivo.atenuado, isFalse);
expect(
objetivo.reaplicacionesEq,
1,
reason:
'Android puede desactivar en silencio el efecto de esta app cuando '
'otro cliente de mayor prioridad toma el foco, y el id de sesión no '
'cambia, así que el disparador por rotación de sesión no salta',
);
expect(objetivo.pausas, 0);
});
test('una llamada entrante SÍ pausa: no todo es un aviso corto', () async {
// The duck change must not turn a real, non-duckable focus loss into a
// station that keeps playing over a phone call.
final objetivo = _ObjetivoFalso();
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.pause),
);
expect(objetivo.pausas, 1);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.pause),
);
expect(objetivo.reanudaciones, 1);
expect(objetivo.reaplicacionesEq, 1);
});
test('desconectar los auriculares pausa y NO reanuda solo', () async {
final objetivo = _ObjetivoFalso();
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarDesconexionSalida();
expect(objetivo.pausas, 1);
expect(objetivo.reanudaciones, 0);
});
}