diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 54c33ef..83a26b4 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -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": "مقطع بلا اسم" } diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index e8c907a..76ee9f0 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -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": "নামহীন ট্র্যাক" } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ad852ba..529a6e7 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -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" } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 68865fc..ff368e4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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" } diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 0805e81..4784316 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -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" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6ff0fe3..e8d609b 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -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" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 9df9299..933b2a5 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -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": "बिना नाम का ट्रैक" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 4fd5dda..ef04ece 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -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" } diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 04b322c..ce0fae0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -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" } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 6a4f0c0..3a24d37 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -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": "名称未設定のトラック" } diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 53fcc29..68991c6 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -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" } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 162f35e..083f734 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -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": "Трек без названия" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 17fb190..4a3dcc5 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -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": "未命名曲目" } diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 82e28c5..1c804c5 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -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 diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index 856a93b..3ba128a 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -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 => 'مقطع بلا اسم'; } diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 89fe034..e47b866 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -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 => 'নামহীন ট্র্যাক'; } diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 71e29b4..7dc6b38 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -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'; } diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 4b26591..ed661cc 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -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'; } diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 5026540..365cb5d 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -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'; } diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 65d1c6a..54ace9d 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -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'; } diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index 13ed2c7..d98e83b 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -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 => 'बिना नाम का ट्रैक'; } diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index a44e4c0..cb480d3 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -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'; } diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 2344dc5..f402cb1 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -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'; } diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index fd3abcb..918e06f 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -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 => '名称未設定のトラック'; } diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index e18ff5e..8fe68cb 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -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'; } diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 51bcdd2..9ae6d1d 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -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 => 'Трек без названия'; } diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 7486964..1898235 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -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 => '未命名曲目'; } diff --git a/lib/servicios/emisoras_destacadas.dart b/lib/servicios/emisoras_destacadas.dart new file mode 100644 index 0000000..1acdb8f --- /dev/null +++ b/lib/servicios/emisoras_destacadas.dart @@ -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 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> 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 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 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 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 _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); + // A record with no uuid or no url cannot be turned into a playable + // `emisora:` 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; + } +} diff --git a/lib/servicios/navegacion_auto.dart b/lib/servicios/navegacion_auto.dart index 842fa0b..71ec13f 100644 --- a/lib/servicios/navegacion_auto.dart +++ b/lib/servicios/navegacion_auto.dart @@ -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 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 hijosDestacadas(List 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::` — 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:` — /// 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::0:` — 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:`. 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:`. 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:` 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:` +/// 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 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? respuestaBloqueadaPorEntitlement({ required String parentMediaId, required bool premium, + required List 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:` media id and the @@ -982,17 +1134,21 @@ List? 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 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 reproducirPorMediaId( String id, { required FuenteEmisorasAuto fuente, required Future 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 reproducirPorMediaId( extras: {'uuid': emisora.uuid}, ); await reproducir(item); + return true; +} + +/// The uuid inside an `emisora:` 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 _destacadas; + + @override + Future> favoritos() async => const []; + + @override + Future> misEmisoras() async => const []; + + @override + Future> todas() async => _destacadas; + + @override + Future> grupos() async => const []; + + @override + Future 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 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 reproducirCarpetaLocal( Future 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> _metadatosDeConCache( Future?> 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?> 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:` media id and the @@ -1663,6 +1870,7 @@ Future reproducirPistaLocal( String id, { required FuenteMusicaLocalAuto fuente, required Future Function(MediaItem) reproducir, + EtiquetasArbolAuto etiquetas = EtiquetasArbolAuto.respaldo, }) async { if (!esPistaMediaId(id)) return; final documentId = id.substring(_prefijoPista.length); @@ -1673,7 +1881,7 @@ Future 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:` 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 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; } diff --git a/lib/servicios/servicio_audio.dart b/lib/servicios/servicio_audio.dart index 60951f1..efa8724 100644 --- a/lib/servicios/servicio_audio.dart +++ b/lib/servicios/servicio_audio.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:ui' show Locale; +import 'dart:ui' show Locale, PlatformDispatcher; import 'package:audio_service/audio_service.dart'; import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting; @@ -14,6 +14,7 @@ import '../modelos/pista_local.dart'; import '../modelos/preset_ecualizador.dart'; import 'cola_local.dart'; import 'controlador_reconexion.dart'; +import 'emisoras_destacadas.dart'; import 'musica_local_auto.dart'; import 'navegacion_auto.dart'; import 'servicio_audio_session.dart'; @@ -283,17 +284,73 @@ void invalidarArbolAuto() { /// handler has registered yet (headless cold bind, widget-only tests). bool hayCocheSuscritoAlArbol() => _handlerGlobal?.hayCocheSuscrito ?? false; -/// Pure Android Auto play-path gate decision (iap-freemium-unlock, Design -/// ADR-4): whether a station-switch dispatch (`playFromMediaId`, -/// `playFromSearch`, `skipToNext`, `skipToPrevious`) must no-op for -/// [premium]. This is the mandatory BACKSTOP alongside -/// `respuestaBloqueadaPorEntitlement` (`navegacion_auto.dart`) — gating -/// `getChildren` alone would leave a stale/cached `emisora:` tap free -/// to bypass browsing entirely (android-auto-media spec "Free-Tier Browse -/// Never Leaks Real Content"). Deliberately does NOT gate `play`/`pause`/ -/// `stop` — transport control of whatever is ALREADY loaded stays free -/// (Spec "Current-Station Playback Unaffected By Free Tier"). -bool debeBloquearCambioDeEmisora({required bool premium}) => !premium; +/// Pure Android Auto play-path gate decision: whether a station-switch +/// dispatch (`playFromMediaId`, `playFromSearch`) must be refused. +/// +/// NARROWED (fix/auto-quality-guidelines, item 11) from `!premium`. It used +/// to refuse EVERY switch for a free-tier user, which is what made the car +/// surface useless for the only tier a Play reviewer can ever be in: the +/// browse tree offered rows, and tapping any of them did nothing at all. +/// +/// The original safety property survives intact and is the whole reason this +/// takes [esEmisoraGratuita] rather than being deleted: a stale +/// `emisora:` from a head unit's CACHED tree, fetched before a +/// downgrade or from another device, still cannot play, because its uuid is +/// not in the free set. `getChildren` gating alone cannot stop that tap. +/// +/// Deliberately does NOT gate `play`/`pause`/`stop` — transport control of +/// whatever is ALREADY loaded stays free for every tier. +bool debeBloquearCambioDeEmisora({ + required bool premium, + required bool esEmisoraGratuita, +}) => !premium && !esEmisoraGratuita; + +/// `PlaybackStateCompat.ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED` (4) — the exact +/// platform code for "this content needs a paid account", forwarded verbatim +/// by the plugin alongside `errorMessage`. +/// +/// Only the `ERROR_RESOLUTION_*` extras (which would render a tappable +/// "upgrade" button on the head unit) are unreachable from Dart on +/// audio_service 0.18.18, and Google's Android for Cars errors documentation +/// explicitly accepts putting the "open the app on your phone" instruction in +/// the message text instead — which is what `autoErrorEmisoraPremium` does. +const codigoErrorEmisoraPremium = 4; + +/// `PlaybackStateCompat.ERROR_CODE_APP_ERROR` (1) — a requested media id the +/// app could not turn into anything playable (stale/unknown uuid, or a voice +/// query that matched nothing). +const codigoErrorEmisoraNoDisponible = 1; + +/// Whether an Android Auto ACTION refusal (a premium tap, a voice query that +/// matched nothing) may be published as a full [AudioProcessingState.error], +/// given the state the session is already in. +/// +/// This is the guard on the one-way door in `getPlaybackState()` +/// (`AudioService.java:601-611`): `error` maps to `STATE_ERROR` +/// UNCONDITIONALLY there — the `playing` flag is not consulted — so an +/// `error` published over live audio replaces the entire now-playing screen, +/// play/pause/stop included, while the station is still audibly playing. +/// +/// And it cannot self-heal. `just_audio`'s `playerStateStream` is +/// `.distinct()` (`just_audio.dart:279-285`), so a steadily playing ExoPlayer +/// emits nothing further and [manejarEstadoPlayer] never runs again; +/// `_bufferedSub` then re-asserts the same `error` ~2x/second through +/// `copyWith`; and the transient-state floor deliberately excludes `error` +/// (see [_esEstadoTransitorio]). Nothing left in the process would ever clear +/// it. The free candidate set is only the six compiled-in stations, so almost +/// any spoken station name misses — one voice miss and the car is stranded. +/// +/// So: only a session with nothing to lose (`idle`, or an error already on +/// screen) may be moved to `error`. A LOADED session — playing, paused, +/// buffering or loading — keeps its state, and the refusal is carried by +/// `errorCode`/`errorMessage` alone, which `setState` forwards to +/// `PlaybackStateCompat.setErrorMessage` regardless of the state +/// (`AudioService.java:541-544`). The driver is told; the session survives. +/// +/// Pure and top-level so the whole matrix is testable without a handler, +/// exactly like [mapearEstadoProceso]. +bool puedePublicarErrorTerminalAuto(AudioProcessingState estado) => + estado == AudioProcessingState.idle || estado == AudioProcessingState.error; /// Builds the phone-initiated "play a station" `MediaItem` (item 3, Android /// Auto fallback artwork): reuses [artUriPara] (`navegacion_auto.dart`) so a @@ -373,13 +430,35 @@ Emisora emisoraDesdeMediaItem(MediaItem mediaItem) { /// `idle` and still tears the service down. Pure — no handler dependency — so /// the full [ProcessingState] x [cambiandoFuente] matrix is unit-testable /// directly. +/// +/// [errorTerminal] is the SECOND masked `idle`, and it exists for the same +/// class of bug as the first. After a terminal playback error this file +/// publishes `error` + a message and then calls `_player.stop()`, which +/// switches `just_audio` to the idle dummy platform +/// (`just_audio.dart:1016-1025`); `playerStateStream` emits a distinct +/// `(playing: false, idle)`, this function forwarded it verbatim, and +/// `audio_service`'s `_observePlaybackState` (`audio_service.dart:1131-1135`) +/// answered that non-idle -> idle transition with `AudioService._stop()` — +/// `deactivateMediaSession()` + `stopSelf()` (`AudioService.java:355-357`). +/// The error message therefore survived on the car screen for at most one +/// event-loop turn before PluriWave dropped off the Android Auto playback +/// surface entirely, with nothing left to explain why. This is a world-radio +/// app; dead streams are routine, and the reviewer is explicitly told to try +/// one. Holding `error` keeps the session (and the message, and a route back +/// to browse) alive; a genuine user `stop()` clears the latch BEFORE +/// `_player.stop()`, exactly like [cambiandoFuente], so the Stop button never +/// becomes unkillable. AudioProcessingState mapearEstadoProceso( ProcessingState proc, { required bool cambiandoFuente, + bool errorTerminal = false, }) { if (cambiandoFuente && proc == ProcessingState.idle) { return AudioProcessingState.loading; } + if (errorTerminal && proc == ProcessingState.idle) { + return AudioProcessingState.error; + } return switch (proc) { ProcessingState.idle => AudioProcessingState.idle, ProcessingState.loading => AudioProcessingState.loading, @@ -389,6 +468,53 @@ AudioProcessingState mapearEstadoProceso( }; } +/// Resolves the localizations to use when no widget tree has ever handed the +/// handler an [AppLocalizations] — i.e. on the headless Android Auto engine. +/// +/// Matches on `languageCode` only: a device reporting `en_GB`, `pt_BR` or +/// `zh_Hans_CN` must still get English, Portuguese and Chinese rather than the +/// Spanish fallback. Anything we do not ship falls back to `es`, which is the +/// exact behaviour this file had before — unchanged for every locale that +/// could not be served anyway. +/// +/// Pure and top-level so the whole supported/unsupported matrix is testable +/// without a handler (same convention as [mapearEstadoProceso]). +AppLocalizations resolverLocalizacionesRespaldo(Locale plataforma) { + for (final soportado in AppLocalizations.supportedLocales) { + if (soportado.languageCode == plataforma.languageCode) { + return lookupAppLocalizations(soportado); + } + } + return lookupAppLocalizations(const Locale('es')); +} + +/// Bridges [AppLocalizations] into the `AppLocalizations`-free browse-tree +/// builder (`navegacion_auto.dart`), exactly like [itemsEcualizadorAuto] +/// bridges it into the equalizer folder. +/// +/// THE RULE this exists to enforce: anything a user can read in the car gets +/// translated. Every label the browse tree stamps onto a `MediaItem` comes +/// through here, so a new car-tree label cannot ship untranslated without +/// first getting an ARB key — and `test/l10n/etiquetas_arbol_auto_test.dart` +/// fails the build if one tries. +/// +/// Top-level and pure so the mapping is testable without a handler (same +/// convention as [resolverLocalizacionesRespaldo]). +EtiquetasArbolAuto etiquetasArbolAutoDesde(AppLocalizations l10n) => + EtiquetasArbolAuto( + escuchar: l10n.autoCarpetaEscuchar, + favoritos: l10n.autoCarpetaFavoritos, + todasLasEmisoras: l10n.autoCarpetaTodas, + misEmisoras: l10n.autoCarpetaMisEmisoras, + musicaLocal: l10n.autoCarpetaMusicaLocal, + musicaLocalNoDisponible: l10n.autoMusicaLocalNoDisponible, + cargarMas: l10n.autoCargarMas, + ordenarPorCalidad: l10n.autoOrdenarPorCalidad, + reproducirCarpeta: l10n.autoReproducirCarpeta, + reproducirAleatorio: l10n.autoReproducirAleatorio, + pistaSinNombre: l10n.autoPistaSinNombre, + ); + /// Custom-action names for the equalizer's `PlaybackStateCompat` custom /// actions on the now-playing screen (Design "EQ custom actions", item 4). /// Public consts so tests and this file's own `customAction` dispatch share @@ -815,8 +941,76 @@ class ServicioAudio { class PluriWaveAudioHandler extends BaseAudioHandler with SeekHandler implements ObjetivoAudioInterrumpible { - static const _timeoutCambioFuente = Duration(seconds: 12); + /// Per-attempt source-change timeout. + /// + /// Was 12 s, which blew the Android for Cars ten-second budget on the FIRST + /// attempt alone: the driver tapped a station and got a silent spinner for + /// twelve seconds before anything at all happened, then five more silent + /// attempts behind 1/2/4/8/16 s of backoff — roughly 100 s of nothing per + /// tap. 8 s leaves two seconds of headroom for the message to be published + /// and rendered, and the retries continue behind it (see + /// [_intentarReconexion]), so a slow-but-alive station still recovers. + static const timeoutCambioFuentePorDefecto = Duration(seconds: 8); + + /// See [timeoutCambioFuentePorDefecto]. Mutable ONLY so a test can shrink + /// the window to milliseconds; production never writes it. + @visibleForTesting + static Duration timeoutCambioFuente = timeoutCambioFuentePorDefecto; + static const _timeoutCierrePlayer = Duration(seconds: 3); + + /// How long a `loading`/`buffering` published state may stand before the + /// terminal-state floor forces it to a terminal one. + /// + /// Android for Cars App Quality Guidelines: a media app must never leave the + /// head unit on an indefinite spinner. The only exits from a transient state + /// today are player events, and `playerStateStream` is `.distinct()`, so a + /// repeat of a state we already hold emits NOTHING — a stalled state machine + /// stays on the spinner forever with no event left to rescue it. + /// + /// 8 s, not the 20 s this shipped with. For the failure mode this floor is + /// the ONLY exit from — an icecast mount whose socket opens but never + /// delivers data, one of the most common failures in this catalogue — the + /// window IS the time to the first user-visible message: `setUrl` returns + /// inside [timeoutCambioFuente], so no `TimeoutException` and no + /// `PlayerException` is ever raised, `_esErrorDeRed` never fires, the + /// reconnect machine is never entered, and `_cambiosEnVuelo` is already + /// back to 0 (the `finally` runs as soon as the non-blocking + /// `_iniciarPlaySinBloquear` returns). Twenty seconds was double the + /// ten-second budget the paragraph above cites. Same value as + /// [timeoutCambioFuentePorDefecto], for the same reason: two seconds of + /// headroom for the message to be published and rendered. + static const vigilanciaTransitoriaPorDefecto = Duration(seconds: 8); + + /// See [vigilanciaTransitoriaPorDefecto]. Mutable ONLY so a test can shrink + /// the window to milliseconds; production never writes it. + @visibleForTesting + static Duration vigilanciaTransitoria = vigilanciaTransitoriaPorDefecto; + + /// How long an Android Auto ACTION refusal published over a LIVE session + /// (see [_publicarErrorAuto]) keeps its `errorCode`/`errorMessage` on the + /// head unit before they are cleared again. + /// + /// A refusal over live audio describes the REQUESTED ACTION, not the + /// session, so it has to expire: `_bufferedSub` republishes + /// `playbackState.value.copyWith(...)` roughly twice a 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. Left + /// unbounded, a single voice miss makes the session advertise an error + /// code and message for the rest of that station's playback, over audio + /// that is playing perfectly. + /// + /// Six seconds: long enough to be read at a glance from a head unit while + /// driving (the Cars guidelines' own budget for telling the driver + /// something at all is ten), short enough that it cannot be mistaken for a + /// description of the session it is riding on. + static const ventanaErrorAccionAutoPorDefecto = Duration(seconds: 6); + + /// See [ventanaErrorAccionAutoPorDefecto]. Mutable ONLY so a test can + /// shrink the window to milliseconds; production never writes it. + @visibleForTesting + static Duration ventanaErrorAccionAuto = ventanaErrorAccionAutoPorDefecto; static const _factorAtenuacion = 0.3; // ── Live-stream buffer (Design 7.1, S7-R1) ──────────────────────────────── @@ -876,6 +1070,53 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// — i.e. before every single `_player.stop()` call in this class. bool _cambiandoFuente = false; + /// How many [_cambiarFuente] bodies are currently between their entry guard + /// and their `finally`. A counter rather than a bool because + /// `_colaCambioFuente` is a chain, and a reconnect retry can be enqueued + /// while the previous attempt is still unwinding. + /// + /// Read ONLY by the terminal-state floor, to tell «a station is genuinely + /// still opening» apart from «the state machine stalled on a spinner». + int _cambiosEnVuelo = 0; + + /// Terminal-state floor (see [vigilanciaTransitoriaPorDefecto]). + Timer? _vigilanciaTimer; + + /// The `errorCode`/`errorMessage` pair the LAST non-terminal + /// [_publicarErrorAuto] put on the head unit, and the timer that takes it + /// back off again. `null` when no action refusal is standing. + /// + /// Both halves are remembered so the clear can verify it is removing its + /// OWN fields: anything else may have published over them in the meantime + /// (a reconnect status message, a terminal playback error), and clearing + /// those would blank the car screen for a reason that is still true. + ({int codigo, String mensaje})? _errorAccionAuto; + Timer? _temporizadorErrorAccionAuto; + + /// True once the CURRENT source has actually produced audio — i.e. the + /// player reached `ready` while `playing`. Reset at the entry of every + /// [_cambiarFuente] (a reconnect attempt included) and by [stop]. + /// + /// This is the terminal-state floor's discriminator, and it exists because + /// the obvious one does not work: `just_audio`'s `PlayerState.playing` is + /// the play-when-ready INTENT flag, set the instant `play()` is called + /// (`just_audio.dart` `_playInterrupted`/`playing`), so the stalled icecast + /// mount the floor was built for — socket accepted, not one byte delivered + /// — publishes `buffering` with `playing: true` exactly like a healthy + /// stream refilling its buffer. Only "did this run ever reach `ready`?" + /// separates them. + bool _reproduccionEstablecida = false; + + /// A terminal playback error is standing on the car screen, so the `idle` + /// that follows it must NOT be forwarded — see [mapearEstadoProceso]'s + /// `errorTerminal` parameter for the teardown chain that idle triggers. + /// + /// Cleared by every path that represents a fresh user intent: [play], + /// [pause], [stop] and the entry of [_cambiarFuente]. [stop] in particular + /// clears it BEFORE `_player.stop()`, so a genuine user stop still tears + /// the session down. + bool _errorTerminal = false; + /// Active local-music queue (Design "single load-bearing invariant"): /// `null` means "not local-queue playback" — the ONLY gate the /// auto-advance/skip/isolation logic reads. Radio never sets this field. @@ -1001,21 +1242,180 @@ class PluriWaveAudioHandler extends BaseAudioHandler PluriWaveAudioHandler() { _conectarStreamsPlayer(); + _vigilarEstadosTransitorios(); } + /// Arms the terminal-state floor by watching our OWN published stream + /// rather than any single push site: `playbackState.add` is called from a + /// dozen places in this file, and a floor that only covered some of them + /// would be exactly as good as no floor at all on the path it missed. + /// + /// The subscription is deliberately not held: it is on the handler's OWN + /// `playbackState` subject, so it lives and dies with the handler — exactly + /// like the subject itself — and there is no teardown that could cancel it + /// without also ending the object it belongs to. + void _vigilarEstadosTransitorios() { + playbackState.listen((estado) { + if (!_esEstadoTransitorio(estado.processingState)) { + _vigilanciaTimer?.cancel(); + _vigilanciaTimer = null; + return; + } + // Already armed for THIS transient run: do not restart the window. + // `bufferedPositionStream` republishes ~2/s while buffering, and + // re-arming on each of those would push the deadline out forever — + // the spinner would once again have no bound. + if (_vigilanciaTimer?.isActive ?? false) return; + _vigilanciaTimer = Timer( + vigilanciaTransitoria, + _cerrarEstadoTransitorio, + ); + }); + } + + static bool _esEstadoTransitorio(AudioProcessingState estado) => + estado == AudioProcessingState.loading || + estado == AudioProcessingState.buffering; + + /// Fires [vigilanciaTransitoria] after the first transient publish of a run. + void _cerrarEstadoTransitorio() { + _vigilanciaTimer = null; + if (!_esEstadoTransitorio(playbackState.value.processingState)) return; + if (_cambiosEnVuelo > 0 || _reconexion.reintentoPendiente) { + // A station really is still opening, or a backoff retry is already + // scheduled to end this. The floor exists for a STALLED machine, not to + // cap how long a slow stream may take — re-arm and look again. + _vigilanciaTimer = Timer( + vigilanciaTransitoria, + _cerrarEstadoTransitorio, + ); + return; + } + // ORDINARY MID-STREAM RE-BUFFER — never a floor case. + // + // The rule: the floor only fires while NOTHING is known to be happening. + // A run that already reached `ready` while playing has proven the mount + // delivers audio, and `bufferForPlaybackAfterRebufferDuration` is 5 s, so + // a tunnel or an LTE handover routinely parks this session in + // `buffering` for longer than [vigilanciaTransitoria] — with + // `_cambiosEnVuelo` at 0 (the `finally` ran the moment the non-blocking + // `_iniciarPlaySinBloquear` returned) and `reintentoPendiente` false + // (ExoPlayer raised no error, so `_intentarReconexion` never ran). The + // floor as first written turned every one of those into a latched + // `error` over a stream that was still alive: the single worst outcome + // in a car, and unrecoverable, because `_errorTerminal` then suppresses + // the player's own recovery events. + // + // Deferring here does NOT reopen the unbounded spinner the guidelines + // forbid. This branch is reachable only after audio actually played, and + // that path keeps two independent exits the stalled-mount path lacks: + // ExoPlayer's own source read timeout raises a `PlayerException` into + // `_eventosSub`, which enters `_gestionarErrorReproduccion` and the + // reconnect machine, and any real transition re-publishes and disarms + // this timer. Re-arm rather than return so the floor stays authoritative + // if the session later drops back to a state nothing owns: the flag is + // cleared by [_cambiarFuente] and [stop], so the very next attempt to + // reopen the source is floored normally. + // + // `playing` alone is deliberately NOT the test: it is `just_audio`'s + // play-when-ready intent flag, true for a mount that never delivered a + // byte. See [_reproduccionEstablecida]. + if (_reproduccionEstablecida && playbackState.value.playing) { + _vigilanciaTimer = Timer( + vigilanciaTransitoria, + _cerrarEstadoTransitorio, + ); + return; + } + debugPrint( + '[PluriWave][ServicioAudio] suelo de estado: ' + '${playbackState.value.processingState.name} sin carga viva -> error', + ); + // CORRECTED: this used to publish a BARE `idle` — no message, no code — + // on the theory that "nothing we can name actually failed" and that idle + // at least let `audio_service` tear the session down. Both halves were + // wrong for the case that actually reaches here. + // + // Something DID fail and it has a name: a mount that accepted the socket + // and then delivered nothing. `setUrl` returns inside + // [timeoutCambioFuente], so no exception is raised, `_esErrorDeRed` never + // fires and the reconnect machine is never entered; this floor is the + // only exit. And the teardown was not a mercy: a bare idle routes into + // `AudioService._stop()` (`audio_service.dart:1131-1135`), so the driver + // got a wordless spinner followed by silence, a dead session and no + // explanation whatsoever. + // + // `error` + `audioErrorTimeout` instead. It keeps the session (and the + // route back to browse) alive, and it says the one true thing we know: + // the connection never produced audio. The latch stops the player's own + // `idle`, if one ever arrives, from undoing it. + _errorTerminal = true; + playbackState.add( + playbackState.value.copyWith( + processingState: AudioProcessingState.error, + playing: false, + errorCode: codigoErrorEmisoraNoDisponible, + errorMessage: _textos.audioErrorTimeout, + ), + ); + } + + /// Production reader for the device locale. + static Locale lectorLocalePlataformaPorDefecto() => + PlatformDispatcher.instance.locale; + + /// See [lectorLocalePlataformaPorDefecto]. Mutable ONLY so a test can pin a + /// locale; production never writes it. + @visibleForTesting + static Locale Function() lectorLocalePlataforma = + lectorLocalePlataformaPorDefecto; + AppLocalizations get _textos { final actual = _l10n; if (actual != null) return actual; - return lookupAppLocalizations(const Locale('es')); + // The headless Android Auto engine NEVER reaches + // [configurarLocalizaciones]: its only production caller chain is + // `EstadoRadio.configurarLocalizaciones` <- `mini_reproductor.dart`'s + // `didChangeDependencies`, and a car bind has no Activity and no widget + // tree. So `_l10n` stays null there and every message the car ever + // showed — including the error text the whole compliance story rests + // on — came out in Spanish no matter what language the driver uses. + return resolverLocalizacionesRespaldo(lectorLocalePlataforma()); } void configurarLocalizaciones(AppLocalizations l10n) { _l10n = l10n; } + /// Test seam for the player factory. `null` in production, where + /// [_crearPlayer] builds the real `just_audio.AudioPlayer` exactly as it + /// always has. + /// + /// It exists because the car transport state machine (stop during an + /// in-flight source change, pause during a load, the transient-state + /// watchdog, the time-to-first-message budget) is a sequence of + /// `playbackState` pushes, and asserting a SEQUENCE requires driving the + /// real handler — the pure-predicate style used elsewhere in this file + /// cannot see the order in which those pushes land, which is precisely + /// where the "Stop leaves the app useless" defect lived. + /// + /// Static rather than a constructor parameter because `_player` is a + /// `late` field initialized by the constructor itself (through + /// `_conectarStreamsPlayer`), so the factory must already be installed + /// before `PluriWaveAudioHandler()` runs. Tests clear it in `tearDown`. + @visibleForTesting + static AudioPlayer Function( + AudioPipeline pipeline, + AudioLoadConfiguration carga, + )? + fabricaReproductorPrueba; + AudioPlayer _crearPlayer() { + final pipeline = AudioPipeline(androidAudioEffects: [_eq]); + final fabrica = fabricaReproductorPrueba; + if (fabrica != null) return fabrica(pipeline, configuracionCargaAndroid); return AudioPlayer( - audioPipeline: AudioPipeline(androidAudioEffects: [_eq]), + audioPipeline: pipeline, audioLoadConfiguration: configuracionCargaAndroid, ); } @@ -1034,6 +1434,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler void manejarEstadoPlayer(PlayerState state) { final playing = state.playing; final proc = state.processingState; + // A real player transition supersedes a refusal about an ACTION: the + // session just moved, so the code/message describing something the + // driver asked for a moment ago must not ride along on the new state. + // Done BEFORE the publish below so its `copyWith` carries the cleared + // fields forward rather than the stale pair (`audio_service.dart` + // :400-427 keeps every omitted field). + _limpiarErrorAccionAuto(); // First line of the listener (Design ADR-3, Phase 3 task 3.3): // double-gated on `completed` + an active local queue, so this is a // no-op for radio (which never emits `completed`) and for @@ -1054,6 +1461,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler // re-assert above fires once per activation, not on every event. _reproductorActivo = proc != ProcessingState.idle; if (playing && proc == ProcessingState.ready) { + // The mount has proven it delivers audio on THIS run. Read only by the + // terminal-state floor, to tell an ordinary re-buffer apart from a + // source that never produced a byte — see [_reproduccionEstablecida]. + _reproduccionEstablecida = true; // Successful (re)connection: reset the backoff so the next stall // starts over, and leave the reconnect window (S7-R7). _reconexion.restablecer(); @@ -1114,6 +1525,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler processingState: mapearEstadoProceso( proc, cambiandoFuente: _cambiandoFuente, + errorTerminal: _errorTerminal, ), playing: playing, // Reported: in Android Auto the progress bar and the time labels of @@ -1311,6 +1723,12 @@ class PluriWaveAudioHandler extends BaseAudioHandler debugPrint('[PluriWave][ServicioAudio] Error reproducción: $codigoLog'); _detenerReconexion(); + // Armed BEFORE the publish and BEFORE `_player.stop()`: the stop below + // makes `playerStateStream` emit a distinct `idle`, and forwarding that + // idle is what used to tear the whole media session down one event-loop + // turn after the message appeared. See [mapearEstadoProceso]'s + // `errorTerminal` parameter for the full chain. + _errorTerminal = true; playbackState.add( playbackState.value.copyWith( processingState: AudioProcessingState.error, @@ -1321,19 +1739,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler // The failed item and station are KEPT, deliberately. // // This used to do `emisoraActual = null; mediaItem.add(null);`, which - // left the media session in STATE_ERROR with no metadata at all. Android - // Auto drops a session with nothing to show, which is what made PluriWave - // vanish from the car pane the moment a station failed -- reported as - // "if a station fails it seems to crash, and going to 1/3 it fails". + // left the media session in STATE_ERROR with no metadata at all. // - // Keeping them costs nothing on the phone (nothing outside this file - // consumes `mediaItem`, verified) and buys two things in the car: the - // screen can still name the station that failed instead of going blank, - // and previous/next stay usable, so the driver skips out of a dead - // station instead of being stranded -- `_saltarEmisora` needs - // `emisoraActual` to know where it is in the list. + // CORRECTED: an earlier revision of this comment claimed that keeping + // `mediaItem`/`emisoraActual` was what stopped PluriWave vanishing from + // the car pane when a station failed. It was not, and the app kept + // vanishing. The teardown is driven by the `idle` `_player.stop()` + // produces, not by missing metadata: `audio_service` calls + // `AudioService._stop()` on any non-idle -> idle transition + // (`audio_service.dart:1131-1135`) whatever the media item holds. The + // `_errorTerminal` latch above is the actual fix; keeping the metadata is + // still worth doing for two smaller reasons — the screen can name the + // station that failed instead of going blank, and previous/next stay + // usable so the driver skips out of a dead station instead of being + // stranded (`_saltarEmisora` needs `emisoraActual` to know where it is). // - // The error state itself is unchanged: STATE_ERROR with the message. + // `_player.stop()` itself stays: it releases the decoders behind a stream + // that is already dead, and its `idle` is now suppressed rather than + // forwarded, so the error state stands until the driver does something. _player.stop().catchError((_) {}); } @@ -1373,7 +1796,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler playbackState.value.copyWith( processingState: AudioProcessingState.buffering, playing: false, - errorMessage: null, + // NOT `null` any more. `copyWith` treats an explicit `null` as + // "clear" (audio_service.dart:419-420), so the whole reconnect window + // used to be a spinner with nothing written on it — up to ~100 s in + // which the driver was told exactly nothing. `buffering` + a message + // is the shape the Cars guidelines ask for: say what is happening, + // and keep trying behind it. + errorMessage: _textos.playbackStatusReconnecting, ), ); return true; @@ -1483,7 +1912,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler if (colaEsperada != null) _avanzandoCola = false; return; } - final item = await construirMediaItemColaLocal(nodo, fuente: fuente); + final item = await construirMediaItemColaLocal( + nodo, + fuente: fuente, + etiquetas: etiquetasArbolAutoDesde(_textos), + ); if (colaEsperada != null && !avanceEsValido(_colaLocal, colaEsperada)) { // Stale: a user action (external play, stop, another skip) replaced // `_colaLocal` during the await — abort this advance without @@ -1576,13 +2009,51 @@ class PluriWaveAudioHandler extends BaseAudioHandler } Future _cambiarFuente(MediaItem mediaItem, int revision) async { + // Revision guard ON ENTRY — the P0 "Stop makes the app useless" fix. + // + // `_encolarCambioFuente` bumps `_revisionFuente` when a change is + // ENQUEUED, not when it runs, so several changes can be waiting on + // `_colaCambioFuente` at once. `stop()` bumps the revision again and + // publishes `idle` itself. Every queued change then woke up, REWROTE + // `loading` over that `idle`, and only afterwards (past `_recrearPlayer`) + // discovered its revision was stale. The last thing Android Auto saw was + // `loading` on a session `audio_service` had already torn down: a + // permanent spinner with a dead Stop button. + // + // Nothing below this line can strand `_cambiandoFuente`: the flag is only + // set AFTER this return, so an early exit here leaves it exactly as the + // caller found it. + if (revision != _revisionFuente) return; this.mediaItem.add(mediaItem); emisoraActual = _emisoraDesdeMediaItem(mediaItem); + // A new source is being opened, so no previous terminal error owns the + // screen any more (see [_errorTerminal]). + _errorTerminal = false; + // A fresh source (a reconnect ATTEMPT included) has proven nothing yet: + // re-arm the terminal-state floor for it. Without this reset, a station + // that played and then died would inherit the previous run's "audio is + // flowing" verdict and its spinner would never be floored. + _reproduccionEstablecida = false; playbackState.add( playbackState.value.copyWith( processingState: AudioProcessingState.loading, playing: false, - errorMessage: null, + // A FRESH user source change clears whatever was on screen; a + // reconnect ATTEMPT does not. Retries run behind the "reconnecting" + // message [_intentarReconexion] published, and blanking it here on + // every attempt would put the driver back on a wordless spinner for + // most of the backoff window. + errorMessage: _reconectando ? playbackState.value.errorMessage : null, + // ALWAYS cleared, unlike the message. `copyWith` carries an OMITTED + // field forward (`audio_service.dart:419-420`), and this used to omit + // `errorCode`, so a code published by an earlier refusal (a premium + // tap, a voice miss) rode along on every later state indefinitely — + // pairing a stale `ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED` with whatever + // message came next. It is cleared even while reconnecting because + // "Reconectando..." is a STATUS, not an error: `setState` still + // forwards a message with a null code + // (`AudioService.java:541-544`). + errorCode: null, ), ); // Opens the masking window BEFORE `_recrearPlayer`, which is what @@ -1590,11 +2061,15 @@ class PluriWaveAudioHandler extends BaseAudioHandler // `playerStateStream` event is the transient `idle` we must not forward // (see [mapearEstadoProceso]). _cambiandoFuente = true; + // Paired with the `finally` below: tells the terminal-state floor that a + // station is genuinely still opening, so it re-arms instead of forcing a + // slow-but-healthy load to `idle`. + _cambiosEnVuelo++; try { await _recrearPlayer(); if (revision != _revisionFuente) return; - await _player.setUrl(mediaItem.id).timeout(_timeoutCambioFuente); + await _player.setUrl(mediaItem.id).timeout(timeoutCambioFuente); // Source swap complete: the fresh player's transient `idle` is behind // us, so stop masking immediately — before anything below can await — // and let a real `idle` through again from here on. @@ -1632,6 +2107,22 @@ class PluriWaveAudioHandler extends BaseAudioHandler '[PluriWave][ServicioAudio] Error inesperado en playMediaItem: $e', ); if (revision == _revisionFuente) { + // Same shape as [_gestionarErrorReproduccion]'s terminal path, and + // for the same reasons — this clause is the THIRD way a station can + // die and it used to be the least survivable of them. + // + // The latch stops the `idle` that follows (`_recrearPlayer` and the + // player's own teardown both produce one) from being forwarded into + // `AudioService._stop()` (`audio_service.dart:1131-1135`). + // + // And the metadata is now KEPT. Clearing it here contradicted the + // sibling path outright: Android Auto drops a session with nothing to + // show, so `mediaItem.add(null)` made PluriWave disappear from the + // car pane the moment an unexpected error hit, and `emisoraActual = + // null` additionally left `_saltarEmisora` with no idea where it was, + // so previous/next stopped working — stranding the driver on the one + // failure they most need to skip out of. + _errorTerminal = true; playbackState.add( playbackState.value.copyWith( processingState: AudioProcessingState.error, @@ -1639,8 +2130,6 @@ class PluriWaveAudioHandler extends BaseAudioHandler errorMessage: _textos.audioErrorUnexpectedPlayback, ), ); - emisoraActual = null; - this.mediaItem.add(null); } rethrow; } finally { @@ -1652,6 +2141,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler // hand-audited exit path, because a `_cambiandoFuente` stuck at `true` // would mask a REAL stop's `idle` and leave the notification unkillable. _cambiandoFuente = false; + _cambiosEnVuelo--; } } @@ -1685,6 +2175,34 @@ class PluriWaveAudioHandler extends BaseAudioHandler } void _iniciarPlaySinBloquear(MediaItem mediaItem, int revision) { + // Re-validate the user's intent, not just the source revision. + // + // Only `stop()` bumps `_revisionFuente`, so a `pause()` landing WHILE a + // station was still loading did not cancel anything: the load finished, + // this method ran, and the station started playing right after the user + // had asked for silence. `_intencionReproducir` is the flag `pause()` + // already clears (and `play()` already sets), so reading it here is the + // narrow fix. + // + // Deliberately NOT done by bumping `_revisionFuente` inside `pause()`: + // that would abort the whole source change, and a pause landing before + // `_recrearPlayer()` returned would leave a fresh player with NO source + // loaded — the subsequent resume would then call `play()` on nothing and + // produce silence. Letting the load COMPLETE and only withholding the + // `play()` keeps resume working: the source is already there. + if (!_intencionReproducir) { + // The source IS loaded, the user just does not want it playing. Say so + // explicitly: without this the car keeps the `loading` spinner the + // source change opened with, and nothing else would ever replace it — + // the player makes no transition when `play()` is never called. + playbackState.add( + playbackState.value.copyWith( + processingState: AudioProcessingState.ready, + playing: false, + ), + ); + return; + } unawaited( _player.play().catchError((Object error, StackTrace stackTrace) { debugPrint( @@ -1938,6 +2456,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler @override Future play() { _intencionReproducir = true; + // Fresh user intent: whatever terminal error was standing no longer owns + // the screen, so stop masking the player's `idle` (see [_errorTerminal]). + _errorTerminal = false; return _player.play(); } @@ -1946,6 +2467,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler // User (or audio-session interruption) pause: disarm any pending retry — // a stall must never fight an intentional pause (S7-R2-B, S7-R6). _intencionReproducir = false; + _errorTerminal = false; _detenerReconexion(); return _player.pause(); } @@ -1956,6 +2478,9 @@ class PluriWaveAudioHandler extends BaseAudioHandler // so retries never restart playback after a stop (S7-R6). _intencionReproducir = false; _detenerReconexion(); + // Disarm the action-refusal window before it can fire against the idle + // this method is about to publish (see [_limpiarErrorAccionAuto]). + _limpiarErrorAccionAuto(); // Local queue (Design ADR-2): clears alongside the reconnect-cancel // logic above — `onTaskRemoved` (which calls stop()) inherits this for // free (Phase 3 task 3.5). @@ -1971,6 +2496,14 @@ class PluriWaveAudioHandler extends BaseAudioHandler // that in-flight change; its `finally` only re-clears the flag, and // nothing re-arms it (the single `= true` assignment already ran). _cambiandoFuente = false; + // Same reason and the same ordering as `_cambiandoFuente` above: a mask + // still armed here would swallow the real `idle` and leave the + // notification unkillable with a dead Stop button — the exact citation + // this branch exists to answer. + _errorTerminal = false; + // The session is over: whatever this run proved about the mount does not + // carry into the next one (see [_reproduccionEstablecida]). + _reproduccionEstablecida = false; _revisionFuente++; await _player.stop(); // Publish `idle` OURSELVES rather than trusting the player to emit it. @@ -1991,6 +2524,13 @@ class PluriWaveAudioHandler extends BaseAudioHandler processingState: AudioProcessingState.idle, playing: false, errorMessage: null, + // Paired with `errorMessage` above, which shipped alone. `copyWith` + // carries an OMITTED field forward (`audio_service.dart:400-427`), + // so a stopped, idle session went on advertising the code of the + // last refusal — an ERROR_CODE_PREMIUM_ACCOUNT_REQUIRED (4) with the + // message deliberately blanked out, i.e. an error the head unit + // could show but never explain. + errorCode: null, ), ); emisoraActual = null; @@ -2010,12 +2550,15 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// and a button that is present but inert is worse than no button. @override Future skipToNext() async { - // iap-freemium-unlock (Design ADR-4 backstop): station-to-station - // skipping is a browse/switch action, blocked for free tier regardless - // of queue state. Current-station play/pause/stop is untouched. - if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) { - return; - } + // fix/auto-quality-guidelines item 14: the free-tier no-op is GONE. The + // buttons stay advertised for every tier and CYCLE WITHIN the free set + // instead — see [_saltarEmisora]. Withdrawing them from `controls` and + // `systemActions` was the alternative and was rejected: those two lists + // are rebuilt inside a SYNCHRONOUS `playerStateStream` listener, so + // making them tier-dependent would mean either awaiting a prefs read on + // every player event or caching entitlement in a second place; and a + // button that works is better UX than a hole, on a surface where Auto + // reserves the slots anyway. final cola = _colaLocal; if (cola == null) { if (_reproduciendoRadio) await _saltarEmisora(haciaAtras: false); @@ -2037,10 +2580,8 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// [skipToNext]. @override Future skipToPrevious() async { - // iap-freemium-unlock (Design ADR-4 backstop): mirrors [skipToNext]. - if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) { - return; - } + // Mirrors [skipToNext] — see its comment for why the free-tier gate is + // gone (fix/auto-quality-guidelines, item 14). final cola = _colaLocal; if (cola == null) { if (_reproduciendoRadio) await _saltarEmisora(haciaAtras: true); @@ -2081,15 +2622,26 @@ class PluriWaveAudioHandler extends BaseAudioHandler /// would surface as the app going silent mid-drive. Future _saltarEmisora({required bool haciaAtras}) async { try { - final fuente = _fuenteNavegacionGlobal; final actual = emisoraActual; - if (fuente == null || actual == null) return; - final lista = listaParaSaltoEmisora( - actual: actual, - favoritos: await fuente.favoritos(), - misEmisoras: await fuente.misEmisoras(), - todas: await fuente.todas(), - ); + if (actual == null) return; + // fix/auto-quality-guidelines item 14: the list is SCOPED by tier + // rather than the buttons being refused. A free driver cycles the free + // set — which always has at least [emisorasDestacadas.length] entries, + // so neither button is ever a dead end — and a premium driver keeps the + // narrowest-context walk (`listaParaSaltoEmisora`) unchanged. + final List lista; + if (await esPremiumPersistido()) { + final fuente = _fuenteNavegacionGlobal; + if (fuente == null) return; + lista = listaParaSaltoEmisora( + actual: actual, + favoritos: await fuente.favoritos(), + misEmisoras: await fuente.misEmisoras(), + todas: await fuente.todas(), + ); + } else { + lista = await resolverEmisorasDestacadas(); + } final destino = emisoraVecina(actual, lista, haciaAtras: haciaAtras); // Reported: in the car these buttons did nothing for radio. Every early // return here is silent, so the log has to say WHICH one fired -- @@ -2141,20 +2693,64 @@ class PluriWaveAudioHandler extends BaseAudioHandler @override Future onTaskRemoved() async { await stop(); + await liberar(); + } + + /// Releases everything this handler owns that can still run on its own: + /// the terminal-state floor timer, the action-refusal window, the + /// reconnect backoff (1/2/4/8/16 s — easily longer than whatever created + /// the handler lives for), anything still queued on `_colaCambioFuente`, + /// every player subscription, the player itself and the browse subjects. + /// + /// The revision bump is the load-bearing part: a queued `_cambiarFuente` + /// body that outlives its owner calls `_crearPlayer()`, which reads the + /// CURRENT [fabricaReproductorPrueba]. In a test suite that means a + /// released handler building a double bound to a LATER test's script and + /// driving it. Bumping the revision makes every queued body return at its + /// entry guard, before it can publish or build anything. + /// + /// Idempotent, and never throws: teardown that can fail halfway is not + /// teardown. Called by [onTaskRemoved] in production and by every test + /// that constructs a handler. + Future liberar() async { + // Invalidates every queued and in-flight source change (see above). + _revisionFuente++; + _intencionReproducir = false; + _cambiandoFuente = false; + _errorTerminal = false; + _reproduccionEstablecida = false; + _vigilanciaTimer?.cancel(); + _vigilanciaTimer = null; + _detenerReconexion(); + _limpiarErrorAccionAuto(); + _desactivarCola(); await _estadoPlayerSub?.cancel(); + _estadoPlayerSub = null; await _bufferedSub?.cancel(); + _bufferedSub = null; await _duracionSub?.cancel(); + _duracionSub = null; await _eventosSub?.cancel(); + _eventosSub = null; await _androidAudioSessionIdSub?.cancel(); - await _player.dispose(); - await _androidAudioSessionIdController.close(); + _androidAudioSessionIdSub = null; + // A player built without the test factory talks to platform channels that + // do not exist under `flutter test`, so disposal is allowed to fail — + // the subscriptions above are already gone either way. + try { + await _player.dispose(); + } catch (_) {} + if (!_androidAudioSessionIdController.isClosed) { + await _androidAudioSessionIdController.close(); + } for (final subject in _childrenSubjects.values) { await subject.close(); } + _childrenSubjects.clear(); // Handler teardown: release the bootstrap-owned `AudioService.asyncError` // subscription too, so it cannot outlive the handler it was instrumenting. // Never throws out of teardown — a failing cleanup hook must not prevent - // the rest of `onTaskRemoved` from having completed above. + // the rest of this method from having completed above. try { await _limpiezaArranqueGlobal?.call(); } catch (_) {} @@ -2180,18 +2776,59 @@ class PluriWaveAudioHandler extends BaseAudioHandler Map? options, ]) async { try { - final constructor = ConstructorArbolAuto(); - // iap-freemium-unlock (Design ADR-4): the AUTHORITATIVE entitlement - // gate, resolved ONCE per call and checked BEFORE any other - // resolution — the backstop against a stale/deep-linked non-root id - // (android-auto-media spec "Free-Tier Browse Never Leaks Real - // Content"). Never blocks the root itself (see that function's doc). + // Every user-readable label the car tree stamps onto a `MediaItem` + // comes from here. `_textos` resolves headlessly through + // [resolverLocalizacionesRespaldo], so this works on the engine + // Android Auto starts without an Activity -- which is the only engine + // a Play reviewer ever gets. + final etiquetas = etiquetasArbolAutoDesde(_textos); + final constructor = ConstructorArbolAuto(etiquetas: etiquetas); + // The "recent" root, resolved BEFORE the entitlement gate. + // + // `onGetRoot` (`AudioService.java:817-821`) hands back `RECENT_ROOT_ID` + // whenever the head unit sets `BrowserRoot.EXTRA_RECENT`, which Android + // Auto does on every reconnect. There was no branch for it, so it fell + // through: free tier hit `respuestaBloqueadaPorEntitlement` and got all + // six stations where the platform expects exactly ONE resume item, and + // premium fell past every branch to `const []` — a dead resume tile. + // + // Tier-independent on purpose (see [ultimaEmisoraPersistida]): the + // station offered here is by definition one this device has already + // played, so resuming it leaks nothing. An absent/corrupt record yields + // an empty list rather than a placeholder — a non-playable row is the + // thing Play cited. + if (parentMediaId == AudioService.recentRootId) { + final ultima = await ultimaEmisoraPersistida(); + return ultima == null + ? const [] + : [constructor.itemEmisora(ultima)]; + } + // The AUTHORITATIVE entitlement gate, resolved ONCE per call and + // checked BEFORE any other resolution — the backstop against a + // stale/deep-linked non-root id from a head unit's cached tree. + // + // fix/auto-quality-guidelines item 10: it now SCOPES content instead of + // blocking actions, so its answer for a premium id is the free tier's + // own playable stations, never a non-playable row. final premium = await esPremiumPersistido(); + final destacadas = + premium ? const [] : await resolverEmisorasDestacadas(); final bloqueada = respuestaBloqueadaPorEntitlement( parentMediaId: parentMediaId, premium: premium, + destacadas: destacadas, ); if (bloqueada != null) return bloqueada; + // The free tier's only folder (item 9). Resolved HERE, before the + // `_fuenteNavegacionGlobal` gate below, exactly like the local-music and + // equalizer branches: its content comes from the binary, so it must + // survive a bind where no browse source has been registered yet — which + // is precisely the bind a Play reviewer's first launch performs. + if (parentMediaId == ConstructorArbolAuto.idDestacadas) { + return constructor.hijosDestacadas( + premium ? await resolverEmisorasDestacadas() : destacadas, + ); + } final fuenteLocal = _fuenteMusicaLocalGlobal; if (parentMediaId == AudioService.browsableRootId) { // fix/android-auto-musica-local: la RAÍZ ya no se decide con el @@ -2207,7 +2844,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler // `canalNoDisponible` lo mantiene, y es el SUBÁRBOL quien explica // el problema (`hijosMusicaLocal`) en vez de dejar una carpeta // vacía. + // + // fix/auto-quality-guidelines item 9: `premium &&` comes FIRST on + // purpose. Dart short-circuits `&&`, so a free-tier root never makes + // the `estadoCarpeta()` call at all — it cannot show Música Local + // anyway, and that call travels over a native channel whose handler + // only exists when an Activity has registered it, so skipping it + // removes a failure mode from the one browse call that must never + // fail. final incluirMusicaLocal = + premium && fuenteLocal != null && await fuenteLocal.estadoCarpeta() != EstadoCarpetaLocal.noConfigurada; @@ -2219,6 +2865,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler final musicaLocal = await hijosMusicaLocal( parentMediaId, fuente: fuenteLocal, + etiquetas: etiquetas, ); if (musicaLocal != null) return musicaLocal; // Ecualizador folder (decision `auto/ecualizador-diseno`): needs no @@ -2279,38 +2926,169 @@ class PluriWaveAudioHandler extends BaseAudioHandler } } + /// Publishes a terminal, EXPLAINED refusal to the car + /// (fix/auto-quality-guidelines, item 12). + /// + /// Every dispatch path that used to `return;` in silence now ends here + /// instead. A silent return is the worst possible answer on a head unit: + /// the driver taps a row (or speaks a command), the assistant accepts it, + /// and absolutely nothing happens with nothing on screen to say why — + /// which is the shape of the defect Google Play cited. + /// `ACTION_PLAY_FROM_SEARCH` in particular is forced into the plugin's + /// `AUTO_ENABLED_ACTIONS`, so a mute handler stays advertised forever and + /// cannot be withdrawn from Dart. + /// + /// `playing`, `mediaItem` and the player itself are deliberately untouched: + /// this error describes the requested ACTION that could not be carried out, + /// not the session. Whatever was already playing keeps playing. + /// + /// CORRECTED: it used to publish [AudioProcessingState.error] + /// unconditionally, which was worse than the silent return it replaced — + /// see [puedePublicarErrorTerminalAuto] for the full mechanism. The + /// processing state now only moves when there is no live session to + /// destroy; otherwise the refusal travels as `errorCode`/`errorMessage` + /// alone, which still reaches `PlaybackStateCompat.setErrorMessage` + /// (`AudioService.java:541-544`) whatever the state is. + void _publicarErrorAuto(int codigo, String mensaje) { + final actual = playbackState.value; + final terminal = puedePublicarErrorTerminalAuto(actual.processingState); + debugPrint( + '[PluriWave][ServicioAudio] rechazo auto codigo=$codigo ' + 'terminal=$terminal sobre ${actual.processingState.name}: $mensaje', + ); + // A previous refusal's window must not outlive the refusal it belonged + // to: this publish takes ownership of the two fields. + _temporizadorErrorAccionAuto?.cancel(); + _temporizadorErrorAccionAuto = null; + _errorAccionAuto = null; + playbackState.add( + actual.copyWith( + processingState: terminal + ? AudioProcessingState.error + : actual.processingState, + errorCode: codigo, + errorMessage: mensaje, + ), + ); + // A TERMINAL refusal stands until something else happens: it IS the + // session state now, the driver has nothing playing to go back to, and + // `error` is not a field that can be quietly withdrawn. + // + // A refusal over a LIVE session is the opposite — it describes an action, + // not the session, and nothing in this class would ever have taken it + // back: `_bufferedSub` republishes through `copyWith` about twice a + // second and `copyWith` carries omitted fields forward + // (`audio_service.dart:400-427`), so `AudioService.java:541-544` re-armed + // `setErrorMessage(code, msg)` on every push for the rest of the + // station's playback. Bound it here. + if (terminal) return; + _errorAccionAuto = (codigo: codigo, mensaje: mensaje); + _temporizadorErrorAccionAuto = Timer( + ventanaErrorAccionAuto, + _limpiarErrorAccionAuto, + ); + } + + /// Takes a standing action refusal back off the head unit (see + /// [ventanaErrorAccionAutoPorDefecto]). Idempotent, and a no-op unless the + /// fields on screen are still the exact pair this handler published. + /// + /// Called by the window timer, by [manejarEstadoPlayer] (a real player + /// transition supersedes a refusal about an action) and by [stop]. + void _limpiarErrorAccionAuto() { + _temporizadorErrorAccionAuto?.cancel(); + _temporizadorErrorAccionAuto = null; + final pendiente = _errorAccionAuto; + if (pendiente == null) return; + _errorAccionAuto = null; + final actual = playbackState.value; + // Someone else owns these fields now — a reconnect status message, or a + // terminal playback error that moved the state itself. Blanking those + // would remove a message that is still true. + if (actual.processingState == AudioProcessingState.error) return; + if (actual.errorCode != pendiente.codigo || + actual.errorMessage != pendiente.mensaje) { + return; + } + playbackState.add(actual.copyWith(errorCode: null, errorMessage: null)); + } + + /// The stations a voice query is matched against, and the station an EMPTY + /// query starts, for the tier resolved by [premium]. + /// + /// Free tier searches ONLY the free set — not favourites + my stations + + /// the catalogue — so a match can never resolve to something the tier + /// cannot then play. + Future> _candidatasBusqueda({ + required bool premium, + required List destacadas, + }) async { + if (!premium) return destacadas; + final fuente = _fuenteNavegacionGlobal; + if (fuente == null) return destacadas; + return [ + ...await fuente.favoritos(), + ...await fuente.misEmisoras(), + ...await fuente.todas(), + // Appended last so they only ever win a query nothing else matched. + ...destacadas, + ]; + } + /// Voice search from the car ("pon Radio Clásica"). /// /// `ACTION_PLAY_FROM_SEARCH` is one of the actions Android for Cars - /// documents as required, and it is now advertised in `systemActions` — so - /// it has to actually do something. Advertising it unimplemented would be + /// documents as required, and it is advertised in `systemActions` — so it + /// has to actually do something. Advertising it unimplemented would be /// worse than omitting it: the assistant would accept the command and /// nothing would play, with no error to explain it. /// - /// Favourites first, then my stations, then the full list, so a station the - /// driver already cares about wins a name tie. Never throws and never plays - /// something arbitrary on a miss — see [emisoraParaBusqueda]. + /// An EMPTY query ("Reproduce PluriWave") starts the last played station, + /// or the first featured one (fix/auto-quality-guidelines, item 13). This + /// used to do NOTHING for either tier — `emisoraParaBusqueda` returns + /// `null` on an empty query and the caller just returned — so the single + /// most likely voice command failed even for a paying customer. Google's + /// voice-actions documentation requires an empty query to start playback. + /// + /// Never throws. A miss now publishes an explained error rather than + /// returning silently; it still never plays something arbitrary. @override Future playFromSearch( String query, [ Map? extras, ]) async { try { - // iap-freemium-unlock (Design ADR-4 backstop): voice search resolves a - // station and switches to it — a browse/switch action, blocked for - // free tier just like `playFromMediaId`/`skipToNext-Previous`. - if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) { + final premium = await esPremiumPersistido(); + final destacadas = await resolverEmisorasDestacadas(); + + if (query.trim().isEmpty) { + // `resolverEmisorasDestacadas` puts the last played station first + // when one is persisted, so this is "resume what I was listening + // to", falling back to the first featured station on a fresh install. + final arranque = destacadas.isEmpty ? null : destacadas.first; + if (arranque == null) { + _publicarErrorAuto( + codigoErrorEmisoraNoDisponible, + _textos.autoErrorBusquedaSinResultados, + ); + return; + } + await playMediaItem(mediaItemParaEmisora(arranque, l10n: _textos)); return; } - final fuente = _fuenteNavegacionGlobal; - if (fuente == null) return; - final candidatas = [ - ...await fuente.favoritos(), - ...await fuente.misEmisoras(), - ...await fuente.todas(), - ]; + + final candidatas = await _candidatasBusqueda( + premium: premium, + destacadas: destacadas, + ); final emisora = emisoraParaBusqueda(query, candidatas); - if (emisora == null) return; + if (emisora == null) { + _publicarErrorAuto( + codigoErrorEmisoraNoDisponible, + _textos.autoErrorBusquedaSinResultados, + ); + return; + } await playMediaItem(mediaItemParaEmisora(emisora, l10n: _textos)); } catch (e) { debugPrint( @@ -2325,14 +3103,29 @@ class PluriWaveAudioHandler extends BaseAudioHandler Map? extras, ]) async { try { - // iap-freemium-unlock (Design ADR-4 backstop): the mandatory backstop - // against a head-unit's CACHED browse tree — `getChildren` alone - // cannot stop a stale `emisora:`/`pista:`/`eq_preset:` tap from - // a tree fetched before a downgrade (or from another device). Checked - // BEFORE every branch below, including local tracks and the - // equalizer (android-auto-media spec "Free-Tier Browse Never Leaks - // Real Content (Authoritative Backstop)"). - if (debeBloquearCambioDeEmisora(premium: await esPremiumPersistido())) { + // The mandatory backstop against a head unit's CACHED browse tree: + // `getChildren` alone cannot stop a stale `emisora:`/`pista:`/ + // `eq_preset:` tap from a tree fetched before a downgrade (or from + // another device). Checked BEFORE every branch below, including local + // tracks and the equalizer. + // + // fix/auto-quality-guidelines item 11/12: the gate is now content + // scoped, and its refusal is EXPLAINED instead of silent. Only an + // `emisora:` whose uuid is in the free set is free content — + // every other id shape (local tracks, folder-play actions, equalizer + // presets, catalogue stations) resolves `uuid` to `null` here and is + // therefore premium, exactly as before. + final premium = await esPremiumPersistido(); + final destacadas = await resolverEmisorasDestacadas(); + final uuid = uuidDeMediaIdEmisora(mediaId); + if (debeBloquearCambioDeEmisora( + premium: premium, + esEmisoraGratuita: esEmisoraGratuita(uuid, destacadas), + )) { + _publicarErrorAuto( + codigoErrorEmisoraPremium, + _textos.autoErrorEmisoraPremium, + ); return; } // Local-track playback (Design "Local Track Playback Reuses Existing @@ -2346,6 +3139,7 @@ class PluriWaveAudioHandler extends BaseAudioHandler mediaId, fuente: fuenteLocal, reproducir: playMediaItem, + etiquetas: etiquetasArbolAutoDesde(_textos), ); return; } @@ -2385,13 +3179,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler ); return; } - final fuente = _fuenteNavegacionGlobal; - if (fuente == null) return; - await reproducirPorMediaId( + // Station ids. `_fuenteNavegacionGlobal` is null until `main.dart` + // registers it, and used to make this branch return in silence — so a + // tap that arrived before registration did nothing, with nothing said. + // The free set is resolvable straight from the binary, so it stands in + // as the browse source instead (item 12). + final fuente = + _fuenteNavegacionGlobal ?? FuenteEmisorasAutoDestacadas(destacadas); + final reproducida = await reproducirPorMediaId( mediaId, fuente: fuente, reproducir: playMediaItem, ); + if (!reproducida) { + _publicarErrorAuto( + codigoErrorEmisoraNoDisponible, + _textos.autoErrorBusquedaSinResultados, + ); + } } catch (e) { // Spec "Unknown or stale media id": never propagate from the handler. debugPrint( diff --git a/test/arranque_invalidacion_arbol_auto_test.dart b/test/arranque_invalidacion_arbol_auto_test.dart index 6915ed6..347847d 100644 --- a/test/arranque_invalidacion_arbol_auto_test.dart +++ b/test/arranque_invalidacion_arbol_auto_test.dart @@ -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); diff --git a/test/helpers/handlers_audio.dart b/test/helpers/handlers_audio.dart new file mode 100644 index 0000000..b656f0e --- /dev/null +++ b/test/helpers/handlers_audio.dart @@ -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 = []; + 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; + }; +} diff --git a/test/l10n/etiquetas_arbol_auto_test.dart b/test/l10n/etiquetas_arbol_auto_test.dart new file mode 100644 index 0000000..14fcd22 Binary files /dev/null and b/test/l10n/etiquetas_arbol_auto_test.dart differ diff --git a/test/l10n/identical_value_allowlist.dart b/test/l10n/identical_value_allowlist.dart index 11f3661..ff6f1f8 100644 --- a/test/l10n/identical_value_allowlist.dart +++ b/test/l10n/identical_value_allowlist.dart @@ -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. }; diff --git a/test/servicios/emisoras_destacadas_test.dart b/test/servicios/emisoras_destacadas_test.dart new file mode 100644 index 0000000..8487ad2 --- /dev/null +++ b/test/servicios/emisoras_destacadas_test.dart @@ -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:', + ); + 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'); + }); +} diff --git a/test/servicios/navegacion_auto_destacadas_test.dart b/test/servicios/navegacion_auto_destacadas_test.dart new file mode 100644 index 0000000..5e30e46 --- /dev/null +++ b/test/servicios/navegacion_auto_destacadas_test.dart @@ -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:', () { + 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? 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); + }); +} diff --git a/test/servicios/navegacion_auto_gating_test.dart b/test/servicios/navegacion_auto_gating_test.dart index 5541582..1d3661f 100644 --- a/test/servicios/navegacion_auto_gating_test.dart +++ b/test/servicios/navegacion_auto_gating_test.dart @@ -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: 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); }); }); } diff --git a/test/servicios/navegacion_auto_localizacion_test.dart b/test/servicios/navegacion_auto_localizacion_test.dart new file mode 100644 index 0000000..a724e8e --- /dev/null +++ b/test/servicios/navegacion_auto_localizacion_test.dart @@ -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> _sinMetadatos(List ids) async => + const {}; + +List _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 estadoCarpeta() async => + EstadoCarpetaLocal.configurada; + + @override + Future> hijos(String documentId) async => + documentId.isEmpty + ? const [ + NodoLocal( + documentId: 'doc-0', + nombre: 'cancion.mp3', + esDirectorio: false, + ), + ] + : const []; + + @override + Future> metadatosDe(List documentIds) => + _sinMetadatos(documentIds); + + @override + Future uriContenidoDePista(String documentId) async => + 'content://fake/$documentId'; +} diff --git a/test/servicios/navegacion_auto_test.dart b/test/servicios/navegacion_auto_test.dart index cd589fb..9a24d09 100644 --- a/test/servicios/navegacion_auto_test.dart +++ b/test/servicios/navegacion_auto_test.dart @@ -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( diff --git a/test/servicios/servicio_audio_auto_free_test.dart b/test/servicios/servicio_audio_auto_free_test.dart new file mode 100644 index 0000000..507df8a --- /dev/null +++ b/test/servicios/servicio_audio_auto_free_test.dart @@ -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: 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 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.play, + MediaAction.pause, + MediaAction.stop, + ]), + ); + expect( + estado.controls.map((c) => c.action), + containsAll([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.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 estadoCarpeta() async { + llamadasEstadoCarpeta++; + return EstadoCarpetaLocal.configurada; + } + + @override + Future> hijos(String documentId) async => const []; + + @override + Future uriContenidoDePista(String documentId) async => null; + + @override + Future> metadatosDe( + List documentIds, + ) async => const {}; +} + +/// Misma forma que el doble de `servicio_audio_transporte_test.dart`. +class _GuionReproductor { + int llamadasPlay = 0; + final urlsSolicitadas = []; + + /// 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.broadcast(); + + void emitir(PlayerState estado) => _estados.add(estado); + + @override + Stream get playerStateStream => _estados.stream; + + @override + Future setUrl( + String url, { + Map? headers, + Duration? initialPosition, + bool preload = true, + dynamic tag, + }) async { + _guion.urlsSolicitadas.add(url); + return null; + } + + @override + Future play() async { + _guion.llamadasPlay++; + } + + @override + Future pause() async {} + + @override + Future stop() async {} + + @override + Future setVolume(double volume) async {} + + @override + Future dispose() async { + await _estados.close(); + } +} diff --git a/test/servicios/servicio_audio_eq_estado_unico_test.dart b/test/servicios/servicio_audio_eq_estado_unico_test.dart index 14be0e9..a85a95f 100644 --- a/test/servicios/servicio_audio_eq_estado_unico_test.dart +++ b/test/servicios/servicio_audio_eq_estado_unico_test.dart @@ -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 = []; 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 = []; 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 = []; 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( diff --git a/test/servicios/servicio_audio_gating_test.dart b/test/servicios/servicio_audio_gating_test.dart index fa6debd..b2a0f7c 100644 --- a/test/servicios/servicio_audio_gating_test.dart +++ b/test/servicios/servicio_audio_gating_test.dart @@ -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 = >[]; @@ -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 = >[]; final sub = stream.listen(recibidos.add); @@ -143,7 +163,7 @@ void main() { Future> 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(); } diff --git a/test/servicios/servicio_audio_transporte_test.dart b/test/servicios/servicio_audio_transporte_test.dart new file mode 100644 index 0000000..a6fd707 --- /dev/null +++ b/test/servicios/servicio_audio_transporte_test.dart @@ -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 = []; + 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(); + 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(); + 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.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(); + final handler = crearHandler(); + + unawaited( + handler + .playMediaItem(const MediaItem(id: 'https://a', title: 'A')) + .catchError((_) {}), + ); + await pumpEventQueue(); + + await Future.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.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.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.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 = []; + 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.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.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.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.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 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 = []; + + /// 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? 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.broadcast(); + + /// Drives the exact `playerStateStream` event the real player would emit. + void emitir(PlayerState estado) { + if (!_estados.isClosed) _estados.add(estado); + } + + @override + Stream get playerStateStream => _estados.stream; + + @override + Future setUrl( + String url, { + Map? headers, + Duration? initialPosition, + bool preload = true, + dynamic tag, + }) { + _guion.llamadasSetUrl++; + _guion.urlsSolicitadas.add(url); + if (_guion.setUrlCuelga) return Completer().future; + final pendiente = _guion.completerSetUrl; + if (pendiente != null) return pendiente.future; + final error = _guion.errorSetUrl; + if (error != null) return Future.error(error); + return Future.value(null); + } + + @override + Future play() async { + _guion.llamadasPlay++; + } + + @override + Future pause() async {} + + @override + Future stop() async {} + + @override + Future setVolume(double volume) async {} + + @override + Future dispose() async { + await _estados.close(); + } +}