merge: duck instead of pause, keep the service foreground, restore local music
This commit is contained in:
+44
-9
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user