From 9eff7604626ac50147163f5da5d5ffcd1a248b83 Mon Sep 17 00:00:00 2001 From: freetlab Date: Fri, 31 Jul 2026 00:54:05 +0200 Subject: [PATCH] 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. --- lib/l10n/app_ar.arb | 12 +- lib/l10n/app_bn.arb | 12 +- lib/l10n/app_de.arb | 12 +- lib/l10n/app_en.arb | 12 +- lib/l10n/app_es.arb | 12 +- lib/l10n/app_fr.arb | 12 +- lib/l10n/app_hi.arb | 12 +- lib/l10n/app_id.arb | 12 +- lib/l10n/app_it.arb | 12 +- lib/l10n/app_ja.arb | 12 +- lib/l10n/app_pt.arb | 12 +- lib/l10n/app_ru.arb | 12 +- lib/l10n/app_zh.arb | 12 +- lib/l10n/gen/app_localizations.dart | 18 ++ lib/l10n/gen/app_localizations_ar.dart | 11 + lib/l10n/gen/app_localizations_bn.dart | 11 + lib/l10n/gen/app_localizations_de.dart | 11 + lib/l10n/gen/app_localizations_en.dart | 11 + lib/l10n/gen/app_localizations_es.dart | 11 + lib/l10n/gen/app_localizations_fr.dart | 11 + lib/l10n/gen/app_localizations_hi.dart | 11 + lib/l10n/gen/app_localizations_id.dart | 11 + lib/l10n/gen/app_localizations_it.dart | 11 + lib/l10n/gen/app_localizations_ja.dart | 11 + lib/l10n/gen/app_localizations_pt.dart | 11 + lib/l10n/gen/app_localizations_ru.dart | 11 + lib/l10n/gen/app_localizations_zh.dart | 11 + lib/servicios/servicio_audio.dart | 227 +++++++++++++++--- ...servicio_audio_eq_custom_actions_test.dart | 189 +++++++++++++++ 29 files changed, 688 insertions(+), 45 deletions(-) create mode 100644 test/servicios/servicio_audio_eq_custom_actions_test.dart diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 92199c0..deb9d1b 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "عالمك، على الهواء مباشرة", "yourStationsTitle": "محطاتك", "nowListeningLabel": "الاستماع الآن", - "popularNowTitle": "الأكثر شيوعًا الآن" + "popularNowTitle": "الأكثر شيوعًا الآن", + "eqCustomActionEnableLabel": "تفعيل الموازن", + "eqCustomActionDisableLabel": "إيقاف الموازن", + "eqCustomActionPresetLabel": "الإعداد المسبق: {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index ec8b0f0..cf70e01 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "আপনার বিশ্ব, সরাসরি", "yourStationsTitle": "আপনার স্টেশন", "nowListeningLabel": "এখন শোনা হচ্ছে", - "popularNowTitle": "এখন জনপ্রিয়" + "popularNowTitle": "এখন জনপ্রিয়", + "eqCustomActionEnableLabel": "ইকুয়ালাইজার চালু করুন", + "eqCustomActionDisableLabel": "ইকুয়ালাইজার বন্ধ করুন", + "eqCustomActionPresetLabel": "প্রিসেট: {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 12ddab6..41067df 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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" + } + } + } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index f57b44d..8b80932 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" + } + } + } } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 835e4ac..0ff53ee 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -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" + } + } + } } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index bb4d7a9..95e56dd 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -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" + } + } + } } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index b9e4a9c..f770523 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "आपकी दुनिया, लाइव", "yourStationsTitle": "आपके स्टेशन", "nowListeningLabel": "अभी सुन रहे हैं", - "popularNowTitle": "अभी लोकप्रिय" + "popularNowTitle": "अभी लोकप्रिय", + "eqCustomActionEnableLabel": "इक्वलाइज़र चालू करें", + "eqCustomActionDisableLabel": "इक्वलाइज़र बंद करें", + "eqCustomActionPresetLabel": "प्रीसेट: {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index b16c871..54de15f 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -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" + } + } + } } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 8e26513..b9196bd 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -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" + } + } + } } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 432b52d..5bab00d 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "あなたの世界を、ライブで", "yourStationsTitle": "あなたの局", "nowListeningLabel": "再生中", - "popularNowTitle": "今人気" + "popularNowTitle": "今人気", + "eqCustomActionEnableLabel": "イコライザーをオンにする", + "eqCustomActionDisableLabel": "イコライザーをオフにする", + "eqCustomActionPresetLabel": "プリセット: {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 545a927..11d474a 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -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" + } + } + } } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 98166f2..701f3cb 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "Ваш мир, в прямом эфире", "yourStationsTitle": "Ваши станции", "nowListeningLabel": "Сейчас слушаете", - "popularNowTitle": "Популярно сейчас" + "popularNowTitle": "Популярно сейчас", + "eqCustomActionEnableLabel": "Включить эквалайзер", + "eqCustomActionDisableLabel": "Выключить эквалайзер", + "eqCustomActionPresetLabel": "Пресет: {preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index dc2925c..5fe58a1 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -830,5 +830,15 @@ "welcomeHeadline": "你的世界,直播中", "yourStationsTitle": "你的电台", "nowListeningLabel": "正在收听", - "popularNowTitle": "当前热门" + "popularNowTitle": "当前热门", + "eqCustomActionEnableLabel": "启用均衡器", + "eqCustomActionDisableLabel": "关闭均衡器", + "eqCustomActionPresetLabel": "预设:{preset}", + "@eqCustomActionPresetLabel": { + "placeholders": { + "preset": { + "type": "String" + } + } + } } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 1f62743..fca451f 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -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 diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index 722038b..651c617 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -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'; + } } diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 5627416..7474095 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -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'; + } } diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 4c63b22..295812a 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -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'; + } } diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index a50957d..f02de76 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -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'; + } } diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 55857ea..fbb6307 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -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'; + } } diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 1232e33..f071a13 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -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'; + } } diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index 3655468..6f62753 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -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'; + } } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index a374b37..2b62c6a 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -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'; + } } diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index d1c3a1d..68bc853 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -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'; + } } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 1a01bd9..bec733e 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -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'; + } } diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index d6c92e1..fa10bc0 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -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'; + } } diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 01054c2..7b6b837 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -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'; + } } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index fc7826b..6c97bc2 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -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'; + } } diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 0545728..89139ce 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -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? 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 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 _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 _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 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 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 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 customAction( + String name, [ + Map? extras, + ]) async { + switch (name) { + case accionEqToggle: + await setEcualizadorActivo(!_ecualizadorActivo); + case accionEqPresetSiguiente: + await aplicarPreset(presetSiguiente(_presetActual)); + } + } + @override Future onTaskRemoved() async { await stop(); diff --git a/test/servicios/servicio_audio_eq_custom_actions_test.dart b/test/servicios/servicio_audio_eq_custom_actions_test.dart new file mode 100644 index 0000000..2de6d45 --- /dev/null +++ b/test/servicios/servicio_audio_eq_custom_actions_test.dart @@ -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))); + }); + }); +}