Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ed33c7dbb |
@@ -268,40 +268,25 @@ jobs:
|
||||
echo "✅ APK: builds.freetimelab.es → pluriwave → v${VERSION} → ${APK_NOMBRE}"
|
||||
echo "✅ AAB: builds.freetimelab.es → pluriwave → v${VERSION} → ${AAB_NOMBRE}"
|
||||
|
||||
# La publicacion automatica en Google Play es OPCIONAL.
|
||||
#
|
||||
# Este paso hacia `exit 1` cuando faltaba el secreto, asi que TODA
|
||||
# compilacion de PRO terminaba en rojo por una funcion que nunca llego a
|
||||
# activarse: el secreto no se configuro nunca y las subidas a Play se han
|
||||
# hecho siempre a mano. Un rojo permanente entrena a ignorar los rojos, y
|
||||
# entonces el dia que falle algo de verdad tampoco se mira.
|
||||
#
|
||||
# Ahora se omite con un aviso. El AAB ya esta compilado, firmado y subido
|
||||
# a ftl-builds por el paso anterior, asi que no se pierde nada. El dia que
|
||||
# se configure el secreto, los tres pasos se activan solos.
|
||||
- name: Preparar credenciales de Google Play
|
||||
id: credenciales_play
|
||||
if: ${{ gitea.ref == 'refs/heads/PRO' }}
|
||||
env:
|
||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
run: |
|
||||
if [ -z "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then
|
||||
echo "disponible=no" >> "$GITHUB_OUTPUT"
|
||||
echo "AVISO: falta el secreto GOOGLE_PLAY_SERVICE_ACCOUNT_JSON."
|
||||
echo "Se omite la publicacion en Google Play; sube el AAB a mano."
|
||||
exit 0
|
||||
echo "ERROR: falta el secreto GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p fastlane/credentials
|
||||
printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" > fastlane/credentials/google-play-service-account.json
|
||||
echo "disponible=si" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Instalar Fastlane
|
||||
if: ${{ gitea.ref == 'refs/heads/PRO' && steps.credenciales_play.outputs.disponible == 'si' }}
|
||||
if: ${{ gitea.ref == 'refs/heads/PRO' }}
|
||||
run: |
|
||||
gem list -i fastlane >/dev/null 2>&1 || gem install fastlane --no-document
|
||||
|
||||
- name: Publicar AAB en Google Play Internal Testing
|
||||
if: ${{ gitea.ref == 'refs/heads/PRO' && steps.credenciales_play.outputs.disponible == 'si' }}
|
||||
if: ${{ gitea.ref == 'refs/heads/PRO' }}
|
||||
env:
|
||||
PLAY_JSON_KEY_PATH: fastlane/credentials/google-play-service-account.json
|
||||
PLAY_AAB_PATH: build/app/outputs/bundle/release/app-release.aab
|
||||
@@ -319,13 +304,8 @@ jobs:
|
||||
if [ -z "$BOT_TOKEN" ]; then exit 0; fi
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
MSG="✅ *PluriWave* v${VERSION} · rama ${BRANCH} · ${COMMIT}%0AAPK + AAB generados"
|
||||
# Solo se anuncia la subida a Play cuando de verdad ocurrio: el paso
|
||||
# se omite si falta el secreto, y un aviso que dice "publicado"
|
||||
# cuando no se publico es peor que no avisar.
|
||||
if [ "$BRANCH" = "PRO" ] && [ "${{ steps.credenciales_play.outputs.disponible }}" = "si" ]; then
|
||||
if [ "$BRANCH" = "PRO" ]; then
|
||||
MSG="${MSG}%0APublicado en Google Play · Internal Testing"
|
||||
elif [ "$BRANCH" = "PRO" ]; then
|
||||
MSG="${MSG}%0AEn builds.freetimelab.es · sube el AAB a Play a mano"
|
||||
else
|
||||
MSG="${MSG}%0APublicado en builds.freetimelab.es"
|
||||
fi
|
||||
|
||||
@@ -663,29 +663,12 @@ class EstadoEcualizador extends ChangeNotifier {
|
||||
/// Each step then re-checks [_activo]: a newer tap that landed mid-flight
|
||||
/// owns the outcome, and this superseded call must not apply a preset or
|
||||
/// persist a value the user has already changed their mind about.
|
||||
///
|
||||
/// The handler can also REFUSE the change: when the native `setEnabled`
|
||||
/// throws, `PluriWaveAudioHandler._aplicarEcualizadorActivo` rolls its own
|
||||
/// flag back and skips its persistence write, so the value we optimistically
|
||||
/// published never happened. Reading [ServicioAudio.ecualizadorActivo] back
|
||||
/// (the handler is the single owner of the flag — eq-estado-unico) is how we
|
||||
/// learn that: on divergence we adopt the handler's real value and return
|
||||
/// WITHOUT persisting, instead of showing a lie and writing a rejected value
|
||||
/// to disk that would resurrect it on the next start. The supersede check
|
||||
/// runs FIRST so a newer tap still owns the outcome; the read-back only
|
||||
/// speaks for a call nobody overtook.
|
||||
Future<void> cambiarActivo(bool activo) async {
|
||||
_activo = activo;
|
||||
notifyListeners();
|
||||
|
||||
await audio.setEcualizadorActivo(activo);
|
||||
if (_activo != activo) return;
|
||||
final aceptado = audio.ecualizadorActivo;
|
||||
if (aceptado != activo) {
|
||||
_activo = aceptado;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
if (activo) {
|
||||
await audio.aplicarPreset(_presetActual);
|
||||
if (_activo != activo) return;
|
||||
|
||||
@@ -338,6 +338,24 @@ class EstadoRadio extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort remembers [emisora] as the last used station (issue 4) so
|
||||
/// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and-
|
||||
/// forget, same treatment [reproducir] already gives other non-critical
|
||||
/// side effects (e.g. `radio.registrarClick`) — a failed write here must
|
||||
/// never block or fail actual playback.
|
||||
Future<void> _persistirUltimaEmisora(Emisora emisora) async {
|
||||
try {
|
||||
final prefs = await _resolverPrefs();
|
||||
await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap()));
|
||||
} catch (e) {
|
||||
registrarSaltoPersistencia(
|
||||
subsistema: 'ultima_emisora',
|
||||
detalle: 'persistir ${emisora.uuid}',
|
||||
razon: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Escucha el stream de estado del audio y gestiona errores de reproducción.
|
||||
void _escucharErroresReproduccion() {
|
||||
_suscripcionEstadoAudio = audio.estadoStream.listen((estado) {
|
||||
@@ -357,12 +375,9 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final actual = audio.emisoraActual;
|
||||
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
|
||||
_emisoraSeleccionada = actual;
|
||||
// Issue 4's write used to live here as well. It is gone: the handler
|
||||
// persists every station itself from `_cambiarFuente`, which is the
|
||||
// same source change that moved `audio.emisoraActual` and is the
|
||||
// reason this branch runs at all. Writing again here would make the
|
||||
// key's final value depend on how two independent fire-and-forget
|
||||
// chains interleave on a fast station switch.
|
||||
// Issue 4: an Android-Auto-initiated selection is a real station
|
||||
// change too — remember it the same way `reproducir` does.
|
||||
unawaited(_persistirUltimaEmisora(actual));
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
@@ -573,13 +588,10 @@ class EstadoRadio extends ChangeNotifier {
|
||||
}
|
||||
_emisoraSeleccionada = emisora;
|
||||
notifyListeners();
|
||||
// Issue 4's `ultima_emisora_v1` write used to be here. It now happens
|
||||
// once, inside the handler's `_cambiarFuente`, which `audio.reproducir`
|
||||
// below reaches for this very station — see
|
||||
// [GuardarUltimaEmisoraPersistida]. Persisting here as well would have
|
||||
// left the key with TWO fire-and-forget writers whose relative order
|
||||
// decides the value after a fast A -> B switch, and this one cannot see
|
||||
// the revision guard that already cancels a superseded change.
|
||||
// Issue 4: remembers the station the user just picked so it survives a
|
||||
// restart — fire-and-forget, same treatment as `radio.registrarClick`
|
||||
// below (a persistence failure here must never block playback).
|
||||
unawaited(_persistirUltimaEmisora(emisora));
|
||||
try {
|
||||
await audio.reproducir(emisora);
|
||||
if (revision != _revisionReproduccion) return;
|
||||
@@ -865,12 +877,7 @@ class EstadoRadio extends ChangeNotifier {
|
||||
final favRaw = data['favoritos'] as List? ?? [];
|
||||
for (final raw in favRaw) {
|
||||
final emisora = Emisora.fromMap(Map<String, dynamic>.from(raw as Map));
|
||||
// `restaurarFavorito`, NO `agregar`: `agregar` es la primitiva de
|
||||
// «marcar como favorita» y fuerza `sin_asignar` + un `orden` al final,
|
||||
// que es justo lo que la copia trae y hay que conservar. Con `agregar`
|
||||
// los grupos restaurados arriba volvían como cascarones vacíos y todas
|
||||
// las emisoras aterrizaban en «Sin asignar».
|
||||
await favoritos.restaurarFavorito(emisora);
|
||||
await favoritos.agregar(emisora);
|
||||
}
|
||||
|
||||
// ── Emisoras custom ───────────────────────────────────────────────────
|
||||
|
||||
+1
-14
@@ -912,18 +912,5 @@
|
||||
"premiumBeneficioVacaciones": "فترات إجازة للمنبهات",
|
||||
"premiumBeneficioAlarmasIlimitadas": "منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)",
|
||||
"premiumPagoUnico": "دفعة واحدة، للأبد. ليس اشتراكًا.",
|
||||
"premiumAhoraNo": "ليس الآن",
|
||||
"autoErrorEmisoraPremium": "هذه المحطة ضمن Premium. افتح PluriWave على هاتفك لفتحها.",
|
||||
"autoErrorBusquedaSinResultados": "لم نعثر على تلك المحطة. جرّب اسمًا آخر.",
|
||||
"autoCarpetaEscuchar": "الاستماع",
|
||||
"autoCarpetaFavoritos": "المفضلة",
|
||||
"autoCarpetaTodas": "كل المحطات",
|
||||
"autoCarpetaMisEmisoras": "محطاتي",
|
||||
"autoCarpetaMusicaLocal": "الموسيقى المحلية",
|
||||
"autoMusicaLocalNoDisponible": "افتح PluriWave على هاتفك لقراءة موسيقاك",
|
||||
"autoCargarMas": "المزيد…",
|
||||
"autoOrdenarPorCalidad": "الترتيب حسب الجودة",
|
||||
"autoReproducirCarpeta": "تشغيل المجلد",
|
||||
"autoReproducirAleatorio": "تشغيل عشوائي",
|
||||
"autoPistaSinNombre": "مقطع بلا اسم"
|
||||
"premiumAhoraNo": "ليس الآن"
|
||||
}
|
||||
|
||||
+1
-14
@@ -912,18 +912,5 @@
|
||||
"premiumBeneficioVacaciones": "অ্যালার্মের জন্য ছুটির সময়কাল",
|
||||
"premiumBeneficioAlarmasIlimitadas": "সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)",
|
||||
"premiumPagoUnico": "একবারের পেমেন্ট, চিরকালের জন্য। এটি সাবস্ক্রিপশন নয়।",
|
||||
"premiumAhoraNo": "এখন নয়",
|
||||
"autoErrorEmisoraPremium": "এই স্টেশনটি Premium। আনলক করতে ফোনে PluriWave খুলুন।",
|
||||
"autoErrorBusquedaSinResultados": "সেই স্টেশনটি খুঁজে পাওয়া যায়নি। অন্য নাম চেষ্টা করুন।",
|
||||
"autoCarpetaEscuchar": "শুনুন",
|
||||
"autoCarpetaFavoritos": "প্রিয়",
|
||||
"autoCarpetaTodas": "সব স্টেশন",
|
||||
"autoCarpetaMisEmisoras": "আমার স্টেশন",
|
||||
"autoCarpetaMusicaLocal": "স্থানীয় সঙ্গীত",
|
||||
"autoMusicaLocalNoDisponible": "আপনার গান পড়তে ফোনে PluriWave খুলুন",
|
||||
"autoCargarMas": "আরও…",
|
||||
"autoOrdenarPorCalidad": "মান অনুসারে সাজান",
|
||||
"autoReproducirCarpeta": "ফোল্ডার চালান",
|
||||
"autoReproducirAleatorio": "এলোমেলোভাবে চালান",
|
||||
"autoPistaSinNombre": "নামহীন ট্র্যাক"
|
||||
"premiumAhoraNo": "এখন নয়"
|
||||
}
|
||||
|
||||
+1
-14
@@ -912,18 +912,5 @@
|
||||
"premiumBeneficioVacaciones": "Urlaubszeiträume für Wecker",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Unbegrenzte Wecker (die kostenlose Version erlaubt bis zu 5)",
|
||||
"premiumPagoUnico": "Einmalzahlung, für immer. Kein Abonnement.",
|
||||
"premiumAhoraNo": "Nicht jetzt",
|
||||
"autoErrorEmisoraPremium": "Dieser Sender ist Premium. Öffne PluriWave auf dem Handy, um ihn freizuschalten.",
|
||||
"autoErrorBusquedaSinResultados": "Wir haben diesen Sender nicht gefunden. Versuch es mit einem anderen Namen.",
|
||||
"autoCarpetaEscuchar": "Hören",
|
||||
"autoCarpetaFavoritos": "Favoriten",
|
||||
"autoCarpetaTodas": "Alle Sender",
|
||||
"autoCarpetaMisEmisoras": "Meine Sender",
|
||||
"autoCarpetaMusicaLocal": "Lokale Musik",
|
||||
"autoMusicaLocalNoDisponible": "Öffne PluriWave auf dem Handy, um deine Musik zu lesen",
|
||||
"autoCargarMas": "Mehr…",
|
||||
"autoOrdenarPorCalidad": "Nach Qualität sortieren",
|
||||
"autoReproducirCarpeta": "Ordner abspielen",
|
||||
"autoReproducirAleatorio": "Zufallswiedergabe",
|
||||
"autoPistaSinNombre": "Unbenannter Titel"
|
||||
"premiumAhoraNo": "Nicht jetzt"
|
||||
}
|
||||
|
||||
+1
-14
@@ -912,18 +912,5 @@
|
||||
"premiumBeneficioVacaciones": "Vacation ranges for alarms",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Unlimited alarms (the free plan allows up to 5)",
|
||||
"premiumPagoUnico": "One-time purchase, forever. Not a subscription.",
|
||||
"premiumAhoraNo": "Not now",
|
||||
"autoErrorEmisoraPremium": "This station is Premium. Open PluriWave on your phone to unlock it.",
|
||||
"autoErrorBusquedaSinResultados": "We couldn't find that station. Try another name.",
|
||||
"autoCarpetaEscuchar": "Listen",
|
||||
"autoCarpetaFavoritos": "Favorites",
|
||||
"autoCarpetaTodas": "All stations",
|
||||
"autoCarpetaMisEmisoras": "My stations",
|
||||
"autoCarpetaMusicaLocal": "Local music",
|
||||
"autoMusicaLocalNoDisponible": "Open PluriWave on your phone to read your music",
|
||||
"autoCargarMas": "More…",
|
||||
"autoOrdenarPorCalidad": "Sort by quality",
|
||||
"autoReproducirCarpeta": "Play folder",
|
||||
"autoReproducirAleatorio": "Shuffle play",
|
||||
"autoPistaSinNombre": "Untitled track"
|
||||
"premiumAhoraNo": "Not now"
|
||||
}
|
||||
|
||||
+1
-14
@@ -871,18 +871,5 @@
|
||||
"premiumBeneficioVacaciones": "Rangos de vacaciones para las alarmas",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Alarmas ilimitadas (el plan gratuito permite hasta 5)",
|
||||
"premiumPagoUnico": "Pago único, para siempre. No es una suscripción.",
|
||||
"premiumAhoraNo": "Ahora no",
|
||||
"autoErrorEmisoraPremium": "Esta emisora es Premium. Abre PluriWave en el móvil para desbloquearla.",
|
||||
"autoErrorBusquedaSinResultados": "No hemos encontrado esa emisora. Prueba con otro nombre.",
|
||||
"autoCarpetaEscuchar": "Escuchar",
|
||||
"autoCarpetaFavoritos": "Favoritos",
|
||||
"autoCarpetaTodas": "Todas las emisoras",
|
||||
"autoCarpetaMisEmisoras": "Mis emisoras",
|
||||
"autoCarpetaMusicaLocal": "Música Local",
|
||||
"autoMusicaLocalNoDisponible": "Abre PluriWave en el móvil para leer tu música",
|
||||
"autoCargarMas": "Más…",
|
||||
"autoOrdenarPorCalidad": "Ordenar por calidad",
|
||||
"autoReproducirCarpeta": "Reproducir carpeta",
|
||||
"autoReproducirAleatorio": "Reproducir aleatorio",
|
||||
"autoPistaSinNombre": "Pista sin nombre"
|
||||
"premiumAhoraNo": "Ahora no"
|
||||
}
|
||||
|
||||
+1
-14
@@ -912,18 +912,5 @@
|
||||
"premiumBeneficioVacaciones": "Périodes de vacances pour les alarmes",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Alarmes illimitées (la version gratuite en autorise jusqu'à 5)",
|
||||
"premiumPagoUnico": "Achat unique, pour toujours. Ce n'est pas un abonnement.",
|
||||
"premiumAhoraNo": "Plus tard",
|
||||
"autoErrorEmisoraPremium": "Cette station est Premium. Ouvre PluriWave sur ton téléphone pour la débloquer.",
|
||||
"autoErrorBusquedaSinResultados": "Nous n'avons pas trouvé cette station. Essaie un autre nom.",
|
||||
"autoCarpetaEscuchar": "Écouter",
|
||||
"autoCarpetaFavoritos": "Favoris",
|
||||
"autoCarpetaTodas": "Toutes les stations",
|
||||
"autoCarpetaMisEmisoras": "Mes stations",
|
||||
"autoCarpetaMusicaLocal": "Musique locale",
|
||||
"autoMusicaLocalNoDisponible": "Ouvrez PluriWave sur votre téléphone pour lire votre musique",
|
||||
"autoCargarMas": "Plus…",
|
||||
"autoOrdenarPorCalidad": "Trier par qualité",
|
||||
"autoReproducirCarpeta": "Lire le dossier",
|
||||
"autoReproducirAleatorio": "Lecture aléatoire",
|
||||
"autoPistaSinNombre": "Piste sans nom"
|
||||
"premiumAhoraNo": "Plus tard"
|
||||
}
|
||||
|
||||
+1
-14
@@ -912,18 +912,5 @@
|
||||
"premiumBeneficioVacaciones": "अलार्म के लिए छुट्टी की अवधि",
|
||||
"premiumBeneficioAlarmasIlimitadas": "असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)",
|
||||
"premiumPagoUnico": "एकमुश्त भुगतान, हमेशा के लिए। यह सदस्यता नहीं है।",
|
||||
"premiumAhoraNo": "अभी नहीं",
|
||||
"autoErrorEmisoraPremium": "यह स्टेशन Premium है। इसे अनलॉक करने के लिए फ़ोन पर PluriWave खोलें।",
|
||||
"autoErrorBusquedaSinResultados": "वह स्टेशन नहीं मिला। कोई दूसरा नाम आज़माएँ।",
|
||||
"autoCarpetaEscuchar": "सुनें",
|
||||
"autoCarpetaFavoritos": "पसंदीदा",
|
||||
"autoCarpetaTodas": "सभी स्टेशन",
|
||||
"autoCarpetaMisEmisoras": "मेरे स्टेशन",
|
||||
"autoCarpetaMusicaLocal": "लोकल संगीत",
|
||||
"autoMusicaLocalNoDisponible": "अपना संगीत पढ़ने के लिए फ़ोन पर PluriWave खोलें",
|
||||
"autoCargarMas": "और…",
|
||||
"autoOrdenarPorCalidad": "गुणवत्ता के अनुसार क्रमबद्ध करें",
|
||||
"autoReproducirCarpeta": "फ़ोल्डर चलाएँ",
|
||||
"autoReproducirAleatorio": "शफ़ल चलाएँ",
|
||||
"autoPistaSinNombre": "बिना नाम का ट्रैक"
|
||||
"premiumAhoraNo": "अभी नहीं"
|
||||
}
|
||||
|
||||
+1
-14
@@ -912,18 +912,5 @@
|
||||
"premiumBeneficioVacaciones": "Rentang liburan untuk alarm",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Alarm tanpa batas (paket gratis mengizinkan hingga 5)",
|
||||
"premiumPagoUnico": "Pembelian sekali bayar, untuk selamanya. Bukan langganan.",
|
||||
"premiumAhoraNo": "Nanti saja",
|
||||
"autoErrorEmisoraPremium": "Stasiun ini Premium. Buka PluriWave di ponsel untuk membukanya.",
|
||||
"autoErrorBusquedaSinResultados": "Kami tidak menemukan stasiun itu. Coba nama lain.",
|
||||
"autoCarpetaEscuchar": "Dengarkan",
|
||||
"autoCarpetaFavoritos": "Favorit",
|
||||
"autoCarpetaTodas": "Semua stasiun",
|
||||
"autoCarpetaMisEmisoras": "Stasiun saya",
|
||||
"autoCarpetaMusicaLocal": "Musik lokal",
|
||||
"autoMusicaLocalNoDisponible": "Buka PluriWave di ponsel untuk membaca musik Anda",
|
||||
"autoCargarMas": "Lainnya…",
|
||||
"autoOrdenarPorCalidad": "Urutkan menurut kualitas",
|
||||
"autoReproducirCarpeta": "Putar folder",
|
||||
"autoReproducirAleatorio": "Putar acak",
|
||||
"autoPistaSinNombre": "Trek tanpa nama"
|
||||
"premiumAhoraNo": "Nanti saja"
|
||||
}
|
||||
|
||||
+1
-14
@@ -912,18 +912,5 @@
|
||||
"premiumBeneficioVacaciones": "Intervalli di vacanza per le sveglie",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Sveglie illimitate (il piano gratuito ne consente fino a 5)",
|
||||
"premiumPagoUnico": "Acquisto unico, per sempre. Non è un abbonamento.",
|
||||
"premiumAhoraNo": "Non ora",
|
||||
"autoErrorEmisoraPremium": "Questa stazione è Premium. Apri PluriWave sul telefono per sbloccarla.",
|
||||
"autoErrorBusquedaSinResultados": "Non abbiamo trovato quella stazione. Prova con un altro nome.",
|
||||
"autoCarpetaEscuchar": "Ascolta",
|
||||
"autoCarpetaFavoritos": "Preferiti",
|
||||
"autoCarpetaTodas": "Tutte le emittenti",
|
||||
"autoCarpetaMisEmisoras": "Le mie emittenti",
|
||||
"autoCarpetaMusicaLocal": "Musica locale",
|
||||
"autoMusicaLocalNoDisponible": "Apri PluriWave sul telefono per leggere la tua musica",
|
||||
"autoCargarMas": "Altro…",
|
||||
"autoOrdenarPorCalidad": "Ordina per qualità",
|
||||
"autoReproducirCarpeta": "Riproduci cartella",
|
||||
"autoReproducirAleatorio": "Riproduzione casuale",
|
||||
"autoPistaSinNombre": "Traccia senza nome"
|
||||
"premiumAhoraNo": "Non ora"
|
||||
}
|
||||
|
||||
+1
-14
@@ -912,18 +912,5 @@
|
||||
"premiumBeneficioVacaciones": "アラームの休暇期間設定",
|
||||
"premiumBeneficioAlarmasIlimitadas": "アラーム数無制限(無料プランは5個まで)",
|
||||
"premiumPagoUnico": "買い切りの一度きりの支払いで永久に使用可能。サブスクリプションではありません。",
|
||||
"premiumAhoraNo": "後で",
|
||||
"autoErrorEmisoraPremium": "この放送局は Premium です。スマートフォンで PluriWave を開いてロックを解除してください。",
|
||||
"autoErrorBusquedaSinResultados": "その放送局は見つかりませんでした。別の名前をお試しください。",
|
||||
"autoCarpetaEscuchar": "聴く",
|
||||
"autoCarpetaFavoritos": "お気に入り",
|
||||
"autoCarpetaTodas": "すべての局",
|
||||
"autoCarpetaMisEmisoras": "マイ局",
|
||||
"autoCarpetaMusicaLocal": "ローカルの音楽",
|
||||
"autoMusicaLocalNoDisponible": "音楽を読み込むにはスマートフォンで PluriWave を開いてください",
|
||||
"autoCargarMas": "もっと見る…",
|
||||
"autoOrdenarPorCalidad": "音質順に並べ替え",
|
||||
"autoReproducirCarpeta": "フォルダを再生",
|
||||
"autoReproducirAleatorio": "シャッフル再生",
|
||||
"autoPistaSinNombre": "名称未設定のトラック"
|
||||
"premiumAhoraNo": "後で"
|
||||
}
|
||||
|
||||
+1
-14
@@ -912,18 +912,5 @@
|
||||
"premiumBeneficioVacaciones": "Períodos de férias para os alarmes",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Alarmes ilimitados (o plano gratuito permite até 5)",
|
||||
"premiumPagoUnico": "Pagamento único, para sempre. Não é uma assinatura.",
|
||||
"premiumAhoraNo": "Agora não",
|
||||
"autoErrorEmisoraPremium": "Esta estação é Premium. Abra o PluriWave no telemóvel para a desbloquear.",
|
||||
"autoErrorBusquedaSinResultados": "Não encontrámos essa estação. Tente outro nome.",
|
||||
"autoCarpetaEscuchar": "Ouvir",
|
||||
"autoCarpetaFavoritos": "Favoritos",
|
||||
"autoCarpetaTodas": "Todas as estações",
|
||||
"autoCarpetaMisEmisoras": "As minhas estações",
|
||||
"autoCarpetaMusicaLocal": "Música local",
|
||||
"autoMusicaLocalNoDisponible": "Abra o PluriWave no telemóvel para ler a sua música",
|
||||
"autoCargarMas": "Mais…",
|
||||
"autoOrdenarPorCalidad": "Ordenar por qualidade",
|
||||
"autoReproducirCarpeta": "Reproduzir pasta",
|
||||
"autoReproducirAleatorio": "Reprodução aleatória",
|
||||
"autoPistaSinNombre": "Faixa sem nome"
|
||||
"premiumAhoraNo": "Agora não"
|
||||
}
|
||||
|
||||
+1
-14
@@ -912,18 +912,5 @@
|
||||
"premiumBeneficioVacaciones": "Периоды отпуска для будильников",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Неограниченное количество будильников (бесплатный план позволяет до 5)",
|
||||
"premiumPagoUnico": "Единоразовая покупка, навсегда. Это не подписка.",
|
||||
"premiumAhoraNo": "Не сейчас",
|
||||
"autoErrorEmisoraPremium": "Эта станция доступна в Premium. Откройте PluriWave на телефоне, чтобы разблокировать её.",
|
||||
"autoErrorBusquedaSinResultados": "Мы не нашли такую станцию. Попробуйте другое название.",
|
||||
"autoCarpetaEscuchar": "Слушать",
|
||||
"autoCarpetaFavoritos": "Избранное",
|
||||
"autoCarpetaTodas": "Все станции",
|
||||
"autoCarpetaMisEmisoras": "Мои станции",
|
||||
"autoCarpetaMusicaLocal": "Локальная музыка",
|
||||
"autoMusicaLocalNoDisponible": "Откройте PluriWave на телефоне, чтобы прочитать вашу музыку",
|
||||
"autoCargarMas": "Ещё…",
|
||||
"autoOrdenarPorCalidad": "Сортировать по качеству",
|
||||
"autoReproducirCarpeta": "Воспроизвести папку",
|
||||
"autoReproducirAleatorio": "Случайное воспроизведение",
|
||||
"autoPistaSinNombre": "Трек без названия"
|
||||
"premiumAhoraNo": "Не сейчас"
|
||||
}
|
||||
|
||||
+1
-14
@@ -912,18 +912,5 @@
|
||||
"premiumBeneficioVacaciones": "闹钟的假期时间段",
|
||||
"premiumBeneficioAlarmasIlimitadas": "无限闹钟(免费版最多支持5个)",
|
||||
"premiumPagoUnico": "一次性付费,永久使用,不是订阅。",
|
||||
"premiumAhoraNo": "以后再说",
|
||||
"autoErrorEmisoraPremium": "该电台属于 Premium 内容。请在手机上打开 PluriWave 解锁。",
|
||||
"autoErrorBusquedaSinResultados": "没有找到该电台。请换个名称再试。",
|
||||
"autoCarpetaEscuchar": "收听",
|
||||
"autoCarpetaFavoritos": "收藏",
|
||||
"autoCarpetaTodas": "全部电台",
|
||||
"autoCarpetaMisEmisoras": "我的电台",
|
||||
"autoCarpetaMusicaLocal": "本地音乐",
|
||||
"autoMusicaLocalNoDisponible": "请在手机上打开 PluriWave 以读取您的音乐",
|
||||
"autoCargarMas": "更多…",
|
||||
"autoOrdenarPorCalidad": "按音质排序",
|
||||
"autoReproducirCarpeta": "播放文件夹",
|
||||
"autoReproducirAleatorio": "随机播放",
|
||||
"autoPistaSinNombre": "未命名曲目"
|
||||
"premiumAhoraNo": "以后再说"
|
||||
}
|
||||
|
||||
@@ -3415,84 +3415,6 @@ abstract class AppLocalizations {
|
||||
/// In es, this message translates to:
|
||||
/// **'Ahora no'**
|
||||
String get premiumAhoraNo;
|
||||
|
||||
/// No description provided for @autoErrorEmisoraPremium.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Esta emisora es Premium. Abre PluriWave en el móvil para desbloquearla.'**
|
||||
String get autoErrorEmisoraPremium;
|
||||
|
||||
/// No description provided for @autoErrorBusquedaSinResultados.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'No hemos encontrado esa emisora. Prueba con otro nombre.'**
|
||||
String get autoErrorBusquedaSinResultados;
|
||||
|
||||
/// No description provided for @autoCarpetaEscuchar.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Escuchar'**
|
||||
String get autoCarpetaEscuchar;
|
||||
|
||||
/// No description provided for @autoCarpetaFavoritos.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Favoritos'**
|
||||
String get autoCarpetaFavoritos;
|
||||
|
||||
/// No description provided for @autoCarpetaTodas.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Todas las emisoras'**
|
||||
String get autoCarpetaTodas;
|
||||
|
||||
/// No description provided for @autoCarpetaMisEmisoras.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Mis emisoras'**
|
||||
String get autoCarpetaMisEmisoras;
|
||||
|
||||
/// No description provided for @autoCarpetaMusicaLocal.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Música Local'**
|
||||
String get autoCarpetaMusicaLocal;
|
||||
|
||||
/// No description provided for @autoMusicaLocalNoDisponible.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Abre PluriWave en el móvil para leer tu música'**
|
||||
String get autoMusicaLocalNoDisponible;
|
||||
|
||||
/// No description provided for @autoCargarMas.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Más…'**
|
||||
String get autoCargarMas;
|
||||
|
||||
/// No description provided for @autoOrdenarPorCalidad.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Ordenar por calidad'**
|
||||
String get autoOrdenarPorCalidad;
|
||||
|
||||
/// No description provided for @autoReproducirCarpeta.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Reproducir carpeta'**
|
||||
String get autoReproducirCarpeta;
|
||||
|
||||
/// No description provided for @autoReproducirAleatorio.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Reproducir aleatorio'**
|
||||
String get autoReproducirAleatorio;
|
||||
|
||||
/// No description provided for @autoPistaSinNombre.
|
||||
///
|
||||
/// In es, this message translates to:
|
||||
/// **'Pista sin nombre'**
|
||||
String get autoPistaSinNombre;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -1888,46 +1888,4 @@ class AppLocalizationsAr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'ليس الآن';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'هذه المحطة ضمن Premium. افتح PluriWave على هاتفك لفتحها.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'لم نعثر على تلك المحطة. جرّب اسمًا آخر.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'الاستماع';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'المفضلة';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'كل المحطات';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'محطاتي';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'الموسيقى المحلية';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'افتح PluriWave على هاتفك لقراءة موسيقاك';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'المزيد…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'الترتيب حسب الجودة';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'تشغيل المجلد';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'تشغيل عشوائي';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'مقطع بلا اسم';
|
||||
}
|
||||
|
||||
@@ -1900,46 +1900,4 @@ class AppLocalizationsBn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'এখন নয়';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'এই স্টেশনটি Premium। আনলক করতে ফোনে PluriWave খুলুন।';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'সেই স্টেশনটি খুঁজে পাওয়া যায়নি। অন্য নাম চেষ্টা করুন।';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'শুনুন';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'প্রিয়';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'সব স্টেশন';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'আমার স্টেশন';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'স্থানীয় সঙ্গীত';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'আপনার গান পড়তে ফোনে PluriWave খুলুন';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'আরও…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'মান অনুসারে সাজান';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'ফোল্ডার চালান';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'এলোমেলোভাবে চালান';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'নামহীন ট্র্যাক';
|
||||
}
|
||||
|
||||
@@ -1913,46 +1913,4 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'Nicht jetzt';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'Dieser Sender ist Premium. Öffne PluriWave auf dem Handy, um ihn freizuschalten.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'Wir haben diesen Sender nicht gefunden. Versuch es mit einem anderen Namen.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'Hören';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'Favoriten';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'Alle Sender';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'Meine Sender';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'Lokale Musik';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'Öffne PluriWave auf dem Handy, um deine Musik zu lesen';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'Mehr…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'Nach Qualität sortieren';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'Ordner abspielen';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'Zufallswiedergabe';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'Unbenannter Titel';
|
||||
}
|
||||
|
||||
@@ -1893,46 +1893,4 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'Not now';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'This station is Premium. Open PluriWave on your phone to unlock it.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'We couldn\'t find that station. Try another name.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'Listen';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'Favorites';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'All stations';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'My stations';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'Local music';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'Open PluriWave on your phone to read your music';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'More…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'Sort by quality';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'Play folder';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'Shuffle play';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'Untitled track';
|
||||
}
|
||||
|
||||
@@ -1908,46 +1908,4 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'Ahora no';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'Esta emisora es Premium. Abre PluriWave en el móvil para desbloquearla.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'No hemos encontrado esa emisora. Prueba con otro nombre.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'Escuchar';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'Favoritos';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'Todas las emisoras';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'Mis emisoras';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'Música Local';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'Abre PluriWave en el móvil para leer tu música';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'Más…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'Ordenar por calidad';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'Reproducir carpeta';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'Reproducir aleatorio';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'Pista sin nombre';
|
||||
}
|
||||
|
||||
@@ -1922,46 +1922,4 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'Plus tard';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'Cette station est Premium. Ouvre PluriWave sur ton téléphone pour la débloquer.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'Nous n\'avons pas trouvé cette station. Essaie un autre nom.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'Écouter';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'Favoris';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'Toutes les stations';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'Mes stations';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'Musique locale';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'Ouvrez PluriWave sur votre téléphone pour lire votre musique';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'Plus…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'Trier par qualité';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'Lire le dossier';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'Lecture aléatoire';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'Piste sans nom';
|
||||
}
|
||||
|
||||
@@ -1893,46 +1893,4 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'अभी नहीं';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'यह स्टेशन Premium है। इसे अनलॉक करने के लिए फ़ोन पर PluriWave खोलें।';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'वह स्टेशन नहीं मिला। कोई दूसरा नाम आज़माएँ।';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'सुनें';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'पसंदीदा';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'सभी स्टेशन';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'मेरे स्टेशन';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'लोकल संगीत';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'अपना संगीत पढ़ने के लिए फ़ोन पर PluriWave खोलें';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'और…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'गुणवत्ता के अनुसार क्रमबद्ध करें';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'फ़ोल्डर चलाएँ';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'शफ़ल चलाएँ';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'बिना नाम का ट्रैक';
|
||||
}
|
||||
|
||||
@@ -1904,46 +1904,4 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'Nanti saja';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'Stasiun ini Premium. Buka PluriWave di ponsel untuk membukanya.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'Kami tidak menemukan stasiun itu. Coba nama lain.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'Dengarkan';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'Favorit';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'Semua stasiun';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'Stasiun saya';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'Musik lokal';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'Buka PluriWave di ponsel untuk membaca musik Anda';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'Lainnya…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'Urutkan menurut kualitas';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'Putar folder';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'Putar acak';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'Trek tanpa nama';
|
||||
}
|
||||
|
||||
@@ -1919,46 +1919,4 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'Non ora';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'Questa stazione è Premium. Apri PluriWave sul telefono per sbloccarla.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'Non abbiamo trovato quella stazione. Prova con un altro nome.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'Ascolta';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'Preferiti';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'Tutte le emittenti';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'Le mie emittenti';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'Musica locale';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'Apri PluriWave sul telefono per leggere la tua musica';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'Altro…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'Ordina per qualità';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'Riproduci cartella';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'Riproduzione casuale';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'Traccia senza nome';
|
||||
}
|
||||
|
||||
@@ -1836,45 +1836,4 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => '後で';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'この放送局は Premium です。スマートフォンで PluriWave を開いてロックを解除してください。';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados => 'その放送局は見つかりませんでした。別の名前をお試しください。';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => '聴く';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'お気に入り';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'すべての局';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'マイ局';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'ローカルの音楽';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'音楽を読み込むにはスマートフォンで PluriWave を開いてください';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'もっと見る…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => '音質順に並べ替え';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'フォルダを再生';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'シャッフル再生';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => '名称未設定のトラック';
|
||||
}
|
||||
|
||||
@@ -1904,46 +1904,4 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'Agora não';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'Esta estação é Premium. Abra o PluriWave no telemóvel para a desbloquear.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'Não encontrámos essa estação. Tente outro nome.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'Ouvir';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'Favoritos';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'Todas as estações';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'As minhas estações';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'Música local';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'Abra o PluriWave no telemóvel para ler a sua música';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'Mais…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'Ordenar por qualidade';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'Reproduzir pasta';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'Reprodução aleatória';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'Faixa sem nome';
|
||||
}
|
||||
|
||||
@@ -1911,46 +1911,4 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => 'Не сейчас';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'Эта станция доступна в Premium. Откройте PluriWave на телефоне, чтобы разблокировать её.';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados =>
|
||||
'Мы не нашли такую станцию. Попробуйте другое название.';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => 'Слушать';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => 'Избранное';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => 'Все станции';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => 'Мои станции';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => 'Локальная музыка';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible =>
|
||||
'Откройте PluriWave на телефоне, чтобы прочитать вашу музыку';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => 'Ещё…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => 'Сортировать по качеству';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => 'Воспроизвести папку';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => 'Случайное воспроизведение';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => 'Трек без названия';
|
||||
}
|
||||
|
||||
@@ -1821,44 +1821,4 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get premiumAhoraNo => '以后再说';
|
||||
|
||||
@override
|
||||
String get autoErrorEmisoraPremium =>
|
||||
'该电台属于 Premium 内容。请在手机上打开 PluriWave 解锁。';
|
||||
|
||||
@override
|
||||
String get autoErrorBusquedaSinResultados => '没有找到该电台。请换个名称再试。';
|
||||
|
||||
@override
|
||||
String get autoCarpetaEscuchar => '收听';
|
||||
|
||||
@override
|
||||
String get autoCarpetaFavoritos => '收藏';
|
||||
|
||||
@override
|
||||
String get autoCarpetaTodas => '全部电台';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMisEmisoras => '我的电台';
|
||||
|
||||
@override
|
||||
String get autoCarpetaMusicaLocal => '本地音乐';
|
||||
|
||||
@override
|
||||
String get autoMusicaLocalNoDisponible => '请在手机上打开 PluriWave 以读取您的音乐';
|
||||
|
||||
@override
|
||||
String get autoCargarMas => '更多…';
|
||||
|
||||
@override
|
||||
String get autoOrdenarPorCalidad => '按音质排序';
|
||||
|
||||
@override
|
||||
String get autoReproducirCarpeta => '播放文件夹';
|
||||
|
||||
@override
|
||||
String get autoReproducirAleatorio => '随机播放';
|
||||
|
||||
@override
|
||||
String get autoPistaSinNombre => '未命名曲目';
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'app.dart';
|
||||
import 'estado/estado_entitlement.dart';
|
||||
import 'servicios/arranque_audio.dart';
|
||||
import 'servicios/contexto_reproduccion.dart';
|
||||
import 'servicios/emisoras_destacadas.dart';
|
||||
import 'servicios/musica_local_auto.dart';
|
||||
import 'servicios/navegacion_auto.dart';
|
||||
import 'servicios/servicio_audio.dart';
|
||||
@@ -198,28 +196,6 @@ Future<void> main() async {
|
||||
handler,
|
||||
leerEqActivoPersistido: ecualizador.leerActivo,
|
||||
guardarEqActivoPersistido: ecualizador.guardarActivo,
|
||||
// The PRESET's half of the same seam. Without it the handler enabled
|
||||
// the equalizer with its hardcoded FLAT preset on any engine where the
|
||||
// phone UI never ran — i.e. every headless Android Auto bind. There is
|
||||
// no write port: `EstadoEcualizador` still owns saving presets (a car
|
||||
// preset choice goes through it), so the handler only ever reads.
|
||||
leerPresetPersistido: ecualizador.leerPresetPrincipal,
|
||||
// Skip context («in which list am I»). Bound here, on the audio
|
||||
// bootstrap path of EVERY engine, precisely because the headless
|
||||
// Android Auto engine builds no widget tree and therefore no
|
||||
// `EstadoRadio`: a context only the phone UI could write would be a
|
||||
// context the car could never have.
|
||||
leerContextoSalto: contextoSaltoPersistido,
|
||||
guardarContextoSalto: guardarContextoSalto,
|
||||
// Last played station (`ultima_emisora_v1`). Bound here for the SAME
|
||||
// reason as the skip context: `EstadoRadio` — which used to be its only
|
||||
// writer — belongs to the widget tree, and the Android Auto engine
|
||||
// builds none, so a session that happened only in the car never updated
|
||||
// the key and the head unit was offered whatever the PHONE last played.
|
||||
// The write port is now the key's single writer; the read port feeds the
|
||||
// cold-start metadata seed and the bare-`play()` resume.
|
||||
leerUltimaEmisora: ultimaEmisoraPersistida,
|
||||
guardarUltimaEmisora: guardarUltimaEmisoraPersistida,
|
||||
);
|
||||
// The handler is the only thing this app ever tears down
|
||||
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
|
||||
/// The skip context's persistence key.
|
||||
///
|
||||
/// Same headless-safe shape as `emisoras_destacadas.dart`: this file imports
|
||||
/// nothing but `shared_preferences` and the models, never `EstadoRadio` nor
|
||||
/// anything that drags a `ChangeNotifier` graph in. Android Auto starts the
|
||||
/// engine WITHOUT an Activity, so there is no widget tree and `EstadoRadio` is
|
||||
/// never constructed there — a context only that class could write would be a
|
||||
/// context the car can never have.
|
||||
///
|
||||
/// `contexto_reproduccion_test.dart` pins the literal so a rename fails loudly
|
||||
/// instead of silently leaving every driver context-less after an update.
|
||||
const claveContextoSalto = 'contexto_salto_v1';
|
||||
|
||||
/// Which LIST the driver is walking with the car's previous/next buttons.
|
||||
///
|
||||
/// The type is the durable part; the members are not. A group's contents
|
||||
/// change between sessions (the phone renames it, empties it, deletes it), so
|
||||
/// remembering the members would be remembering something that expires —
|
||||
/// [resolverListaContexto] re-resolves against the LIVE lists every time.
|
||||
enum TipoContextoSalto {
|
||||
/// One favourites group. The only type that carries [ContextoSalto.grupoFavoritosId].
|
||||
grupoFavoritos,
|
||||
favoritos,
|
||||
misEmisoras,
|
||||
|
||||
/// The `populares` catalogue snapshot.
|
||||
todas,
|
||||
|
||||
/// The free tier's curated set (`emisorasDestacadas`). The ONLY type that
|
||||
/// carries [ContextoSalto.uuidsOrdenados] — see that field.
|
||||
destacadas,
|
||||
}
|
||||
|
||||
/// The remembered playback context: the smallest thing that still identifies
|
||||
/// the list on the other side of a process restart.
|
||||
class ContextoSalto {
|
||||
/// One favourites group, named by its stable id.
|
||||
const ContextoSalto.grupo(String grupoId)
|
||||
: tipo = TipoContextoSalto.grupoFavoritos,
|
||||
grupoFavoritosId = grupoId,
|
||||
uuidsOrdenados = const [];
|
||||
|
||||
const ContextoSalto.favoritos()
|
||||
: tipo = TipoContextoSalto.favoritos,
|
||||
grupoFavoritosId = null,
|
||||
uuidsOrdenados = const [];
|
||||
|
||||
const ContextoSalto.misEmisoras()
|
||||
: tipo = TipoContextoSalto.misEmisoras,
|
||||
grupoFavoritosId = null,
|
||||
uuidsOrdenados = const [];
|
||||
|
||||
const ContextoSalto.todas()
|
||||
: tipo = TipoContextoSalto.todas,
|
||||
grupoFavoritosId = null,
|
||||
uuidsOrdenados = const [];
|
||||
|
||||
/// The free tier's set, FROZEN in [uuids] order.
|
||||
const ContextoSalto.destacadas(List<String> uuids)
|
||||
: tipo = TipoContextoSalto.destacadas,
|
||||
grupoFavoritosId = null,
|
||||
uuidsOrdenados = uuids;
|
||||
|
||||
final TipoContextoSalto tipo;
|
||||
|
||||
/// Set only for [TipoContextoSalto.grupoFavoritos].
|
||||
final String? grupoFavoritosId;
|
||||
|
||||
/// The frozen order, set only for [TipoContextoSalto.destacadas].
|
||||
///
|
||||
/// Every other type resolves against a list that HAS a stable, user-owned
|
||||
/// order (the favourites' `orden` column, the custom-stations file, the
|
||||
/// catalogue snapshot), so freezing it would only mean ignoring a reorder
|
||||
/// the user just made on the phone. The free set is the exception:
|
||||
/// `resolverEmisorasDestacadas` rebuilds it as `[última reproducida,
|
||||
/// ...curadas]`, so it REORDERS ITSELF as the driver skips, and `previous`
|
||||
/// stops being the inverse of `next`. Freezing that order is the fix.
|
||||
final List<String> uuidsOrdenados;
|
||||
|
||||
Map<String, dynamic> aMapa() => {
|
||||
'tipo': tipo.name,
|
||||
if (grupoFavoritosId != null) 'grupoId': grupoFavoritosId,
|
||||
if (uuidsOrdenados.isNotEmpty) 'uuids': uuidsOrdenados,
|
||||
};
|
||||
|
||||
/// Parses a persisted map, or `null` when it is unusable.
|
||||
///
|
||||
/// Tolerant on purpose: this payload survives app updates, backups and
|
||||
/// hand-edited preference files, and it is read from a steering-wheel
|
||||
/// button. An unreadable context must mean "derive it again", never a
|
||||
/// crash.
|
||||
static ContextoSalto? desdeMapa(Map<String, dynamic> mapa) {
|
||||
final tipoRaw = mapa['tipo'];
|
||||
if (tipoRaw is! String) return null;
|
||||
final tipo = TipoContextoSalto.values
|
||||
.where((t) => t.name == tipoRaw)
|
||||
.firstOrNull;
|
||||
if (tipo == null) return null;
|
||||
switch (tipo) {
|
||||
case TipoContextoSalto.grupoFavoritos:
|
||||
final grupoId = mapa['grupoId'];
|
||||
// A group context with no group is not a context.
|
||||
if (grupoId is! String || grupoId.isEmpty) return null;
|
||||
return ContextoSalto.grupo(grupoId);
|
||||
case TipoContextoSalto.favoritos:
|
||||
return const ContextoSalto.favoritos();
|
||||
case TipoContextoSalto.misEmisoras:
|
||||
return const ContextoSalto.misEmisoras();
|
||||
case TipoContextoSalto.todas:
|
||||
return const ContextoSalto.todas();
|
||||
case TipoContextoSalto.destacadas:
|
||||
final uuids = mapa['uuids'];
|
||||
if (uuids is! List) return null;
|
||||
return ContextoSalto.destacadas(uuids.whereType<String>().toList());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is ContextoSalto &&
|
||||
other.tipo == tipo &&
|
||||
other.grupoFavoritosId == grupoFavoritosId &&
|
||||
_mismosUuids(other.uuidsOrdenados, uuidsOrdenados);
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
tipo,
|
||||
grupoFavoritosId,
|
||||
Object.hashAll(uuidsOrdenados),
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ContextoSalto(${tipo.name}, grupo=$grupoFavoritosId, '
|
||||
'uuids=${uuidsOrdenados.length})';
|
||||
|
||||
static bool _mismosUuids(List<String> a, List<String> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists [contexto]. Never throws — a failed write costs the driver a
|
||||
/// re-derivation, an exception would cost them the station change.
|
||||
Future<void> guardarContextoSalto(
|
||||
ContextoSalto contexto, {
|
||||
SharedPreferences? prefs,
|
||||
}) async {
|
||||
try {
|
||||
final resueltas = prefs ?? await SharedPreferences.getInstance();
|
||||
await resueltas.setString(claveContextoSalto, jsonEncode(contexto.aMapa()));
|
||||
} catch (_) {
|
||||
// Deliberately swallowed — see the doc above.
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the persisted context, or `null` when there is none, the payload is
|
||||
/// unreadable, or prefs themselves fail.
|
||||
///
|
||||
/// Follows the same inject-or-`getInstance()` convention as
|
||||
/// `esPremiumPersistido` and `resolverEmisorasDestacadas`, so a test pins
|
||||
/// prefs without a platform channel.
|
||||
Future<ContextoSalto?> contextoSaltoPersistido({
|
||||
SharedPreferences? prefs,
|
||||
}) async {
|
||||
try {
|
||||
final resueltas = prefs ?? await SharedPreferences.getInstance();
|
||||
final raw = resueltas.getString(claveContextoSalto);
|
||||
if (raw == null) return null;
|
||||
final decodificado = jsonDecode(raw);
|
||||
if (decodificado is! Map) return null;
|
||||
return ContextoSalto.desdeMapa(Map<String, dynamic>.from(decodificado));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// The LIVE, ordered list a remembered [contexto] resolves to right now, or an
|
||||
/// empty list when it no longer resolves at all.
|
||||
///
|
||||
/// Pure — no handler, no prefs — so every degradation rule below is testable
|
||||
/// on its own. An empty result means "this memory has expired": the caller
|
||||
/// derives a fresh context instead (and, failing that, leaves playback alone —
|
||||
/// never jumps somewhere arbitrary mid-drive).
|
||||
///
|
||||
/// Degradation rules, all of them deliberate. The group chain is the owner's,
|
||||
/// decided from real use in the car:
|
||||
/// * remembered group ALIVE -> it is walked, even when the playing station
|
||||
/// has LEFT it (the caller then takes the group's first station) and even
|
||||
/// when it is down to a single member (skipping there simply leaves the
|
||||
/// driver where they are — a one-station group is still a group).
|
||||
/// * remembered group DELETED, or alive but EMPTY -> widen to all
|
||||
/// favourites, whether or not the playing station is still one of them:
|
||||
/// "if the whole group is gone, pick a station from the favourites".
|
||||
/// * no favourites left -> empty, i.e. the no-stations behaviour.
|
||||
/// * every OTHER context type still expires when the playing station left
|
||||
/// its list (unfavourited, removed from the catalogue snapshot) — the
|
||||
/// owner's decision was about the group chain only.
|
||||
/// * [TipoContextoSalto.destacadas] alone honours
|
||||
/// [ContextoSalto.uuidsOrdenados] — see that field for why.
|
||||
List<Emisora> resolverListaContexto({
|
||||
required ContextoSalto contexto,
|
||||
required Emisora actual,
|
||||
required List<Emisora> favoritos,
|
||||
required List<Emisora> misEmisoras,
|
||||
required List<Emisora> todas,
|
||||
required List<Emisora> destacadas,
|
||||
required List<GrupoFavoritos> grupos,
|
||||
}) {
|
||||
bool contiene(List<Emisora> lista) =>
|
||||
lista.any((e) => e.uuid == actual.uuid);
|
||||
|
||||
switch (contexto.tipo) {
|
||||
case TipoContextoSalto.grupoFavoritos:
|
||||
final grupoId = contexto.grupoFavoritosId;
|
||||
if (grupoId == null || grupoId == GrupoFavoritos.sinAsignarId) {
|
||||
return const [];
|
||||
}
|
||||
final existe = grupos.any((g) => g.id == grupoId);
|
||||
final miembros =
|
||||
favoritos.where((e) => e.grupoFavoritosId == grupoId).toList();
|
||||
if (existe && miembros.isNotEmpty) {
|
||||
// A surviving group is honoured as-is. The station does NOT have to
|
||||
// still be in it — the caller takes the group's first station rather
|
||||
// than wandering off to another list.
|
||||
return miembros;
|
||||
}
|
||||
// Group deleted (or alive but empty, which offers no station to take):
|
||||
// widen to all favourites. Unlike the other context types this does not
|
||||
// require the station to still BE a favourite — the caller takes the
|
||||
// first one.
|
||||
return favoritos;
|
||||
case TipoContextoSalto.favoritos:
|
||||
return contiene(favoritos) ? favoritos : const [];
|
||||
case TipoContextoSalto.misEmisoras:
|
||||
return contiene(misEmisoras) ? misEmisoras : const [];
|
||||
case TipoContextoSalto.todas:
|
||||
return contiene(todas) ? todas : const [];
|
||||
case TipoContextoSalto.destacadas:
|
||||
// The frozen order is authoritative. `actual` is resolvable from itself
|
||||
// so a station frozen into the walk from a previous session still
|
||||
// resolves even when it never belonged to the curated set.
|
||||
final porUuid = <String, Emisora>{
|
||||
for (final e in destacadas) e.uuid: e,
|
||||
actual.uuid: actual,
|
||||
};
|
||||
final lista = <Emisora>[
|
||||
for (final uuid in contexto.uuidsOrdenados)
|
||||
if (porUuid[uuid] != null) porUuid[uuid]!,
|
||||
];
|
||||
return lista.any((e) => e.uuid == actual.uuid) ? lista : const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// The free tier's frozen walk order: the curated set in its compiled-in
|
||||
/// order, with [actual] prepended when it does not belong to it.
|
||||
///
|
||||
/// Prepending rather than dropping keeps both buttons alive for a station left
|
||||
/// over from a premium session (or from `ultima_emisora_v1`): a walk the
|
||||
/// playing station is not part of would make `emisoraVecina` return `null` and
|
||||
/// both buttons would be dead.
|
||||
List<String> uuidsCongeladosDestacadas({
|
||||
required Emisora actual,
|
||||
required List<Emisora> destacadas,
|
||||
}) => [
|
||||
if (!destacadas.any((e) => e.uuid == actual.uuid)) actual.uuid,
|
||||
...destacadas.map((e) => e.uuid),
|
||||
];
|
||||
@@ -1,196 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../modelos/emisora.dart';
|
||||
|
||||
/// The last-played station's persistence key.
|
||||
///
|
||||
/// MUST stay byte-identical to `EstadoRadio._keyUltimaEmisora`
|
||||
/// (`lib/estado/estado_radio.dart`), which is the only writer. It is
|
||||
/// duplicated here rather than exported from there on purpose: this file has
|
||||
/// to be readable from the headless Android Auto engine, where `EstadoRadio`
|
||||
/// is never constructed, and importing a `ChangeNotifier` that pulls in the
|
||||
/// whole app-state graph just to read one string constant would drag the
|
||||
/// entire phone-side stack into a car bind. `emisoras_destacadas_test.dart`
|
||||
/// pins the literal so a rename on either side fails loudly.
|
||||
const claveUltimaEmisora = 'ultima_emisora_v1';
|
||||
|
||||
/// The stations a FREE-tier driver can browse and play in the car
|
||||
/// (fix/auto-quality-guidelines, item 6).
|
||||
///
|
||||
/// Compiled into the binary, on purpose. Everything else the car could show
|
||||
/// is empty on the bind a Play reviewer actually performs: a fresh install
|
||||
/// is free tier (`esPremiumPersistido` is `getBool(...) ?? false`, no trial
|
||||
/// key), `FuenteEmisorasAutoLocal.todas()` is literally
|
||||
/// `_snapshotTodas ?? const []` until `EstadoRadio` pushes a network
|
||||
/// snapshot that a headless bind never fetches, favourites and custom
|
||||
/// stations are empty, and `ultima_emisora_v1` is absent. A curated const
|
||||
/// list is the ONLY thing that can put real, playable rows in front of that
|
||||
/// reviewer.
|
||||
///
|
||||
/// Deliberately small. This is not a catalogue — the catalogue is the
|
||||
/// premium feature. Six rows is enough to prove the app works and short
|
||||
/// enough to read at a glance from a driving position.
|
||||
///
|
||||
/// `favicon` is null for every entry on purpose: `artUriPara` then resolves
|
||||
/// the on-brand bundled `station_art_*` drawable, so a browse row needs no
|
||||
/// network at all to render its artwork.
|
||||
///
|
||||
/// `uuid`s are app-owned (`pw-destacada-*`), not Radio Browser uuids: these
|
||||
/// rows must resolve identically whether or not the catalogue is reachable,
|
||||
/// and a Radio Browser uuid we cannot re-fetch would be a promise this file
|
||||
/// cannot keep.
|
||||
const List<Emisora> emisorasDestacadas = [
|
||||
Emisora(
|
||||
uuid: 'pw-destacada-fip',
|
||||
nombre: 'FIP',
|
||||
url: 'https://icecast.radiofrance.fr/fip-midfi.mp3',
|
||||
pais: 'France',
|
||||
codigoPais: 'FR',
|
||||
idioma: 'french',
|
||||
),
|
||||
Emisora(
|
||||
uuid: 'pw-destacada-france-inter',
|
||||
nombre: 'France Inter',
|
||||
url: 'https://icecast.radiofrance.fr/franceinter-midfi.mp3',
|
||||
pais: 'France',
|
||||
codigoPais: 'FR',
|
||||
idioma: 'french',
|
||||
),
|
||||
Emisora(
|
||||
uuid: 'pw-destacada-deutschlandfunk',
|
||||
nombre: 'Deutschlandfunk',
|
||||
url: 'https://st01.sslstream.dlf.de/dlf/01/128/mp3/stream.mp3',
|
||||
pais: 'Germany',
|
||||
codigoPais: 'DE',
|
||||
idioma: 'german',
|
||||
),
|
||||
Emisora(
|
||||
uuid: 'pw-destacada-kexp',
|
||||
nombre: 'KEXP 90.3 FM',
|
||||
url: 'https://kexp-mp3-128.streamguys1.com/kexp128.mp3',
|
||||
pais: 'United States',
|
||||
codigoPais: 'US',
|
||||
idioma: 'english',
|
||||
),
|
||||
Emisora(
|
||||
uuid: 'pw-destacada-radio-paradise',
|
||||
nombre: 'Radio Paradise',
|
||||
url: 'https://stream.radioparadise.com/mp3-128',
|
||||
pais: 'United States',
|
||||
codigoPais: 'US',
|
||||
idioma: 'english',
|
||||
),
|
||||
Emisora(
|
||||
uuid: 'pw-destacada-soma-groove-salad',
|
||||
nombre: 'SomaFM Groove Salad',
|
||||
url: 'https://ice1.somafm.com/groovesalad-128-mp3',
|
||||
pais: 'United States',
|
||||
codigoPais: 'US',
|
||||
idioma: 'english',
|
||||
),
|
||||
];
|
||||
|
||||
/// The free tier's complete, ordered station set: the last station the user
|
||||
/// actually played (when one is persisted) first, then [emisorasDestacadas],
|
||||
/// deduplicated by `uuid`.
|
||||
///
|
||||
/// Last-played goes first because it is the single row a returning driver is
|
||||
/// most likely to want, and because it is the only entry that can make the
|
||||
/// free folder feel like *their* app rather than a demo. It is NOT appended
|
||||
/// a second time when it already belongs to the curated set.
|
||||
///
|
||||
/// Follows `esPremiumPersistido({SharedPreferences? prefs})`'s
|
||||
/// inject-or-`getInstance()` convention (`estado_entitlement.dart`), so a
|
||||
/// test can pin prefs without a platform channel.
|
||||
///
|
||||
/// Never throws: a corrupt/foreign `ultima_emisora_v1` payload, or a
|
||||
/// `SharedPreferences` failure, degrades to the curated set alone. This runs
|
||||
/// inside `getChildren`, and a browse call that throws is a dead folder.
|
||||
Future<List<Emisora>> resolverEmisorasDestacadas({
|
||||
SharedPreferences? prefs,
|
||||
}) async {
|
||||
final ultima = await _ultimaEmisora(prefs: prefs);
|
||||
if (ultima == null) return emisorasDestacadas;
|
||||
return [
|
||||
ultima,
|
||||
...emisorasDestacadas.where((e) => e.uuid != ultima.uuid),
|
||||
];
|
||||
}
|
||||
|
||||
/// Whether [uuid] belongs to [destacadas] — the predicate every play-path
|
||||
/// gate reads to tell "free content" from "the premium catalogue".
|
||||
///
|
||||
/// Pure, and takes the free universe rather than resolving it, so a caller
|
||||
/// that already holds the list (every one of them does — it also needs it to
|
||||
/// build the response) asks the question without a second prefs round trip.
|
||||
///
|
||||
/// A `null` or empty [uuid] is never free: `emisora:` with no tail is a
|
||||
/// malformed id, and matching it against an entry with an empty uuid would be
|
||||
/// a resolution hole rather than a feature.
|
||||
bool esEmisoraGratuita(String? uuid, List<Emisora> destacadas) =>
|
||||
uuid != null && uuid.isNotEmpty && destacadas.any((e) => e.uuid == uuid);
|
||||
|
||||
/// [esEmisoraGratuita] against the CURRENT free set, resolved here. For
|
||||
/// callers that do not already hold the list.
|
||||
Future<bool> esEmisoraGratuitaPorUuid(
|
||||
String uuid, {
|
||||
SharedPreferences? prefs,
|
||||
}) async =>
|
||||
esEmisoraGratuita(uuid, await resolverEmisorasDestacadas(prefs: prefs));
|
||||
|
||||
/// Reads the persisted last-played station, or `null` when there is none.
|
||||
///
|
||||
/// Public because the Android Auto "recent" browse root
|
||||
/// (`AudioService.recentRootId`) needs exactly this one station and nothing
|
||||
/// else: `onGetRoot` (`AudioService.java:817-821`) answers `recent` whenever
|
||||
/// the head unit sends `EXTRA_RECENT`, which Android Auto does on every
|
||||
/// reconnect, and the platform expects a SINGLE resume item there — not a
|
||||
/// station list, and not an empty folder.
|
||||
///
|
||||
/// Tier-independent on purpose: this station is by definition one the user
|
||||
/// has already played on this device, so offering to resume it is never
|
||||
/// leaking premium content they have not already had.
|
||||
Future<Emisora?> ultimaEmisoraPersistida({SharedPreferences? prefs}) =>
|
||||
_ultimaEmisora(prefs: prefs);
|
||||
|
||||
/// Writes [emisora] as the last-played station — the SINGLE writer of
|
||||
/// [claveUltimaEmisora].
|
||||
///
|
||||
/// It lives beside [ultimaEmisoraPersistida] rather than in `EstadoRadio`
|
||||
/// because the key has to be written from the engine Android Auto starts,
|
||||
/// which builds no widget tree and therefore never constructs `EstadoRadio`
|
||||
/// at all: a session that happened only in the car used to leave the key
|
||||
/// holding whatever the PHONE last played, so the head unit's resume row and
|
||||
/// the free tier's featured folder were both stale on the next connect.
|
||||
///
|
||||
/// Deliberately NOT swallowing failures here: the handler port that calls it
|
||||
/// traces and swallows (a persistence failure must never break playback),
|
||||
/// and a silent `catch` in BOTH places would make a dead write channel
|
||||
/// invisible from a car logcat.
|
||||
Future<void> guardarUltimaEmisoraPersistida(
|
||||
Emisora emisora, {
|
||||
SharedPreferences? prefs,
|
||||
}) async {
|
||||
final resueltas = prefs ?? await SharedPreferences.getInstance();
|
||||
await resueltas.setString(claveUltimaEmisora, jsonEncode(emisora.toMap()));
|
||||
}
|
||||
|
||||
/// Reads the persisted last-played station, or `null` when there is none,
|
||||
/// the payload is unreadable, or prefs themselves fail.
|
||||
Future<Emisora?> _ultimaEmisora({SharedPreferences? prefs}) async {
|
||||
try {
|
||||
final resueltas = prefs ?? await SharedPreferences.getInstance();
|
||||
final raw = resueltas.getString(claveUltimaEmisora);
|
||||
if (raw == null) return null;
|
||||
final emisora = Emisora.fromMap(jsonDecode(raw) as Map<String, dynamic>);
|
||||
// A record with no uuid or no url cannot be turned into a playable
|
||||
// `emisora:<uuid>` row, so it is worse than absent: it would occupy the
|
||||
// first slot with a row that does nothing when tapped.
|
||||
if (emisora.uuid.isEmpty || emisora.url.isEmpty) return null;
|
||||
return emisora;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+104
-372
@@ -10,8 +10,6 @@ import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
import '../modelos/pista_local.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import 'contexto_reproduccion.dart';
|
||||
import 'emisoras_destacadas.dart';
|
||||
import 'musica_local_auto.dart';
|
||||
import 'persistencia_tolerante.dart';
|
||||
import 'servicio_favoritos.dart';
|
||||
@@ -202,97 +200,10 @@ abstract class FuenteEmisorasAuto {
|
||||
}) {}
|
||||
}
|
||||
|
||||
/// Every user-readable label of the Android Auto browse tree, already
|
||||
/// resolved to one locale by the caller.
|
||||
///
|
||||
/// THE RULE (fix/auto-quality-guidelines, l10n item): anything a user can
|
||||
/// read gets translated. This bundle replaces the previous
|
||||
/// "car-tree labels are hardcoded Spanish, deliberately NOT an arb key"
|
||||
/// convention, which was defensible only while those labels sat deep inside
|
||||
/// a premium tree and stopped being defensible the moment Google Play
|
||||
/// reviewed the car surface on an English head unit.
|
||||
///
|
||||
/// It exists as a plain value object rather than an `AppLocalizations`
|
||||
/// dependency so [ConstructorArbolAuto] stays a PURE builder — the same
|
||||
/// reason `itemsEcualizadorAuto` lives in `servicio_audio.dart`. The handler,
|
||||
/// which can resolve localizations headlessly through
|
||||
/// `resolverLocalizacionesRespaldo`, builds one via
|
||||
/// `etiquetasArbolAutoDesde` and hands it in.
|
||||
///
|
||||
/// NOT in here on purpose: the alphabetical bucket labels (`'A-F'`, `'G-M'`,
|
||||
/// …). Those are ranges of Latin letters, not prose — translating them would
|
||||
/// make them lie about which filenames they contain.
|
||||
class EtiquetasArbolAuto {
|
||||
const EtiquetasArbolAuto({
|
||||
required this.escuchar,
|
||||
required this.favoritos,
|
||||
required this.todasLasEmisoras,
|
||||
required this.misEmisoras,
|
||||
required this.musicaLocal,
|
||||
required this.musicaLocalNoDisponible,
|
||||
required this.cargarMas,
|
||||
required this.ordenarPorCalidad,
|
||||
required this.reproducirCarpeta,
|
||||
required this.reproducirAleatorio,
|
||||
required this.pistaSinNombre,
|
||||
});
|
||||
|
||||
/// Fallback bundle for callers that have no localizations to hand: pure
|
||||
/// builder tests, and any future non-car consumer.
|
||||
///
|
||||
/// It is NOT what the car shows. `ServicioAudio` always injects a bundle
|
||||
/// resolved from `AppLocalizations`, in every browse and playback path
|
||||
/// that can produce a label — `etiquetas_arbol_auto_test.dart` is the
|
||||
/// guard that no NEW hardcoded label can be introduced alongside these.
|
||||
static const respaldo = EtiquetasArbolAuto(
|
||||
escuchar: 'Escuchar',
|
||||
favoritos: 'Favoritos',
|
||||
todasLasEmisoras: 'Todas las emisoras',
|
||||
misEmisoras: 'Mis emisoras',
|
||||
musicaLocal: 'Música Local',
|
||||
musicaLocalNoDisponible: 'Abre PluriWave en el móvil para leer tu música',
|
||||
cargarMas: 'Más…',
|
||||
ordenarPorCalidad: 'Ordenar por calidad',
|
||||
reproducirCarpeta: 'Reproducir carpeta',
|
||||
reproducirAleatorio: 'Reproducir aleatorio',
|
||||
pistaSinNombre: 'Pista sin nombre',
|
||||
);
|
||||
|
||||
/// The free tier's single root folder ([ConstructorArbolAuto.idDestacadas]).
|
||||
final String escuchar;
|
||||
|
||||
/// Premium root folders.
|
||||
final String favoritos;
|
||||
final String todasLasEmisoras;
|
||||
final String misEmisoras;
|
||||
final String musicaLocal;
|
||||
|
||||
/// The non-playable row shown when the local-music folder cannot be read
|
||||
/// from the car ([ConstructorArbolAuto.idLocalNoLista]).
|
||||
final String musicaLocalNoDisponible;
|
||||
|
||||
/// Trailing "load more" row of every paged local-music view.
|
||||
final String cargarMas;
|
||||
|
||||
/// Local-folder navigation and action rows.
|
||||
final String ordenarPorCalidad;
|
||||
final String reproducirCarpeta;
|
||||
final String reproducirAleatorio;
|
||||
|
||||
/// Fallback title for a local file whose name is blank after stripping.
|
||||
final String pistaSinNombre;
|
||||
}
|
||||
|
||||
/// Pure builder for the Android Auto browse tree: folders, leaf items, id
|
||||
/// resolution. No platform dependency — fully testable without a running
|
||||
/// car or a real `AudioHandler`.
|
||||
class ConstructorArbolAuto {
|
||||
const ConstructorArbolAuto({this.etiquetas = EtiquetasArbolAuto.respaldo});
|
||||
|
||||
/// The already-localized labels this builder stamps onto every
|
||||
/// user-readable `MediaItem` it produces.
|
||||
final EtiquetasArbolAuto etiquetas;
|
||||
|
||||
/// Root folder ids (Design "media-id scheme"). The tree root itself is
|
||||
/// identified by [AudioService.browsableRootId], not by a constant here —
|
||||
/// the handler compares against it directly before calling [raiz].
|
||||
@@ -300,17 +211,6 @@ class ConstructorArbolAuto {
|
||||
static const idTodas = 'todas';
|
||||
static const idMisEmisoras = 'mis_emisoras';
|
||||
|
||||
/// Root folder id for the FREE tier's only browsable folder
|
||||
/// (fix/auto-quality-guidelines, item 8).
|
||||
///
|
||||
/// Deliberately NOT added to [_idsCarpetas] — like [idMusicaLocal] and
|
||||
/// [idEcualizador] it has its own dedicated children ([hijosDestacadas]),
|
||||
/// fed by `emisoras_destacadas.dart`'s compiled-in set rather than by the
|
||||
/// generic station-list [hijos] path over a `FuenteEmisorasAuto` that is
|
||||
/// empty on the bind a Play reviewer actually performs.
|
||||
static const idDestacadas = 'destacadas';
|
||||
|
||||
|
||||
/// Root folder id for the local-music browsable root (Design "media-id
|
||||
/// scheme"). Deliberately NOT added to [_idsCarpetas] — it has its own
|
||||
/// dedicated branch (`hijosMusicaLocal`), not the generic station-list
|
||||
@@ -422,10 +322,8 @@ class ConstructorArbolAuto {
|
||||
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
|
||||
};
|
||||
|
||||
/// The root folders, all non-playable, and TIER-DEPENDENT: Favoritos,
|
||||
/// Todas las emisoras, Mis emisoras and optionally Música Local for a
|
||||
/// premium driver; the single [idDestacadas] folder for a free one (see
|
||||
/// [premium] below).
|
||||
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras,
|
||||
/// optionally Música Local, Ecualizador), all non-playable.
|
||||
///
|
||||
/// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
|
||||
/// There is NO `Ecualizador` folder. The car's only equalizer control is
|
||||
@@ -445,77 +343,53 @@ class ConstructorArbolAuto {
|
||||
/// `Música Local` is OMITTED entirely (not just empty) unless
|
||||
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
|
||||
/// is configured") — the caller lo deriva de
|
||||
/// `premium && fuente.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada`
|
||||
/// `fuente.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada`
|
||||
/// (fix/android-auto-musica-local: un canal nativo ausente ya NO oculta el
|
||||
/// nodo; fix/auto-quality-guidelines item 9: el `premium &&` va delante a
|
||||
/// propósito, para que el tier gratuito ni siquiera pague ese round trip
|
||||
/// nativo), keeping this builder itself synchronous and side-effect free.
|
||||
/// nodo), keeping this builder itself synchronous and side-effect free.
|
||||
///
|
||||
/// [premium] (fix/auto-quality-guidelines, item 8) is finally READ. It used
|
||||
/// to be accepted and ignored, on the theory that "the root keeps the same
|
||||
/// visible folder labels for free users" was friendlier than a reduced
|
||||
/// menu. It was not: every one of those four folders dead-ended on a single
|
||||
/// non-playable "Función Premium" row, and Google Play cited exactly that
|
||||
/// against the Android for Cars App Quality Guidelines.
|
||||
///
|
||||
/// The free root is therefore ONE browsable folder, [idDestacadas], and the
|
||||
/// premium-only folders are OMITTED rather than shown-and-blocked: a folder
|
||||
/// a driver cannot use is worse than a folder that is not there.
|
||||
///
|
||||
/// It must stay at least one BROWSABLE item, never a bare playable one:
|
||||
/// `audio_service` 0.18.18 discards `rootHints`
|
||||
/// (`AudioService.java:817-826`), so this code cannot detect whether the
|
||||
/// head unit accepts a `FLAG_PLAYABLE` root child, and the documented
|
||||
/// default of `BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_SUPPORTED_FLAGS` is
|
||||
/// `FLAG_BROWSABLE` alone — a root of one playable item renders EMPTY on
|
||||
/// such a unit.
|
||||
///
|
||||
/// Every label here comes from [etiquetas], already resolved to the head
|
||||
/// unit's locale — the free root's [EtiquetasArbolAuto.escuchar] AND the
|
||||
/// four premium folders.
|
||||
///
|
||||
/// The four premium ones used to be hardcoded Spanish, on the theory that
|
||||
/// they were leaf rows deep inside a tree only a user who had already
|
||||
/// chosen the app would reach. That was never a rule, only an untested
|
||||
/// assumption, and it is retired: anything a user can read gets
|
||||
/// translated. `escuchar` was localized first (it is 100% of what a free
|
||||
/// Play reviewer sees), which is exactly why the rest had to follow.
|
||||
///
|
||||
/// [tituloDestacadas] stays as an explicit per-call override of
|
||||
/// [EtiquetasArbolAuto.escuchar]; `null` (the default) uses the bundle.
|
||||
/// [premium] (iap-freemium-unlock, Design ADR-4): the ROOT keeps the exact
|
||||
/// same visible folder labels for every tier — "keeps the same visible
|
||||
/// folder labels for free users" is the explicit design choice, so a free
|
||||
/// driver still sees a real, familiar menu rather than a wall of "Función
|
||||
/// Premium" rows. The lock itself is enforced one level DOWN, at the
|
||||
/// `getChildren` choke point (see [itemPremiumBloqueado] and
|
||||
/// [respuestaBloqueadaPorEntitlement] below) — tapping any of these
|
||||
/// folders as a free user reveals the lock there, never here.
|
||||
List<MediaItem> raiz({
|
||||
required bool incluirMusicaLocal,
|
||||
required bool premium,
|
||||
String? tituloDestacadas,
|
||||
}) =>
|
||||
premium
|
||||
? [
|
||||
_carpeta(idFavoritos, etiquetas.favoritos),
|
||||
_carpeta(idTodas, etiquetas.todasLasEmisoras),
|
||||
_carpeta(idMisEmisoras, etiquetas.misEmisoras),
|
||||
if (incluirMusicaLocal)
|
||||
_carpeta(idMusicaLocal, etiquetas.musicaLocal),
|
||||
]
|
||||
: [_carpeta(idDestacadas, tituloDestacadas ?? etiquetas.escuchar)];
|
||||
}) => [
|
||||
_carpeta(idFavoritos, 'Favoritos'),
|
||||
_carpeta(idTodas, 'Todas las emisoras'),
|
||||
_carpeta(idMisEmisoras, 'Mis emisoras'),
|
||||
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
|
||||
];
|
||||
|
||||
/// The free tier's playable station rows (fix/auto-quality-guidelines,
|
||||
/// items 8/9): [emisoras] mapped through the SAME [itemEmisora] the premium
|
||||
/// folders use, capped like every other folder.
|
||||
///
|
||||
/// Separate from [hijos] because that path is gated on [_idsCarpetas] and
|
||||
/// fed by a `FuenteEmisorasAuto` whose lists are all empty on a cold
|
||||
/// headless bind — which is precisely the bind this folder has to survive.
|
||||
/// An empty [emisoras] returns `[]` rather than any placeholder row: a
|
||||
/// non-playable row in the car tree is the thing Play cited.
|
||||
List<MediaItem> hijosDestacadas(List<Emisora> emisoras) =>
|
||||
emisoras.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
|
||||
/// Free-tier id prefix reserved id (iap-freemium-unlock, Design ADR-4):
|
||||
/// the single non-playable item every non-root folder collapses to for a
|
||||
/// free-tier user. Hardcoded Spanish label, matching every other car-tree
|
||||
/// label in this file (never routed through `AppLocalizations` —
|
||||
/// established convention, see [_tituloMasLocal]'s doc).
|
||||
static const idPremiumInfo = 'premium:info';
|
||||
|
||||
/// El item de [idLocalNoLista]. Rotulado con
|
||||
/// [EtiquetasArbolAuto.musicaLocalNoDisponible], ya resuelto al idioma del
|
||||
/// head unit. No reproducible — seleccionarlo es un no-op.
|
||||
/// The single locked item shown for ANY non-root folder when the browsing
|
||||
/// user is free tier (Design ADR-4, android-auto-media spec "Free-Tier
|
||||
/// Reduced Root Browse"). Non-playable — selecting it is a no-op, never a
|
||||
/// crash (Spec "Free-tier user selects a locked item").
|
||||
MediaItem itemPremiumBloqueado() => MediaItem(
|
||||
id: idPremiumInfo,
|
||||
title: 'Función Premium',
|
||||
playable: false,
|
||||
extras: _contentStyleLista,
|
||||
);
|
||||
|
||||
/// El item de [idLocalNoLista]. Etiqueta en castellano hardcodeado, como
|
||||
/// TODAS las etiquetas del árbol del coche en este archivo (ver
|
||||
/// [itemPremiumBloqueado]): convención establecida, nunca
|
||||
/// `AppLocalizations`. No reproducible — seleccionarlo es un no-op.
|
||||
MediaItem itemLocalNoDisponible() => MediaItem(
|
||||
id: idLocalNoLista,
|
||||
title: etiquetas.musicaLocalNoDisponible,
|
||||
title: 'Abre PluriWave en el móvil para leer tu música',
|
||||
playable: false,
|
||||
extras: _contentStyleLista,
|
||||
);
|
||||
@@ -614,15 +488,20 @@ class ConstructorArbolAuto {
|
||||
return (documentId, pagina);
|
||||
}
|
||||
|
||||
/// Hardcoded-Spanish car-tree label for the trailing "load more" item
|
||||
/// (Design ADR-5) — matches every other car-tree label in this file
|
||||
/// (`'Favoritos'`, `'Música Local'`, [_tituloLocalFallback]), none of
|
||||
/// which go through `AppLocalizations`. Deliberately NOT an arb key.
|
||||
static const _tituloMasLocal = 'Más…';
|
||||
|
||||
/// The trailing "load more" `MediaItem` (Design ADR-5): non-playable, no
|
||||
/// `artUri` (the label alone is the affordance, like [_carpeta]), id
|
||||
/// `carpeta_local_pag:<siguientePagina>:<documentIdPadre>` — round-trips
|
||||
/// via [paginaCarpetaLocalDesde] back to the parent folder's next page.
|
||||
/// Rotulado con [EtiquetasArbolAuto.cargarMas].
|
||||
MediaItem _itemMasLocal(String documentIdPadre, int siguientePagina) =>
|
||||
MediaItem(
|
||||
id: '$_prefijoCarpetaLocalPaginada$siguientePagina:$documentIdPadre',
|
||||
title: etiquetas.cargarMas,
|
||||
title: _tituloMasLocal,
|
||||
playable: false,
|
||||
extras: _contentStyleLista,
|
||||
);
|
||||
@@ -747,41 +626,41 @@ class ConstructorArbolAuto {
|
||||
/// for small folders.
|
||||
bool ofreceBuckets(int totalPistas) => totalPistas > _minPistasParaBuckets;
|
||||
|
||||
/// The "sort by quality" mode-entry `MediaItem` (Design ADR-4):
|
||||
/// The "Ordenar por calidad" mode-entry `MediaItem` (Design ADR-4):
|
||||
/// non-playable, id `carpeta_local_ord:calidad:0:<documentIdPadre>` —
|
||||
/// always page 0 of the sorted view, round-trips via [ordenLocalDesde].
|
||||
/// Rotulado con [EtiquetasArbolAuto.ordenarPorCalidad].
|
||||
/// Hardcoded Spanish label, matching every other car-tree label in this
|
||||
/// file — never routed through `AppLocalizations` (established
|
||||
/// car-tree-label precedent, see [_tituloMasLocal]).
|
||||
MediaItem _itemModoOrdenCalidad(String documentIdPadre) => _carpeta(
|
||||
'${_prefijoCarpetaLocalOrd}calidad:0:$documentIdPadre',
|
||||
etiquetas.ordenarPorCalidad,
|
||||
'Ordenar por calidad',
|
||||
);
|
||||
|
||||
/// A single bucket-folder `MediaItem` (Design ADR-4): non-playable, id
|
||||
/// `carpeta_local_bucket:<idx>:0:<documentIdPadre>` — always page 0,
|
||||
/// round-trips via [bucketLocalDesde].
|
||||
///
|
||||
/// [etiqueta] is an alphabetical RANGE (e.g. `'A-F'`), and it is the one
|
||||
/// user-visible car-tree string that deliberately does NOT go through
|
||||
/// [EtiquetasArbolAuto]: it names the Latin letters the folder's filenames
|
||||
/// actually start with, so translating it would make it lie.
|
||||
/// round-trips via [bucketLocalDesde]. [etiqueta] is the hardcoded
|
||||
/// alphabetical-range label (e.g. `'A-F'`), matching every other
|
||||
/// car-tree label in this file — never routed through `AppLocalizations`.
|
||||
MediaItem _itemBucket(String documentIdPadre, int idx, String etiqueta) =>
|
||||
_carpeta('$_prefijoCarpetaLocalBucket$idx:0:$documentIdPadre', etiqueta);
|
||||
|
||||
/// The "play folder" playable action item (Design ADR-5): id
|
||||
/// `carpeta_local_reproducir:<documentIdPadre>`. Rotulado con
|
||||
/// [EtiquetasArbolAuto.reproducirCarpeta].
|
||||
/// The "Reproducir carpeta" playable action item (Design ADR-5): id
|
||||
/// `carpeta_local_reproducir:<documentIdPadre>`. Hardcoded Spanish label,
|
||||
/// matching every other car-tree label in this file — never routed
|
||||
/// through `AppLocalizations`.
|
||||
MediaItem _itemReproducirCarpeta(String documentIdPadre) => MediaItem(
|
||||
id: '$_prefijoCarpetaLocalReproducir$documentIdPadre',
|
||||
title: etiquetas.reproducirCarpeta,
|
||||
title: 'Reproducir carpeta',
|
||||
playable: true,
|
||||
extras: _contentStyleGrid,
|
||||
);
|
||||
|
||||
/// The "shuffle play" playable action item (Design ADR-5),
|
||||
/// The "Reproducir aleatorio" playable action item (Design ADR-5),
|
||||
/// mirrors [_itemReproducirCarpeta].
|
||||
MediaItem _itemReproducirAleatorio(String documentIdPadre) => MediaItem(
|
||||
id: '$_prefijoCarpetaLocalAleatorio$documentIdPadre',
|
||||
title: etiquetas.reproducirAleatorio,
|
||||
title: 'Reproducir aleatorio',
|
||||
playable: true,
|
||||
extras: _contentStyleGrid,
|
||||
);
|
||||
@@ -863,7 +742,7 @@ class ConstructorArbolAuto {
|
||||
int siguientePagina,
|
||||
) => MediaItem(
|
||||
id: '$_prefijoCarpetaLocalOrd$modo:$siguientePagina:$documentIdPadre',
|
||||
title: etiquetas.cargarMas,
|
||||
title: _tituloMasLocal,
|
||||
playable: false,
|
||||
extras: _contentStyleLista,
|
||||
);
|
||||
@@ -877,7 +756,7 @@ class ConstructorArbolAuto {
|
||||
int siguientePagina,
|
||||
) => MediaItem(
|
||||
id: '$_prefijoCarpetaLocalBucket$idxBucket:$siguientePagina:$documentIdPadre',
|
||||
title: etiquetas.cargarMas,
|
||||
title: _tituloMasLocal,
|
||||
playable: false,
|
||||
extras: _contentStyleLista,
|
||||
);
|
||||
@@ -957,7 +836,7 @@ class ConstructorArbolAuto {
|
||||
final titulo =
|
||||
(tituloMeta != null && tituloMeta.isNotEmpty)
|
||||
? tituloMeta
|
||||
: _tituloDesdeNombre(nodo.nombre, etiquetas.pistaSinNombre);
|
||||
: _tituloDesdeNombre(nodo.nombre);
|
||||
final artUriMeta = meta?.artUri?.trim();
|
||||
final artUri =
|
||||
(artUriMeta != null && artUriMeta.isNotEmpty)
|
||||
@@ -1074,56 +953,24 @@ class ConstructorArbolAuto {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [parentMediaId] is content the FREE tier is allowed to browse
|
||||
/// (fix/auto-quality-guidelines, item 10): the browsable root itself, the
|
||||
/// free folder [ConstructorArbolAuto.idDestacadas], and an `emisora:<uuid>`
|
||||
/// whose uuid belongs to [destacadas].
|
||||
///
|
||||
/// Everything else — the catalogue folders, favourites, custom stations,
|
||||
/// local music, the equalizer folder, group folders, local tracks, and any
|
||||
/// station uuid that is not in the free set — is premium content.
|
||||
///
|
||||
/// Pure and id-shaped, with the free universe INJECTED, so the whole matrix
|
||||
/// is testable without prefs or a handler.
|
||||
bool idPermitidoEnFree(
|
||||
String parentMediaId, {
|
||||
required List<Emisora> destacadas,
|
||||
}) {
|
||||
if (parentMediaId == AudioService.browsableRootId) return true;
|
||||
if (parentMediaId == ConstructorArbolAuto.idDestacadas) return true;
|
||||
if (!parentMediaId.startsWith(_prefijoEmisora)) return false;
|
||||
final uuid = parentMediaId.substring(_prefijoEmisora.length);
|
||||
if (uuid.isEmpty) return false;
|
||||
return destacadas.any((e) => e.uuid == uuid);
|
||||
}
|
||||
|
||||
/// Pure Android Auto browse-gate decision: the AUTHORITATIVE `getChildren`
|
||||
/// choke point, called BEFORE any other resolution.
|
||||
///
|
||||
/// REWRITTEN (fix/auto-quality-guidelines, item 10) from action-blocking to
|
||||
/// content-scoping. It used to answer ANY non-root id, for a free-tier user,
|
||||
/// with a single non-playable "Función Premium" row — which is what Google
|
||||
/// Play cited on version code 157 ("clicking on stop button makes the entire
|
||||
/// app useless" was the headline, but the browse tree it was reviewed
|
||||
/// against was four folders that each dead-ended on that row). A
|
||||
/// non-playable row reachable from a head unit's CACHED tree is a citation
|
||||
/// waiting to happen, so there is no longer any code path that can produce
|
||||
/// one: the blocked branch returns the free tier's own playable stations.
|
||||
///
|
||||
/// Returns `null` when the caller should proceed with its normal resolution
|
||||
/// (premium, or free-tier content the free tier owns).
|
||||
///
|
||||
/// [destacadas] is the free universe (`resolverEmisorasDestacadas()`); the
|
||||
/// caller resolves it once per browse. Passing an empty list is legal and
|
||||
/// yields an empty blocked response — still never a dead row.
|
||||
/// Pure Android Auto browse-gate decision (iap-freemium-unlock, Design
|
||||
/// ADR-4): the AUTHORITATIVE `getChildren` choke point, called BEFORE any
|
||||
/// other resolution. For the root itself this NEVER blocks (the root always
|
||||
/// resolves through [ConstructorArbolAuto.raiz] instead, which stays
|
||||
/// visible for every tier). For any non-root [parentMediaId] and a free-tier
|
||||
/// [premium], it returns the single locked item regardless of what the id
|
||||
/// actually is — a stale/deep-linked `emisora:<uuid>` or folder id from
|
||||
/// before a downgrade is blocked exactly the same way as a legitimate
|
||||
/// current folder id (android-auto-media spec "Free-Tier Browse Never
|
||||
/// Leaks Real Content (Authoritative Backstop)"). Returns `null` when the
|
||||
/// caller should proceed with its normal resolution (root, or premium).
|
||||
List<MediaItem>? respuestaBloqueadaPorEntitlement({
|
||||
required String parentMediaId,
|
||||
required bool premium,
|
||||
required List<Emisora> destacadas,
|
||||
}) {
|
||||
if (parentMediaId == AudioService.browsableRootId) return null;
|
||||
if (premium) return null;
|
||||
if (idPermitidoEnFree(parentMediaId, destacadas: destacadas)) return null;
|
||||
return ConstructorArbolAuto().hijosDestacadas(destacadas);
|
||||
return [ConstructorArbolAuto().itemPremiumBloqueado()];
|
||||
}
|
||||
|
||||
/// Routing seam between a car-tapped `emisora:<uuid>` media id and the
|
||||
@@ -1135,21 +982,17 @@ List<MediaItem>? respuestaBloqueadaPorEntitlement({
|
||||
/// A stale/unknown id (or a malformed one) is a no-op: [reproducir] is
|
||||
/// never called and no exception propagates (Spec "Unknown or stale media
|
||||
/// id").
|
||||
///
|
||||
/// RETURNS whether it actually dispatched (fix/auto-quality-guidelines,
|
||||
/// item 12). The caller needs to tell "played" from "resolved to nothing"
|
||||
/// so the second case can publish an explained error to the car instead of
|
||||
/// leaving the driver with a tap that did nothing and said nothing.
|
||||
Future<bool> reproducirPorMediaId(
|
||||
Future<void> reproducirPorMediaId(
|
||||
String id, {
|
||||
required FuenteEmisorasAuto fuente,
|
||||
required Future<void> Function(MediaItem) reproducir,
|
||||
}) async {
|
||||
final uuid = uuidDeMediaIdEmisora(id);
|
||||
if (uuid == null) return false;
|
||||
if (!id.startsWith(_prefijoEmisora)) return;
|
||||
final uuid = id.substring(_prefijoEmisora.length);
|
||||
if (uuid.isEmpty) return;
|
||||
|
||||
final emisora = await fuente.porUuid(uuid);
|
||||
if (emisora == null) return false;
|
||||
if (emisora == null) return;
|
||||
|
||||
final item = MediaItem(
|
||||
id: emisora.url,
|
||||
@@ -1165,58 +1008,6 @@ Future<bool> reproducirPorMediaId(
|
||||
extras: {'uuid': emisora.uuid},
|
||||
);
|
||||
await reproducir(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The uuid inside an `emisora:<uuid>` media id, or `null` for any other
|
||||
/// shape — no prefix (a `pista:`/`carpeta_local_*`/`eq_preset:` id, or a
|
||||
/// folder id) and an empty tail both answer `null`.
|
||||
///
|
||||
/// Extracted (fix/auto-quality-guidelines, item 11) because the play-path
|
||||
/// entitlement gate has to ask the same question `reproducirPorMediaId` asks,
|
||||
/// one step earlier: "is this a station id, and which station?".
|
||||
String? uuidDeMediaIdEmisora(String id) {
|
||||
if (!id.startsWith(_prefijoEmisora)) return null;
|
||||
final uuid = id.substring(_prefijoEmisora.length);
|
||||
return uuid.isEmpty ? null : uuid;
|
||||
}
|
||||
|
||||
/// A [FuenteEmisorasAuto] over nothing but the free tier's station set
|
||||
/// (fix/auto-quality-guidelines, item 12).
|
||||
///
|
||||
/// Stands in for `_fuenteNavegacionGlobal` while that is still `null` — the
|
||||
/// window between the headless Android Auto engine starting and `main.dart`
|
||||
/// registering the real source. A tap arriving in that window used to return
|
||||
/// in silence; the free set is compiled into the binary, so it can always be
|
||||
/// answered.
|
||||
///
|
||||
/// Reports the free stations through [todas] (they are, from the car's point
|
||||
/// of view, everything there is) and nothing through the curated lists, which
|
||||
/// a headless bind could not populate anyway.
|
||||
class FuenteEmisorasAutoDestacadas extends FuenteEmisorasAuto {
|
||||
FuenteEmisorasAutoDestacadas(this._destacadas);
|
||||
|
||||
final List<Emisora> _destacadas;
|
||||
|
||||
@override
|
||||
Future<List<Emisora>> favoritos() async => const [];
|
||||
|
||||
@override
|
||||
Future<List<Emisora>> misEmisoras() async => const [];
|
||||
|
||||
@override
|
||||
Future<List<Emisora>> todas() async => _destacadas;
|
||||
|
||||
@override
|
||||
Future<List<GrupoFavoritos>> grupos() async => const [];
|
||||
|
||||
@override
|
||||
Future<Emisora?> porUuid(String uuid) async {
|
||||
for (final emisora in _destacadas) {
|
||||
if (emisora.uuid == uuid) return emisora;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Which list previous/next should walk for [actual]: the NARROWEST context
|
||||
@@ -1247,52 +1038,6 @@ List<Emisora> listaParaSaltoEmisora({
|
||||
required List<Emisora> favoritos,
|
||||
required List<Emisora> misEmisoras,
|
||||
required List<Emisora> todas,
|
||||
}) {
|
||||
final contexto = contextoParaSaltoEmisora(
|
||||
actual: actual,
|
||||
favoritos: favoritos,
|
||||
misEmisoras: misEmisoras,
|
||||
todas: todas,
|
||||
);
|
||||
if (contexto == null) return const [];
|
||||
switch (contexto.tipo) {
|
||||
case TipoContextoSalto.grupoFavoritos:
|
||||
return favoritos
|
||||
.where((e) => e.grupoFavoritosId == contexto.grupoFavoritosId)
|
||||
.toList();
|
||||
case TipoContextoSalto.favoritos:
|
||||
return favoritos;
|
||||
case TipoContextoSalto.misEmisoras:
|
||||
return misEmisoras;
|
||||
case TipoContextoSalto.todas:
|
||||
return todas;
|
||||
case TipoContextoSalto.destacadas:
|
||||
// Never produced by [contextoParaSaltoEmisora] — the free set is
|
||||
// resolved by the handler, which owns the entitlement read.
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// The same decision as [listaParaSaltoEmisora], NAMED instead of materialised
|
||||
/// — so it can be remembered across a process restart.
|
||||
///
|
||||
/// The car kills and restarts the engine on every reconnect, and a list of
|
||||
/// stations is not something that survives that: its members change while the
|
||||
/// app is dead. The NAME of the list does survive, which is what
|
||||
/// [ContextoSalto] persists and [resolverListaContexto] re-resolves against
|
||||
/// whatever the lists hold next time.
|
||||
///
|
||||
/// [listaParaSaltoEmisora] is implemented on top of this so the walked list
|
||||
/// and the remembered context can never disagree (pinned by a test that runs
|
||||
/// both over the same scenarios).
|
||||
///
|
||||
/// Returns `null` when [actual] belongs to none of the three lists — the
|
||||
/// caller then has no context to remember and leaves playback alone.
|
||||
ContextoSalto? contextoParaSaltoEmisora({
|
||||
required Emisora actual,
|
||||
required List<Emisora> favoritos,
|
||||
required List<Emisora> misEmisoras,
|
||||
required List<Emisora> todas,
|
||||
}) {
|
||||
Emisora? enLista(List<Emisora> lista) {
|
||||
for (final e in lista) {
|
||||
@@ -1310,13 +1055,13 @@ ContextoSalto? contextoParaSaltoEmisora({
|
||||
if (grupo != GrupoFavoritos.sinAsignarId) {
|
||||
final delGrupo =
|
||||
favoritos.where((e) => e.grupoFavoritosId == grupo).toList();
|
||||
if (delGrupo.length > 1) return ContextoSalto.grupo(grupo);
|
||||
if (delGrupo.length > 1) return delGrupo;
|
||||
}
|
||||
return const ContextoSalto.favoritos();
|
||||
return favoritos;
|
||||
}
|
||||
if (enLista(misEmisoras) != null) return const ContextoSalto.misEmisoras();
|
||||
if (enLista(todas) != null) return const ContextoSalto.todas();
|
||||
return null;
|
||||
if (enLista(misEmisoras) != null) return misEmisoras;
|
||||
if (enLista(todas) != null) return todas;
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// The station before or after [actual] in [lista], wrapping around at both
|
||||
@@ -1443,22 +1188,25 @@ Future<void> seleccionarPresetEqPorMediaId(
|
||||
if (!activo) await activarEcualizador(true);
|
||||
}
|
||||
|
||||
/// Fallback title (Design "Title = filename minus extension") for a blank
|
||||
/// or otherwise empty-after-stripping local filename — hardcoded Spanish,
|
||||
/// matching every other car-tree label in this file (`'Favoritos'`,
|
||||
/// `'Música Local'`, etc.), none of which go through `AppLocalizations`.
|
||||
const _tituloLocalFallback = 'Pista sin nombre';
|
||||
|
||||
/// Filename → display title (Design "Title = filename minus extension"):
|
||||
/// strips the LAST `.ext` (the whole trimmed name is kept when there is no
|
||||
/// dot, or the dot is the first character — e.g. a hidden file like
|
||||
/// `.mp3`), falling back to [sinNombre] when the result would be blank.
|
||||
///
|
||||
/// [sinNombre] is [EtiquetasArbolAuto.pistaSinNombre], passed in rather than
|
||||
/// hardcoded: it is a title the driver reads, so it is translated like every
|
||||
/// other car-tree label.
|
||||
String _tituloDesdeNombre(String nombre, String sinNombre) {
|
||||
/// `.mp3`), falling back to [_tituloLocalFallback] when the result would be
|
||||
/// blank.
|
||||
String _tituloDesdeNombre(String nombre) {
|
||||
final recortado = nombre.trim();
|
||||
if (recortado.isEmpty) return sinNombre;
|
||||
if (recortado.isEmpty) return _tituloLocalFallback;
|
||||
final ultimoPunto = recortado.lastIndexOf('.');
|
||||
final sinExtension =
|
||||
ultimoPunto > 0 ? recortado.substring(0, ultimoPunto) : recortado;
|
||||
final resultado = sinExtension.trim();
|
||||
return resultado.isEmpty ? sinNombre : resultado;
|
||||
return resultado.isEmpty ? _tituloLocalFallback : resultado;
|
||||
}
|
||||
|
||||
/// Resolves the on-brand fallback `artUri` for a local track (Design "art =
|
||||
@@ -1731,13 +1479,12 @@ Future<void> reproducirCarpetaLocal(
|
||||
Future<MediaItem?> construirMediaItemColaLocal(
|
||||
NodoLocal nodo, {
|
||||
required FuenteMusicaLocalAuto fuente,
|
||||
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
|
||||
}) async {
|
||||
final contentUri = await fuente.uriContenidoDePista(nodo.documentId);
|
||||
if (contentUri == null || contentUri.isEmpty) return null;
|
||||
return MediaItem(
|
||||
id: contentUri,
|
||||
title: _tituloDesdeDocumentId(nodo.documentId, etiquetas.pistaSinNombre),
|
||||
title: _tituloDesdeDocumentId(nodo.documentId),
|
||||
album: 'PluriWave',
|
||||
// Item 3: a queued local track had NO artUri at all before — reuses
|
||||
// [artUriLocal] (the SAME on-brand rotation the browse tree's
|
||||
@@ -1801,9 +1548,8 @@ Future<Map<String, MetadatosPista>> _metadatosDeConCache(
|
||||
Future<List<MediaItem>?> hijosMusicaLocal(
|
||||
String parentMediaId, {
|
||||
required FuenteMusicaLocalAuto? fuente,
|
||||
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
|
||||
}) async {
|
||||
final constructor = ConstructorArbolAuto(etiquetas: etiquetas);
|
||||
final constructor = ConstructorArbolAuto();
|
||||
|
||||
// Sort-mode and bucket views (Design ADR-4, Phase 2) are routed FIRST —
|
||||
// routing order is irrelevant to correctness (every prefix in this file
|
||||
@@ -1894,11 +1640,11 @@ Future<List<MediaItem>?> hijosMusicaLocal(
|
||||
/// the SAME [_tituloDesdeNombre] rule the browse tree uses. This keeps the
|
||||
/// Now Playing title consistent with what the user tapped without requiring
|
||||
/// a second native round trip.
|
||||
String _tituloDesdeDocumentId(String documentId, String sinNombre) {
|
||||
String _tituloDesdeDocumentId(String documentId) {
|
||||
final ultimaBarra = documentId.lastIndexOf('/');
|
||||
final segmento =
|
||||
ultimaBarra >= 0 ? documentId.substring(ultimaBarra + 1) : documentId;
|
||||
return _tituloDesdeNombre(segmento, sinNombre);
|
||||
return _tituloDesdeNombre(segmento);
|
||||
}
|
||||
|
||||
/// Routing seam between a car-tapped `pista:<docId>` media id and the
|
||||
@@ -1917,7 +1663,6 @@ Future<void> reproducirPistaLocal(
|
||||
String id, {
|
||||
required FuenteMusicaLocalAuto fuente,
|
||||
required Future<void> Function(MediaItem) reproducir,
|
||||
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
|
||||
}) async {
|
||||
if (!esPistaMediaId(id)) return;
|
||||
final documentId = id.substring(_prefijoPista.length);
|
||||
@@ -1928,7 +1673,7 @@ Future<void> reproducirPistaLocal(
|
||||
|
||||
final pista = PistaLocal(
|
||||
documentId: documentId,
|
||||
titulo: _tituloDesdeDocumentId(documentId, etiquetas.pistaSinNombre),
|
||||
titulo: _tituloDesdeDocumentId(documentId),
|
||||
contentUri: contentUri,
|
||||
);
|
||||
|
||||
@@ -2023,16 +1768,6 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
|
||||
return _snapshotTodas ?? const [];
|
||||
}
|
||||
|
||||
/// Resolves a station uuid across every list this source can reach.
|
||||
///
|
||||
/// The free tier's set ([resolverEmisorasDestacadas]) is searched LAST
|
||||
/// (fix/auto-quality-guidelines, item 7). It has to be searched at all
|
||||
/// because on a cold headless bind the three lists above are all empty —
|
||||
/// `todas()` is `_snapshotTodas ?? const []`, favourites and custom
|
||||
/// stations have nothing persisted on a fresh install — so a curated
|
||||
/// `emisora:<uuid>` resolved to `null` and tapping the row did NOTHING.
|
||||
/// It is searched last so a live catalogue/favourite record for the same
|
||||
/// uuid (richer metadata, the user's own group assignment) still wins.
|
||||
@override
|
||||
Future<Emisora?> porUuid(String uuid) async {
|
||||
final listas = await Future.wait([favoritos(), misEmisoras(), todas()]);
|
||||
@@ -2041,9 +1776,6 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
|
||||
if (emisora.uuid == uuid) return emisora;
|
||||
}
|
||||
}
|
||||
for (final emisora in await resolverEmisorasDestacadas()) {
|
||||
if (emisora.uuid == uuid) return emisora;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+168
-1670
File diff suppressed because it is too large
Load Diff
@@ -256,27 +256,6 @@ class ServicioEcualizador {
|
||||
return prefs.getBool(_keyActivo);
|
||||
}
|
||||
|
||||
/// The persisted principal preset, or `null` when the user has never saved
|
||||
/// one.
|
||||
///
|
||||
/// The exact sibling of [leerActivo] and narrow for the same reason: its
|
||||
/// caller is `registrarHandler`, on the audio bootstrap path of EVERY
|
||||
/// engine — including the headless one Android Auto starts, where there is
|
||||
/// no widget tree and `EstadoEcualizador` never exists to push a preset
|
||||
/// into the handler. It reads ONE key, runs none of [cargar]'s migrations
|
||||
/// and mutates nothing.
|
||||
///
|
||||
/// `null` (nothing saved, or an unreadable value) is preserved rather than
|
||||
/// collapsed to [PresetEcualizador.flat] so the handler's own default —
|
||||
/// not this service — decides what "never persisted" means, and so a seed
|
||||
/// with nothing to say does not overwrite anything.
|
||||
Future<PresetEcualizador?> leerPresetPrincipal() async {
|
||||
final prefs = await _resolverPrefs();
|
||||
final raw = prefs.getString(_keyPresetPrincipal);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
return _leerPresetPrincipal(prefs);
|
||||
}
|
||||
|
||||
Future<void> eliminarPorEmisora(String uuid) async {
|
||||
final prefs = await _resolverPrefs();
|
||||
final mapa = _leerPresetsPorEmisora(prefs);
|
||||
|
||||
@@ -213,43 +213,6 @@ class ServicioFavoritos {
|
||||
);
|
||||
}
|
||||
|
||||
/// Restaura un favorito tal como estaba en el dispositivo de origen,
|
||||
/// preservando su `orden` y su `grupo_id`.
|
||||
/// Usado exclusivamente por importarConfig, igual que [restaurarGrupo].
|
||||
///
|
||||
/// Existe porque [agregar] NO sirve como primitiva de restauración: es la
|
||||
/// primitiva de «marcar como favorita» y fuerza `sin_asignar` más un
|
||||
/// `orden` al final de la lista, cosa correcta para una emisora recién
|
||||
/// marcada (que de verdad no pertenece a ningún grupo) y destructiva para
|
||||
/// una copia de seguridad, que trae ambos campos. Reusarla era la causa de
|
||||
/// que los grupos volvieran vacíos tras restaurar.
|
||||
///
|
||||
/// El grupo se valida igual que en [asignarGrupo]: un `grupo_id` que no
|
||||
/// existe en `grupos_favoritos` cae a [GrupoFavoritos.sinAsignarId], de modo
|
||||
/// que una copia editada a mano o restaurada a medias no puede dejar
|
||||
/// emisoras apuntando a un grupo inexistente. `importarConfig` restaura los
|
||||
/// grupos ANTES de este bucle, así que en el camino normal siempre existen.
|
||||
Future<void> restaurarFavorito(Emisora emisora) async {
|
||||
final db = await _database;
|
||||
final existe =
|
||||
Sqflite.firstIntValue(
|
||||
await db.rawQuery(
|
||||
'SELECT COUNT(*) FROM grupos_favoritos WHERE id = ?',
|
||||
[emisora.grupoFavoritosId],
|
||||
),
|
||||
) ??
|
||||
0;
|
||||
final restaurada =
|
||||
existe > 0
|
||||
? emisora
|
||||
: emisora.copyWith(grupoFavoritosId: GrupoFavoritos.sinAsignarId);
|
||||
await db.insert(
|
||||
'favoritos',
|
||||
restaurada.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> eliminarGrupo(String id) async {
|
||||
if (id == GrupoFavoritos.sinAsignarId) return;
|
||||
final db = await _database;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
name: pluriwave
|
||||
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
|
||||
publish_to: 'none'
|
||||
version: 1.3.3+161
|
||||
version: 1.3.1+157
|
||||
|
||||
environment:
|
||||
sdk: ^3.7.0
|
||||
|
||||
@@ -4,8 +4,6 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/main.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
import 'helpers/handlers_audio.dart';
|
||||
|
||||
/// fix/android-auto-musica-local item 4 — CORRECCIÓN del disparador.
|
||||
///
|
||||
/// El disparador anterior era `View.maybeOf(context) != null` dentro de
|
||||
@@ -30,8 +28,6 @@ import 'helpers/handlers_audio.dart';
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final crearHandler = registrarHandlersLiberables();
|
||||
|
||||
group('debeInvalidarArbolAutoAlReanudar (decisión pura)', () {
|
||||
test('resumed + coche ya suscrito + latch libre invalida', () {
|
||||
expect(
|
||||
@@ -113,7 +109,7 @@ void main() {
|
||||
testWidgets('arranque headless: hay View desde el primer frame, pero sin '
|
||||
'Activity ni coche suscrito el latch NO se gasta y sigue disponible '
|
||||
'para cuando el coche por fin navegue', (tester) async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
var invalidaciones = 0;
|
||||
registrarInvalidacionArbolAuto(() => invalidaciones++);
|
||||
@@ -148,7 +144,7 @@ void main() {
|
||||
|
||||
testWidgets('con el coche YA suscrito, adjuntar una Activity (resumed) '
|
||||
'empuja de verdad por el stream de hijos de la raíz', (tester) async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
// El coche navegó la raíz durante el arranque headless: el sujeto
|
||||
@@ -194,7 +190,7 @@ void main() {
|
||||
group('hayCocheSuscritoAlArbol', () {
|
||||
test('es false sin handler suscrito y true en cuanto el coche navega un '
|
||||
'id', () async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
expect(hayCocheSuscritoAlArbol(), isFalse);
|
||||
|
||||
@@ -1944,59 +1944,6 @@ void main() {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The handler REJECTED the toggle (native setEnabled threw): the handler
|
||||
// rolls its own flag back, so this class must not keep — nor persist — a
|
||||
// value the engine refused.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('EstadoEcualizador — cambiarActivo cuando el handler rechaza', () {
|
||||
test(
|
||||
'adopta el valor real del handler y NO persiste el valor rechazado',
|
||||
() async {
|
||||
final fakeAudio = _FakeAudioEqRechazaConmutacion();
|
||||
final fakeServicio = FakeServicioEcualizador(activo: true);
|
||||
final eq = EstadoEcualizador(audio: fakeAudio, servicio: fakeServicio);
|
||||
await eq.cargarPersistido();
|
||||
fakeAudio.cambiosEcualizadorActivo.clear();
|
||||
fakeServicio.guardarActivoLlamadas = 0;
|
||||
|
||||
var avisos = 0;
|
||||
eq.addListener(() => avisos++);
|
||||
|
||||
await eq.cambiarActivo(false);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
|
||||
// The native call failed, so the handler kept the equalizer ON.
|
||||
expect(fakeAudio.ecualizadorActivo, isTrue);
|
||||
expect(
|
||||
eq.activo,
|
||||
isTrue,
|
||||
reason: 'the UI must show what the engine really does',
|
||||
);
|
||||
expect(avisos, greaterThanOrEqualTo(1));
|
||||
expect(
|
||||
fakeServicio.guardarActivoLlamadas,
|
||||
equals(0),
|
||||
reason: 'a rejected value must never reach disk',
|
||||
);
|
||||
expect(fakeServicio.config.activo, isTrue);
|
||||
eq.dispose();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Fake handler that REFUSES every on/off change: it records the call (the
|
||||
/// UI-initiated path did reach the engine) but leaves [ecualizadorActivo]
|
||||
/// untouched, exactly like `PluriWaveAudioHandler._aplicarEcualizadorActivo`
|
||||
/// rolling its flag back when the native `setEnabled` throws.
|
||||
class _FakeAudioEqRechazaConmutacion extends FakeServicioAudio {
|
||||
@override
|
||||
Future<void> setEcualizadorActivo(bool activo) async {
|
||||
cambiosEcualizadorActivo.add(activo);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fake whose [guardarActivo] stays pending until released, and releases the
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
/// Copia de seguridad — ROUND TRIP de los grupos de favoritos y de la
|
||||
/// asignación emisora -> grupo.
|
||||
///
|
||||
/// Reportado desde el uso real: al restaurar una copia en otro dispositivo
|
||||
/// los grupos volvían VACÍOS y todas las emisoras aparecían en «Sin
|
||||
/// asignar». El sobre exportado siempre llevó ambas cosas (`gruposFavoritos`
|
||||
/// desde v2, y `grupo_id` dentro de cada entrada de `favoritos`, porque es
|
||||
/// una clave intrínseca de `Emisora.toMap()`); lo que fallaba era la
|
||||
/// APLICACIÓN del estado: `importarConfig` reusaba `ServicioFavoritos.agregar`,
|
||||
/// la primitiva de «marcar como favorita», que fuerza `sin_asignar` y un
|
||||
/// `orden` nuevo a propósito.
|
||||
///
|
||||
/// Por eso estos tests prueban el VIAJE COMPLETO (origen -> exportar ->
|
||||
/// destino limpio -> importar), no la forma del sobre: la forma ya estaba
|
||||
/// bien y aun así el usuario perdía sus grupos.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late Directory tempDir;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
tempDir = await Directory.systemTemp.createTemp(
|
||||
'pluriwave_export_grupos_test',
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
if (tempDir.existsSync()) {
|
||||
await tempDir.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
var contadorArchivos = 0;
|
||||
|
||||
Future<EstadoRadio> crearRadio() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
// Un archivo POR instancia: `importarConfig` escribe siempre en el que
|
||||
// resuelva `resolverArchivoCustom`, y origen y destino no pueden
|
||||
// compartirlo.
|
||||
final archivoCustom = File(
|
||||
'${tempDir.path}/emisoras_custom_${contadorArchivos++}.json',
|
||||
);
|
||||
if (!archivoCustom.existsSync()) {
|
||||
await archivoCustom.writeAsString('[]');
|
||||
}
|
||||
final radio = EstadoRadio(
|
||||
esPremium: () => true,
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
|
||||
resolverArchivoCustom: () async => archivoCustom,
|
||||
prefs: prefs,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await radio.ecualizador.cargarPersistido();
|
||||
return radio;
|
||||
}
|
||||
|
||||
Emisora emisora(String uuid, String nombre) => Emisora(
|
||||
uuid: uuid,
|
||||
nombre: nombre,
|
||||
url: 'https://example.com/$uuid.mp3',
|
||||
);
|
||||
|
||||
group('EstadoRadio export/import — grupos de favoritos', () {
|
||||
test('round trip: los grupos y la asignación de CADA emisora sobreviven '
|
||||
'al viaje origen -> copia -> destino limpio', () async {
|
||||
final origen = await crearRadio();
|
||||
await origen.toggleFavorito(emisora('rock-1', 'Rock Uno'));
|
||||
await origen.toggleFavorito(emisora('rock-2', 'Rock Dos'));
|
||||
await origen.toggleFavorito(emisora('jazz-1', 'Jazz Uno'));
|
||||
await origen.toggleFavorito(emisora('suelta', 'Sin grupo'));
|
||||
await origen.crearGrupoFavoritos('Rock');
|
||||
await origen.crearGrupoFavoritos('Jazz');
|
||||
final rock = origen.gruposFavoritos.firstWhere((g) => g.nombre == 'Rock');
|
||||
final jazz = origen.gruposFavoritos.firstWhere((g) => g.nombre == 'Jazz');
|
||||
await origen.asignarGrupoFavorito('rock-1', rock.id);
|
||||
await origen.asignarGrupoFavorito('rock-2', rock.id);
|
||||
await origen.asignarGrupoFavorito('jazz-1', jazz.id);
|
||||
|
||||
final copia = await origen.exportarConfig();
|
||||
|
||||
final destino = await crearRadio();
|
||||
await destino.importarConfig(copia);
|
||||
|
||||
// Los grupos vuelven, con su nombre y su orden.
|
||||
final gruposDestino = destino.gruposFavoritos;
|
||||
expect(
|
||||
gruposDestino.map((g) => g.id),
|
||||
containsAll(<String>[rock.id, jazz.id]),
|
||||
);
|
||||
expect(gruposDestino.firstWhere((g) => g.id == rock.id).nombre, 'Rock');
|
||||
expect(gruposDestino.firstWhere((g) => g.id == jazz.id).nombre, 'Jazz');
|
||||
|
||||
// Y la asignación de CADA emisora vuelve con ellos.
|
||||
String grupoDe(String uuid) =>
|
||||
destino.listaFavoritos.firstWhere((e) => e.uuid == uuid)
|
||||
.grupoFavoritosId;
|
||||
expect(grupoDe('rock-1'), rock.id);
|
||||
expect(grupoDe('rock-2'), rock.id);
|
||||
expect(grupoDe('jazz-1'), jazz.id);
|
||||
expect(grupoDe('suelta'), GrupoFavoritos.sinAsignarId);
|
||||
});
|
||||
|
||||
test('una copia ANTIGUA sin `gruposFavoritos` importa limpiamente y deja '
|
||||
'intactos los grupos que ya existen en el dispositivo', () async {
|
||||
final destino = await crearRadio();
|
||||
await destino.crearGrupoFavoritos('Mío');
|
||||
final propio = destino.gruposFavoritos.firstWhere(
|
||||
(g) => g.nombre == 'Mío',
|
||||
);
|
||||
await destino.toggleFavorito(emisora('local-1', 'Local Uno'));
|
||||
await destino.asignarGrupoFavorito('local-1', propio.id);
|
||||
|
||||
// v1: ni `gruposFavoritos` ni `alarmas` ni preferencias. La regla del
|
||||
// sobre es que un campo AUSENTE no toca ese estado.
|
||||
await destino.importarConfig(<String, dynamic>{
|
||||
'version': 1,
|
||||
'favoritos': <Map<String, dynamic>>[],
|
||||
'emisorasCustom': <Map<String, dynamic>>[],
|
||||
'presetsEcualizador': <String, dynamic>{},
|
||||
});
|
||||
|
||||
expect(destino.gruposFavoritos.any((g) => g.id == propio.id), isTrue);
|
||||
expect(
|
||||
destino.gruposFavoritos.firstWhere((g) => g.id == propio.id).nombre,
|
||||
'Mío',
|
||||
);
|
||||
expect(
|
||||
destino.listaFavoritos.firstWhere((e) => e.uuid == 'local-1')
|
||||
.grupoFavoritosId,
|
||||
propio.id,
|
||||
);
|
||||
});
|
||||
|
||||
test('los grupos importados quedan visibles SIN reiniciar: importarConfig '
|
||||
'recarga la lista en memoria y notifica', () async {
|
||||
final origen = await crearRadio();
|
||||
await origen.toggleFavorito(emisora('rock-1', 'Rock Uno'));
|
||||
await origen.crearGrupoFavoritos('Rock');
|
||||
final rock = origen.gruposFavoritos.firstWhere((g) => g.nombre == 'Rock');
|
||||
await origen.asignarGrupoFavorito('rock-1', rock.id);
|
||||
final copia = await origen.exportarConfig();
|
||||
|
||||
final destino = await crearRadio();
|
||||
var notificaciones = 0;
|
||||
destino.addListener(() => notificaciones++);
|
||||
|
||||
await destino.importarConfig(copia);
|
||||
|
||||
expect(notificaciones, greaterThan(0));
|
||||
expect(destino.gruposFavoritos.any((g) => g.id == rock.id), isTrue);
|
||||
expect(
|
||||
destino.listaFavoritos.single.grupoFavoritosId,
|
||||
rock.id,
|
||||
reason:
|
||||
'la vista de favoritos agrupa por `grupoFavoritosId`: si la lista '
|
||||
'en memoria no se recarga tras restaurar los grupos, la pantalla '
|
||||
'sigue mostrando todo en «Sin asignar» hasta reiniciar',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import 'package:pluriwave/estado/estado_radio.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -793,16 +792,21 @@ void main() {
|
||||
'(reinicio) como emisoraActual DETENIDA -- no arranca audio, no '
|
||||
'reproduce, sólo queda seleccionada', () async {
|
||||
final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM');
|
||||
// The record is now written by the audio handler's `_cambiarFuente`
|
||||
// (`GuardarUltimaEmisoraPersistida`), which is the SINGLE writer of
|
||||
// `ultima_emisora_v1` and the only one that also exists on the headless
|
||||
// Android Auto engine — `EstadoRadio` used to write it too and no
|
||||
// longer does. Seeded through that same production function here, so
|
||||
// this test covers what `EstadoRadio` actually owns (the RESTORE) with
|
||||
// a real payload instead of one a fake invented. The write itself is
|
||||
// covered end to end in
|
||||
// `test/servicios/servicio_audio_ultima_emisora_test.dart`.
|
||||
await guardarUltimaEmisoraPersistida(emisora);
|
||||
final estadoUno = EstadoRadio(
|
||||
esPremium: () => true,
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estadoUno.inicializar();
|
||||
await estadoUno.reproducir(emisora);
|
||||
await estadoUno.detenerReproduccion();
|
||||
// Lets the fire-and-forget persistence write settle before
|
||||
// spinning up the "restart" instance.
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final audioDos = FakeServicioAudio();
|
||||
final estadoDos = EstadoRadio(
|
||||
@@ -844,18 +848,9 @@ void main() {
|
||||
});
|
||||
|
||||
test('una emisora seleccionada desde el auto (fuera de reproducir()) '
|
||||
'deja de estar ensombrecida por la seleccion previa del telefono',
|
||||
() async {
|
||||
// The PERSISTENCE half of this scenario moved to the handler, which is
|
||||
// the only writer that exists on a car-only session — it is covered by
|
||||
// «playFromMediaId desde el coche persiste ESA emisora» in
|
||||
// `test/servicios/servicio_audio_ultima_emisora_test.dart`. What
|
||||
// `EstadoRadio` still owns here, and what this test now pins, is the
|
||||
// shadowing fix: a car selection bypasses `reproducir()`, so without
|
||||
// the `estadoStream` listener `_emisoraSeleccionada` would keep
|
||||
// shadowing the car's station on the `emisoraActual` getter.
|
||||
'también se recuerda para la próxima instancia', () async {
|
||||
final audio = _AudioControlado();
|
||||
final estado = EstadoRadio(
|
||||
final estadoUno = EstadoRadio(
|
||||
esPremium: () => true,
|
||||
audio: audio,
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
@@ -864,16 +859,7 @@ void main() {
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estado.inicializar();
|
||||
final desdeElTelefono = emisoraDemo(
|
||||
uuid: 'phone-picked',
|
||||
nombre: 'Elegida en el telefono',
|
||||
);
|
||||
unawaited(estado.reproducir(desdeElTelefono));
|
||||
audio.completar(desdeElTelefono.uuid);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(estado.emisoraActual?.uuid, desdeElTelefono.uuid);
|
||||
|
||||
await estadoUno.inicializar();
|
||||
final desdeCoche = emisoraDemo(
|
||||
uuid: 'auto-remembered',
|
||||
nombre: 'Recordada desde el auto',
|
||||
@@ -881,14 +867,18 @@ void main() {
|
||||
audio.seleccionarDesdeAuto(desdeCoche);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(
|
||||
estado.emisoraActual?.uuid,
|
||||
desdeCoche.uuid,
|
||||
reason:
|
||||
'the car changed the station without going through reproducir(); '
|
||||
'the phone UI must follow it instead of keeping the previous '
|
||||
'selection on screen',
|
||||
final estadoDos = EstadoRadio(
|
||||
esPremium: () => true,
|
||||
audio: FakeServicioAudio(),
|
||||
favoritos: FakeServicioFavoritos(),
|
||||
radio: FakeServicioRadio(),
|
||||
servicioEcualizador: FakeServicioEcualizador(),
|
||||
resolverArchivoCustom: _archivoCustomVacio,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
await estadoDos.inicializar();
|
||||
|
||||
expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+1
-39
@@ -171,45 +171,7 @@ class FakeServicioFavoritos extends ServicioFavoritos {
|
||||
@override
|
||||
Future<void> agregar(Emisora emisora) async {
|
||||
_favoritos.removeWhere((e) => e.uuid == emisora.uuid);
|
||||
// FIEL a producción (`ServicioFavoritos.agregar`): esta es la primitiva de
|
||||
// «marcar como favorita», y fuerza `sin_asignar` además de un `orden`
|
||||
// nuevo. El doble NO lo hacía, así que cualquier test de import escrito
|
||||
// contra él salía verde mientras el dispositivo real perdía la asignación
|
||||
// de grupo. Para RESTAURAR una copia existe `restaurarFavorito`.
|
||||
_favoritos.add(
|
||||
emisora.copyWith(
|
||||
orden: _favoritos.length,
|
||||
grupoFavoritosId: GrupoFavoritos.sinAsignarId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> restaurarFavorito(Emisora emisora) async {
|
||||
// Fiel a producción: preserva `orden` y `grupo_id`, cayendo a
|
||||
// `sin_asignar` cuando el grupo de la copia no existe.
|
||||
_favoritos.removeWhere((e) => e.uuid == emisora.uuid);
|
||||
final existe = _grupos.any((g) => g.id == emisora.grupoFavoritosId);
|
||||
_favoritos.add(
|
||||
existe
|
||||
? emisora
|
||||
: emisora.copyWith(grupoFavoritosId: GrupoFavoritos.sinAsignarId),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> restaurarGrupo(GrupoFavoritos grupo) async {
|
||||
// Sin este override la llamada caía en la implementación REAL de sqflite
|
||||
// y explotaba con «databaseFactory not initialized»; solo pasaba
|
||||
// desapercibido porque todos los tests de import existentes mandaban
|
||||
// `gruposFavoritos: []`.
|
||||
if (grupo.esSinAsignar) return;
|
||||
final index = _grupos.indexWhere((g) => g.id == grupo.id);
|
||||
if (index == -1) {
|
||||
_grupos.add(grupo);
|
||||
} else {
|
||||
_grupos[index] = grupo;
|
||||
}
|
||||
_favoritos.add(emisora.copyWith(orden: _favoritos.length));
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
/// Test-isolation seam for [PluriWaveAudioHandler].
|
||||
///
|
||||
/// A handler nobody releases keeps running after the test that built it: its
|
||||
/// terminal-state floor timer, its `ControladorReconexion` backoff (1/2/4/8/16
|
||||
/// s, longer than most of the tests that arm it) and anything still queued on
|
||||
/// its source-change chain. When one of those finally performs a source change
|
||||
/// it calls `_crearPlayer()`, which reads the CURRENT
|
||||
/// [PluriWaveAudioHandler.fabricaReproductorPrueba] — so a dead handler builds
|
||||
/// a double bound to a LATER test's script and drives it, incrementing that
|
||||
/// test's counters for work it never asked for.
|
||||
///
|
||||
/// That is why `servicio_audio_transporte_test.dart` behaved differently run
|
||||
/// alone and run inside the whole suite. A suite that passes under those
|
||||
/// conditions passes by luck, and luck runs out on a broken build exactly when
|
||||
/// it matters.
|
||||
///
|
||||
/// Usage — call ONCE at the top of `main()` and build every handler through
|
||||
/// the returned function:
|
||||
///
|
||||
/// ```dart
|
||||
/// final crearHandler = registrarHandlersLiberables();
|
||||
/// ...
|
||||
/// final handler = crearHandler();
|
||||
/// ```
|
||||
///
|
||||
/// The `tearDown` it registers covers every group in the file.
|
||||
PluriWaveAudioHandler Function() registrarHandlersLiberables() {
|
||||
final creados = <PluriWaveAudioHandler>[];
|
||||
tearDown(() async {
|
||||
// Released in reverse creation order so a handler built on top of an
|
||||
// earlier one is torn down first. `liberar` is idempotent, so a test that
|
||||
// already released its own handler is fine.
|
||||
for (final handler in creados.reversed) {
|
||||
await handler.liberar();
|
||||
}
|
||||
creados.clear();
|
||||
});
|
||||
return () {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
creados.add(handler);
|
||||
return handler;
|
||||
};
|
||||
}
|
||||
Binary file not shown.
@@ -300,10 +300,4 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
|
||||
'restaurarCompras',
|
||||
), // iap-freemium-unlock new key -- "Restaurar compras" is the standard
|
||||
// Portuguese store wording and coincides with es word for word.
|
||||
(
|
||||
'pt',
|
||||
'autoCarpetaFavoritos',
|
||||
), // fix/auto-quality-guidelines car-tree label -- "Favoritos" is the same
|
||||
// word in pt and es, exactly like the already-listed ('pt',
|
||||
// 'favoritesTitle') above, which carries this very value.
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||
import 'package:pluriwave/servicios/contexto_reproduccion.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
|
||||
/// Requested: the Android Auto playback screen must offer previous/next for
|
||||
@@ -173,145 +172,4 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('contextoParaSaltoEmisora — la MISMA decisión, nombrada para poder '
|
||||
'recordarla entre procesos', () {
|
||||
Emisora favorita(String uuid, String grupo) => Emisora(
|
||||
uuid: uuid,
|
||||
nombre: uuid,
|
||||
url: 'https://example.com/$uuid',
|
||||
grupoFavoritosId: grupo,
|
||||
);
|
||||
|
||||
final rock1 = favorita('rock1', 'g-rock');
|
||||
final rock2 = favorita('rock2', 'g-rock');
|
||||
final jazz1 = favorita('jazz1', 'g-jazz');
|
||||
|
||||
test('nombra el grupo cuando el salto se queda dentro del grupo', () {
|
||||
expect(
|
||||
contextoParaSaltoEmisora(
|
||||
actual: rock1,
|
||||
favoritos: [rock1, jazz1, rock2],
|
||||
misEmisoras: const [],
|
||||
todas: const [],
|
||||
),
|
||||
const ContextoSalto.grupo('g-rock'),
|
||||
);
|
||||
});
|
||||
|
||||
test('nombra la lista de cada uno de los otros tres casos', () {
|
||||
expect(
|
||||
contextoParaSaltoEmisora(
|
||||
actual: jazz1,
|
||||
favoritos: [rock1, jazz1, rock2],
|
||||
misEmisoras: const [],
|
||||
todas: const [],
|
||||
),
|
||||
const ContextoSalto.favoritos(),
|
||||
reason: 'un grupo de un solo miembro cae a todos los favoritos',
|
||||
);
|
||||
expect(
|
||||
contextoParaSaltoEmisora(
|
||||
actual: c,
|
||||
favoritos: [a, b],
|
||||
misEmisoras: [c, a],
|
||||
todas: [a, b, c],
|
||||
),
|
||||
const ContextoSalto.misEmisoras(),
|
||||
);
|
||||
expect(
|
||||
contextoParaSaltoEmisora(
|
||||
actual: c,
|
||||
favoritos: [a],
|
||||
misEmisoras: [b],
|
||||
todas: [a, b, c],
|
||||
),
|
||||
const ContextoSalto.todas(),
|
||||
);
|
||||
});
|
||||
|
||||
test('null cuando la emisora no está en ninguna lista', () {
|
||||
expect(
|
||||
contextoParaSaltoEmisora(
|
||||
actual: emisora('huerfana'),
|
||||
favoritos: [a],
|
||||
misEmisoras: [b],
|
||||
todas: [a, b],
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('CONCUERDA con listaParaSaltoEmisora en todos los casos: son la '
|
||||
'misma decisión y no pueden divergir', () {
|
||||
final escenarios = <List<List<Emisora>>>[
|
||||
[
|
||||
[rock1],
|
||||
[rock1, jazz1, rock2],
|
||||
const [],
|
||||
const [],
|
||||
],
|
||||
[
|
||||
[jazz1],
|
||||
[rock1, jazz1, rock2],
|
||||
const [],
|
||||
const [],
|
||||
],
|
||||
[
|
||||
[c],
|
||||
[a, b],
|
||||
[c, a],
|
||||
[a, b, c],
|
||||
],
|
||||
[
|
||||
[c],
|
||||
[a],
|
||||
[b],
|
||||
[a, b, c],
|
||||
],
|
||||
[
|
||||
[emisora('huerfana')],
|
||||
[a],
|
||||
[b],
|
||||
[a, b],
|
||||
],
|
||||
];
|
||||
for (final escenario in escenarios) {
|
||||
final actual = escenario[0].single;
|
||||
final favoritos = escenario[1];
|
||||
final misEmisoras = escenario[2];
|
||||
final todas = escenario[3];
|
||||
final contexto = contextoParaSaltoEmisora(
|
||||
actual: actual,
|
||||
favoritos: favoritos,
|
||||
misEmisoras: misEmisoras,
|
||||
todas: todas,
|
||||
);
|
||||
final porContexto =
|
||||
contexto == null
|
||||
? const <Emisora>[]
|
||||
: resolverListaContexto(
|
||||
contexto: contexto,
|
||||
actual: actual,
|
||||
favoritos: favoritos,
|
||||
misEmisoras: misEmisoras,
|
||||
todas: todas,
|
||||
destacadas: const [],
|
||||
grupos: const [
|
||||
GrupoFavoritos(id: 'g-rock', nombre: 'Rock', orden: 1),
|
||||
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
|
||||
],
|
||||
);
|
||||
expect(
|
||||
porContexto,
|
||||
listaParaSaltoEmisora(
|
||||
actual: actual,
|
||||
favoritos: favoritos,
|
||||
misEmisoras: misEmisoras,
|
||||
todas: todas,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||
import 'package:pluriwave/servicios/contexto_reproduccion.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Contexto de reproducción — el «en qué lista estoy» que sobrevive a que el
|
||||
/// proceso muera.
|
||||
///
|
||||
/// Mismo molde headless-safe que `emisoras_destacadas.dart`: solo
|
||||
/// `shared_preferences` y modelos, jamás `EstadoRadio` ni un `ChangeNotifier`,
|
||||
/// porque este módulo tiene que leerse desde el motor sin árbol de widgets que
|
||||
/// levanta Android Auto.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
Emisora emisora(String uuid, {String grupo = GrupoFavoritos.sinAsignarId}) =>
|
||||
Emisora(
|
||||
uuid: uuid,
|
||||
nombre: uuid,
|
||||
url: 'https://example.com/$uuid',
|
||||
grupoFavoritosId: grupo,
|
||||
);
|
||||
|
||||
group('ContextoSalto — serialización', () {
|
||||
test('la clave de persistencia queda fijada literalmente', () {
|
||||
// Un rename silencioso aquí no rompe nada en compilación y deja al
|
||||
// conductor sin contexto tras actualizar: se fija a propósito.
|
||||
expect(claveContextoSalto, 'contexto_salto_v1');
|
||||
});
|
||||
|
||||
test('round trip de los tres tipos que llevan carga útil', () {
|
||||
for (final contexto in [
|
||||
const ContextoSalto.grupo('g-rock'),
|
||||
const ContextoSalto.favoritos(),
|
||||
const ContextoSalto.misEmisoras(),
|
||||
const ContextoSalto.todas(),
|
||||
const ContextoSalto.destacadas(['a', 'b', 'c']),
|
||||
]) {
|
||||
expect(ContextoSalto.desdeMapa(contexto.aMapa()), contexto);
|
||||
}
|
||||
});
|
||||
|
||||
test('un payload corrupto o ajeno devuelve null en vez de lanzar', () {
|
||||
expect(ContextoSalto.desdeMapa(const {}), isNull);
|
||||
expect(ContextoSalto.desdeMapa(const {'tipo': 'inventado'}), isNull);
|
||||
expect(
|
||||
ContextoSalto.desdeMapa(const {'tipo': 'grupoFavoritos'}),
|
||||
isNull,
|
||||
reason: 'un contexto de grupo sin id de grupo no resuelve a nada',
|
||||
);
|
||||
expect(
|
||||
ContextoSalto.desdeMapa(const {'tipo': 'destacadas', 'uuids': 7}),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('persistencia', () {
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test('round trip por disco', () async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await guardarContextoSalto(
|
||||
const ContextoSalto.grupo('g-rock'),
|
||||
prefs: prefs,
|
||||
);
|
||||
|
||||
expect(
|
||||
await contextoSaltoPersistido(prefs: prefs),
|
||||
const ContextoSalto.grupo('g-rock'),
|
||||
);
|
||||
});
|
||||
|
||||
test('sin nada persistido devuelve null', () async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(await contextoSaltoPersistido(prefs: prefs), isNull);
|
||||
});
|
||||
|
||||
test('un JSON ilegible degrada a null, nunca lanza: esto se lee desde un '
|
||||
'botón del volante', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveContextoSalto: 'esto no es json',
|
||||
});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(await contextoSaltoPersistido(prefs: prefs), isNull);
|
||||
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveContextoSalto: jsonEncode({'tipo': 'inventado'}),
|
||||
});
|
||||
expect(
|
||||
await contextoSaltoPersistido(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolverListaContexto — degradación del contexto recordado', () {
|
||||
final rock1 = emisora('rock1', grupo: 'g-rock');
|
||||
final rock2 = emisora('rock2', grupo: 'g-rock');
|
||||
final jazz1 = emisora('jazz1', grupo: 'g-jazz');
|
||||
final suelta = emisora('suelta');
|
||||
final favoritos = [rock1, jazz1, rock2, suelta];
|
||||
const grupos = [
|
||||
GrupoFavoritos(id: 'g-rock', nombre: 'Rock', orden: 1),
|
||||
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
|
||||
];
|
||||
|
||||
List<Emisora> resolver(
|
||||
ContextoSalto contexto,
|
||||
Emisora actual, {
|
||||
List<Emisora>? favs,
|
||||
List<GrupoFavoritos>? gruposVivos,
|
||||
List<Emisora> misEmisoras = const [],
|
||||
List<Emisora> todas = const [],
|
||||
List<Emisora> destacadas = const [],
|
||||
}) => resolverListaContexto(
|
||||
contexto: contexto,
|
||||
actual: actual,
|
||||
favoritos: favs ?? favoritos,
|
||||
misEmisoras: misEmisoras,
|
||||
todas: todas,
|
||||
destacadas: destacadas,
|
||||
grupos: gruposVivos ?? grupos,
|
||||
);
|
||||
|
||||
test('el grupo recordado se recorre con sus miembros VIVOS, no con el '
|
||||
'snapshot', () {
|
||||
final nuevo = emisora('rock3', grupo: 'g-rock');
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.grupo('g-rock'),
|
||||
rock1,
|
||||
favs: [rock1, jazz1, rock2, nuevo],
|
||||
),
|
||||
[rock1, rock2, nuevo],
|
||||
);
|
||||
});
|
||||
|
||||
test('el grupo recordado ya NO existe -> cae a todos los favoritos', () {
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.grupo('g-borrado'),
|
||||
rock1,
|
||||
gruposVivos: const [
|
||||
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
|
||||
],
|
||||
),
|
||||
favoritos,
|
||||
);
|
||||
});
|
||||
|
||||
test('el grupo SIGUE VIVO con un solo miembro -> se honra igual: un grupo '
|
||||
'de una emisora sigue siendo el grupo que eligió el conductor', () {
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.grupo('g-jazz'),
|
||||
jazz1,
|
||||
),
|
||||
[jazz1],
|
||||
);
|
||||
});
|
||||
|
||||
test('el grupo sigue vivo pero la emisora que suena ya NO pertenece a él '
|
||||
'-> se PERMANECE en el grupo (el llamador coge su primera emisora)',
|
||||
() {
|
||||
expect(
|
||||
resolver(const ContextoSalto.grupo('g-rock'), jazz1),
|
||||
[rock1, rock2],
|
||||
);
|
||||
});
|
||||
|
||||
test('el grupo sigue vivo pero se quedó VACÍO -> no hay primera emisora '
|
||||
'que coger, así que se ensancha a todos los favoritos', () {
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.grupo('g-rock'),
|
||||
jazz1,
|
||||
favs: [jazz1, suelta],
|
||||
),
|
||||
[jazz1, suelta],
|
||||
);
|
||||
});
|
||||
|
||||
test('el grupo fue borrado y la emisora ya NO es favorita -> aun así se '
|
||||
'cae a los favoritos: el llamador elegirá una de ellas', () {
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.grupo('g-borrado'),
|
||||
emisora('fuera'),
|
||||
gruposVivos: const [],
|
||||
),
|
||||
favoritos,
|
||||
);
|
||||
});
|
||||
|
||||
test('no quedan favoritos -> lista vacía: el comportamiento de siempre '
|
||||
'cuando no hay emisoras agregadas', () {
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.grupo('g-rock'),
|
||||
emisora('fuera'),
|
||||
favs: const [],
|
||||
gruposVivos: const [],
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('la emisora salió de favoritos por completo -> el contexto de '
|
||||
'FAVORITOS se descarta', () {
|
||||
expect(
|
||||
resolver(const ContextoSalto.favoritos(), emisora('fuera')),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
|
||||
test('favoritos / misEmisoras / todas se resuelven contra su lista viva', () {
|
||||
expect(resolver(const ContextoSalto.favoritos(), rock1), favoritos);
|
||||
final propia = emisora('propia');
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.misEmisoras(),
|
||||
propia,
|
||||
misEmisoras: [propia, suelta],
|
||||
),
|
||||
[propia, suelta],
|
||||
);
|
||||
final catalogo = emisora('catalogo');
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.todas(),
|
||||
catalogo,
|
||||
todas: [catalogo, rock1],
|
||||
),
|
||||
[catalogo, rock1],
|
||||
);
|
||||
});
|
||||
|
||||
test('destacadas respeta el ORDEN CONGELADO, que es la razón de existir '
|
||||
'del snapshot: la lista viva se reordena sola en cada lectura', () {
|
||||
final fip = emisora('fip');
|
||||
final soma = emisora('soma');
|
||||
final ajena = emisora('ajena');
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.destacadas(['ajena', 'fip', 'soma']),
|
||||
ajena,
|
||||
destacadas: [fip, soma],
|
||||
),
|
||||
[ajena, fip, soma],
|
||||
reason:
|
||||
'la emisora que suena entra en la lista aunque no esté en el set '
|
||||
'curado; si no, ambos botones morirían',
|
||||
);
|
||||
});
|
||||
|
||||
test('destacadas: un uuid del snapshot que ya no resuelve se descarta', () {
|
||||
final fip = emisora('fip');
|
||||
final soma = emisora('soma');
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.destacadas(['fip', 'retirada', 'soma']),
|
||||
fip,
|
||||
destacadas: [fip, soma],
|
||||
),
|
||||
[fip, soma],
|
||||
);
|
||||
});
|
||||
|
||||
test('destacadas: si la emisora que suena no está en el snapshot el '
|
||||
'contexto se descarta', () {
|
||||
final fip = emisora('fip');
|
||||
expect(
|
||||
resolver(
|
||||
const ContextoSalto.destacadas(['fip']),
|
||||
emisora('otra'),
|
||||
destacadas: [fip],
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('uuidsCongeladosDestacadas', () {
|
||||
test('respeta el orden curado y antepone la emisora que suena cuando no '
|
||||
'pertenece al set', () {
|
||||
final fip = emisora('fip');
|
||||
final soma = emisora('soma');
|
||||
expect(
|
||||
uuidsCongeladosDestacadas(actual: fip, destacadas: [fip, soma]),
|
||||
['fip', 'soma'],
|
||||
);
|
||||
expect(
|
||||
uuidsCongeladosDestacadas(
|
||||
actual: emisora('ajena'),
|
||||
destacadas: [fip, soma],
|
||||
),
|
||||
['ajena', 'fip', 'soma'],
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Free-tier featured set (fix/auto-quality-guidelines, item 6).
|
||||
///
|
||||
/// The whole compliance story rests on this: a Play reviewer on a fresh
|
||||
/// install is ALWAYS free tier, has no network catalogue snapshot, no
|
||||
/// favourites, no custom stations and no `ultima_emisora_v1` — so the free
|
||||
/// root's single folder MUST still resolve to real, playable stations from
|
||||
/// nothing but the binary itself.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const ultima = Emisora(
|
||||
uuid: 'uuid-ultima',
|
||||
nombre: 'Ultima escuchada',
|
||||
url: 'https://ultima.example/stream',
|
||||
);
|
||||
|
||||
group('resolverEmisorasDestacadas', () {
|
||||
test('cold bind: sin red, sin EstadoRadio y con prefs vacías devuelve '
|
||||
'>= 3 emisoras reales', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
final destacadas = await resolverEmisorasDestacadas();
|
||||
|
||||
expect(destacadas.length, greaterThanOrEqualTo(3));
|
||||
expect(
|
||||
destacadas.every((e) => e.uuid.isNotEmpty),
|
||||
isTrue,
|
||||
reason: 'un uuid vacío no se puede resolver desde emisora:<uuid>',
|
||||
);
|
||||
expect(
|
||||
destacadas.every(
|
||||
(e) => e.url.startsWith('http://') || e.url.startsWith('https://'),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
destacadas.map((e) => e.uuid).toSet().length,
|
||||
destacadas.length,
|
||||
reason: 'uuids duplicados romperían porUuid',
|
||||
);
|
||||
});
|
||||
|
||||
test('con ultima_emisora_v1 presente: va PRIMERA y no se duplica',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveUltimaEmisora: jsonEncode(ultima.toMap()),
|
||||
});
|
||||
|
||||
final destacadas = await resolverEmisorasDestacadas();
|
||||
|
||||
expect(destacadas.first.uuid, ultima.uuid);
|
||||
expect(
|
||||
destacadas.where((e) => e.uuid == ultima.uuid).length,
|
||||
1,
|
||||
reason: 'la última escuchada no puede aparecer dos veces',
|
||||
);
|
||||
expect(destacadas.length, emisorasDestacadas.length + 1);
|
||||
});
|
||||
|
||||
test('la última escuchada YA curada no añade una segunda fila', () async {
|
||||
final yaCurada = emisorasDestacadas.first;
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveUltimaEmisora: jsonEncode(yaCurada.toMap()),
|
||||
});
|
||||
|
||||
final destacadas = await resolverEmisorasDestacadas();
|
||||
|
||||
expect(destacadas.first.uuid, yaCurada.uuid);
|
||||
expect(destacadas.length, emisorasDestacadas.length);
|
||||
});
|
||||
|
||||
test('ultima_emisora_v1 corrupta degrada al set curado, nunca lanza',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveUltimaEmisora: 'no-es-json{{',
|
||||
});
|
||||
|
||||
final destacadas = await resolverEmisorasDestacadas();
|
||||
|
||||
expect(destacadas.length, emisorasDestacadas.length);
|
||||
});
|
||||
});
|
||||
|
||||
group('esEmisoraGratuitaPorUuid', () {
|
||||
test('un uuid curado es gratuito', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
expect(
|
||||
await esEmisoraGratuitaPorUuid(emisorasDestacadas.first.uuid),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('la última escuchada es gratuita aunque no esté curada', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveUltimaEmisora: jsonEncode(ultima.toMap()),
|
||||
});
|
||||
|
||||
expect(await esEmisoraGratuitaPorUuid(ultima.uuid), isTrue);
|
||||
});
|
||||
|
||||
test('un uuid del catálogo Radio Browser NO es gratuito', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
expect(await esEmisoraGratuitaPorUuid('uuid-del-catalogo'), isFalse);
|
||||
});
|
||||
|
||||
test('uuid vacío nunca es gratuito', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
expect(await esEmisoraGratuitaPorUuid(''), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
test('claveUltimaEmisora coincide con la que persiste EstadoRadio', () {
|
||||
expect(claveUltimaEmisora, 'ultima_emisora_v1');
|
||||
});
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Free-tier Android Auto surface (fix/auto-quality-guidelines, items 7, 8
|
||||
/// and 10).
|
||||
///
|
||||
/// Google Play returned "Approved with Issues" against the Android for Cars
|
||||
/// App Quality Guidelines on version code 157. The free root advertised four
|
||||
/// folders that each dead-ended on a single non-playable "Función Premium"
|
||||
/// row, and on a cold headless bind every one of the underlying lists is
|
||||
/// empty anyway. This suite pins the replacement: ONE browsable folder that
|
||||
/// resolves to real, playable stations.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('FuenteEmisorasAutoLocal.porUuid — item 7', () {
|
||||
// Cold bind shape: `todas()` is `_snapshotTodas ?? const []`, no
|
||||
// favourites (sqflite is not initialised under `flutter test`, so the
|
||||
// read throws and degrades to `[]`), and a custom-stations path that
|
||||
// does not exist.
|
||||
FuenteEmisorasAutoLocal fuenteFria() => FuenteEmisorasAutoLocal(
|
||||
resolverRutaCustom: () async => 'no/existe/emisoras_custom.json',
|
||||
);
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test('en frío resuelve un uuid destacado (antes devolvía null y la fila '
|
||||
'no hacía nada al tocarla)', () async {
|
||||
final fuente = fuenteFria();
|
||||
|
||||
final resuelta = await fuente.porUuid(emisorasDestacadas.first.uuid);
|
||||
|
||||
expect(resuelta, isNotNull);
|
||||
expect(resuelta!.url, emisorasDestacadas.first.url);
|
||||
});
|
||||
|
||||
test('en frío resuelve la última escuchada persistida', () async {
|
||||
const ultima = Emisora(
|
||||
uuid: 'uuid-ultima',
|
||||
nombre: 'Ultima',
|
||||
url: 'https://ultima.example/stream',
|
||||
);
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveUltimaEmisora:
|
||||
'{"uuid":"uuid-ultima","nombre":"Ultima",'
|
||||
'"url":"https://ultima.example/stream"}',
|
||||
});
|
||||
|
||||
final resuelta = await fuenteFria().porUuid(ultima.uuid);
|
||||
|
||||
expect(resuelta?.url, ultima.url);
|
||||
});
|
||||
|
||||
test('un uuid desconocido sigue devolviendo null', () async {
|
||||
expect(await fuenteFria().porUuid('uuid-inexistente'), isNull);
|
||||
});
|
||||
|
||||
test('el snapshot vivo gana al set destacado para el MISMO uuid',
|
||||
() async {
|
||||
final fuente = fuenteFria();
|
||||
final delCatalogo = Emisora(
|
||||
uuid: emisorasDestacadas.first.uuid,
|
||||
nombre: 'Version viva',
|
||||
url: 'https://viva.example/stream',
|
||||
);
|
||||
fuente.actualizarSnapshot(todas: [delCatalogo]);
|
||||
|
||||
final resuelta = await fuente.porUuid(delCatalogo.uuid);
|
||||
|
||||
expect(resuelta?.url, 'https://viva.example/stream');
|
||||
});
|
||||
});
|
||||
|
||||
group('raiz(premium:) — item 8', () {
|
||||
test('free: exactamente UNA carpeta navegable, y ninguna de las cuatro '
|
||||
'que morían en la fila premium', () {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
|
||||
// `incluirMusicaLocal: true` a propósito: ni siquiera con carpeta
|
||||
// local configurada puede el tier gratuito ver ese nodo.
|
||||
final libre = constructor.raiz(incluirMusicaLocal: true, premium: false);
|
||||
|
||||
expect(libre, hasLength(1));
|
||||
expect(libre.single.id, ConstructorArbolAuto.idDestacadas);
|
||||
expect(libre.single.playable, isFalse);
|
||||
expect(libre.single.title, isNotEmpty);
|
||||
expect(
|
||||
libre.map((m) => m.id),
|
||||
isNot(
|
||||
anyOf(
|
||||
contains(ConstructorArbolAuto.idFavoritos),
|
||||
contains(ConstructorArbolAuto.idTodas),
|
||||
contains(ConstructorArbolAuto.idMisEmisoras),
|
||||
contains(ConstructorArbolAuto.idMusicaLocal),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('premium: el árbol de hoy, sin cambios (guardia de regresión)', () {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
|
||||
expect(
|
||||
constructor
|
||||
.raiz(incluirMusicaLocal: true, premium: true)
|
||||
.map((m) => m.id),
|
||||
[
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
],
|
||||
);
|
||||
expect(
|
||||
constructor
|
||||
.raiz(incluirMusicaLocal: false, premium: true)
|
||||
.map((m) => m.id),
|
||||
[
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
],
|
||||
);
|
||||
expect(
|
||||
constructor
|
||||
.raiz(incluirMusicaLocal: true, premium: true)
|
||||
.every((m) => m.playable == false),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('la raíz del tier gratuito SIEMPRE lleva una carpeta navegable: '
|
||||
'audio_service 0.18.18 descarta los rootHints, así que un root de '
|
||||
'un solo item PLAYABLE se renderiza vacío en una unidad que solo '
|
||||
'acepta FLAG_BROWSABLE', () {
|
||||
final libre = ConstructorArbolAuto().raiz(
|
||||
incluirMusicaLocal: false,
|
||||
premium: false,
|
||||
);
|
||||
|
||||
expect(libre.any((m) => m.playable == false), isTrue);
|
||||
});
|
||||
|
||||
test('el titulo de la unica carpeta gratuita lo decide el LLAMANTE, no '
|
||||
'una constante castellana de este archivo (hallazgo 4)', () {
|
||||
final libre = ConstructorArbolAuto().raiz(
|
||||
incluirMusicaLocal: false,
|
||||
premium: false,
|
||||
tituloDestacadas: 'Listen',
|
||||
);
|
||||
|
||||
expect(
|
||||
libre.single.title,
|
||||
'Listen',
|
||||
reason:
|
||||
'this one label is 100% of the browse tree a free-tier (i.e. '
|
||||
'every Play reviewer) driver ever sees; the pure builder stays '
|
||||
'AppLocalizations-free, so the handler has to hand it the string',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('hijosDestacadas — item 8/9', () {
|
||||
test('mapea a items PLAYABLE con id emisora:<uuid>', () {
|
||||
final items = ConstructorArbolAuto().hijosDestacadas(emisorasDestacadas);
|
||||
|
||||
expect(items, hasLength(emisorasDestacadas.length));
|
||||
expect(items.every((m) => m.playable == true), isTrue);
|
||||
expect(items.first.id, 'emisora:${emisorasDestacadas.first.uuid}');
|
||||
expect(items.every((m) => m.artUri != null), isTrue);
|
||||
});
|
||||
|
||||
test('lista vacía devuelve lista vacía, nunca lanza', () {
|
||||
expect(ConstructorArbolAuto().hijosDestacadas(const []), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('respuestaBloqueadaPorEntitlement — item 10', () {
|
||||
List<MediaItem>? gate(String id, {required bool premium}) =>
|
||||
respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: id,
|
||||
premium: premium,
|
||||
destacadas: emisorasDestacadas,
|
||||
);
|
||||
|
||||
test('premium: nada se bloquea', () {
|
||||
for (final id in [
|
||||
AudioService.browsableRootId,
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
ConstructorArbolAuto.idDestacadas,
|
||||
'emisora:uuid-del-catalogo',
|
||||
]) {
|
||||
expect(gate(id, premium: true), isNull, reason: id);
|
||||
}
|
||||
});
|
||||
|
||||
test('free: la raíz y el contenido gratuito PASAN', () {
|
||||
expect(gate(AudioService.browsableRootId, premium: false), isNull);
|
||||
expect(gate(ConstructorArbolAuto.idDestacadas, premium: false), isNull);
|
||||
for (final e in emisorasDestacadas) {
|
||||
expect(gate('emisora:${e.uuid}', premium: false), isNull);
|
||||
}
|
||||
});
|
||||
|
||||
test('free: el catálogo premium se bloquea', () {
|
||||
for (final id in [
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idEcualizador,
|
||||
'grupo:algo',
|
||||
'emisora:uuid-del-catalogo',
|
||||
'pista:doc-id',
|
||||
]) {
|
||||
expect(gate(id, premium: false), isNotNull, reason: id);
|
||||
}
|
||||
});
|
||||
|
||||
test('la rama bloqueada devuelve el contenido gratuito, NUNCA una fila '
|
||||
'no reproducible: eso es exactamente lo que Play citó', () {
|
||||
final bloqueada = gate(ConstructorArbolAuto.idTodas, premium: false);
|
||||
|
||||
expect(bloqueada, isNotNull);
|
||||
expect(bloqueada, isNotEmpty);
|
||||
expect(
|
||||
bloqueada!.every((m) => m.playable == true),
|
||||
isTrue,
|
||||
reason: 'una fila no reproducible en el árbol es la cita de Play',
|
||||
);
|
||||
expect(bloqueada.map((m) => m.id), [
|
||||
for (final e in emisorasDestacadas) 'emisora:${e.uuid}',
|
||||
]);
|
||||
});
|
||||
|
||||
test('sin destacadas resolubles la rama bloqueada sigue sin inventar una '
|
||||
'fila muerta', () {
|
||||
final bloqueada = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: ConstructorArbolAuto.idTodas,
|
||||
premium: false,
|
||||
destacadas: const [],
|
||||
);
|
||||
|
||||
expect(bloqueada, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('idPremiumInfo / itemPremiumBloqueado ya no existen — item 10', () {
|
||||
// Guardia estructural: si alguien los reintroduce, este archivo deja de
|
||||
// compilar por el `expect` de abajo, no por un comentario. La única
|
||||
// prueba real es que `ConstructorArbolAuto` no expone ningún item no
|
||||
// reproducible fuera de las carpetas.
|
||||
final libre = ConstructorArbolAuto().raiz(
|
||||
incluirMusicaLocal: true,
|
||||
premium: false,
|
||||
);
|
||||
|
||||
expect(libre.every((m) => m.id != 'premium:info'), isTrue);
|
||||
});
|
||||
}
|
||||
@@ -1,142 +1,106 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
|
||||
/// Android Auto entitlement gating — the id-shape matrix.
|
||||
///
|
||||
/// REWRITTEN for fix/auto-quality-guidelines item 10. This suite used to
|
||||
/// assert the opposite design: that every non-root id, for a free-tier user,
|
||||
/// collapsed to a single non-playable `premium:info` row. Google Play cited
|
||||
/// that browse tree against the Android for Cars App Quality Guidelines, so
|
||||
/// the contract is now content-scoping — the free tier sees LESS, never a
|
||||
/// row that does nothing.
|
||||
///
|
||||
/// [respuestaBloqueadaPorEntitlement]'s return VALUE is covered in
|
||||
/// `navegacion_auto_destacadas_test.dart`; this file pins the decision
|
||||
/// surface ([idPermitidoEnFree]) across every id shape the tree can produce,
|
||||
/// including the stale/deep-linked ones a head unit's cached tree replays.
|
||||
/// Android Auto entitlement gating (android-auto-media spec "Free-Tier
|
||||
/// Reduced Root Browse" + "Free-Tier Browse Never Leaks Real Content",
|
||||
/// design.md ADR-4). All pure — no handler instantiation needed
|
||||
/// (`PluriWaveAudioHandler` cannot be constructed in a unit test).
|
||||
void main() {
|
||||
const gratuitas = [
|
||||
Emisora(uuid: 'libre-1', nombre: 'Libre 1', url: 'https://libre1.example'),
|
||||
Emisora(uuid: 'libre-2', nombre: 'Libre 2', url: 'https://libre2.example'),
|
||||
];
|
||||
group('raiz(premium:) — root keeps its labels for every tier', () {
|
||||
test('premium: identical to today\'s tree (regression guard)', () {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
|
||||
group('idPermitidoEnFree', () {
|
||||
test('la raíz siempre pasa: es lo único que decide qué ve el tier', () {
|
||||
expect(
|
||||
idPermitidoEnFree(
|
||||
AudioService.browsableRootId,
|
||||
destacadas: gratuitas,
|
||||
),
|
||||
isTrue,
|
||||
final premiumConLocal = constructor.raiz(
|
||||
incluirMusicaLocal: true,
|
||||
premium: true,
|
||||
);
|
||||
});
|
||||
|
||||
test('la carpeta gratuita pasa', () {
|
||||
expect(
|
||||
idPermitidoEnFree(
|
||||
ConstructorArbolAuto.idDestacadas,
|
||||
destacadas: gratuitas,
|
||||
),
|
||||
isTrue,
|
||||
final premiumSinLocal = constructor.raiz(
|
||||
incluirMusicaLocal: false,
|
||||
premium: true,
|
||||
);
|
||||
|
||||
expect(premiumConLocal.map((m) => m.id), [
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
]);
|
||||
expect(premiumConLocal.every((m) => m.playable == false), isTrue);
|
||||
expect(premiumConLocal.every((m) => m.displaySubtitle == null), isTrue);
|
||||
expect(premiumSinLocal.map((m) => m.id), [
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
]);
|
||||
});
|
||||
|
||||
test('un emisora:<uuid> del set gratuito pasa', () {
|
||||
for (final e in gratuitas) {
|
||||
expect(
|
||||
idPermitidoEnFree('emisora:${e.uuid}', destacadas: gratuitas),
|
||||
isTrue,
|
||||
reason: e.uuid,
|
||||
);
|
||||
}
|
||||
test('free: same folder ids/titles, non-blank, never playable', () {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
|
||||
final libre = constructor.raiz(incluirMusicaLocal: true, premium: false);
|
||||
|
||||
expect(libre, isNotEmpty);
|
||||
expect(libre.map((m) => m.id), [
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
]);
|
||||
expect(libre.every((m) => m.playable == false), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'itemPremiumBloqueado(): id fijo, no reproducible, etiqueta premium',
|
||||
() {
|
||||
final item = ConstructorArbolAuto().itemPremiumBloqueado();
|
||||
|
||||
expect(item.id, 'premium:info');
|
||||
expect(item.playable, isFalse);
|
||||
expect(item.title, isNotEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
group('respuestaBloqueadaPorEntitlement — backstop de navegacion', () {
|
||||
test('root nunca es bloqueada (root siempre resuelve via raiz)', () {
|
||||
final respuesta = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: AudioService.browsableRootId,
|
||||
premium: false,
|
||||
);
|
||||
|
||||
expect(respuesta, isNull);
|
||||
});
|
||||
|
||||
test('las carpetas premium NO pasan', () {
|
||||
test('cualquier id no-root, en free, retorna SOLO el item bloqueado', () {
|
||||
for (final id in [
|
||||
ConstructorArbolAuto.idFavoritos,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
ConstructorArbolAuto.idMisEmisoras,
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
ConstructorArbolAuto.idEcualizador,
|
||||
// Stale/deep-linked id from before a downgrade — the backstop must
|
||||
// not special-case known ids (Spec "Stale folder id bypass
|
||||
// attempt").
|
||||
'emisora:algun-uuid-viejo',
|
||||
'grupo:algo',
|
||||
]) {
|
||||
expect(idPermitidoEnFree(id, destacadas: gratuitas), isFalse,
|
||||
reason: id);
|
||||
}
|
||||
});
|
||||
|
||||
test('un id rancio/deep-link de antes de una bajada de tier NO pasa: '
|
||||
'ésa es la propiedad de seguridad que el rediseño tenía que '
|
||||
'conservar', () {
|
||||
for (final id in [
|
||||
'emisora:uuid-del-catalogo',
|
||||
'grupo:algun-grupo',
|
||||
'pista:doc-id',
|
||||
'carpeta_local:doc-id',
|
||||
'carpeta_local_reproducir:doc-id',
|
||||
'carpeta_local_aleatorio:doc-id',
|
||||
'eq_preset:Rock',
|
||||
'premium:info', // la fila muerta que ya no existe
|
||||
'',
|
||||
]) {
|
||||
expect(idPermitidoEnFree(id, destacadas: gratuitas), isFalse,
|
||||
reason: id);
|
||||
}
|
||||
});
|
||||
|
||||
test('emisora: con uuid vacío NO pasa (id malformado, no comodín)', () {
|
||||
expect(idPermitidoEnFree('emisora:', destacadas: gratuitas), isFalse);
|
||||
});
|
||||
|
||||
test('con el set gratuito vacío solo pasan la raíz y su carpeta', () {
|
||||
expect(
|
||||
idPermitidoEnFree(AudioService.browsableRootId, destacadas: const []),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
idPermitidoEnFree(
|
||||
ConstructorArbolAuto.idDestacadas,
|
||||
destacadas: const [],
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
idPermitidoEnFree('emisora:libre-1', destacadas: const []),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('respuestaBloqueadaPorEntitlement', () {
|
||||
test('premium: ningún id se bloquea, ni siquiera uno inventado', () {
|
||||
for (final id in [
|
||||
AudioService.browsableRootId,
|
||||
ConstructorArbolAuto.idTodas,
|
||||
'emisora:cualquier-cosa',
|
||||
'basura',
|
||||
]) {
|
||||
expect(
|
||||
respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: id,
|
||||
premium: true,
|
||||
destacadas: gratuitas,
|
||||
),
|
||||
isNull,
|
||||
reason: id,
|
||||
final respuesta = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: id,
|
||||
premium: false,
|
||||
);
|
||||
expect(respuesta, hasLength(1));
|
||||
expect(respuesta!.single.id, 'premium:info');
|
||||
}
|
||||
});
|
||||
|
||||
test('free: lo bloqueado NUNCA incluye un item no reproducible', () {
|
||||
final bloqueada = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: ConstructorArbolAuto.idMisEmisoras,
|
||||
premium: false,
|
||||
destacadas: gratuitas,
|
||||
test('cualquier id no-root, en premium, no es bloqueada', () {
|
||||
final respuesta = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: ConstructorArbolAuto.idFavoritos,
|
||||
premium: true,
|
||||
);
|
||||
|
||||
expect(bloqueada, isNotNull);
|
||||
expect(bloqueada!.every((m) => m.playable == true), isTrue);
|
||||
expect(respuesta, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
import 'dart:ui' show Locale;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/pista_local.dart';
|
||||
import 'package:pluriwave/servicios/musica_local_auto.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
/// Every user-readable label of the Android Auto browse tree is translated.
|
||||
///
|
||||
/// The owner's rule, after Play saw a Spanish-only car tree on a head unit
|
||||
/// in any of the 13 shipped locales: anything a user can read gets
|
||||
/// translated. The two ARB guards (`arb_parity_test`/`arb_anti_copy_test`)
|
||||
/// only ever see strings that already entered the ARB system, so neither
|
||||
/// could catch a label hardcoded in `navegacion_auto.dart` that never
|
||||
/// became a key. This file closes that hole from the CONSUMPTION side
|
||||
/// (the tree really renders the injected locale);
|
||||
/// `test/l10n/etiquetas_arbol_auto_test.dart` closes it from the SOURCE
|
||||
/// side (no new hardcoded label can be added at all).
|
||||
Future<Map<String, MetadatosPista>> _sinMetadatos(List<String> ids) async =>
|
||||
const {};
|
||||
|
||||
List<NodoLocal> _pistas(int cuantas, {String prefijo = 'cancion'}) =>
|
||||
List.generate(
|
||||
cuantas,
|
||||
(i) => NodoLocal(
|
||||
documentId: 'doc-$prefijo-$i',
|
||||
nombre: '${prefijo}_${i.toString().padLeft(3, '0')}.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
final ingles = lookupAppLocalizations(const Locale('en'));
|
||||
|
||||
group('EtiquetasArbolAuto desde AppLocalizations', () {
|
||||
test('mapea cada etiqueta del árbol a su clave ARB del locale', () {
|
||||
final etiquetas = etiquetasArbolAutoDesde(ingles);
|
||||
|
||||
expect(etiquetas.escuchar, ingles.autoCarpetaEscuchar);
|
||||
expect(etiquetas.favoritos, ingles.autoCarpetaFavoritos);
|
||||
expect(etiquetas.todasLasEmisoras, ingles.autoCarpetaTodas);
|
||||
expect(etiquetas.misEmisoras, ingles.autoCarpetaMisEmisoras);
|
||||
expect(etiquetas.musicaLocal, ingles.autoCarpetaMusicaLocal);
|
||||
expect(
|
||||
etiquetas.musicaLocalNoDisponible,
|
||||
ingles.autoMusicaLocalNoDisponible,
|
||||
);
|
||||
expect(etiquetas.cargarMas, ingles.autoCargarMas);
|
||||
expect(etiquetas.ordenarPorCalidad, ingles.autoOrdenarPorCalidad);
|
||||
expect(etiquetas.reproducirCarpeta, ingles.autoReproducirCarpeta);
|
||||
expect(etiquetas.reproducirAleatorio, ingles.autoReproducirAleatorio);
|
||||
expect(etiquetas.pistaSinNombre, ingles.autoPistaSinNombre);
|
||||
});
|
||||
|
||||
test('ninguna etiqueta inglesa cae en el castellano de respaldo', () {
|
||||
final etiquetas = etiquetasArbolAutoDesde(ingles);
|
||||
const respaldo = EtiquetasArbolAuto.respaldo;
|
||||
|
||||
expect(etiquetas.favoritos, isNot(respaldo.favoritos));
|
||||
expect(etiquetas.todasLasEmisoras, isNot(respaldo.todasLasEmisoras));
|
||||
expect(etiquetas.misEmisoras, isNot(respaldo.misEmisoras));
|
||||
expect(etiquetas.musicaLocal, isNot(respaldo.musicaLocal));
|
||||
expect(
|
||||
etiquetas.musicaLocalNoDisponible,
|
||||
isNot(respaldo.musicaLocalNoDisponible),
|
||||
);
|
||||
expect(etiquetas.cargarMas, isNot(respaldo.cargarMas));
|
||||
expect(etiquetas.ordenarPorCalidad, isNot(respaldo.ordenarPorCalidad));
|
||||
expect(etiquetas.reproducirCarpeta, isNot(respaldo.reproducirCarpeta));
|
||||
expect(etiquetas.reproducirAleatorio, isNot(respaldo.reproducirAleatorio));
|
||||
expect(etiquetas.pistaSinNombre, isNot(respaldo.pistaSinNombre));
|
||||
});
|
||||
});
|
||||
|
||||
group('ConstructorArbolAuto rotula con las etiquetas inyectadas', () {
|
||||
final constructor = ConstructorArbolAuto(
|
||||
etiquetas: etiquetasArbolAutoDesde(ingles),
|
||||
);
|
||||
|
||||
test('la raíz premium rotula sus cuatro carpetas en el locale', () {
|
||||
final raiz = constructor.raiz(incluirMusicaLocal: true, premium: true);
|
||||
|
||||
expect(raiz.map((i) => i.title).toList(), [
|
||||
ingles.autoCarpetaFavoritos,
|
||||
ingles.autoCarpetaTodas,
|
||||
ingles.autoCarpetaMisEmisoras,
|
||||
ingles.autoCarpetaMusicaLocal,
|
||||
]);
|
||||
});
|
||||
|
||||
test('la raíz gratuita sigue rotulando Escuchar en el locale', () {
|
||||
final raiz = constructor.raiz(incluirMusicaLocal: false, premium: false);
|
||||
|
||||
expect(raiz.single.title, ingles.autoCarpetaEscuchar);
|
||||
});
|
||||
|
||||
test('el item de música local no disponible va en el locale', () {
|
||||
expect(
|
||||
constructor.itemLocalNoDisponible().title,
|
||||
ingles.autoMusicaLocalNoDisponible,
|
||||
);
|
||||
});
|
||||
|
||||
test('las acciones de carpeta y la entrada de orden van en el '
|
||||
'locale', () async {
|
||||
final items = await constructor.itemsLocales(
|
||||
_pistas(3),
|
||||
documentIdPadre: 'padre',
|
||||
metadatosDe: _sinMetadatos,
|
||||
);
|
||||
|
||||
final titulos = items.map((i) => i.title).toList();
|
||||
expect(titulos, contains(ingles.autoReproducirCarpeta));
|
||||
expect(titulos, contains(ingles.autoReproducirAleatorio));
|
||||
expect(titulos, contains(ingles.autoOrdenarPorCalidad));
|
||||
});
|
||||
|
||||
test('el item "cargar más" de las tres vistas paginadas va en el '
|
||||
'locale', () async {
|
||||
final nodos = _pistas(60);
|
||||
|
||||
final porNombre = await constructor.itemsLocales(
|
||||
nodos,
|
||||
documentIdPadre: 'padre',
|
||||
metadatosDe: _sinMetadatos,
|
||||
);
|
||||
final porCalidad = await constructor.itemsLocalesOrdenCalidad(
|
||||
nodos,
|
||||
documentIdPadre: 'padre',
|
||||
metadatosDe: _sinMetadatos,
|
||||
);
|
||||
final porBucket = await constructor.itemsLocalesBucket(
|
||||
_pistas(60, prefijo: 'apple'),
|
||||
documentIdPadre: 'padre',
|
||||
idxBucket: 0,
|
||||
metadatosDe: _sinMetadatos,
|
||||
);
|
||||
|
||||
expect(porNombre.last.title, ingles.autoCargarMas);
|
||||
expect(porCalidad.last.title, ingles.autoCargarMas);
|
||||
expect(porBucket.last.title, ingles.autoCargarMas);
|
||||
});
|
||||
|
||||
test('un nombre de fichero en blanco cae en la pista sin nombre del '
|
||||
'locale', () async {
|
||||
final items = await constructor.itemsLocales(
|
||||
const [
|
||||
NodoLocal(
|
||||
documentId: 'doc-vacio',
|
||||
nombre: ' ',
|
||||
esDirectorio: false,
|
||||
),
|
||||
],
|
||||
documentIdPadre: 'padre',
|
||||
metadatosDe: _sinMetadatos,
|
||||
);
|
||||
|
||||
final pista = items.singleWhere((i) => i.id.startsWith('pista:'));
|
||||
expect(pista.title, ingles.autoPistaSinNombre);
|
||||
});
|
||||
|
||||
test('los rangos alfabéticos NO se traducen: son rangos de letras '
|
||||
'latinas, no prosa', () async {
|
||||
final items = await constructor.itemsLocales(
|
||||
_pistas(60),
|
||||
documentIdPadre: 'padre',
|
||||
metadatosDe: _sinMetadatos,
|
||||
);
|
||||
|
||||
expect(items.map((i) => i.title), containsAll(['A-F', 'G-M']));
|
||||
});
|
||||
});
|
||||
|
||||
group('hijosMusicaLocal propaga las etiquetas', () {
|
||||
test('la carpeta raíz local rotula sus acciones en el locale', () async {
|
||||
final items = await hijosMusicaLocal(
|
||||
ConstructorArbolAuto.idMusicaLocal,
|
||||
fuente: _FuenteLocalFalsa(),
|
||||
etiquetas: etiquetasArbolAutoDesde(ingles),
|
||||
);
|
||||
|
||||
expect(
|
||||
items!.map((i) => i.title),
|
||||
containsAll([
|
||||
ingles.autoReproducirCarpeta,
|
||||
ingles.autoReproducirAleatorio,
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Minimal in-memory [FuenteMusicaLocalAuto]: one playable track at the
|
||||
/// tree root, no metadata, native channel available.
|
||||
class _FuenteLocalFalsa implements FuenteMusicaLocalAuto {
|
||||
@override
|
||||
Future<EstadoCarpetaLocal> estadoCarpeta() async =>
|
||||
EstadoCarpetaLocal.configurada;
|
||||
|
||||
@override
|
||||
Future<List<NodoLocal>> hijos(String documentId) async =>
|
||||
documentId.isEmpty
|
||||
? const [
|
||||
NodoLocal(
|
||||
documentId: 'doc-0',
|
||||
nombre: 'cancion.mp3',
|
||||
esDirectorio: false,
|
||||
),
|
||||
]
|
||||
: const [];
|
||||
|
||||
@override
|
||||
Future<Map<String, MetadatosPista>> metadatosDe(List<String> documentIds) =>
|
||||
_sinMetadatos(documentIds);
|
||||
|
||||
@override
|
||||
Future<String?> uriContenidoDePista(String documentId) async =>
|
||||
'content://fake/$documentId';
|
||||
}
|
||||
@@ -2135,11 +2135,9 @@ void main() {
|
||||
/// EXPLICAR el problema en vez de abrirse vacío (una carpeta vacía se
|
||||
/// lee como «no tengo música», que es justo la conclusión equivocada).
|
||||
///
|
||||
/// La etiqueta sale de `EtiquetasArbolAuto.musicaLocalNoDisponible`,
|
||||
/// como TODAS las etiquetas legibles del árbol del coche: todo lo que
|
||||
/// un usuario lee se traduce. Este test solo comprueba que hay UNA
|
||||
/// etiqueta no vacía; el idioma concreto lo cubre
|
||||
/// `navegacion_auto_localizacion_test.dart`.
|
||||
/// La etiqueta va en castellano hardcodeado, como TODAS las etiquetas
|
||||
/// del árbol del coche en `navegacion_auto.dart` (ver
|
||||
/// `itemPremiumBloqueado`): convención establecida, nunca `AppLocalizations`.
|
||||
test('canalNoDisponible y carpeta vacía: la raíz local devuelve un item '
|
||||
'explicativo NO reproducible, no una carpeta vacía', () async {
|
||||
final fuente = _FakeFuenteMusicaLocalAuto(
|
||||
|
||||
@@ -1,679 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' show Locale;
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/pista_local.dart';
|
||||
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
|
||||
import 'package:pluriwave/servicios/musica_local_auto.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/handlers_audio.dart';
|
||||
|
||||
/// Free-tier Android Auto surface, handler side (fix/auto-quality-guidelines,
|
||||
/// items 9, 11, 12, 13, 14).
|
||||
///
|
||||
/// Every test here runs with `_fuenteNavegacionGlobal` NEVER registered —
|
||||
/// this file never calls `registrarFuenteNavegacion`. That is the exact bind
|
||||
/// a Play reviewer performs: Android Auto starts the headless engine, and
|
||||
/// until (and even after) `main.dart` wires its sources, the car's browse and
|
||||
/// play paths must produce real, playable content out of the binary alone.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final crearHandler = registrarHandlersLiberables();
|
||||
|
||||
late _GuionReproductor guion;
|
||||
|
||||
setUp(() {
|
||||
guion = _GuionReproductor();
|
||||
PluriWaveAudioHandler.fabricaReproductorPrueba =
|
||||
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
|
||||
// Fresh install = free tier: `esPremiumPersistido` is
|
||||
// `getBool('compra_premium_v1') ?? false`, and there is no trial key.
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
|
||||
PluriWaveAudioHandler.lectorLocalePlataforma =
|
||||
PluriWaveAudioHandler.lectorLocalePlataformaPorDefecto;
|
||||
});
|
||||
|
||||
AppLocalizations textos() => lookupAppLocalizations(const Locale('es'));
|
||||
|
||||
group('debeBloquearCambioDeEmisora — item 11', () {
|
||||
test('free + emisora gratuita: NO bloquea', () {
|
||||
expect(
|
||||
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: true),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('free + emisora premium: bloquea (propiedad de seguridad original — '
|
||||
'un emisora:<uuid> rancio de antes de una bajada de tier no puede '
|
||||
'sonar)', () {
|
||||
expect(
|
||||
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: false),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('premium: nunca bloquea', () {
|
||||
expect(
|
||||
debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: false),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: true),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('getChildren — item 9', () {
|
||||
test('sin fuente de navegación registrada, la carpeta gratuita devuelve '
|
||||
'>= 3 items REPRODUCIBLES', () async {
|
||||
final handler = crearHandler();
|
||||
|
||||
final items = await handler.getChildren(
|
||||
ConstructorArbolAuto.idDestacadas,
|
||||
);
|
||||
|
||||
expect(items.length, greaterThanOrEqualTo(3));
|
||||
expect(items.every((m) => m.playable == true), isTrue);
|
||||
expect(items.every((m) => m.id.startsWith('emisora:')), isTrue);
|
||||
});
|
||||
|
||||
test('la raíz gratuita es UNA carpeta y no paga el round trip nativo de '
|
||||
'estadoCarpeta()', () async {
|
||||
final fuenteLocal = _FuenteMusicaLocalEspia();
|
||||
registrarFuenteMusicaLocal(fuenteLocal);
|
||||
final handler = crearHandler();
|
||||
|
||||
final raiz = await handler.getChildren(AudioService.browsableRootId);
|
||||
|
||||
expect(raiz.map((m) => m.id), [ConstructorArbolAuto.idDestacadas]);
|
||||
expect(
|
||||
fuenteLocal.llamadasEstadoCarpeta,
|
||||
0,
|
||||
reason:
|
||||
'estadoCarpeta() viaja por un MethodChannel que NO existe en el '
|
||||
'motor headless; el tier gratuito no puede ver Música Local, así '
|
||||
'que preguntarlo solo añade una vía de fallo en la raíz',
|
||||
);
|
||||
});
|
||||
|
||||
test('una carpeta premium NO devuelve una fila no reproducible: devuelve '
|
||||
'el contenido gratuito', () async {
|
||||
final handler = crearHandler();
|
||||
|
||||
final items = await handler.getChildren(ConstructorArbolAuto.idTodas);
|
||||
|
||||
expect(items, isNotEmpty);
|
||||
expect(items.every((m) => m.playable == true), isTrue);
|
||||
expect(items.every((m) => m.id != 'premium:info'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('playFromMediaId — item 12', () {
|
||||
test('free + uuid gratuito: SUENA', () async {
|
||||
final handler = crearHandler();
|
||||
final destacada = emisorasDestacadas.first;
|
||||
|
||||
await handler.playFromMediaId('emisora:${destacada.uuid}');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas, contains(destacada.url));
|
||||
});
|
||||
|
||||
test('free + uuid premium: publica error CON errorCode y errorMessage '
|
||||
'localizado, nunca vuelve en silencio', () async {
|
||||
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en');
|
||||
final handler = crearHandler();
|
||||
|
||||
await handler.playFromMediaId('emisora:uuid-del-catalogo');
|
||||
await pumpEventQueue();
|
||||
|
||||
final estado = handler.playbackState.value;
|
||||
expect(estado.processingState, AudioProcessingState.error);
|
||||
expect(estado.errorCode, isNotNull);
|
||||
expect(
|
||||
estado.errorMessage,
|
||||
lookupAppLocalizations(const Locale('en')).autoErrorEmisoraPremium,
|
||||
);
|
||||
expect(guion.urlsSolicitadas, isEmpty);
|
||||
});
|
||||
|
||||
test('free + pista local: bloqueada, y también publica el error', () async {
|
||||
final handler = crearHandler();
|
||||
|
||||
await handler.playFromMediaId('pista:doc-id');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
handler.playbackState.value.processingState,
|
||||
AudioProcessingState.error,
|
||||
);
|
||||
});
|
||||
|
||||
test('free + uuid inexistente en NINGÚN sitio: error, no silencio',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
|
||||
await handler.playFromMediaId('emisora:');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
handler.playbackState.value.processingState,
|
||||
AudioProcessingState.error,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('playFromSearch — items 12 y 13', () {
|
||||
test('free: una consulta que casa con el set gratuito SUENA', () async {
|
||||
final handler = crearHandler();
|
||||
final destacada = emisorasDestacadas.first;
|
||||
|
||||
await handler.playFromSearch(destacada.nombre);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas, contains(destacada.url));
|
||||
});
|
||||
|
||||
test('free: una consulta sin resultados publica error localizado, nunca '
|
||||
'vuelve en silencio', () async {
|
||||
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es');
|
||||
final handler = crearHandler();
|
||||
|
||||
await handler.playFromSearch('emisora que no existe en ningun sitio');
|
||||
await pumpEventQueue();
|
||||
|
||||
final estado = handler.playbackState.value;
|
||||
expect(estado.processingState, AudioProcessingState.error);
|
||||
expect(estado.errorCode, isNotNull);
|
||||
expect(estado.errorMessage, textos().autoErrorBusquedaSinResultados);
|
||||
});
|
||||
|
||||
test('consulta VACÍA ("Reproduce PluriWave") arranca algo — free', () async {
|
||||
final handler = crearHandler();
|
||||
|
||||
await handler.playFromSearch('');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas, isNotEmpty);
|
||||
});
|
||||
|
||||
test('consulta VACÍA arranca algo — PREMIUM (antes fallaba incluso para '
|
||||
'un cliente que había pagado)', () async {
|
||||
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
|
||||
final handler = crearHandler();
|
||||
|
||||
await handler.playFromSearch(' ');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas, isNotEmpty);
|
||||
});
|
||||
|
||||
test('consulta vacía prefiere la ÚLTIMA escuchada', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveUltimaEmisora:
|
||||
'{"uuid":"uuid-ultima","nombre":"Ultima",'
|
||||
'"url":"https://ultima.example/stream"}',
|
||||
});
|
||||
final handler = crearHandler();
|
||||
|
||||
await handler.playFromSearch('');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas, ['https://ultima.example/stream']);
|
||||
});
|
||||
});
|
||||
|
||||
group('un rechazo NO destruye una sesion que esta sonando — hallazgo 1', () {
|
||||
/// Puts the handler in the exact state the reviewer reproduces: a free
|
||||
/// station tapped from the browse tree and audibly playing.
|
||||
Future<PluriWaveAudioHandler> sonando() async {
|
||||
final handler = crearHandler();
|
||||
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
|
||||
await pumpEventQueue();
|
||||
guion.ultimoReproductor!.emitir(
|
||||
PlayerState(true, ProcessingState.ready),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
expect(
|
||||
handler.playbackState.value.playing,
|
||||
isTrue,
|
||||
reason: 'precondicion: la emisora esta sonando',
|
||||
);
|
||||
return handler;
|
||||
}
|
||||
|
||||
test(
|
||||
'una busqueda por voz fallida deja el estado publicado FUERA de error y '
|
||||
'sigue ofreciendo play/pause/stop',
|
||||
() async {
|
||||
final handler = await sonando();
|
||||
|
||||
// The free candidate set is only the six compiled-in stations, so
|
||||
// almost any spoken station name misses. That must not cost the
|
||||
// driver the whole now-playing screen.
|
||||
await handler.playFromSearch('BBC');
|
||||
await pumpEventQueue();
|
||||
|
||||
final estado = handler.playbackState.value;
|
||||
expect(
|
||||
estado.processingState,
|
||||
isNot(AudioProcessingState.error),
|
||||
reason:
|
||||
'AudioService.java:601-611 maps `error` to STATE_ERROR with the '
|
||||
'`playing` flag IGNORED, so publishing it over live audio '
|
||||
'replaces the transport row with an error the session can never '
|
||||
'clear: playerStateStream is .distinct() (nothing more comes '
|
||||
'from a steadily playing ExoPlayer) and _bufferedSub re-asserts '
|
||||
'it ~2x/second through copyWith',
|
||||
);
|
||||
expect(
|
||||
estado.playing,
|
||||
isTrue,
|
||||
reason: 'el audio sigue sonando; el estado tiene que decirlo',
|
||||
);
|
||||
expect(
|
||||
estado.systemActions,
|
||||
containsAll(<MediaAction>[
|
||||
MediaAction.play,
|
||||
MediaAction.pause,
|
||||
MediaAction.stop,
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
estado.controls.map((c) => c.action),
|
||||
containsAll(<MediaAction>[MediaAction.pause, MediaAction.stop]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('y el motivo del fallo SIGUE llegando al head unit', () async {
|
||||
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es');
|
||||
final handler = await sonando();
|
||||
|
||||
await handler.playFromSearch('BBC');
|
||||
await pumpEventQueue();
|
||||
|
||||
// AudioService.java:541-544 calls `setErrorMessage` from `setState`
|
||||
// regardless of processingState, so the text still reaches
|
||||
// PlaybackStateCompat without STATE_ERROR.
|
||||
expect(
|
||||
handler.playbackState.value.errorMessage,
|
||||
textos().autoErrorBusquedaSinResultados,
|
||||
);
|
||||
expect(handler.playbackState.value.errorCode, isNotNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'sin sesion viva el rechazo SI es terminal: error explicado (el caso en '
|
||||
'que el coche no tiene nada que perder)',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
|
||||
await handler.playFromSearch('BBC');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
handler.playbackState.value.processingState,
|
||||
AudioProcessingState.error,
|
||||
);
|
||||
expect(handler.playbackState.value.errorMessage, isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
/// CONTRACT for an action refusal published over a LIVE session
|
||||
/// (`_publicarErrorAuto`'s non-terminal branch):
|
||||
///
|
||||
/// 1. the code and the message are published immediately and stand for
|
||||
/// [PluriWaveAudioHandler.ventanaErrorAccionAuto] (the two tests
|
||||
/// above pin step 1);
|
||||
/// 2. when that window elapses they are BOTH cleared, and nothing else
|
||||
/// about the state moves;
|
||||
/// 3. any real player transition arriving first clears them early — a
|
||||
/// genuine state change supersedes a stale refusal;
|
||||
/// 4. only the fields this refusal published are ever cleared, so a
|
||||
/// reconnect status message that replaced them survives.
|
||||
///
|
||||
/// It has to be bounded: `_bufferedSub` republishes
|
||||
/// `playbackState.value.copyWith(...)` ~2x/second and `copyWith` carries
|
||||
/// every omitted field forward (audio_service.dart:400-427), so
|
||||
/// `AudioService.java:541-544` re-calls `setErrorMessage(code, msg)` on
|
||||
/// every one of those pushes. Without a clear, one voice miss makes the
|
||||
/// session advertise an error for the rest of the station's playback,
|
||||
/// over audible healthy audio.
|
||||
group('y el rechazo es TRANSITORIO: nada lo arrastra para siempre', () {
|
||||
setUp(() {
|
||||
PluriWaveAudioHandler.ventanaErrorAccionAuto = const Duration(
|
||||
milliseconds: 80,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PluriWaveAudioHandler.ventanaErrorAccionAuto =
|
||||
PluriWaveAudioHandler.ventanaErrorAccionAutoPorDefecto;
|
||||
});
|
||||
|
||||
test('al pasar la ventana, codigo y mensaje desaparecen', () async {
|
||||
final handler = await sonando();
|
||||
|
||||
await handler.playFromSearch('BBC');
|
||||
await pumpEventQueue();
|
||||
expect(
|
||||
handler.playbackState.value.errorCode,
|
||||
isNotNull,
|
||||
reason: 'precondicion: el rechazo se publico',
|
||||
);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
|
||||
final estado = handler.playbackState.value;
|
||||
expect(
|
||||
estado.errorCode,
|
||||
isNull,
|
||||
reason:
|
||||
'every later push carries the code forward through copyWith, so '
|
||||
'the session would keep telling the head unit it is in error '
|
||||
'while the station plays perfectly',
|
||||
);
|
||||
expect(estado.errorMessage, isNull);
|
||||
expect(
|
||||
estado.playing,
|
||||
isTrue,
|
||||
reason: 'clearing the refusal must not touch the session itself',
|
||||
);
|
||||
expect(
|
||||
estado.processingState,
|
||||
isNot(AudioProcessingState.error),
|
||||
reason: 'nor its processing state',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'y un cambio de estado real del reproductor los limpia antes',
|
||||
() async {
|
||||
final handler = await sonando();
|
||||
|
||||
await handler.playFromSearch('BBC');
|
||||
await pumpEventQueue();
|
||||
expect(handler.playbackState.value.errorMessage, isNotNull);
|
||||
|
||||
// The driver pauses: a genuine transition. `manejarEstadoPlayer`
|
||||
// omitted both fields, so `copyWith` carried the refusal into the
|
||||
// paused state and every state after it.
|
||||
handler.manejarEstadoPlayer(
|
||||
PlayerState(false, ProcessingState.ready),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
final estado = handler.playbackState.value;
|
||||
expect(estado.errorCode, isNull);
|
||||
expect(estado.errorMessage, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test('y stop() limpia el codigo, no solo el mensaje', () async {
|
||||
final handler = await sonando();
|
||||
|
||||
await handler.playFromSearch('BBC');
|
||||
await pumpEventQueue();
|
||||
expect(handler.playbackState.value.errorCode, isNotNull);
|
||||
|
||||
await handler.stop();
|
||||
await pumpEventQueue();
|
||||
|
||||
final estado = handler.playbackState.value;
|
||||
expect(
|
||||
estado.errorCode,
|
||||
isNull,
|
||||
reason:
|
||||
'stop() cleared errorMessage but omitted errorCode, so the idle '
|
||||
'it publishes shipped a stale ERROR_CODE_PREMIUM_ACCOUNT_'
|
||||
'REQUIRED (4) with no message to explain it',
|
||||
);
|
||||
expect(estado.errorMessage, isNull);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('errorCode se limpia en un cambio de fuente — item 6', () {
|
||||
test('un rechazo premium seguido de una emisora gratuita NO arrastra el '
|
||||
'codigo de error', () async {
|
||||
final handler = crearHandler();
|
||||
|
||||
await handler.playFromMediaId('emisora:uuid-del-catalogo');
|
||||
await pumpEventQueue();
|
||||
expect(
|
||||
handler.playbackState.value.errorCode,
|
||||
isNotNull,
|
||||
reason: 'precondicion: el rechazo publico un codigo',
|
||||
);
|
||||
|
||||
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
handler.playbackState.value.errorCode,
|
||||
isNull,
|
||||
reason:
|
||||
'`_cambiarFuente` cleared errorMessage but omitted errorCode, and '
|
||||
'copyWith carries an omitted field forward, so a stale code rode '
|
||||
'along indefinitely',
|
||||
);
|
||||
expect(handler.playbackState.value.errorMessage, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('getChildren(recentRootId) — reanudacion del head unit', () {
|
||||
test('con una ultima emisora persistida devuelve EXACTAMENTE un item '
|
||||
'reproducible', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveUltimaEmisora:
|
||||
'{"uuid":"uuid-ultima","nombre":"Ultima",'
|
||||
'"url":"https://ultima.example/stream"}',
|
||||
});
|
||||
final handler = crearHandler();
|
||||
|
||||
final items = await handler.getChildren(AudioService.recentRootId);
|
||||
|
||||
expect(
|
||||
items,
|
||||
hasLength(1),
|
||||
reason:
|
||||
'onGetRoot (AudioService.java:817-821) answers "recent" whenever '
|
||||
'the head unit sends EXTRA_RECENT, which Android Auto does on '
|
||||
'reconnect, and the platform expects exactly one resume item — '
|
||||
'free tier used to fall through and return all six stations',
|
||||
);
|
||||
expect(items.single.playable, isTrue);
|
||||
expect(items.single.id, 'emisora:uuid-ultima');
|
||||
});
|
||||
|
||||
test('sin ultima emisora devuelve lista vacia y no lanza', () async {
|
||||
final handler = crearHandler();
|
||||
|
||||
expect(await handler.getChildren(AudioService.recentRootId), isEmpty);
|
||||
});
|
||||
|
||||
test('premium tambien obtiene su tile de reanudacion, no una lista '
|
||||
'vacia', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'compra_premium_v1': true,
|
||||
claveUltimaEmisora:
|
||||
'{"uuid":"uuid-premium","nombre":"Premium",'
|
||||
'"url":"https://premium.example/stream"}',
|
||||
});
|
||||
final handler = crearHandler();
|
||||
|
||||
final items = await handler.getChildren(AudioService.recentRootId);
|
||||
|
||||
expect(items, hasLength(1));
|
||||
expect(items.single.id, 'emisora:uuid-premium');
|
||||
});
|
||||
});
|
||||
|
||||
group('la raiz gratuita esta LOCALIZADA — hallazgo 4', () {
|
||||
test('en un motor headless en ingles la unica carpeta que un revisor de '
|
||||
'Play ve NO sale en castellano', () async {
|
||||
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en');
|
||||
final handler = crearHandler();
|
||||
|
||||
final raiz = await handler.getChildren(AudioService.browsableRootId);
|
||||
|
||||
expect(raiz, hasLength(1));
|
||||
expect(
|
||||
raiz.single.title,
|
||||
lookupAppLocalizations(const Locale('en')).autoCarpetaEscuchar,
|
||||
reason:
|
||||
'raiz(premium: false) is 100% of the browse tree a Play reviewer '
|
||||
'ever sees, on a device in any of the 13 shipped locales — the '
|
||||
'hardcoded-Spanish car-label convention stops being defensible '
|
||||
'once one label IS the whole free root',
|
||||
);
|
||||
expect(raiz.single.title, isNot('Escuchar'));
|
||||
});
|
||||
|
||||
test('en castellano sigue diciendo Escuchar', () async {
|
||||
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('es');
|
||||
final handler = crearHandler();
|
||||
|
||||
final raiz = await handler.getChildren(AudioService.browsableRootId);
|
||||
|
||||
expect(raiz.single.title, 'Escuchar');
|
||||
});
|
||||
});
|
||||
|
||||
group('botones de salto — item 14', () {
|
||||
test('free: el salto CICLA dentro del set gratuito en vez de no hacer '
|
||||
'nada', () async {
|
||||
final handler = crearHandler();
|
||||
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
|
||||
await pumpEventQueue();
|
||||
|
||||
await handler.skipToNext();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas.last, emisorasDestacadas[1].url);
|
||||
});
|
||||
|
||||
test('free: el salto hacia atrás envuelve al final del set', () async {
|
||||
final handler = crearHandler();
|
||||
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
|
||||
await pumpEventQueue();
|
||||
|
||||
await handler.skipToPrevious();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas.last, emisorasDestacadas.last.url);
|
||||
});
|
||||
|
||||
test('free: los botones se SIGUEN anunciando (un boton que funciona es '
|
||||
'mejor UX que un hueco, y controls/systemActions se construyen en un '
|
||||
'listener sincrono que no puede await-ear prefs)', () async {
|
||||
final handler = crearHandler();
|
||||
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
|
||||
await pumpEventQueue();
|
||||
// `controls`/`systemActions` are only rebuilt from a real player event,
|
||||
// so the double has to emit one for this assertion to mean anything.
|
||||
guion.ultimoReproductor!.emitir(
|
||||
PlayerState(true, ProcessingState.ready),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
final estado = handler.playbackState.value;
|
||||
expect(estado.systemActions, contains(MediaAction.skipToNext));
|
||||
expect(estado.systemActions, contains(MediaAction.skipToPrevious));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Cuenta los round trips nativos que la raíz del árbol dispara.
|
||||
class _FuenteMusicaLocalEspia implements FuenteMusicaLocalAuto {
|
||||
int llamadasEstadoCarpeta = 0;
|
||||
|
||||
@override
|
||||
Future<EstadoCarpetaLocal> estadoCarpeta() async {
|
||||
llamadasEstadoCarpeta++;
|
||||
return EstadoCarpetaLocal.configurada;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<NodoLocal>> hijos(String documentId) async => const [];
|
||||
|
||||
@override
|
||||
Future<String?> uriContenidoDePista(String documentId) async => null;
|
||||
|
||||
@override
|
||||
Future<Map<String, MetadatosPista>> metadatosDe(
|
||||
List<String> documentIds,
|
||||
) async => const {};
|
||||
}
|
||||
|
||||
/// Misma forma que el doble de `servicio_audio_transporte_test.dart`.
|
||||
class _GuionReproductor {
|
||||
int llamadasPlay = 0;
|
||||
final urlsSolicitadas = <String>[];
|
||||
|
||||
/// The handler rebuilds its player on every source change, so a test that
|
||||
/// needs to drive player events has to reach the LATEST instance.
|
||||
_ReproductorFalso? ultimoReproductor;
|
||||
}
|
||||
|
||||
class _ReproductorFalso extends AudioPlayer {
|
||||
_ReproductorFalso(
|
||||
this._guion,
|
||||
AudioPipeline pipeline,
|
||||
AudioLoadConfiguration carga,
|
||||
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
|
||||
_guion.ultimoReproductor = this;
|
||||
}
|
||||
|
||||
final _GuionReproductor _guion;
|
||||
final _estados = StreamController<PlayerState>.broadcast();
|
||||
|
||||
void emitir(PlayerState estado) => _estados.add(estado);
|
||||
|
||||
@override
|
||||
Stream<PlayerState> get playerStateStream => _estados.stream;
|
||||
|
||||
@override
|
||||
Future<Duration?> setUrl(
|
||||
String url, {
|
||||
Map<String, String>? headers,
|
||||
Duration? initialPosition,
|
||||
bool preload = true,
|
||||
dynamic tag,
|
||||
}) async {
|
||||
_guion.urlsSolicitadas.add(url);
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {
|
||||
_guion.llamadasPlay++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
|
||||
@override
|
||||
Future<void> setVolume(double volume) async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _estados.close();
|
||||
}
|
||||
}
|
||||
@@ -1,618 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' show Locale;
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/modelos/grupo_favoritos.dart';
|
||||
import 'package:pluriwave/servicios/contexto_reproduccion.dart';
|
||||
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
|
||||
import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/handlers_audio.dart';
|
||||
|
||||
/// Contexto de reproducción en el HANDLER — «al conectar el coche, siguiente/
|
||||
/// anterior ya no recuerdan en qué lista estaba».
|
||||
///
|
||||
/// El handler es el único que existe en los dos motores (el del móvil y el
|
||||
/// headless que levanta Android Auto sin Activity), así que es él quien tiene
|
||||
/// que escribir y leer el contexto. Lo hace por un PUERTO inyectado en
|
||||
/// `registrarHandler`, exactamente igual que el flag on/off del ecualizador:
|
||||
/// `servicio_audio.dart` no importa `shared_preferences` ni conoce
|
||||
/// `EstadoRadio`, que en el coche no llega a construirse nunca.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final crearHandler = registrarHandlersLiberables();
|
||||
|
||||
late _GuionReproductor guion;
|
||||
late _PuertoContexto puerto;
|
||||
|
||||
setUp(() {
|
||||
guion = _GuionReproductor();
|
||||
puerto = _PuertoContexto();
|
||||
PluriWaveAudioHandler.fabricaReproductorPrueba =
|
||||
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
|
||||
// `_fuenteNavegacionGlobal` es global de módulo: se deja siempre en una
|
||||
// fuente vacía para que un test no herede las listas del anterior.
|
||||
registrarFuenteNavegacion(_FuenteFalsa());
|
||||
});
|
||||
|
||||
AppLocalizations textos() => lookupAppLocalizations(const Locale('es'));
|
||||
|
||||
Emisora favorita(String uuid, String grupo) => Emisora(
|
||||
uuid: uuid,
|
||||
nombre: uuid,
|
||||
url: 'https://example.com/$uuid',
|
||||
grupoFavoritosId: grupo,
|
||||
);
|
||||
|
||||
final rock1 = favorita('rock1', 'g-rock');
|
||||
final rock2 = favorita('rock2', 'g-rock');
|
||||
final rock3 = favorita('rock3', 'g-rock');
|
||||
final jazz1 = favorita('jazz1', 'g-jazz');
|
||||
final jazz2 = favorita('jazz2', 'g-jazz');
|
||||
const gruposVivos = [
|
||||
GrupoFavoritos(id: 'g-rock', nombre: 'Rock', orden: 1),
|
||||
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
|
||||
];
|
||||
|
||||
PluriWaveAudioHandler handlerConPuerto() {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerContextoSalto: puerto.leer,
|
||||
guardarContextoSalto: puerto.guardar,
|
||||
);
|
||||
return handler;
|
||||
}
|
||||
|
||||
Future<void> premium() async {
|
||||
SharedPreferences.setMockInitialValues({'compra_premium_v1': true});
|
||||
}
|
||||
|
||||
group('escritura — el contexto se fija en TODO camino que arranca una '
|
||||
'emisora, no solo en el del árbol del coche', () {
|
||||
setUp(premium);
|
||||
|
||||
test('playFromMediaId (toque en el árbol del coche)', () async {
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
|
||||
);
|
||||
final handler = handlerConPuerto();
|
||||
|
||||
await handler.playFromMediaId('emisora:rock1');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(puerto.guardados.last, const ContextoSalto.grupo('g-rock'));
|
||||
});
|
||||
|
||||
test('playFromSearch (voz)', () async {
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
|
||||
);
|
||||
final handler = handlerConPuerto();
|
||||
|
||||
await handler.playFromSearch('jazz1');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(puerto.guardados.last, const ContextoSalto.favoritos());
|
||||
});
|
||||
|
||||
test('playMediaItem (el camino del móvil: ServicioAudio.reproducir)',
|
||||
() async {
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
|
||||
);
|
||||
final handler = handlerConPuerto();
|
||||
|
||||
await handler.playMediaItem(mediaItemParaEmisora(rock2, l10n: textos()));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(puerto.guardados.last, const ContextoSalto.grupo('g-rock'));
|
||||
});
|
||||
|
||||
test('una PISTA LOCAL no escribe contexto de emisora: su cola es otra '
|
||||
'cosa y pisarlo dejaría la radio recorriendo una lista ajena',
|
||||
() async {
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
|
||||
);
|
||||
final handler = handlerConPuerto();
|
||||
|
||||
await handler.playMediaItem(
|
||||
const MediaItem(
|
||||
id: 'content://media/documents/pista-1',
|
||||
title: 'Pista local',
|
||||
),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(puerto.guardados, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('lectura — el contexto recordado sobrevive al reinicio del proceso',
|
||||
() {
|
||||
setUp(premium);
|
||||
|
||||
test('la fuente aún no está registrada cuando arranca la emisora (carrera '
|
||||
'real del bind en frío): el salto usa el contexto de la sesión '
|
||||
'anterior en vez de morir', () async {
|
||||
puerto.persistido = const ContextoSalto.grupo('g-rock');
|
||||
// Bind en frío: `main.dart` todavía no ha llamado a
|
||||
// registrarFuenteNavegacion cuando el coche pide reproducir.
|
||||
registrarFuenteNavegacion(_FuenteFalsa());
|
||||
final handler = handlerConPuerto();
|
||||
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
|
||||
await pumpEventQueue();
|
||||
|
||||
// La fuente llega después, ya con las listas cargadas.
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
|
||||
);
|
||||
await handler.skipToNext();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas.last, rock2.url);
|
||||
expect(puerto.lecturas, greaterThan(0));
|
||||
});
|
||||
|
||||
test('el contexto recordado MANDA sobre la rederivación: el registro de '
|
||||
'favoritos perdió el grupo, y aun así se recorre el grupo', () async {
|
||||
// Escenario real: el snapshot que empuja el móvil llega antes de que
|
||||
// los grupos estén cargados, así que `favoritos()` reporta
|
||||
// «sin asignar» un rato. Sin memoria, el salto se ensancha a todos los
|
||||
// favoritos justo en mitad del trayecto.
|
||||
puerto.persistido = const ContextoSalto.grupo('g-rock');
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
|
||||
);
|
||||
final handler = handlerConPuerto();
|
||||
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
|
||||
await pumpEventQueue();
|
||||
guion.urlsSolicitadas.clear();
|
||||
|
||||
await handler.skipToNext();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas.last, rock2.url);
|
||||
});
|
||||
});
|
||||
|
||||
group('degradación del contexto recordado', () {
|
||||
setUp(premium);
|
||||
|
||||
test('el grupo recordado fue BORRADO -> se recorren todos los favoritos',
|
||||
() async {
|
||||
puerto.persistido = const ContextoSalto.grupo('g-rock');
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(
|
||||
favoritos: [
|
||||
favorita('rock1', GrupoFavoritos.sinAsignarId),
|
||||
jazz1,
|
||||
jazz2,
|
||||
],
|
||||
grupos: const [
|
||||
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
|
||||
],
|
||||
),
|
||||
);
|
||||
final handler = handlerConPuerto();
|
||||
|
||||
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
|
||||
await pumpEventQueue();
|
||||
guion.urlsSolicitadas.clear();
|
||||
await handler.skipToNext();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas.last, jazz1.url);
|
||||
});
|
||||
|
||||
test('el grupo SIGUE VIVO pero la emisora se salió de él -> se PERMANECE '
|
||||
'en el grupo y suena su PRIMERA emisora', () async {
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(favoritos: [rock1, rock2, jazz1], grupos: gruposVivos),
|
||||
);
|
||||
final handler = handlerConPuerto();
|
||||
|
||||
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
|
||||
await pumpEventQueue();
|
||||
expect(puerto.guardados.last, const ContextoSalto.grupo('g-rock'));
|
||||
guion.urlsSolicitadas.clear();
|
||||
|
||||
// El móvil saca rock1 del grupo a media marcha: g-rock sigue existiendo
|
||||
// y sigue teniendo emisoras, solo que ya no la que suena.
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(
|
||||
// jazz1 va DELANTE de rock2 a propósito: si el contexto se
|
||||
// ensanchara a todos los favoritos, «siguiente» sonaría jazz1.
|
||||
favoritos: [
|
||||
favorita('rock1', GrupoFavoritos.sinAsignarId),
|
||||
jazz1,
|
||||
rock2,
|
||||
rock3,
|
||||
],
|
||||
grupos: gruposVivos,
|
||||
),
|
||||
);
|
||||
await handler.skipToNext();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas.last, rock2.url);
|
||||
expect(
|
||||
puerto.persistido,
|
||||
const ContextoSalto.grupo('g-rock'),
|
||||
reason: 'el grupo sobrevive, así que el contexto no se rederiva',
|
||||
);
|
||||
});
|
||||
|
||||
test('el grupo sigue vivo con UNA sola emisora -> el salto NO se ensancha '
|
||||
'a todos los favoritos: se queda donde está', () async {
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(
|
||||
favoritos: [rock1, rock2, jazz1, jazz2],
|
||||
grupos: gruposVivos,
|
||||
),
|
||||
);
|
||||
final handler = handlerConPuerto();
|
||||
|
||||
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
|
||||
await pumpEventQueue();
|
||||
guion.urlsSolicitadas.clear();
|
||||
|
||||
// rock2 sale del grupo: g-rock se queda solo con la emisora que suena.
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(
|
||||
favoritos: [
|
||||
rock1,
|
||||
favorita('rock2', GrupoFavoritos.sinAsignarId),
|
||||
jazz1,
|
||||
jazz2,
|
||||
],
|
||||
grupos: gruposVivos,
|
||||
),
|
||||
);
|
||||
await handler.skipToNext();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas, isEmpty);
|
||||
expect(puerto.persistido, const ContextoSalto.grupo('g-rock'));
|
||||
});
|
||||
|
||||
test('el grupo fue BORRADO y la emisora ya NO es favorita -> suena la '
|
||||
'PRIMERA de los favoritos que queden', () async {
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(favoritos: [rock1, rock2], grupos: gruposVivos),
|
||||
);
|
||||
final handler = handlerConPuerto();
|
||||
|
||||
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
|
||||
await pumpEventQueue();
|
||||
guion.urlsSolicitadas.clear();
|
||||
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(
|
||||
favoritos: [jazz1, jazz2],
|
||||
grupos: const [
|
||||
GrupoFavoritos(id: 'g-jazz', nombre: 'Jazz', orden: 2),
|
||||
],
|
||||
),
|
||||
);
|
||||
await handler.skipToNext();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas.last, jazz1.url);
|
||||
});
|
||||
|
||||
test('no queda ningún favorito y la emisora no está en ninguna lista -> '
|
||||
'no-op silencioso, nunca un salto arbitrario', () async {
|
||||
puerto.persistido = const ContextoSalto.grupo('g-rock');
|
||||
registrarFuenteNavegacion(_FuenteFalsa(grupos: gruposVivos));
|
||||
final handler = handlerConPuerto();
|
||||
|
||||
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
|
||||
await pumpEventQueue();
|
||||
guion.urlsSolicitadas.clear();
|
||||
await handler.skipToNext();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('tier gratuito', () {
|
||||
test('la lista de salto queda CONGELADA en el orden curado: que cambie '
|
||||
'`ultima_emisora_v1` a media marcha no reordena el recorrido, así '
|
||||
'que «anterior» vuelve a deshacer «siguiente»', () async {
|
||||
// `resolverEmisorasDestacadas` se recompone como
|
||||
// `[última reproducida, ...curadas]`, así que la lista SE REORDENA SOLA
|
||||
// según suena cada emisora. Recorrerla en vivo hacía que «anterior»
|
||||
// dejara de ser el inverso de «siguiente» a mitad de trayecto.
|
||||
final kexp = emisorasDestacadas[3];
|
||||
final siguienteCurada = emisorasDestacadas[4];
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveUltimaEmisora:
|
||||
'{"uuid":"${kexp.uuid}","nombre":"${kexp.nombre}",'
|
||||
'"url":"${kexp.url}"}',
|
||||
});
|
||||
final handler = handlerConPuerto();
|
||||
|
||||
// Fija la divergencia: la lista VIVA con `ultima` = KEXP empieza por
|
||||
// KEXP, así que recorrerla daría la primera curada. Sin esta línea el
|
||||
// test de abajo pasaría también con el comportamiento antiguo.
|
||||
final enVivo = await resolverEmisorasDestacadas();
|
||||
expect(
|
||||
emisoraVecina(kexp, enVivo, haciaAtras: false)?.url,
|
||||
emisorasDestacadas.first.url,
|
||||
);
|
||||
|
||||
await handler.playFromMediaId('emisora:${kexp.uuid}');
|
||||
await pumpEventQueue();
|
||||
await handler.skipToNext();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
guion.urlsSolicitadas.last,
|
||||
siguienteCurada.url,
|
||||
reason:
|
||||
'en vivo la lista sería [KEXP, ...resto], y «siguiente» se iría a '
|
||||
'la primera curada en vez de a la que va detrás de KEXP',
|
||||
);
|
||||
|
||||
// El móvil (vivo) persiste la emisora que acaba de sonar.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
claveUltimaEmisora,
|
||||
'{"uuid":"${siguienteCurada.uuid}",'
|
||||
'"nombre":"${siguienteCurada.nombre}",'
|
||||
'"url":"${siguienteCurada.url}"}',
|
||||
);
|
||||
|
||||
await handler.skipToPrevious();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas.last, kexp.url);
|
||||
});
|
||||
|
||||
test('una emisora ajena al set curado se ANTEPONE al recorrido congelado, '
|
||||
'para que ningún botón quede muerto', () async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
claveUltimaEmisora:
|
||||
'{"uuid":"ajena","nombre":"Ajena",'
|
||||
'"url":"https://example.com/ajena"}',
|
||||
});
|
||||
final handler = handlerConPuerto();
|
||||
|
||||
await handler.playFromMediaId('emisora:ajena');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
puerto.guardados.last,
|
||||
ContextoSalto.destacadas([
|
||||
'ajena',
|
||||
...emisorasDestacadas.map((e) => e.uuid),
|
||||
]),
|
||||
);
|
||||
|
||||
await handler.skipToPrevious();
|
||||
await pumpEventQueue();
|
||||
expect(guion.urlsSolicitadas.last, emisorasDestacadas.last.url);
|
||||
});
|
||||
|
||||
test('un contexto PREMIUM recordado NO se camina en tier gratuito: una '
|
||||
'cuenta degradada no sigue recorriendo el catálogo', () async {
|
||||
puerto.persistido = const ContextoSalto.grupo('g-rock');
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(favoritos: [rock1, rock2], grupos: gruposVivos),
|
||||
);
|
||||
final handler = handlerConPuerto();
|
||||
|
||||
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
|
||||
await pumpEventQueue();
|
||||
guion.urlsSolicitadas.clear();
|
||||
await handler.skipToNext();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas.last, emisorasDestacadas[1].url);
|
||||
expect(
|
||||
puerto.guardados.last.tipo,
|
||||
TipoContextoSalto.destacadas,
|
||||
reason: 'el contexto de un usuario free es SIEMPRE el set gratuito',
|
||||
);
|
||||
});
|
||||
|
||||
test('free: aunque el grupo de favoritos siga VIVO, el recorrido no entra '
|
||||
'en él tampoco en el segundo salto', () async {
|
||||
// La nueva caída «grupo vivo -> su primera emisora» no puede convertirse
|
||||
// en una puerta trasera al contenido de pago.
|
||||
puerto.persistido = const ContextoSalto.grupo('g-rock');
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(favoritos: [rock1, rock2, rock3], grupos: gruposVivos),
|
||||
);
|
||||
final handler = handlerConPuerto();
|
||||
|
||||
await handler.playFromMediaId('emisora:${emisorasDestacadas.first.uuid}');
|
||||
await pumpEventQueue();
|
||||
guion.urlsSolicitadas.clear();
|
||||
await handler.skipToNext();
|
||||
await pumpEventQueue();
|
||||
await handler.skipToNext();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas, [
|
||||
emisorasDestacadas[1].url,
|
||||
emisorasDestacadas[2].url,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('robustez del puerto', () {
|
||||
setUp(premium);
|
||||
|
||||
test('sin puerto registrado el salto sigue funcionando por rederivación '
|
||||
'(un test de widgets, o el arranque antes de main.dart)', () async {
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
|
||||
);
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
|
||||
await pumpEventQueue();
|
||||
guion.urlsSolicitadas.clear();
|
||||
await handler.skipToNext();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas.last, rock2.url);
|
||||
});
|
||||
|
||||
test('un puerto que revienta degrada a rederivación en vez de dejar al '
|
||||
'conductor sin botones', () async {
|
||||
puerto.explota = true;
|
||||
registrarFuenteNavegacion(
|
||||
_FuenteFalsa(favoritos: [rock1, jazz1, rock2], grupos: gruposVivos),
|
||||
);
|
||||
final handler = handlerConPuerto();
|
||||
|
||||
await handler.playMediaItem(mediaItemParaEmisora(rock1, l10n: textos()));
|
||||
await pumpEventQueue();
|
||||
guion.urlsSolicitadas.clear();
|
||||
await handler.skipToNext();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guion.urlsSolicitadas.last, rock2.url);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Doble del puerto de persistencia que `main.dart` ata a
|
||||
/// `contexto_reproduccion.dart`.
|
||||
class _PuertoContexto {
|
||||
ContextoSalto? persistido;
|
||||
final guardados = <ContextoSalto>[];
|
||||
int lecturas = 0;
|
||||
bool explota = false;
|
||||
|
||||
Future<ContextoSalto?> leer() async {
|
||||
lecturas++;
|
||||
if (explota) throw StateError('disco ilegible');
|
||||
return persistido;
|
||||
}
|
||||
|
||||
Future<void> guardar(ContextoSalto contexto) async {
|
||||
if (explota) throw StateError('disco de solo lectura');
|
||||
guardados.add(contexto);
|
||||
persistido = contexto;
|
||||
}
|
||||
}
|
||||
|
||||
class _FuenteFalsa implements FuenteEmisorasAuto {
|
||||
_FuenteFalsa({
|
||||
List<Emisora>? favoritos,
|
||||
List<Emisora>? misEmisoras,
|
||||
List<Emisora>? todas,
|
||||
List<GrupoFavoritos>? grupos,
|
||||
}) : _favoritos = favoritos ?? const [],
|
||||
_misEmisoras = misEmisoras ?? const [],
|
||||
_todas = todas ?? const [],
|
||||
_grupos = grupos ?? const [];
|
||||
|
||||
final List<Emisora> _favoritos;
|
||||
final List<Emisora> _misEmisoras;
|
||||
final List<Emisora> _todas;
|
||||
final List<GrupoFavoritos> _grupos;
|
||||
|
||||
@override
|
||||
Future<List<Emisora>> favoritos() async => _favoritos;
|
||||
|
||||
@override
|
||||
Future<List<Emisora>> misEmisoras() async => _misEmisoras;
|
||||
|
||||
@override
|
||||
Future<List<Emisora>> todas() async => _todas;
|
||||
|
||||
@override
|
||||
Future<List<GrupoFavoritos>> grupos() async => _grupos;
|
||||
|
||||
@override
|
||||
Future<Emisora?> porUuid(String uuid) async {
|
||||
for (final lista in [_favoritos, _misEmisoras, _todas]) {
|
||||
for (final e in lista) {
|
||||
if (e.uuid == uuid) return e;
|
||||
}
|
||||
}
|
||||
for (final e in await resolverEmisorasDestacadas()) {
|
||||
if (e.uuid == uuid) return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void actualizarSnapshot({
|
||||
List<Emisora>? favoritos,
|
||||
List<Emisora>? misEmisoras,
|
||||
List<Emisora>? todas,
|
||||
List<GrupoFavoritos>? grupos,
|
||||
}) {}
|
||||
}
|
||||
|
||||
/// Misma forma que el doble de `servicio_audio_auto_free_test.dart`.
|
||||
class _GuionReproductor {
|
||||
final urlsSolicitadas = <String>[];
|
||||
_ReproductorFalso? ultimoReproductor;
|
||||
}
|
||||
|
||||
class _ReproductorFalso extends AudioPlayer {
|
||||
_ReproductorFalso(
|
||||
this._guion,
|
||||
AudioPipeline pipeline,
|
||||
AudioLoadConfiguration carga,
|
||||
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
|
||||
_guion.ultimoReproductor = this;
|
||||
}
|
||||
|
||||
final _GuionReproductor _guion;
|
||||
final _estados = StreamController<PlayerState>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<PlayerState> get playerStateStream => _estados.stream;
|
||||
|
||||
@override
|
||||
Future<Duration?> setUrl(
|
||||
String url, {
|
||||
Map<String, String>? headers,
|
||||
Duration? initialPosition,
|
||||
bool preload = true,
|
||||
dynamic tag,
|
||||
}) async {
|
||||
_guion.urlsSolicitadas.add(url);
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
|
||||
@override
|
||||
Future<void> setVolume(double volume) async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _estados.close();
|
||||
}
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:pluriwave/modelos/preset_ecualizador.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
import '../helpers/handlers_audio.dart';
|
||||
|
||||
/// eq-coche — the equalizer toggle pressed FROM ANDROID AUTO.
|
||||
///
|
||||
/// Reported by the owner: the toggle behaves correctly from the phone screen
|
||||
/// but from the car it «sometimes sounds like a doubled equalization and
|
||||
/// sometimes does nothing».
|
||||
///
|
||||
/// Three independent causes, one per group below:
|
||||
///
|
||||
/// A. The handler's `_presetActual` was hardcoded to `PresetEcualizador.flat`
|
||||
/// and had NO disk seam. The on/off flag got one (`leerEqActivoPersistido`,
|
||||
/// `eq-estado-unico` item A); the preset never did. On a headless Android
|
||||
/// Auto engine — no Activity, no Provider tree, so no `EstadoEcualizador`
|
||||
/// to push the real preset — enabling the equalizer from the car applied
|
||||
/// FLAT.
|
||||
///
|
||||
/// B. `_aplicarEcualizadorActivo` called `setEnabled(activo)` BEFORE pushing
|
||||
/// the preset's gains, so the native effect was re-activated carrying
|
||||
/// whatever band levels the previous preset had left in it and only
|
||||
/// afterwards were the intended ones written, band by band. That audible
|
||||
/// gap is the «doubled equalization».
|
||||
///
|
||||
/// C. `_recrearPlayer` dropped `_eqDisponible` to `false` on EVERY station
|
||||
/// change and never restored it until the fresh player attached. Every
|
||||
/// native EQ path is gated on that flag, so a car toggle landing inside
|
||||
/// the window flipped the icon and the flag but never touched the audio —
|
||||
/// the «does nothing» — and the EQ button itself vanished from the car's
|
||||
/// now-playing screen (`controlesEcualizadorPersonalizados` returns
|
||||
/// `const []` when unavailable) and came back seconds later.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final crearHandler = registrarHandlersLiberables();
|
||||
|
||||
late _GuionReproductorEq guion;
|
||||
|
||||
setUp(() {
|
||||
guion = _GuionReproductorEq();
|
||||
PluriWaveAudioHandler.fabricaReproductorPrueba =
|
||||
(pipeline, carga) => _ReproductorFalsoEq(guion, pipeline, carga);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
|
||||
PluriWaveAudioHandler.fabricaEcualizadorPrueba = null;
|
||||
});
|
||||
|
||||
group('A — the preset is seeded from disk on a headless engine', () {
|
||||
test('registrarHandler consults the injected preset port exactly once '
|
||||
'and seeds the handler with it, with no widget tree', () async {
|
||||
final handler = crearHandler();
|
||||
var lecturas = 0;
|
||||
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerPresetPersistido: () async {
|
||||
lecturas++;
|
||||
return PresetEcualizador.jazz;
|
||||
},
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(lecturas, 1, reason: 'exactly one disk read per engine start');
|
||||
expect(
|
||||
handler.presetActual,
|
||||
PresetEcualizador.jazz,
|
||||
reason:
|
||||
'from the car the handler is the ONLY owner of the preset — '
|
||||
'nothing else ever pushes one on a headless engine',
|
||||
);
|
||||
});
|
||||
|
||||
test('a read failure leaves the historical default instead of '
|
||||
'propagating', () async {
|
||||
final handler = crearHandler();
|
||||
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerPresetPersistido: () async => throw StateError('sin disco'),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(handler.presetActual, PresetEcualizador.flat);
|
||||
});
|
||||
|
||||
test('without a preset port the handler is left untouched (widget tests, '
|
||||
'fakes)', () async {
|
||||
final handler = crearHandler();
|
||||
await handler.aplicarPreset(PresetEcualizador.rock);
|
||||
|
||||
registrarHandler(handler);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(handler.presetActual, PresetEcualizador.rock);
|
||||
});
|
||||
|
||||
test('a preset already chosen while the disk read was in flight WINS — '
|
||||
'seeding never clobbers a live choice', () async {
|
||||
final handler = crearHandler();
|
||||
final lectura = Completer<PresetEcualizador?>();
|
||||
|
||||
registrarHandler(handler, leerPresetPersistido: () => lectura.future);
|
||||
// The phone UI (`EstadoEcualizador`) resolves a per-station preset and
|
||||
// pushes it while the seed's disk read is still pending.
|
||||
await handler.aplicarPreset(PresetEcualizador.pop);
|
||||
lectura.complete(PresetEcualizador.jazz);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
handler.presetActual,
|
||||
PresetEcualizador.pop,
|
||||
reason:
|
||||
'the seed exists to fill a VOID, not to overrule the richer '
|
||||
'per-station/per-device preset the phone UI resolves',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('B — the preset is pushed BEFORE the effect is enabled', () {
|
||||
test('enabling applies the gains first and only then flips the native '
|
||||
'effect on', () {
|
||||
expect(
|
||||
PluriWaveAudioHandler.pasosEcualizador(activo: true),
|
||||
[PasoEcualizador.ganancias, PasoEcualizador.habilitacion],
|
||||
reason:
|
||||
'enabling first would re-activate the native Equalizer carrying '
|
||||
'the PREVIOUS preset gains, which is the doubled equalization '
|
||||
'the owner hears',
|
||||
);
|
||||
});
|
||||
|
||||
test('disabling only flips the effect off — the band gains are NOT '
|
||||
'reset', () {
|
||||
expect(
|
||||
PluriWaveAudioHandler.pasosEcualizador(activo: false),
|
||||
[PasoEcualizador.habilitacion],
|
||||
reason:
|
||||
'android.media.audiofx.AudioEffect.setEnabled(false) bypasses '
|
||||
'the effect and RETAINS its band levels, and the enable path '
|
||||
'rewrites them before re-enabling anyway — zeroing them would be '
|
||||
'one native round trip per band for no audible difference',
|
||||
);
|
||||
});
|
||||
|
||||
test('the real toggle path executes those steps IN THAT ORDER', () async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
|
||||
await handler.setEcualizadorActivo(true);
|
||||
|
||||
expect(
|
||||
handler.pasosEcualizadorEjecutados,
|
||||
[PasoEcualizador.ganancias, PasoEcualizador.habilitacion],
|
||||
reason:
|
||||
'the ORDER is the fix; asserting only that both happened would '
|
||||
'stay green against the exact bug being fixed',
|
||||
);
|
||||
});
|
||||
|
||||
test('the real disable path executes only the habilitacion step', () async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
|
||||
await handler.setEcualizadorActivo(false);
|
||||
|
||||
expect(handler.pasosEcualizadorEjecutados, [
|
||||
PasoEcualizador.habilitacion,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
group('C — a station change no longer drops the equalizer', () {
|
||||
test('once the EQ was available, no state published across a station '
|
||||
'change and a car toggle has zero custom actions', () async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
// The EQ action is on the car's now-playing screen before the station
|
||||
// changes — that is the state the driver is looking at.
|
||||
await handler.setEcualizadorActivo(true);
|
||||
|
||||
final acciones = <int>[];
|
||||
final sub = handler.playbackState.listen(
|
||||
(estado) => acciones.add(
|
||||
estado.controls.where((c) => c.customAction != null).length,
|
||||
),
|
||||
);
|
||||
|
||||
await handler.playMediaItem(
|
||||
const MediaItem(id: 'https://a', title: 'A'),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
// The car tap that used to land inside the window `_recrearPlayer`
|
||||
// opened. It republishes the controls from `_eqDisponible`, so a flag
|
||||
// reset to `false` shows up here as an EQ button that disappeared.
|
||||
await handler.customAction(accionEqToggle);
|
||||
await sub.cancel();
|
||||
|
||||
expect(
|
||||
acciones,
|
||||
isNotEmpty,
|
||||
reason: 'the station change must publish at least one state',
|
||||
);
|
||||
expect(
|
||||
acciones.every((n) => n > 0),
|
||||
isTrue,
|
||||
reason:
|
||||
'the EQ button vanished and reappeared on every station change '
|
||||
'because `_recrearPlayer` reset `_eqDisponible`; availability is '
|
||||
'a DEVICE property and does not change with the station. Got '
|
||||
'$acciones',
|
||||
);
|
||||
});
|
||||
|
||||
test('the availability flag survives the player rebuild, so a car toggle '
|
||||
'inside the window still reaches the native effect', () async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
|
||||
await handler.playMediaItem(
|
||||
const MediaItem(id: 'https://a', title: 'A'),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
handler.ecualizadorDisponible,
|
||||
isTrue,
|
||||
reason:
|
||||
'this is the flag every native EQ path is gated on; false here '
|
||||
'is exactly the reported «does nothing»',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('D — a failed native call is traced and never lies', () {
|
||||
test('a throwing setEnabled is traced instead of swallowed', () async {
|
||||
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
|
||||
() => _EcualizadorQueFalla();
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
|
||||
await handler.setEcualizadorActivo(true);
|
||||
|
||||
expect(
|
||||
handler.fallosNativosEcualizador,
|
||||
greaterThan(0),
|
||||
reason:
|
||||
'the silent `catch (_) {}` made a dead native equalizer '
|
||||
'indistinguishable from a working one in a car logcat',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed on/off call leaves the published state honest instead of '
|
||||
'claiming a state the audio does not have', () async {
|
||||
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
|
||||
() => _EcualizadorQueFalla();
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
await handler.sembrarEcualizadorActivo(false);
|
||||
|
||||
await handler.setEcualizadorActivo(true);
|
||||
|
||||
expect(
|
||||
handler.ecualizadorActivo,
|
||||
isFalse,
|
||||
reason:
|
||||
'the native effect refused, so the car icon must not read "on" '
|
||||
'over audio that is not equalized',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed on/off call is not persisted', () async {
|
||||
PluriWaveAudioHandler.fabricaEcualizadorPrueba =
|
||||
() => _EcualizadorQueFalla();
|
||||
final handler = crearHandler();
|
||||
final escrituras = <bool>[];
|
||||
registrarHandler(
|
||||
handler,
|
||||
guardarEqActivoPersistido: (activo) async => escrituras.add(activo),
|
||||
);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
await handler.sembrarEcualizadorActivo(false);
|
||||
|
||||
await handler.setEcualizadorActivo(true);
|
||||
|
||||
expect(
|
||||
escrituras,
|
||||
isEmpty,
|
||||
reason:
|
||||
'persisting a state the device rejected would resurrect it on '
|
||||
'the next engine start',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// An `AndroidEqualizer` whose `setEnabled` always throws, standing in for a
|
||||
/// device whose native `Equalizer` effect refuses the call. Nothing else is
|
||||
/// overridden, so the rest of the handler runs unchanged.
|
||||
class _EcualizadorQueFalla extends AndroidEqualizer {
|
||||
@override
|
||||
Future<void> setEnabled(bool enabled) async {
|
||||
throw StateError('el efecto nativo rechazo la llamada');
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal script/observation record shared by every [_ReproductorFalsoEq]
|
||||
/// the handler builds (it rebuilds its player on every source change).
|
||||
class _GuionReproductorEq {
|
||||
int llamadasSetUrl = 0;
|
||||
_ReproductorFalsoEq? ultimoReproductor;
|
||||
}
|
||||
|
||||
/// An [AudioPlayer] whose platform-touching methods are replaced, so a real
|
||||
/// station change can be driven under `flutter test`. Mirrors the double in
|
||||
/// `servicio_audio_transporte_test.dart`.
|
||||
class _ReproductorFalsoEq extends AudioPlayer {
|
||||
_ReproductorFalsoEq(
|
||||
this._guion,
|
||||
AudioPipeline pipeline,
|
||||
AudioLoadConfiguration carga,
|
||||
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
|
||||
_guion.ultimoReproductor = this;
|
||||
}
|
||||
|
||||
final _GuionReproductorEq _guion;
|
||||
final _estados = StreamController<PlayerState>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<PlayerState> get playerStateStream => _estados.stream;
|
||||
|
||||
@override
|
||||
Future<Duration?> setUrl(
|
||||
String url, {
|
||||
Map<String, String>? headers,
|
||||
Duration? initialPosition,
|
||||
bool preload = true,
|
||||
dynamic tag,
|
||||
}) async {
|
||||
_guion.llamadasSetUrl++;
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
|
||||
@override
|
||||
Future<void> setVolume(double volume) async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _estados.close();
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:just_audio/just_audio.dart' show PlayerState, ProcessingState;
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
import '../helpers/handlers_audio.dart';
|
||||
|
||||
/// eq-estado-unico — the equalizer's on/off flag gets a SINGLE owner.
|
||||
///
|
||||
/// Reported bug: «alguna emisora parece que esta con la ecualizacion activada
|
||||
@@ -30,8 +28,6 @@ import '../helpers/handlers_audio.dart';
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final crearHandler = registrarHandlersLiberables();
|
||||
|
||||
group('estadoEqInicial (A — seed the handler from disk on every engine)', () {
|
||||
test('adopts the persisted value when there is one', () {
|
||||
expect(estadoEqInicial(persistido: false), isFalse);
|
||||
@@ -50,7 +46,7 @@ void main() {
|
||||
group('registrarHandler (A — seeding)', () {
|
||||
test('consults the injected read port exactly once and seeds the handler '
|
||||
'with the persisted value', () async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
var lecturas = 0;
|
||||
|
||||
registrarHandler(
|
||||
@@ -72,7 +68,7 @@ void main() {
|
||||
|
||||
test('a read failure leaves the handler on the safe default instead of '
|
||||
'propagating', () async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
|
||||
registrarHandler(
|
||||
handler,
|
||||
@@ -85,7 +81,7 @@ void main() {
|
||||
|
||||
test('without a read port the handler is left untouched (widget tests, '
|
||||
'fakes)', () async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
await handler.setEcualizadorActivo(false);
|
||||
|
||||
registrarHandler(handler);
|
||||
@@ -110,7 +106,7 @@ void main() {
|
||||
'starts from the persisted value, not from a hardcoded default',
|
||||
() async {
|
||||
// One engine does the read `registrarHandler` performs in main.dart.
|
||||
final primero = crearHandler();
|
||||
final primero = PluriWaveAudioHandler();
|
||||
|
||||
// Pin the module cache to the OPPOSITE value first. Without this the
|
||||
// test passes for the wrong reason: whatever ran before may already
|
||||
@@ -127,7 +123,7 @@ void main() {
|
||||
|
||||
// Now the construction window: a handler built by `AudioService.init`'s
|
||||
// builder, with no port of its own yet.
|
||||
final segundo = crearHandler();
|
||||
final segundo = PluriWaveAudioHandler();
|
||||
|
||||
expect(
|
||||
segundo.ecualizadorActivo,
|
||||
@@ -140,12 +136,12 @@ void main() {
|
||||
|
||||
test('the cache follows what the handler itself writes, in both '
|
||||
'directions', () async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
await handler.setEcualizadorActivo(false);
|
||||
expect(
|
||||
crearHandler().ecualizadorActivo,
|
||||
PluriWaveAudioHandler().ecualizadorActivo,
|
||||
isFalse,
|
||||
reason:
|
||||
'the write side of the cache: a toggle must be visible to the '
|
||||
@@ -153,14 +149,14 @@ void main() {
|
||||
);
|
||||
|
||||
await handler.setEcualizadorActivo(true);
|
||||
expect(crearHandler().ecualizadorActivo, isTrue);
|
||||
expect(PluriWaveAudioHandler().ecualizadorActivo, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('B — the handler persists its OWN toggle', () {
|
||||
test('an eq toggle writes through the injected port even with no '
|
||||
'EstadoEcualizador in play', () async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final escrituras = <bool>[];
|
||||
|
||||
registrarHandler(
|
||||
@@ -181,7 +177,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('seeding from disk does NOT write back to disk', () async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final escrituras = <bool>[];
|
||||
|
||||
registrarHandler(
|
||||
@@ -196,7 +192,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('a failing write port never breaks the toggle', () async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
|
||||
registrarHandler(
|
||||
handler,
|
||||
@@ -252,7 +248,7 @@ void main() {
|
||||
|
||||
group('customAction dispatch (C — zero coverage before this)', () {
|
||||
test('the accionEqToggle literal routes through decidirToggleEq', () async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
await handler.setEcualizadorActivo(true);
|
||||
|
||||
@@ -265,7 +261,7 @@ void main() {
|
||||
|
||||
test('a car toggle persists through the same write port as a phone '
|
||||
'toggle', () async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final escrituras = <bool>[];
|
||||
registrarHandler(
|
||||
handler,
|
||||
@@ -286,7 +282,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('an unknown custom action is a silent no-op', () async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
final antes = handler.ecualizadorActivo;
|
||||
|
||||
@@ -384,7 +380,7 @@ void main() {
|
||||
|
||||
test('the first non-idle event re-asserts the native effect exactly '
|
||||
'once, and staying active never re-asserts again', () async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
|
||||
@@ -414,7 +410,7 @@ void main() {
|
||||
|
||||
test('going idle re-arms the edge, so stop + play re-asserts again — '
|
||||
'this is the `_reproductorActivo = proc != idle` line', () async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
|
||||
@@ -439,7 +435,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('with no native effect attached nothing is ever re-asserted', () async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
// `_eqDisponible` is false off-device, which is also the real
|
||||
// "device has no Equalizer effect" case.
|
||||
@@ -455,7 +451,7 @@ void main() {
|
||||
group('F — the EQ re-push must not rewind the car progress bar', () {
|
||||
test('the EQ controls re-push refreshes updatePosition from the '
|
||||
'player', () async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
handler.playbackState.add(
|
||||
handler.playbackState.value.copyWith(
|
||||
|
||||
@@ -1,43 +1,28 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
/// `mapearGananciaNativa` — the hand-off from the app's ±12 dB slider to the
|
||||
/// device's native `Equalizer`, whose capability is reported as
|
||||
/// `AndroidEqualizerParameters.min/maxDecibels`
|
||||
/// (`Equalizer.getBandLevelRange()` in millibels, divided by 1000).
|
||||
/// eq-estado-unico item E — `mapearGananciaNativa`, the translation from the
|
||||
/// app's fixed ±12 dB slider scale to whatever range the device's native
|
||||
/// `Equalizer.getBandLevelRange()` reports.
|
||||
///
|
||||
/// WHY THIS CONTRACT CHANGED — the previous one stretched each side of the
|
||||
/// slider against its own end of the native range, so `+6` on a device
|
||||
/// reporting `[-12, +20]` was delivered as `+10`. Both sides of the mapping
|
||||
/// are already the SAME unit, so that multiplication was a unit error:
|
||||
/// This is the only source-plausible explanation for the reported «suena muy
|
||||
/// alto» half of the bug. The original implementation normalised the input
|
||||
/// across the WHOLE range and mapped it linearly:
|
||||
///
|
||||
/// * `just_audio` documents `setGain` as "Sets the gain for this band in
|
||||
/// decibels", and its Android bridge does `setBandLevel(band,
|
||||
/// round(gain * 1000.0))` — plain dB to millibels, no normalisation.
|
||||
/// `min/maxDecibels` are the device's absolute CAPABILITY in dB, i.e. a
|
||||
/// bound on the control, not a scale to normalise into.
|
||||
/// * The app makes the user a decibel promise in three places at once: the
|
||||
/// slider is hard-coded `min: -12.0, max: 12.0`, the label under each
|
||||
/// band prints `'${banda.toStringAsFixed(1)}dB'`, and TalkBack reads out
|
||||
/// `equalizerBandValue` = "{value} decibels". Stretching made that label
|
||||
/// a lie on every device whose range is not exactly ±12.
|
||||
/// * Presets are persisted and EXPORTED as those same raw slider dB
|
||||
/// (`PresetEcualizador.toJson`), so under the old mapping a backup
|
||||
/// restored on a wider-range phone showed identical numbers and played
|
||||
/// louder — and on the common asymmetric shape `[-12, +19]` boosts were
|
||||
/// multiplied by 1.58 while cuts were not, deforming the preset's SHAPE
|
||||
/// rather than merely its depth.
|
||||
/// normalizado = (db.clamp(-12, 12) + 12) / 24
|
||||
/// return minDecibels + normalizado * (maxDecibels - minDecibels)
|
||||
///
|
||||
/// So: the number the user reads is the number the device is asked for. The
|
||||
/// native range only CLAMPS it.
|
||||
/// which sends 0 dB to the MIDPOINT of the native range. That is only 0 when
|
||||
/// the range happens to be symmetric. Android does not guarantee that: the
|
||||
/// AudioEffect Equalizer contract only requires a min/max pair, and real
|
||||
/// devices ship asymmetric ranges. On such a device a FLAT preset — every
|
||||
/// band 0 dB — was silently pushing a positive boost into every band, which
|
||||
/// is audibly louder while the on/off button still reads "off".
|
||||
///
|
||||
/// What this deliberately KEEPS from the previous contract — every invariant
|
||||
/// the «suena muy alto» fix actually earned. 0 dB is always exactly 0 (a
|
||||
/// naive `db.clamp(minDecibels, maxDecibels)` would regress that on a wholly
|
||||
/// positive reported range, turning a FLAT preset into a boost again), the
|
||||
/// sign of the user's intent is never inverted, the result never escapes the
|
||||
/// native range, a device with no headroom above unity can never boost, and a
|
||||
/// zero-width range collapses to 0.
|
||||
/// The contract asserted here: 0 dB always maps to exactly 0, and the two
|
||||
/// sides of the scale are stretched INDEPENDENTLY against their own end of
|
||||
/// the native range, so the sign of the user's intent is never inverted and
|
||||
/// the extremes still reach the device's real limits.
|
||||
void main() {
|
||||
group('mapearGananciaNativa — 0 dB is always exactly 0', () {
|
||||
test('symmetric range (the common case) is unchanged', () {
|
||||
@@ -58,10 +43,6 @@ void main() {
|
||||
});
|
||||
|
||||
test('a wholly positive range still cannot boost a FLAT preset', () {
|
||||
// This is precisely why the mapping cannot be a plain
|
||||
// `db.clamp(minDecibels, maxDecibels)`: that would answer +3 here and
|
||||
// bring the «suena muy alto» bug straight back. The clamp window has
|
||||
// to be widened so that it always contains 0.
|
||||
expect(mapearGananciaNativa(0, minDecibels: 3, maxDecibels: 19), 0);
|
||||
});
|
||||
|
||||
@@ -70,61 +51,36 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('mapearGananciaNativa — the slider dB reach the device literally', () {
|
||||
test('+12 dB is delivered as +12 dB, not stretched to the native max', () {
|
||||
// CONTRACT CHANGE: this used to assert 19, i.e. the whole of the
|
||||
// device's headroom. The slider says "12.0dB" and the accessibility
|
||||
// label says "12.0 decibels", so 12 dB is what the device must be
|
||||
// asked for. The 7 dB of extra hardware headroom is unreachable by
|
||||
// design until the slider itself is widened and says so.
|
||||
expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 12);
|
||||
group('mapearGananciaNativa — the extremes reach the native limits', () {
|
||||
test('+12 dB maps to the native maximum', () {
|
||||
expect(mapearGananciaNativa(12, minDecibels: -12, maxDecibels: 19), 19);
|
||||
});
|
||||
|
||||
test('-12 dB is delivered as -12 dB', () {
|
||||
test('-12 dB maps to the native minimum', () {
|
||||
expect(mapearGananciaNativa(-12, minDecibels: -12, maxDecibels: 19), -12);
|
||||
});
|
||||
|
||||
test('values beyond the slider scale clamp to the slider limit', () {
|
||||
// CONTRACT CHANGE: these used to answer the NATIVE extremes (±15).
|
||||
// The slider scale is the first bound; the device range is the second.
|
||||
expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 12);
|
||||
expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -12);
|
||||
test('values beyond the slider scale are clamped, not extrapolated', () {
|
||||
expect(mapearGananciaNativa(40, minDecibels: -15, maxDecibels: 15), 15);
|
||||
expect(mapearGananciaNativa(-40, minDecibels: -15, maxDecibels: 15), -15);
|
||||
});
|
||||
});
|
||||
|
||||
group('mapearGananciaNativa — the label is the value the device gets', () {
|
||||
test('+6 dB on a wide-range device is +6 dB, never 10', () {
|
||||
// CONTRACT CHANGE: this used to assert closeTo(10) — "half boost is
|
||||
// half of the positive headroom". A slider reading "6.0dB" that
|
||||
// produced +10 dB of real boost is exactly what made a restored backup
|
||||
// sound different on a different phone.
|
||||
group('mapearGananciaNativa — each side scales against its own end', () {
|
||||
test('half boost is half of the positive headroom', () {
|
||||
expect(
|
||||
mapearGananciaNativa(6, minDecibels: -12, maxDecibels: 20),
|
||||
closeTo(6, 1e-9),
|
||||
closeTo(10, 1e-9),
|
||||
);
|
||||
});
|
||||
|
||||
test('-6 dB on that same device is -6 dB', () {
|
||||
test('half cut is half of the negative headroom', () {
|
||||
expect(
|
||||
mapearGananciaNativa(-6, minDecibels: -12, maxDecibels: 20),
|
||||
closeTo(-6, 1e-9),
|
||||
);
|
||||
});
|
||||
|
||||
test('the six factory presets keep their shape on an asymmetric device', () {
|
||||
// Jazz, authored in true dB before any scaling existed. Under the old
|
||||
// mapping [-12, +19] delivered it as [4.75, -1, -1.5, 3.17, 6.33]: a
|
||||
// different tonal curve, not merely a louder one.
|
||||
const jazz = [3.0, -1.0, -1.5, 2.0, 4.0];
|
||||
final entregado = jazz
|
||||
.map(
|
||||
(db) =>
|
||||
mapearGananciaNativa(db, minDecibels: -12, maxDecibels: 19),
|
||||
)
|
||||
.toList();
|
||||
expect(entregado, jazz);
|
||||
});
|
||||
|
||||
test('the sign of the user intent is never inverted', () {
|
||||
for (final db in [-12.0, -6.0, -1.0, 1.0, 6.0, 12.0]) {
|
||||
final nativo = mapearGananciaNativa(
|
||||
@@ -141,44 +97,12 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('mapearGananciaNativa — a device narrower than the slider', () {
|
||||
test('a request that fits is still delivered literally', () {
|
||||
// CONTRACT CHANGE: the old mapping shrank this to (3/12)*6 = 1.5 dB,
|
||||
// so a modest device silently under-delivered every request too.
|
||||
expect(mapearGananciaNativa(3, minDecibels: -6, maxDecibels: 6), 3);
|
||||
expect(mapearGananciaNativa(-3, minDecibels: -6, maxDecibels: 6), -3);
|
||||
});
|
||||
|
||||
test('a request beyond the device range clamps to the device limit', () {
|
||||
expect(mapearGananciaNativa(12, minDecibels: -6, maxDecibels: 6), 6);
|
||||
expect(mapearGananciaNativa(-12, minDecibels: -6, maxDecibels: 6), -6);
|
||||
});
|
||||
|
||||
test('a very narrow device still gets a sane, in-range value', () {
|
||||
for (final db in [-12.0, -5.0, 0.0, 5.0, 12.0]) {
|
||||
final nativo = mapearGananciaNativa(
|
||||
db,
|
||||
minDecibels: -1.5,
|
||||
maxDecibels: 1.5,
|
||||
);
|
||||
expect(nativo, greaterThanOrEqualTo(-1.5));
|
||||
expect(nativo, lessThanOrEqualTo(1.5));
|
||||
expect(nativo.sign, db.sign);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('mapearGananciaNativa — degenerate ranges reported by the device', () {
|
||||
test('a device with no headroom above unity can never boost', () {
|
||||
test('a range with no headroom on one side clamps that side to 0', () {
|
||||
// A device that reports max == 0 can only cut. Asking for a boost must
|
||||
// resolve to "no change", never to a negative value.
|
||||
expect(mapearGananciaNativa(12, minDecibels: -15, maxDecibels: 0), 0);
|
||||
expect(mapearGananciaNativa(6, minDecibels: -15, maxDecibels: 0), 0);
|
||||
});
|
||||
|
||||
test('a cut the device could honour exactly is not over-delivered', () {
|
||||
// CONTRACT CHANGE: this used to answer -15, spending the device's whole
|
||||
// range on a request for -12 dB. The user asked for -12; -12 is
|
||||
// representable here, so -12 is what is sent.
|
||||
expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -12);
|
||||
expect(mapearGananciaNativa(-12, minDecibels: -15, maxDecibels: 0), -15);
|
||||
});
|
||||
|
||||
test('a zero-width range collapses everything to 0', () {
|
||||
|
||||
@@ -6,8 +6,6 @@ import 'package:pluriwave/servicios/navegacion_auto.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/handlers_audio.dart';
|
||||
|
||||
/// Android Auto play-path backstop (design.md ADR-4, android-auto-media
|
||||
/// spec "Free-Tier Browse Never Leaks Real Content" + "Current-Station
|
||||
/// Playback Unaffected By Free Tier"): `playFromMediaId`, `playFromSearch`,
|
||||
@@ -22,30 +20,12 @@ import '../helpers/handlers_audio.dart';
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final crearHandler = registrarHandlersLiberables();
|
||||
|
||||
// fix/auto-quality-guidelines item 11: the gate is CONTENT-scoped now.
|
||||
// Blocking every switch for the free tier is what made the car surface
|
||||
// useless for the only tier a Play reviewer can be in.
|
||||
test('free tier: bloquea una emisora del catálogo premium', () {
|
||||
expect(
|
||||
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: false),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('free tier: NO bloquea una emisora del set gratuito', () {
|
||||
expect(
|
||||
debeBloquearCambioDeEmisora(premium: false, esEmisoraGratuita: true),
|
||||
isFalse,
|
||||
);
|
||||
test('free tier: bloquea cualquier cambio de emisora/salto', () {
|
||||
expect(debeBloquearCambioDeEmisora(premium: false), isTrue);
|
||||
});
|
||||
|
||||
test('premium: nunca bloquea', () {
|
||||
expect(
|
||||
debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: false),
|
||||
isFalse,
|
||||
);
|
||||
expect(debeBloquearCambioDeEmisora(premium: true), isFalse);
|
||||
});
|
||||
|
||||
/// fix/android-auto-musica-local, item 4: el hook dejó de ser «solo la
|
||||
@@ -71,7 +51,7 @@ void main() {
|
||||
|
||||
test('registrarHandler conecta la invalidación al handler: una llamada '
|
||||
'notifica la raíz Y Música Local', () async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
final raiz = <Map<String, dynamic>>[];
|
||||
@@ -106,13 +86,13 @@ void main() {
|
||||
group('subscribeToChildren', () {
|
||||
test('el sujeto arranca SIN valor: nada que reenviar en la primera '
|
||||
'suscripción, así que no hay notifyChildrenChanged espurio', () {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
|
||||
expect(handler.subscribeToChildren('musica_local').hasValue, isFalse);
|
||||
});
|
||||
|
||||
test('memoiza por id: dos llamadas devuelven el MISMO stream', () {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
|
||||
expect(
|
||||
identical(
|
||||
@@ -132,7 +112,7 @@ void main() {
|
||||
|
||||
test('notificarHijosCambiaron sí empuja un valor al sujeto ya suscrito',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final stream = handler.subscribeToChildren('musica_local');
|
||||
final recibidos = <Map<String, dynamic>>[];
|
||||
final sub = stream.listen(recibidos.add);
|
||||
@@ -163,7 +143,7 @@ void main() {
|
||||
|
||||
Future<List<String>> idsRaizCon(EstadoCarpetaLocal estado) async {
|
||||
registrarFuenteMusicaLocal(_FakeFuenteMusicaLocalGating(estado));
|
||||
final handler = crearHandler();
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final items = await handler.getChildren(AudioService.browsableRootId);
|
||||
return items.map((i) => i.id).toList();
|
||||
}
|
||||
|
||||
@@ -1,736 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' show Locale;
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
|
||||
import '../helpers/handlers_audio.dart';
|
||||
|
||||
/// Android for Cars App Quality Guidelines — transport state machine.
|
||||
///
|
||||
/// Google Play returned "Approved with Issues" against version code 157:
|
||||
/// «clicking on stop button makes the entire app useless». These tests drive
|
||||
/// the REAL [PluriWaveAudioHandler] against a scripted [AudioPlayer] double
|
||||
/// (installed through [PluriWaveAudioHandler.fabricaReproductorPrueba]) so
|
||||
/// the published `playbackState` sequence — the only thing Android Auto ever
|
||||
/// sees — can be asserted end to end.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final crearHandler = registrarHandlersLiberables();
|
||||
|
||||
late _GuionReproductor guion;
|
||||
|
||||
setUp(() {
|
||||
guion = _GuionReproductor();
|
||||
PluriWaveAudioHandler.fabricaReproductorPrueba =
|
||||
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
|
||||
});
|
||||
|
||||
group('stop() durante cambios de fuente en vuelo (P0 — botón Stop)', () {
|
||||
test(
|
||||
'dos cambios encolados y un stop: el ultimo estado publicado es idle, '
|
||||
'nunca vuelve a loading',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
final publicados = <AudioProcessingState>[];
|
||||
final sub = handler.playbackState.listen(
|
||||
(estado) => publicados.add(estado.processingState),
|
||||
);
|
||||
|
||||
unawaited(
|
||||
handler
|
||||
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
|
||||
.catchError((_) {}),
|
||||
);
|
||||
unawaited(
|
||||
handler
|
||||
.playMediaItem(const MediaItem(id: 'https://b', title: 'B'))
|
||||
.catchError((_) {}),
|
||||
);
|
||||
|
||||
await handler.stop();
|
||||
// Drain both queued source changes: they must discover the stale
|
||||
// revision WITHOUT ever publishing again.
|
||||
await pumpEventQueue();
|
||||
await sub.cancel();
|
||||
|
||||
expect(
|
||||
publicados.last,
|
||||
AudioProcessingState.idle,
|
||||
reason:
|
||||
'a stale queued source change must never rewrite `loading` over '
|
||||
'the `idle` that stop() published — that is what leaves Android '
|
||||
'Auto spinning forever on a dead session. Secuencia: $publicados',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('pause() durante un cambio de fuente en vuelo (P0 — botón Pausa)', () {
|
||||
test('la emisora NO arranca: _player.play() nunca se invoca', () async {
|
||||
guion.completerSetUrl = Completer<Duration?>();
|
||||
final handler = crearHandler();
|
||||
|
||||
unawaited(
|
||||
handler
|
||||
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
|
||||
.catchError((_) {}),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
expect(
|
||||
guion.llamadasSetUrl,
|
||||
1,
|
||||
reason: 'precondicion: el cambio de fuente esta en vuelo',
|
||||
);
|
||||
|
||||
await handler.pause();
|
||||
guion.completerSetUrl!.complete(null);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
guion.llamadasPlay,
|
||||
0,
|
||||
reason:
|
||||
'the user pressed Pause while the station was loading — the load '
|
||||
'finishing afterwards must never start playback behind their back',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'y el coche no se queda en el spinner: el estado publicado sale de '
|
||||
'loading',
|
||||
() async {
|
||||
guion.completerSetUrl = Completer<Duration?>();
|
||||
final handler = crearHandler();
|
||||
|
||||
unawaited(
|
||||
handler
|
||||
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
|
||||
.catchError((_) {}),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
await handler.pause();
|
||||
guion.completerSetUrl!.complete(null);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
handler.playbackState.value.processingState,
|
||||
isNot(AudioProcessingState.loading),
|
||||
reason:
|
||||
'withholding the play() must not leave the car showing the '
|
||||
'spinner the load started with — nothing else will publish, '
|
||||
'because the player never transitions',
|
||||
);
|
||||
expect(handler.playbackState.value.playing, isFalse);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('Suelo de estado terminal (P0 — nunca un spinner eterno)', () {
|
||||
setUp(() {
|
||||
PluriWaveAudioHandler.vigilanciaTransitoria = const Duration(
|
||||
milliseconds: 60,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PluriWaveAudioHandler.vigilanciaTransitoria =
|
||||
PluriWaveAudioHandler.vigilanciaTransitoriaPorDefecto;
|
||||
});
|
||||
|
||||
test(
|
||||
'un buffering publicado SIN carga viva cae a un estado terminal dentro '
|
||||
'de la ventana',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
|
||||
handler.manejarEstadoPlayer(
|
||||
PlayerState(false, ProcessingState.buffering),
|
||||
);
|
||||
expect(
|
||||
handler.playbackState.value.processingState,
|
||||
AudioProcessingState.buffering,
|
||||
reason: 'precondicion: el coche esta viendo el spinner',
|
||||
);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
|
||||
expect(
|
||||
handler.playbackState.value.processingState,
|
||||
isIn(const [
|
||||
AudioProcessingState.ready,
|
||||
AudioProcessingState.idle,
|
||||
AudioProcessingState.error,
|
||||
]),
|
||||
reason:
|
||||
'the only exits from loading/buffering are player events that '
|
||||
'.distinct() can swallow — without a floor the car spins '
|
||||
'forever over a session nobody is driving',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('una carga LEGITIMA en vuelo no se interrumpe', () async {
|
||||
guion.completerSetUrl = Completer<Duration?>();
|
||||
final handler = crearHandler();
|
||||
|
||||
unawaited(
|
||||
handler
|
||||
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
|
||||
.catchError((_) {}),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
|
||||
expect(
|
||||
handler.playbackState.value.processingState,
|
||||
AudioProcessingState.loading,
|
||||
reason:
|
||||
'the watchdog is a floor for a STALLED state machine, not a cap '
|
||||
'on how long a slow station may take to open',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'un mount estancado NO cae en un idle mudo: publica un motivo legible '
|
||||
'(hallazgo 3)',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
|
||||
// Exact shape of a stalled icecast mount: the socket opens, `setUrl`
|
||||
// returns inside the timeout (so no TimeoutException and no
|
||||
// PlayerException — `_esErrorDeRed` never fires and the reconnect
|
||||
// machine is never entered), and then no data ever arrives. State
|
||||
// sits at buffering with `_cambiosEnVuelo` already back to 0.
|
||||
handler.manejarEstadoPlayer(
|
||||
PlayerState(false, ProcessingState.buffering),
|
||||
);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
|
||||
final estado = handler.playbackState.value;
|
||||
expect(
|
||||
estado.processingState,
|
||||
AudioProcessingState.error,
|
||||
reason:
|
||||
'a bare `idle` routes straight into `AudioService._stop()` '
|
||||
'(audio_service.dart:1131-1135), so the driver got silence, a '
|
||||
'dead session and no explanation. `error` keeps the session '
|
||||
'alive and carries a message',
|
||||
);
|
||||
expect(
|
||||
estado.errorMessage,
|
||||
isNotNull,
|
||||
reason:
|
||||
'the floor must say something the driver can read and act on',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'un rebuffer normal a mitad de emision NO se convierte en error '
|
||||
'(regresion: el suelo miraba solo processingState)',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
|
||||
// Established playback: the stream delivered audio and ExoPlayer
|
||||
// reached `ready` while playing. This is what separates a re-buffer
|
||||
// from a mount that never produced a byte.
|
||||
handler.manejarEstadoPlayer(PlayerState(true, ProcessingState.ready));
|
||||
// Ordinary mid-stream re-buffer: `bufferForPlaybackAfterRebuffer` is
|
||||
// 5 s, so a tunnel or an LTE handover routinely holds this state for
|
||||
// longer than the floor's window. ExoPlayer raised no error, so
|
||||
// `_intentarReconexion` never ran and `reintentoPendiente` is false;
|
||||
// `_cambiosEnVuelo` is already 0 because the non-blocking
|
||||
// `_iniciarPlaySinBloquear` returned long ago.
|
||||
handler.manejarEstadoPlayer(
|
||||
PlayerState(true, ProcessingState.buffering),
|
||||
);
|
||||
expect(
|
||||
handler.playbackState.value.processingState,
|
||||
AudioProcessingState.buffering,
|
||||
reason: 'precondicion: el reproductor esta rellenando el buffer',
|
||||
);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
|
||||
final estado = handler.playbackState.value;
|
||||
expect(
|
||||
estado.processingState,
|
||||
isNot(AudioProcessingState.error),
|
||||
reason:
|
||||
'a self-recovering re-buffer over live audio must never be '
|
||||
'converted into a hard STATE_ERROR: the driver is in a tunnel, '
|
||||
'not on a dead mount, and `_errorTerminal` latches so nothing '
|
||||
'the player emits afterwards could undo it',
|
||||
);
|
||||
expect(
|
||||
estado.playing,
|
||||
isTrue,
|
||||
reason:
|
||||
'the player still owns the timeline — publishing `playing: '
|
||||
'false` over it desynchronises the head unit transport row',
|
||||
);
|
||||
expect(
|
||||
estado.errorMessage,
|
||||
isNull,
|
||||
reason: 'nothing failed, so there is nothing to tell the driver',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'un mount que NUNCA entrego audio sigue cayendo al suelo aunque el '
|
||||
'reproductor diga playing: true',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
|
||||
// `just_audio`'s `playing` is the play-when-ready intent flag: it
|
||||
// flips to true the moment `play()` is called, whether or not a
|
||||
// single byte ever arrives. So the stalled icecast mount the floor
|
||||
// exists for reports `playing: true` too — `playing` alone can never
|
||||
// be the discriminator.
|
||||
handler.manejarEstadoPlayer(
|
||||
PlayerState(true, ProcessingState.buffering),
|
||||
);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 250));
|
||||
|
||||
final estado = handler.playbackState.value;
|
||||
expect(
|
||||
estado.processingState,
|
||||
AudioProcessingState.error,
|
||||
reason:
|
||||
'no `ready` was ever reached on this run, so nothing is '
|
||||
're-buffering: the driver is staring at a spinner and the floor '
|
||||
'is the only exit',
|
||||
);
|
||||
expect(estado.errorMessage, isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'la ventana del suelo respeta el presupuesto de diez segundos hasta el '
|
||||
'primer mensaje',
|
||||
() {
|
||||
expect(
|
||||
PluriWaveAudioHandler.vigilanciaTransitoriaPorDefecto,
|
||||
lessThanOrEqualTo(const Duration(seconds: 10)),
|
||||
reason:
|
||||
'the floor is the ONLY exit for a stalled mount, so its window '
|
||||
'IS the time-to-first-message for that failure mode; twenty '
|
||||
"seconds was double the code's own cited budget",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('Error terminal de reproduccion: la sesion sobrevive (hallazgo 2)', () {
|
||||
test(
|
||||
'el ultimo estado publicado es error CON mensaje, y no lo sigue un idle',
|
||||
() async {
|
||||
// A non-network failure: `_esErrorDeRed` is false, so this goes
|
||||
// straight down the terminal path instead of the reconnect machine.
|
||||
guion.errorSetUrl = Exception('mount muerto');
|
||||
final handler = crearHandler();
|
||||
final publicados = <AudioProcessingState>[];
|
||||
final sub = handler.playbackState.listen(
|
||||
(estado) => publicados.add(estado.processingState),
|
||||
);
|
||||
|
||||
await handler
|
||||
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
|
||||
.catchError((_) {});
|
||||
await pumpEventQueue();
|
||||
|
||||
// What `_player.stop()` really does: just_audio.dart:1016-1025
|
||||
// switches to the idle dummy platform, so `playerStateStream` emits a
|
||||
// distinct (playing:false, idle). The double cannot do that on its
|
||||
// own, so the test drives the exact event the real player would.
|
||||
guion.ultimoReproductor!.emitir(
|
||||
PlayerState(false, ProcessingState.idle),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
await sub.cancel();
|
||||
|
||||
final estado = handler.playbackState.value;
|
||||
expect(
|
||||
estado.processingState,
|
||||
AudioProcessingState.error,
|
||||
reason:
|
||||
'forwarding that idle makes audio_service call '
|
||||
'AudioService._stop() -> deactivateMediaSession() + stopSelf(), '
|
||||
'so PluriWave dropped off the Android Auto playback surface a '
|
||||
'single event-loop turn after showing the error',
|
||||
);
|
||||
expect(estado.errorMessage, isNotNull);
|
||||
expect(
|
||||
publicados.last,
|
||||
isNot(AudioProcessingState.idle),
|
||||
reason: 'secuencia publicada: $publicados',
|
||||
);
|
||||
expect(
|
||||
handler.mediaItem.value,
|
||||
isNotNull,
|
||||
reason:
|
||||
'Android Auto drops a session with no metadata to show, so '
|
||||
'nulling the media item on the error path makes the app vanish '
|
||||
'from the car pane even when the state itself survives — the '
|
||||
'station that failed has to keep its name on screen',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'un stop() del usuario DESPUES del error sigue produciendo un idle real '
|
||||
'(la sesion tiene que poder morir cuando el conductor lo pide)',
|
||||
() async {
|
||||
guion.errorSetUrl = Exception('mount muerto');
|
||||
final handler = crearHandler();
|
||||
|
||||
await handler
|
||||
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
|
||||
.catchError((_) {});
|
||||
await pumpEventQueue();
|
||||
expect(
|
||||
handler.playbackState.value.processingState,
|
||||
AudioProcessingState.error,
|
||||
reason: 'precondicion',
|
||||
);
|
||||
|
||||
await handler.stop();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
handler.playbackState.value.processingState,
|
||||
AudioProcessingState.idle,
|
||||
reason:
|
||||
'suppressing the error-driven idle must NEVER make the Stop '
|
||||
'button unkillable — that is the original citation',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('Presupuesto de tiempo hasta el primer mensaje (<= 10 s)', () {
|
||||
tearDown(() {
|
||||
PluriWaveAudioHandler.timeoutCambioFuente =
|
||||
PluriWaveAudioHandler.timeoutCambioFuentePorDefecto;
|
||||
});
|
||||
|
||||
test('el timeout por defecto deja el primer mensaje dentro de 10 s', () {
|
||||
expect(
|
||||
PluriWaveAudioHandler.timeoutCambioFuentePorDefecto,
|
||||
lessThanOrEqualTo(const Duration(seconds: 10)),
|
||||
reason:
|
||||
'Android for Cars App Quality Guidelines allow ten seconds before '
|
||||
'the driver must be told something; the first attempt alone used '
|
||||
'to burn twelve',
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'una fuente que nunca responde publica un mensaje visible al agotar el '
|
||||
'primer intento',
|
||||
() async {
|
||||
PluriWaveAudioHandler.timeoutCambioFuente = const Duration(
|
||||
milliseconds: 100,
|
||||
);
|
||||
guion.setUrlCuelga = true;
|
||||
final handler = crearHandler();
|
||||
|
||||
unawaited(
|
||||
handler
|
||||
.playMediaItem(const MediaItem(id: 'https://muerta', title: 'X'))
|
||||
.catchError((_) {}),
|
||||
);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
expect(
|
||||
handler.playbackState.value.errorMessage,
|
||||
isNotNull,
|
||||
reason:
|
||||
'the backoff used to publish `buffering` with errorMessage: '
|
||||
'null, so the car showed a silent spinner for the whole ~100 s '
|
||||
'reconnect window',
|
||||
);
|
||||
expect(
|
||||
handler.playbackState.value.processingState,
|
||||
AudioProcessingState.buffering,
|
||||
reason: 'still retrying — the message rides ON TOP of the retry',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('los reintentos siguen DETRAS del mensaje', () async {
|
||||
PluriWaveAudioHandler.timeoutCambioFuente = const Duration(
|
||||
milliseconds: 100,
|
||||
);
|
||||
guion.setUrlCuelga = true;
|
||||
final handler = crearHandler();
|
||||
|
||||
unawaited(
|
||||
handler
|
||||
.playMediaItem(const MediaItem(id: 'https://muerta', title: 'X'))
|
||||
.catchError((_) {}),
|
||||
);
|
||||
// First backoff delay is 1 s (ControladorReconexion default).
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1300));
|
||||
|
||||
expect(
|
||||
guion.llamadasSetUrl,
|
||||
greaterThanOrEqualTo(2),
|
||||
reason: 'the reconnect machine keeps working after the first message',
|
||||
);
|
||||
expect(
|
||||
handler.playbackState.value.errorMessage,
|
||||
isNotNull,
|
||||
reason:
|
||||
'and the message survives the retry: re-entering `_cambiarFuente` '
|
||||
'must not blank the car screen back to a silent spinner',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/// A handler nobody released goes on running: its terminal-state floor
|
||||
/// timer, its `ControladorReconexion` backoff (1/2/4/8/16 s, which easily
|
||||
/// outlives the test that armed it) and whatever is still queued on
|
||||
/// `_colaCambioFuente`. When one of those finally performs a source change
|
||||
/// it calls `_crearPlayer()`, which reads the CURRENT static
|
||||
/// `fabricaReproductorPrueba` — so it builds a double bound to a LATER
|
||||
/// test's script and increments that test's counters for work it never
|
||||
/// asked for. A suite that passes under those conditions passes by luck.
|
||||
group('Liberacion del handler: nada sobrevive al test que lo creo', () {
|
||||
tearDown(() {
|
||||
PluriWaveAudioHandler.timeoutCambioFuente =
|
||||
PluriWaveAudioHandler.timeoutCambioFuentePorDefecto;
|
||||
});
|
||||
|
||||
test(
|
||||
'un handler liberado NO vuelve a construir un reproductor contra la '
|
||||
'fabrica del test siguiente',
|
||||
() async {
|
||||
PluriWaveAudioHandler.timeoutCambioFuente = const Duration(
|
||||
milliseconds: 60,
|
||||
);
|
||||
guion.setUrlCuelga = true;
|
||||
final handler = crearHandler();
|
||||
|
||||
unawaited(
|
||||
handler
|
||||
.playMediaItem(const MediaItem(id: 'https://muerta', title: 'X'))
|
||||
.catchError((_) {}),
|
||||
);
|
||||
// Long enough for the source-change timeout to fire and the reconnect
|
||||
// machine to arm its first backoff retry (1 s).
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
expect(
|
||||
guion.llamadasSetUrl,
|
||||
1,
|
||||
reason: 'precondicion: hay un reintento armado detras',
|
||||
);
|
||||
|
||||
await handler.liberar();
|
||||
|
||||
// Exactly what the framework does between tests: a brand-new script
|
||||
// and a factory bound to it. Nothing from the previous test may
|
||||
// reach this.
|
||||
final guionSiguiente = _GuionReproductor();
|
||||
PluriWaveAudioHandler.fabricaReproductorPrueba = (pipeline, carga) =>
|
||||
_ReproductorFalso(guionSiguiente, pipeline, carga);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 1400));
|
||||
|
||||
expect(
|
||||
guionSiguiente.llamadasSetUrl,
|
||||
0,
|
||||
reason:
|
||||
'the leaked backoff retry re-enters `_cambiarFuente`, which '
|
||||
'calls `_crearPlayer()` and therefore reads whatever factory is '
|
||||
'installed NOW — attributing a dead handler s work to the test '
|
||||
'that happens to be running',
|
||||
);
|
||||
expect(
|
||||
guionSiguiente.ultimoReproductor,
|
||||
isNull,
|
||||
reason: 'no player at all may be built against the new script',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('liberar() es idempotente', () async {
|
||||
final handler = crearHandler();
|
||||
|
||||
await handler.liberar();
|
||||
await handler.liberar();
|
||||
});
|
||||
});
|
||||
|
||||
group('Idioma de la superficie del coche (motor sin Activity)', () {
|
||||
tearDown(() {
|
||||
PluriWaveAudioHandler.lectorLocalePlataforma =
|
||||
PluriWaveAudioHandler.lectorLocalePlataformaPorDefecto;
|
||||
});
|
||||
|
||||
/// Drives a NON-network failure through the real source-change path so the
|
||||
/// terminal error message published to the car can be read back.
|
||||
Future<String?> mensajeDeError(PluriWaveAudioHandler handler) async {
|
||||
guion.errorSetUrl = Exception('boom');
|
||||
await handler
|
||||
.playMediaItem(const MediaItem(id: 'https://a', title: 'A'))
|
||||
.catchError((_) {});
|
||||
await pumpEventQueue();
|
||||
return handler.playbackState.value.errorMessage;
|
||||
}
|
||||
|
||||
test(
|
||||
'sin configurarLocalizaciones, los mensajes salen en el locale de la '
|
||||
'plataforma, no en es',
|
||||
() async {
|
||||
PluriWaveAudioHandler.lectorLocalePlataforma = () =>
|
||||
const Locale('en');
|
||||
final handler = crearHandler();
|
||||
|
||||
final mensaje = await mensajeDeError(handler);
|
||||
|
||||
expect(
|
||||
mensaje,
|
||||
lookupAppLocalizations(const Locale('en')).audioErrorUnexpectedPlayback,
|
||||
reason:
|
||||
'`configurarLocalizaciones` only ever runs from '
|
||||
'`mini_reproductor.dart` didChangeDependencies. The headless '
|
||||
'Android Auto engine has no Activity and no widget tree, so it '
|
||||
'never ran there and every car message came out in Spanish',
|
||||
);
|
||||
expect(
|
||||
mensaje,
|
||||
isNot(
|
||||
lookupAppLocalizations(
|
||||
const Locale('es'),
|
||||
).audioErrorUnexpectedPlayback,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('un locale de plataforma no soportado conserva el respaldo es', () async {
|
||||
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('sw');
|
||||
final handler = crearHandler();
|
||||
|
||||
expect(
|
||||
await mensajeDeError(handler),
|
||||
lookupAppLocalizations(const Locale('es')).audioErrorUnexpectedPlayback,
|
||||
reason: 'the existing fallback must survive an unresolvable locale',
|
||||
);
|
||||
});
|
||||
|
||||
test('configurarLocalizaciones sigue teniendo prioridad', () async {
|
||||
PluriWaveAudioHandler.lectorLocalePlataforma = () => const Locale('en');
|
||||
final handler = crearHandler();
|
||||
handler.configurarLocalizaciones(
|
||||
lookupAppLocalizations(const Locale('fr')),
|
||||
);
|
||||
|
||||
expect(
|
||||
await mensajeDeError(handler),
|
||||
lookupAppLocalizations(const Locale('fr')).audioErrorUnexpectedPlayback,
|
||||
reason: 'the phone UI still owns the locale once a widget tree exists',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Shared script/observation record for every [_ReproductorFalso] the handler
|
||||
/// builds (it rebuilds its player on every source change, so counters cannot
|
||||
/// live on the instance).
|
||||
class _GuionReproductor {
|
||||
int llamadasPlay = 0;
|
||||
int llamadasSetUrl = 0;
|
||||
final urlsSolicitadas = <String>[];
|
||||
|
||||
/// When set, `setUrl` completes with this error instead of succeeding.
|
||||
Object? errorSetUrl;
|
||||
|
||||
/// When true, `setUrl` never completes (simulates a dead stream that only
|
||||
/// the source-change timeout can end).
|
||||
bool setUrlCuelga = false;
|
||||
|
||||
/// When set, `setUrl` returns this completer's future, so a test can hold a
|
||||
/// source change mid-flight and release it after acting on the handler.
|
||||
Completer<Duration?>? completerSetUrl;
|
||||
|
||||
/// The handler rebuilds its player on every source change, so a test that
|
||||
/// needs to drive a player event has to reach the LATEST instance.
|
||||
_ReproductorFalso? ultimoReproductor;
|
||||
}
|
||||
|
||||
/// A [AudioPlayer] whose platform-touching methods are replaced by the script
|
||||
/// above. Everything else (the rx subjects the constructor wires up) is the
|
||||
/// real thing, so the handler's stream plumbing is exercised unchanged.
|
||||
class _ReproductorFalso extends AudioPlayer {
|
||||
_ReproductorFalso(
|
||||
this._guion,
|
||||
AudioPipeline pipeline,
|
||||
AudioLoadConfiguration carga,
|
||||
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga) {
|
||||
_guion.ultimoReproductor = this;
|
||||
}
|
||||
|
||||
final _GuionReproductor _guion;
|
||||
final _estados = StreamController<PlayerState>.broadcast();
|
||||
|
||||
/// Drives the exact `playerStateStream` event the real player would emit.
|
||||
void emitir(PlayerState estado) {
|
||||
if (!_estados.isClosed) _estados.add(estado);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<PlayerState> get playerStateStream => _estados.stream;
|
||||
|
||||
@override
|
||||
Future<Duration?> setUrl(
|
||||
String url, {
|
||||
Map<String, String>? headers,
|
||||
Duration? initialPosition,
|
||||
bool preload = true,
|
||||
dynamic tag,
|
||||
}) {
|
||||
_guion.llamadasSetUrl++;
|
||||
_guion.urlsSolicitadas.add(url);
|
||||
if (_guion.setUrlCuelga) return Completer<Duration?>().future;
|
||||
final pendiente = _guion.completerSetUrl;
|
||||
if (pendiente != null) return pendiente.future;
|
||||
final error = _guion.errorSetUrl;
|
||||
if (error != null) return Future<Duration?>.error(error);
|
||||
return Future<Duration?>.value(null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {
|
||||
_guion.llamadasPlay++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
|
||||
@override
|
||||
Future<void> setVolume(double volume) async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _estados.close();
|
||||
}
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:pluriwave/modelos/emisora.dart';
|
||||
import 'package:pluriwave/servicios/emisoras_destacadas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/handlers_audio.dart';
|
||||
|
||||
/// Resuming the last station in Android Auto — the three defects that made a
|
||||
/// car-only session unable to remember, restart or even NAME what it was
|
||||
/// playing.
|
||||
///
|
||||
/// Every test here runs with NO widget tree and NO browse source registered:
|
||||
/// that is the engine Android Auto actually starts
|
||||
/// (`AudioServicePlugin.java:75-111` builds `new FlutterEngine(context)` with
|
||||
/// no Activity), so `EstadoRadio` — the only thing that used to write
|
||||
/// `ultima_emisora_v1` — is never constructed there.
|
||||
///
|
||||
/// A1. The last station was written EXCLUSIVELY by `EstadoRadio`, so a
|
||||
/// session that happened only in the car never updated the key and the
|
||||
/// head unit was offered the station from the last time the PHONE was
|
||||
/// used. The same key feeds `resolverEmisorasDestacadas`, so the free
|
||||
/// tier's featured folder was stale too.
|
||||
///
|
||||
/// A2. `play()` with no source called `_player.play()`, and
|
||||
/// `just_audio.dart:937-967` publishes `_playingSubject.add(true)`
|
||||
/// BEFORE the `_audioSource != null` gate — so the platform was never
|
||||
/// touched, the returned Future never completed, and `playing: true`
|
||||
/// was forwarded over `processingState: idle`.
|
||||
/// `AudioService.java:559-560` then runs `enterPlayingState()` while
|
||||
/// `getPlaybackState()` is `STATE_NONE`: a notification with a pause
|
||||
/// button, no audio, no title and no artwork (or a
|
||||
/// `ForegroundServiceStartNotAllowedException` on API 31+).
|
||||
///
|
||||
/// A3. `mediaItem` was null on a cold start — the only `mediaItem.add` sites
|
||||
/// are the duration update, `_cambiarFuente` and `stop` — so
|
||||
/// `audio_service.dart:1029-1033` returned before `setMediaItem` and the
|
||||
/// native side got no metadata at all.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final crearHandler = registrarHandlersLiberables();
|
||||
|
||||
late _GuionReproductor guion;
|
||||
|
||||
/// The free set's first station: resolvable from the binary alone, so it
|
||||
/// works on a bind where no browse source was ever registered — which is
|
||||
/// the whole point of these tests.
|
||||
const emisoraFip = Emisora(
|
||||
uuid: 'pw-destacada-fip',
|
||||
nombre: 'FIP',
|
||||
url: 'https://icecast.radiofrance.fr/fip-midfi.mp3',
|
||||
pais: 'France',
|
||||
codigoPais: 'FR',
|
||||
idioma: 'french',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
guion = _GuionReproductor();
|
||||
PluriWaveAudioHandler.fabricaReproductorPrueba =
|
||||
(pipeline, carga) => _ReproductorFalso(guion, pipeline, carga);
|
||||
// Fresh install = free tier (`esPremiumPersistido` is `getBool(...) ??
|
||||
// false`) and no `ultima_emisora_v1`.
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PluriWaveAudioHandler.fabricaReproductorPrueba = null;
|
||||
});
|
||||
|
||||
group('A1 — el coche escribe la ultima emisora', () {
|
||||
test(
|
||||
'playFromMediaId desde el coche persiste ESA emisora por el puerto '
|
||||
'inyectado, sin arbol de widgets',
|
||||
() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final handler = crearHandler();
|
||||
final guardadas = <Emisora>[];
|
||||
registrarHandler(
|
||||
handler,
|
||||
guardarUltimaEmisora: (emisora) async {
|
||||
guardadas.add(emisora);
|
||||
await guardarUltimaEmisoraPersistida(emisora, prefs: prefs);
|
||||
},
|
||||
);
|
||||
|
||||
await handler.playFromMediaId('emisora:${emisoraFip.uuid}');
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
guardadas.map((e) => e.uuid),
|
||||
[emisoraFip.uuid],
|
||||
reason:
|
||||
'a car-only session must update `ultima_emisora_v1` itself — '
|
||||
'`EstadoRadio` is never built on a headless engine',
|
||||
);
|
||||
final persistida = await ultimaEmisoraPersistida(prefs: prefs);
|
||||
expect(persistida?.uuid, emisoraFip.uuid);
|
||||
expect(
|
||||
persistida?.url,
|
||||
emisoraFip.url,
|
||||
reason:
|
||||
'the record has to be PLAYABLE: it is what the recent root and '
|
||||
'`resolverEmisorasDestacadas` hand back to the head unit',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('playMediaItem directo (voz, telefono) persiste igual', () async {
|
||||
final handler = crearHandler();
|
||||
final guardadas = <Emisora>[];
|
||||
registrarHandler(
|
||||
handler,
|
||||
guardarUltimaEmisora: (emisora) async => guardadas.add(emisora),
|
||||
);
|
||||
|
||||
await handler.playMediaItem(
|
||||
const MediaItem(
|
||||
id: 'https://ejemplo/stream',
|
||||
title: 'Ejemplo',
|
||||
extras: {'uuid': 'uuid-ejemplo'},
|
||||
),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(guardadas.map((e) => e.uuid), ['uuid-ejemplo']);
|
||||
expect(guardadas.single.url, 'https://ejemplo/stream');
|
||||
});
|
||||
|
||||
test(
|
||||
'una pista local NO se persiste como ultima emisora',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
final guardadas = <Emisora>[];
|
||||
registrarHandler(
|
||||
handler,
|
||||
guardarUltimaEmisora: (emisora) async => guardadas.add(emisora),
|
||||
);
|
||||
|
||||
await handler.playMediaItem(
|
||||
const MediaItem(
|
||||
id: 'content://media/audio/7',
|
||||
title: 'Pista local',
|
||||
extras: {'documentId': 'doc-7'},
|
||||
),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
guardadas,
|
||||
isEmpty,
|
||||
reason:
|
||||
'`ultima_emisora_v1` feeds the recent root and the featured '
|
||||
'folder as an `emisora:<uuid>` row — a `content://` track '
|
||||
'there is a row that does nothing when tapped',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('un fallo del puerto se traza y NUNCA propaga', () async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(
|
||||
handler,
|
||||
guardarUltimaEmisora: (_) async => throw StateError('sin disco'),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
handler.playMediaItem(
|
||||
const MediaItem(
|
||||
id: 'https://ejemplo/stream',
|
||||
title: 'Ejemplo',
|
||||
extras: {'uuid': 'uuid-ejemplo'},
|
||||
),
|
||||
),
|
||||
completes,
|
||||
);
|
||||
await pumpEventQueue();
|
||||
});
|
||||
|
||||
test('sin puerto (tests de widget, fakes) no pasa nada', () async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
await expectLater(
|
||||
handler.playMediaItem(
|
||||
const MediaItem(
|
||||
id: 'https://ejemplo/stream',
|
||||
title: 'Ejemplo',
|
||||
extras: {'uuid': 'uuid-ejemplo'},
|
||||
),
|
||||
),
|
||||
completes,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('A2 — play() sin fuente no publica una sesion fantasma', () {
|
||||
test(
|
||||
'con una emisora persistida, play() resuelve y arranca ESA emisora: el '
|
||||
'reproductor recibe su url',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerUltimaEmisora: () async => emisoraFip,
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
unawaited(handler.play().catchError((_) {}));
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
guion.urlsSolicitadas,
|
||||
contains(emisoraFip.url),
|
||||
reason:
|
||||
'`AudioService.java:920` routes the car KEYCODE_MEDIA_PLAY '
|
||||
'straight into play(); on a cold engine there is no source, so '
|
||||
'it has to resolve the persisted station instead',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'y NINGUN estado publicado lleva playing:true sobre processingState '
|
||||
'idle',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerUltimaEmisora: () async => emisoraFip,
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
final fantasmas = <PlaybackState>[];
|
||||
final sub = handler.playbackState.listen((estado) {
|
||||
if (estado.playing &&
|
||||
estado.processingState == AudioProcessingState.idle) {
|
||||
fantasmas.add(estado);
|
||||
}
|
||||
});
|
||||
|
||||
unawaited(handler.play().catchError((_) {}));
|
||||
await pumpEventQueue();
|
||||
await sub.cancel();
|
||||
|
||||
expect(
|
||||
fantasmas,
|
||||
isEmpty,
|
||||
reason:
|
||||
'playing:true over idle is what makes `AudioService.java:559` '
|
||||
'call enterPlayingState() with STATE_NONE — a PluriWave '
|
||||
'notification with a pause button, no audio and no title',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'sin nada persistido: no se toca el reproductor, no hay estado '
|
||||
'fantasma y play() no se queda colgado',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler, leerUltimaEmisora: () async => null);
|
||||
await pumpEventQueue();
|
||||
|
||||
final fantasmas = <PlaybackState>[];
|
||||
final sub = handler.playbackState.listen((estado) {
|
||||
if (estado.playing &&
|
||||
estado.processingState == AudioProcessingState.idle) {
|
||||
fantasmas.add(estado);
|
||||
}
|
||||
});
|
||||
|
||||
await expectLater(
|
||||
handler.play().timeout(const Duration(seconds: 2)),
|
||||
completes,
|
||||
);
|
||||
await pumpEventQueue();
|
||||
await sub.cancel();
|
||||
|
||||
expect(
|
||||
guion.llamadasPlay,
|
||||
0,
|
||||
reason:
|
||||
'with nothing to restore the player must not be touched at '
|
||||
'all: `just_audio` publishes playing:true before its source '
|
||||
'gate and never completes the future it returns',
|
||||
);
|
||||
expect(guion.llamadasSetUrl, 0);
|
||||
expect(fantasmas, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'con una fuente ya abierta, play() sigue siendo la reanudacion de '
|
||||
'siempre (pausa -> play no reabre nada)',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerUltimaEmisora: () async => emisoraFip,
|
||||
);
|
||||
await handler.playMediaItem(
|
||||
const MediaItem(
|
||||
id: 'https://ejemplo/stream',
|
||||
title: 'Ejemplo',
|
||||
extras: {'uuid': 'uuid-ejemplo'},
|
||||
),
|
||||
);
|
||||
await pumpEventQueue();
|
||||
await handler.pause();
|
||||
final urlsAntes = List<String>.from(guion.urlsSolicitadas);
|
||||
|
||||
await handler.play();
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
guion.urlsSolicitadas,
|
||||
urlsAntes,
|
||||
reason:
|
||||
'a resume must NOT re-open the source, and must never replace '
|
||||
'the live station with the persisted one',
|
||||
);
|
||||
expect(handler.intencionReproducir, isTrue);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('A3 — arranque en frio: el coche recibe metadatos', () {
|
||||
test(
|
||||
'con una emisora persistida se publica su mediaItem SIN arrancar '
|
||||
'reproduccion',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
|
||||
registrarHandler(
|
||||
handler,
|
||||
leerUltimaEmisora: () async => emisoraFip,
|
||||
);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
handler.mediaItem.value,
|
||||
isNotNull,
|
||||
reason:
|
||||
'`audio_service.dart:1029-1033` returns before setMediaItem '
|
||||
'when mediaItem is null, so a cold engine sent the head unit '
|
||||
'no metadata whatsoever',
|
||||
);
|
||||
expect(handler.mediaItem.value?.id, emisoraFip.url);
|
||||
expect(handler.playbackState.value.playing, isFalse);
|
||||
expect(
|
||||
guion.llamadasSetUrl,
|
||||
0,
|
||||
reason:
|
||||
'publishing metadata must not open a stream: a cold bind '
|
||||
'happens on every reconnect and must stay silent',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('sin nada persistido el mediaItem sigue vacio', () async {
|
||||
final handler = crearHandler();
|
||||
|
||||
registrarHandler(handler, leerUltimaEmisora: () async => null);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(handler.mediaItem.value, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'una emisora que ya empezo a sonar NO es pisada por la siembra',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
final lectura = Completer<Emisora?>();
|
||||
|
||||
registrarHandler(handler, leerUltimaEmisora: () => lectura.future);
|
||||
await handler.playMediaItem(
|
||||
const MediaItem(
|
||||
id: 'https://enVivo/stream',
|
||||
title: 'En vivo',
|
||||
extras: {'uuid': 'uuid-en-vivo'},
|
||||
),
|
||||
);
|
||||
lectura.complete(emisoraFip);
|
||||
await pumpEventQueue();
|
||||
|
||||
expect(
|
||||
handler.mediaItem.value?.id,
|
||||
'https://enVivo/stream',
|
||||
reason:
|
||||
'the seed exists to fill a VOID; clobbering the live station '
|
||||
'would rename what the driver is listening to',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('getMediaItem resuelve tambien el set destacado', () {
|
||||
test(
|
||||
'sin fuente de navegacion registrada, una emisora destacada resuelve',
|
||||
() async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
final item = await handler.getMediaItem('emisora:${emisoraFip.uuid}');
|
||||
|
||||
expect(
|
||||
item,
|
||||
isNotNull,
|
||||
reason:
|
||||
'`porUuid` already falls back to the featured set, so the car '
|
||||
'could BROWSE a featured station and not resolve its media '
|
||||
'item — the asymmetry is the bug',
|
||||
);
|
||||
expect(item?.id, 'emisora:${emisoraFip.uuid}');
|
||||
expect(item?.title, emisoraFip.nombre);
|
||||
},
|
||||
);
|
||||
|
||||
test('un id que no es de emisora sigue devolviendo null', () async {
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
expect(await handler.getMediaItem('pista:doc-1'), isNull);
|
||||
expect(await handler.getMediaItem('emisora:'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Shared script/observation record for every [_ReproductorFalso] the handler
|
||||
/// builds (it rebuilds its player on every source change, so counters cannot
|
||||
/// live on the instance).
|
||||
class _GuionReproductor {
|
||||
int llamadasPlay = 0;
|
||||
int llamadasSetUrl = 0;
|
||||
final urlsSolicitadas = <String>[];
|
||||
}
|
||||
|
||||
/// An [AudioPlayer] double that reproduces the ONE `just_audio` behaviour
|
||||
/// defect A2 is about: `play()` (`just_audio.dart:937-967`) publishes
|
||||
/// `playing: true` BEFORE the `_audioSource != null` gate, and with no source
|
||||
/// it never touches the platform and never completes the future it returned.
|
||||
class _ReproductorFalso extends AudioPlayer {
|
||||
_ReproductorFalso(
|
||||
this._guion,
|
||||
AudioPipeline pipeline,
|
||||
AudioLoadConfiguration carga,
|
||||
) : super(audioPipeline: pipeline, audioLoadConfiguration: carga);
|
||||
|
||||
final _GuionReproductor _guion;
|
||||
final _estados = StreamController<PlayerState>.broadcast();
|
||||
|
||||
/// A fresh player has no source, exactly like the real one.
|
||||
bool _fuenteCargada = false;
|
||||
|
||||
@override
|
||||
Stream<PlayerState> get playerStateStream => _estados.stream;
|
||||
|
||||
@override
|
||||
Future<Duration?> setUrl(
|
||||
String url, {
|
||||
Map<String, String>? headers,
|
||||
Duration? initialPosition,
|
||||
bool preload = true,
|
||||
dynamic tag,
|
||||
}) async {
|
||||
_guion.llamadasSetUrl++;
|
||||
_guion.urlsSolicitadas.add(url);
|
||||
_fuenteCargada = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() {
|
||||
_guion.llamadasPlay++;
|
||||
if (!_estados.isClosed) {
|
||||
_estados.add(
|
||||
PlayerState(
|
||||
true,
|
||||
_fuenteCargada ? ProcessingState.ready : ProcessingState.idle,
|
||||
),
|
||||
);
|
||||
}
|
||||
// The dangling future: with no source, upstream `play()` awaits a
|
||||
// `_playingSubject` transition the platform will never produce.
|
||||
if (!_fuenteCargada) return Completer<void>().future;
|
||||
return Future<void>.value();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
|
||||
@override
|
||||
Future<void> setVolume(double volume) async {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _estados.close();
|
||||
}
|
||||
}
|
||||
@@ -105,80 +105,6 @@ void main() {
|
||||
expect(favoritos.single.grupoFavoritosId, GrupoFavoritos.sinAsignarId);
|
||||
expect(grupos.any((g) => g.id == grupo.id), isFalse);
|
||||
});
|
||||
|
||||
// ── Primitivas de restauración (importarConfig) ────────────────────────────
|
||||
//
|
||||
// `agregar` es la primitiva de "marcar como favorita": fuerza `sin_asignar`
|
||||
// y un `orden` nuevo a propósito, porque una emisora recién marcada no
|
||||
// pertenece a ningún grupo. Reusarla para RESTAURAR una copia de seguridad
|
||||
// destruía justo los dos campos que la copia traía. `restaurarFavorito` es
|
||||
// la primitiva que faltaba.
|
||||
|
||||
test('restaurarFavorito preserva el grupo y el orden de la copia', () async {
|
||||
final servicio = crearServicio();
|
||||
addTearDown(servicio.cerrar);
|
||||
|
||||
await servicio.restaurarGrupo(
|
||||
const GrupoFavoritos(id: 'grupo-rock', nombre: 'Rock', orden: 7),
|
||||
);
|
||||
|
||||
await servicio.restaurarFavorito(
|
||||
_emisora(
|
||||
'radio-1',
|
||||
'Radio Uno',
|
||||
).copyWith(orden: 42, grupoFavoritosId: 'grupo-rock'),
|
||||
);
|
||||
|
||||
final favoritos = await servicio.obtenerTodos();
|
||||
expect(favoritos.single.grupoFavoritosId, 'grupo-rock');
|
||||
expect(favoritos.single.orden, 42);
|
||||
});
|
||||
|
||||
test('restaurarFavorito cae a Sin asignar cuando el grupo de la copia no '
|
||||
'existe (copia editada a mano o restauración parcial)', () async {
|
||||
final servicio = crearServicio();
|
||||
addTearDown(servicio.cerrar);
|
||||
|
||||
await servicio.restaurarFavorito(
|
||||
_emisora(
|
||||
'radio-1',
|
||||
'Radio Uno',
|
||||
).copyWith(orden: 3, grupoFavoritosId: 'grupo-fantasma'),
|
||||
);
|
||||
|
||||
final favoritos = await servicio.obtenerTodos();
|
||||
expect(favoritos.single.grupoFavoritosId, GrupoFavoritos.sinAsignarId);
|
||||
expect(favoritos.single.orden, 3);
|
||||
});
|
||||
|
||||
test('restaurarGrupo hace upsert preservando id, nombre y orden, y NUNCA '
|
||||
'duplica el grupo protegido', () async {
|
||||
final servicio = crearServicio();
|
||||
addTearDown(servicio.cerrar);
|
||||
|
||||
await servicio.restaurarGrupo(
|
||||
const GrupoFavoritos(id: 'grupo-jazz', nombre: 'Jazz', orden: 5),
|
||||
);
|
||||
// Segunda pasada (reimportar la misma copia): upsert, no duplicado.
|
||||
await servicio.restaurarGrupo(
|
||||
const GrupoFavoritos(id: 'grupo-jazz', nombre: 'Jazz renombrado', orden: 5),
|
||||
);
|
||||
await servicio.restaurarGrupo(
|
||||
const GrupoFavoritos(
|
||||
id: GrupoFavoritos.sinAsignarId,
|
||||
nombre: 'Unassigned',
|
||||
orden: 0,
|
||||
protegido: true,
|
||||
),
|
||||
);
|
||||
|
||||
final grupos = await servicio.obtenerGrupos();
|
||||
final jazz = grupos.singleWhere((g) => g.id == 'grupo-jazz');
|
||||
expect(jazz.nombre, 'Jazz renombrado');
|
||||
expect(jazz.orden, 5);
|
||||
// El protegido conserva su nombre local: no se importa desde la copia.
|
||||
expect(grupos.singleWhere((g) => g.esSinAsignar).nombre, 'Sin asignar');
|
||||
});
|
||||
}
|
||||
|
||||
Emisora _emisora(String uuid, String nombre) {
|
||||
|
||||
Reference in New Issue
Block a user