feat(auto,eq,alarmas): address the second round of on-device feedback
- Local-music subfolders sort before files, so they no longer fall past the 50-item page boundary and vanish from the car - Playing a folder now plays its subfolders too, bounded at depth 4 and 500 tracks to cap native SAF round-trips - Stations and tracks with no artwork fall back to on-brand art instead of an empty tile - Equalizer on/off and preset cycling are reachable from the car's now-playing screen as two custom actions - The equalizer is re-applied after an audio-focus interruption, not only when the audio session id changes -- a nav-app prompt keeps the same session, which is why the earlier fix missed this case - The alarm list shows which days an alarm actually fires on Tests: 933 -> 991.
This commit is contained in:
+12
-1
@@ -830,5 +830,16 @@
|
||||
"welcomeHeadline": "عالمك، على الهواء مباشرة",
|
||||
"yourStationsTitle": "محطاتك",
|
||||
"nowListeningLabel": "الاستماع الآن",
|
||||
"popularNowTitle": "الأكثر شيوعًا الآن"
|
||||
"popularNowTitle": "الأكثر شيوعًا الآن",
|
||||
"eqCustomActionEnableLabel": "تفعيل الموازن",
|
||||
"eqCustomActionDisableLabel": "إيقاف الموازن",
|
||||
"eqCustomActionPresetLabel": "الإعداد المسبق: {preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "متوقفة مؤقتًا بسبب الإجازة"
|
||||
}
|
||||
|
||||
+12
-1
@@ -830,5 +830,16 @@
|
||||
"welcomeHeadline": "আপনার বিশ্ব, সরাসরি",
|
||||
"yourStationsTitle": "আপনার স্টেশন",
|
||||
"nowListeningLabel": "এখন শোনা হচ্ছে",
|
||||
"popularNowTitle": "এখন জনপ্রিয়"
|
||||
"popularNowTitle": "এখন জনপ্রিয়",
|
||||
"eqCustomActionEnableLabel": "ইকুয়ালাইজার চালু করুন",
|
||||
"eqCustomActionDisableLabel": "ইকুয়ালাইজার বন্ধ করুন",
|
||||
"eqCustomActionPresetLabel": "প্রিসেট: {preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "ছুটির কারণে বিরত"
|
||||
}
|
||||
|
||||
+12
-1
@@ -830,5 +830,16 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "Wegen Urlaub pausiert"
|
||||
}
|
||||
|
||||
+12
-1
@@ -830,5 +830,16 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "Paused for vacation"
|
||||
}
|
||||
|
||||
+12
-1
@@ -789,5 +789,16 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "Pausada por vacaciones"
|
||||
}
|
||||
|
||||
+12
-1
@@ -830,5 +830,16 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "En pause pour les vacances"
|
||||
}
|
||||
|
||||
+12
-1
@@ -830,5 +830,16 @@
|
||||
"welcomeHeadline": "आपकी दुनिया, लाइव",
|
||||
"yourStationsTitle": "आपके स्टेशन",
|
||||
"nowListeningLabel": "अभी सुन रहे हैं",
|
||||
"popularNowTitle": "अभी लोकप्रिय"
|
||||
"popularNowTitle": "अभी लोकप्रिय",
|
||||
"eqCustomActionEnableLabel": "इक्वलाइज़र चालू करें",
|
||||
"eqCustomActionDisableLabel": "इक्वलाइज़र बंद करें",
|
||||
"eqCustomActionPresetLabel": "प्रीसेट: {preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "छुट्टी के कारण रोका गया"
|
||||
}
|
||||
|
||||
+12
-1
@@ -830,5 +830,16 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "Dijeda karena liburan"
|
||||
}
|
||||
|
||||
+12
-1
@@ -830,5 +830,16 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "In pausa per le vacanze"
|
||||
}
|
||||
|
||||
+12
-1
@@ -830,5 +830,16 @@
|
||||
"welcomeHeadline": "あなたの世界を、ライブで",
|
||||
"yourStationsTitle": "あなたの局",
|
||||
"nowListeningLabel": "再生中",
|
||||
"popularNowTitle": "今人気"
|
||||
"popularNowTitle": "今人気",
|
||||
"eqCustomActionEnableLabel": "イコライザーをオンにする",
|
||||
"eqCustomActionDisableLabel": "イコライザーをオフにする",
|
||||
"eqCustomActionPresetLabel": "プリセット: {preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "休暇のため一時停止中"
|
||||
}
|
||||
|
||||
+12
-1
@@ -830,5 +830,16 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "Pausada por férias"
|
||||
}
|
||||
|
||||
+12
-1
@@ -830,5 +830,16 @@
|
||||
"welcomeHeadline": "Ваш мир, в прямом эфире",
|
||||
"yourStationsTitle": "Ваши станции",
|
||||
"nowListeningLabel": "Сейчас слушаете",
|
||||
"popularNowTitle": "Популярно сейчас"
|
||||
"popularNowTitle": "Популярно сейчас",
|
||||
"eqCustomActionEnableLabel": "Включить эквалайзер",
|
||||
"eqCustomActionDisableLabel": "Выключить эквалайзер",
|
||||
"eqCustomActionPresetLabel": "Пресет: {preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "Приостановлено на время отпуска"
|
||||
}
|
||||
|
||||
+12
-1
@@ -830,5 +830,16 @@
|
||||
"welcomeHeadline": "你的世界,直播中",
|
||||
"yourStationsTitle": "你的电台",
|
||||
"nowListeningLabel": "正在收听",
|
||||
"popularNowTitle": "当前热门"
|
||||
"popularNowTitle": "当前热门",
|
||||
"eqCustomActionEnableLabel": "启用均衡器",
|
||||
"eqCustomActionDisableLabel": "关闭均衡器",
|
||||
"eqCustomActionPresetLabel": "预设:{preset}",
|
||||
"@eqCustomActionPresetLabel": {
|
||||
"placeholders": {
|
||||
"preset": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"alarmCardVacationPausedBadge": "因假期已暂停"
|
||||
}
|
||||
|
||||
@@ -3049,6 +3049,30 @@ 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);
|
||||
|
||||
/// No description provided for @alarmCardVacationPausedBadge.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Pausada por vacaciones'**
|
||||
String get alarmCardVacationPausedBadge;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -1676,4 +1676,18 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => 'ابدأ الاستماع';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'تفعيل الموازن';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'إيقاف الموازن';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'الإعداد المسبق: $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'متوقفة مؤقتًا بسبب الإجازة';
|
||||
}
|
||||
|
||||
@@ -1685,4 +1685,18 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => 'শোনা শুরু করুন';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'ইকুয়ালাইজার চালু করুন';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'ইকুয়ালাইজার বন্ধ করুন';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'প্রিসেট: $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'ছুটির কারণে বিরত';
|
||||
}
|
||||
|
||||
@@ -1698,4 +1698,18 @@ 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';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'Wegen Urlaub pausiert';
|
||||
}
|
||||
|
||||
@@ -1678,4 +1678,18 @@ 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';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'Paused for vacation';
|
||||
}
|
||||
|
||||
@@ -1692,4 +1692,18 @@ 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';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'Pausada por vacaciones';
|
||||
}
|
||||
|
||||
@@ -1701,4 +1701,18 @@ 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';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'En pause pour les vacances';
|
||||
}
|
||||
|
||||
@@ -1680,4 +1680,18 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => 'सुनना शुरू करें';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'इक्वलाइज़र चालू करें';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'इक्वलाइज़र बंद करें';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'प्रीसेट: $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'छुट्टी के कारण रोका गया';
|
||||
}
|
||||
|
||||
@@ -1688,4 +1688,18 @@ 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';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'Dijeda karena liburan';
|
||||
}
|
||||
|
||||
@@ -1700,4 +1700,18 @@ 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';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'In pausa per le vacanze';
|
||||
}
|
||||
|
||||
@@ -1631,4 +1631,18 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => '聴き始める';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'イコライザーをオンにする';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'イコライザーをオフにする';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'プリセット: $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => '休暇のため一時停止中';
|
||||
}
|
||||
|
||||
@@ -1688,4 +1688,18 @@ 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';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'Pausada por férias';
|
||||
}
|
||||
|
||||
@@ -1692,4 +1692,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => 'Начать слушать';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => 'Включить эквалайзер';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => 'Выключить эквалайзер';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return 'Пресет: $preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => 'Приостановлено на время отпуска';
|
||||
}
|
||||
|
||||
@@ -1623,4 +1623,18 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get welcomeCtaLabel => '开始收听';
|
||||
|
||||
@override
|
||||
String get eqCustomActionEnableLabel => '启用均衡器';
|
||||
|
||||
@override
|
||||
String get eqCustomActionDisableLabel => '关闭均衡器';
|
||||
|
||||
@override
|
||||
String eqCustomActionPresetLabel(String preset) {
|
||||
return '预设:$preset';
|
||||
}
|
||||
|
||||
@override
|
||||
String get alarmCardVacationPausedBadge => '因假期已暂停';
|
||||
}
|
||||
|
||||
@@ -265,6 +265,26 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
? l10n.noStationUseInternalSound
|
||||
: localizedStationName(l10n, alarma.emisora!.nombre);
|
||||
|
||||
// Item 5: surfaces the genuinely useful fields that already exist on
|
||||
// the model, WITHOUT turning the row into clutter -- each is shown
|
||||
// only when it is a meaningful deviation from the common case.
|
||||
// Mirrors EXACTLY the pause predicate `impactoDeRango`/
|
||||
// `ServicioProgramacionAlarmas` already use
|
||||
// (`!sonarEnVacaciones` while `activa`), gated by whether a vacation
|
||||
// range is CURRENTLY active -- an alarm configured to pause but with
|
||||
// no active range right now is not actually paused by anything yet.
|
||||
final pausadaPorVacaciones =
|
||||
alarma.activa &&
|
||||
!alarma.sonarEnVacaciones &&
|
||||
estado.rangoVacacionesActivo() != null;
|
||||
final detalles = <String>[
|
||||
if (alarma.fadeInSegundos > 0)
|
||||
l10n.alarmFadeInLabel(alarma.fadeInSegundos),
|
||||
if ((alarma.volumen * 100).round() != 85)
|
||||
'${(alarma.volumen * 100).round()}%',
|
||||
if (pausadaPorVacaciones) l10n.alarmCardVacationPausedBadge,
|
||||
];
|
||||
|
||||
return Dismissible(
|
||||
key: ValueKey('tarjeta-alarma-${alarma.id}'),
|
||||
direction: DismissDirection.horizontal,
|
||||
@@ -309,14 +329,21 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_recurrenciaCorta(l10n, alarma),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
// Item 5: real day list can run longer than the
|
||||
// old generic "Días" label -- Flexible+ellipsis
|
||||
// keeps a long selection from overflowing the
|
||||
// Row instead of clipping visibly.
|
||||
Flexible(
|
||||
child: Text(
|
||||
_recurrenciaCorta(l10n, alarma),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -369,6 +396,25 @@ class _TarjetaAlarma extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
// Item 5: fade/volume/vacation-pause state, only
|
||||
// when each is a genuinely useful deviation from
|
||||
// the common case (see `detalles` above) -- a
|
||||
// single compact line, not a badge per field.
|
||||
if (detalles.isNotEmpty) ...[
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
detalles.join(' · '),
|
||||
key: ValueKey('tarjeta-alarma-detalles-${alarma.id}'),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1668,16 +1714,39 @@ String _weekdayShort(AppLocalizations l10n, int day) => switch (day) {
|
||||
String _fechaCorta(AppLocalizations l10n, DateTime fecha) =>
|
||||
fechaCortaLocalizada(l10n.localeName, fecha);
|
||||
|
||||
/// Audit 7.4 (t4:339): a compact recurrence label next to the alarm card's
|
||||
/// giant time. Reuses the SAME generic labels the editor's own
|
||||
/// `TipoProgramacionAlarma` `SegmentedButton` already shows (`oneTimeOption`
|
||||
/// / `dailyOption` / `weekdaysOption`) rather than inventing a new, more
|
||||
/// specific ARB string -- honest given the space (12px, next to a 34px
|
||||
/// time) genuinely only fits a short word, not a full weekday list.
|
||||
/// Audit 7.4 (t4:339) / item 5: a compact recurrence label next to the
|
||||
/// alarm card's giant time. `diaria`/`unica` still show the SAME generic
|
||||
/// labels the editor's own `TipoProgramacionAlarma` `SegmentedButton`
|
||||
/// already uses (`dailyOption`/`oneTimeOption`) -- both are already fully
|
||||
/// specific (there is nothing more concrete to say than "every day"/"just
|
||||
/// once"). `diasSemana` now renders the alarm's ACTUAL configured days
|
||||
/// (e.g. "Lun, Mié, Vie") instead of the generic `weekdaysOption` ("Días"),
|
||||
/// reusing [_weekdayShort] (the SAME per-day abbreviation the editor's own
|
||||
/// day-picker circles already use) -- no new ARB keys, no second
|
||||
/// formatting scheme, and the resulting Text is wrapped in a
|
||||
/// `Flexible`+ellipsis at the call site so a long selection never
|
||||
/// overflows the row.
|
||||
String _recurrenciaCorta(AppLocalizations l10n, AlarmaMusical alarma) {
|
||||
return switch (alarma.tipoProgramacion) {
|
||||
TipoProgramacionAlarma.diaria => l10n.dailyOption,
|
||||
TipoProgramacionAlarma.diasSemana => l10n.weekdaysOption,
|
||||
TipoProgramacionAlarma.diasSemana => _diasSemanaCorto(
|
||||
l10n,
|
||||
alarma.diasSemana,
|
||||
),
|
||||
TipoProgramacionAlarma.unica => l10n.oneTimeOption,
|
||||
};
|
||||
}
|
||||
|
||||
/// The real, ordered day abbreviations for a `diasSemana` alarm (item 5),
|
||||
/// e.g. "Lun, Mié, Vie". [diasSemana] is re-sorted defensively (the editor
|
||||
/// always persists it sorted, but this does not rely on that). Falls back
|
||||
/// to the generic [AppLocalizations.weekdaysOption] label when
|
||||
/// [diasSemana] is empty -- the editor already blocks saving an empty
|
||||
/// selection in this mode, but a corrupt/legacy persisted record could
|
||||
/// still reach here, and showing nothing would be worse than the old
|
||||
/// generic label.
|
||||
String _diasSemanaCorto(AppLocalizations l10n, List<int> diasSemana) {
|
||||
if (diasSemana.isEmpty) return l10n.weekdaysOption;
|
||||
final ordenados = [...diasSemana]..sort();
|
||||
return ordenados.map((dia) => _weekdayShort(l10n, dia)).join(', ');
|
||||
}
|
||||
|
||||
@@ -30,6 +30,21 @@ List<T> paginaDe<T>(List<T> items, {required int pagina, required int tamano}) =
|
||||
bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) =>
|
||||
total > (pagina + 1) * tamano;
|
||||
|
||||
/// Browse-tree ordering comparator for a local-music folder's children
|
||||
/// (Design "Directories before files", item 1): directories sort before
|
||||
/// files regardless of name, and within each group, alphabetically by
|
||||
/// [NodoLocal.nombre] -- the standard file-browser convention. Fixes a
|
||||
/// driver-facing bug where a folder's subfolders could land on a later
|
||||
/// "Más…" page whenever enough tracks sorted alphabetically ahead of them
|
||||
/// (e.g. a "Live" subfolder behind 80 numbered tracks), making the
|
||||
/// subfolder unreachable without paging through every track first.
|
||||
int compararNodoLocalParaNavegacion(NodoLocal a, NodoLocal b) {
|
||||
if (a.esDirectorio != b.esDirectorio) {
|
||||
return a.esDirectorio ? -1 : 1;
|
||||
}
|
||||
return a.nombre.compareTo(b.nombre);
|
||||
}
|
||||
|
||||
const _prefijoEmisora = 'emisora:';
|
||||
|
||||
/// Local-track media-id prefix (Design "media-id scheme"), collision-free
|
||||
@@ -432,9 +447,15 @@ class ConstructorArbolAuto {
|
||||
int tamano = _maxItemsCarpetaLocal,
|
||||
@visibleForTesting
|
||||
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
|
||||
// Item 2 (recursive folder play): optional so every pre-existing call
|
||||
// site/test that has no need for the recursive gate keeps working
|
||||
// unchanged. Only used on page 0, and only when [nodos] has zero
|
||||
// DIRECT tracks (a direct track already makes the gate cheaply true
|
||||
// without it) — see the `hayContenidoReproducible` computation below.
|
||||
FuenteMusicaLocalAuto? fuente,
|
||||
}) async {
|
||||
final construir = construirItem ?? _itemLocal;
|
||||
final ordenados = [...nodos]..sort((a, b) => a.nombre.compareTo(b.nombre));
|
||||
final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion);
|
||||
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
|
||||
final docIds = paginaActual
|
||||
.where((n) => !n.esDirectorio)
|
||||
@@ -447,14 +468,26 @@ class ConstructorArbolAuto {
|
||||
}
|
||||
if (pagina == 0) {
|
||||
final totalPistas = nodos.where((n) => !n.esDirectorio).length;
|
||||
// Item 2: a folder plays everything beneath it, recursively -- so
|
||||
// the play actions must be offered whenever the RECURSIVE count is
|
||||
// > 0, not just the direct count. `totalPistas > 0` short-circuits
|
||||
// the bounded recursive walk entirely for the common case (a direct
|
||||
// track already answers the question); only a folder with ZERO
|
||||
// direct tracks but at least one subfolder pays the recursive-check
|
||||
// cost, and only up to [profundidadMaximaRecursivaLocal] levels.
|
||||
final hayContenidoReproducible =
|
||||
totalPistas > 0 ||
|
||||
(fuente != null &&
|
||||
await _haySubcarpetaConPistas(nodos, fuente: fuente));
|
||||
final prepend = <MediaItem>[
|
||||
// Folder-play actions (Design ADR-5, Phase 3): prepended BEFORE
|
||||
// the sort/bucket nav entries, guarded the same shape as
|
||||
// ofreceOrdenCalidad(totalPistas > 0) — present iff the folder has
|
||||
// at least one direct audio child, absent for a folder with only
|
||||
// subfolders (Spec "Folder has no tracks").
|
||||
if (totalPistas > 0) _itemReproducirCarpeta(documentIdPadre),
|
||||
if (totalPistas > 0) _itemReproducirAleatorio(documentIdPadre),
|
||||
// Folder-play actions (Design ADR-5, Phase 3; recursive gate item
|
||||
// 2): prepended BEFORE the sort/bucket nav entries, present iff
|
||||
// the folder has at least one playable track anywhere beneath it
|
||||
// (direct or nested), absent for a folder that is genuinely empty
|
||||
// even recursively (Spec "Folder has no tracks").
|
||||
if (hayContenidoReproducible) _itemReproducirCarpeta(documentIdPadre),
|
||||
if (hayContenidoReproducible)
|
||||
_itemReproducirAleatorio(documentIdPadre),
|
||||
if (ofreceOrdenCalidad(totalPistas))
|
||||
_itemModoOrdenCalidad(documentIdPadre),
|
||||
if (ofreceBuckets(totalPistas))
|
||||
@@ -466,6 +499,32 @@ class ConstructorArbolAuto {
|
||||
return items;
|
||||
}
|
||||
|
||||
/// Whether at least one subfolder within [nodos] recursively contains a
|
||||
/// playable track (Design "recursive folder play, gate", item 2): called
|
||||
/// ONLY when the folder has zero DIRECT tracks (the caller already
|
||||
/// checked that cheaply) — descends into each direct subfolder via
|
||||
/// [pistasRecursivas] with `limite: 1`, stopping at the very first
|
||||
/// match so a folder with an early hit costs as little as possible.
|
||||
/// [nodos] is assumed already resolved by the caller (its own
|
||||
/// `fuente.hijos(...)` result), so this folder's own children are never
|
||||
/// re-fetched.
|
||||
Future<bool> _haySubcarpetaConPistas(
|
||||
List<NodoLocal> nodos, {
|
||||
required FuenteMusicaLocalAuto fuente,
|
||||
}) async {
|
||||
for (final nodo in nodos) {
|
||||
if (!nodo.esDirectorio) continue;
|
||||
final encontradas = await pistasRecursivas(
|
||||
nodo.documentId,
|
||||
fuente: fuente,
|
||||
profundidadMaxima: profundidadMaximaRecursivaLocal - 1,
|
||||
limite: 1,
|
||||
);
|
||||
if (encontradas.isNotEmpty) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Whether the "Ordenar por calidad" mode entry should be offered for a
|
||||
/// folder with [totalPistas] audio files (Design ADR-3): present for
|
||||
/// `0 < totalPistas <= 150`, omitted otherwise (empty folder or above the
|
||||
@@ -777,10 +836,12 @@ Future<void> reproducirPorMediaId(
|
||||
title: emisora.nombre,
|
||||
artist: emisora.pais ?? '',
|
||||
album: 'PluriWave',
|
||||
artUri:
|
||||
emisora.favicon != null && emisora.favicon!.isNotEmpty
|
||||
? Uri.tryParse(emisora.favicon!)
|
||||
: null,
|
||||
// Item 3: reuses [artUriPara] (the SAME fallback the browse tree's
|
||||
// itemEmisora already applies) so the "now playing" media item never
|
||||
// falls back to a blank tile — a real usable favicon still wins, a
|
||||
// missing/unusable one gets the on-brand rotating drawable instead of
|
||||
// `null`.
|
||||
artUri: Uri.parse(artUriPara(emisora)),
|
||||
extras: {'uuid': emisora.uuid},
|
||||
);
|
||||
await reproducir(item);
|
||||
@@ -931,17 +992,115 @@ List<NodoLocal> mezclarFisherYates(List<NodoLocal> nodos, Random rng) {
|
||||
List<NodoLocal> pistasEnOrdenAleatorio(List<NodoLocal> nodos, Random rng) =>
|
||||
mezclarFisherYates(pistasEnOrdenNombre(nodos), rng);
|
||||
|
||||
/// Maximum recursion depth for "play folder recursively" (Design "recursive
|
||||
/// folder play, cost bound", item 2): SAF directory listing is a native
|
||||
/// round-trip PER folder, so unbounded recursion could turn a single tap
|
||||
/// into dozens of channel calls for a pathologically deep tree. 4 levels
|
||||
/// below the tapped folder covers virtually every real music-library
|
||||
/// layout (even `Artist/Album/Disc/track.mp3` is only 3 levels deep) while
|
||||
/// keeping a worst-case tree's native-call count bounded. A subfolder
|
||||
/// beyond this depth is simply never explored — its tracks are not
|
||||
/// collected, exactly like content beyond the browse tree's own page cap
|
||||
/// is never listed.
|
||||
const profundidadMaximaRecursivaLocal = 4;
|
||||
|
||||
/// Maximum number of tracks collected by a recursive folder walk (Design
|
||||
/// "recursive folder play, cost bound", item 2): a folder-play/shuffle
|
||||
/// queue beyond a few hundred tracks has no practical benefit, and an
|
||||
/// unbounded collection risks an extremely long queue AND an extremely
|
||||
/// long recursive walk over a huge library. 500 is an order of magnitude
|
||||
/// above the existing quality-sort cap
|
||||
/// ([ConstructorArbolAuto._maxPistasParaOrdenCalidad], 150) — generous for
|
||||
/// a "play everything" action, while still bounded.
|
||||
const limitePistasRecursivasLocal = 500;
|
||||
|
||||
/// Recursively collects every audio-file [NodoLocal] reachable from
|
||||
/// [documentId] (Design "recursive folder play", item 2): [documentId]'s
|
||||
/// own direct audio children, plus — for every direct subfolder — that
|
||||
/// subfolder's own recursive result. Walked depth-first, sorted by
|
||||
/// [NodoLocal.nombre] at each level (the SAME comparator the sequential/
|
||||
/// shuffle play actions already used pre-recursion), so the collected
|
||||
/// order is deterministic and reproducible under a fixed shuffle seed.
|
||||
///
|
||||
/// Bounded on two independent axes so a pathological tree (very deep, or
|
||||
/// very wide-and-deep) can never turn a single tap into an unbounded
|
||||
/// number of native SAF round-trips or an unbounded in-memory list:
|
||||
/// - [profundidadMaxima] caps how many folder levels BELOW [documentId]
|
||||
/// are ever descended into (`0` = only [documentId]'s own direct
|
||||
/// children, no descent at all).
|
||||
/// - [limite] caps the TOTAL number of tracks collected across the whole
|
||||
/// walk; collection stops (mid-folder if needed) the instant this many
|
||||
/// have been gathered.
|
||||
///
|
||||
/// Never throws: a [fuente.hijos] failure on any one subfolder (revoked
|
||||
/// permission, a race with the OS SAF layer) is swallowed for that
|
||||
/// subfolder only — sibling folders already queued for traversal are
|
||||
/// still visited — mirroring this file's existing no-throw contract
|
||||
/// (Design "no-op on empty/unresolvable folder").
|
||||
Future<List<NodoLocal>> pistasRecursivas(
|
||||
String documentId, {
|
||||
required FuenteMusicaLocalAuto fuente,
|
||||
int profundidadMaxima = profundidadMaximaRecursivaLocal,
|
||||
int limite = limitePistasRecursivasLocal,
|
||||
}) async {
|
||||
final resultado = <NodoLocal>[];
|
||||
await _recolectarPistasRecursivas(
|
||||
documentId,
|
||||
fuente: fuente,
|
||||
profundidadRestante: profundidadMaxima,
|
||||
limite: limite,
|
||||
resultado: resultado,
|
||||
);
|
||||
return resultado;
|
||||
}
|
||||
|
||||
Future<void> _recolectarPistasRecursivas(
|
||||
String documentId, {
|
||||
required FuenteMusicaLocalAuto fuente,
|
||||
required int profundidadRestante,
|
||||
required int limite,
|
||||
required List<NodoLocal> resultado,
|
||||
}) async {
|
||||
if (resultado.length >= limite) return;
|
||||
final List<NodoLocal> hijos;
|
||||
try {
|
||||
hijos = await fuente.hijos(documentId);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
final ordenados = [...hijos]..sort((a, b) => a.nombre.compareTo(b.nombre));
|
||||
for (final nodo in ordenados) {
|
||||
if (resultado.length >= limite) return;
|
||||
if (nodo.esDirectorio) {
|
||||
if (profundidadRestante <= 0) continue;
|
||||
await _recolectarPistasRecursivas(
|
||||
nodo.documentId,
|
||||
fuente: fuente,
|
||||
profundidadRestante: profundidadRestante - 1,
|
||||
limite: limite,
|
||||
resultado: resultado,
|
||||
);
|
||||
} else {
|
||||
resultado.add(nodo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Orchestrates a "Reproducir carpeta"/"Reproducir aleatorio" tap (Design
|
||||
/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2): resolves whichever of the
|
||||
/// two action prefixes matches [id] (ignoring [aleatorio] for the STRIP —
|
||||
/// the prefix itself is authoritative), fetches [fuente]'s direct children
|
||||
/// for that folder, filters to audio files, orders them ([aleatorio] picks
|
||||
/// shuffled vs name order), and hands the resulting list to [iniciarCola].
|
||||
/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2; recursive collection item
|
||||
/// 2): resolves whichever of the two action prefixes matches [id]
|
||||
/// (ignoring [aleatorio] for the STRIP — the prefix itself is
|
||||
/// authoritative), RECURSIVELY collects every track beneath that folder
|
||||
/// via [pistasRecursivas] (direct children AND every nested subfolder, up
|
||||
/// to its depth/count bounds), orders them ([aleatorio] picks shuffled vs
|
||||
/// the recursive walk's own name-sorted order), and hands the resulting
|
||||
/// list to [iniciarCola].
|
||||
///
|
||||
/// A no-op (never calls [iniciarCola]) when: [id] matches neither action
|
||||
/// prefix; [fuente.hijos] throws or returns only directories (an
|
||||
/// unresolvable/empty folder — Design "no-op on empty/unresolvable
|
||||
/// folder").
|
||||
/// prefix; the folder (or everything beneath it, within the recursion
|
||||
/// bounds) is unresolvable/empty (Design "no-op on empty/unresolvable
|
||||
/// folder") — [pistasRecursivas] never throws, so this never propagates an
|
||||
/// exception either.
|
||||
Future<void> reproducirCarpetaLocal(
|
||||
String id, {
|
||||
required bool aleatorio,
|
||||
@@ -959,16 +1118,10 @@ Future<void> reproducirCarpetaLocal(
|
||||
return;
|
||||
}
|
||||
|
||||
final List<NodoLocal> nodos;
|
||||
try {
|
||||
nodos = await fuente.hijos(documentId);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
final recolectadas = await pistasRecursivas(documentId, fuente: fuente);
|
||||
final pistas = aleatorio
|
||||
? pistasEnOrdenAleatorio(nodos, rng ?? Random())
|
||||
: pistasEnOrdenNombre(nodos);
|
||||
? mezclarFisherYates(recolectadas, rng ?? Random())
|
||||
: recolectadas;
|
||||
if (pistas.isEmpty) return;
|
||||
|
||||
await iniciarCola(pistas);
|
||||
@@ -992,6 +1145,11 @@ Future<MediaItem?> construirMediaItemColaLocal(
|
||||
id: contentUri,
|
||||
title: _tituloDesdeDocumentId(nodo.documentId),
|
||||
album: 'PluriWave',
|
||||
// Item 3: a queued local track had NO artUri at all before — reuses
|
||||
// [artUriLocal] (the SAME on-brand rotation the browse tree's
|
||||
// `_itemLocal` already falls back to) so the car's now-playing screen
|
||||
// never shows a blank tile for a track with no embedded art.
|
||||
artUri: Uri.parse(artUriLocal(nodo.documentId)),
|
||||
extras: {'documentId': nodo.documentId},
|
||||
);
|
||||
}
|
||||
@@ -1114,6 +1272,7 @@ Future<List<MediaItem>?> hijosMusicaLocal(
|
||||
documentIdPadre: documentId,
|
||||
pagina: pagina,
|
||||
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
|
||||
fuente: fuente,
|
||||
);
|
||||
} catch (_) {
|
||||
return const [];
|
||||
@@ -1170,6 +1329,9 @@ Future<void> reproducirPistaLocal(
|
||||
id: pista.contentUri,
|
||||
title: pista.titulo,
|
||||
album: 'PluriWave',
|
||||
// Item 3: same fallback as construirMediaItemColaLocal, for a track
|
||||
// tapped directly (not via a folder-play queue).
|
||||
artUri: Uri.parse(artUriLocal(pista.documentId)),
|
||||
extras: {'documentId': pista.documentId},
|
||||
);
|
||||
await reproducir(item);
|
||||
|
||||
@@ -60,6 +60,131 @@ void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) {
|
||||
_fuenteMusicaLocalGlobal = fuente;
|
||||
}
|
||||
|
||||
/// Builds the phone-initiated "play a station" `MediaItem` (item 3, Android
|
||||
/// Auto fallback artwork): reuses [artUriPara] (`navegacion_auto.dart`) so a
|
||||
/// station with no usable favicon gets the SAME on-brand rotating fallback
|
||||
/// the browse tree and the car-tap path already show, instead of a blank
|
||||
/// tile on the car/lockscreen/notification. Pure — no [PluriWaveAudioHandler]
|
||||
/// dependency — so it is unit-testable without instantiating the handler.
|
||||
MediaItem mediaItemParaEmisora(Emisora emisora, {required AppLocalizations l10n}) {
|
||||
return MediaItem(
|
||||
id: emisora.url,
|
||||
title: localizedStationName(l10n, emisora.nombre),
|
||||
artist: emisora.pais ?? '',
|
||||
album: 'PluriWave',
|
||||
artUri: Uri.parse(artUriPara(emisora)),
|
||||
extras: {'uuid': emisora.uuid},
|
||||
);
|
||||
}
|
||||
|
||||
/// Reconstructs the phone-side [Emisora] from the handler's current
|
||||
/// [MediaItem] (item 3): gates `favicon` through [faviconUsable]
|
||||
/// (`navegacion_auto.dart`) so a car/car-tap "now playing" item's on-brand
|
||||
/// FALLBACK `artUri` (an `android.resource://` drawable, never a real
|
||||
/// favicon) is never misread as a genuine station favicon — the phone UI's
|
||||
/// `CachedNetworkImage` widgets gate only on `favicon != null && isNotEmpty`
|
||||
/// (not on `faviconUsable`'s scheme check), so without this guard they would
|
||||
/// attempt a doomed network fetch of the fallback's non-http URI before
|
||||
/// falling back to [PluriStationArtFallback] themselves. A genuine http(s)
|
||||
/// favicon still round-trips exactly as before. Pure — no handler
|
||||
/// dependency — unit-testable directly.
|
||||
Emisora emisoraDesdeMediaItem(MediaItem mediaItem) {
|
||||
final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id;
|
||||
final artUriTexto = mediaItem.artUri?.toString();
|
||||
return Emisora(
|
||||
uuid: uuid,
|
||||
nombre: mediaItem.title,
|
||||
url: mediaItem.id,
|
||||
pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null,
|
||||
favicon: faviconUsable(artUriTexto) ? artUriTexto : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -94,19 +219,9 @@ class ServicioAudio {
|
||||
});
|
||||
|
||||
Future<void> reproducir(Emisora emisora) async {
|
||||
final item = MediaItem(
|
||||
id: emisora.url,
|
||||
title: localizedStationName(
|
||||
lookupAppLocalizations(const Locale('es')),
|
||||
emisora.nombre,
|
||||
),
|
||||
artist: emisora.pais ?? '',
|
||||
album: 'PluriWave',
|
||||
artUri:
|
||||
emisora.favicon != null && emisora.favicon!.isNotEmpty
|
||||
? Uri.tryParse(emisora.favicon!)
|
||||
: null,
|
||||
extras: {'uuid': emisora.uuid},
|
||||
final item = mediaItemParaEmisora(
|
||||
emisora,
|
||||
l10n: lookupAppLocalizations(const Locale('es')),
|
||||
);
|
||||
await _handler.playMediaItem(item);
|
||||
}
|
||||
@@ -289,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,
|
||||
@@ -339,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
|
||||
@@ -724,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.
|
||||
@@ -742,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.
|
||||
@@ -796,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 {
|
||||
@@ -834,6 +1006,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
await _player.setVolume(_volumenEfectivo);
|
||||
}
|
||||
|
||||
/// Fix "EQ Re-Apply After Audio-Focus Interruption": thin delegate to the
|
||||
/// existing [_activarEcualizador] (already does the correct idempotent
|
||||
/// `setEnabled` + re-push-gains work, already re-asserts the CURRENT
|
||||
/// [_ecualizadorActivo] rather than forcing it on). Called by
|
||||
/// [ServicioAudioSession] on resume-from-pause and on un-duck — see that
|
||||
/// interface member's doc for why the existing session-id-change trigger
|
||||
/// misses this case.
|
||||
@override
|
||||
Future<void> reaplicarEcualizador() => _activarEcualizador();
|
||||
|
||||
@override
|
||||
Future<void> play() {
|
||||
_intencionReproducir = true;
|
||||
@@ -899,6 +1081,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();
|
||||
@@ -911,14 +1114,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler
|
||||
}
|
||||
|
||||
Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) {
|
||||
final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id;
|
||||
return Emisora(
|
||||
uuid: uuid,
|
||||
nombre: mediaItem.title,
|
||||
url: mediaItem.id,
|
||||
pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null,
|
||||
favicon: mediaItem.artUri?.toString(),
|
||||
);
|
||||
// Item 3: delegates to the top-level, unit-testable function so the
|
||||
// `faviconUsable` guard (never reflect the on-brand fallback artUri
|
||||
// back as a real favicon) is covered without instantiating the handler.
|
||||
return emisoraDesdeMediaItem(mediaItem);
|
||||
}
|
||||
|
||||
// ── Android Auto browsing (thin delegation to navegacion_auto.dart's
|
||||
|
||||
@@ -20,6 +20,20 @@ abstract class ObjetivoAudioInterrumpible {
|
||||
|
||||
/// Temporarily lowers ("ducks") the output volume without pausing.
|
||||
Future<void> setAtenuado(bool atenuado);
|
||||
|
||||
/// Re-attaches the equalizer effect and re-pushes the current preset's
|
||||
/// gains (fix "EQ Re-Apply After Audio-Focus Interruption"). Called after
|
||||
/// resuming from a transient interruption pause and after un-ducking,
|
||||
/// because Android's AudioEffect framework can let a higher-priority
|
||||
/// client silently disable this app's effect instance while the
|
||||
/// underlying player session id never changes — the existing session-id
|
||||
/// rotation trigger (`ServicioAudio.debeReaplicarEcualizador`) therefore
|
||||
/// never fires for a SHORT interruption (e.g. a nav-app voice prompt).
|
||||
/// Idempotent and cheap (a `setEnabled` plus band `setGain` calls); takes
|
||||
/// no argument by design — it re-asserts whatever enabled/disabled state
|
||||
/// the handler ALREADY holds, so a caller here can never force the
|
||||
/// equalizer on. Never restarts or repositions playback.
|
||||
Future<void> reaplicarEcualizador();
|
||||
}
|
||||
|
||||
/// Wrapper around `package:audio_session` (S3-R1): configures the session
|
||||
@@ -84,11 +98,18 @@ class ServicioAudioSession {
|
||||
switch (evento.type) {
|
||||
case AudioInterruptionType.duck:
|
||||
await _objetivo.setAtenuado(false);
|
||||
// Un-ducking never rotates the native player session id, so the
|
||||
// session-id-change trigger never fires for this case — re-assert
|
||||
// here too (belt-and-braces, additive to that trigger).
|
||||
await _objetivo.reaplicarEcualizador();
|
||||
case AudioInterruptionType.pause:
|
||||
// Transient loss ended and the OS says we may resume.
|
||||
if (_pausadoPorInterrupcion) {
|
||||
_pausadoPorInterrupcion = false;
|
||||
await _objetivo.reanudar();
|
||||
// Same rationale as the duck branch above: a short transient
|
||||
// interruption keeps the SAME player session id.
|
||||
await _objetivo.reaplicarEcualizador();
|
||||
}
|
||||
case AudioInterruptionType.unknown:
|
||||
// Permanent focus loss: never auto-resume.
|
||||
|
||||
@@ -190,9 +190,26 @@ void main() {
|
||||
expect(antes, isNot(l10n.alarmNoNextExecution));
|
||||
|
||||
// Lunes -> Martes: la fecha calculada SIEMPRE cambia, sea cual sea hoy.
|
||||
await tester.tap(find.text(l10n.weekdayShortTuesday));
|
||||
//
|
||||
// Item 5: the alarm CARD underneath now also renders the real day
|
||||
// abbreviation ("Lun") for a diasSemana alarm, so a bare
|
||||
// `find.text(...)` for a weekday letter is ambiguous while the
|
||||
// editor sheet is open on top of the list — scope to the sheet's own
|
||||
// BottomSheet subtree to target the day-picker circle specifically.
|
||||
final hojaEditor = find.byType(BottomSheet);
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: hojaEditor,
|
||||
matching: find.text(l10n.weekdayShortTuesday),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text(l10n.weekdayShortMonday));
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: hojaEditor,
|
||||
matching: find.text(l10n.weekdayShortMonday),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final despues = _textoPreview(tester);
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Item 5: the alarm list must show which days a `diasSemana` alarm
|
||||
/// actually fires on (e.g. "Lun, Mié, Vie"), not the generic "Días" label,
|
||||
/// plus surface fade/volume/vacation-pause state when they are genuinely
|
||||
/// informative -- without cluttering the row.
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
Future<(EstadoRadio, EstadoAlarmas)> montar(
|
||||
WidgetTester tester, {
|
||||
required AlarmaMusical alarma,
|
||||
List<RangoVacaciones> vacaciones = const [],
|
||||
}) async {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final radio = EstadoRadio(
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(radio.dispose);
|
||||
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estadoAlarmas = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 6, 0)),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
addTearDown(estadoAlarmas.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await estadoAlarmas.guardarAlarma(alarma);
|
||||
if (vacaciones.isNotEmpty) {
|
||||
await estadoAlarmas.guardarVacaciones(vacaciones);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
|
||||
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('es'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(body: PantallaAlarmas()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
return (radio, estadoAlarmas);
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'diasSemana alarm shows the ACTUAL configured days (Lun, Mié, Vie), '
|
||||
'not the generic "Días" label',
|
||||
(tester) async {
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-dias',
|
||||
nombre: 'Entre semana',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diasSemana,
|
||||
diasSemana: [DateTime.monday, DateTime.wednesday, DateTime.friday],
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Lun, Mié, Vie'), findsOneWidget);
|
||||
expect(find.text('Días'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('daily alarm still shows "Diaria" (unaffected)', (
|
||||
tester,
|
||||
) async {
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-diaria',
|
||||
nombre: 'Todos los días',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Diaria'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('one-time alarm still shows "Una vez" (unaffected)', (
|
||||
tester,
|
||||
) async {
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-unica',
|
||||
nombre: 'Una sola vez',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.unica,
|
||||
diasSemana: [],
|
||||
fechaUnica: null,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Una vez'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'a diasSemana alarm with an (invalid/legacy) empty diasSemana falls '
|
||||
'back to the generic label instead of showing nothing',
|
||||
(tester) async {
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-dias-vacio',
|
||||
nombre: 'Corrupta',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diasSemana,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Días'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('a configured fade-in shows a compact "Fade-in Ns" detail', (
|
||||
tester,
|
||||
) async {
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-fade',
|
||||
nombre: 'Con fade',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
fadeInSegundos: 8,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textContaining('Fade-in 8s'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('no fade-in (0s, the default) shows no fade detail', (
|
||||
tester,
|
||||
) async {
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-sin-fade',
|
||||
nombre: 'Sin fade',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
fadeInSegundos: 0,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textContaining('Fade-in'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'a non-default volume shows a compact percentage detail',
|
||||
(tester) async {
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-vol',
|
||||
nombre: 'Volumen bajo',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
volumen: 0.5,
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textContaining('50%'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('the default volume (85%) shows no volume detail', (
|
||||
tester,
|
||||
) async {
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-vol-default',
|
||||
nombre: 'Volumen default',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textContaining('85%'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'an alarm paused by a CURRENTLY active vacation range shows a '
|
||||
'vacation-paused detail',
|
||||
(tester) async {
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-vacaciones',
|
||||
nombre: 'Pausada',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
sonarEnVacaciones: false,
|
||||
),
|
||||
vacaciones: [
|
||||
// Wide, real-wall-clock-safe range (rangoVacacionesActivo()
|
||||
// defaults to the REAL DateTime.now(), not this file's injected
|
||||
// `reloj`) -- deliberately spans many years so the test stays
|
||||
// valid regardless of exactly when it runs.
|
||||
RangoVacaciones(
|
||||
id: 'v1',
|
||||
nombre: 'Verano',
|
||||
inicio: DateTime(2020, 1, 1),
|
||||
fin: DateTime(2030, 12, 31),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
find.textContaining(l10n.alarmCardVacationPausedBadge),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'an alarm that DOES sound during vacations shows NO vacation-paused '
|
||||
'detail even with an active range',
|
||||
(tester) async {
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-suena-vacaciones',
|
||||
nombre: 'Suena igual',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
sonarEnVacaciones: true,
|
||||
),
|
||||
vacaciones: [
|
||||
// Wide, real-wall-clock-safe range (rangoVacacionesActivo()
|
||||
// defaults to the REAL DateTime.now(), not this file's injected
|
||||
// `reloj`) -- deliberately spans many years so the test stays
|
||||
// valid regardless of exactly when it runs.
|
||||
RangoVacaciones(
|
||||
id: 'v1',
|
||||
nombre: 'Verano',
|
||||
inicio: DateTime(2020, 1, 1),
|
||||
fin: DateTime(2030, 12, 31),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
find.textContaining(l10n.alarmCardVacationPausedBadge),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'sonarEnVacaciones:false with NO currently-active vacation range shows '
|
||||
'no vacation-paused detail (nothing to be paused BY right now)',
|
||||
(tester) async {
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
await montar(
|
||||
tester,
|
||||
alarma: const AlarmaMusical(
|
||||
id: 'a-sin-rango-activo',
|
||||
nombre: 'Sin vacaciones activas',
|
||||
hora: 7,
|
||||
minuto: 0,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
sonarEnVacaciones: false,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
find.textContaining(l10n.alarmCardVacationPausedBadge),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2189,6 +2189,28 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'item 3: el MediaItem de una pista local tocada directamente usa el '
|
||||
'fallback de marca (artUriLocal), no queda sin artUri',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
uriPorDocId: const {'doc1': 'content://provider/doc1'},
|
||||
);
|
||||
MediaItem? recibido;
|
||||
|
||||
await reproducirPistaLocal(
|
||||
'pista:doc1',
|
||||
fuente: fuente,
|
||||
reproducir: (item) async {
|
||||
recibido = item;
|
||||
},
|
||||
);
|
||||
|
||||
expect(recibido!.artUri, isNotNull);
|
||||
expect(recibido!.artUri.toString(), artUriLocal('doc1'));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'id obsoleto/desconocido (uriContenidoDePista devuelve null) no '
|
||||
'llama a reproducir ni lanza excepción',
|
||||
@@ -2334,8 +2356,9 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'carpeta con solo subcarpetas (sin pistas de audio directas) es un '
|
||||
'no-op: iniciarCola nunca se llama',
|
||||
'carpeta con solo subcarpetas (sin pistas de audio directas), y esa '
|
||||
'subcarpeta TAMBIÉN está vacía incluso recursivamente, es un no-op: '
|
||||
'iniciarCola nunca se llama',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
@@ -2361,6 +2384,43 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'item 2: carpeta con SOLO una subcarpeta que sí tiene pistas -- las '
|
||||
'recolecta RECURSIVAMENTE y llama iniciarCola con ellas (antes era '
|
||||
'un no-op)',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'carpeta1': const [
|
||||
NodoLocal(
|
||||
documentId: 'd-sub',
|
||||
nombre: 'Subcarpeta',
|
||||
esDirectorio: true,
|
||||
),
|
||||
],
|
||||
'd-sub': const [
|
||||
NodoLocal(
|
||||
documentId: 'd-honda',
|
||||
nombre: 'honda.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
List<NodoLocal>? recibidas;
|
||||
|
||||
await reproducirCarpetaLocal(
|
||||
'carpeta_local_reproducir:carpeta1',
|
||||
aleatorio: false,
|
||||
fuente: fuente,
|
||||
iniciarCola: (pistas) async => recibidas = pistas,
|
||||
);
|
||||
|
||||
expect(recibidas, isNotNull);
|
||||
expect(recibidas!.map((n) => n.documentId).toList(), ['d-honda']);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'carpeta irresoluble (fuente.hijos lanza) es un no-op, sin propagar '
|
||||
'la excepción',
|
||||
@@ -2456,6 +2516,27 @@ void main() {
|
||||
expect(item, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'item 3: el MediaItem de una pista en cola de "Reproducir carpeta" '
|
||||
'usa el fallback de marca (artUriLocal), no queda sin artUri (antes '
|
||||
'una pista en cola no tenía NINGÚN arte)',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
uriPorDocId: const {'doc1': 'content://provider/doc1'},
|
||||
);
|
||||
const nodo = NodoLocal(
|
||||
documentId: 'doc1',
|
||||
nombre: 'ignorado.mp3',
|
||||
esDirectorio: false,
|
||||
);
|
||||
|
||||
final item = await construirMediaItemColaLocal(nodo, fuente: fuente);
|
||||
|
||||
expect(item!.artUri, isNotNull);
|
||||
expect(item.artUri.toString(), artUriLocal('doc1'));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('ConstructorArbolAuto.itemEmisora', () {
|
||||
@@ -2828,7 +2909,590 @@ void main() {
|
||||
expect(llamadas, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'item 3: emisora SIN favicon usable -- el MediaItem "en reproducción" '
|
||||
'usa el fallback de marca (artUriPara), no queda con artUri null '
|
||||
'(antes se perdía el arte al reproducir desde el auto)',
|
||||
() async {
|
||||
final emisora = _emisora(
|
||||
uuid: 'uuid-sin-arte',
|
||||
nombre: 'Radio sin logo',
|
||||
favicon: null,
|
||||
);
|
||||
final fuente = _FakeFuenteEmisorasAuto(
|
||||
porUuidResultado: {emisora.uuid: emisora},
|
||||
);
|
||||
MediaItem? recibido;
|
||||
|
||||
await reproducirPorMediaId(
|
||||
'emisora:${emisora.uuid}',
|
||||
fuente: fuente,
|
||||
reproducir: (item) async {
|
||||
recibido = item;
|
||||
},
|
||||
);
|
||||
|
||||
expect(recibido, isNotNull);
|
||||
expect(recibido!.artUri, isNotNull);
|
||||
expect(recibido!.artUri.toString(), artUriPara(emisora));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'item 3: emisora CON favicon http(s) usable -- el MediaItem "en '
|
||||
'reproducción" sigue usando ese favicon real, no el fallback',
|
||||
() async {
|
||||
final emisora = _emisora(
|
||||
uuid: 'uuid-con-arte',
|
||||
nombre: 'Radio con logo',
|
||||
favicon: 'https://cdn.example.com/logo.png',
|
||||
);
|
||||
final fuente = _FakeFuenteEmisorasAuto(
|
||||
porUuidResultado: {emisora.uuid: emisora},
|
||||
);
|
||||
MediaItem? recibido;
|
||||
|
||||
await reproducirPorMediaId(
|
||||
'emisora:${emisora.uuid}',
|
||||
fuente: fuente,
|
||||
reproducir: (item) async {
|
||||
recibido = item;
|
||||
},
|
||||
);
|
||||
|
||||
expect(recibido!.artUri.toString(), emisora.favicon);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── Item 1: Android Auto subfolder visibility ───────────────────────────
|
||||
// `itemsLocales` used to sort a folder's children by name ONLY, mixing
|
||||
// directories and files -- a subfolder whose name sorted alphabetically
|
||||
// after enough tracks landed on a later "Más…" page, making it invisible
|
||||
// until the driver paged through every track first. Fix: directories
|
||||
// before files, then by name within each group (standard file-browser
|
||||
// convention) -- subfolders always land on the first page.
|
||||
group(
|
||||
'compararNodoLocalParaNavegacion (Design "Directories before files")',
|
||||
() {
|
||||
test('a directory always sorts before a file, regardless of name', () {
|
||||
const archivo = NodoLocal(
|
||||
documentId: 'f1',
|
||||
nombre: 'AAA archivo.mp3',
|
||||
esDirectorio: false,
|
||||
);
|
||||
const carpeta = NodoLocal(
|
||||
documentId: 'd1',
|
||||
nombre: 'ZZZ carpeta',
|
||||
esDirectorio: true,
|
||||
);
|
||||
|
||||
expect(
|
||||
compararNodoLocalParaNavegacion(carpeta, archivo),
|
||||
lessThan(0),
|
||||
);
|
||||
expect(
|
||||
compararNodoLocalParaNavegacion(archivo, carpeta),
|
||||
greaterThan(0),
|
||||
);
|
||||
});
|
||||
|
||||
test('two directories sort alphabetically by name', () {
|
||||
const a = NodoLocal(
|
||||
documentId: 'd1',
|
||||
nombre: 'Alpha',
|
||||
esDirectorio: true,
|
||||
);
|
||||
const b = NodoLocal(
|
||||
documentId: 'd2',
|
||||
nombre: 'Beta',
|
||||
esDirectorio: true,
|
||||
);
|
||||
|
||||
expect(compararNodoLocalParaNavegacion(a, b), lessThan(0));
|
||||
expect(compararNodoLocalParaNavegacion(b, a), greaterThan(0));
|
||||
});
|
||||
|
||||
test('two files sort alphabetically by name', () {
|
||||
const a = NodoLocal(
|
||||
documentId: 'f1',
|
||||
nombre: 'Alpha.mp3',
|
||||
esDirectorio: false,
|
||||
);
|
||||
const b = NodoLocal(
|
||||
documentId: 'f2',
|
||||
nombre: 'Beta.mp3',
|
||||
esDirectorio: false,
|
||||
);
|
||||
|
||||
expect(compararNodoLocalParaNavegacion(a, b), lessThan(0));
|
||||
});
|
||||
|
||||
test('equal esDirectorio and equal name compares equal', () {
|
||||
const a = NodoLocal(
|
||||
documentId: 'f1',
|
||||
nombre: 'Same.mp3',
|
||||
esDirectorio: false,
|
||||
);
|
||||
const b = NodoLocal(
|
||||
documentId: 'f2',
|
||||
nombre: 'Same.mp3',
|
||||
esDirectorio: false,
|
||||
);
|
||||
|
||||
expect(compararNodoLocalParaNavegacion(a, b), 0);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
group(
|
||||
'ConstructorArbolAuto.itemsLocales: subfolders sort before files '
|
||||
'(Android Auto subfolder-visibility bug)',
|
||||
() {
|
||||
test(
|
||||
'a subfolder whose name sorts AFTER every one of 80 numbered '
|
||||
'tracks (e.g. "Live") still lands on page 0 -- pure alphabetical '
|
||||
'mixing used to push it to a later page, making it unreachable '
|
||||
'without paging through every track first (the reported bug)',
|
||||
() async {
|
||||
final nodos = [
|
||||
for (var i = 1; i <= 80; i++)
|
||||
NodoLocal(
|
||||
documentId: 'track-$i',
|
||||
nombre: '${i.toString().padLeft(3, '0')} Track.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
const NodoLocal(
|
||||
documentId: 'folder-live',
|
||||
nombre: 'Live',
|
||||
esDirectorio: true,
|
||||
),
|
||||
];
|
||||
|
||||
final pagina0 = await ConstructorArbolAuto().itemsLocales(
|
||||
nodos,
|
||||
documentIdPadre: 'root',
|
||||
metadatosDe: _metadatosVacio,
|
||||
);
|
||||
|
||||
expect(
|
||||
pagina0.where((i) => i.id == 'carpeta_local:folder-live'),
|
||||
hasLength(1),
|
||||
reason:
|
||||
'the "Live" subfolder must be visible on the FIRST page, '
|
||||
'not hidden behind 80 tracks on a later page',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'mixed directories and files: ALL directories precede ALL files, '
|
||||
'each group still alphabetical by name',
|
||||
() async {
|
||||
final nodos = [
|
||||
const NodoLocal(
|
||||
documentId: 'f-z',
|
||||
nombre: 'zzz.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
const NodoLocal(
|
||||
documentId: 'd-z',
|
||||
nombre: 'ZZZ Carpeta',
|
||||
esDirectorio: true,
|
||||
),
|
||||
const NodoLocal(
|
||||
documentId: 'f-a',
|
||||
nombre: 'aaa.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
const NodoLocal(
|
||||
documentId: 'd-a',
|
||||
nombre: 'AAA Carpeta',
|
||||
esDirectorio: true,
|
||||
),
|
||||
];
|
||||
|
||||
final items = await ConstructorArbolAuto().itemsLocales(
|
||||
nodos,
|
||||
documentIdPadre: 'root',
|
||||
metadatosDe: _metadatosVacio,
|
||||
);
|
||||
final soloArbol = items
|
||||
.where(
|
||||
(i) =>
|
||||
i.id.startsWith('carpeta_local:') ||
|
||||
i.id.startsWith('pista:'),
|
||||
)
|
||||
.toList();
|
||||
|
||||
expect(soloArbol.map((i) => i.id).toList(), [
|
||||
'carpeta_local:d-a',
|
||||
'carpeta_local:d-z',
|
||||
'pista:f-a',
|
||||
'pista:f-z',
|
||||
]);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ── Item 2: Android Auto recursive folder play ─────────────────────────
|
||||
// totalPistas used to count only DIRECT audio children, so "Reproducir
|
||||
// carpeta"/"Aleatorio" were hidden for a folder that contains only
|
||||
// subfolders, and playing a folder queued only its direct tracks. Fix: a
|
||||
// bounded recursive walk collects every track beneath a folder, and the
|
||||
// play actions are offered whenever that recursive count is > 0.
|
||||
group('pistasRecursivas (Design "recursive folder play", item 2)', () {
|
||||
test(
|
||||
'flat folder (no subfolders): same tracks as the direct-children-'
|
||||
'only view, sorted by name',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'root': const [
|
||||
NodoLocal(documentId: 'd-c', nombre: 'c.mp3', esDirectorio: false),
|
||||
NodoLocal(documentId: 'd-a', nombre: 'a.mp3', esDirectorio: false),
|
||||
NodoLocal(documentId: 'd-b', nombre: 'b.mp3', esDirectorio: false),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
final resultado = await pistasRecursivas('root', fuente: fuente);
|
||||
|
||||
expect(
|
||||
resultado.map((n) => n.documentId).toList(),
|
||||
['d-a', 'd-b', 'd-c'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a folder that contains ONLY subfolders: recursively collects the '
|
||||
'tracks from every one of them',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'root': const [
|
||||
NodoLocal(documentId: 'sub-a', nombre: 'A', esDirectorio: true),
|
||||
NodoLocal(documentId: 'sub-b', nombre: 'B', esDirectorio: true),
|
||||
],
|
||||
'sub-a': const [
|
||||
NodoLocal(documentId: 't1', nombre: 'uno.mp3', esDirectorio: false),
|
||||
],
|
||||
'sub-b': const [
|
||||
NodoLocal(documentId: 't2', nombre: 'dos.mp3', esDirectorio: false),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
final resultado = await pistasRecursivas('root', fuente: fuente);
|
||||
|
||||
expect(resultado.map((n) => n.documentId).toSet(), {'t1', 't2'});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a nested tree deeper than one level: finds a track 3 levels below '
|
||||
'the tapped folder',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'root': const [
|
||||
NodoLocal(
|
||||
documentId: 'nivel1',
|
||||
nombre: 'Nivel1',
|
||||
esDirectorio: true,
|
||||
),
|
||||
],
|
||||
'nivel1': const [
|
||||
NodoLocal(
|
||||
documentId: 'nivel2',
|
||||
nombre: 'Nivel2',
|
||||
esDirectorio: true,
|
||||
),
|
||||
],
|
||||
'nivel2': const [
|
||||
NodoLocal(
|
||||
documentId: 'nivel3',
|
||||
nombre: 'Nivel3',
|
||||
esDirectorio: true,
|
||||
),
|
||||
],
|
||||
'nivel3': const [
|
||||
NodoLocal(
|
||||
documentId: 't-hondo',
|
||||
nombre: 'hondo.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
final resultado = await pistasRecursivas('root', fuente: fuente);
|
||||
|
||||
expect(resultado.map((n) => n.documentId).toList(), ['t-hondo']);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'depth bound: a track beyond profundidadMaxima levels is NOT '
|
||||
'collected',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'root': const [
|
||||
NodoLocal(documentId: 'n1', nombre: 'N1', esDirectorio: true),
|
||||
],
|
||||
'n1': const [
|
||||
NodoLocal(documentId: 'n2', nombre: 'N2', esDirectorio: true),
|
||||
],
|
||||
'n2': const [
|
||||
NodoLocal(
|
||||
documentId: 't-hondo',
|
||||
nombre: 'hondo.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// profundidadMaxima: 1 allows root -> n1 (1 hop) but NOT n1 -> n2
|
||||
// (a 2nd hop), so the track inside n2 is never reached.
|
||||
final resultado = await pistasRecursivas(
|
||||
'root',
|
||||
fuente: fuente,
|
||||
profundidadMaxima: 1,
|
||||
);
|
||||
|
||||
expect(resultado, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'count bound: stops collecting once limite tracks are gathered, '
|
||||
'even if more exist',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'root': List.generate(
|
||||
10,
|
||||
(i) => NodoLocal(
|
||||
documentId: 't$i',
|
||||
nombre: 'cancion_$i.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
final resultado = await pistasRecursivas(
|
||||
'root',
|
||||
fuente: fuente,
|
||||
limite: 3,
|
||||
);
|
||||
|
||||
expect(resultado, hasLength(3));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a subfolder that fails to resolve (revoked permission) is skipped; '
|
||||
'sibling subfolders are still visited, never throws',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'root': const [
|
||||
NodoLocal(
|
||||
documentId: 'sub-mala',
|
||||
nombre: 'A-Mala',
|
||||
esDirectorio: true,
|
||||
),
|
||||
NodoLocal(
|
||||
documentId: 'sub-buena',
|
||||
nombre: 'B-Buena',
|
||||
esDirectorio: true,
|
||||
),
|
||||
],
|
||||
'sub-buena': const [
|
||||
NodoLocal(documentId: 't-ok', nombre: 'ok.mp3', esDirectorio: false),
|
||||
],
|
||||
},
|
||||
idsConErrorEnHijos: const {'sub-mala'},
|
||||
);
|
||||
|
||||
final resultado = await pistasRecursivas('root', fuente: fuente);
|
||||
|
||||
expect(resultado.map((n) => n.documentId).toList(), ['t-ok']);
|
||||
},
|
||||
);
|
||||
|
||||
test('carpeta vacía (sin hijos en absoluto) devuelve lista vacía', () async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto();
|
||||
|
||||
final resultado = await pistasRecursivas('root', fuente: fuente);
|
||||
|
||||
expect(resultado, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group(
|
||||
'ConstructorArbolAuto.itemsLocales: recursive folder-play gate '
|
||||
'(item 2)',
|
||||
() {
|
||||
test(
|
||||
'folder with ONLY subfolders, but a subfolder recursively '
|
||||
'contains a track (fuente injected): DOES prepend both play '
|
||||
'actions',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'sub1': const [
|
||||
NodoLocal(
|
||||
documentId: 't-hondo',
|
||||
nombre: 'hondo.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
final nodos = [
|
||||
const NodoLocal(
|
||||
documentId: 'sub1',
|
||||
nombre: 'Subcarpeta',
|
||||
esDirectorio: true,
|
||||
),
|
||||
];
|
||||
|
||||
final pagina0 = await ConstructorArbolAuto().itemsLocales(
|
||||
nodos,
|
||||
documentIdPadre: 'padre1',
|
||||
metadatosDe: _metadatosVacio,
|
||||
fuente: fuente,
|
||||
);
|
||||
|
||||
expect(
|
||||
pagina0.where(
|
||||
(i) => i.id.startsWith('carpeta_local_reproducir:'),
|
||||
),
|
||||
hasLength(1),
|
||||
);
|
||||
expect(
|
||||
pagina0.where((i) => i.id.startsWith('carpeta_local_aleatorio:')),
|
||||
hasLength(1),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'folder with only subfolders, nested 2 levels deep, track only '
|
||||
'at the deepest level (fuente injected): still prepends both '
|
||||
'actions',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
hijosPorDocId: {
|
||||
'nivel1': const [
|
||||
NodoLocal(
|
||||
documentId: 'nivel2',
|
||||
nombre: 'N2',
|
||||
esDirectorio: true,
|
||||
),
|
||||
],
|
||||
'nivel2': const [
|
||||
NodoLocal(
|
||||
documentId: 't-hondo',
|
||||
nombre: 'hondo.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
],
|
||||
},
|
||||
);
|
||||
final nodos = [
|
||||
const NodoLocal(
|
||||
documentId: 'nivel1',
|
||||
nombre: 'Nivel1',
|
||||
esDirectorio: true,
|
||||
),
|
||||
];
|
||||
|
||||
final pagina0 = await ConstructorArbolAuto().itemsLocales(
|
||||
nodos,
|
||||
documentIdPadre: 'padre1',
|
||||
metadatosDe: _metadatosVacio,
|
||||
fuente: fuente,
|
||||
);
|
||||
|
||||
expect(
|
||||
pagina0.where(
|
||||
(i) => i.id.startsWith('carpeta_local_reproducir:'),
|
||||
),
|
||||
hasLength(1),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'folder with only subfolders that are genuinely empty even '
|
||||
'recursively (fuente injected): still does NOT prepend',
|
||||
() async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto();
|
||||
final nodos = [
|
||||
const NodoLocal(
|
||||
documentId: 'd-sub',
|
||||
nombre: 'Subcarpeta',
|
||||
esDirectorio: true,
|
||||
),
|
||||
];
|
||||
|
||||
final pagina0 = await ConstructorArbolAuto().itemsLocales(
|
||||
nodos,
|
||||
documentIdPadre: 'padre1',
|
||||
metadatosDe: _metadatosVacio,
|
||||
fuente: fuente,
|
||||
);
|
||||
|
||||
expect(
|
||||
pagina0.where(
|
||||
(i) => i.id.startsWith('carpeta_local_reproducir:'),
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
pagina0.where((i) => i.id.startsWith('carpeta_local_aleatorio:')),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'without a fuente (omitted): falls back to direct-count-only '
|
||||
'gating -- folder with only subfolders never prepends, matching '
|
||||
'the pre-item-2 behaviour',
|
||||
() async {
|
||||
final nodos = [
|
||||
const NodoLocal(
|
||||
documentId: 'd-sub',
|
||||
nombre: 'Subcarpeta',
|
||||
esDirectorio: true,
|
||||
),
|
||||
];
|
||||
|
||||
final pagina0 = await ConstructorArbolAuto().itemsLocales(
|
||||
nodos,
|
||||
documentIdPadre: 'padre1',
|
||||
metadatosDe: _metadatosVacio,
|
||||
);
|
||||
|
||||
expect(
|
||||
pagina0.where(
|
||||
(i) => i.id.startsWith('carpeta_local_reproducir:'),
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Fake `metadatosDe` that always resolves to an empty map — used by every
|
||||
@@ -2904,13 +3568,15 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
|
||||
Object? errorEnHijos,
|
||||
Object? errorEnUriContenido,
|
||||
Object? errorEnMetadatosDe,
|
||||
Set<String>? idsConErrorEnHijos,
|
||||
}) : _configurada = configurada,
|
||||
_hijosPorDocId = hijosPorDocId ?? const {},
|
||||
_uriPorDocId = uriPorDocId ?? const {},
|
||||
_metadatosPorDocId = metadatosPorDocId ?? const {},
|
||||
_errorEnHijos = errorEnHijos,
|
||||
_errorEnUriContenido = errorEnUriContenido,
|
||||
_errorEnMetadatosDe = errorEnMetadatosDe;
|
||||
_errorEnMetadatosDe = errorEnMetadatosDe,
|
||||
_idsConErrorEnHijos = idsConErrorEnHijos ?? const {};
|
||||
|
||||
final bool _configurada;
|
||||
final Map<String, List<NodoLocal>> _hijosPorDocId;
|
||||
@@ -2920,6 +3586,11 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
|
||||
final Object? _errorEnUriContenido;
|
||||
final Object? _errorEnMetadatosDe;
|
||||
|
||||
/// Item 2 (recursive folder play): documentIds whose `hijos` call throws
|
||||
/// -- used to prove a single unresolvable subfolder does not abort the
|
||||
/// whole recursive walk, unlike [_errorEnHijos] (which fails EVERY call).
|
||||
final Set<String> _idsConErrorEnHijos;
|
||||
|
||||
/// Records every `metadatosDe` call's documentIds list, in call order —
|
||||
/// used by tests asserting the page-scoped resolution invariant at the
|
||||
/// `hijosMusicaLocal` level.
|
||||
@@ -2931,6 +3602,9 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
|
||||
@override
|
||||
Future<List<NodoLocal>> hijos(String documentId) async {
|
||||
if (_errorEnHijos != null) throw _errorEnHijos;
|
||||
if (_idsConErrorEnHijos.contains(documentId)) {
|
||||
throw Exception('permiso revocado para $documentId');
|
||||
}
|
||||
return _hijosPorDocId[documentId] ?? const [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio_session.dart';
|
||||
|
||||
/// EQ audio-focus re-apply — pure decision predicate truth table.
|
||||
///
|
||||
@@ -96,4 +98,192 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── EQ re-apply after a SHORT audio-focus interruption ──────────────────
|
||||
// debeReaplicarEcualizador only fires on a session-id CHANGE. A short
|
||||
// transient interruption (a nav-app voice prompt) keeps the SAME player
|
||||
// session id, so that trigger never fires and the equalizer stays
|
||||
// silently disabled after Android lets another app's AudioEffect steal
|
||||
// control. Fix: re-assert the equalizer on resume-from-pause and on
|
||||
// un-duck too, via a new no-arg ObjetivoAudioInterrumpible.reaplicarEcualizador()
|
||||
// that the handler implements as a thin delegate to the existing
|
||||
// _activarEcualizador() (setEnabled + band gains, already correct).
|
||||
//
|
||||
// ServicioAudioSession is the orchestration layer under test here (the
|
||||
// same layer servicio_audio_session_test.dart already covers) -- it is
|
||||
// fully unit-testable, unlike PluriWaveAudioHandler itself.
|
||||
group(
|
||||
'ServicioAudioSession re-applies the equalizer on interruption resume '
|
||||
'(no session-id change involved)',
|
||||
() {
|
||||
test(
|
||||
'a pause-interruption cycle (begin -> end/resume) calls '
|
||||
'reaplicarEcualizador exactly once, AFTER reanudar()',
|
||||
() async {
|
||||
final objetivo = _ObjetivoFake()
|
||||
..reproduciendo = true
|
||||
..intencion = true;
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(true, AudioInterruptionType.pause),
|
||||
);
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(false, AudioInterruptionType.pause),
|
||||
);
|
||||
|
||||
expect(objetivo.reaplicaciones, 1);
|
||||
expect(
|
||||
objetivo.eventos,
|
||||
['pausar', 'reanudar', 'reaplicar'],
|
||||
reason:
|
||||
'the re-apply must happen on RESUME, after reanudar() -- '
|
||||
'never before, never on the begin/pause side',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a duck cycle (begin -> end/un-duck) calls reaplicarEcualizador '
|
||||
'exactly once, AFTER setAtenuado(false)',
|
||||
() async {
|
||||
final objetivo = _ObjetivoFake()
|
||||
..reproduciendo = true
|
||||
..intencion = true;
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(true, AudioInterruptionType.duck),
|
||||
);
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(false, AudioInterruptionType.duck),
|
||||
);
|
||||
|
||||
expect(objetivo.reaplicaciones, 1);
|
||||
expect(
|
||||
objetivo.eventos,
|
||||
['atenuado:true', 'atenuado:false', 'reaplicar'],
|
||||
reason:
|
||||
'the re-apply must happen on UN-DUCK, after '
|
||||
'setAtenuado(false)',
|
||||
);
|
||||
expect(objetivo.pausas, 0, reason: 'a duck never pauses');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'with the equalizer switched OFF by the user, an interruption '
|
||||
'cycle still only calls the SAME parameterless reassert -- '
|
||||
'ServicioAudioSession has no way to force it on',
|
||||
() async {
|
||||
final objetivo = _ObjetivoFake()
|
||||
..reproduciendo = true
|
||||
..intencion = true
|
||||
..eqActivo = false;
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(true, AudioInterruptionType.pause),
|
||||
);
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(false, AudioInterruptionType.pause),
|
||||
);
|
||||
|
||||
expect(objetivo.reaplicaciones, 1);
|
||||
expect(
|
||||
objetivo.estadosReaplicados,
|
||||
[false],
|
||||
reason:
|
||||
'reaplicarEcualizador takes no boolean argument -- it can '
|
||||
'only ask the handler to reassert whatever state it '
|
||||
'ALREADY holds, never flip it on',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'end without a prior begin/pause never calls reaplicarEcualizador '
|
||||
'(mirrors "end sin pausa previa" -- no resume happened)',
|
||||
() async {
|
||||
final objetivo = _ObjetivoFake();
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(false, AudioInterruptionType.pause),
|
||||
);
|
||||
|
||||
expect(objetivo.reaplicaciones, 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a permanent (unknown-type) focus loss never calls '
|
||||
'reaplicarEcualizador -- there is no resume to re-assert after',
|
||||
() async {
|
||||
final objetivo = _ObjetivoFake()
|
||||
..reproduciendo = true
|
||||
..intencion = true;
|
||||
final servicio = ServicioAudioSession(objetivo: objetivo);
|
||||
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(true, AudioInterruptionType.unknown),
|
||||
);
|
||||
await servicio.manejarInterrupcion(
|
||||
AudioInterruptionEvent(false, AudioInterruptionType.unknown),
|
||||
);
|
||||
|
||||
expect(objetivo.reaplicaciones, 0);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _ObjetivoFake implements ObjetivoAudioInterrumpible {
|
||||
bool intencion = false;
|
||||
bool reproduciendo = false;
|
||||
bool eqActivo = true;
|
||||
int pausas = 0;
|
||||
int reaplicaciones = 0;
|
||||
final List<bool> atenuaciones = [];
|
||||
final List<bool> estadosReaplicados = [];
|
||||
|
||||
/// Ordering log shared across every method — proves reaplicarEcualizador
|
||||
/// fires at the EXACT point in the sequence the fix requires (after
|
||||
/// reanudar()/setAtenuado(false)), not merely "at some point".
|
||||
final List<String> eventos = [];
|
||||
|
||||
@override
|
||||
bool get intencionReproducir => intencion;
|
||||
|
||||
@override
|
||||
bool get estaReproduciendo => reproduciendo;
|
||||
|
||||
@override
|
||||
Future<void> pausar() async {
|
||||
pausas++;
|
||||
reproduciendo = false;
|
||||
intencion = false;
|
||||
eventos.add('pausar');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reanudar() async {
|
||||
reproduciendo = true;
|
||||
intencion = true;
|
||||
eventos.add('reanudar');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setAtenuado(bool atenuado) async {
|
||||
atenuaciones.add(atenuado);
|
||||
eventos.add('atenuado:$atenuado');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> reaplicarEcualizador() async {
|
||||
reaplicaciones++;
|
||||
estadosReaplicados.add(eqActivo);
|
||||
eventos.add('reaplicar');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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/emisora.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
/// Item 3 (Android Auto fallback artwork) — the pure, handler-independent
|
||||
/// half of the fix. `PluriWaveAudioHandler` cannot be instantiated in unit
|
||||
/// tests (a real `just_audio.AudioPlayer` requires platform MethodChannels,
|
||||
/// confirmed by `servicio_audio_source_switch_test.dart`), so the actual
|
||||
/// "now playing" MediaItem construction and the reverse Emisora
|
||||
/// reconstruction are extracted as pure top-level functions here, exactly
|
||||
/// like `debeReaplicarEcualizador` was extracted for the EQ re-apply fix.
|
||||
void main() {
|
||||
final l10n = lookupAppLocalizations(const Locale('es'));
|
||||
|
||||
group('mediaItemParaEmisora (item 3)', () {
|
||||
test(
|
||||
'estación SIN favicon usable: el MediaItem usa el fallback de marca '
|
||||
'(artUriPara), no queda con artUri null',
|
||||
() {
|
||||
const emisora = Emisora(
|
||||
uuid: 'uuid-sin-arte',
|
||||
nombre: 'Radio sin logo',
|
||||
url: 'https://stream.demo/sin-logo',
|
||||
);
|
||||
|
||||
final item = mediaItemParaEmisora(emisora, l10n: l10n);
|
||||
|
||||
expect(item.artUri, isNotNull);
|
||||
expect(item.artUri.toString(), artUriPara(emisora));
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'estación CON favicon http(s) usable: el MediaItem sigue usando ese '
|
||||
'favicon real, no el fallback',
|
||||
() {
|
||||
const emisora = Emisora(
|
||||
uuid: 'uuid-con-arte',
|
||||
nombre: 'Radio con logo',
|
||||
url: 'https://stream.demo/con-logo',
|
||||
favicon: 'https://cdn.example.com/logo.png',
|
||||
);
|
||||
|
||||
final item = mediaItemParaEmisora(emisora, l10n: l10n);
|
||||
|
||||
expect(item.artUri.toString(), emisora.favicon);
|
||||
},
|
||||
);
|
||||
|
||||
test('preserva id, artist y extras.uuid como antes', () {
|
||||
const emisora = Emisora(
|
||||
uuid: 'uuid-forma',
|
||||
nombre: 'Radio Forma',
|
||||
url: 'https://stream.demo/forma',
|
||||
pais: 'Argentina',
|
||||
);
|
||||
|
||||
final item = mediaItemParaEmisora(emisora, l10n: l10n);
|
||||
|
||||
expect(item.id, emisora.url);
|
||||
expect(item.artist, 'Argentina');
|
||||
expect(item.album, 'PluriWave');
|
||||
expect(item.extras?['uuid'], emisora.uuid);
|
||||
});
|
||||
});
|
||||
|
||||
group('emisoraDesdeMediaItem (item 3 — no phone-UI regression)', () {
|
||||
test(
|
||||
'artUri de marca (android.resource://…, no http) NUNCA se refleja '
|
||||
'como favicon -- evitaría un intento de red inválido en '
|
||||
'CachedNetworkImage del lado telefono',
|
||||
() {
|
||||
final mediaItem = MediaItem(
|
||||
id: 'https://stream.demo/sin-logo',
|
||||
title: 'Radio sin logo',
|
||||
artUri: Uri.parse(
|
||||
'android.resource://es.freetimelab.pluriwave/drawable/'
|
||||
'station_art_aurora',
|
||||
),
|
||||
extras: const {'uuid': 'uuid-sin-arte'},
|
||||
);
|
||||
|
||||
final emisora = emisoraDesdeMediaItem(mediaItem);
|
||||
|
||||
expect(emisora.favicon, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'artUri http(s) real SÍ se refleja como favicon (comportamiento '
|
||||
'previo preservado)',
|
||||
() {
|
||||
final mediaItem = MediaItem(
|
||||
id: 'https://stream.demo/con-logo',
|
||||
title: 'Radio con logo',
|
||||
artUri: Uri.parse('https://cdn.example.com/logo.png'),
|
||||
extras: const {'uuid': 'uuid-con-arte'},
|
||||
);
|
||||
|
||||
final emisora = emisoraDesdeMediaItem(mediaItem);
|
||||
|
||||
expect(emisora.favicon, 'https://cdn.example.com/logo.png');
|
||||
},
|
||||
);
|
||||
|
||||
test('sin artUri: favicon queda null, sin lanzar', () {
|
||||
final mediaItem = MediaItem(
|
||||
id: 'https://stream.demo/sin-arturi',
|
||||
title: 'Radio',
|
||||
extras: const {'uuid': 'uuid-x'},
|
||||
);
|
||||
|
||||
final emisora = emisoraDesdeMediaItem(mediaItem);
|
||||
|
||||
expect(emisora.favicon, isNull);
|
||||
});
|
||||
|
||||
test('preserva uuid, nombre, url y pais como antes', () {
|
||||
final mediaItem = MediaItem(
|
||||
id: 'https://stream.demo/forma',
|
||||
title: 'Radio Forma',
|
||||
artist: 'Argentina',
|
||||
extras: const {'uuid': 'uuid-forma'},
|
||||
);
|
||||
|
||||
final emisora = emisoraDesdeMediaItem(mediaItem);
|
||||
|
||||
expect(emisora.uuid, 'uuid-forma');
|
||||
expect(emisora.nombre, 'Radio Forma');
|
||||
expect(emisora.url, mediaItem.id);
|
||||
expect(emisora.pais, 'Argentina');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -33,6 +33,13 @@ class _ObjetivoFake implements ObjetivoAudioInterrumpible {
|
||||
Future<void> setAtenuado(bool atenuado) async {
|
||||
atenuaciones.add(atenuado);
|
||||
}
|
||||
|
||||
int reaplicaciones = 0;
|
||||
|
||||
@override
|
||||
Future<void> reaplicarEcualizador() async {
|
||||
reaplicaciones++;
|
||||
}
|
||||
}
|
||||
|
||||
/// S3-R1: audio-session interruptions (phone call, transient loss, duck) and
|
||||
|
||||
Reference in New Issue
Block a user