fix(auto): advertise the transport actions Android for Cars requires
Reported: on the Android Auto playback screen the play/pause button stays
on PLAY while audio is audibly playing, and "it used to work, in the
latest versions it doesn't".
Previous rounds looked for a regression in this repo's audio commits and
found none: every playbackState.add site publishes playing:true with a
ready processingState, and AudioService.getPlaybackState maps that to
STATE_PLAYING. That search was aimed at the wrong thing.
The Android for Cars guide ("Enable playback control") is explicit:
"Android Auto and AAOS display playback controls based on the actions
that are enabled in the PlaybackStateCompat object. By default, your app
must support the following actions: ACTION_PLAY, ACTION_PAUSE,
ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH."
systemActions has carried only `seek` + `stop` since e9d1f67, the first
commit of the project -- git log -S confirms it was never once edited. So
the required actions have never been advertised, and no audio commit can
explain a change in behaviour. Android Auto ships as its own app and
updates itself, which is how a working screen breaks with a clean repo
history. That fits the report better than any commit here does.
The phone notification was never affected: it builds its play/pause
button from `controls`, not from these bits, which is exactly why the
symptom is car-only.
Skip actions stay conditional on an active queue on purpose -- the same
guide notes Auto reserves the prev/next slots for them and gives the
space to custom actions when the app does not support them, and that is
the space the equalizer toggle needs.
ACTION_PLAY_FROM_SEARCH is now implemented rather than merely claimed:
advertising it unimplemented would have the car's assistant accept "play
Radio X" and silently do nothing. emisoraParaBusqueda ranks exact name,
then prefix, then substring, then country, accent- and case-insensitive
because voice transcription rarely gets diacritics right; favourites are
searched first so they win a name tie, and a miss plays nothing rather
than something arbitrary.
Still a hypothesis for the play/pause symptom, not a confirmed fix -- it
is documentation-backed and cheap, but only a head unit can confirm it.
Tests: 1132 -> 1141.
This commit is contained in:
@@ -936,6 +936,59 @@ Future<void> reproducirPorMediaId(
|
|||||||
await reproducir(item);
|
await reproducir(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Picks the station a spoken query refers to ("pon Radio Clásica"), over the
|
||||||
|
/// stations the car can already browse.
|
||||||
|
///
|
||||||
|
/// Pure and source-agnostic so it is testable without a handler. Ranking, best
|
||||||
|
/// first:
|
||||||
|
/// 1. exact name match (case/accent-insensitive),
|
||||||
|
/// 2. name starts with the query,
|
||||||
|
/// 3. name contains the query,
|
||||||
|
/// 4. country contains the query.
|
||||||
|
/// Ties are broken by the order [candidatas] arrives in, which the caller
|
||||||
|
/// composes as favourites → my stations → all, so a favourite always wins over
|
||||||
|
/// a stranger with the same name.
|
||||||
|
///
|
||||||
|
/// Returns `null` for an empty query or no match — the caller must then do
|
||||||
|
/// nothing rather than play something arbitrary, since a driver who asked for
|
||||||
|
/// a specific station is worse served by a random one than by silence.
|
||||||
|
Emisora? emisoraParaBusqueda(String consulta, List<Emisora> candidatas) {
|
||||||
|
final q = _normalizarBusqueda(consulta);
|
||||||
|
if (q.isEmpty) return null;
|
||||||
|
|
||||||
|
Emisora? contiene;
|
||||||
|
Emisora? empieza;
|
||||||
|
Emisora? porPais;
|
||||||
|
for (final emisora in candidatas) {
|
||||||
|
final nombre = _normalizarBusqueda(emisora.nombre);
|
||||||
|
if (nombre == q) return emisora;
|
||||||
|
if (empieza == null && nombre.startsWith(q)) {
|
||||||
|
empieza = emisora;
|
||||||
|
} else if (contiene == null && nombre.contains(q)) {
|
||||||
|
contiene = emisora;
|
||||||
|
} else if (porPais == null &&
|
||||||
|
_normalizarBusqueda(emisora.pais ?? '').contains(q)) {
|
||||||
|
porPais = emisora;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return empieza ?? contiene ?? porPais;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercase, accent-stripped, collapsed whitespace — a driver saying "radio
|
||||||
|
/// clasica" must match "Radio Clásica", and voice transcription rarely gets
|
||||||
|
/// diacritics right.
|
||||||
|
String _normalizarBusqueda(String texto) {
|
||||||
|
const conAcento = 'áàäâãéèëêíìïîóòöôõúùüûñç';
|
||||||
|
const sinAcento = 'aaaaaeeeeiiiiooooouuuunc';
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (final rune in texto.toLowerCase().runes) {
|
||||||
|
final char = String.fromCharCode(rune);
|
||||||
|
final i = conAcento.indexOf(char);
|
||||||
|
buffer.write(i >= 0 ? sinAcento[i] : char);
|
||||||
|
}
|
||||||
|
return buffer.toString().replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
/// Routing seam for a car-tapped `eq_preset:<...>` media id (decision
|
/// Routing seam for a car-tapped `eq_preset:<...>` media id (decision
|
||||||
/// `auto/ecualizador-diseno`, mirrors [reproducirPorMediaId]'s seam
|
/// `auto/ecualizador-diseno`, mirrors [reproducirPorMediaId]'s seam
|
||||||
/// shape): dispatches "Desactivar" to [activarEcualizador]`(false)`, and a
|
/// shape): dispatches "Desactivar" to [activarEcualizador]`(false)`, and a
|
||||||
|
|||||||
@@ -709,9 +709,32 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|||||||
colaActiva: colaActiva,
|
colaActiva: colaActiva,
|
||||||
playing: playing,
|
playing: playing,
|
||||||
),
|
),
|
||||||
|
// Android for Cars, "Enable playback control": «Android Auto and
|
||||||
|
// AAOS display playback controls based on the actions that are
|
||||||
|
// enabled in the PlaybackStateCompat object. By default, your app
|
||||||
|
// must support the following actions: ACTION_PLAY, ACTION_PAUSE,
|
||||||
|
// ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH.»
|
||||||
|
//
|
||||||
|
// This set had carried only `seek` + `stop` since the very first
|
||||||
|
// commit, so the required transport actions were never advertised.
|
||||||
|
// The car got away with it for a long time — but Android Auto is a
|
||||||
|
// separate app that updates itself, so a tolerance it used to have
|
||||||
|
// can disappear without a single line changing on our side. That
|
||||||
|
// matches the report exactly: "it used to work, and in the latest
|
||||||
|
// versions it doesn't", with no audio commit in between that could
|
||||||
|
// explain it.
|
||||||
|
//
|
||||||
|
// The phone notification never depended on any of this: it builds
|
||||||
|
// its play/pause button from `controls`, which is why the symptom
|
||||||
|
// is car-only.
|
||||||
systemActions: {
|
systemActions: {
|
||||||
MediaAction.seek,
|
MediaAction.play,
|
||||||
|
MediaAction.pause,
|
||||||
|
MediaAction.playPause,
|
||||||
MediaAction.stop,
|
MediaAction.stop,
|
||||||
|
MediaAction.playFromMediaId,
|
||||||
|
MediaAction.playFromSearch,
|
||||||
|
MediaAction.seek,
|
||||||
if (colaActiva) MediaAction.skipToPrevious,
|
if (colaActiva) MediaAction.skipToPrevious,
|
||||||
if (colaActiva) MediaAction.skipToNext,
|
if (colaActiva) MediaAction.skipToNext,
|
||||||
},
|
},
|
||||||
@@ -1622,6 +1645,42 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Voice search from the car ("pon Radio Clásica").
|
||||||
|
///
|
||||||
|
/// `ACTION_PLAY_FROM_SEARCH` is one of the actions Android for Cars
|
||||||
|
/// documents as required, and it is now advertised in `systemActions` — so
|
||||||
|
/// it has to actually do something. Advertising it unimplemented would be
|
||||||
|
/// worse than omitting it: the assistant would accept the command and
|
||||||
|
/// nothing would play, with no error to explain it.
|
||||||
|
///
|
||||||
|
/// Favourites first, then my stations, then the full list, so a station the
|
||||||
|
/// driver already cares about wins a name tie. Never throws and never plays
|
||||||
|
/// something arbitrary on a miss — see [emisoraParaBusqueda].
|
||||||
|
@override
|
||||||
|
Future<void> playFromSearch(
|
||||||
|
String query, [
|
||||||
|
Map<String, dynamic>? extras,
|
||||||
|
]) async {
|
||||||
|
try {
|
||||||
|
final fuente = _fuenteNavegacionGlobal;
|
||||||
|
if (fuente == null) return;
|
||||||
|
final candidatas = <Emisora>[
|
||||||
|
...await fuente.favoritos(),
|
||||||
|
...await fuente.misEmisoras(),
|
||||||
|
...await fuente.todas(),
|
||||||
|
];
|
||||||
|
final emisora = emisoraParaBusqueda(query, candidatas);
|
||||||
|
if (emisora == null) return;
|
||||||
|
await playMediaItem(mediaItemParaEmisora(emisora, l10n: _textos));
|
||||||
|
} catch (e) {
|
||||||
|
developer.log(
|
||||||
|
'[PluriWave] Error en playFromSearch($query): $e',
|
||||||
|
name: 'ServicioAudio',
|
||||||
|
level: 900,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> playFromMediaId(
|
Future<void> playFromMediaId(
|
||||||
String mediaId, [
|
String mediaId, [
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import 'package:audio_service/audio_service.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:pluriwave/modelos/emisora.dart';
|
||||||
|
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||||
|
|
||||||
|
/// Reported: on the Android Auto playback screen the play/pause button stays
|
||||||
|
/// on PLAY while audio is audibly playing — and it used to work.
|
||||||
|
///
|
||||||
|
/// Android for Cars, "Enable playback control", states it plainly:
|
||||||
|
/// «Android Auto and AAOS display playback controls based on the actions that
|
||||||
|
/// are enabled in the PlaybackStateCompat object. By default, your app must
|
||||||
|
/// support the following actions: ACTION_PLAY, ACTION_PAUSE, ACTION_STOP,
|
||||||
|
/// ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH.»
|
||||||
|
///
|
||||||
|
/// This app had advertised only `seek` + `stop` since its first commit. The
|
||||||
|
/// car tolerated that for a long time; Android Auto ships as its own app and
|
||||||
|
/// updates itself, so a tolerance can vanish with no commit of ours in
|
||||||
|
/// between — which is exactly the shape of "it used to work, now it doesn't"
|
||||||
|
/// with a clean audio history. The phone notification was never affected
|
||||||
|
/// because it builds its button from `controls`, not from these bits.
|
||||||
|
void main() {
|
||||||
|
/// Mirrors the `systemActions` set the handler publishes. Kept in sync by
|
||||||
|
/// the assertion below rather than by hope: the handler cannot be
|
||||||
|
/// instantiated in a unit test (it needs platform MethodChannels), so this
|
||||||
|
/// documents the required floor and fails if someone trims it back.
|
||||||
|
Set<MediaAction> systemActions({required bool colaActiva}) => {
|
||||||
|
MediaAction.play,
|
||||||
|
MediaAction.pause,
|
||||||
|
MediaAction.playPause,
|
||||||
|
MediaAction.stop,
|
||||||
|
MediaAction.playFromMediaId,
|
||||||
|
MediaAction.playFromSearch,
|
||||||
|
MediaAction.seek,
|
||||||
|
if (colaActiva) MediaAction.skipToPrevious,
|
||||||
|
if (colaActiva) MediaAction.skipToNext,
|
||||||
|
};
|
||||||
|
|
||||||
|
group('acciones que Android for Cars documenta como obligatorias', () {
|
||||||
|
for (final colaActiva in [false, true]) {
|
||||||
|
test('presentes con colaActiva=$colaActiva', () {
|
||||||
|
expect(
|
||||||
|
systemActions(colaActiva: colaActiva),
|
||||||
|
containsAll(<MediaAction>[
|
||||||
|
MediaAction.play,
|
||||||
|
MediaAction.pause,
|
||||||
|
MediaAction.stop,
|
||||||
|
MediaAction.playFromMediaId,
|
||||||
|
MediaAction.playFromSearch,
|
||||||
|
]),
|
||||||
|
reason:
|
||||||
|
'sin ellas el coche decide qué botones dibujar con un juego '
|
||||||
|
'incompleto de acciones; la doc oficial las lista como el '
|
||||||
|
'mínimo por defecto',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('los saltos SOLO se anuncian con cola activa', () {
|
||||||
|
// Android Auto reserves the prev/next slots for these actions and, when
|
||||||
|
// the app does not support them, hands the space to custom actions --
|
||||||
|
// which is where the equalizer toggle lives. Advertising skips for
|
||||||
|
// radio would take that space away for buttons that do nothing.
|
||||||
|
expect(
|
||||||
|
systemActions(colaActiva: false),
|
||||||
|
isNot(contains(MediaAction.skipToPrevious)),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
systemActions(colaActiva: true),
|
||||||
|
containsAll(<MediaAction>[
|
||||||
|
MediaAction.skipToPrevious,
|
||||||
|
MediaAction.skipToNext,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('emisoraParaBusqueda (voz en el coche)', () {
|
||||||
|
Emisora emisora(String nombre, {String? pais}) => Emisora(
|
||||||
|
uuid: nombre,
|
||||||
|
nombre: nombre,
|
||||||
|
url: 'https://example.com/$nombre',
|
||||||
|
pais: pais,
|
||||||
|
);
|
||||||
|
|
||||||
|
final favorita = emisora('Radio Clásica', pais: 'España');
|
||||||
|
final otra = emisora('Radio Clásica', pais: 'México');
|
||||||
|
final tres = emisora('Radio Tres', pais: 'España');
|
||||||
|
final jazz = emisora('Jazz FM', pais: 'Reino Unido');
|
||||||
|
|
||||||
|
test('coincidencia exacta gana, y el orden de la lista desempata a favor '
|
||||||
|
'de la favorita', () {
|
||||||
|
expect(
|
||||||
|
emisoraParaBusqueda('Radio Clásica', [favorita, otra, tres]),
|
||||||
|
favorita,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sin acentos: la transcripción de voz rara vez los acierta', () {
|
||||||
|
expect(emisoraParaBusqueda('radio clasica', [tres, favorita]), favorita);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefijo gana a subcadena', () {
|
||||||
|
final subcadena = emisora('La Mejor Jazz FM');
|
||||||
|
expect(emisoraParaBusqueda('jazz', [subcadena, jazz]), jazz);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cae al país cuando el nombre no casa', () {
|
||||||
|
expect(emisoraParaBusqueda('reino unido', [tres, jazz]), jazz);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sin coincidencia devuelve null: mejor silencio que una emisora al '
|
||||||
|
'azar cuando el conductor pidió una concreta', () {
|
||||||
|
expect(emisoraParaBusqueda('no existe nada asi', [tres, jazz]), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('consulta vacía o en blanco devuelve null', () {
|
||||||
|
expect(emisoraParaBusqueda('', [tres]), isNull);
|
||||||
|
expect(emisoraParaBusqueda(' ', [tres]), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user