feat(auto): equalizer enable/disable and preset cycling from the car

Expose the equalizer's on/off toggle and preset choice as PlaybackStateCompat
custom actions on the now-playing screen. The redesign's removal of the
in-car equalizer FOLDER from the browse tree stays as-is (2403da3) -- this
is a different surface (playback screen custom actions, not a browse
folder) and does not reintroduce it.

Deliberately just 2 actions -- an on/off toggle plus a cycling preset
action, not one action per preset -- since Android Auto only surfaces a
limited number of custom actions. Both reuse the existing
setEcualizadorActivo/aplicarPreset entry points (the same ones
EstadoEcualizador's phone settings screen uses), so a car tap and a phone
tap behave identically and both keep the action labels in sync. Reuses the
bundled ic_stat_pluriwave drawable (the notification's own equalizer-bars
icon) -- zero new native assets. The 5-band constraint is untouched.

New pure, unit-tested functions in servicio_audio.dart: presetSiguiente,
nombrePresetVisible, controlesEcualizadorPersonalizados. New ARB keys
(eqCustomActionEnableLabel/DisableLabel/PresetLabel) across all 13 locales,
regenerated via flutter gen-l10n.
This commit is contained in:
2026-07-31 00:54:05 +02:00
parent 1b0bea5492
commit 9eff760462
29 changed files with 688 additions and 45 deletions
+11 -1
View File
@@ -830,5 +830,15 @@
"welcomeHeadline": "عالمك، على الهواء مباشرة",
"yourStationsTitle": "محطاتك",
"nowListeningLabel": "الاستماع الآن",
"popularNowTitle": "الأكثر شيوعًا الآن"
"popularNowTitle": "الأكثر شيوعًا الآن",
"eqCustomActionEnableLabel": "تفعيل الموازن",
"eqCustomActionDisableLabel": "إيقاف الموازن",
"eqCustomActionPresetLabel": "الإعداد المسبق: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
}
}
+11 -1
View File
@@ -830,5 +830,15 @@
"welcomeHeadline": "আপনার বিশ্ব, সরাসরি",
"yourStationsTitle": "আপনার স্টেশন",
"nowListeningLabel": "এখন শোনা হচ্ছে",
"popularNowTitle": "এখন জনপ্রিয়"
"popularNowTitle": "এখন জনপ্রিয়",
"eqCustomActionEnableLabel": "ইকুয়ালাইজার চালু করুন",
"eqCustomActionDisableLabel": "ইকুয়ালাইজার বন্ধ করুন",
"eqCustomActionPresetLabel": "প্রিসেট: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
}
}
+11 -1
View File
@@ -830,5 +830,15 @@
"welcomeHeadline": "Deine Welt, live",
"yourStationsTitle": "Deine Sender",
"nowListeningLabel": "Läuft gerade",
"popularNowTitle": "Jetzt beliebt"
"popularNowTitle": "Jetzt beliebt",
"eqCustomActionEnableLabel": "Equalizer aktivieren",
"eqCustomActionDisableLabel": "Equalizer deaktivieren",
"eqCustomActionPresetLabel": "Voreinstellung: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
}
}
+11 -1
View File
@@ -830,5 +830,15 @@
"welcomeBullet2Subtitle": "Your favorites and local music in the car",
"welcomeBullet3Title": "Music alarms",
"welcomeBullet3Subtitle": "With gradual volume rise and vacation mode",
"welcomeCtaLabel": "Start listening"
"welcomeCtaLabel": "Start listening",
"eqCustomActionEnableLabel": "Enable equalizer",
"eqCustomActionDisableLabel": "Disable equalizer",
"eqCustomActionPresetLabel": "Preset: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
}
}
+11 -1
View File
@@ -789,5 +789,15 @@
"welcomeBullet2Subtitle": "Tus favoritas y tu música local en el auto",
"welcomeBullet3Title": "Alarmas musicales",
"welcomeBullet3Subtitle": "Con subida progresiva y modo vacaciones",
"welcomeCtaLabel": "Empezar a escuchar"
"welcomeCtaLabel": "Empezar a escuchar",
"eqCustomActionEnableLabel": "Activar ecualizador",
"eqCustomActionDisableLabel": "Desactivar ecualizador",
"eqCustomActionPresetLabel": "Preset: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
}
}
+11 -1
View File
@@ -830,5 +830,15 @@
"welcomeHeadline": "Votre monde, en direct",
"yourStationsTitle": "Vos stations",
"nowListeningLabel": "En cours d'écoute",
"popularNowTitle": "Populaire maintenant"
"popularNowTitle": "Populaire maintenant",
"eqCustomActionEnableLabel": "Activer l'égaliseur",
"eqCustomActionDisableLabel": "Désactiver l'égaliseur",
"eqCustomActionPresetLabel": "Préréglage : {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
}
}
+11 -1
View File
@@ -830,5 +830,15 @@
"welcomeHeadline": "आपकी दुनिया, लाइव",
"yourStationsTitle": "आपके स्टेशन",
"nowListeningLabel": "अभी सुन रहे हैं",
"popularNowTitle": "अभी लोकप्रिय"
"popularNowTitle": "अभी लोकप्रिय",
"eqCustomActionEnableLabel": "इक्वलाइज़र चालू करें",
"eqCustomActionDisableLabel": "इक्वलाइज़र बंद करें",
"eqCustomActionPresetLabel": "प्रीसेट: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
}
}
+11 -1
View File
@@ -830,5 +830,15 @@
"welcomeHeadline": "Duniamu, secara langsung",
"yourStationsTitle": "Stasiun Anda",
"nowListeningLabel": "Sedang mendengarkan",
"popularNowTitle": "Populer sekarang"
"popularNowTitle": "Populer sekarang",
"eqCustomActionEnableLabel": "Aktifkan equalizer",
"eqCustomActionDisableLabel": "Nonaktifkan equalizer",
"eqCustomActionPresetLabel": "Prasetel: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
}
}
+11 -1
View File
@@ -830,5 +830,15 @@
"welcomeHeadline": "Il tuo mondo, in diretta",
"yourStationsTitle": "Le tue emittenti",
"nowListeningLabel": "In ascolto ora",
"popularNowTitle": "Popolari ora"
"popularNowTitle": "Popolari ora",
"eqCustomActionEnableLabel": "Attiva equalizzatore",
"eqCustomActionDisableLabel": "Disattiva equalizzatore",
"eqCustomActionPresetLabel": "Preset attivo: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
}
}
+11 -1
View File
@@ -830,5 +830,15 @@
"welcomeHeadline": "あなたの世界を、ライブで",
"yourStationsTitle": "あなたの局",
"nowListeningLabel": "再生中",
"popularNowTitle": "今人気"
"popularNowTitle": "今人気",
"eqCustomActionEnableLabel": "イコライザーをオンにする",
"eqCustomActionDisableLabel": "イコライザーをオフにする",
"eqCustomActionPresetLabel": "プリセット: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
}
}
+11 -1
View File
@@ -830,5 +830,15 @@
"welcomeHeadline": "Seu mundo, ao vivo",
"yourStationsTitle": "Suas estações",
"nowListeningLabel": "Ouvindo agora",
"popularNowTitle": "Populares agora"
"popularNowTitle": "Populares agora",
"eqCustomActionEnableLabel": "Ativar equalizador",
"eqCustomActionDisableLabel": "Desativar equalizador",
"eqCustomActionPresetLabel": "Predefinição: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
}
}
+11 -1
View File
@@ -830,5 +830,15 @@
"welcomeHeadline": "Ваш мир, в прямом эфире",
"yourStationsTitle": "Ваши станции",
"nowListeningLabel": "Сейчас слушаете",
"popularNowTitle": "Популярно сейчас"
"popularNowTitle": "Популярно сейчас",
"eqCustomActionEnableLabel": "Включить эквалайзер",
"eqCustomActionDisableLabel": "Выключить эквалайзер",
"eqCustomActionPresetLabel": "Пресет: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
}
}
+11 -1
View File
@@ -830,5 +830,15 @@
"welcomeHeadline": "你的世界,直播中",
"yourStationsTitle": "你的电台",
"nowListeningLabel": "正在收听",
"popularNowTitle": "当前热门"
"popularNowTitle": "当前热门",
"eqCustomActionEnableLabel": "启用均衡器",
"eqCustomActionDisableLabel": "关闭均衡器",
"eqCustomActionPresetLabel": "预设:{preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
}
}
+18
View File
@@ -3049,6 +3049,24 @@ abstract class AppLocalizations {
/// In es, this message translates to:
/// **'Empezar a escuchar'**
String get welcomeCtaLabel;
/// No description provided for @eqCustomActionEnableLabel.
///
/// In es, this message translates to:
/// **'Activar ecualizador'**
String get eqCustomActionEnableLabel;
/// No description provided for @eqCustomActionDisableLabel.
///
/// In es, this message translates to:
/// **'Desactivar ecualizador'**
String get eqCustomActionDisableLabel;
/// No description provided for @eqCustomActionPresetLabel.
///
/// In es, this message translates to:
/// **'Preset: {preset}'**
String eqCustomActionPresetLabel(String preset);
}
class _AppLocalizationsDelegate
+11
View File
@@ -1676,4 +1676,15 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get welcomeCtaLabel => 'ابدأ الاستماع';
@override
String get eqCustomActionEnableLabel => 'تفعيل الموازن';
@override
String get eqCustomActionDisableLabel => 'إيقاف الموازن';
@override
String eqCustomActionPresetLabel(String preset) {
return 'الإعداد المسبق: $preset';
}
}
+11
View File
@@ -1685,4 +1685,15 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get welcomeCtaLabel => 'শোনা শুরু করুন';
@override
String get eqCustomActionEnableLabel => 'ইকুয়ালাইজার চালু করুন';
@override
String get eqCustomActionDisableLabel => 'ইকুয়ালাইজার বন্ধ করুন';
@override
String eqCustomActionPresetLabel(String preset) {
return 'প্রিসেট: $preset';
}
}
+11
View File
@@ -1698,4 +1698,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Jetzt hören';
@override
String get eqCustomActionEnableLabel => 'Equalizer aktivieren';
@override
String get eqCustomActionDisableLabel => 'Equalizer deaktivieren';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Voreinstellung: $preset';
}
}
+11
View File
@@ -1678,4 +1678,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Start listening';
@override
String get eqCustomActionEnableLabel => 'Enable equalizer';
@override
String get eqCustomActionDisableLabel => 'Disable equalizer';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Preset: $preset';
}
}
+11
View File
@@ -1692,4 +1692,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Empezar a escuchar';
@override
String get eqCustomActionEnableLabel => 'Activar ecualizador';
@override
String get eqCustomActionDisableLabel => 'Desactivar ecualizador';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Preset: $preset';
}
}
+11
View File
@@ -1701,4 +1701,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Commencer à écouter';
@override
String get eqCustomActionEnableLabel => 'Activer l\'égaliseur';
@override
String get eqCustomActionDisableLabel => 'Désactiver l\'égaliseur';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Préréglage : $preset';
}
}
+11
View File
@@ -1680,4 +1680,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get welcomeCtaLabel => 'सुनना शुरू करें';
@override
String get eqCustomActionEnableLabel => 'इक्वलाइज़र चालू करें';
@override
String get eqCustomActionDisableLabel => 'इक्वलाइज़र बंद करें';
@override
String eqCustomActionPresetLabel(String preset) {
return 'प्रीसेट: $preset';
}
}
+11
View File
@@ -1688,4 +1688,15 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Mulai mendengarkan';
@override
String get eqCustomActionEnableLabel => 'Aktifkan equalizer';
@override
String get eqCustomActionDisableLabel => 'Nonaktifkan equalizer';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Prasetel: $preset';
}
}
+11
View File
@@ -1700,4 +1700,15 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Inizia ad ascoltare';
@override
String get eqCustomActionEnableLabel => 'Attiva equalizzatore';
@override
String get eqCustomActionDisableLabel => 'Disattiva equalizzatore';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Preset attivo: $preset';
}
}
+11
View File
@@ -1631,4 +1631,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get welcomeCtaLabel => '聴き始める';
@override
String get eqCustomActionEnableLabel => 'イコライザーをオンにする';
@override
String get eqCustomActionDisableLabel => 'イコライザーをオフにする';
@override
String eqCustomActionPresetLabel(String preset) {
return 'プリセット: $preset';
}
}
+11
View File
@@ -1688,4 +1688,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Começar a ouvir';
@override
String get eqCustomActionEnableLabel => 'Ativar equalizador';
@override
String get eqCustomActionDisableLabel => 'Desativar equalizador';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Predefinição: $preset';
}
}
+11
View File
@@ -1692,4 +1692,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Начать слушать';
@override
String get eqCustomActionEnableLabel => 'Включить эквалайзер';
@override
String get eqCustomActionDisableLabel => 'Выключить эквалайзер';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Пресет: $preset';
}
}
+11
View File
@@ -1623,4 +1623,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get welcomeCtaLabel => '开始收听';
@override
String get eqCustomActionEnableLabel => '启用均衡器';
@override
String get eqCustomActionDisableLabel => '关闭均衡器';
@override
String eqCustomActionPresetLabel(String preset) {
return '预设:$preset';
}
}
+195 -32
View File
@@ -100,6 +100,91 @@ Emisora emisoraDesdeMediaItem(MediaItem mediaItem) {
);
}
/// Custom-action names for the equalizer's `PlaybackStateCompat` custom
/// actions on the now-playing screen (Design "EQ custom actions", item 4).
/// Public consts so tests and this file's own `customAction` dispatch share
/// the exact same literals; distinct from every browse-tree media-id prefix
/// in `navegacion_auto.dart` (they live in a completely different
/// `MediaControl`/`customAction` namespace, never compared against a
/// media id).
const accionEqToggle = 'eq_toggle';
const accionEqPresetSiguiente = 'eq_preset_siguiente';
/// Advances to the NEXT factory preset after [actual] in [presets] order
/// (Design "EQ custom actions — cycling presets", item 4): wraps around
/// after the last one. When [actual] is not found in [presets] (e.g. a
/// user-tweaked "Personalizado" preset from `EstadoEcualizador.cambiarBanda`),
/// starts from the FIRST preset rather than throwing — cycling from an
/// unknown state always lands somewhere sane. Pure, no I/O.
///
/// [presets] defaults to [PresetEcualizador.presets] — not a literal default
/// value, since that field is `static final` (not `const`) and Dart default
/// parameter values must be compile-time constants.
PresetEcualizador presetSiguiente(
PresetEcualizador actual, {
List<PresetEcualizador>? presets,
}) {
final lista = presets ?? PresetEcualizador.presets;
final indice = lista.indexWhere((p) => p == actual);
if (indice == -1) return lista.first;
return lista[(indice + 1) % lista.length];
}
/// Localizes a preset's raw `nombre` for the equalizer custom action's
/// label (Design "EQ custom actions", item 4) — mirrors
/// `ecualizador_widget.dart`'s private `_nombrePreset` mapping (duplicated
/// rather than shared: that file is UI-widget layer, this one is the
/// service/handler layer, and the mapping is a single small switch, not
/// worth a cross-layer import for). An unrecognized name (e.g. a future
/// user-named custom preset) falls through to the raw name verbatim.
String nombrePresetVisible(AppLocalizations l10n, String nombre) {
return switch (nombre) {
'Flat' => l10n.equalizerPresetFlat,
'Rock' => l10n.equalizerPresetRock,
'Pop' => l10n.equalizerPresetPop,
'Bass Boost' => l10n.equalizerPresetBassBoost,
'Jazz' => l10n.equalizerPresetJazz,
'Voz' => l10n.equalizerPresetVoice,
'Personalizado' => l10n.equalizerPresetCustom,
_ => nombre,
};
}
/// Builds the equalizer's custom-action `MediaControl`s for the now-playing
/// screen (Design "EQ custom actions", item 4) — deliberately just 2: an
/// on/off toggle plus a cycling-preset action, NOT one action per preset,
/// since Android Auto only surfaces a limited number of custom actions.
/// Empty when [disponible] is false (gate on EQ availability, mirrors the
/// existing `debeReaplicarEcualizador`/`_eqDisponible` gate) — a device
/// without the native Equalizer effect gets no EQ actions at all, not
/// broken ones. Reuses the SAME bundled `ic_stat_pluriwave` drawable the
/// notification's own status-bar icon already uses (an equalizer-bars
/// glyph) — zero new native assets. Pure, no handler dependency.
List<MediaControl> controlesEcualizadorPersonalizados({
required bool disponible,
required bool activo,
required PresetEcualizador presetActual,
required AppLocalizations l10n,
}) {
if (!disponible) return const [];
return [
MediaControl.custom(
androidIcon: 'drawable/ic_stat_pluriwave',
label: activo
? l10n.eqCustomActionDisableLabel
: l10n.eqCustomActionEnableLabel,
name: accionEqToggle,
),
MediaControl.custom(
androidIcon: 'drawable/ic_stat_pluriwave',
label: l10n.eqCustomActionPresetLabel(
nombrePresetVisible(l10n, presetActual.nombre),
),
name: accionEqPresetSiguiente,
),
];
}
/// Wrapper de alto nivel para el UI.
class ServicioAudio {
PluriWaveAudioHandler get _handler {
@@ -319,12 +404,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler
final colaActiva = _colaLocal != null;
playbackState.add(
playbackState.value.copyWith(
controls: [
if (colaActiva) MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
if (colaActiva) MediaControl.skipToNext,
],
controls: _controlesTransporte(
colaActiva: colaActiva,
playing: playing,
),
systemActions: {
MediaAction.seek,
MediaAction.stop,
@@ -369,6 +452,49 @@ class PluriWaveAudioHandler extends BaseAudioHandler
});
}
/// The full transport `controls` list for a `playbackState` push (item 4):
/// the existing skip/play-pause/stop set, plus the equalizer's custom
/// actions appended at the end. Appending (rather than interleaving) keeps
/// [MediaControl.skipToPrevious]/play-pause/stop/[MediaControl.skipToNext]
/// at their existing indices 0-3, so `androidCompactActionIndices`
/// (`[colaActiva ? 1 : 0]`) stays correct unchanged.
List<MediaControl> _controlesTransporte({
required bool colaActiva,
required bool playing,
}) => [
if (colaActiva) MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
if (colaActiva) MediaControl.skipToNext,
..._controlesEqPersonalizados(),
];
List<MediaControl> _controlesEqPersonalizados() =>
controlesEcualizadorPersonalizados(
disponible: _eqDisponible,
activo: _ecualizadorActivo,
presetActual: _presetActual,
l10n: _textos,
);
/// Re-pushes `playbackState` with a freshly built controls list (item 4):
/// called whenever EQ availability/enabled/preset state changes outside a
/// player-state transition (a custom-action tap, or a phone-side preset/
/// toggle change), so the equalizer custom actions' label and current-
/// preset name stay in sync on the now-playing screen without waiting for
/// an unrelated player event. Idempotent and cheap (no native calls) —
/// safe to call from any EQ state-changing path.
void _actualizarControlesEq() {
playbackState.add(
playbackState.value.copyWith(
controls: _controlesTransporte(
colaActiva: _colaLocal != null,
playing: playbackState.value.playing,
),
),
);
}
/// Gestiona cualquier error de reproducción de ExoPlayer.
///
/// Network-class failures while the user still intends to play enter the
@@ -754,6 +880,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler
} catch (_) {
_eqDisponible = false;
}
// Item 4: an availability flip (e.g. a station switch that lands on a
// device without the native Equalizer effect) must show/hide the EQ
// custom actions immediately, not wait for a coincidental later
// player-state event.
_actualizarControlesEq();
}
/// Pure re-apply decision for a native session-id emission. No side effects.
@@ -772,25 +903,31 @@ class PluriWaveAudioHandler extends BaseAudioHandler
/// Aplica un preset al ecualizador nativo Android.
Future<void> aplicarPreset(PresetEcualizador preset) async {
_presetActual = preset;
if (!_eqDisponible) return;
try {
await _eq.setEnabled(_ecualizadorActivo);
if (!_ecualizadorActivo) return;
final params = await _eq.parameters;
for (
int i = 0;
i < params.bands.length && i < preset.bandas.length;
i++
) {
await params.bands[i].setGain(
_mapearGananciaNativa(
preset.bandas[i],
minDecibels: params.minDecibels,
maxDecibels: params.maxDecibels,
),
);
}
} catch (_) {}
if (_eqDisponible) {
try {
await _eq.setEnabled(_ecualizadorActivo);
if (_ecualizadorActivo) {
final params = await _eq.parameters;
for (
int i = 0;
i < params.bands.length && i < preset.bandas.length;
i++
) {
await params.bands[i].setGain(
_mapearGananciaNativa(
preset.bandas[i],
minDecibels: params.minDecibels,
maxDecibels: params.maxDecibels,
),
);
}
}
} catch (_) {}
}
// Item 4: keeps the EQ custom action's preset-cycle label in sync
// regardless of WHO changed the preset (a car customAction tap or the
// phone settings screen via EstadoEcualizador) — single chokepoint.
_actualizarControlesEq();
}
/// Ajusta una banda individual.
@@ -826,13 +963,18 @@ class PluriWaveAudioHandler extends BaseAudioHandler
Future<void> setEcualizadorActivo(bool activo) async {
_ecualizadorActivo = activo;
if (!_eqDisponible) return;
try {
await _eq.setEnabled(activo);
if (activo) {
await aplicarPreset(_presetActual);
}
} catch (_) {}
if (_eqDisponible) {
try {
await _eq.setEnabled(activo);
if (activo) {
await aplicarPreset(_presetActual);
}
} catch (_) {}
}
// Item 4: keeps the EQ custom action's on/off label in sync regardless
// of WHO toggled it (a car customAction tap or the phone settings
// screen via EstadoEcualizador).
_actualizarControlesEq();
}
Future<void> setVolumen(double vol) async {
@@ -929,6 +1071,27 @@ class PluriWaveAudioHandler extends BaseAudioHandler
await _reproducirEntradaCola(anterior.actual);
}
/// Dispatches the equalizer's 2 custom actions (item 4, Design "EQ custom
/// actions"): `accionEqToggle` flips on/off, `accionEqPresetSiguiente`
/// cycles to the next factory preset. Both delegate to the existing
/// [setEcualizadorActivo]/[aplicarPreset] — the SAME entry points the
/// phone settings screen uses via `EstadoEcualizador` — so a car tap and a
/// phone tap have identical effects and both refresh the custom action's
/// label via `_actualizarControlesEq()` (already wired into those two
/// methods). Any other [name] is a no-op — never throws.
@override
Future<dynamic> customAction(
String name, [
Map<String, dynamic>? extras,
]) async {
switch (name) {
case accionEqToggle:
await setEcualizadorActivo(!_ecualizadorActivo);
case accionEqPresetSiguiente:
await aplicarPreset(presetSiguiente(_presetActual));
}
}
@override
Future<void> onTaskRemoved() async {
await stop();
@@ -0,0 +1,189 @@
import 'dart:ui' show Locale;
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Item 4 (Android Auto: equalizer custom actions) — the pure, handler-
/// independent half of the fix. `PluriWaveAudioHandler` cannot be
/// instantiated in unit tests (a real `just_audio.AudioPlayer` requires
/// platform MethodChannels), so the preset-cycling decision, the preset-name
/// localization and the `MediaControl` list construction are extracted as
/// pure top-level functions here. The handler's own `customAction` dispatch
/// and `playbackState` wiring are static-review-only, same as the existing
/// EQ re-apply/session-id wiring.
void main() {
final l10n = lookupAppLocalizations(const Locale('es'));
group('presetSiguiente (item 4 — cycling presets)', () {
test('advances to the next preset in order', () {
expect(
presetSiguiente(PresetEcualizador.flat),
PresetEcualizador.rock,
);
expect(
presetSiguiente(PresetEcualizador.rock),
PresetEcualizador.pop,
);
});
test('wraps around after the last preset', () {
expect(
presetSiguiente(PresetEcualizador.presets.last),
PresetEcualizador.presets.first,
);
});
test(
'an unknown/custom preset (e.g. a user-tweaked "Personalizado" band '
'set) starts from the FIRST preset instead of throwing',
() {
final personalizado = PresetEcualizador(
nombre: 'Personalizado',
bandas: [1.0, 2.0, 3.0, 4.0, 5.0],
);
expect(
presetSiguiente(personalizado),
PresetEcualizador.presets.first,
);
},
);
test('respects an injected presets list instead of the default 6', () {
final propios = [PresetEcualizador.jazz, PresetEcualizador.voz];
expect(
presetSiguiente(PresetEcualizador.jazz, presets: propios),
PresetEcualizador.voz,
);
expect(
presetSiguiente(PresetEcualizador.voz, presets: propios),
PresetEcualizador.jazz,
);
});
});
group('nombrePresetVisible (item 4)', () {
test('maps every factory preset name to its localized ARB string', () {
expect(nombrePresetVisible(l10n, 'Flat'), l10n.equalizerPresetFlat);
expect(nombrePresetVisible(l10n, 'Rock'), l10n.equalizerPresetRock);
expect(nombrePresetVisible(l10n, 'Pop'), l10n.equalizerPresetPop);
expect(
nombrePresetVisible(l10n, 'Bass Boost'),
l10n.equalizerPresetBassBoost,
);
expect(nombrePresetVisible(l10n, 'Jazz'), l10n.equalizerPresetJazz);
expect(nombrePresetVisible(l10n, 'Voz'), l10n.equalizerPresetVoice);
expect(
nombrePresetVisible(l10n, 'Personalizado'),
l10n.equalizerPresetCustom,
);
});
test('an unrecognized name falls through verbatim', () {
expect(nombrePresetVisible(l10n, 'Mi Preset Guardado'), 'Mi Preset Guardado');
});
});
group('controlesEcualizadorPersonalizados (item 4)', () {
test('empty when the equalizer is not available on this device', () {
final controles = controlesEcualizadorPersonalizados(
disponible: false,
activo: true,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
expect(controles, isEmpty);
});
test(
'exactly 2 custom actions when available: on/off toggle + preset '
'cycle -- Android Auto shows a limited number of custom actions, so '
'this is deliberately NOT one action per preset',
() {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
presetActual: PresetEcualizador.rock,
l10n: l10n,
);
expect(controles, hasLength(2));
expect(controles.every((c) => c.action == MediaAction.custom), isTrue);
},
);
test('toggle label reflects ON -> shows "disable" action', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
final toggle = controles.firstWhere(
(c) => c.customAction?.name == accionEqToggle,
);
expect(toggle.label, l10n.eqCustomActionDisableLabel);
});
test('toggle label reflects OFF -> shows "enable" action', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: false,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
final toggle = controles.firstWhere(
(c) => c.customAction?.name == accionEqToggle,
);
expect(toggle.label, l10n.eqCustomActionEnableLabel);
});
test('preset-cycle label shows the CURRENT preset localized name', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
presetActual: PresetEcualizador.jazz,
l10n: l10n,
);
final ciclo = controles.firstWhere(
(c) => c.customAction?.name == accionEqPresetSiguiente,
);
expect(
ciclo.label,
l10n.eqCustomActionPresetLabel(l10n.equalizerPresetJazz),
);
});
test('both actions reuse the bundled notification drawable (zero new '
'native assets)', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
expect(
controles.every((c) => c.androidIcon == 'drawable/ic_stat_pluriwave'),
isTrue,
);
});
});
group('action name constants (item 4 — collision-free with car-tree ids)', () {
test('names are non-empty, distinct, and do not collide with any '
'existing browse-tree media-id prefix', () {
expect(accionEqToggle, isNotEmpty);
expect(accionEqPresetSiguiente, isNotEmpty);
expect(accionEqToggle, isNot(equals(accionEqPresetSiguiente)));
});
});
}