feat(auto,eq,alarmas): address the second round of on-device feedback
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m37s

- 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:
2026-07-31 01:12:13 +02:00
38 changed files with 2461 additions and 115 deletions
+12 -1
View File
@@ -830,5 +830,16 @@
"welcomeHeadline": "عالمك، على الهواء مباشرة", "welcomeHeadline": "عالمك، على الهواء مباشرة",
"yourStationsTitle": "محطاتك", "yourStationsTitle": "محطاتك",
"nowListeningLabel": "الاستماع الآن", "nowListeningLabel": "الاستماع الآن",
"popularNowTitle": "الأكثر شيوعًا الآن" "popularNowTitle": "الأكثر شيوعًا الآن",
"eqCustomActionEnableLabel": "تفعيل الموازن",
"eqCustomActionDisableLabel": "إيقاف الموازن",
"eqCustomActionPresetLabel": "الإعداد المسبق: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "متوقفة مؤقتًا بسبب الإجازة"
} }
+12 -1
View File
@@ -830,5 +830,16 @@
"welcomeHeadline": "আপনার বিশ্ব, সরাসরি", "welcomeHeadline": "আপনার বিশ্ব, সরাসরি",
"yourStationsTitle": "আপনার স্টেশন", "yourStationsTitle": "আপনার স্টেশন",
"nowListeningLabel": "এখন শোনা হচ্ছে", "nowListeningLabel": "এখন শোনা হচ্ছে",
"popularNowTitle": "এখন জনপ্রিয়" "popularNowTitle": "এখন জনপ্রিয়",
"eqCustomActionEnableLabel": "ইকুয়ালাইজার চালু করুন",
"eqCustomActionDisableLabel": "ইকুয়ালাইজার বন্ধ করুন",
"eqCustomActionPresetLabel": "প্রিসেট: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "ছুটির কারণে বিরত"
} }
+12 -1
View File
@@ -830,5 +830,16 @@
"welcomeHeadline": "Deine Welt, live", "welcomeHeadline": "Deine Welt, live",
"yourStationsTitle": "Deine Sender", "yourStationsTitle": "Deine Sender",
"nowListeningLabel": "Läuft gerade", "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
View File
@@ -830,5 +830,16 @@
"welcomeBullet2Subtitle": "Your favorites and local music in the car", "welcomeBullet2Subtitle": "Your favorites and local music in the car",
"welcomeBullet3Title": "Music alarms", "welcomeBullet3Title": "Music alarms",
"welcomeBullet3Subtitle": "With gradual volume rise and vacation mode", "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
View File
@@ -789,5 +789,16 @@
"welcomeBullet2Subtitle": "Tus favoritas y tu música local en el auto", "welcomeBullet2Subtitle": "Tus favoritas y tu música local en el auto",
"welcomeBullet3Title": "Alarmas musicales", "welcomeBullet3Title": "Alarmas musicales",
"welcomeBullet3Subtitle": "Con subida progresiva y modo vacaciones", "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
View File
@@ -830,5 +830,16 @@
"welcomeHeadline": "Votre monde, en direct", "welcomeHeadline": "Votre monde, en direct",
"yourStationsTitle": "Vos stations", "yourStationsTitle": "Vos stations",
"nowListeningLabel": "En cours d'écoute", "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
View File
@@ -830,5 +830,16 @@
"welcomeHeadline": "आपकी दुनिया, लाइव", "welcomeHeadline": "आपकी दुनिया, लाइव",
"yourStationsTitle": "आपके स्टेशन", "yourStationsTitle": "आपके स्टेशन",
"nowListeningLabel": "अभी सुन रहे हैं", "nowListeningLabel": "अभी सुन रहे हैं",
"popularNowTitle": "अभी लोकप्रिय" "popularNowTitle": "अभी लोकप्रिय",
"eqCustomActionEnableLabel": "इक्वलाइज़र चालू करें",
"eqCustomActionDisableLabel": "इक्वलाइज़र बंद करें",
"eqCustomActionPresetLabel": "प्रीसेट: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "छुट्टी के कारण रोका गया"
} }
+12 -1
View File
@@ -830,5 +830,16 @@
"welcomeHeadline": "Duniamu, secara langsung", "welcomeHeadline": "Duniamu, secara langsung",
"yourStationsTitle": "Stasiun Anda", "yourStationsTitle": "Stasiun Anda",
"nowListeningLabel": "Sedang mendengarkan", "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
View File
@@ -830,5 +830,16 @@
"welcomeHeadline": "Il tuo mondo, in diretta", "welcomeHeadline": "Il tuo mondo, in diretta",
"yourStationsTitle": "Le tue emittenti", "yourStationsTitle": "Le tue emittenti",
"nowListeningLabel": "In ascolto ora", "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
View File
@@ -830,5 +830,16 @@
"welcomeHeadline": "あなたの世界を、ライブで", "welcomeHeadline": "あなたの世界を、ライブで",
"yourStationsTitle": "あなたの局", "yourStationsTitle": "あなたの局",
"nowListeningLabel": "再生中", "nowListeningLabel": "再生中",
"popularNowTitle": "今人気" "popularNowTitle": "今人気",
"eqCustomActionEnableLabel": "イコライザーをオンにする",
"eqCustomActionDisableLabel": "イコライザーをオフにする",
"eqCustomActionPresetLabel": "プリセット: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "休暇のため一時停止中"
} }
+12 -1
View File
@@ -830,5 +830,16 @@
"welcomeHeadline": "Seu mundo, ao vivo", "welcomeHeadline": "Seu mundo, ao vivo",
"yourStationsTitle": "Suas estações", "yourStationsTitle": "Suas estações",
"nowListeningLabel": "Ouvindo agora", "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
View File
@@ -830,5 +830,16 @@
"welcomeHeadline": "Ваш мир, в прямом эфире", "welcomeHeadline": "Ваш мир, в прямом эфире",
"yourStationsTitle": "Ваши станции", "yourStationsTitle": "Ваши станции",
"nowListeningLabel": "Сейчас слушаете", "nowListeningLabel": "Сейчас слушаете",
"popularNowTitle": "Популярно сейчас" "popularNowTitle": "Популярно сейчас",
"eqCustomActionEnableLabel": "Включить эквалайзер",
"eqCustomActionDisableLabel": "Выключить эквалайзер",
"eqCustomActionPresetLabel": "Пресет: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "Приостановлено на время отпуска"
} }
+12 -1
View File
@@ -830,5 +830,16 @@
"welcomeHeadline": "你的世界,直播中", "welcomeHeadline": "你的世界,直播中",
"yourStationsTitle": "你的电台", "yourStationsTitle": "你的电台",
"nowListeningLabel": "正在收听", "nowListeningLabel": "正在收听",
"popularNowTitle": "当前热门" "popularNowTitle": "当前热门",
"eqCustomActionEnableLabel": "启用均衡器",
"eqCustomActionDisableLabel": "关闭均衡器",
"eqCustomActionPresetLabel": "预设:{preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "因假期已暂停"
} }
+24
View File
@@ -3049,6 +3049,30 @@ abstract class AppLocalizations {
/// In es, this message translates to: /// In es, this message translates to:
/// **'Empezar a escuchar'** /// **'Empezar a escuchar'**
String get welcomeCtaLabel; 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 class _AppLocalizationsDelegate
+14
View File
@@ -1676,4 +1676,18 @@ class AppLocalizationsAr extends AppLocalizations {
@override @override
String get welcomeCtaLabel => 'ابدأ الاستماع'; String get welcomeCtaLabel => 'ابدأ الاستماع';
@override
String get eqCustomActionEnableLabel => 'تفعيل الموازن';
@override
String get eqCustomActionDisableLabel => 'إيقاف الموازن';
@override
String eqCustomActionPresetLabel(String preset) {
return 'الإعداد المسبق: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'متوقفة مؤقتًا بسبب الإجازة';
} }
+14
View File
@@ -1685,4 +1685,18 @@ class AppLocalizationsBn extends AppLocalizations {
@override @override
String get welcomeCtaLabel => 'শোনা শুরু করুন'; String get welcomeCtaLabel => 'শোনা শুরু করুন';
@override
String get eqCustomActionEnableLabel => 'ইকুয়ালাইজার চালু করুন';
@override
String get eqCustomActionDisableLabel => 'ইকুয়ালাইজার বন্ধ করুন';
@override
String eqCustomActionPresetLabel(String preset) {
return 'প্রিসেট: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'ছুটির কারণে বিরত';
} }
+14
View File
@@ -1698,4 +1698,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get welcomeCtaLabel => 'Jetzt hören'; 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';
} }
+14
View File
@@ -1678,4 +1678,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get welcomeCtaLabel => 'Start listening'; 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';
} }
+14
View File
@@ -1692,4 +1692,18 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get welcomeCtaLabel => 'Empezar a escuchar'; 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';
} }
+14
View File
@@ -1701,4 +1701,18 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get welcomeCtaLabel => 'Commencer à écouter'; 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';
} }
+14
View File
@@ -1680,4 +1680,18 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get welcomeCtaLabel => 'सुनना शुरू करें'; String get welcomeCtaLabel => 'सुनना शुरू करें';
@override
String get eqCustomActionEnableLabel => 'इक्वलाइज़र चालू करें';
@override
String get eqCustomActionDisableLabel => 'इक्वलाइज़र बंद करें';
@override
String eqCustomActionPresetLabel(String preset) {
return 'प्रीसेट: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'छुट्टी के कारण रोका गया';
} }
+14
View File
@@ -1688,4 +1688,18 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get welcomeCtaLabel => 'Mulai mendengarkan'; 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';
} }
+14
View File
@@ -1700,4 +1700,18 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get welcomeCtaLabel => 'Inizia ad ascoltare'; 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';
} }
+14
View File
@@ -1631,4 +1631,18 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get welcomeCtaLabel => '聴き始める'; String get welcomeCtaLabel => '聴き始める';
@override
String get eqCustomActionEnableLabel => 'イコライザーをオンにする';
@override
String get eqCustomActionDisableLabel => 'イコライザーをオフにする';
@override
String eqCustomActionPresetLabel(String preset) {
return 'プリセット: $preset';
}
@override
String get alarmCardVacationPausedBadge => '休暇のため一時停止中';
} }
+14
View File
@@ -1688,4 +1688,18 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get welcomeCtaLabel => 'Começar a ouvir'; 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';
} }
+14
View File
@@ -1692,4 +1692,18 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get welcomeCtaLabel => 'Начать слушать'; String get welcomeCtaLabel => 'Начать слушать';
@override
String get eqCustomActionEnableLabel => 'Включить эквалайзер';
@override
String get eqCustomActionDisableLabel => 'Выключить эквалайзер';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Пресет: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Приостановлено на время отпуска';
} }
+14
View File
@@ -1623,4 +1623,18 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get welcomeCtaLabel => '开始收听'; String get welcomeCtaLabel => '开始收听';
@override
String get eqCustomActionEnableLabel => '启用均衡器';
@override
String get eqCustomActionDisableLabel => '关闭均衡器';
@override
String eqCustomActionPresetLabel(String preset) {
return '预设:$preset';
}
@override
String get alarmCardVacationPausedBadge => '因假期已暂停';
} }
+84 -15
View File
@@ -265,6 +265,26 @@ class _TarjetaAlarma extends StatelessWidget {
? l10n.noStationUseInternalSound ? l10n.noStationUseInternalSound
: localizedStationName(l10n, alarma.emisora!.nombre); : 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( return Dismissible(
key: ValueKey('tarjeta-alarma-${alarma.id}'), key: ValueKey('tarjeta-alarma-${alarma.id}'),
direction: DismissDirection.horizontal, direction: DismissDirection.horizontal,
@@ -309,14 +329,21 @@ class _TarjetaAlarma extends StatelessWidget {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( // Item 5: real day list can run longer than the
_recurrenciaCorta(l10n, alarma), // old generic "Días" label -- Flexible+ellipsis
style: TextStyle( // keeps a long selection from overflowing the
fontSize: 12, // Row instead of clipping visibly.
fontWeight: FontWeight.w700, Flexible(
color: Theme.of( child: Text(
context, _recurrenciaCorta(l10n, alarma),
).colorScheme.onSurface.withValues(alpha: 0.5), 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) => String _fechaCorta(AppLocalizations l10n, DateTime fecha) =>
fechaCortaLocalizada(l10n.localeName, fecha); fechaCortaLocalizada(l10n.localeName, fecha);
/// Audit 7.4 (t4:339): a compact recurrence label next to the alarm card's /// Audit 7.4 (t4:339) / item 5: a compact recurrence label next to the
/// giant time. Reuses the SAME generic labels the editor's own /// alarm card's giant time. `diaria`/`unica` still show the SAME generic
/// `TipoProgramacionAlarma` `SegmentedButton` already shows (`oneTimeOption` /// labels the editor's own `TipoProgramacionAlarma` `SegmentedButton`
/// / `dailyOption` / `weekdaysOption`) rather than inventing a new, more /// already uses (`dailyOption`/`oneTimeOption`) -- both are already fully
/// specific ARB string -- honest given the space (12px, next to a 34px /// specific (there is nothing more concrete to say than "every day"/"just
/// time) genuinely only fits a short word, not a full weekday list. /// 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) { String _recurrenciaCorta(AppLocalizations l10n, AlarmaMusical alarma) {
return switch (alarma.tipoProgramacion) { return switch (alarma.tipoProgramacion) {
TipoProgramacionAlarma.diaria => l10n.dailyOption, TipoProgramacionAlarma.diaria => l10n.dailyOption,
TipoProgramacionAlarma.diasSemana => l10n.weekdaysOption, TipoProgramacionAlarma.diasSemana => _diasSemanaCorto(
l10n,
alarma.diasSemana,
),
TipoProgramacionAlarma.unica => l10n.oneTimeOption, 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(', ');
}
+191 -29
View File
@@ -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}) => bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) =>
total > (pagina + 1) * 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:'; const _prefijoEmisora = 'emisora:';
/// Local-track media-id prefix (Design "media-id scheme"), collision-free /// Local-track media-id prefix (Design "media-id scheme"), collision-free
@@ -432,9 +447,15 @@ class ConstructorArbolAuto {
int tamano = _maxItemsCarpetaLocal, int tamano = _maxItemsCarpetaLocal,
@visibleForTesting @visibleForTesting
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem, 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 { }) async {
final construir = construirItem ?? _itemLocal; 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 paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final docIds = paginaActual final docIds = paginaActual
.where((n) => !n.esDirectorio) .where((n) => !n.esDirectorio)
@@ -447,14 +468,26 @@ class ConstructorArbolAuto {
} }
if (pagina == 0) { if (pagina == 0) {
final totalPistas = nodos.where((n) => !n.esDirectorio).length; 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>[ final prepend = <MediaItem>[
// Folder-play actions (Design ADR-5, Phase 3): prepended BEFORE // Folder-play actions (Design ADR-5, Phase 3; recursive gate item
// the sort/bucket nav entries, guarded the same shape as // 2): prepended BEFORE the sort/bucket nav entries, present iff
// ofreceOrdenCalidad(totalPistas > 0) — present iff the folder has // the folder has at least one playable track anywhere beneath it
// at least one direct audio child, absent for a folder with only // (direct or nested), absent for a folder that is genuinely empty
// subfolders (Spec "Folder has no tracks"). // even recursively (Spec "Folder has no tracks").
if (totalPistas > 0) _itemReproducirCarpeta(documentIdPadre), if (hayContenidoReproducible) _itemReproducirCarpeta(documentIdPadre),
if (totalPistas > 0) _itemReproducirAleatorio(documentIdPadre), if (hayContenidoReproducible)
_itemReproducirAleatorio(documentIdPadre),
if (ofreceOrdenCalidad(totalPistas)) if (ofreceOrdenCalidad(totalPistas))
_itemModoOrdenCalidad(documentIdPadre), _itemModoOrdenCalidad(documentIdPadre),
if (ofreceBuckets(totalPistas)) if (ofreceBuckets(totalPistas))
@@ -466,6 +499,32 @@ class ConstructorArbolAuto {
return items; 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 /// Whether the "Ordenar por calidad" mode entry should be offered for a
/// folder with [totalPistas] audio files (Design ADR-3): present for /// folder with [totalPistas] audio files (Design ADR-3): present for
/// `0 < totalPistas <= 150`, omitted otherwise (empty folder or above the /// `0 < totalPistas <= 150`, omitted otherwise (empty folder or above the
@@ -777,10 +836,12 @@ Future<void> reproducirPorMediaId(
title: emisora.nombre, title: emisora.nombre,
artist: emisora.pais ?? '', artist: emisora.pais ?? '',
album: 'PluriWave', album: 'PluriWave',
artUri: // Item 3: reuses [artUriPara] (the SAME fallback the browse tree's
emisora.favicon != null && emisora.favicon!.isNotEmpty // itemEmisora already applies) so the "now playing" media item never
? Uri.tryParse(emisora.favicon!) // falls back to a blank tile — a real usable favicon still wins, a
: null, // missing/unusable one gets the on-brand rotating drawable instead of
// `null`.
artUri: Uri.parse(artUriPara(emisora)),
extras: {'uuid': emisora.uuid}, extras: {'uuid': emisora.uuid},
); );
await reproducir(item); await reproducir(item);
@@ -931,17 +992,115 @@ List<NodoLocal> mezclarFisherYates(List<NodoLocal> nodos, Random rng) {
List<NodoLocal> pistasEnOrdenAleatorio(List<NodoLocal> nodos, Random rng) => List<NodoLocal> pistasEnOrdenAleatorio(List<NodoLocal> nodos, Random rng) =>
mezclarFisherYates(pistasEnOrdenNombre(nodos), 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 /// Orchestrates a "Reproducir carpeta"/"Reproducir aleatorio" tap (Design
/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2): resolves whichever of the /// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2; recursive collection item
/// two action prefixes matches [id] (ignoring [aleatorio] for the STRIP — /// 2): resolves whichever of the two action prefixes matches [id]
/// the prefix itself is authoritative), fetches [fuente]'s direct children /// (ignoring [aleatorio] for the STRIP — the prefix itself is
/// for that folder, filters to audio files, orders them ([aleatorio] picks /// authoritative), RECURSIVELY collects every track beneath that folder
/// shuffled vs name order), and hands the resulting list to [iniciarCola]. /// 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 /// A no-op (never calls [iniciarCola]) when: [id] matches neither action
/// prefix; [fuente.hijos] throws or returns only directories (an /// prefix; the folder (or everything beneath it, within the recursion
/// unresolvable/empty folder — Design "no-op on empty/unresolvable /// bounds) is unresolvable/empty (Design "no-op on empty/unresolvable
/// folder"). /// folder") — [pistasRecursivas] never throws, so this never propagates an
/// exception either.
Future<void> reproducirCarpetaLocal( Future<void> reproducirCarpetaLocal(
String id, { String id, {
required bool aleatorio, required bool aleatorio,
@@ -959,16 +1118,10 @@ Future<void> reproducirCarpetaLocal(
return; return;
} }
final List<NodoLocal> nodos; final recolectadas = await pistasRecursivas(documentId, fuente: fuente);
try {
nodos = await fuente.hijos(documentId);
} catch (_) {
return;
}
final pistas = aleatorio final pistas = aleatorio
? pistasEnOrdenAleatorio(nodos, rng ?? Random()) ? mezclarFisherYates(recolectadas, rng ?? Random())
: pistasEnOrdenNombre(nodos); : recolectadas;
if (pistas.isEmpty) return; if (pistas.isEmpty) return;
await iniciarCola(pistas); await iniciarCola(pistas);
@@ -992,6 +1145,11 @@ Future<MediaItem?> construirMediaItemColaLocal(
id: contentUri, id: contentUri,
title: _tituloDesdeDocumentId(nodo.documentId), title: _tituloDesdeDocumentId(nodo.documentId),
album: 'PluriWave', 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}, extras: {'documentId': nodo.documentId},
); );
} }
@@ -1114,6 +1272,7 @@ Future<List<MediaItem>?> hijosMusicaLocal(
documentIdPadre: documentId, documentIdPadre: documentId,
pagina: pagina, pagina: pagina,
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente), metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
fuente: fuente,
); );
} catch (_) { } catch (_) {
return const []; return const [];
@@ -1170,6 +1329,9 @@ Future<void> reproducirPistaLocal(
id: pista.contentUri, id: pista.contentUri,
title: pista.titulo, title: pista.titulo,
album: 'PluriWave', 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}, extras: {'documentId': pista.documentId},
); );
await reproducir(item); await reproducir(item);
+252 -53
View File
@@ -60,6 +60,131 @@ void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) {
_fuenteMusicaLocalGlobal = 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. /// Wrapper de alto nivel para el UI.
class ServicioAudio { class ServicioAudio {
PluriWaveAudioHandler get _handler { PluriWaveAudioHandler get _handler {
@@ -94,19 +219,9 @@ class ServicioAudio {
}); });
Future<void> reproducir(Emisora emisora) async { Future<void> reproducir(Emisora emisora) async {
final item = MediaItem( final item = mediaItemParaEmisora(
id: emisora.url, emisora,
title: localizedStationName( l10n: lookupAppLocalizations(const Locale('es')),
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},
); );
await _handler.playMediaItem(item); await _handler.playMediaItem(item);
} }
@@ -289,12 +404,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler
final colaActiva = _colaLocal != null; final colaActiva = _colaLocal != null;
playbackState.add( playbackState.add(
playbackState.value.copyWith( playbackState.value.copyWith(
controls: [ controls: _controlesTransporte(
if (colaActiva) MediaControl.skipToPrevious, colaActiva: colaActiva,
if (playing) MediaControl.pause else MediaControl.play, playing: playing,
MediaControl.stop, ),
if (colaActiva) MediaControl.skipToNext,
],
systemActions: { systemActions: {
MediaAction.seek, MediaAction.seek,
MediaAction.stop, 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. /// Gestiona cualquier error de reproducción de ExoPlayer.
/// ///
/// Network-class failures while the user still intends to play enter the /// Network-class failures while the user still intends to play enter the
@@ -724,6 +880,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler
} catch (_) { } catch (_) {
_eqDisponible = false; _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. /// 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. /// Aplica un preset al ecualizador nativo Android.
Future<void> aplicarPreset(PresetEcualizador preset) async { Future<void> aplicarPreset(PresetEcualizador preset) async {
_presetActual = preset; _presetActual = preset;
if (!_eqDisponible) return; if (_eqDisponible) {
try { try {
await _eq.setEnabled(_ecualizadorActivo); await _eq.setEnabled(_ecualizadorActivo);
if (!_ecualizadorActivo) return; if (_ecualizadorActivo) {
final params = await _eq.parameters; final params = await _eq.parameters;
for ( for (
int i = 0; int i = 0;
i < params.bands.length && i < preset.bandas.length; i < params.bands.length && i < preset.bandas.length;
i++ i++
) { ) {
await params.bands[i].setGain( await params.bands[i].setGain(
_mapearGananciaNativa( _mapearGananciaNativa(
preset.bandas[i], preset.bandas[i],
minDecibels: params.minDecibels, minDecibels: params.minDecibels,
maxDecibels: params.maxDecibels, maxDecibels: params.maxDecibels,
), ),
); );
} }
} catch (_) {} }
} 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. /// Ajusta una banda individual.
@@ -796,13 +963,18 @@ class PluriWaveAudioHandler extends BaseAudioHandler
Future<void> setEcualizadorActivo(bool activo) async { Future<void> setEcualizadorActivo(bool activo) async {
_ecualizadorActivo = activo; _ecualizadorActivo = activo;
if (!_eqDisponible) return; if (_eqDisponible) {
try { try {
await _eq.setEnabled(activo); await _eq.setEnabled(activo);
if (activo) { if (activo) {
await aplicarPreset(_presetActual); await aplicarPreset(_presetActual);
} }
} catch (_) {} } 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 { Future<void> setVolumen(double vol) async {
@@ -834,6 +1006,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler
await _player.setVolume(_volumenEfectivo); 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 @override
Future<void> play() { Future<void> play() {
_intencionReproducir = true; _intencionReproducir = true;
@@ -899,6 +1081,27 @@ class PluriWaveAudioHandler extends BaseAudioHandler
await _reproducirEntradaCola(anterior.actual); 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 @override
Future<void> onTaskRemoved() async { Future<void> onTaskRemoved() async {
await stop(); await stop();
@@ -911,14 +1114,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler
} }
Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) { Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) {
final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id; // Item 3: delegates to the top-level, unit-testable function so the
return Emisora( // `faviconUsable` guard (never reflect the on-brand fallback artUri
uuid: uuid, // back as a real favicon) is covered without instantiating the handler.
nombre: mediaItem.title, return emisoraDesdeMediaItem(mediaItem);
url: mediaItem.id,
pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null,
favicon: mediaItem.artUri?.toString(),
);
} }
// ── Android Auto browsing (thin delegation to navegacion_auto.dart's // ── Android Auto browsing (thin delegation to navegacion_auto.dart's
+21
View File
@@ -20,6 +20,20 @@ abstract class ObjetivoAudioInterrumpible {
/// Temporarily lowers ("ducks") the output volume without pausing. /// Temporarily lowers ("ducks") the output volume without pausing.
Future<void> setAtenuado(bool atenuado); 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 /// Wrapper around `package:audio_session` (S3-R1): configures the session
@@ -84,11 +98,18 @@ class ServicioAudioSession {
switch (evento.type) { switch (evento.type) {
case AudioInterruptionType.duck: case AudioInterruptionType.duck:
await _objetivo.setAtenuado(false); 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: case AudioInterruptionType.pause:
// Transient loss ended and the OS says we may resume. // Transient loss ended and the OS says we may resume.
if (_pausadoPorInterrupcion) { if (_pausadoPorInterrupcion) {
_pausadoPorInterrupcion = false; _pausadoPorInterrupcion = false;
await _objetivo.reanudar(); 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: case AudioInterruptionType.unknown:
// Permanent focus loss: never auto-resume. // Permanent focus loss: never auto-resume.
@@ -190,9 +190,26 @@ void main() {
expect(antes, isNot(l10n.alarmNoNextExecution)); expect(antes, isNot(l10n.alarmNoNextExecution));
// Lunes -> Martes: la fecha calculada SIEMPRE cambia, sea cual sea hoy. // 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.pumpAndSettle();
await tester.tap(find.text(l10n.weekdayShortMonday)); await tester.tap(
find.descendant(
of: hojaEditor,
matching: find.text(l10n.weekdayShortMonday),
),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final despues = _textoPreview(tester); 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,
);
},
);
}
+677 -3
View File
@@ -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( test(
'id obsoleto/desconocido (uriContenidoDePista devuelve null) no ' 'id obsoleto/desconocido (uriContenidoDePista devuelve null) no '
'llama a reproducir ni lanza excepción', 'llama a reproducir ni lanza excepción',
@@ -2334,8 +2356,9 @@ void main() {
); );
test( test(
'carpeta con solo subcarpetas (sin pistas de audio directas) es un ' 'carpeta con solo subcarpetas (sin pistas de audio directas), y esa '
'no-op: iniciarCola nunca se llama', 'subcarpeta TAMBIÉN está vacía incluso recursivamente, es un no-op: '
'iniciarCola nunca se llama',
() async { () async {
final fuente = _FakeFuenteMusicaLocalAuto( final fuente = _FakeFuenteMusicaLocalAuto(
hijosPorDocId: { 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( test(
'carpeta irresoluble (fuente.hijos lanza) es un no-op, sin propagar ' 'carpeta irresoluble (fuente.hijos lanza) es un no-op, sin propagar '
'la excepción', 'la excepción',
@@ -2456,6 +2516,27 @@ void main() {
expect(item, isNull); 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', () { group('ConstructorArbolAuto.itemEmisora', () {
@@ -2828,7 +2909,590 @@ void main() {
expect(llamadas, 0); 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 /// Fake `metadatosDe` that always resolves to an empty map — used by every
@@ -2904,13 +3568,15 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
Object? errorEnHijos, Object? errorEnHijos,
Object? errorEnUriContenido, Object? errorEnUriContenido,
Object? errorEnMetadatosDe, Object? errorEnMetadatosDe,
Set<String>? idsConErrorEnHijos,
}) : _configurada = configurada, }) : _configurada = configurada,
_hijosPorDocId = hijosPorDocId ?? const {}, _hijosPorDocId = hijosPorDocId ?? const {},
_uriPorDocId = uriPorDocId ?? const {}, _uriPorDocId = uriPorDocId ?? const {},
_metadatosPorDocId = metadatosPorDocId ?? const {}, _metadatosPorDocId = metadatosPorDocId ?? const {},
_errorEnHijos = errorEnHijos, _errorEnHijos = errorEnHijos,
_errorEnUriContenido = errorEnUriContenido, _errorEnUriContenido = errorEnUriContenido,
_errorEnMetadatosDe = errorEnMetadatosDe; _errorEnMetadatosDe = errorEnMetadatosDe,
_idsConErrorEnHijos = idsConErrorEnHijos ?? const {};
final bool _configurada; final bool _configurada;
final Map<String, List<NodoLocal>> _hijosPorDocId; final Map<String, List<NodoLocal>> _hijosPorDocId;
@@ -2920,6 +3586,11 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
final Object? _errorEnUriContenido; final Object? _errorEnUriContenido;
final Object? _errorEnMetadatosDe; 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 — /// Records every `metadatosDe` call's documentIds list, in call order —
/// used by tests asserting the page-scoped resolution invariant at the /// used by tests asserting the page-scoped resolution invariant at the
/// `hijosMusicaLocal` level. /// `hijosMusicaLocal` level.
@@ -2931,6 +3602,9 @@ class _FakeFuenteMusicaLocalAuto implements FuenteMusicaLocalAuto {
@override @override
Future<List<NodoLocal>> hijos(String documentId) async { Future<List<NodoLocal>> hijos(String documentId) async {
if (_errorEnHijos != null) throw _errorEnHijos; if (_errorEnHijos != null) throw _errorEnHijos;
if (_idsConErrorEnHijos.contains(documentId)) {
throw Exception('permiso revocado para $documentId');
}
return _hijosPorDocId[documentId] ?? const []; 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:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.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. /// 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 { Future<void> setAtenuado(bool atenuado) async {
atenuaciones.add(atenuado); atenuaciones.add(atenuado);
} }
int reaplicaciones = 0;
@override
Future<void> reaplicarEcualizador() async {
reaplicaciones++;
}
} }
/// S3-R1: audio-session interruptions (phone call, transient loss, duck) and /// S3-R1: audio-session interruptions (phone call, transient loss, duck) and