fix: cumplir las guias de calidad de Android Auto y localizar el arbol del coche
Google Play devolvio "Approved with Issues" en el codigo 157: "clicking on stop button makes the entire app useless", citado contra las Android for Cars App Quality Guidelines. La causa no era el boton de parar. Maquina de estados del transporte _cambiarFuente publicaba mediaItem y loading ANTES de su primer await y solo comprobaba su revision despues de que _recrearPlayer retornase. Los cambios de fuente se encolan incrementando la revision al encolar, no al ejecutar, asi que tocar una emisora, tocar otra antes de que cargue y pulsar Stop dejaba que las entradas obsoletas reescribieran loading sobre el idle que stop() acababa de publicar. Estado final: loading para siempre sobre una sesion que audio_service ya habia desactivado. Ahora la guarda de revision es la primera sentencia del metodo. pause() no invalidaba una carga en vuelo, asi que la emisora arrancaba igual despues de pulsar pausa; se revalida la intencion antes de llamar a play(). Se anade un suelo de estado que cierra cualquier loading o buffering sin carga viva, exento cuando el reproductor ya entrego audio y solo esta rebufferando, para no convertir un tunel en un error. El presupuesto hasta el primer mensaje baja a menos de diez segundos y los reintentos ya no borran el mensaje visible. Tier gratuito en el coche El arbol devolvia una unica fila no reproducible para cualquier carpeta cuando no habia premium, y un revisor con instalacion limpia siempre es tier gratuito. Ademas skipToNext, skipToPrevious, playFromSearch y playFromMediaId retornaban en silencio. La raiz gratuita pasa a ofrecer una sola carpeta con emisoras reales y reproducibles, compiladas en el binario para que existan en frio, y la puerta de entitlement acota contenido en vez de bloquear acciones. Se elimina la fila "Funcion Premium". Una consulta de voz vacia arranca la ultima emisora, que fallaba tambien a los clientes de pago. Localizacion El locale del handler solo lo fijaba un widget que el motor headless nunca construye, asi que todo error del coche salia en castellano. Se resuelve desde el locale de plataforma. Se traducen las once etiquetas del arbol que estaban a fuego y se retira la convencion que lo justificaba. Un test nuevo falla si vuelve a aparecer texto visible fuera del sistema de traduccion. Suite completa: 1455 pasan, 2 omitidos. Los mecanismos se verificaron por mutacion: borrar cada uno pone la suite en rojo. flutter analyze mantiene los 5 avisos preexistentes.
This commit is contained in:
+14
-1
@@ -912,5 +912,18 @@
|
||||
"premiumBeneficioVacaciones": "فترات إجازة للمنبهات",
|
||||
"premiumBeneficioAlarmasIlimitadas": "منبهات غير محدودة (تسمح الخطة المجانية بحتى 5)",
|
||||
"premiumPagoUnico": "دفعة واحدة، للأبد. ليس اشتراكًا.",
|
||||
"premiumAhoraNo": "ليس الآن"
|
||||
"premiumAhoraNo": "ليس الآن",
|
||||
"autoErrorEmisoraPremium": "هذه المحطة ضمن Premium. افتح PluriWave على هاتفك لفتحها.",
|
||||
"autoErrorBusquedaSinResultados": "لم نعثر على تلك المحطة. جرّب اسمًا آخر.",
|
||||
"autoCarpetaEscuchar": "الاستماع",
|
||||
"autoCarpetaFavoritos": "المفضلة",
|
||||
"autoCarpetaTodas": "كل المحطات",
|
||||
"autoCarpetaMisEmisoras": "محطاتي",
|
||||
"autoCarpetaMusicaLocal": "الموسيقى المحلية",
|
||||
"autoMusicaLocalNoDisponible": "افتح PluriWave على هاتفك لقراءة موسيقاك",
|
||||
"autoCargarMas": "المزيد…",
|
||||
"autoOrdenarPorCalidad": "الترتيب حسب الجودة",
|
||||
"autoReproducirCarpeta": "تشغيل المجلد",
|
||||
"autoReproducirAleatorio": "تشغيل عشوائي",
|
||||
"autoPistaSinNombre": "مقطع بلا اسم"
|
||||
}
|
||||
|
||||
+14
-1
@@ -912,5 +912,18 @@
|
||||
"premiumBeneficioVacaciones": "অ্যালার্মের জন্য ছুটির সময়কাল",
|
||||
"premiumBeneficioAlarmasIlimitadas": "সীমাহীন অ্যালার্ম (ফ্রি প্ল্যানে সর্বোচ্চ ৫টি অনুমোদিত)",
|
||||
"premiumPagoUnico": "একবারের পেমেন্ট, চিরকালের জন্য। এটি সাবস্ক্রিপশন নয়।",
|
||||
"premiumAhoraNo": "এখন নয়"
|
||||
"premiumAhoraNo": "এখন নয়",
|
||||
"autoErrorEmisoraPremium": "এই স্টেশনটি Premium। আনলক করতে ফোনে PluriWave খুলুন।",
|
||||
"autoErrorBusquedaSinResultados": "সেই স্টেশনটি খুঁজে পাওয়া যায়নি। অন্য নাম চেষ্টা করুন।",
|
||||
"autoCarpetaEscuchar": "শুনুন",
|
||||
"autoCarpetaFavoritos": "প্রিয়",
|
||||
"autoCarpetaTodas": "সব স্টেশন",
|
||||
"autoCarpetaMisEmisoras": "আমার স্টেশন",
|
||||
"autoCarpetaMusicaLocal": "স্থানীয় সঙ্গীত",
|
||||
"autoMusicaLocalNoDisponible": "আপনার গান পড়তে ফোনে PluriWave খুলুন",
|
||||
"autoCargarMas": "আরও…",
|
||||
"autoOrdenarPorCalidad": "মান অনুসারে সাজান",
|
||||
"autoReproducirCarpeta": "ফোল্ডার চালান",
|
||||
"autoReproducirAleatorio": "এলোমেলোভাবে চালান",
|
||||
"autoPistaSinNombre": "নামহীন ট্র্যাক"
|
||||
}
|
||||
|
||||
+14
-1
@@ -912,5 +912,18 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
|
||||
+14
-1
@@ -912,5 +912,18 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
|
||||
+14
-1
@@ -871,5 +871,18 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
|
||||
+14
-1
@@ -912,5 +912,18 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
|
||||
+14
-1
@@ -912,5 +912,18 @@
|
||||
"premiumBeneficioVacaciones": "अलार्म के लिए छुट्टी की अवधि",
|
||||
"premiumBeneficioAlarmasIlimitadas": "असीमित अलार्म (मुफ़्त प्लान में अधिकतम 5 की अनुमति है)",
|
||||
"premiumPagoUnico": "एकमुश्त भुगतान, हमेशा के लिए। यह सदस्यता नहीं है।",
|
||||
"premiumAhoraNo": "अभी नहीं"
|
||||
"premiumAhoraNo": "अभी नहीं",
|
||||
"autoErrorEmisoraPremium": "यह स्टेशन Premium है। इसे अनलॉक करने के लिए फ़ोन पर PluriWave खोलें।",
|
||||
"autoErrorBusquedaSinResultados": "वह स्टेशन नहीं मिला। कोई दूसरा नाम आज़माएँ।",
|
||||
"autoCarpetaEscuchar": "सुनें",
|
||||
"autoCarpetaFavoritos": "पसंदीदा",
|
||||
"autoCarpetaTodas": "सभी स्टेशन",
|
||||
"autoCarpetaMisEmisoras": "मेरे स्टेशन",
|
||||
"autoCarpetaMusicaLocal": "लोकल संगीत",
|
||||
"autoMusicaLocalNoDisponible": "अपना संगीत पढ़ने के लिए फ़ोन पर PluriWave खोलें",
|
||||
"autoCargarMas": "और…",
|
||||
"autoOrdenarPorCalidad": "गुणवत्ता के अनुसार क्रमबद्ध करें",
|
||||
"autoReproducirCarpeta": "फ़ोल्डर चलाएँ",
|
||||
"autoReproducirAleatorio": "शफ़ल चलाएँ",
|
||||
"autoPistaSinNombre": "बिना नाम का ट्रैक"
|
||||
}
|
||||
|
||||
+14
-1
@@ -912,5 +912,18 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
|
||||
+14
-1
@@ -912,5 +912,18 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
|
||||
+14
-1
@@ -912,5 +912,18 @@
|
||||
"premiumBeneficioVacaciones": "アラームの休暇期間設定",
|
||||
"premiumBeneficioAlarmasIlimitadas": "アラーム数無制限(無料プランは5個まで)",
|
||||
"premiumPagoUnico": "買い切りの一度きりの支払いで永久に使用可能。サブスクリプションではありません。",
|
||||
"premiumAhoraNo": "後で"
|
||||
"premiumAhoraNo": "後で",
|
||||
"autoErrorEmisoraPremium": "この放送局は Premium です。スマートフォンで PluriWave を開いてロックを解除してください。",
|
||||
"autoErrorBusquedaSinResultados": "その放送局は見つかりませんでした。別の名前をお試しください。",
|
||||
"autoCarpetaEscuchar": "聴く",
|
||||
"autoCarpetaFavoritos": "お気に入り",
|
||||
"autoCarpetaTodas": "すべての局",
|
||||
"autoCarpetaMisEmisoras": "マイ局",
|
||||
"autoCarpetaMusicaLocal": "ローカルの音楽",
|
||||
"autoMusicaLocalNoDisponible": "音楽を読み込むにはスマートフォンで PluriWave を開いてください",
|
||||
"autoCargarMas": "もっと見る…",
|
||||
"autoOrdenarPorCalidad": "音質順に並べ替え",
|
||||
"autoReproducirCarpeta": "フォルダを再生",
|
||||
"autoReproducirAleatorio": "シャッフル再生",
|
||||
"autoPistaSinNombre": "名称未設定のトラック"
|
||||
}
|
||||
|
||||
+14
-1
@@ -912,5 +912,18 @@
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
|
||||
+14
-1
@@ -912,5 +912,18 @@
|
||||
"premiumBeneficioVacaciones": "Периоды отпуска для будильников",
|
||||
"premiumBeneficioAlarmasIlimitadas": "Неограниченное количество будильников (бесплатный план позволяет до 5)",
|
||||
"premiumPagoUnico": "Единоразовая покупка, навсегда. Это не подписка.",
|
||||
"premiumAhoraNo": "Не сейчас"
|
||||
"premiumAhoraNo": "Не сейчас",
|
||||
"autoErrorEmisoraPremium": "Эта станция доступна в Premium. Откройте PluriWave на телефоне, чтобы разблокировать её.",
|
||||
"autoErrorBusquedaSinResultados": "Мы не нашли такую станцию. Попробуйте другое название.",
|
||||
"autoCarpetaEscuchar": "Слушать",
|
||||
"autoCarpetaFavoritos": "Избранное",
|
||||
"autoCarpetaTodas": "Все станции",
|
||||
"autoCarpetaMisEmisoras": "Мои станции",
|
||||
"autoCarpetaMusicaLocal": "Локальная музыка",
|
||||
"autoMusicaLocalNoDisponible": "Откройте PluriWave на телефоне, чтобы прочитать вашу музыку",
|
||||
"autoCargarMas": "Ещё…",
|
||||
"autoOrdenarPorCalidad": "Сортировать по качеству",
|
||||
"autoReproducirCarpeta": "Воспроизвести папку",
|
||||
"autoReproducirAleatorio": "Случайное воспроизведение",
|
||||
"autoPistaSinNombre": "Трек без названия"
|
||||
}
|
||||
|
||||
+14
-1
@@ -912,5 +912,18 @@
|
||||
"premiumBeneficioVacaciones": "闹钟的假期时间段",
|
||||
"premiumBeneficioAlarmasIlimitadas": "无限闹钟(免费版最多支持5个)",
|
||||
"premiumPagoUnico": "一次性付费,永久使用,不是订阅。",
|
||||
"premiumAhoraNo": "以后再说"
|
||||
"premiumAhoraNo": "以后再说",
|
||||
"autoErrorEmisoraPremium": "该电台属于 Premium 内容。请在手机上打开 PluriWave 解锁。",
|
||||
"autoErrorBusquedaSinResultados": "没有找到该电台。请换个名称再试。",
|
||||
"autoCarpetaEscuchar": "收听",
|
||||
"autoCarpetaFavoritos": "收藏",
|
||||
"autoCarpetaTodas": "全部电台",
|
||||
"autoCarpetaMisEmisoras": "我的电台",
|
||||
"autoCarpetaMusicaLocal": "本地音乐",
|
||||
"autoMusicaLocalNoDisponible": "请在手机上打开 PluriWave 以读取您的音乐",
|
||||
"autoCargarMas": "更多…",
|
||||
"autoOrdenarPorCalidad": "按音质排序",
|
||||
"autoReproducirCarpeta": "播放文件夹",
|
||||
"autoReproducirAleatorio": "随机播放",
|
||||
"autoPistaSinNombre": "未命名曲目"
|
||||
}
|
||||
|
||||
@@ -3415,6 +3415,84 @@ 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,4 +1888,46 @@ 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,4 +1900,46 @@ 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,4 +1913,46 @@ 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,4 +1893,46 @@ 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,4 +1908,46 @@ 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,4 +1922,46 @@ 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,4 +1893,46 @@ 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,4 +1904,46 @@ 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,4 +1919,46 @@ 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,4 +1836,45 @@ 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,4 +1904,46 @@ 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,4 +1911,46 @@ 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,4 +1821,44 @@ 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 => '未命名曲目';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
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);
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../modelos/emisora.dart';
|
||||
import '../modelos/grupo_favoritos.dart';
|
||||
import '../modelos/pista_local.dart';
|
||||
import '../modelos/preset_ecualizador.dart';
|
||||
import 'emisoras_destacadas.dart';
|
||||
import 'musica_local_auto.dart';
|
||||
import 'persistencia_tolerante.dart';
|
||||
import 'servicio_favoritos.dart';
|
||||
@@ -200,10 +201,97 @@ 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].
|
||||
@@ -211,6 +299,17 @@ 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
|
||||
@@ -322,8 +421,10 @@ class ConstructorArbolAuto {
|
||||
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 2,
|
||||
};
|
||||
|
||||
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras,
|
||||
/// optionally Música Local, Ecualizador), all non-playable.
|
||||
/// 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).
|
||||
///
|
||||
/// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
|
||||
/// There is NO `Ecualizador` folder. The car's only equalizer control is
|
||||
@@ -343,53 +444,77 @@ 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
|
||||
/// `fuente.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada`
|
||||
/// `premium && fuente.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada`
|
||||
/// (fix/android-auto-musica-local: un canal nativo ausente ya NO oculta el
|
||||
/// nodo), keeping this builder itself synchronous and side-effect free.
|
||||
/// 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.
|
||||
///
|
||||
/// [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.
|
||||
/// [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.
|
||||
List<MediaItem> raiz({
|
||||
required bool incluirMusicaLocal,
|
||||
required bool premium,
|
||||
}) => [
|
||||
_carpeta(idFavoritos, 'Favoritos'),
|
||||
_carpeta(idTodas, 'Todas las emisoras'),
|
||||
_carpeta(idMisEmisoras, 'Mis emisoras'),
|
||||
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
|
||||
];
|
||||
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)];
|
||||
|
||||
/// 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';
|
||||
/// 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();
|
||||
|
||||
/// 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.
|
||||
/// El item de [idLocalNoLista]. Rotulado con
|
||||
/// [EtiquetasArbolAuto.musicaLocalNoDisponible], ya resuelto al idioma del
|
||||
/// head unit. No reproducible — seleccionarlo es un no-op.
|
||||
MediaItem itemLocalNoDisponible() => MediaItem(
|
||||
id: idLocalNoLista,
|
||||
title: 'Abre PluriWave en el móvil para leer tu música',
|
||||
title: etiquetas.musicaLocalNoDisponible,
|
||||
playable: false,
|
||||
extras: _contentStyleLista,
|
||||
);
|
||||
@@ -488,20 +613,15 @@ 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: _tituloMasLocal,
|
||||
title: etiquetas.cargarMas,
|
||||
playable: false,
|
||||
extras: _contentStyleLista,
|
||||
);
|
||||
@@ -626,41 +746,41 @@ class ConstructorArbolAuto {
|
||||
/// for small folders.
|
||||
bool ofreceBuckets(int totalPistas) => totalPistas > _minPistasParaBuckets;
|
||||
|
||||
/// The "Ordenar por calidad" mode-entry `MediaItem` (Design ADR-4):
|
||||
/// The "sort by quality" 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].
|
||||
/// Hardcoded Spanish label, matching every other car-tree label in this
|
||||
/// file — never routed through `AppLocalizations` (established
|
||||
/// car-tree-label precedent, see [_tituloMasLocal]).
|
||||
/// Rotulado con [EtiquetasArbolAuto.ordenarPorCalidad].
|
||||
MediaItem _itemModoOrdenCalidad(String documentIdPadre) => _carpeta(
|
||||
'${_prefijoCarpetaLocalOrd}calidad:0:$documentIdPadre',
|
||||
'Ordenar por calidad',
|
||||
etiquetas.ordenarPorCalidad,
|
||||
);
|
||||
|
||||
/// 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 the hardcoded
|
||||
/// alphabetical-range label (e.g. `'A-F'`), matching every other
|
||||
/// car-tree label in this file — never routed through `AppLocalizations`.
|
||||
/// 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.
|
||||
MediaItem _itemBucket(String documentIdPadre, int idx, String etiqueta) =>
|
||||
_carpeta('$_prefijoCarpetaLocalBucket$idx:0:$documentIdPadre', etiqueta);
|
||||
|
||||
/// 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`.
|
||||
/// The "play folder" playable action item (Design ADR-5): id
|
||||
/// `carpeta_local_reproducir:<documentIdPadre>`. Rotulado con
|
||||
/// [EtiquetasArbolAuto.reproducirCarpeta].
|
||||
MediaItem _itemReproducirCarpeta(String documentIdPadre) => MediaItem(
|
||||
id: '$_prefijoCarpetaLocalReproducir$documentIdPadre',
|
||||
title: 'Reproducir carpeta',
|
||||
title: etiquetas.reproducirCarpeta,
|
||||
playable: true,
|
||||
extras: _contentStyleGrid,
|
||||
);
|
||||
|
||||
/// The "Reproducir aleatorio" playable action item (Design ADR-5),
|
||||
/// The "shuffle play" playable action item (Design ADR-5),
|
||||
/// mirrors [_itemReproducirCarpeta].
|
||||
MediaItem _itemReproducirAleatorio(String documentIdPadre) => MediaItem(
|
||||
id: '$_prefijoCarpetaLocalAleatorio$documentIdPadre',
|
||||
title: 'Reproducir aleatorio',
|
||||
title: etiquetas.reproducirAleatorio,
|
||||
playable: true,
|
||||
extras: _contentStyleGrid,
|
||||
);
|
||||
@@ -742,7 +862,7 @@ class ConstructorArbolAuto {
|
||||
int siguientePagina,
|
||||
) => MediaItem(
|
||||
id: '$_prefijoCarpetaLocalOrd$modo:$siguientePagina:$documentIdPadre',
|
||||
title: _tituloMasLocal,
|
||||
title: etiquetas.cargarMas,
|
||||
playable: false,
|
||||
extras: _contentStyleLista,
|
||||
);
|
||||
@@ -756,7 +876,7 @@ class ConstructorArbolAuto {
|
||||
int siguientePagina,
|
||||
) => MediaItem(
|
||||
id: '$_prefijoCarpetaLocalBucket$idxBucket:$siguientePagina:$documentIdPadre',
|
||||
title: _tituloMasLocal,
|
||||
title: etiquetas.cargarMas,
|
||||
playable: false,
|
||||
extras: _contentStyleLista,
|
||||
);
|
||||
@@ -836,7 +956,7 @@ class ConstructorArbolAuto {
|
||||
final titulo =
|
||||
(tituloMeta != null && tituloMeta.isNotEmpty)
|
||||
? tituloMeta
|
||||
: _tituloDesdeNombre(nodo.nombre);
|
||||
: _tituloDesdeNombre(nodo.nombre, etiquetas.pistaSinNombre);
|
||||
final artUriMeta = meta?.artUri?.trim();
|
||||
final artUri =
|
||||
(artUriMeta != null && artUriMeta.isNotEmpty)
|
||||
@@ -953,24 +1073,56 @@ class ConstructorArbolAuto {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
/// 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.
|
||||
List<MediaItem>? respuestaBloqueadaPorEntitlement({
|
||||
required String parentMediaId,
|
||||
required bool premium,
|
||||
required List<Emisora> destacadas,
|
||||
}) {
|
||||
if (parentMediaId == AudioService.browsableRootId) return null;
|
||||
if (premium) return null;
|
||||
return [ConstructorArbolAuto().itemPremiumBloqueado()];
|
||||
if (idPermitidoEnFree(parentMediaId, destacadas: destacadas)) return null;
|
||||
return ConstructorArbolAuto().hijosDestacadas(destacadas);
|
||||
}
|
||||
|
||||
/// Routing seam between a car-tapped `emisora:<uuid>` media id and the
|
||||
@@ -982,17 +1134,21 @@ 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").
|
||||
Future<void> reproducirPorMediaId(
|
||||
///
|
||||
/// 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(
|
||||
String id, {
|
||||
required FuenteEmisorasAuto fuente,
|
||||
required Future<void> Function(MediaItem) reproducir,
|
||||
}) async {
|
||||
if (!id.startsWith(_prefijoEmisora)) return;
|
||||
final uuid = id.substring(_prefijoEmisora.length);
|
||||
if (uuid.isEmpty) return;
|
||||
final uuid = uuidDeMediaIdEmisora(id);
|
||||
if (uuid == null) return false;
|
||||
|
||||
final emisora = await fuente.porUuid(uuid);
|
||||
if (emisora == null) return;
|
||||
if (emisora == null) return false;
|
||||
|
||||
final item = MediaItem(
|
||||
id: emisora.url,
|
||||
@@ -1008,6 +1164,58 @@ Future<void> 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
|
||||
@@ -1188,25 +1396,22 @@ 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 [_tituloLocalFallback] when the result would be
|
||||
/// blank.
|
||||
String _tituloDesdeNombre(String nombre) {
|
||||
/// `.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) {
|
||||
final recortado = nombre.trim();
|
||||
if (recortado.isEmpty) return _tituloLocalFallback;
|
||||
if (recortado.isEmpty) return sinNombre;
|
||||
final ultimoPunto = recortado.lastIndexOf('.');
|
||||
final sinExtension =
|
||||
ultimoPunto > 0 ? recortado.substring(0, ultimoPunto) : recortado;
|
||||
final resultado = sinExtension.trim();
|
||||
return resultado.isEmpty ? _tituloLocalFallback : resultado;
|
||||
return resultado.isEmpty ? sinNombre : resultado;
|
||||
}
|
||||
|
||||
/// Resolves the on-brand fallback `artUri` for a local track (Design "art =
|
||||
@@ -1479,12 +1684,13 @@ 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),
|
||||
title: _tituloDesdeDocumentId(nodo.documentId, etiquetas.pistaSinNombre),
|
||||
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
|
||||
@@ -1548,8 +1754,9 @@ Future<Map<String, MetadatosPista>> _metadatosDeConCache(
|
||||
Future<List<MediaItem>?> hijosMusicaLocal(
|
||||
String parentMediaId, {
|
||||
required FuenteMusicaLocalAuto? fuente,
|
||||
EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo,
|
||||
}) async {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
final constructor = ConstructorArbolAuto(etiquetas: etiquetas);
|
||||
|
||||
// Sort-mode and bucket views (Design ADR-4, Phase 2) are routed FIRST —
|
||||
// routing order is irrelevant to correctness (every prefix in this file
|
||||
@@ -1640,11 +1847,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 _tituloDesdeDocumentId(String documentId, String sinNombre) {
|
||||
final ultimaBarra = documentId.lastIndexOf('/');
|
||||
final segmento =
|
||||
ultimaBarra >= 0 ? documentId.substring(ultimaBarra + 1) : documentId;
|
||||
return _tituloDesdeNombre(segmento);
|
||||
return _tituloDesdeNombre(segmento, sinNombre);
|
||||
}
|
||||
|
||||
/// Routing seam between a car-tapped `pista:<docId>` media id and the
|
||||
@@ -1663,6 +1870,7 @@ 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);
|
||||
@@ -1673,7 +1881,7 @@ Future<void> reproducirPistaLocal(
|
||||
|
||||
final pista = PistaLocal(
|
||||
documentId: documentId,
|
||||
titulo: _tituloDesdeDocumentId(documentId),
|
||||
titulo: _tituloDesdeDocumentId(documentId, etiquetas.pistaSinNombre),
|
||||
contentUri: contentUri,
|
||||
);
|
||||
|
||||
@@ -1768,6 +1976,16 @@ 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()]);
|
||||
@@ -1776,6 +1994,9 @@ class FuenteEmisorasAutoLocal implements FuenteEmisorasAuto {
|
||||
if (emisora.uuid == uuid) return emisora;
|
||||
}
|
||||
}
|
||||
for (final emisora in await resolverEmisorasDestacadas()) {
|
||||
if (emisora.uuid == uuid) return emisora;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ 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
|
||||
@@ -28,6 +30,8 @@ import 'package:pluriwave/servicios/servicio_audio.dart';
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final crearHandler = registrarHandlersLiberables();
|
||||
|
||||
group('debeInvalidarArbolAutoAlReanudar (decisión pura)', () {
|
||||
test('resumed + coche ya suscrito + latch libre invalida', () {
|
||||
expect(
|
||||
@@ -109,7 +113,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 = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
var invalidaciones = 0;
|
||||
registrarInvalidacionArbolAuto(() => invalidaciones++);
|
||||
@@ -144,7 +148,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 = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
// El coche navegó la raíz durante el arranque headless: el sujeto
|
||||
@@ -190,7 +194,7 @@ void main() {
|
||||
group('hayCocheSuscritoAlArbol', () {
|
||||
test('es false sin handler suscrito y true en cuanto el coche navega un '
|
||||
'id', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
expect(hayCocheSuscritoAlArbol(), isFalse);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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,4 +300,10 @@ 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.
|
||||
};
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
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,106 +1,142 @@
|
||||
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 (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).
|
||||
/// 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.
|
||||
void main() {
|
||||
group('raiz(premium:) — root keeps its labels for every tier', () {
|
||||
test('premium: identical to today\'s tree (regression guard)', () {
|
||||
final constructor = ConstructorArbolAuto();
|
||||
const gratuitas = [
|
||||
Emisora(uuid: 'libre-1', nombre: 'Libre 1', url: 'https://libre1.example'),
|
||||
Emisora(uuid: 'libre-2', nombre: 'Libre 2', url: 'https://libre2.example'),
|
||||
];
|
||||
|
||||
final premiumConLocal = constructor.raiz(
|
||||
incluirMusicaLocal: true,
|
||||
premium: true,
|
||||
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 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('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,
|
||||
test('la carpeta gratuita pasa', () {
|
||||
expect(
|
||||
idPermitidoEnFree(
|
||||
ConstructorArbolAuto.idDestacadas,
|
||||
destacadas: gratuitas,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
expect(respuesta, isNull);
|
||||
});
|
||||
|
||||
test('cualquier id no-root, en free, retorna SOLO el item bloqueado', () {
|
||||
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('las carpetas premium NO pasan', () {
|
||||
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',
|
||||
]) {
|
||||
final respuesta = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: id,
|
||||
premium: false,
|
||||
);
|
||||
expect(respuesta, hasLength(1));
|
||||
expect(respuesta!.single.id, 'premium:info');
|
||||
expect(idPermitidoEnFree(id, destacadas: gratuitas), isFalse,
|
||||
reason: id);
|
||||
}
|
||||
});
|
||||
|
||||
test('cualquier id no-root, en premium, no es bloqueada', () {
|
||||
final respuesta = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: ConstructorArbolAuto.idFavoritos,
|
||||
premium: true,
|
||||
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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('free: lo bloqueado NUNCA incluye un item no reproducible', () {
|
||||
final bloqueada = respuestaBloqueadaPorEntitlement(
|
||||
parentMediaId: ConstructorArbolAuto.idMisEmisoras,
|
||||
premium: false,
|
||||
destacadas: gratuitas,
|
||||
);
|
||||
|
||||
expect(respuesta, isNull);
|
||||
expect(bloqueada, isNotNull);
|
||||
expect(bloqueada!.every((m) => m.playable == true), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
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,9 +2135,11 @@ 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 va en castellano hardcodeado, como TODAS las etiquetas
|
||||
/// del árbol del coche en `navegacion_auto.dart` (ver
|
||||
/// `itemPremiumBloqueado`): convención establecida, nunca `AppLocalizations`.
|
||||
/// 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`.
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ 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
|
||||
@@ -28,6 +30,8 @@ import 'package:pluriwave/servicios/servicio_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);
|
||||
@@ -46,7 +50,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 = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
var lecturas = 0;
|
||||
|
||||
registrarHandler(
|
||||
@@ -68,7 +72,7 @@ void main() {
|
||||
|
||||
test('a read failure leaves the handler on the safe default instead of '
|
||||
'propagating', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
|
||||
registrarHandler(
|
||||
handler,
|
||||
@@ -81,7 +85,7 @@ void main() {
|
||||
|
||||
test('without a read port the handler is left untouched (widget tests, '
|
||||
'fakes)', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
await handler.setEcualizadorActivo(false);
|
||||
|
||||
registrarHandler(handler);
|
||||
@@ -106,7 +110,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 = PluriWaveAudioHandler();
|
||||
final primero = crearHandler();
|
||||
|
||||
// Pin the module cache to the OPPOSITE value first. Without this the
|
||||
// test passes for the wrong reason: whatever ran before may already
|
||||
@@ -123,7 +127,7 @@ void main() {
|
||||
|
||||
// Now the construction window: a handler built by `AudioService.init`'s
|
||||
// builder, with no port of its own yet.
|
||||
final segundo = PluriWaveAudioHandler();
|
||||
final segundo = crearHandler();
|
||||
|
||||
expect(
|
||||
segundo.ecualizadorActivo,
|
||||
@@ -136,12 +140,12 @@ void main() {
|
||||
|
||||
test('the cache follows what the handler itself writes, in both '
|
||||
'directions', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
await handler.setEcualizadorActivo(false);
|
||||
expect(
|
||||
PluriWaveAudioHandler().ecualizadorActivo,
|
||||
crearHandler().ecualizadorActivo,
|
||||
isFalse,
|
||||
reason:
|
||||
'the write side of the cache: a toggle must be visible to the '
|
||||
@@ -149,14 +153,14 @@ void main() {
|
||||
);
|
||||
|
||||
await handler.setEcualizadorActivo(true);
|
||||
expect(PluriWaveAudioHandler().ecualizadorActivo, isTrue);
|
||||
expect(crearHandler().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 = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
final escrituras = <bool>[];
|
||||
|
||||
registrarHandler(
|
||||
@@ -177,7 +181,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('seeding from disk does NOT write back to disk', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
final escrituras = <bool>[];
|
||||
|
||||
registrarHandler(
|
||||
@@ -192,7 +196,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('a failing write port never breaks the toggle', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
|
||||
registrarHandler(
|
||||
handler,
|
||||
@@ -248,7 +252,7 @@ void main() {
|
||||
|
||||
group('customAction dispatch (C — zero coverage before this)', () {
|
||||
test('the accionEqToggle literal routes through decidirToggleEq', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
await handler.setEcualizadorActivo(true);
|
||||
|
||||
@@ -261,7 +265,7 @@ void main() {
|
||||
|
||||
test('a car toggle persists through the same write port as a phone '
|
||||
'toggle', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
final escrituras = <bool>[];
|
||||
registrarHandler(
|
||||
handler,
|
||||
@@ -282,7 +286,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('an unknown custom action is a silent no-op', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
final antes = handler.ecualizadorActivo;
|
||||
|
||||
@@ -380,7 +384,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 = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
|
||||
@@ -410,7 +414,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 = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
handler.simularEcualizadorDisponible(true);
|
||||
|
||||
@@ -435,7 +439,7 @@ void main() {
|
||||
});
|
||||
|
||||
test('with no native effect attached nothing is ever re-asserted', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
// `_eqDisponible` is false off-device, which is also the real
|
||||
// "device has no Equalizer effect" case.
|
||||
@@ -451,7 +455,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 = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
handler.playbackState.add(
|
||||
handler.playbackState.value.copyWith(
|
||||
|
||||
@@ -6,6 +6,8 @@ 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`,
|
||||
@@ -20,12 +22,30 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('free tier: bloquea cualquier cambio de emisora/salto', () {
|
||||
expect(debeBloquearCambioDeEmisora(premium: false), isTrue);
|
||||
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('premium: nunca bloquea', () {
|
||||
expect(debeBloquearCambioDeEmisora(premium: true), isFalse);
|
||||
expect(
|
||||
debeBloquearCambioDeEmisora(premium: true, esEmisoraGratuita: false),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
/// fix/android-auto-musica-local, item 4: el hook dejó de ser «solo la
|
||||
@@ -51,7 +71,7 @@ void main() {
|
||||
|
||||
test('registrarHandler conecta la invalidación al handler: una llamada '
|
||||
'notifica la raíz Y Música Local', () async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
registrarHandler(handler);
|
||||
|
||||
final raiz = <Map<String, dynamic>>[];
|
||||
@@ -86,13 +106,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 = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
|
||||
expect(handler.subscribeToChildren('musica_local').hasValue, isFalse);
|
||||
});
|
||||
|
||||
test('memoiza por id: dos llamadas devuelven el MISMO stream', () {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
|
||||
expect(
|
||||
identical(
|
||||
@@ -112,7 +132,7 @@ void main() {
|
||||
|
||||
test('notificarHijosCambiaron sí empuja un valor al sujeto ya suscrito',
|
||||
() async {
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
final stream = handler.subscribeToChildren('musica_local');
|
||||
final recibidos = <Map<String, dynamic>>[];
|
||||
final sub = stream.listen(recibidos.add);
|
||||
@@ -143,7 +163,7 @@ void main() {
|
||||
|
||||
Future<List<String>> idsRaizCon(EstadoCarpetaLocal estado) async {
|
||||
registrarFuenteMusicaLocal(_FakeFuenteMusicaLocalGating(estado));
|
||||
final handler = PluriWaveAudioHandler();
|
||||
final handler = crearHandler();
|
||||
final items = await handler.getChildren(AudioService.browsableRootId);
|
||||
return items.map((i) => i.id).toList();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,736 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user