From 3b81ab53a4eec22a8fcbf4b64d87927abc3b4d93 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:32:33 +0500 Subject: [PATCH 01/16] Voice search that explains failures, follows the app language, and answers the TV remote Speech is initialised on the first mic tap instead of when search opens, and the button no longer disappears. The dialog now shows distinct states for no speech, a refused microphone permission (with a link to the app's settings), a missing recogniser, and network or recogniser errors, with a retry where it can help. The recogniser locale follows the app language when the device has it. The mic stays next to the clear button once the field has text, and the remote's search and voice keys start voice search on Android TV. --- .../kotlin/com/soplay/sozo/MainActivity.kt | 15 + assets/translations/ar.json | 14 +- assets/translations/ckb.json | 14 +- assets/translations/de.json | 14 +- assets/translations/en.json | 14 +- assets/translations/es.json | 14 +- assets/translations/fr.json | 14 +- assets/translations/hi.json | 14 +- assets/translations/id.json | 14 +- assets/translations/ku.json | 14 +- assets/translations/nl.json | 14 +- assets/translations/pt.json | 14 +- assets/translations/ru.json | 14 +- assets/translations/tr.json | 14 +- assets/translations/uz.json | 14 +- assets/translations/vi.json | 14 +- assets/translations/yue.json | 14 +- lib/core/system/app_settings.dart | 23 + lib/core/tv/tv_keys.dart | 18 + .../presentation/pages/search_page.dart | 20 +- .../presentation/widgets/search_header.dart | 34 +- .../widgets/voice_search_button.dart | 133 ++--- .../widgets/voice_search_dialog.dart | 493 +++++++++++------- .../widgets/voice_search_session.dart | 227 ++++++++ test/core/tv/tv_keys_test.dart | 18 + .../search/voice_search_session_test.dart | 184 +++++++ 26 files changed, 1091 insertions(+), 298 deletions(-) create mode 100644 lib/core/system/app_settings.dart create mode 100644 lib/features/search/presentation/widgets/voice_search_session.dart create mode 100644 test/core/tv/tv_keys_test.dart create mode 100644 test/features/search/voice_search_session_test.dart diff --git a/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt b/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt index bd9580f2..a3161af5 100644 --- a/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt +++ b/android/app/src/main/kotlin/com/soplay/sozo/MainActivity.kt @@ -266,6 +266,7 @@ class MainActivity : FlutterFragmentActivity() { ) ) "openNotificationSettings" -> result.success(openNotificationSettings()) + "openAppSettings" -> result.success(openAppSettings()) "apkPackageName" -> { val path = call.argument("path").orEmpty() val name = try { @@ -1172,6 +1173,20 @@ class MainActivity : FlutterFragmentActivity() { } } + private fun openAppSettings(): Boolean { + return try { + startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:$packageName") + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + true + } catch (_: Exception) { + false + } + } + private fun openDefaultLinksSettings(): Boolean { val pkgUri = Uri.parse("package:$packageName") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 6e6bbb79..7cabc277 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -874,8 +874,20 @@ "all_sources_failed_one": "فشل المصدر الذي بحثت فيه في الرد. هذه ليست نتيجة فارغة — حاول مرة أخرى.", "voice": "البحث الصوتي", "voice_listening": "جارٍ الاستماع…", - "voice_done": "توقّف الاستماع", "voice_hint": "قل اسم فيلم أو مسلسل", + "voice_starting": "جارٍ تجهيز الميكروفون…", + "voice_no_speech": "لم نتمكن من سماعك", + "voice_no_speech_hint": "تحدّث بصوت أعلى قليلاً وبالقرب من الميكروفون، ثم حاول مرة أخرى.", + "voice_permission": "إذن الميكروفون متوقف", + "voice_permission_hint": "اسمح لـ Sozo باستخدام الميكروفون من إعدادات النظام للبحث بالصوت.", + "voice_unavailable": "التعرّف على الكلام غير متاح على هذا الجهاز", + "voice_unavailable_hint": "لا توجد خدمة للتعرّف على الكلام على هذا الجهاز. ثبّت خدمة أو فعّلها من إعدادات النظام، مثل خدمة Google.", + "voice_network": "تعذّر الوصول إلى خدمة الكلام", + "voice_network_hint": "تحقّق من اتصالك بالإنترنت وحاول مرة أخرى.", + "voice_error": "حدث خطأ أثناء الاستماع", + "voice_error_hint": "ربما يستخدم تطبيق آخر الميكروفون. حاول مرة أخرى بعد قليل.", + "voice_try_again": "حاول مرة أخرى", + "voice_open_settings": "فتح الإعدادات", "try_torrents": "البحث في التورنت", "capped_note": "تم البحث في أكثر {} مصدرًا موثوقية من أصل {} — ضيّق النطاق للوصول إلى مصدر بعينه.", "rail_popular_on": "رائج على {source}", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 6cef5071..47238f71 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -874,8 +874,20 @@ "all_sources_failed_one": "ئەو سەرچاوەیەی تێیدا گەڕایت وەڵامی نەدایەوە. ئەمە ئەنجامی بەتاڵ نییە — دووبارە هەوڵبدەرەوە.", "voice": "گەڕان بە دەنگ", "voice_listening": "گوێ دەگرێت…", - "voice_done": "گوێگرتن وەستا", "voice_hint": "ناوی فیلمێک یان زنجیرەیەک بڵێ", + "voice_starting": "مایکرۆفۆن ئامادە دەکرێت…", + "voice_no_speech": "تێنەگەیشتین", + "voice_no_speech_hint": "کەمێک بەرزتر و نزیکتر لە مایکرۆفۆن قسە بکە، پاشان دووبارە هەوڵ بدەرەوە.", + "voice_permission": "مۆڵەتی مایکرۆفۆن کوژاوەتەوە", + "voice_permission_hint": "لە ڕێکخستنەکانی سیستەمدا ڕێگە بە Sozo بدە مایکرۆفۆن بەکاربهێنێت بۆ گەڕان بە دەنگ.", + "voice_unavailable": "ناسینەوەی قسە لەم ئامێرەدا بەردەست نییە", + "voice_unavailable_hint": "ئەم ئامێرە خزمەتگوزاریی ناسینەوەی قسەی نییە. لە ڕێکخستنەکانی سیستەمدا یەکێک دابمەزرێنە یان چالاکی بکە، وەک هی Google.", + "voice_network": "نەتوانرا بگەیەنرێت بە خزمەتگوزاریی قسە", + "voice_network_hint": "پەیوەندیی ئینتەرنێتەکەت بپشکنە و دووبارە هەوڵ بدەرەوە.", + "voice_error": "هەڵەیەک ڕوویدا لە کاتی گوێگرتندا", + "voice_error_hint": "لەوانەیە ئەپێکی تر مایکرۆفۆن بەکاربهێنێت. کەمێکی تر دووبارە هەوڵ بدەرەوە.", + "voice_try_again": "دووبارە هەوڵ بدەرەوە", + "voice_open_settings": "کردنەوەی ڕێکخستنەکان", "try_torrents": "گەڕان لە تۆرێنتەکان", "capped_note": "لە {} باوەڕپێکراوترین سەرچاوە لە کۆی {} گەڕا — مەودا تەسکتر بکەرەوە بۆ گەیشتن بە یەکێکی دیاریکراو.", "rail_popular_on": "بەناوبانگ لە {source}", diff --git a/assets/translations/de.json b/assets/translations/de.json index 0970f03c..71effada 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -874,8 +874,20 @@ "all_sources_failed_one": "Die durchsuchte Quelle hat nicht geantwortet. Das ist kein leeres Ergebnis — versuche es noch einmal.", "voice": "Per Sprache suchen", "voice_listening": "Ich höre zu …", - "voice_done": "Zuhören beendet", "voice_hint": "Sag den Namen eines Films oder einer Serie", + "voice_starting": "Mikrofon wird vorbereitet…", + "voice_no_speech": "Das habe ich nicht verstanden", + "voice_no_speech_hint": "Sprich etwas lauter und näher am Mikrofon und versuch es noch einmal.", + "voice_permission": "Mikrofonzugriff ist aus", + "voice_permission_hint": "Erlaube Sozo in den Systemeinstellungen den Zugriff aufs Mikrofon, um per Sprache zu suchen.", + "voice_unavailable": "Spracherkennung ist auf diesem Gerät nicht verfügbar", + "voice_unavailable_hint": "Auf diesem Gerät gibt es keinen Spracherkennungsdienst. Installiere oder aktiviere einen in den Systemeinstellungen, etwa den von Google.", + "voice_network": "Sprachdienst nicht erreichbar", + "voice_network_hint": "Prüf deine Internetverbindung und versuch es noch einmal.", + "voice_error": "Beim Zuhören ist etwas schiefgelaufen", + "voice_error_hint": "Vielleicht nutzt eine andere App das Mikrofon. Versuch es gleich noch einmal.", + "voice_try_again": "Noch einmal versuchen", + "voice_open_settings": "Einstellungen öffnen", "try_torrents": "Torrents durchsuchen", "capped_note": "Die {} verlässlichsten von {} Quellen wurden durchsucht — grenze die Auswahl ein, um eine bestimmte zu erreichen.", "rail_popular_on": "Beliebt auf {source}", diff --git a/assets/translations/en.json b/assets/translations/en.json index 8fbca5aa..d8d084d2 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -874,8 +874,20 @@ "all_sources_failed_one": "The source you searched failed to answer. This is not an empty result — try again.", "voice": "Search by voice", "voice_listening": "Listening…", - "voice_done": "Stopped listening", "voice_hint": "Say the name of a film or series", + "voice_starting": "Getting the microphone ready…", + "voice_no_speech": "Didn't catch that", + "voice_no_speech_hint": "Speak a little louder, close to the microphone, and try again.", + "voice_permission": "Microphone permission is off", + "voice_permission_hint": "Allow Sozo to use the microphone in system settings to search by voice.", + "voice_unavailable": "Speech recognition isn't available on this device", + "voice_unavailable_hint": "This device has no speech recognition service. Install or turn one on in system settings, such as Google's.", + "voice_network": "Couldn't reach the speech service", + "voice_network_hint": "Check your internet connection and try again.", + "voice_error": "Something went wrong while listening", + "voice_error_hint": "Another app may be using the microphone. Try again in a moment.", + "voice_try_again": "Try again", + "voice_open_settings": "Open settings", "try_torrents": "Search torrents", "capped_note": "Searched the {} most reliable of {} sources — narrow the scope to reach a specific one.", "rail_popular_on": "Popular on {source}", diff --git a/assets/translations/es.json b/assets/translations/es.json index 319ddb48..381debe9 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -874,8 +874,20 @@ "all_sources_failed_one": "La fuente en la que buscaste no respondió. Esto no es un resultado vacío: inténtalo de nuevo.", "voice": "Buscar por voz", "voice_listening": "Escuchando…", - "voice_done": "Dejé de escuchar", "voice_hint": "Di el nombre de una película o serie", + "voice_starting": "Preparando el micrófono…", + "voice_no_speech": "No te entendí", + "voice_no_speech_hint": "Habla un poco más alto y cerca del micrófono, y vuelve a intentarlo.", + "voice_permission": "El permiso del micrófono está desactivado", + "voice_permission_hint": "Permite que Sozo use el micrófono en los ajustes del sistema para buscar por voz.", + "voice_unavailable": "El reconocimiento de voz no está disponible en este dispositivo", + "voice_unavailable_hint": "Este dispositivo no tiene un servicio de reconocimiento de voz. Instala o activa uno en los ajustes del sistema, como el de Google.", + "voice_network": "No se pudo conectar con el servicio de voz", + "voice_network_hint": "Revisa tu conexión a internet y vuelve a intentarlo.", + "voice_error": "Algo salió mal al escuchar", + "voice_error_hint": "Puede que otra app esté usando el micrófono. Vuelve a intentarlo en un momento.", + "voice_try_again": "Reintentar", + "voice_open_settings": "Abrir ajustes", "try_torrents": "Buscar torrents", "capped_note": "Se buscó en las {} fuentes más fiables de {} — acota la selección para llegar a una concreta.", "rail_popular_on": "Popular en {source}", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 599703a9..ec6a2aa5 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -874,8 +874,20 @@ "all_sources_failed_one": "La source interrogée n’a pas répondu. Ce n’est pas un résultat vide — réessaie.", "voice": "Recherche vocale", "voice_listening": "J’écoute…", - "voice_done": "Écoute terminée", "voice_hint": "Dis le nom d’un film ou d’une série", + "voice_starting": "Préparation du micro…", + "voice_no_speech": "Je n’ai pas compris", + "voice_no_speech_hint": "Parle un peu plus fort, près du micro, puis réessaie.", + "voice_permission": "L’autorisation du micro est désactivée", + "voice_permission_hint": "Autorise Sozo à utiliser le micro dans les réglages du système pour chercher à la voix.", + "voice_unavailable": "La reconnaissance vocale n’est pas disponible sur cet appareil", + "voice_unavailable_hint": "Cet appareil n’a aucun service de reconnaissance vocale. Installes-en ou actives-en un dans les réglages du système, comme celui de Google.", + "voice_network": "Impossible de joindre le service vocal", + "voice_network_hint": "Vérifie ta connexion internet et réessaie.", + "voice_error": "Un problème est survenu pendant l’écoute", + "voice_error_hint": "Une autre app utilise peut-être le micro. Réessaie dans un instant.", + "voice_try_again": "Réessayer", + "voice_open_settings": "Ouvrir les réglages", "try_torrents": "Chercher des torrents", "capped_note": "Recherche dans les {} sources les plus fiables sur {} — restreins la sélection pour en atteindre une précise.", "rail_popular_on": "Populaire sur {source}", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 12d0cb70..7760c071 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -874,8 +874,20 @@ "all_sources_failed_one": "जिस सोर्स में आपने खोजा, वह जवाब नहीं दे पाया। यह ख़ाली नतीजा नहीं है — फिर से कोशिश करें।", "voice": "आवाज़ से खोजें", "voice_listening": "सुन रहे हैं…", - "voice_done": "सुनना बंद हुआ", "voice_hint": "किसी फ़िल्म या सीरीज़ का नाम बोलें", + "voice_starting": "माइक्रोफ़ोन तैयार हो रहा है…", + "voice_no_speech": "समझ नहीं आया", + "voice_no_speech_hint": "थोड़ा ज़ोर से और माइक्रोफ़ोन के पास बोलें, फिर दोबारा कोशिश करें।", + "voice_permission": "माइक्रोफ़ोन की अनुमति बंद है", + "voice_permission_hint": "आवाज़ से खोजने के लिए सिस्टम सेटिंग में Sozo को माइक्रोफ़ोन इस्तेमाल करने की अनुमति दें।", + "voice_unavailable": "इस डिवाइस पर आवाज़ पहचान उपलब्ध नहीं है", + "voice_unavailable_hint": "इस डिवाइस पर कोई आवाज़ पहचान सेवा नहीं है। सिस्टम सेटिंग में कोई सेवा इंस्टॉल या चालू करें, जैसे Google की।", + "voice_network": "आवाज़ सेवा तक नहीं पहुँच सके", + "voice_network_hint": "अपना इंटरनेट कनेक्शन जाँचें और दोबारा कोशिश करें।", + "voice_error": "सुनते समय कुछ गड़बड़ हो गई", + "voice_error_hint": "हो सकता है कोई दूसरा ऐप माइक्रोफ़ोन इस्तेमाल कर रहा हो। थोड़ी देर बाद दोबारा कोशिश करें।", + "voice_try_again": "दोबारा कोशिश करें", + "voice_open_settings": "सेटिंग खोलें", "try_torrents": "टोरेंट खोजें", "capped_note": "{} सबसे भरोसेमंद सोर्स में खोजा गया (कुल {} में से) — किसी खास सोर्स तक पहुँचने के लिए दायरा छोटा करें।", "rail_popular_on": "{source} पर लोकप्रिय", diff --git a/assets/translations/id.json b/assets/translations/id.json index 28442646..1df904de 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -874,8 +874,20 @@ "all_sources_failed_one": "Sumber yang kamu cari tidak menjawab. Ini bukan hasil kosong — coba lagi.", "voice": "Cari dengan suara", "voice_listening": "Mendengarkan…", - "voice_done": "Berhenti mendengarkan", "voice_hint": "Sebutkan nama film atau serial", + "voice_starting": "Menyiapkan mikrofon…", + "voice_no_speech": "Tidak terdengar jelas", + "voice_no_speech_hint": "Bicaralah sedikit lebih keras dan dekat ke mikrofon, lalu coba lagi.", + "voice_permission": "Izin mikrofon nonaktif", + "voice_permission_hint": "Izinkan Sozo memakai mikrofon di pengaturan sistem untuk mencari dengan suara.", + "voice_unavailable": "Pengenalan suara tidak tersedia di perangkat ini", + "voice_unavailable_hint": "Perangkat ini tidak punya layanan pengenalan suara. Pasang atau aktifkan satu di pengaturan sistem, misalnya milik Google.", + "voice_network": "Tidak dapat menghubungi layanan suara", + "voice_network_hint": "Periksa koneksi internet lalu coba lagi.", + "voice_error": "Terjadi kesalahan saat mendengarkan", + "voice_error_hint": "Mungkin aplikasi lain sedang memakai mikrofon. Coba lagi sebentar lagi.", + "voice_try_again": "Coba lagi", + "voice_open_settings": "Buka pengaturan", "try_torrents": "Cari torrent", "capped_note": "Mencari di {} sumber paling andal dari {} — persempit cakupan untuk menjangkau sumber tertentu.", "rail_popular_on": "Populer di {source}", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index e876ed5b..5af2264b 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -874,8 +874,20 @@ "all_sources_failed_one": "Çavkaniya ku te lê geriya bersiv neda. Ev ne encameke vala ye — dîsa biceribîne.", "voice": "Bi deng bigere", "voice_listening": "Guhdarî dike…", - "voice_done": "Guhdarî rawestiya", "voice_hint": "Navê fîlmek an rêzefîlmekê bibêje", + "voice_starting": "Mîkrofon tê amadekirin…", + "voice_no_speech": "Me fêm nekir", + "voice_no_speech_hint": "Hinekî bilindtir û nêzîkî mîkrofonê biaxive, paşê dîsa biceribîne.", + "voice_permission": "Destûra mîkrofonê girtî ye", + "voice_permission_hint": "Ji bo lêgerîna bi deng, di mîhengên pergalê de destûrê bide Sozo ku mîkrofonê bi kar bîne.", + "voice_unavailable": "Naskirina axaftinê li ser vê cîhazê tune ye", + "voice_unavailable_hint": "Li ser vê cîhazê xizmeteke naskirina axaftinê tune ye. Di mîhengên pergalê de yekê saz bike an veke, wek ya Google.", + "voice_network": "Negihîşt xizmeta axaftinê", + "voice_network_hint": "Girêdana xwe ya înternetê kontrol bike û dîsa biceribîne.", + "voice_error": "Di dema guhdarîkirinê de tiştek xelet çû", + "voice_error_hint": "Dibe ku sepaneke din mîkrofonê bi kar tîne. Piştî demekê dîsa biceribîne.", + "voice_try_again": "Dîsa biceribîne", + "voice_open_settings": "Mîhengan veke", "try_torrents": "Li torrentan bigere", "capped_note": "Li {} çavkaniyên herî pêbawer ji {} hat gerîn — qadê teng bike da ku bigihîjî yeka taybet.", "rail_popular_on": "Populer li ser {source}", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 8ca39779..b7fdf2e8 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -874,8 +874,20 @@ "all_sources_failed_one": "De doorzochte bron gaf geen antwoord. Dit is geen leeg resultaat — probeer het opnieuw.", "voice": "Zoeken met je stem", "voice_listening": "Luisteren…", - "voice_done": "Gestopt met luisteren", "voice_hint": "Zeg de naam van een film of serie", + "voice_starting": "Microfoon wordt klaargemaakt…", + "voice_no_speech": "Dat verstond ik niet", + "voice_no_speech_hint": "Spreek iets luider en dichter bij de microfoon en probeer het opnieuw.", + "voice_permission": "Microfoontoestemming staat uit", + "voice_permission_hint": "Geef Sozo in de systeeminstellingen toegang tot de microfoon om met je stem te zoeken.", + "voice_unavailable": "Spraakherkenning is niet beschikbaar op dit apparaat", + "voice_unavailable_hint": "Dit apparaat heeft geen spraakherkenningsdienst. Installeer of activeer er een in de systeeminstellingen, zoals die van Google.", + "voice_network": "Spraakdienst niet bereikbaar", + "voice_network_hint": "Controleer je internetverbinding en probeer het opnieuw.", + "voice_error": "Er ging iets mis tijdens het luisteren", + "voice_error_hint": "Misschien gebruikt een andere app de microfoon. Probeer het zo opnieuw.", + "voice_try_again": "Opnieuw proberen", + "voice_open_settings": "Instellingen openen", "try_torrents": "Torrents zoeken", "capped_note": "De {} betrouwbaarste van {} bronnen doorzocht — beperk de selectie om een specifieke te bereiken.", "rail_popular_on": "Populair op {source}", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index f79b5482..ae228233 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -874,8 +874,20 @@ "all_sources_failed_one": "A fonte que você buscou não respondeu. Isto não é um resultado vazio — tente de novo.", "voice": "Buscar por voz", "voice_listening": "Ouvindo…", - "voice_done": "Parei de ouvir", "voice_hint": "Diga o nome de um filme ou série", + "voice_starting": "Preparando o microfone…", + "voice_no_speech": "Não entendi", + "voice_no_speech_hint": "Fale um pouco mais alto e perto do microfone, e tente de novo.", + "voice_permission": "A permissão do microfone está desativada", + "voice_permission_hint": "Permita que o Sozo use o microfone nas configurações do sistema para buscar por voz.", + "voice_unavailable": "O reconhecimento de voz não está disponível neste dispositivo", + "voice_unavailable_hint": "Este dispositivo não tem um serviço de reconhecimento de voz. Instale ou ative um nas configurações do sistema, como o do Google.", + "voice_network": "Não foi possível acessar o serviço de voz", + "voice_network_hint": "Verifique sua conexão com a internet e tente de novo.", + "voice_error": "Algo deu errado ao ouvir", + "voice_error_hint": "Outro app pode estar usando o microfone. Tente de novo daqui a pouco.", + "voice_try_again": "Tentar de novo", + "voice_open_settings": "Abrir configurações", "try_torrents": "Buscar torrents", "capped_note": "Buscamos nas {} fontes mais confiáveis de {} — restrinja a seleção para chegar a uma específica.", "rail_popular_on": "Popular em {source}", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index c85aef09..03e8603e 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -874,8 +874,20 @@ "all_sources_failed_one": "Источник не ответил. Это не пустой результат — попробуйте снова.", "voice": "Голосовой поиск", "voice_listening": "Слушаю…", - "voice_done": "Прослушивание остановлено", "voice_hint": "Назовите фильм или сериал", + "voice_starting": "Готовим микрофон…", + "voice_no_speech": "Не удалось расслышать", + "voice_no_speech_hint": "Говорите чуть громче и ближе к микрофону, затем попробуйте ещё раз.", + "voice_permission": "Нет доступа к микрофону", + "voice_permission_hint": "Разрешите Sozo доступ к микрофону в настройках системы, чтобы искать голосом.", + "voice_unavailable": "Распознавание речи недоступно на этом устройстве", + "voice_unavailable_hint": "На устройстве нет службы распознавания речи. Установите или включите её в настройках системы, например от Google.", + "voice_network": "Не удалось связаться со службой распознавания", + "voice_network_hint": "Проверьте подключение к интернету и попробуйте ещё раз.", + "voice_error": "Что-то пошло не так во время записи", + "voice_error_hint": "Возможно, микрофон занят другим приложением. Попробуйте ещё раз чуть позже.", + "voice_try_again": "Попробовать ещё раз", + "voice_open_settings": "Открыть настройки", "try_torrents": "Искать в торрентах", "capped_note": "Искали в {} самых надёжных из {} источников — сузьте область, чтобы дойти до конкретного.", "rail_popular_on": "Популярное на {source}", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index f22f299e..dd256cd7 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -874,8 +874,20 @@ "all_sources_failed_one": "Aradığın kaynak yanıt vermedi. Bu boş bir sonuç değil — tekrar dene.", "voice": "Sesle ara", "voice_listening": "Dinleniyor…", - "voice_done": "Dinleme durduruldu", "voice_hint": "Bir film ya da dizi adı söyle", + "voice_starting": "Mikrofon hazırlanıyor…", + "voice_no_speech": "Anlaşılamadı", + "voice_no_speech_hint": "Biraz daha yüksek sesle ve mikrofona yakın konuş, sonra tekrar dene.", + "voice_permission": "Mikrofon izni kapalı", + "voice_permission_hint": "Sesle aramak için sistem ayarlarından Sozo'nun mikrofonu kullanmasına izin ver.", + "voice_unavailable": "Bu cihazda konuşma tanıma kullanılamıyor", + "voice_unavailable_hint": "Bu cihazda konuşma tanıma hizmeti yok. Sistem ayarlarından bir tane yükle ya da aç, örneğin Google'ınkini.", + "voice_network": "Konuşma hizmetine ulaşılamadı", + "voice_network_hint": "İnternet bağlantını kontrol edip tekrar dene.", + "voice_error": "Dinlerken bir sorun oluştu", + "voice_error_hint": "Mikrofonu başka bir uygulama kullanıyor olabilir. Biraz sonra tekrar dene.", + "voice_try_again": "Tekrar dene", + "voice_open_settings": "Ayarları aç", "try_torrents": "Torrent ara", "capped_note": "{} kaynaktan en güvenilir {} tanesinde arandı — belirli birine ulaşmak için kapsamı daralt.", "rail_popular_on": "{source} içinde popüler", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index a0ec0494..1b391cbc 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -874,8 +874,20 @@ "all_sources_failed_one": "Manba javob bermadi. Bu bo‘sh natija emas — qayta urinib ko‘ring.", "voice": "Ovozli qidiruv", "voice_listening": "Tinglanmoqda…", - "voice_done": "Tinglash to‘xtadi", "voice_hint": "Film yoki serial nomini ayting", + "voice_starting": "Mikrofon tayyorlanmoqda…", + "voice_no_speech": "Eshitib bo‘lmadi", + "voice_no_speech_hint": "Mikrofonga yaqinroq, biroz balandroq gapiring va qayta urinib ko‘ring.", + "voice_permission": "Mikrofonga ruxsat berilmagan", + "voice_permission_hint": "Ovoz bilan qidirish uchun tizim sozlamalarida Sozo’ga mikrofondan foydalanishga ruxsat bering.", + "voice_unavailable": "Bu qurilmada nutqni aniqlash mavjud emas", + "voice_unavailable_hint": "Bu qurilmada nutqni aniqlash xizmati yo‘q. Uni tizim sozlamalarida o‘rnating yoki yoqing, masalan, Google xizmatini.", + "voice_network": "Nutq xizmatiga ulanib bo‘lmadi", + "voice_network_hint": "Internet aloqasini tekshirib, qayta urinib ko‘ring.", + "voice_error": "Tinglashda xatolik yuz berdi", + "voice_error_hint": "Mikrofonni boshqa ilova band qilgan bo‘lishi mumkin. Birozdan so‘ng qayta urinib ko‘ring.", + "voice_try_again": "Qayta urinish", + "voice_open_settings": "Sozlamalarni ochish", "try_torrents": "Torrentlarda qidirish", "capped_note": "{} ta manbadan eng ishonchli {} tasi qidirildi — aniq bittasiga yetish uchun qamrovni toraytiring.", "rail_popular_on": "{source} da mashhur", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 5a8f5b47..77f7026e 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -874,8 +874,20 @@ "all_sources_failed_one": "Nguồn bạn tìm không phản hồi. Đây không phải kết quả trống — hãy thử lại.", "voice": "Tìm bằng giọng nói", "voice_listening": "Đang nghe…", - "voice_done": "Đã dừng nghe", "voice_hint": "Nói tên một phim lẻ hoặc phim bộ", + "voice_starting": "Đang chuẩn bị micrô…", + "voice_no_speech": "Không nghe rõ", + "voice_no_speech_hint": "Hãy nói to hơn một chút, gần micrô hơn, rồi thử lại.", + "voice_permission": "Quyền micrô đang tắt", + "voice_permission_hint": "Cho phép Sozo dùng micrô trong cài đặt hệ thống để tìm kiếm bằng giọng nói.", + "voice_unavailable": "Thiết bị này không hỗ trợ nhận dạng giọng nói", + "voice_unavailable_hint": "Thiết bị này không có dịch vụ nhận dạng giọng nói. Hãy cài đặt hoặc bật một dịch vụ trong cài đặt hệ thống, chẳng hạn của Google.", + "voice_network": "Không kết nối được dịch vụ giọng nói", + "voice_network_hint": "Kiểm tra kết nối internet rồi thử lại.", + "voice_error": "Đã xảy ra lỗi khi nghe", + "voice_error_hint": "Có thể một ứng dụng khác đang dùng micrô. Hãy thử lại sau giây lát.", + "voice_try_again": "Thử lại", + "voice_open_settings": "Mở cài đặt", "try_torrents": "Tìm torrent", "capped_note": "Đã tìm {} nguồn đáng tin cậy nhất trong {} nguồn — hãy thu hẹp phạm vi để tìm một nguồn cụ thể.", "rail_popular_on": "Phổ biến trên {source}", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 91e3e54a..b07f7d3f 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -874,8 +874,20 @@ "all_sources_failed_one": "你搜尋嘅來源冇回應。呢個唔係冇結果 — 請再試一次。", "voice": "語音搜尋", "voice_listening": "聽緊…", - "voice_done": "已停止聆聽", "voice_hint": "講出電影或者劇集嘅名", + "voice_starting": "準備緊咪高峰…", + "voice_no_speech": "聽唔清楚", + "voice_no_speech_hint": "講大聲少少、近啲咪高峰,再試一次。", + "voice_permission": "咪高峰權限已經熄咗", + "voice_permission_hint": "去系統設定俾 Sozo 用咪高峰,就可以用把聲搜尋。", + "voice_unavailable": "呢部機冇語音辨識", + "voice_unavailable_hint": "呢部機冇語音辨識服務。去系統設定安裝或者開一個,例如 Google 嘅。", + "voice_network": "連唔到語音服務", + "voice_network_hint": "檢查吓網絡連線,再試一次。", + "voice_error": "聽緊嘅時候出咗問題", + "voice_error_hint": "可能有其他 app 用緊咪高峰。等陣再試。", + "voice_try_again": "再試一次", + "voice_open_settings": "打開設定", "try_torrents": "搜尋 torrent", "capped_note": "已經搵咗 {} 個最穩定嘅來源(共 {} 個)— 想搵特定嗰個就縮窄範圍。", "rail_popular_on": "{source} 熱門", diff --git a/lib/core/system/app_settings.dart b/lib/core/system/app_settings.dart new file mode 100644 index 00000000..e09321ad --- /dev/null +++ b/lib/core/system/app_settings.dart @@ -0,0 +1,23 @@ +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Opens this app's page in the system settings, where permissions live. +/// Returns false where there is no such page or it could not be opened. +Future openAppSettings() async { + if (kIsWeb) return false; + try { + if (Platform.isAndroid) { + return await const MethodChannel( + 'soplay/platform', + ).invokeMethod('openAppSettings') ?? + false; + } + if (Platform.isIOS) { + return await launchUrl(Uri.parse('app-settings:')); + } + } catch (_) {} + return false; +} diff --git a/lib/core/tv/tv_keys.dart b/lib/core/tv/tv_keys.dart index 8afb4efe..1ae64dca 100644 --- a/lib/core/tv/tv_keys.dart +++ b/lib/core/tv/tv_keys.dart @@ -28,6 +28,24 @@ class TvKeys { LogicalKeyboardKey.arrowRight, }; + /// The remote's search and microphone buttons. KEYCODE_SEARCH arrives as + /// [LogicalKeyboardKey.browserSearch] and KEYCODE_ASSIST as + /// [LogicalKeyboardKey.launchAssistant]; KEYCODE_VOICE_ASSIST (231) has no + /// Flutter name and arrives in the Android plane. Many TVs keep the + /// assistant keys for the system, so the app sees them only on some remotes. + static final Set voiceSearch = { + LogicalKeyboardKey.browserSearch, + LogicalKeyboardKey.launchAssistant, + LogicalKeyboardKey.speechInputToggle, + const LogicalKeyboardKey( + LogicalKeyboardKey.androidPlane | androidKeycodeVoiceAssist, + ), + }; + + static const int androidKeycodeVoiceAssist = 231; + + static bool isVoiceSearch(LogicalKeyboardKey k) => voiceSearch.contains(k); + static bool isActivate(LogicalKeyboardKey k) => activate.contains(k); static bool isDirectional(LogicalKeyboardKey k) => directional.contains(k); diff --git a/lib/features/search/presentation/pages/search_page.dart b/lib/features/search/presentation/pages/search_page.dart index 18f92ec6..057f63c4 100644 --- a/lib/features/search/presentation/pages/search_page.dart +++ b/lib/features/search/presentation/pages/search_page.dart @@ -9,6 +9,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/search/presentation/blocs/search_bloc.dart'; import 'package:soplay/features/search/presentation/widgets/voice_search_button.dart'; import 'package:soplay/features/search/presentation/widgets/search_filter_sheet.dart'; @@ -34,6 +35,7 @@ class _SearchViewState extends State<_SearchView> { final _focus = FocusNode(); final _scrollController = ScrollController(); final _blurProgress = ValueNotifier(0); + final _voiceKey = GlobalKey(); @override void initState() { @@ -197,7 +199,7 @@ class _SearchViewState extends State<_SearchView> { final topPad = MediaQuery.paddingOf(context).top; final bottomPad = MediaQuery.paddingOf(context).bottom; - return Scaffold( + final page = Scaffold( backgroundColor: AppColors.background, body: BlocConsumer( listener: (context, state) => _maybeAutoFill(state), @@ -233,6 +235,7 @@ class _SearchViewState extends State<_SearchView> { context.read().add(SearchSubmitted(q)), onClear: _clearSearch, voiceButton: VoiceSearchButton( + key: _voiceKey, // Partial results land in the field as they are heard; only // the final transcript runs a search, so a half-heard title // never fires a query of its own. @@ -332,5 +335,20 @@ class _SearchViewState extends State<_SearchView> { ), ), ); + if (!isTvPlatform) return page; + // The remote's search or voice key starts voice search from anywhere on + // this page, the text field included. + return Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: (_, event) { + if (event is! KeyDownEvent || !TvKeys.isVoiceSearch(event.logicalKey)) { + return KeyEventResult.ignored; + } + _voiceKey.currentState?.start(); + return KeyEventResult.handled; + }, + child: page, + ); } } diff --git a/lib/features/search/presentation/widgets/search_header.dart b/lib/features/search/presentation/widgets/search_header.dart index 942719f6..3a2fdba1 100644 --- a/lib/features/search/presentation/widgets/search_header.dart +++ b/lib/features/search/presentation/widgets/search_header.dart @@ -176,11 +176,11 @@ class _SearchField extends StatelessWidget { suffixIcon: ValueListenableBuilder( valueListenable: controller, builder: (context, value, _) { - // Microphone while there is nothing to clear, clear button once - // there is. They never both apply, so the two share one slot rather - // than crowding the field with a second icon. + // The microphone stays reachable with text in the field, so a + // misheard query can be spoken again without clearing it first. + final voice = voiceButton; if (value.text.isEmpty) { - return voiceButton ?? const SizedBox.shrink(); + return voice ?? const SizedBox.shrink(); } const icon = Icon( Icons.close_rounded, @@ -189,17 +189,21 @@ class _SearchField extends StatelessWidget { ); // Android TV: the clear (X) is the only way to drop a query // without a hardware keyboard, so it needs to be a focus stop. - if (isTvPlatform) { - return TvFocusable( - onPressed: onClear, - borderRadius: 9, - child: icon, - ); - } - return IconButton( - tooltip: 'general.clear'.tr(), - onPressed: onClear, - icon: icon, + final Widget clear = isTvPlatform + ? TvFocusable( + onPressed: onClear, + borderRadius: 9, + child: icon, + ) + : IconButton( + tooltip: 'general.clear'.tr(), + onPressed: onClear, + icon: icon, + ); + if (voice == null) return clear; + return Row( + mainAxisSize: MainAxisSize.min, + children: [clear, voice], ); }, ), diff --git a/lib/features/search/presentation/widgets/voice_search_button.dart b/lib/features/search/presentation/widgets/voice_search_button.dart index 2da9e5f3..4efe0a18 100644 --- a/lib/features/search/presentation/widgets/voice_search_button.dart +++ b/lib/features/search/presentation/widgets/voice_search_button.dart @@ -1,24 +1,23 @@ import 'dart:io' show Platform; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:speech_to_text/speech_to_text.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/search/presentation/widgets/voice_search_dialog.dart'; /// Microphone button for the search field. /// -/// Renders nothing on the platforms with no recogniser to talk to, rather than -/// showing a button that reports a failure when pressed. On Android the -/// recogniser is also invisible without the `` entry in the manifest, -/// and the failure mode there is silent — the plugin simply says no recogniser -/// is available — so the check below covers both. +/// Shown on every platform with a system recogniser. Nothing is initialised +/// until it is pressed: the dialog does that, so the permission prompt comes +/// when the user asks to speak, and a refusal or a missing recogniser is +/// explained there instead of the button quietly disappearing. /// -/// Partial results are reported as they arrive, so the field fills in while the -/// user is still speaking. That is what makes it feel like dictation rather -/// than a recording being uploaded, and it lets someone see a misheard word and -/// stop rather than waiting for a wrong search to run. +/// Partial results are reported as they arrive, so the field fills in while +/// the user is still speaking. class VoiceSearchButton extends StatefulWidget { const VoiceSearchButton({ super.key, @@ -35,101 +34,65 @@ class VoiceSearchButton extends StatefulWidget { final double size; + static bool get platformSupported => + !kIsWeb && (Platform.isAndroid || Platform.isIOS); + @override - State createState() => _VoiceSearchButtonState(); + State createState() => VoiceSearchButtonState(); } -class _VoiceSearchButtonState extends State - with SingleTickerProviderStateMixin { +class VoiceSearchButtonState extends State { final SpeechToText _speech = SpeechToText(); + bool _open = false; - bool _available = false; - bool _listening = false; - late final AnimationController _pulse; - - /// Only these have a system recogniser worth offering. - static bool get _platformSupported => Platform.isAndroid || Platform.isIOS; - - @override - void initState() { - super.initState(); - _pulse = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 900), - ); - if (_platformSupported) _init(); - } - - Future _init() async { + /// Opens the listening dialog. Also called by the TV remote's search key. + Future start() async { + if (_open || !VoiceSearchButton.platformSupported) return; + _open = true; try { - final ok = await _speech.initialize( - onStatus: (s) { - if (!mounted) return; - final listening = s == 'listening'; - if (listening != _listening) { - setState(() => _listening = listening); - listening ? _pulse.repeat(reverse: true) : _pulse.stop(); - } - }, - onError: (_) { - if (!mounted) return; - setState(() => _listening = false); - _pulse.stop(); - }, + final result = await VoiceSearchDialog.show( + context, + speech: _speech, + onPartial: widget.onText, ); - if (mounted) setState(() => _available = ok); - } catch (_) { - // A device with no recogniser, or one that refuses to initialise. The - // button simply does not appear. - if (mounted) setState(() => _available = false); + final words = result?.trim() ?? ''; + if (words.isEmpty || !mounted) return; + widget.onText(words); + widget.onSubmit?.call(words); + } finally { + _open = false; } } @override void dispose() { - _pulse.dispose(); - // Not awaited: dispose cannot, and an orphaned recogniser session ends on - // its own timeout anyway. - _speech.cancel(); + // Not awaited: dispose cannot, and an orphaned session ends on its own. + if (_speech.isListening) _speech.cancel(); super.dispose(); } - /// Open the listening dialog and run whatever it comes back with. - /// - /// The session itself lives in the dialog, which owns the waveform and needs - /// the sound level anyway. This widget stays what it looks like: a button - /// that knows whether the device can listen at all. - Future _open() async { - if (_listening) return; - final result = await VoiceSearchDialog.show( - context, - speech: _speech, - onPartial: widget.onText, - ); - final words = result?.trim() ?? ''; - if (words.isEmpty) return; - widget.onText(words); - widget.onSubmit?.call(words); - } - @override Widget build(BuildContext context) { - if (!_platformSupported || !_available) return const SizedBox.shrink(); + if (!VoiceSearchButton.platformSupported) return const SizedBox.shrink(); + final icon = Icon( + Icons.mic_none_rounded, + size: widget.size, + color: AppColors.textSecondary, + ); + if (isTvPlatform) { + return Tooltip( + message: 'search.voice'.tr(), + child: TvFocusable( + onPressed: start, + borderRadius: 9, + child: icon, + ), + ); + } return IconButton( tooltip: 'search.voice'.tr(), - onPressed: _open, - icon: AnimatedBuilder( - animation: _pulse, - builder: (_, child) => Opacity( - opacity: _listening ? 0.45 + (_pulse.value * 0.55) : 1, - child: child, - ), - child: Icon( - _listening ? Icons.mic_rounded : Icons.mic_none_rounded, - size: widget.size, - color: _listening ? AppColors.primary : AppColors.textSecondary, - ), - ), + onPressed: start, + icon: icon, ); } } diff --git a/lib/features/search/presentation/widgets/voice_search_dialog.dart b/lib/features/search/presentation/widgets/voice_search_dialog.dart index ddd4eb49..330494c1 100644 --- a/lib/features/search/presentation/widgets/voice_search_dialog.dart +++ b/lib/features/search/presentation/widgets/voice_search_dialog.dart @@ -2,44 +2,55 @@ import 'dart:math' as math; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:speech_to_text/speech_recognition_error.dart'; import 'package:speech_to_text/speech_to_text.dart'; +import 'package:soplay/core/system/app_settings.dart'; +import 'package:soplay/core/system/platform_utils.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/search/presentation/widgets/voice_search_session.dart'; -/// The listening dialog: a live waveform, the words as they are heard, and one -/// way out. +/// The listening dialog: a live waveform, the words as they are heard, and a +/// plain account of what went wrong when nothing usable came back. /// /// The waveform is drawn from the recogniser's own sound level, not from a -/// timer. That distinction is the whole point of having one: a decorative -/// animation keeps dancing when the microphone has stopped hearing anything, -/// which is exactly the moment the user needs to be told. Bars that go flat -/// when someone stops talking — or when the phone is not picking them up — -/// answer "is it hearing me?" without a word of copy. +/// timer, so it goes flat when the microphone stops hearing anything. The +/// level scale differs between Android and iOS, so a running peak normalises +/// whatever arrives. /// -/// The level scale differs between Android and iOS (iOS reports decibels, -/// Android something undocumented), so nothing here assumes a range. A running -/// peak normalises whatever arrives, which also adapts to a quiet room and a -/// loud one without a calibration step. +/// The recogniser is initialised here, on the first tap, so the permission +/// prompt appears when the user asks to speak rather than when search opens. /// /// Returns the final transcript, or null if the user backed out. class VoiceSearchDialog extends StatefulWidget { - const VoiceSearchDialog._({required this.speech, required this.onPartial}); + const VoiceSearchDialog._({ + required this.speech, + required this.onPartial, + required this.appLocale, + }); final SpeechToText speech; /// Fires for every partial result so the field behind the dialog fills in - /// alongside it — closing then feels like the search was already there. + /// alongside it. final ValueChanged onPartial; + final Locale appLocale; + static Future show( BuildContext context, { required SpeechToText speech, required ValueChanged onPartial, }) { + final appLocale = context.locale; return showDialog( context: context, barrierDismissible: true, - builder: (_) => VoiceSearchDialog._(speech: speech, onPartial: onPartial), + builder: (_) => VoiceSearchDialog._( + speech: speech, + onPartial: onPartial, + appLocale: appLocale, + ), ); } @@ -49,24 +60,30 @@ class VoiceSearchDialog extends StatefulWidget { class _VoiceSearchDialogState extends State with SingleTickerProviderStateMixin { - /// How many bars the strip holds. Enough to read as a waveform, few enough - /// that each one is still wide enough to see on a phone. static const int _barCount = 34; - final List _levels = List.filled(_barCount, 0); + /// A cold recogniser on a slow device genuinely takes a second or two. + static const Duration _startDeadline = Duration(seconds: 6); - late final AnimationController _ticker; + /// Resolved once per app language; asking the recogniser is not free. + static final Map _localeCache = {}; - /// Where the newest sample is heading; bars ease toward it so a single loud - /// syllable does not make the strip flicker. + final List _levels = List.filled(_barCount, 0); + late final AnimationController _ticker; double _target = 0; - - /// Running peak used to normalise, since the platforms disagree on scale. - /// Never allowed to reach zero, which would divide the strip by nothing. double _peak = 1; - String _words = ''; - bool _done = false; + VoiceSearchState _state = VoiceSearchState.initial; + + /// Bumped on every attempt so callbacks from an abandoned one are ignored. + int _attempt = 0; + + /// Until the recogniser has started, `isListening` is false for an ordinary + /// reason and must not be read as "it stopped". + bool _everListened = false; + + String? _localeId; + bool _localeDropped = false; @override void initState() { @@ -75,98 +92,151 @@ class _VoiceSearchDialogState extends State vsync: this, duration: const Duration(milliseconds: 16), )..addListener(_advance); - _ticker.repeat(); - _start(); + _begin(); + } + + void _set(VoiceSearchState next) { + if (!mounted || next == _state) return; + final wasActive = _state.isActive; + setState(() => _state = next); + if (next.phase == VoicePhase.done) { + Navigator.of(context).pop(next.words); + return; + } + if (wasActive && !next.isActive) { + _ticker.stop(); + _levels.fillRange(0, _barCount, 0); + widget.speech.cancel(); + } } - Future _start() async { + Future _begin() async { + final attempt = ++_attempt; + _everListened = false; + _set(VoiceSearchMachine.retry()); + if (!_ticker.isAnimating) _ticker.repeat(); + + final speech = widget.speech; + var ok = false; try { - await _listen(); + ok = await speech.initialize(); } catch (e) { - // A recogniser that refuses to start — no permission, no service, another - // app holding the microphone. Closing beats a dialog with a flat - // waveform that never explains itself. - debugPrint('[voice] could not start listening: $e'); - if (mounted && !_done) _finish(''); + debugPrint('[voice] initialize failed: $e'); } + if (!mounted || attempt != _attempt) return; + if (!ok) { + _set(VoiceSearchMachine.initFailed(hasPermission: await _hasPermission())); + return; + } + // initialize() only takes listeners on its first successful call, and the + // plugin is a singleton, so this dialog claims them directly. + speech.errorListener = (e) => _onError(e, attempt); + speech.statusListener = (s) { + if (attempt == _attempt && s == SpeechToText.listeningStatus) { + _everListened = true; + _set(VoiceSearchMachine.listeningStarted(_state)); + } + }; + + if (!_localeDropped) _localeId = await _resolveLocale(); + if (!mounted || attempt != _attempt) return; + await _listen(attempt); } - Future _listen() async { - await widget.speech.listen( - onResult: (r) { - final words = r.recognizedWords.trim(); - if (mounted && words.isNotEmpty) { - setState(() => _words = words); - widget.onPartial(words); - } - if (r.finalResult) _finish(words); - }, - onSoundLevelChange: (level) { - // Absolute value: iOS reports negative decibels, Android does not. - final v = level.abs(); - if (v > _peak) _peak = v; - // Decay the peak slowly so one loud noise does not flatten the strip - // for the rest of the session. - _peak = math.max(1, _peak * 0.995); - _target = (v / _peak).clamp(0.0, 1.0); - }, - listenOptions: SpeechListenOptions( - partialResults: true, - cancelOnError: true, - listenFor: const Duration(seconds: 20), - pauseFor: const Duration(seconds: 3), - ), - ); - // NOT the end of the session. - // - // `listen()` completes as soon as the platform has STARTED listening — it - // stamps the start time, arms its pause timers and returns. Treating that - // as the end closed this dialog on the frame it opened, with an empty - // transcript, while the recogniser carried on running: the microphone - // indicator lit up and no dialog was ever visible. - // - // The session ends when a final result arrives (handled in onResult), when - // the user stops it, or when the recogniser gives up on its own — and the - // last of those is only observable through `isListening`, which [_advance] - // already watches on every frame. - // - // A start that never happens is the other failure. `listen()` can return - // without the platform ever entering the listening state, and the watchdog - // in [_advance] only fires once it has seen listening begin — so without - // this the dialog would wait forever on a microphone that was never opened. + Future _hasPermission() async { + try { + return await widget.speech.hasPermission; + } catch (_) { + return false; + } + } + + Future _resolveLocale() async { + final key = widget.appLocale.toLanguageTag(); + if (_localeCache.containsKey(key)) return _localeCache[key]; + String? id; + try { + final locales = await widget.speech.locales(); + final system = await widget.speech.systemLocale(); + id = matchSpeechLocale( + widget.appLocale, + locales.map((l) => l.localeId), + systemLocaleId: system?.localeId, + ); + } catch (e) { + debugPrint('[voice] could not read recogniser locales: $e'); + } + _localeCache[key] = id; + return id; + } + + Future _listen(int attempt) async { + try { + if (widget.speech.isListening) await widget.speech.cancel(); + await widget.speech.listen( + onResult: (r) { + if (attempt != _attempt) return; + final words = r.recognizedWords.trim(); + if (words.isNotEmpty) widget.onPartial(words); + _set(VoiceSearchMachine.heard(_state, words, isFinal: r.finalResult)); + }, + onSoundLevelChange: (level) { + final v = level.abs(); + if (v > _peak) _peak = v; + _peak = math.max(1, _peak * 0.995); + _target = (v / _peak).clamp(0.0, 1.0); + }, + listenOptions: SpeechListenOptions( + partialResults: true, + cancelOnError: true, + listenFor: const Duration(seconds: 20), + pauseFor: const Duration(seconds: 3), + localeId: _localeId, + ), + ); + } catch (e) { + debugPrint('[voice] could not start listening: $e'); + if (!mounted || attempt != _attempt) return; + _set( + VoiceSearchMachine.listenFailed(hasPermission: await _hasPermission()), + ); + return; + } + // listen() returns once the platform has been asked to start, not when the + // session ends. A start that never happens is caught here; an end is + // caught by the watchdog in [_advance]. Future.delayed(_startDeadline, () { - if (mounted && !_done && !_everListened) { - debugPrint('[voice] the recogniser never started listening'); - _finish(''); + if (mounted && attempt == _attempt && !_everListened) { + _set(VoiceSearchMachine.startTimedOut(_state)); } }); } - /// How long to wait for the microphone to actually open before giving up. - /// - /// Generous, because a cold recogniser on a slow device genuinely takes a - /// second or two, and closing on somebody who was about to be heard is worse - /// than a moment of blank waveform. - static const Duration _startDeadline = Duration(seconds: 6); - - /// Whether the recogniser has actually started. Until it has, `isListening` - /// is false for an ordinary reason and must not be read as "it stopped". - bool _everListened = false; + void _onError(SpeechRecognitionError e, int attempt) { + if (!mounted || attempt != _attempt) return; + debugPrint('[voice] ${e.errorMsg} permanent=${e.permanent}'); + // The app language is not one the recogniser can do after all: try once + // more in the system language rather than failing outright. + if (VoiceSearchMachine.isLanguageError(e.errorMsg) && + _localeId != null && + !_localeDropped) { + _localeDropped = true; + _localeCache[widget.appLocale.toLanguageTag()] = null; + _begin(); + return; + } + _set(VoiceSearchMachine.error(_state, e.errorMsg, permanent: e.permanent)); + } - /// Shift the strip one step and ease the newest bar toward the live level. - /// - /// Also the session watchdog. The recogniser can end on its own — the - /// `listenFor` ceiling, a `pauseFor` silence, a permanent error — and none of - /// those calls back into this dialog. Without noticing, the dialog would sit - /// over a dead microphone until somebody dismissed it. void _advance() { - if (!mounted) return; + if (!mounted || !_state.isActive) return; if (widget.speech.isListening) { _everListened = true; - } else if (_everListened && !_done) { - // Ended by itself. Whatever was heard is the answer; nothing is the - // answer too, and _finish turns that into a dismissal. - _finish(_words); + if (_state.phase == VoicePhase.starting) { + _set(VoiceSearchMachine.listeningStarted(_state)); + } + } else if (_everListened) { + _set(VoiceSearchMachine.ended(_state)); return; } for (var i = 0; i < _barCount - 1; i++) { @@ -174,111 +244,175 @@ class _VoiceSearchDialogState extends State } final last = _levels[_barCount - 1]; _levels[_barCount - 1] = last + (_target - last) * 0.35; - // Decay toward silence, so the strip settles flat when nothing is arriving - // rather than holding the last value forever. _target *= 0.92; setState(() {}); } - void _finish(String words) { - if (_done) return; - _done = true; - if (!mounted) return; - Navigator.of(context).pop(words.isEmpty ? null : words); - } - Future _stop() async { // stop() asks for a final result; cancel() would throw away the words // already on screen, which is not what a Done button means. await widget.speech.stop(); - if (mounted && !_done) _finish(_words); + if (!mounted) return; + _set(VoiceSearchMachine.ended(_state)); + } + + void _cancel() { + _attempt++; + widget.speech.cancel(); + Navigator.of(context).pop(); } @override void dispose() { + _attempt++; _ticker.dispose(); + widget.speech.errorListener = null; + widget.speech.statusListener = null; if (widget.speech.isListening) widget.speech.cancel(); super.dispose(); } + (IconData, String, String?) get _copy { + switch (_state.phase) { + case VoicePhase.starting: + return (Icons.mic_none_rounded, 'search.voice_starting'.tr(), null); + case VoicePhase.listening: + case VoicePhase.done: + return (Icons.mic_rounded, 'search.voice_listening'.tr(), null); + case VoicePhase.noSpeech: + return ( + Icons.hearing_disabled_rounded, + 'search.voice_no_speech'.tr(), + 'search.voice_no_speech_hint'.tr(), + ); + case VoicePhase.permissionDenied: + return ( + Icons.mic_off_rounded, + 'search.voice_permission'.tr(), + 'search.voice_permission_hint'.tr(), + ); + case VoicePhase.unavailable: + return ( + Icons.mic_off_rounded, + 'search.voice_unavailable'.tr(), + 'search.voice_unavailable_hint'.tr(), + ); + case VoicePhase.network: + return ( + Icons.wifi_off_rounded, + 'search.voice_network'.tr(), + 'search.voice_network_hint'.tr(), + ); + case VoicePhase.recognizer: + return ( + Icons.error_outline_rounded, + 'search.voice_error'.tr(), + 'search.voice_error_hint'.tr(), + ); + } + } + @override Widget build(BuildContext context) { - final listening = widget.speech.isListening; + final (icon, title, hint) = _copy; + final active = _state.isActive; + final words = _state.words; return Dialog( backgroundColor: AppColors.surface, insetPadding: const EdgeInsets.symmetric(horizontal: 32), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 26, 24, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - listening ? Icons.mic_rounded : Icons.mic_off_rounded, - size: 30, - color: listening ? AppColors.primary : AppColors.textHint, - ), - const SizedBox(height: 14), - Text( - listening ? 'search.voice_listening'.tr() : 'search.voice_done'.tr(), - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 26, 24, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 30, + color: _state.phase == VoicePhase.listening + ? AppColors.primary + : AppColors.textHint, ), - ), - const SizedBox(height: 18), - SizedBox( - height: 56, - width: double.infinity, - child: CustomPaint( - painter: _WavePainter( - levels: _levels, - color: AppColors.primary, + const SizedBox(height: 14), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, ), ), - ), - const SizedBox(height: 18), - // Reserves its own height so the dialog does not jump the moment - // the first word arrives. - ConstrainedBox( - constraints: const BoxConstraints(minHeight: 44), - child: Center( - child: Text( - _words.isEmpty ? 'search.voice_hint'.tr() : _words, - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: _words.isEmpty ? 13 : 16, - fontWeight: - _words.isEmpty ? FontWeight.w400 : FontWeight.w600, - color: _words.isEmpty - ? AppColors.textHint - : AppColors.textPrimary, + const SizedBox(height: 18), + if (active) ...[ + SizedBox( + height: 56, + width: double.infinity, + child: CustomPaint( + painter: _WavePainter( + levels: _levels, + color: AppColors.primary, + ), ), ), - ), - ), - const SizedBox(height: 6), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () { - widget.speech.cancel(); - Navigator.of(context).pop(); - }, - child: Text('general.cancel'.tr()), - ), - const SizedBox(width: 4), - TextButton( - onPressed: _words.isEmpty ? null : _stop, - child: Text('general.done'.tr()), - ), + const SizedBox(height: 18), ], - ), - ], + // Reserves its own height so the dialog does not jump the + // moment the first word arrives. + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 44), + child: Center( + child: Text( + active + ? (words.isEmpty ? 'search.voice_hint'.tr() : words) + : (hint ?? ''), + textAlign: TextAlign.center, + maxLines: active ? 2 : 4, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: active && words.isNotEmpty ? 16 : 13, + fontWeight: active && words.isNotEmpty + ? FontWeight.w600 + : FontWeight.w400, + color: active && words.isNotEmpty + ? AppColors.textPrimary + : AppColors.textHint, + ), + ), + ), + ), + const SizedBox(height: 6), + Wrap( + alignment: WrapAlignment.end, + spacing: 4, + children: [ + TextButton( + onPressed: _cancel, + child: Text('general.cancel'.tr()), + ), + if (active) + TextButton( + onPressed: words.isEmpty ? null : _stop, + child: Text('general.done'.tr()), + ), + if (_state.phase == VoicePhase.permissionDenied) + TextButton( + onPressed: openAppSettings, + child: Text('search.voice_open_settings'.tr()), + ), + if (_state.canRetry) + TextButton( + // A remote has no other way into the dialog's buttons. + autofocus: isTvPlatform, + onPressed: _begin, + child: Text('search.voice_try_again'.tr()), + ), + ], + ), + ], + ), ), ), ); @@ -298,15 +432,12 @@ class _WavePainter extends CustomPainter { final slot = size.width / levels.length; final barWidth = math.max(2.0, slot * 0.5); final mid = size.height / 2; - // A floor, so silence reads as a quiet line rather than an empty box that - // looks like the widget failed to render. + // A floor, so silence reads as a quiet line rather than an empty box. const minHeight = 3.0; for (var i = 0; i < levels.length; i++) { final level = levels[i].clamp(0.0, 1.0); final h = minHeight + level * (size.height - minHeight); - // Older samples fade, which gives the strip a direction and makes the - // live end the one the eye goes to. final opacity = 0.25 + (i / levels.length) * 0.75; final paint = Paint() ..color = color.withValues(alpha: opacity) diff --git a/lib/features/search/presentation/widgets/voice_search_session.dart b/lib/features/search/presentation/widgets/voice_search_session.dart new file mode 100644 index 00000000..a199d851 --- /dev/null +++ b/lib/features/search/presentation/widgets/voice_search_session.dart @@ -0,0 +1,227 @@ +import 'dart:ui' show Locale; + +/// Where a voice search session stands. The dialog renders one of these and +/// never closes on a failure: every failure phase has something to say. +enum VoicePhase { + starting, + listening, + + /// Ended with a transcript; the dialog pops with [VoiceSearchState.words]. + done, + noSpeech, + permissionDenied, + unavailable, + network, + recognizer, +} + +class VoiceSearchState { + const VoiceSearchState(this.phase, {this.words = '', this.lastError}); + + static const VoiceSearchState initial = VoiceSearchState(VoicePhase.starting); + + final VoicePhase phase; + final String words; + + /// A non-permanent error seen mid-session, used to explain an empty ending. + final String? lastError; + + bool get isActive => + phase == VoicePhase.starting || phase == VoicePhase.listening; + + bool get isFailure => !isActive && phase != VoicePhase.done; + + /// Retrying cannot fix a device with no recogniser. + bool get canRetry => isFailure && phase != VoicePhase.unavailable; + + @override + bool operator ==(Object other) => + other is VoiceSearchState && + other.phase == phase && + other.words == words && + other.lastError == lastError; + + @override + int get hashCode => Object.hash(phase, words, lastError); + + @override + String toString() => 'VoiceSearchState($phase, "$words", $lastError)'; +} + +/// The session's transitions, kept free of the plugin so they can be tested. +abstract final class VoiceSearchMachine { + static VoiceSearchState retry() => VoiceSearchState.initial; + + static VoiceSearchState initFailed({required bool hasPermission}) => + VoiceSearchState( + hasPermission ? VoicePhase.unavailable : VoicePhase.permissionDenied, + ); + + static VoiceSearchState listenFailed({required bool hasPermission}) => + VoiceSearchState( + hasPermission ? VoicePhase.recognizer : VoicePhase.permissionDenied, + ); + + static VoiceSearchState listeningStarted(VoiceSearchState s) => + s.phase == VoicePhase.starting + ? VoiceSearchState(VoicePhase.listening, words: s.words) + : s; + + static VoiceSearchState heard( + VoiceSearchState s, + String words, { + bool isFinal = false, + }) { + if (!s.isActive) return s; + final w = words.trim().isEmpty ? s.words : words.trim(); + if (isFinal) return _end(w, s.lastError); + return VoiceSearchState(VoicePhase.listening, words: w); + } + + /// The recogniser stopped by itself: silence, the time limit, or an error + /// that did not call back. + static VoiceSearchState ended(VoiceSearchState s) => + s.isActive ? _end(s.words, s.lastError) : s; + + /// The microphone never opened within the start deadline. + static VoiceSearchState startTimedOut(VoiceSearchState s) => + s.phase == VoicePhase.starting + ? const VoiceSearchState(VoicePhase.recognizer) + : s; + + static VoiceSearchState error( + VoiceSearchState s, + String code, { + required bool permanent, + }) { + if (!s.isActive) return s; + if (!permanent) { + return VoiceSearchState(s.phase, words: s.words, lastError: code); + } + return _end(s.words, code); + } + + static VoiceSearchState _end(String words, String? error) { + if (words.isNotEmpty) { + return VoiceSearchState(VoicePhase.done, words: words); + } + return VoiceSearchState( + error == null ? VoicePhase.noSpeech : phaseForError(error), + ); + } + + static VoicePhase phaseForError(String code) { + switch (code) { + case 'error_no_match': + case 'error_speech_timeout': + return VoicePhase.noSpeech; + case 'error_permission': + case 'error_insufficient_permissions': + return VoicePhase.permissionDenied; + case 'error_network': + case 'error_network_timeout': + case 'error_server': + case 'error_server_disconnected': + case 'error_too_many_requests': + return VoicePhase.network; + case 'error_speech_recognizer_disabled': + return VoicePhase.unavailable; + default: + return VoicePhase.recognizer; + } + } + + static bool isLanguageError(String code) => + code == 'error_language_not_supported' || + code == 'error_language_unavailable'; +} + +/// Picks the recogniser locale closest to the app's language, or null to let +/// the recogniser use the system locale. +/// +/// [available] are recogniser ids as the plugin reports them (`en_US` on +/// Android, `en-US` on iOS). The system locale wins among same-language +/// candidates, since it carries the user's own region. +String? matchSpeechLocale( + Locale app, + Iterable available, { + String? systemLocaleId, +}) { + final candidates = available.toList(); + if (candidates.isEmpty) return null; + final langs = _languageAliases[app.languageCode.toLowerCase()] ?? + {app.languageCode.toLowerCase()}; + + bool sameLanguage(String id) { + final parts = _split(id); + if (langs.contains(parts.$1)) return true; + // Cantonese is often listed as Chinese for Hong Kong or Macau. + return app.languageCode == 'yue' && + parts.$1 == 'zh' && + (parts.$2 == 'hk' || parts.$2 == 'mo'); + } + + final matches = candidates.where(sameLanguage).toList(); + if (matches.isEmpty) return null; + + final country = app.countryCode?.toLowerCase(); + if (country != null && country.isNotEmpty) { + for (final id in matches) { + if (_split(id).$2 == country) return id; + } + } + if (systemLocaleId != null) { + final system = _normalise(systemLocaleId); + for (final id in matches) { + if (_normalise(id) == system) return id; + } + } + final preferred = _preferredRegion[app.languageCode.toLowerCase()]; + if (preferred != null) { + for (final id in matches) { + if (_split(id).$2 == preferred) return id; + } + } + return matches.first; +} + +/// Older Android and some recognisers use legacy or alternate codes. +const Map> _languageAliases = { + 'id': {'id', 'in'}, + 'yue': {'yue'}, + 'ckb': {'ckb'}, + 'ku': {'ku', 'kmr'}, +}; + +/// The region the app's copy is written for, when the language has several. +const Map _preferredRegion = { + 'en': 'us', + 'pt': 'br', + 'es': 'es', + 'fr': 'fr', + 'de': 'de', + 'nl': 'nl', + 'ar': 'sa', + 'ru': 'ru', + 'uz': 'uz', + 'tr': 'tr', + 'hi': 'in', + 'id': 'id', + 'vi': 'vn', + 'yue': 'hk', +}; + +String _normalise(String id) => id.replaceAll('_', '-').toLowerCase(); + +/// Language and region of an id like `en_US`, `en-US` or `yue-Hant-HK`. +(String, String?) _split(String id) { + final parts = _normalise(id).split('-'); + String? region; + for (final p in parts.skip(1)) { + if (p.length == 2 || (p.length == 3 && int.tryParse(p) != null)) { + region = p; + break; + } + } + return (parts.first, region); +} diff --git a/test/core/tv/tv_keys_test.dart b/test/core/tv/tv_keys_test.dart new file mode 100644 index 00000000..b455161e --- /dev/null +++ b/test/core/tv/tv_keys_test.dart @@ -0,0 +1,18 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/tv/tv_keys.dart'; + +void main() { + test('the remote search and voice keys start voice search', () { + expect(TvKeys.isVoiceSearch(LogicalKeyboardKey.browserSearch), isTrue); + expect(TvKeys.isVoiceSearch(LogicalKeyboardKey.launchAssistant), isTrue); + expect( + TvKeys.isVoiceSearch( + const LogicalKeyboardKey(LogicalKeyboardKey.androidPlane | 231), + ), + isTrue, + ); + expect(TvKeys.isVoiceSearch(LogicalKeyboardKey.select), isFalse); + expect(TvKeys.isVoiceSearch(LogicalKeyboardKey.keyS), isFalse); + }); +} diff --git a/test/features/search/voice_search_session_test.dart b/test/features/search/voice_search_session_test.dart new file mode 100644 index 00000000..5851e297 --- /dev/null +++ b/test/features/search/voice_search_session_test.dart @@ -0,0 +1,184 @@ +import 'dart:ui' show Locale; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/search/presentation/widgets/voice_search_session.dart'; + +void main() { + group('matchSpeechLocale', () { + const android = ['de_DE', 'en_GB', 'en_US', 'ru_RU', 'uz_UZ', 'pt_PT', 'pt_BR']; + + test('the app language picks its recogniser locale', () { + expect(matchSpeechLocale(const Locale('ru'), android), 'ru_RU'); + expect(matchSpeechLocale(const Locale('uz'), android), 'uz_UZ'); + }); + + test('the system locale wins among the same language', () { + expect( + matchSpeechLocale( + const Locale('en'), + android, + systemLocaleId: 'en_GB', + ), + 'en_GB', + ); + }); + + test('without a system match, the region the copy is written for', () { + expect( + matchSpeechLocale(const Locale('en'), android, systemLocaleId: 'de_DE'), + 'en_US', + ); + expect(matchSpeechLocale(const Locale('pt'), android), 'pt_BR'); + }); + + test('an explicit country beats the system locale', () { + expect( + matchSpeechLocale( + const Locale('pt', 'PT'), + android, + systemLocaleId: 'pt_BR', + ), + 'pt_PT', + ); + }); + + test('iOS hyphenated ids match too, and come back as given', () { + expect( + matchSpeechLocale(const Locale('de'), ['en-US', 'de-AT', 'de-DE']), + 'de-DE', + ); + }); + + test('a language the recogniser lacks falls back to the system', () { + expect(matchSpeechLocale(const Locale('ckb'), android), isNull); + expect(matchSpeechLocale(const Locale('en'), const []), isNull); + }); + + test('Indonesian matches the legacy "in" code', () { + expect(matchSpeechLocale(const Locale('id'), ['en_US', 'in_ID']), 'in_ID'); + }); + + test('Cantonese matches yue and Hong Kong Chinese, not Mandarin', () { + expect( + matchSpeechLocale(const Locale('yue'), ['zh_CN', 'yue-Hant-HK']), + 'yue-Hant-HK', + ); + expect(matchSpeechLocale(const Locale('yue'), ['zh_CN', 'zh_HK']), 'zh_HK'); + expect(matchSpeechLocale(const Locale('yue'), ['zh_CN', 'zh_TW']), isNull); + }); + }); + + group('VoiceSearchMachine', () { + const start = VoiceSearchState.initial; + final listening = VoiceSearchMachine.listeningStarted(start); + + test('starts, then listens', () { + expect(start.phase, VoicePhase.starting); + expect(listening.phase, VoicePhase.listening); + }); + + test('partial words keep listening, a final result finishes', () { + final partial = VoiceSearchMachine.heard(listening, 'the dark'); + expect(partial, const VoiceSearchState(VoicePhase.listening, words: 'the dark')); + final done = VoiceSearchMachine.heard(partial, 'the dark knight', isFinal: true); + expect(done, const VoiceSearchState(VoicePhase.done, words: 'the dark knight')); + }); + + test('an empty final result keeps what was already heard', () { + final partial = VoiceSearchMachine.heard(listening, 'dune'); + expect( + VoiceSearchMachine.heard(partial, '', isFinal: true), + const VoiceSearchState(VoicePhase.done, words: 'dune'), + ); + }); + + test('ending in silence says it did not catch anything', () { + expect(VoiceSearchMachine.ended(listening).phase, VoicePhase.noSpeech); + expect( + VoiceSearchMachine.heard(listening, '', isFinal: true).phase, + VoicePhase.noSpeech, + ); + }); + + test('ending with words finishes with them', () { + final partial = VoiceSearchMachine.heard(listening, 'alien'); + expect( + VoiceSearchMachine.ended(partial), + const VoiceSearchState(VoicePhase.done, words: 'alien'), + ); + }); + + test('permanent errors map to what the user can do about them', () { + VoicePhase after(String code) => + VoiceSearchMachine.error(listening, code, permanent: true).phase; + expect(after('error_no_match'), VoicePhase.noSpeech); + expect(after('error_speech_timeout'), VoicePhase.noSpeech); + expect(after('error_permission'), VoicePhase.permissionDenied); + expect(after('error_network'), VoicePhase.network); + expect(after('error_network_timeout'), VoicePhase.network); + expect(after('error_server'), VoicePhase.network); + expect(after('error_busy'), VoicePhase.recognizer); + expect(after('error_audio_error'), VoicePhase.recognizer); + expect(after('error_speech_recognizer_disabled'), VoicePhase.unavailable); + }); + + test('an error after words were heard still returns the words', () { + final partial = VoiceSearchMachine.heard(listening, 'heat'); + expect( + VoiceSearchMachine.error(partial, 'error_no_match', permanent: true), + const VoiceSearchState(VoicePhase.done, words: 'heat'), + ); + }); + + test('a passing error explains an empty end without stopping early', () { + final s = VoiceSearchMachine.error(listening, 'error_network', permanent: false); + expect(s.phase, VoicePhase.listening); + expect(VoiceSearchMachine.ended(s).phase, VoicePhase.network); + }); + + test('init failure distinguishes a refused permission from no recogniser', () { + expect( + VoiceSearchMachine.initFailed(hasPermission: false).phase, + VoicePhase.permissionDenied, + ); + expect( + VoiceSearchMachine.initFailed(hasPermission: true).phase, + VoicePhase.unavailable, + ); + expect( + VoiceSearchMachine.listenFailed(hasPermission: true).phase, + VoicePhase.recognizer, + ); + }); + + test('a microphone that never opens is an error, but only before it opens', () { + expect(VoiceSearchMachine.startTimedOut(start).phase, VoicePhase.recognizer); + expect(VoiceSearchMachine.startTimedOut(listening), listening); + }); + + test('failures are terminal until retried', () { + final failed = VoiceSearchMachine.ended(listening); + expect(failed.isFailure, isTrue); + expect(failed.canRetry, isTrue); + expect(VoiceSearchMachine.heard(failed, 'late'), failed); + expect(VoiceSearchMachine.listeningStarted(failed), failed); + expect( + VoiceSearchMachine.error(failed, 'error_network', permanent: true), + failed, + ); + expect(VoiceSearchMachine.retry(), start); + }); + + test('a device with no recogniser offers no retry', () { + final s = VoiceSearchMachine.initFailed(hasPermission: true); + expect(s.isFailure, isTrue); + expect(s.canRetry, isFalse); + }); + + test('language errors are recognised for the system-locale fallback', () { + expect(VoiceSearchMachine.isLanguageError('error_language_not_supported'), isTrue); + expect(VoiceSearchMachine.isLanguageError('error_language_unavailable'), isTrue); + expect(VoiceSearchMachine.isLanguageError('error_network'), isFalse); + }); + }); +} From 38adc4736b15aa89ceee2e940f29c775e53bafa1 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:33:45 +0500 Subject: [PATCH 02/16] Mangayomi: per-source install, remove, update and enable Pick which sources to install from a repo, remove or disable single sources, review updates before applying them, and filter the installed list by type and language. Removed sources stay out of later refreshes, and removing a source or repo clears its cached code and settings. Catalog rows install just their own source. --- assets/translations/ar.json | 20 +- assets/translations/ckb.json | 20 +- assets/translations/de.json | 20 +- assets/translations/en.json | 20 +- assets/translations/es.json | 20 +- assets/translations/fr.json | 20 +- assets/translations/hi.json | 20 +- assets/translations/id.json | 20 +- assets/translations/ku.json | 20 +- assets/translations/nl.json | 20 +- assets/translations/pt.json | 20 +- assets/translations/ru.json | 20 +- assets/translations/tr.json | 20 +- assets/translations/uz.json | 20 +- assets/translations/vi.json | 20 +- assets/translations/yue.json | 20 +- .../extensions/data/mangayomi_bridge.dart | 2 +- .../extensions/data/mangayomi_repo_store.dart | 386 +++++++++++++-- .../extensions/data/source_installer.dart | 19 +- .../domain/mangayomi_source_filter.dart | 60 +++ .../pages/mangayomi_source_picker_page.dart | 252 ++++++++++ .../pages/mangayomi_sources_page.dart | 458 ++++++++++++++---- .../widgets/source_filter_chips.dart | 175 +++++++ .../extensions/mangayomi_per_source_test.dart | 362 ++++++++++++++ 24 files changed, 1870 insertions(+), 164 deletions(-) create mode 100644 lib/features/extensions/domain/mangayomi_source_filter.dart create mode 100644 lib/features/extensions/presentation/pages/mangayomi_source_picker_page.dart create mode 100644 lib/features/extensions/presentation/widgets/source_filter_chips.dart create mode 100644 test/features/extensions/mangayomi_per_source_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 6e6bbb79..10ee0afb 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -2253,7 +2253,25 @@ "settings_title": "إعدادات {}", "settings_reset": "إعادة التعيين", "settings_failed": "تعذّرت قراءة إعدادات هذا المصدر.", - "settings_none": "لا توجد إعدادات لهذا المصدر." + "settings_none": "لا توجد إعدادات لهذا المصدر.", + "loading_repo": "جارٍ تحميل المستودع…", + "add_sources": "إضافة مصادر", + "choose_sources": "اختر المصادر", + "select_all": "تحديد الكل", + "select_none": "إلغاء التحديد", + "install_selected": "تثبيت ({})", + "already_installed": "مثبّت", + "remove": "إزالة", + "remove_source": "إزالة المصدر", + "remove_source_confirm": "إزالة {}؟ ستُحذف إعداداته والشيفرة المخزّنة. يمكنك إضافته مجددًا من مستودعه.", + "remove_repo_confirm": "إزالة هذا المستودع وجميع مصادره؟", + "enable_source": "تفعيل", + "disable_source": "تعطيل", + "source_disabled": "معطّل", + "update": "تحديث", + "update_all": "تحديث الكل", + "updates_available": "تتوفر {} تحديثات", + "repo_check_failed": "تعذّر التحقق من {}: {}" }, "ux": { "quick_search": "بحث سريع ({})", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 6cef5071..bfa1eeac 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -2249,7 +2249,25 @@ "settings_title": "ڕێکخستنەکانی {}", "settings_reset": "ڕێکخستنەوە", "settings_failed": "نەتوانرا ڕێکخستنەکانی ئەم سەرچاوەیە بخوێندرێنەوە.", - "settings_none": "ئەم سەرچاوەیە هیچ ڕێکخستنێکی نییە." + "settings_none": "ئەم سەرچاوەیە هیچ ڕێکخستنێکی نییە.", + "loading_repo": "کۆگا بار دەکرێت…", + "add_sources": "زیادکردنی سەرچاوە", + "choose_sources": "سەرچاوەکان هەڵبژێرە", + "select_all": "هەڵبژاردنی هەموو", + "select_none": "هیچ هەڵمەبژێرە", + "install_selected": "دامەزراندن ({})", + "already_installed": "دامەزراوە", + "remove": "لابردن", + "remove_source": "لابردنی سەرچاوە", + "remove_source_confirm": "{} لاببرێت؟ ڕێکخستنەکان و کۆدە پاشەکەوتکراوەکەی دەسڕێنەوە. دەتوانیت دووبارە لە کۆگاکەیەوە زیادی بکەیتەوە.", + "remove_repo_confirm": "ئەم کۆگایە و هەموو سەرچاوەکانی لاببرێن؟", + "enable_source": "چالاککردن", + "disable_source": "ناچالاککردن", + "source_disabled": "ناچالاک", + "update": "نوێکردنەوە", + "update_all": "نوێکردنەوەی هەموو", + "updates_available": "{} نوێکردنەوە بەردەستە", + "repo_check_failed": "نەتوانرا {} بپشکنرێت: {}" }, "ux": { "quick_search": "گەڕانی خێرا ({})", diff --git a/assets/translations/de.json b/assets/translations/de.json index 0970f03c..49fd6001 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -2249,7 +2249,25 @@ "settings_title": "Einstellungen: {}", "settings_reset": "Zurücksetzen", "settings_failed": "Die Einstellungen dieser Quelle konnten nicht gelesen werden.", - "settings_none": "Diese Quelle hat keine Einstellungen." + "settings_none": "Diese Quelle hat keine Einstellungen.", + "loading_repo": "Repository wird geladen…", + "add_sources": "Quellen hinzufügen", + "choose_sources": "Quellen auswählen", + "select_all": "Alle auswählen", + "select_none": "Keine auswählen", + "install_selected": "Installieren ({})", + "already_installed": "Installiert", + "remove": "Entfernen", + "remove_source": "Quelle entfernen", + "remove_source_confirm": "{} entfernen? Einstellungen und zwischengespeicherter Code werden gelöscht. Du kannst die Quelle über ihr Repository erneut hinzufügen.", + "remove_repo_confirm": "Dieses Repository und alle seine Quellen entfernen?", + "enable_source": "Aktivieren", + "disable_source": "Deaktivieren", + "source_disabled": "Deaktiviert", + "update": "Aktualisieren", + "update_all": "Alle aktualisieren", + "updates_available": "{} Update(s) verfügbar", + "repo_check_failed": "{} konnte nicht geprüft werden: {}" }, "ux": { "quick_search": "Schnellsuche ({})", diff --git a/assets/translations/en.json b/assets/translations/en.json index 8fbca5aa..b0aa7503 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -2249,7 +2249,25 @@ "settings_title": "{} settings", "settings_reset": "Reset", "settings_failed": "This source’s settings could not be read.", - "settings_none": "This source has no settings." + "settings_none": "This source has no settings.", + "loading_repo": "Loading repository…", + "add_sources": "Add sources", + "choose_sources": "Choose sources", + "select_all": "Select all", + "select_none": "Select none", + "install_selected": "Install {}", + "already_installed": "Installed", + "remove": "Remove", + "remove_source": "Remove source", + "remove_source_confirm": "Remove {}? Its settings and cached code are deleted. You can add it again from its repo.", + "remove_repo_confirm": "Remove this repo and all of its sources?", + "enable_source": "Enable", + "disable_source": "Disable", + "source_disabled": "Disabled", + "update": "Update", + "update_all": "Update all", + "updates_available": "{} update(s) available", + "repo_check_failed": "Could not check {}: {}" }, "ux": { "quick_search": "Quick search ({})", diff --git a/assets/translations/es.json b/assets/translations/es.json index 319ddb48..633dd797 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -2249,7 +2249,25 @@ "settings_title": "Ajustes de {}", "settings_reset": "Restablecer", "settings_failed": "No se pudieron leer los ajustes de esta fuente.", - "settings_none": "Esta fuente no tiene ajustes." + "settings_none": "Esta fuente no tiene ajustes.", + "loading_repo": "Cargando repositorio…", + "add_sources": "Añadir fuentes", + "choose_sources": "Elige las fuentes", + "select_all": "Seleccionar todo", + "select_none": "No seleccionar ninguna", + "install_selected": "Instalar ({})", + "already_installed": "Instalada", + "remove": "Quitar", + "remove_source": "Quitar fuente", + "remove_source_confirm": "¿Quitar {}? Se borrarán sus ajustes y el código en caché. Puedes volver a añadirla desde su repositorio.", + "remove_repo_confirm": "¿Quitar este repositorio y todas sus fuentes?", + "enable_source": "Activar", + "disable_source": "Desactivar", + "source_disabled": "Desactivada", + "update": "Actualizar", + "update_all": "Actualizar todo", + "updates_available": "{} actualización(es) disponible(s)", + "repo_check_failed": "No se pudo comprobar {}: {}" }, "ux": { "quick_search": "Búsqueda rápida ({})", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 599703a9..bba608e8 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -2249,7 +2249,25 @@ "settings_title": "Réglages de {}", "settings_reset": "Réinitialiser", "settings_failed": "Impossible de lire les réglages de cette source.", - "settings_none": "Cette source n’a pas de réglages." + "settings_none": "Cette source n’a pas de réglages.", + "loading_repo": "Chargement du dépôt…", + "add_sources": "Ajouter des sources", + "choose_sources": "Choisir les sources", + "select_all": "Tout sélectionner", + "select_none": "Ne rien sélectionner", + "install_selected": "Installer ({})", + "already_installed": "Installée", + "remove": "Supprimer", + "remove_source": "Supprimer la source", + "remove_source_confirm": "Supprimer {} ? Ses réglages et son code en cache seront effacés. Vous pourrez la rajouter depuis son dépôt.", + "remove_repo_confirm": "Supprimer ce dépôt et toutes ses sources ?", + "enable_source": "Activer", + "disable_source": "Désactiver", + "source_disabled": "Désactivée", + "update": "Mettre à jour", + "update_all": "Tout mettre à jour", + "updates_available": "{} mise(s) à jour disponible(s)", + "repo_check_failed": "Impossible de vérifier {} : {}" }, "ux": { "quick_search": "Recherche rapide ({})", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 12d0cb70..ee237207 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -2249,7 +2249,25 @@ "settings_title": "{} सेटिंग्स", "settings_reset": "रीसेट करें", "settings_failed": "इस सोर्स की सेटिंग्स पढ़ी नहीं जा सकीं।", - "settings_none": "इस सोर्स की कोई सेटिंग्स नहीं हैं।" + "settings_none": "इस सोर्स की कोई सेटिंग्स नहीं हैं।", + "loading_repo": "रिपॉज़िटरी लोड हो रही है…", + "add_sources": "स्रोत जोड़ें", + "choose_sources": "स्रोत चुनें", + "select_all": "सभी चुनें", + "select_none": "कोई नहीं चुनें", + "install_selected": "इंस्टॉल करें ({})", + "already_installed": "इंस्टॉल है", + "remove": "हटाएँ", + "remove_source": "स्रोत हटाएँ", + "remove_source_confirm": "{} हटाएँ? इसकी सेटिंग्स और कैश किया गया कोड मिटा दिया जाएगा। आप इसे इसकी रिपॉज़िटरी से फिर जोड़ सकते हैं।", + "remove_repo_confirm": "यह रिपॉज़िटरी और इसके सभी स्रोत हटाएँ?", + "enable_source": "चालू करें", + "disable_source": "बंद करें", + "source_disabled": "बंद है", + "update": "अपडेट करें", + "update_all": "सभी अपडेट करें", + "updates_available": "{} अपडेट उपलब्ध", + "repo_check_failed": "{} की जाँच नहीं हो सकी: {}" }, "ux": { "quick_search": "झटपट खोज ({})", diff --git a/assets/translations/id.json b/assets/translations/id.json index 28442646..a3ff611f 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -2249,7 +2249,25 @@ "settings_title": "Pengaturan {}", "settings_reset": "Atur ulang", "settings_failed": "Pengaturan sumber ini tidak dapat dibaca.", - "settings_none": "Sumber ini tidak punya pengaturan." + "settings_none": "Sumber ini tidak punya pengaturan.", + "loading_repo": "Memuat repositori…", + "add_sources": "Tambah sumber", + "choose_sources": "Pilih sumber", + "select_all": "Pilih semua", + "select_none": "Batalkan semua", + "install_selected": "Pasang ({})", + "already_installed": "Terpasang", + "remove": "Hapus", + "remove_source": "Hapus sumber", + "remove_source_confirm": "Hapus {}? Pengaturan dan kode tersimpannya akan dihapus. Anda dapat menambahkannya lagi dari repositorinya.", + "remove_repo_confirm": "Hapus repositori ini beserta semua sumbernya?", + "enable_source": "Aktifkan", + "disable_source": "Nonaktifkan", + "source_disabled": "Nonaktif", + "update": "Perbarui", + "update_all": "Perbarui semua", + "updates_available": "{} pembaruan tersedia", + "repo_check_failed": "Tidak dapat memeriksa {}: {}" }, "ux": { "quick_search": "Pencarian cepat ({})", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index e876ed5b..9f550817 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -2249,7 +2249,25 @@ "settings_title": "Mîhengên {}", "settings_reset": "Ji nû ve saz bike", "settings_failed": "Mîhengên vê çavkaniyê nehatin xwendin.", - "settings_none": "Mîhengên vê çavkaniyê tune ne." + "settings_none": "Mîhengên vê çavkaniyê tune ne.", + "loading_repo": "Depo tê barkirin…", + "add_sources": "Çavkaniyan zêde bike", + "choose_sources": "Çavkaniyan hilbijêre", + "select_all": "Hemûyan hilbijêre", + "select_none": "Hilbijartinê rake", + "install_selected": "Saz bike ({})", + "already_installed": "Sazkirî", + "remove": "Rake", + "remove_source": "Çavkaniyê rake", + "remove_source_confirm": "{} were rakirin? Mîheng û koda wê ya tomarkirî tên jêbirin. Tu dikarî wê ji depoya wê dîsa zêde bikî.", + "remove_repo_confirm": "Ev depo û hemû çavkaniyên wê werin rakirin?", + "enable_source": "Çalak bike", + "disable_source": "Neçalak bike", + "source_disabled": "Neçalak", + "update": "Nû bike", + "update_all": "Hemûyan nû bike", + "updates_available": "{} nûkirin hene", + "repo_check_failed": "{} nehat kontrolkirin: {}" }, "ux": { "quick_search": "Lêgerîna bilez ({})", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 8ca39779..a1b06520 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -2249,7 +2249,25 @@ "settings_title": "Instellingen van {}", "settings_reset": "Herstellen", "settings_failed": "De instellingen van deze bron konden niet worden gelezen.", - "settings_none": "Deze bron heeft geen instellingen." + "settings_none": "Deze bron heeft geen instellingen.", + "loading_repo": "Repository laden…", + "add_sources": "Bronnen toevoegen", + "choose_sources": "Kies bronnen", + "select_all": "Alles selecteren", + "select_none": "Niets selecteren", + "install_selected": "Installeren ({})", + "already_installed": "Geïnstalleerd", + "remove": "Verwijderen", + "remove_source": "Bron verwijderen", + "remove_source_confirm": "{} verwijderen? De instellingen en gecachte code worden gewist. Je kunt de bron opnieuw toevoegen via de repository.", + "remove_repo_confirm": "Deze repository en al zijn bronnen verwijderen?", + "enable_source": "Inschakelen", + "disable_source": "Uitschakelen", + "source_disabled": "Uitgeschakeld", + "update": "Bijwerken", + "update_all": "Alles bijwerken", + "updates_available": "{} update(s) beschikbaar", + "repo_check_failed": "Kon {} niet controleren: {}" }, "ux": { "quick_search": "Snel zoeken ({})", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index f79b5482..99c9e62c 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -2249,7 +2249,25 @@ "settings_title": "Configurações de {}", "settings_reset": "Redefinir", "settings_failed": "Não foi possível ler as configurações desta fonte.", - "settings_none": "Esta fonte não tem configurações." + "settings_none": "Esta fonte não tem configurações.", + "loading_repo": "Carregando repositório…", + "add_sources": "Adicionar fontes", + "choose_sources": "Escolha as fontes", + "select_all": "Selecionar todas", + "select_none": "Desmarcar todas", + "install_selected": "Instalar ({})", + "already_installed": "Instalada", + "remove": "Remover", + "remove_source": "Remover fonte", + "remove_source_confirm": "Remover {}? As configurações e o código em cache serão apagados. Você pode adicioná-la de novo pelo repositório.", + "remove_repo_confirm": "Remover este repositório e todas as suas fontes?", + "enable_source": "Ativar", + "disable_source": "Desativar", + "source_disabled": "Desativada", + "update": "Atualizar", + "update_all": "Atualizar todas", + "updates_available": "{} atualização(ões) disponível(is)", + "repo_check_failed": "Não foi possível verificar {}: {}" }, "ux": { "quick_search": "Busca rápida ({})", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index c85aef09..fe141c90 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -2250,7 +2250,25 @@ "settings_title": "Настройки: {}", "settings_reset": "Сбросить", "settings_failed": "Не удалось прочитать настройки источника.", - "settings_none": "У этого источника нет настроек." + "settings_none": "У этого источника нет настроек.", + "loading_repo": "Загрузка репозитория…", + "add_sources": "Добавить источники", + "choose_sources": "Выберите источники", + "select_all": "Выбрать все", + "select_none": "Снять выбор", + "install_selected": "Установить ({})", + "already_installed": "Установлен", + "remove": "Удалить", + "remove_source": "Удалить источник", + "remove_source_confirm": "Удалить {}? Его настройки и сохранённый код будут удалены. Его можно снова добавить из репозитория.", + "remove_repo_confirm": "Удалить этот репозиторий и все его источники?", + "enable_source": "Включить", + "disable_source": "Отключить", + "source_disabled": "Отключён", + "update": "Обновить", + "update_all": "Обновить все", + "updates_available": "Доступно обновлений: {}", + "repo_check_failed": "Не удалось проверить {}: {}" }, "ux": { "quick_search": "Быстрый поиск ({})", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index f22f299e..8f1e5960 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -2249,7 +2249,25 @@ "settings_title": "{} ayarları", "settings_reset": "Sıfırla", "settings_failed": "Bu kaynağın ayarları okunamadı.", - "settings_none": "Bu kaynağın ayarı yok." + "settings_none": "Bu kaynağın ayarı yok.", + "loading_repo": "Depo yükleniyor…", + "add_sources": "Kaynak ekle", + "choose_sources": "Kaynakları seç", + "select_all": "Tümünü seç", + "select_none": "Hiçbirini seçme", + "install_selected": "Yükle ({})", + "already_installed": "Yüklü", + "remove": "Kaldır", + "remove_source": "Kaynağı kaldır", + "remove_source_confirm": "{} kaldırılsın mı? Ayarları ve önbellekteki kodu silinir. Deposundan yeniden ekleyebilirsin.", + "remove_repo_confirm": "Bu depo ve tüm kaynakları kaldırılsın mı?", + "enable_source": "Etkinleştir", + "disable_source": "Devre dışı bırak", + "source_disabled": "Devre dışı", + "update": "Güncelle", + "update_all": "Tümünü güncelle", + "updates_available": "{} güncelleme mevcut", + "repo_check_failed": "{} kontrol edilemedi: {}" }, "ux": { "quick_search": "Hızlı arama ({})", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index a0ec0494..7417caed 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -2249,7 +2249,25 @@ "settings_title": "{} sozlamalari", "settings_reset": "Asliga qaytarish", "settings_failed": "Bu manbaning sozlamalarini o‘qib bo‘lmadi.", - "settings_none": "Bu manbada sozlamalar yo‘q." + "settings_none": "Bu manbada sozlamalar yo‘q.", + "loading_repo": "Repozitoriy yuklanmoqda…", + "add_sources": "Manbalar qo‘shish", + "choose_sources": "Manbalarni tanlang", + "select_all": "Hammasini tanlash", + "select_none": "Hech birini tanlamaslik", + "install_selected": "O‘rnatish ({})", + "already_installed": "O‘rnatilgan", + "remove": "O‘chirish", + "remove_source": "Manbani o‘chirish", + "remove_source_confirm": "{} o‘chirilsinmi? Uning sozlamalari va keshdagi kodi o‘chiriladi. Uni repozitoriyadan qayta qo‘shishingiz mumkin.", + "remove_repo_confirm": "Bu repozitoriy va uning barcha manbalari o‘chirilsinmi?", + "enable_source": "Yoqish", + "disable_source": "O‘chirib qo‘yish", + "source_disabled": "O‘chirilgan", + "update": "Yangilash", + "update_all": "Hammasini yangilash", + "updates_available": "{} ta yangilanish mavjud", + "repo_check_failed": "{} tekshirib bo‘lmadi: {}" }, "ux": { "quick_search": "Tezkor qidiruv ({})", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 5a8f5b47..b040a9b7 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -2249,7 +2249,25 @@ "settings_title": "Cài đặt {}", "settings_reset": "Đặt lại", "settings_failed": "Không đọc được cài đặt của nguồn này.", - "settings_none": "Nguồn này không có cài đặt." + "settings_none": "Nguồn này không có cài đặt.", + "loading_repo": "Đang tải kho…", + "add_sources": "Thêm nguồn", + "choose_sources": "Chọn nguồn", + "select_all": "Chọn tất cả", + "select_none": "Bỏ chọn tất cả", + "install_selected": "Cài đặt ({})", + "already_installed": "Đã cài", + "remove": "Gỡ bỏ", + "remove_source": "Gỡ nguồn", + "remove_source_confirm": "Gỡ {}? Cài đặt và mã đã lưu của nguồn sẽ bị xóa. Bạn có thể thêm lại từ kho của nó.", + "remove_repo_confirm": "Gỡ kho này và tất cả nguồn của nó?", + "enable_source": "Bật", + "disable_source": "Tắt", + "source_disabled": "Đã tắt", + "update": "Cập nhật", + "update_all": "Cập nhật tất cả", + "updates_available": "Có {} bản cập nhật", + "repo_check_failed": "Không thể kiểm tra {}: {}" }, "ux": { "quick_search": "Tìm nhanh ({})", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 91e3e54a..c5ef22d5 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -2249,7 +2249,25 @@ "settings_title": "{} 設定", "settings_reset": "重設", "settings_failed": "讀唔到呢個來源嘅設定。", - "settings_none": "呢個來源冇設定。" + "settings_none": "呢個來源冇設定。", + "loading_repo": "載入緊儲存庫…", + "add_sources": "加入來源", + "choose_sources": "揀選來源", + "select_all": "全選", + "select_none": "全部唔揀", + "install_selected": "安裝 ({})", + "already_installed": "已安裝", + "remove": "移除", + "remove_source": "移除來源", + "remove_source_confirm": "移除 {}?佢嘅設定同快取程式碼會被刪除。你可以喺佢嘅儲存庫再加返。", + "remove_repo_confirm": "移除呢個儲存庫同佢所有來源?", + "enable_source": "啟用", + "disable_source": "停用", + "source_disabled": "已停用", + "update": "更新", + "update_all": "全部更新", + "updates_available": "有 {} 個更新", + "repo_check_failed": "檢查唔到 {}:{}" }, "ux": { "quick_search": "快速搜尋({})", diff --git a/lib/features/extensions/data/mangayomi_bridge.dart b/lib/features/extensions/data/mangayomi_bridge.dart index 47c7df13..40a9203a 100644 --- a/lib/features/extensions/data/mangayomi_bridge.dart +++ b/lib/features/extensions/data/mangayomi_bridge.dart @@ -74,7 +74,7 @@ class MangayomiBridge { List> listProviders({bool includeNsfw = false}) { final preferred = getIt().getProviderLanguages(); final picked = {}; - for (final s in store.sources()) { + for (final s in store.enabledSources()) { if (s.isNsfw && !includeNsfw) continue; // Languages the user asked for do not compete for a name — they are all // kept, each under its own key, and the picker labels them apart. Only diff --git a/lib/features/extensions/data/mangayomi_repo_store.dart b/lib/features/extensions/data/mangayomi_repo_store.dart index d782846c..d135fba7 100644 --- a/lib/features/extensions/data/mangayomi_repo_store.dart +++ b/lib/features/extensions/data/mangayomi_repo_store.dart @@ -33,6 +33,9 @@ class MangayomiRepoStore { static const _sourcesKey = 'mangayomi_sources'; static const _codePrefix = 'mangayomi_code:'; static const _prefsPrefix = 'mangayomi_prefs:'; + static const _excludedKey = 'mangayomi_excluded'; + static const _disabledKey = 'mangayomi_disabled'; + static const _updatesKey = 'mangayomi_updates'; Box get _box => Hive.box(AppConstants.settingsBox); Object? _sourcesRaw; @@ -110,15 +113,9 @@ class MangayomiRepoStore { // --- install / remove ---------------------------------------------------- - /// Fetches [indexUrl] and merges its JavaScript sources into the installed set. - /// - /// Returns `{added, total, skippedDart}`. `skippedDart` is reported rather - /// than hidden: several large repos are majority-Dart, and a user who - /// installs one and sees a third of the advertised count needs to know why. - Future> addRepo(String indexUrl) async { + /// Fetches and parses [indexUrl] without installing anything. + Future fetchIndex(String indexUrl) async { final url = indexUrl.trim(); - if (url.isEmpty) return const {'added': 0, 'total': 0, 'skippedDart': 0}; - final resp = await _net.get(url); if (resp.statusCode == null || resp.statusCode! < 200 || @@ -133,22 +130,13 @@ class MangayomiRepoStore { final decoded = jsonDecode(body); if (decoded is! List) throw Exception('Unexpected index format'); - final existing = sources(); - // Replace this repository's snapshot; a successful refresh can retire IDs. - // Keep other repositories until their own index has been fetched successfully. - final byId = { - for (final s in existing.where((s) => s.repoUrl != url)) s.id: s, - }; - final oldIds = existing.map((s) => s.id).toSet(); - var added = 0; - var skippedDart = 0; - var validEntries = 0; - // Two index shapes, told apart by what the entries carry rather than by // where they came from — so a mirror, a fork or a local copy of either one // still works. See [MangayomiSource.looksLikeLnReaderIndex]. final lnReader = MangayomiSource.looksLikeLnReaderIndex(decoded); - + final sources = []; + var skippedDart = 0; + var validEntries = 0; for (final entry in decoded.whereType()) { final json = Map.from(entry); final src = lnReader @@ -160,8 +148,7 @@ class MangayomiRepoStore { skippedDart++; continue; } - if (!oldIds.contains(src.id)) added++; - byId[src.id] = src; + sources.add(src); } if (decoded.isNotEmpty && validEntries == 0) { @@ -169,50 +156,324 @@ class MangayomiRepoStore { 'Repository contains no valid source entries', ); } + return MangayomiRepoIndex( + url: url, + sources: sources, + skippedDart: skippedDart, + ); + } - await _saveSources(byId.values.toList()); - final list = repos(); - if (!list.contains(url)) { - await _saveRepos([...list, url]); + /// Fetches [indexUrl] and installs every JavaScript source in it, including + /// ones the user removed earlier. + /// + /// Returns `{added, total, skippedDart}`. `skippedDart` is reported rather + /// than hidden: several large repos are majority-Dart, and a user who + /// installs one and sees a third of the advertised count needs to know why. + Future> addRepo(String indexUrl) async { + final url = indexUrl.trim(); + if (url.isEmpty) return const {'added': 0, 'total': 0, 'skippedDart': 0}; + final index = await fetchIndex(url); + + final existing = sources(); + // Replace this repository's snapshot; a successful refresh can retire IDs. + // Keep other repositories until their own index has been fetched successfully. + final byId = { + for (final s in existing.where((s) => s.repoUrl != url)) s.id: s, + }; + final oldIds = existing.map((s) => s.id).toSet(); + var added = 0; + for (final src in index.sources) { + if (!oldIds.contains(src.id)) added++; + byId[src.id] = src; } + final retired = existing + .where((s) => s.repoUrl == url && !byId.containsKey(s.id)) + .map((s) => s.id) + .toList(); + + await _saveSources(byId.values.toList()); + await _forget(retired); + await _setExcluded(url, const {}); + await _dropUpdates(index.sources.map((s) => s.id)); + await _addRepoUrl(url); return { 'added': added, 'total': byId.values.where((s) => s.repoUrl == url).length, - 'skippedDart': skippedDart, + 'skippedDart': index.skippedDart, }; } + /// Installs only the sources of [index] whose ids are in [ids]. + /// + /// Sources of this repo the user did not pick are remembered as excluded, so + /// a later refresh does not install them behind the user's back. Picking one + /// here lifts its exclusion. + Future> installSources( + MangayomiRepoIndex index, + Set ids, + ) async { + final url = index.url; + final existing = sources(); + final byId = {for (final s in existing) s.id: s}; + final installedHere = existing + .where((s) => s.repoUrl == url) + .map((s) => s.id) + .toSet(); + var added = 0; + var total = 0; + for (final src in index.sources) { + if (!ids.contains(src.id)) continue; + if (!byId.containsKey(src.id)) added++; + total++; + byId[src.id] = src; + } + final excluded = { + ...excludedIds(url), + ...index.sources + .map((s) => s.id) + .where((id) => !ids.contains(id) && !installedHere.contains(id)), + }..removeAll(ids); + + await _saveSources(byId.values.toList()); + await _setExcluded(url, excluded); + await _dropUpdates(ids); + await _addRepoUrl(url); + return {'added': added, 'total': total, 'skippedDart': index.skippedDart}; + } + + Future _addRepoUrl(String url) async { + final list = repos(); + if (!list.contains(url)) await _saveRepos([...list, url]); + } + + /// Uninstalls one source and keeps it out of later refreshes of its repo. + Future removeSource(String id) async { + final bare = id.startsWith('my:') ? id.substring(3) : id; + final source = sourceById(bare); + if (source == null) return; + final all = sources(); + await _saveSources(all.where((s) => s.id != bare).toList()); + if (source.repoUrl.isNotEmpty) { + await _setExcluded(source.repoUrl, { + ...excludedIds(source.repoUrl), + bare, + }); + } + await _forget([bare]); + } + Future removeRepo(String indexUrl) async { final url = indexUrl.trim(); - final kept = sources().where((s) => s.repoUrl != url).toList(); - await _saveSources(kept); + final all = sources(); + final dropped = all.where((s) => s.repoUrl == url).map((s) => s.id); + await _saveSources(all.where((s) => s.repoUrl != url).toList()); await _saveRepos(repos().where((r) => r != url).toList()); - // Cached extension code for the dropped sources is left behind on purpose: - // it is a few KB per source and re-adding the repo is instant with it warm. - // It is cleaned up by [clearCode] when the user asks explicitly. + await _setExcluded(url, const {}); + await _forget(dropped.toList()); } - /// Re-fetches every installed repo index, replacing the stored metadata and - /// dropping cached code for sources whose version changed. Returns how many - /// sources were updated. - Future checkUpdates() async { - final before = {for (final s in sources()) s.id: s.runtimeIdentity}; + /// Drops everything stored for sources that are no longer installed. + Future _forget(List ids) async { + if (ids.isEmpty) return; + await _box.deleteAll([ + for (final id in ids) ...['$_codePrefix$id', '$_prefsPrefix$id'], + ]); + await _dropUpdates(ids); + final disabled = disabledIds(); + if (disabled.any(ids.contains)) { + await _saveDisabled(disabled..removeAll(ids)); + } + } + + /// Re-fetches every installed repo index and records which installed + /// sources have a newer build, without applying them. + /// + /// Sources that left their index are retired and new, non-excluded ones are + /// installed. A repo that cannot be fetched keeps its previous snapshot and + /// is reported in [MangayomiUpdateCheck.failures]. + Future checkUpdates() async { + final failures = {}; + var added = 0; for (final repo in repos()) { try { - await addRepo(repo); - } catch (_) { - // One dead repo must not abort the rest. + added += await _refresh(await fetchIndex(repo)); + } catch (e) { + failures[repo] = '$e'; } } - var updated = 0; - for (final s in sources()) { - final old = before[s.id]; - if (old != null && old != s.runtimeIdentity) { - updated++; - await _box.delete('$_codePrefix${s.id}'); + return MangayomiUpdateCheck( + updates: availableUpdates(), + failures: failures, + added: added, + ); + } + + Future _refresh(MangayomiRepoIndex index) async { + final url = index.url; + final existing = sources(); + final byId = {for (final s in existing) s.id: s}; + final latest = {for (final s in index.sources) s.id: s}; + final excluded = excludedIds(url); + final updates = _readUpdates(); + final retired = []; + var added = 0; + + for (final s in existing.where((s) => s.repoUrl == url)) { + final next = latest[s.id]; + if (next == null) { + byId.remove(s.id); + retired.add(s.id); + } else if (next.runtimeIdentity != s.runtimeIdentity) { + updates[s.id] = next; + } else { + updates.remove(s.id); } } - return updated; + for (final s in index.sources) { + if (byId.containsKey(s.id) || excluded.contains(s.id)) continue; + byId[s.id] = s; + added++; + } + + await _saveSources(byId.values.toList()); + await _saveUpdates(updates); + await _forget(retired); + return added; + } + + // --- updates ------------------------------------------------------------- + + /// Newer builds found by [checkUpdates] that the user has not applied yet. + Map availableUpdates() { + final out = {}; + _readSources(); + for (final entry in _readUpdates().entries) { + final installed = _sourcesById[entry.key]; + if (installed == null || installed.repoUrl != entry.value.repoUrl) { + continue; + } + if (installed.runtimeIdentity == entry.value.runtimeIdentity) continue; + out[entry.key] = entry.value; + } + return out; + } + + /// Applies the pending updates for [ids], or all of them when null. + /// Returns the ids that were updated. + Future> applyUpdates([Iterable? ids]) async { + final pending = availableUpdates(); + final wanted = ids?.toSet() ?? pending.keys.toSet(); + final byId = {for (final s in sources()) s.id: s}; + final applied = []; + for (final id in wanted) { + final next = pending[id]; + if (next == null) continue; + byId[id] = next; + applied.add(id); + await _box.delete('$_codePrefix$id'); + } + if (applied.isEmpty) return applied; + await _saveSources(byId.values.toList()); + await _dropUpdates(applied); + return applied; + } + + Map _readUpdates() { + final raw = _box.get(_updatesKey); + if (raw is! String || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is! List) return {}; + return { + for (final e in decoded.whereType()) + e['id'].toString(): MangayomiSource.fromJson( + Map.from(e), + ), + }; + } catch (_) { + return {}; + } + } + + Future _saveUpdates(Map updates) => _box.put( + _updatesKey, + jsonEncode(updates.values.map((e) => e.toJson()).toList()), + ); + + Future _dropUpdates(Iterable ids) async { + final updates = _readUpdates(); + final before = updates.length; + for (final id in ids) { + updates.remove(id); + } + if (updates.length != before) await _saveUpdates(updates); + } + + // --- exclusions ---------------------------------------------------------- + + /// Ids from [repoUrl] the user removed or chose not to install. + Set excludedIds(String repoUrl) => + (_readExcluded()[repoUrl] ?? const {}).toSet(); + + Map> _readExcluded() { + final raw = _box.get(_excludedKey); + if (raw is! String || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return {}; + return { + for (final e in decoded.entries) + if (e.value is List) + e.key.toString(): (e.value as List).map((v) => '$v').toSet(), + }; + } catch (_) { + return {}; + } + } + + Future _setExcluded(String repoUrl, Set ids) async { + final all = _readExcluded(); + if (ids.isEmpty) { + if (all.remove(repoUrl) == null) return; + } else { + all[repoUrl] = ids; + } + await _box.put( + _excludedKey, + jsonEncode({for (final e in all.entries) e.key: e.value.toList()}), + ); + } + + // --- enabled / disabled -------------------------------------------------- + + /// Installed sources the user switched off. They stay installed, with their + /// settings, but are left out of the provider list. + Set disabledIds() { + final raw = _box.get(_disabledKey); + if (raw is! String || raw.isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + return decoded is List ? decoded.map((e) => '$e').toSet() : {}; + } catch (_) { + return {}; + } + } + + bool isEnabled(String id) => !disabledIds().contains(id); + + Future setEnabled(String id, bool enabled) async { + final disabled = disabledIds(); + final changed = enabled ? disabled.remove(id) : disabled.add(id); + if (changed) await _saveDisabled(disabled); + } + + Future _saveDisabled(Set ids) => + _box.put(_disabledKey, jsonEncode(ids.toList())); + + /// Installed sources that are switched on. + List enabledSources() { + final disabled = disabledIds(); + return sources().where((s) => !disabled.contains(s.id)).toList(); } // --- extension code ------------------------------------------------------ @@ -278,3 +539,34 @@ class MangayomiRepoStore { Future savePrefs(String sourceId, Map values) => _box.put('$_prefsPrefix$sourceId', jsonEncode(values)); } + +/// A parsed repository index. Only JavaScript sources are kept; the Dart ones +/// are counted in [skippedDart]. +class MangayomiRepoIndex { + const MangayomiRepoIndex({ + required this.url, + required this.sources, + this.skippedDart = 0, + }); + + final String url; + final List sources; + final int skippedDart; +} + +class MangayomiUpdateCheck { + const MangayomiUpdateCheck({ + required this.updates, + this.failures = const {}, + this.added = 0, + }); + + /// Newer builds keyed by source id. + final Map updates; + + /// Repo url to error, for indexes that could not be fetched. + final Map failures; + + /// New sources that appeared in an index and were installed. + final int added; +} diff --git a/lib/features/extensions/data/source_installer.dart b/lib/features/extensions/data/source_installer.dart index 6af01232..99890c17 100644 --- a/lib/features/extensions/data/source_installer.dart +++ b/lib/features/extensions/data/source_installer.dart @@ -60,7 +60,19 @@ Future installCatalogSource( result = await MangaChannel.addRepo(url); countKey = 'sourceCount'; case ExtensionRepoKind.mangayomi: - result = await store.addRepo(url); + // A catalog row names one source; only rows without an identity (the + // recommended repos) install the whole index. + if (source.externalId.isEmpty) { + result = await store.addRepo(url); + } else { + final index = await store.fetchIndex(url); + final ids = index.sources + .where((s) => mangayomiCatalogMatch(s.id, source.externalId)) + .map((s) => s.id) + .toSet(); + if (ids.isEmpty) throw StateError('Source not found in repository'); + result = await store.installSources(index, ids); + } countKey = 'total'; } if (result['error'] case final String error when error.isNotEmpty) { @@ -68,3 +80,8 @@ Future installCatalogSource( } return (result[countKey] as num?)?.toInt() ?? 0; } + +/// The catalog stores the index's own id; LNReader plugins are prefixed with +/// `ln.` once installed so they cannot collide with a Mangayomi id. +bool mangayomiCatalogMatch(String installedId, String externalId) => + installedId == externalId || installedId == 'ln.$externalId'; diff --git a/lib/features/extensions/domain/mangayomi_source_filter.dart b/lib/features/extensions/domain/mangayomi_source_filter.dart new file mode 100644 index 00000000..0597c9d0 --- /dev/null +++ b/lib/features/extensions/domain/mangayomi_source_filter.dart @@ -0,0 +1,60 @@ +import 'package:soplay/core/extensions/source_language.dart' as srclang; +import 'package:soplay/features/extensions/domain/entities/mangayomi_source.dart'; + +/// The filters shared by the installed list and the source picker. +class MangayomiSourceFilter { + const MangayomiSourceFilter({ + this.query = '', + this.type, + this.languages = const [], + this.includeNsfw = true, + }); + + final String query; + + /// Null means every type. + final MangayomiItemType? type; + + /// Empty means every language. Sources tagged `all` match any selection. + final List languages; + final bool includeNsfw; + + bool matches(MangayomiSource s) { + if (!includeNsfw && s.isNsfw) return false; + if (type != null && s.itemType != type) return false; + if (!srclang.langMatches(s.lang, languages)) return false; + final q = query.trim().toLowerCase(); + if (q.isEmpty) return true; + return s.name.toLowerCase().contains(q) || + s.baseUrl.toLowerCase().contains(q); + } + + /// Matching sources sorted by name, then language. + List apply(Iterable sources) => + sources.where(matches).toList()..sort(compareByName); + + static int compareByName(MangayomiSource a, MangayomiSource b) { + final byName = a.name.toLowerCase().compareTo(b.name.toLowerCase()); + if (byName != 0) return byName; + return a.lang.compareTo(b.lang); + } + + /// How many of [sources] carry each normalised language code. + static Map languageCounts(Iterable sources) { + final out = {}; + for (final s in sources) { + final key = srclang.normalizeLang(s.lang); + if (key.isEmpty) continue; + out.update(key, (n) => n + 1, ifAbsent: () => 1); + } + return out; + } + + /// The selection after the user taps [code]; `all` clears it. + static List toggleLanguage(List selected, String code) { + if (code == srclang.kAllLanguages) return const []; + final next = List.from(selected); + if (!next.remove(code)) next.add(code); + return next; + } +} diff --git a/lib/features/extensions/presentation/pages/mangayomi_source_picker_page.dart b/lib/features/extensions/presentation/pages/mangayomi_source_picker_page.dart new file mode 100644 index 00000000..3a1e943f --- /dev/null +++ b/lib/features/extensions/presentation/pages/mangayomi_source_picker_page.dart @@ -0,0 +1,252 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/extensions/data/mangayomi_repo_store.dart'; +import 'package:soplay/features/extensions/domain/entities/mangayomi_source.dart'; +import 'package:soplay/features/extensions/domain/mangayomi_source_filter.dart'; +import 'package:soplay/features/extensions/presentation/widgets/source_filter_chips.dart'; + +/// Lets the user pick which sources of one or more fetched indexes to install. +/// +/// Pops with the chosen ids keyed by index url, or null when cancelled. +class MangayomiSourcePickerPage extends StatefulWidget { + const MangayomiSourcePickerPage({ + super.key, + required this.indexes, + required this.installedIds, + this.initialLanguages = const [], + this.includeNsfw = false, + }); + + final List indexes; + final Set installedIds; + final List initialLanguages; + final bool includeNsfw; + + static Future>?> show( + BuildContext context, { + required List indexes, + required Set installedIds, + List initialLanguages = const [], + bool includeNsfw = false, + }) => Navigator.of(context).push>>( + MaterialPageRoute( + builder: (_) => MangayomiSourcePickerPage( + indexes: indexes, + installedIds: installedIds, + initialLanguages: initialLanguages, + includeNsfw: includeNsfw, + ), + ), + ); + + @override + State createState() => + _MangayomiSourcePickerPageState(); +} + +class _MangayomiSourcePickerPageState extends State { + static const Color _accent = Color(0xFFE5484D); + + final _search = TextEditingController(); + final _chosen = {}; + String _query = ''; + MangayomiItemType? _type; + late List _languages = widget.initialLanguages; + + late final List _all = [ + for (final index in widget.indexes) + ...index.sources.where((s) => widget.includeNsfw || !s.isNsfw), + ]; + + static String _key(MangayomiSource s) => '${s.repoUrl}\u0000${s.id}'; + + bool _installed(MangayomiSource s) => widget.installedIds.contains(s.id); + + MangayomiSourceFilter get _filter => MangayomiSourceFilter( + query: _query, + type: _type, + languages: _languages, + includeNsfw: widget.includeNsfw, + ); + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + void _setAll(List visible, bool selected) { + setState(() { + final keys = visible.where((s) => !_installed(s)).map(_key); + if (selected) { + _chosen.addAll(keys); + } else { + _chosen.removeAll(keys); + } + }); + } + + void _submit() { + final out = >{}; + for (final s in _all) { + if (!_chosen.contains(_key(s))) continue; + out.putIfAbsent(s.repoUrl, () => {}).add(s.id); + } + Navigator.of(context).pop(out); + } + + @override + Widget build(BuildContext context) { + final visible = _filter.apply(_all); + final langScope = MangayomiSourceFilter( + type: _type, + includeNsfw: widget.includeNsfw, + ).apply(_all); + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + elevation: 0, + title: Text('ext.choose_sources'.tr()), + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 6), + child: TextField( + controller: _search, + onChanged: (v) => setState(() => _query = v), + style: const TextStyle(color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: 'general.search'.tr(), + hintStyle: const TextStyle(color: AppColors.textHint), + prefixIcon: const Icon(Icons.search, color: AppColors.textHint), + isDense: true, + filled: true, + fillColor: AppColors.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + ), + MangayomiTypeFilterRow( + selected: _type, + onSelected: (t) => setState(() => _type = t), + ), + MangayomiLanguageFilterRow( + counts: MangayomiSourceFilter.languageCounts(langScope), + selected: _languages, + onToggle: (code) => setState( + () => _languages = MangayomiSourceFilter.toggleLanguage( + _languages, + code, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + TextButton( + onPressed: () => _setAll(visible, true), + child: Text('ext.select_all'.tr()), + ), + TextButton( + onPressed: () => _setAll(visible, false), + child: Text('ext.select_none'.tr()), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.only(right: 8), + child: Text( + '${visible.length}', + style: const TextStyle(color: AppColors.textHint), + ), + ), + ], + ), + ), + Expanded( + child: visible.isEmpty + ? Center( + child: Text( + 'ext.no_matches'.tr(), + style: const TextStyle(color: AppColors.textHint), + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 16), + itemCount: visible.length, + itemBuilder: (_, i) => _tile(visible[i]), + ), + ), + ], + ), + bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: SizedBox( + height: 46, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: _accent, + foregroundColor: Colors.white, + ), + onPressed: _chosen.isEmpty ? null : _submit, + icon: const Icon(Icons.download_rounded), + label: Text( + 'ext.install_selected'.tr(args: ['${_chosen.length}']), + ), + ), + ), + ), + ), + ); + } + + Widget _tile(MangayomiSource s) { + final installed = _installed(s); + final type = switch (s.itemType) { + MangayomiItemType.anime => 'ext.type_anime'.tr(), + MangayomiItemType.novel => 'ext.type_novel'.tr(), + MangayomiItemType.manga => 'ext.type_manga'.tr(), + }; + return CheckboxListTile( + value: installed || _chosen.contains(_key(s)), + onChanged: installed + ? null + : (v) => setState(() { + if (v == true) { + _chosen.add(_key(s)); + } else { + _chosen.remove(_key(s)); + } + }), + activeColor: _accent, + dense: true, + controlAffinity: ListTileControlAffinity.leading, + title: Text( + s.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 13.5), + ), + subtitle: Text( + [ + s.lang.toUpperCase(), + type, + 'v${s.version}', + if (s.isNsfw) '18+', + if (installed) 'ext.already_installed'.tr(), + ].join(' · '), + style: const TextStyle(color: AppColors.textHint, fontSize: 11), + ), + ); + } +} diff --git a/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart b/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart index a26198e7..0f772734 100644 --- a/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart +++ b/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart @@ -9,7 +9,10 @@ import 'package:soplay/features/extensions/data/mangayomi_repo_store.dart'; import 'package:soplay/features/extensions/data/mangayomi_runtime.dart'; import 'package:soplay/features/extensions/domain/entities/extension_repo_entity.dart'; import 'package:soplay/features/extensions/domain/entities/mangayomi_source.dart'; +import 'package:soplay/features/extensions/domain/mangayomi_source_filter.dart'; +import 'package:soplay/features/extensions/presentation/pages/mangayomi_source_picker_page.dart'; import 'package:soplay/features/extensions/presentation/widgets/recommended_repos_section.dart'; +import 'package:soplay/features/extensions/presentation/widgets/source_filter_chips.dart'; import 'package:soplay/features/extensions/presentation/widgets/source_settings_sheet.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_event.dart'; @@ -39,22 +42,15 @@ class _MangayomiSourcesPageState extends State { bool _busy = false; String? _status; bool _statusError = false; + MangayomiItemType? _type; + List _languages = const []; List get _repos => _store.repos(); - List get _sources { - // `.toList()` on BOTH branches. `store.sources()` returns a const empty - // list when nothing is installed, and `List.sort` on an unmodifiable list - // throws "Cannot modify an unmodifiable list" regardless of length — so - // sorting the branch that passed the original list straight through blew up - // the whole page for any user with adult sources enabled and no sources yet. - final all = _store.sources(); - final visible = _hive.showAdultContent - ? all.toList() - : all.where((s) => !s.isNsfw).toList(); - visible.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); - return visible; - } + /// Installed sources the user may see, before the type and language filters. + List get _installed => + MangayomiSourceFilter(includeNsfw: _hive.showAdultContent) + .apply(_store.sources()); @override void dispose() { @@ -70,7 +66,8 @@ class _MangayomiSourcesPageState extends State { Future _add() => _install([_controller.text.trim()]); - /// Installs one or more sibling indexes (manga / anime / novel) as one unit. + /// Fetches one or more sibling indexes (manga / anime / novel), lets the + /// user pick sources from them, and installs only those. Future _install(List urls) async { final list = urls.where((u) => u.trim().isNotEmpty).toList(); if (list.isEmpty || _busy) return; @@ -78,23 +75,63 @@ class _MangayomiSourcesPageState extends State { setState(() { _busy = true; _statusError = false; - _status = 'ext.installing_extensions'.tr(); + _status = 'ext.loading_repo'.tr(); }); - var added = 0; - var skippedDart = 0; + final indexes = []; final failures = []; for (final url in list) { try { - final res = await _store.addRepo(url); - added += res['added'] ?? 0; - skippedDart += res['skippedDart'] ?? 0; + indexes.add(await _store.fetchIndex(url)); } catch (e) { // A repo publishing only some of the three sibling indexes is normal — // record the miss and keep going rather than failing the whole install. failures.add('${url.split('/').last}: $e'); } } + if (!mounted) return; + + final skippedDart = indexes.fold(0, (n, i) => n + i.skippedDart); + if (indexes.every((i) => i.sources.isEmpty)) { + setState(() { + _busy = false; + _statusError = true; + _status = indexes.isEmpty && failures.isNotEmpty + ? 'ext.could_not_install'.tr(args: [failures.first]) + : _withDartNote('ext.none_found_js'.tr(), skippedDart); + }); + return; + } + + setState(() { + _busy = false; + _status = null; + }); + final chosen = await MangayomiSourcePickerPage.show( + context, + indexes: indexes, + installedIds: _store.sources().map((s) => s.id).toSet(), + initialLanguages: _hive.getProviderLanguages(), + includeNsfw: _hive.showAdultContent, + ); + if (!mounted || chosen == null || chosen.isEmpty) return; + + setState(() { + _busy = true; + _status = 'ext.installing_extensions'.tr(); + }); + var added = 0; + String? error; + try { + for (final index in indexes) { + final ids = chosen[index.url]; + if (ids == null || ids.isEmpty) continue; + final res = await _store.installSources(index, ids); + added += res['added'] ?? 0; + } + } catch (e) { + error = '$e'; + } if (!mounted) return; if (added > 0) { @@ -103,23 +140,50 @@ class _MangayomiSourcesPageState extends State { } setState(() { _busy = false; - _statusError = added == 0; - if (added == 0) { - _status = failures.isEmpty - ? 'ext.none_found_js'.tr() - : 'ext.could_not_install'.tr(args: [failures.first]); - } else { - _status = 'ext.added_sources'.tr(args: ['$added']); - if (skippedDart > 0) { - _status = '$_status ' - '${'ext.skipped_dart'.tr(args: ['$skippedDart'])}'; - } - } + _statusError = error != null; + _status = error != null + ? 'ext.could_not_install'.tr(args: [error]) + : _withDartNote( + 'ext.added_sources'.tr(args: ['$added']), + skippedDart, + ); }); } + static String _withDartNote(String status, int skippedDart) => + skippedDart > 0 + ? '$status ${'ext.skipped_dart'.tr(args: ['$skippedDart'])}' + : status; + + Future _confirm(String message) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.surface, + content: Text(message), + actions: [ + TextButton( + autofocus: true, + onPressed: () => Navigator.of(ctx).pop(false), + child: Text('general.cancel'.tr()), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: Text( + 'ext.remove'.tr(), + style: const TextStyle(color: Colors.redAccent), + ), + ), + ], + ), + ); + return ok == true; + } + Future _remove(String url) async { + if (!await _confirm('ext.remove_repo_confirm'.tr())) return; await _store.removeRepo(url); + getIt().invalidate(); if (!mounted) return; _reloadProviders(); setState(() { @@ -128,6 +192,27 @@ class _MangayomiSourcesPageState extends State { }); } + Future _removeSource(MangayomiSource s) async { + if (!await _confirm('ext.remove_source_confirm'.tr(args: [s.name]))) { + return; + } + await _store.removeSource(s.id); + getIt().invalidate(s.id); + if (!mounted) return; + _reloadProviders(); + setState(() { + _statusError = false; + _status = 'ext.source_removed'.tr(); + }); + } + + Future _setEnabled(MangayomiSource s, bool enabled) async { + await _store.setEnabled(s.id, enabled); + if (!mounted) return; + _reloadProviders(); + setState(() {}); + } + Future _checkUpdates() async { if (_busy) return; setState(() { @@ -136,17 +221,53 @@ class _MangayomiSourcesPageState extends State { _status = 'ext.checking_extension_updates'.tr(); }); try { - final count = await _store.checkUpdates(); - // Extension code is cached per version; a bump invalidates it, but the - // runtime also holds one loaded in memory. - getIt().invalidate(); + final result = await _store.checkUpdates(); + if (!mounted) return; + if (result.added > 0) _reloadProviders(); + final lines = [ + if (result.updates.isNotEmpty) + 'ext.updates_available'.tr(args: ['${result.updates.length}']) + else if (result.failures.isEmpty) + 'ext.all_up_to_date'.tr(), + if (result.added > 0) 'ext.added_sources'.tr(args: ['${result.added}']), + for (final e in result.failures.entries) + 'ext.repo_check_failed'.tr(args: [e.key.split('/').last, e.value]), + ]; + setState(() { + _statusError = result.failures.isNotEmpty; + _status = lines.join('\n'); + }); + } catch (e) { + if (!mounted) return; + setState(() { + _statusError = true; + _status = 'ext.update_check_failed'.tr(args: ['$e']); + }); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + /// Applies pending updates for [ids], or every pending one when null. + Future _applyUpdates([List? ids]) async { + if (_busy) return; + setState(() { + _busy = true; + _statusError = false; + _status = 'ext.installing_extensions'.tr(); + }); + try { + final applied = await _store.applyUpdates(ids); + // Extension code is cached per version, but the runtime also holds the + // compiled instance in memory. + final runtime = getIt(); + for (final id in applied) { + runtime.invalidate(id); + } if (!mounted) return; - if (count > 0) _reloadProviders(); + if (applied.isNotEmpty) _reloadProviders(); setState(() { - _statusError = false; - _status = count == 0 - ? 'ext.all_up_to_date'.tr() - : 'ext.updated_count'.tr(args: ['$count']); + _status = 'ext.updated_count'.tr(args: ['${applied.length}']); }); } catch (e) { if (!mounted) return; @@ -169,7 +290,14 @@ class _MangayomiSourcesPageState extends State { ), ); } - final sources = _sources; + final installed = _installed; + final byType = MangayomiSourceFilter(type: _type).apply(installed); + final sources = MangayomiSourceFilter( + type: _type, + languages: _languages, + ).apply(installed); + final updates = _store.availableUpdates(); + final disabled = _store.disabledIds(); final repos = _repos; return Scaffold( backgroundColor: AppColors.background, @@ -221,7 +349,7 @@ class _MangayomiSourcesPageState extends State { ), const SizedBox(height: 8), if (repos.isEmpty) _empty() else ...repos.map(_repoTile), - if (sources.isNotEmpty) ...[ + if (installed.isNotEmpty) ...[ const SizedBox(height: 24), Row( children: [ @@ -231,12 +359,47 @@ class _MangayomiSourcesPageState extends State { style: Theme.of(context).textTheme.labelSmall?.copyWith( color: AppColors.textHint, letterSpacing: 1)), const Spacer(), - Text('${sources.length}', + Text( + sources.length == installed.length + ? '${installed.length}' + : '${sources.length} / ${installed.length}', style: const TextStyle(color: AppColors.textHint)), ], ), + if (updates.isNotEmpty) ...[ + const SizedBox(height: 8), + _updatesBanner(updates.length), + ], + const SizedBox(height: 4), + MangayomiTypeFilterRow( + padding: EdgeInsets.zero, + selected: _type, + onSelected: (t) => setState(() => _type = t), + ), + MangayomiLanguageFilterRow( + padding: EdgeInsets.zero, + counts: MangayomiSourceFilter.languageCounts(byType), + selected: _languages, + onToggle: (code) => setState( + () => _languages = + MangayomiSourceFilter.toggleLanguage(_languages, code), + ), + ), const SizedBox(height: 8), - ...sources.map(_sourceTile), + if (sources.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text('ext.no_matches'.tr(), + style: const TextStyle(color: AppColors.textHint)), + ), + ) + else + ...sources.map((s) => _sourceTile( + s, + update: updates[s.id], + enabled: !disabled.contains(s.id), + )), ], ], ), @@ -398,15 +561,60 @@ class _MangayomiSourcesPageState extends State { overflow: TextOverflow.ellipsis, style: const TextStyle(color: AppColors.textHint, fontSize: 11)), - trailing: IconButton( - icon: const Icon(Icons.delete_outline, color: Colors.redAccent), - onPressed: _busy ? null : () => _remove(url), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'ext.add_sources'.tr(), + icon: const Icon(Icons.playlist_add_rounded, + color: Colors.white70), + onPressed: _busy ? null : () => _install([url]), + ), + IconButton( + tooltip: 'ext.remove'.tr(), + icon: const Icon(Icons.delete_outline, + color: Colors.redAccent), + onPressed: _busy ? null : () => _remove(url), + ), + ], ), ), ), ); - Widget _sourceTile(MangayomiSource s) => Padding( + Widget _updatesBanner(int count) => Container( + padding: const EdgeInsets.fromLTRB(12, 6, 6, 6), + decoration: BoxDecoration( + color: _accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: _accent.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + const Icon(Icons.system_update_alt_rounded, + size: 16, color: _accent), + const SizedBox(width: 8), + Expanded( + child: Text( + 'ext.updates_available'.tr(args: ['$count']), + style: const TextStyle(color: Colors.white, fontSize: 12.5), + ), + ), + TextButton( + onPressed: _busy ? null : () => _applyUpdates(), + child: Text('ext.update_all'.tr(), + style: const TextStyle(color: _accent)), + ), + ], + ), + ); + + Widget _sourceTile( + MangayomiSource s, { + MangayomiSource? update, + bool enabled = true, + }) => + Padding( padding: const EdgeInsets.only(bottom: 6), child: Container( decoration: BoxDecoration( @@ -416,63 +624,90 @@ class _MangayomiSourcesPageState extends State { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(7), - child: Image.network( - s.iconUrl.isEmpty ? _logo : s.iconUrl, - width: 26, - height: 26, - cacheWidth: 78, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => Container( - width: 26, - height: 26, - color: Colors.white10, - child: const Icon(Icons.public, - size: 14, color: Colors.white54), - ), - ), - ), - const SizedBox(width: 10), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: Text(s.name, + child: Opacity( + opacity: enabled ? 1 : 0.45, + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(7), + child: Image.network( + s.iconUrl.isEmpty ? _logo : s.iconUrl, + width: 26, + height: 26, + cacheWidth: 78, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + width: 26, + height: 26, + color: Colors.white10, + child: const Icon(Icons.public, + size: 14, color: Colors.white54), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text(s.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, fontSize: 13)), + ), + if (s.isNsfw) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 5, vertical: 1), + decoration: BoxDecoration( + color: Colors.redAccent.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: const Text('18+', + style: TextStyle( + color: Colors.redAccent, + fontSize: 9, + fontWeight: FontWeight.w700)), + ), + ], + ], + ), + Text( + [ + s.lang.toUpperCase(), + _typeLabel(s.itemType), + update == null + ? 'v${s.version}' + : 'v${s.version} → v${update.version}', + if (!enabled) 'ext.source_disabled'.tr(), + ].join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Colors.white, fontSize: 13)), - ), - if (s.isNsfw) ...[ - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: Colors.redAccent.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(4), + color: AppColors.textHint, fontSize: 10.5), ), - child: const Text('18+', - style: TextStyle( - color: Colors.redAccent, - fontSize: 9, - fontWeight: FontWeight.w700)), - ), - ], - ], - ), - Text( - '${s.lang.toUpperCase()} · ${_typeLabel(s.itemType)} · v${s.version}', - style: const TextStyle( - color: AppColors.textHint, fontSize: 10.5), - ), - ], + ], + ), + ), + ], + ), ), ), + if (update != null) + TextButton( + onPressed: _busy ? null : () => _applyUpdates([s.id]), + style: TextButton.styleFrom( + foregroundColor: _accent, + visualDensity: VisualDensity.compact, + ), + child: Text('ext.update'.tr()), + ), // The source's own settings — its domain, preferred quality, // what it hides. IconButton( @@ -482,6 +717,29 @@ class _MangayomiSourcesPageState extends State { size: 19, color: Colors.white70), onPressed: () => SourceSettingsSheet.show(context, s), ), + PopupMenuButton<_SourceAction>( + enabled: !_busy, + icon: const Icon(Icons.more_vert_rounded, + size: 19, color: Colors.white70), + color: AppColors.surface, + onSelected: (a) => switch (a) { + _SourceAction.toggle => _setEnabled(s, !enabled), + _SourceAction.remove => _removeSource(s), + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: _SourceAction.toggle, + child: Text(enabled + ? 'ext.disable_source'.tr() + : 'ext.enable_source'.tr()), + ), + PopupMenuItem( + value: _SourceAction.remove, + child: Text('ext.remove_source'.tr(), + style: const TextStyle(color: Colors.redAccent)), + ), + ], + ), ], ), ), @@ -493,3 +751,5 @@ class _MangayomiSourcesPageState extends State { MangayomiItemType.manga => 'ext.type_manga'.tr(), }; } + +enum _SourceAction { toggle, remove } diff --git a/lib/features/extensions/presentation/widgets/source_filter_chips.dart b/lib/features/extensions/presentation/widgets/source_filter_chips.dart new file mode 100644 index 00000000..a58224dc --- /dev/null +++ b/lib/features/extensions/presentation/widgets/source_filter_chips.dart @@ -0,0 +1,175 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/extensions/source_language.dart' as srclang; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/tv/tv.dart'; +import 'package:soplay/features/extensions/domain/entities/mangayomi_source.dart'; + +/// One chip in a [SourceFilterChipRow]. +class SourceFilterOption { + const SourceFilterOption({ + required this.label, + required this.active, + required this.onTap, + this.count, + }); + + final String label; + final bool active; + final VoidCallback onTap; + final int? count; +} + +/// A horizontal row of filter chips, styled like the catalog's type and +/// language rows and reachable with a D-pad on TV. +class SourceFilterChipRow extends StatelessWidget { + const SourceFilterChipRow({ + super.key, + required this.options, + this.padding = const EdgeInsets.symmetric(horizontal: 16), + }); + + final List options; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 44, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: padding, + itemCount: options.length, + separatorBuilder: (_, _) => const SizedBox(width: 6), + itemBuilder: (context, i) => Center(child: _chip(context, options[i])), + ), + ); + } + + Widget _chip(BuildContext context, SourceFilterOption o) { + return TvFocusable( + onPressed: o.onTap, + borderRadius: 17, + ensureVisibleAlignment: 0.5, + child: AnimatedContainer( + duration: Duration( + milliseconds: MediaQuery.disableAnimationsOf(context) ? 0 : 120, + ), + curve: Curves.easeOutCubic, + height: 34, + padding: const EdgeInsets.symmetric(horizontal: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: o.active ? AppColors.primary : AppColors.surface, + borderRadius: BorderRadius.circular(17), + border: Border.all( + color: o.active + ? AppColors.primary.withValues(alpha: 0.55) + : Colors.white.withValues(alpha: 0.07), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + o.label, + style: TextStyle( + color: o.active ? Colors.white : AppColors.textSecondary, + fontSize: 12.5, + fontWeight: o.active ? FontWeight.w700 : FontWeight.w600, + ), + ), + if (o.count != null) ...[ + const SizedBox(width: 5), + Text( + '${o.count}', + style: TextStyle( + color: o.active + ? AppColors.primaryLight.withValues(alpha: 0.75) + : AppColors.textHint, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ), + ), + ); + } +} + +/// All / anime / manga / novel. +class MangayomiTypeFilterRow extends StatelessWidget { + const MangayomiTypeFilterRow({ + super.key, + required this.selected, + required this.onSelected, + this.padding = const EdgeInsets.symmetric(horizontal: 16), + }); + + final MangayomiItemType? selected; + final ValueChanged onSelected; + final EdgeInsetsGeometry padding; + + static const _options = <(MangayomiItemType?, String)>[ + (null, 'catalog.type_all'), + (MangayomiItemType.anime, 'catalog.type_anime'), + (MangayomiItemType.manga, 'catalog.type_manga'), + (MangayomiItemType.novel, 'catalog.type_novel'), + ]; + + @override + Widget build(BuildContext context) => SourceFilterChipRow( + padding: padding, + options: [ + for (final (type, key) in _options) + SourceFilterOption( + label: key.tr(), + active: type == selected, + onTap: () => onSelected(type), + ), + ], + ); +} + +/// Language chips built from the languages the listed sources carry. +class MangayomiLanguageFilterRow extends StatelessWidget { + const MangayomiLanguageFilterRow({ + super.key, + required this.counts, + required this.selected, + required this.onToggle, + this.padding = const EdgeInsets.symmetric(horizontal: 16), + }); + + final Map counts; + final List selected; + final ValueChanged onToggle; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + final ordered = srclang + .orderedLanguages(counts.keys, selected) + .where((c) => c != srclang.kAllLanguages); + return SourceFilterChipRow( + padding: padding, + options: [ + SourceFilterOption( + label: srclang.labelFor(srclang.kAllLanguages), + active: selected.isEmpty, + onTap: () => onToggle(srclang.kAllLanguages), + ), + for (final code in ordered) + SourceFilterOption( + label: srclang.labelFor(code), + count: counts[code], + active: selected.any((c) => srclang.normalizeLang(c) == code), + onTap: () => onToggle(code), + ), + ], + ); + } +} diff --git a/test/features/extensions/mangayomi_per_source_test.dart b/test/features/extensions/mangayomi_per_source_test.dart new file mode 100644 index 00000000..8d906a85 --- /dev/null +++ b/test/features/extensions/mangayomi_per_source_test.dart @@ -0,0 +1,362 @@ +// Per-source install, removal, updates and enable/disable, against temporary +// Hive and a loopback repository fixture. +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/js/dart_fetch.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/features/extensions/data/mangayomi_bridge.dart'; +import 'package:soplay/features/extensions/data/mangayomi_repo_store.dart'; +import 'package:soplay/features/extensions/data/mangayomi_runtime.dart'; +import 'package:soplay/features/extensions/data/source_installer.dart'; +import 'package:soplay/features/extensions/domain/entities/catalog_source_entity.dart'; +import 'package:soplay/features/extensions/domain/entities/extension_repo_entity.dart'; +import 'package:soplay/features/extensions/domain/entities/mangayomi_source.dart'; +import 'package:soplay/features/extensions/domain/mangayomi_source_filter.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + HttpOverrides.global = null; // Test-only loopback fixture transport. + late Directory dir; + late HttpServer server; + late String base; + late MangayomiRepoStore store; + final responses = {}; + + Map source( + String id, { + String version = '1.0.0', + String lang = 'en', + int type = 0, + }) => { + 'id': id, + 'name': id, + 'lang': lang, + 'baseUrl': 'https://fixture.example', + 'sourceCodeUrl': '$base/$id.js', + 'version': version, + 'itemType': type, + 'sourceCodeLanguage': 1, + }; + + Set ids() => store.sources().map((s) => s.id).toSet(); + + setUp(() async { + dir = await Directory.systemTemp.createTemp('sozo-mangayomi-per-source-'); + Hive.init(dir.path); + await Hive.openBox(AppConstants.authBox); + await Hive.openBox(AppConstants.settingsBox); + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + base = 'http://127.0.0.1:${server.port}'; + responses.clear(); + server.listen((request) async { + final result = responses[request.uri.path]; + if (result == null) { + request.response.statusCode = 404; + } else { + request.response.write(result is String ? result : jsonEncode(result)); + } + await request.response.close(); + }); + store = MangayomiRepoStore(dio: Dio()); + }); + + tearDown(() async { + await server.close(force: true); + if (getIt.isRegistered()) { + await getIt.unregister(); + } + await Hive.close(); + await dir.delete(recursive: true); + }); + + group('per-source removal', () { + test('a removed source stays removed across refreshes', () async { + responses['/repo.json'] = [source('a'), source('b'), source('c')]; + await store.addRepo('$base/repo.json'); + await store.savePrefs('b', {'domain': 'x'}); + await Hive.box( + AppConstants.settingsBox, + ).put('mangayomi_code:b', {'identity': 'x', 'code': 'js'}); + + await store.removeSource('my:b'); + + expect(ids(), {'a', 'c'}); + expect(store.prefs('b'), isEmpty); + expect(Hive.box(AppConstants.settingsBox).get('mangayomi_code:b'), isNull); + expect(store.excludedIds('$base/repo.json'), {'b'}); + + responses['/repo.json'] = [ + source('a'), + source('b', version: '2.0.0'), + source('c'), + source('d'), + ]; + final check = await store.checkUpdates(); + expect(ids(), {'a', 'c', 'd'}); + expect(check.added, 1); + expect(check.updates, isEmpty); + }); + + test('picking a removed source again lifts its exclusion', () async { + responses['/repo.json'] = [source('a'), source('b')]; + await store.addRepo('$base/repo.json'); + await store.removeSource('b'); + final index = await store.fetchIndex('$base/repo.json'); + await store.installSources(index, {'b'}); + expect(ids(), {'a', 'b'}); + expect(store.excludedIds('$base/repo.json'), isEmpty); + await store.checkUpdates(); + expect(ids(), {'a', 'b'}); + }); + + test('removing a repo clears its code, prefs and exclusions', () async { + responses['/repo.json'] = [source('a'), source('b')]; + await store.addRepo('$base/repo.json'); + await store.removeSource('b'); + await store.savePrefs('a', {'k': 1}); + await store.setEnabled('a', false); + await Hive.box( + AppConstants.settingsBox, + ).put('mangayomi_code:a', {'identity': 'x', 'code': 'js'}); + + await store.removeRepo('$base/repo.json'); + + expect(store.sources(), isEmpty); + expect(store.repos(), isEmpty); + expect(store.prefs('a'), isEmpty); + expect(Hive.box(AppConstants.settingsBox).get('mangayomi_code:a'), isNull); + expect(store.disabledIds(), isEmpty); + expect(store.excludedIds('$base/repo.json'), isEmpty); + }); + }); + + group('choosing sources', () { + test('only the chosen sources are installed, the rest excluded', () async { + responses['/repo.json'] = [source('a'), source('b'), source('c')]; + final index = await store.fetchIndex('$base/repo.json'); + final res = await store.installSources(index, {'b'}); + expect(res['added'], 1); + expect(ids(), {'b'}); + expect(store.repos(), ['$base/repo.json']); + + await store.checkUpdates(); + expect(ids(), {'b'}); + + await store.installSources(index, {'a'}); + expect(ids(), {'a', 'b'}); + expect(store.excludedIds('$base/repo.json'), {'c'}); + }); + + test('addRepo still installs everything, excluded or not', () async { + responses['/repo.json'] = [source('a'), source('b')]; + final index = await store.fetchIndex('$base/repo.json'); + await store.installSources(index, {'a'}); + await store.addRepo('$base/repo.json'); + expect(ids(), {'a', 'b'}); + expect(store.excludedIds('$base/repo.json'), isEmpty); + }); + + test('a catalog row installs only its own source', () async { + responses['/repo.json'] = [source('a'), source('b'), source('c')]; + final count = await installCatalogSource( + CatalogSourceEntity( + id: 'db', + externalId: 'b', + kind: ExtensionRepoKind.mangayomi, + name: 'b', + repoUrl: '$base/repo.json', + ), + store, + ); + expect(count, 1); + expect(ids(), {'b'}); + }); + + test('a catalog row missing from its index is an error', () async { + responses['/repo.json'] = [source('a')]; + await expectLater( + installCatalogSource( + CatalogSourceEntity( + id: 'db', + externalId: 'gone', + kind: ExtensionRepoKind.mangayomi, + name: 'gone', + repoUrl: '$base/repo.json', + ), + store, + ), + throwsStateError, + ); + expect(store.sources(), isEmpty); + }); + + test('LNReader catalog ids match their prefixed install id', () { + expect(mangayomiCatalogMatch('ln.royalroad', 'royalroad'), isTrue); + expect(mangayomiCatalogMatch('royalroad', 'royalroad'), isTrue); + expect(mangayomiCatalogMatch('ln.royalroad', 'other'), isFalse); + }); + }); + + group('updates', () { + test('checkUpdates reports newer builds without applying them', () async { + responses['/repo.json'] = [source('a'), source('b')]; + await store.addRepo('$base/repo.json'); + await Hive.box(AppConstants.settingsBox).put('mangayomi_code:a', { + 'identity': store.sourceById('a')!.runtimeIdentity, + 'code': 'old', + }); + responses['/repo.json'] = [source('a', version: '1.1.0'), source('b')]; + + final check = await store.checkUpdates(); + + expect(check.updates.keys, ['a']); + expect(check.updates['a']!.version, '1.1.0'); + expect(store.sourceById('a')!.version, '1.0.0'); + expect( + Hive.box(AppConstants.settingsBox).get('mangayomi_code:a'), + isNotNull, + ); + expect(store.availableUpdates().keys, ['a']); + + final applied = await store.applyUpdates(['a']); + expect(applied, ['a']); + expect(store.sourceById('a')!.version, '1.1.0'); + expect(store.availableUpdates(), isEmpty); + expect(Hive.box(AppConstants.settingsBox).get('mangayomi_code:a'), isNull); + }); + + test('update all applies every pending update', () async { + responses['/repo.json'] = [source('a'), source('b')]; + await store.addRepo('$base/repo.json'); + responses['/repo.json'] = [ + source('a', version: '2.0.0'), + source('b', version: '2.0.0'), + ]; + await store.checkUpdates(); + final applied = await store.applyUpdates(); + expect(applied.toSet(), {'a', 'b'}); + expect(store.sources().every((s) => s.version == '2.0.0'), isTrue); + }); + + test('a failed repo is reported, the others still checked', () async { + responses['/good.json'] = [source('a')]; + responses['/bad.json'] = [source('b')]; + await store.addRepo('$base/good.json'); + await store.addRepo('$base/bad.json'); + responses.remove('/bad.json'); + responses['/good.json'] = [source('a', version: '1.2.0')]; + + final check = await store.checkUpdates(); + + expect(check.failures.keys, ['$base/bad.json']); + expect(check.updates.keys, ['a']); + expect(store.sourceById('b'), isNotNull); + }); + + test('removing a source drops its pending update', () async { + responses['/repo.json'] = [source('a')]; + await store.addRepo('$base/repo.json'); + responses['/repo.json'] = [source('a', version: '2.0.0')]; + await store.checkUpdates(); + await store.removeSource('a'); + expect(store.availableUpdates(), isEmpty); + }); + }); + + group('enable and disable', () { + test('a disabled source is hidden from listProviders', () async { + getIt.registerSingleton(HiveService()); + responses['/repo.json'] = [source('a'), source('b')]; + await store.addRepo('$base/repo.json'); + final bridge = MangayomiBridge( + runtime: MangayomiRuntime(store: store, dartFetch: DartFetch.create()), + store: store, + ); + List listed() => bridge + .listProviders() + .map((p) => p['id'] as String) + .toList() + ..sort(); + + expect(listed(), ['my:a', 'my:b']); + await store.setEnabled('a', false); + expect(store.isEnabled('a'), isFalse); + expect(listed(), ['my:b']); + expect(store.sourceById('a'), isNotNull); + expect(ids(), {'a', 'b'}); + + await store.setEnabled('a', true); + expect(listed(), ['my:a', 'my:b']); + }); + + test('disabled state survives a refresh', () async { + responses['/repo.json'] = [source('a')]; + await store.addRepo('$base/repo.json'); + await store.setEnabled('a', false); + await store.checkUpdates(); + expect(store.enabledSources(), isEmpty); + expect(ids(), {'a'}); + }); + }); + + group('filter', () { + MangayomiSource s(String name, String lang, MangayomiItemType type) => + MangayomiSource( + id: '$name-$lang', + name: name, + baseUrl: 'https://$name.example', + lang: lang, + sourceCodeUrl: 'x', + version: '1', + itemType: type, + ); + + final all = [ + s('Zeta', 'fr', MangayomiItemType.anime), + s('Alpha', 'en', MangayomiItemType.manga), + s('Beta', 'all', MangayomiItemType.manga), + s('alpha', 'fr', MangayomiItemType.novel), + ]; + + test('sorts by name then language', () { + expect( + const MangayomiSourceFilter().apply(all).map((e) => e.id), + ['Alpha-en', 'alpha-fr', 'Beta-all', 'Zeta-fr'], + ); + }); + + test('type, language and query narrow the list', () { + expect( + const MangayomiSourceFilter( + type: MangayomiItemType.manga, + ).apply(all).map((e) => e.id), + ['Alpha-en', 'Beta-all'], + ); + expect( + const MangayomiSourceFilter(languages: ['fr']).apply(all).map((e) => e.id), + ['alpha-fr', 'Beta-all', 'Zeta-fr'], + ); + expect( + const MangayomiSourceFilter(query: 'zet').apply(all).map((e) => e.id), + ['Zeta-fr'], + ); + }); + + test('language counts and toggling', () { + expect(MangayomiSourceFilter.languageCounts(all), { + 'fr': 2, + 'en': 1, + 'all': 1, + }); + expect(MangayomiSourceFilter.toggleLanguage(const [], 'fr'), ['fr']); + expect(MangayomiSourceFilter.toggleLanguage(const ['fr'], 'fr'), isEmpty); + expect(MangayomiSourceFilter.toggleLanguage(const ['fr'], 'all'), isEmpty); + }); + }); +} From deeea5f9aa3c98e44ac86b53a7588b7da23f8683 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:33:51 +0500 Subject: [PATCH 03/16] Show Auto Resolve progress with stop and a cap Try another source > Auto now says which source it is checking and how far through it is, can be stopped, gives up after 20 candidates, and lists what it skipped and why. The walk lives in AutoResolveWalk so it can be unit tested. In the player, an automatic ladder step uses the server switch overlay (from -> to, attempt x of y against RetryPolicy) with a Stop that leaves the player on its error screen. Stop is reachable by D-pad on TV. --- assets/translations/ar.json | 12 + assets/translations/ckb.json | 12 + assets/translations/de.json | 12 + assets/translations/en.json | 12 + assets/translations/es.json | 12 + assets/translations/fr.json | 12 + assets/translations/hi.json | 12 + assets/translations/id.json | 12 + assets/translations/ku.json | 12 + assets/translations/nl.json | 12 + assets/translations/pt.json | 12 + assets/translations/ru.json | 12 + assets/translations/tr.json | 12 + assets/translations/uz.json | 12 + assets/translations/vi.json | 12 + assets/translations/yue.json | 12 + .../services/alternate_source_service.dart | 56 +++- .../domain/services/auto_resolve_walk.dart | 173 +++++++++++ .../pages/player_page.controls.dart | 2 + .../presentation/pages/player_page.dart | 2 + .../presentation/pages/player_page.media.dart | 63 ++-- .../pages/player_page.models.dart | 31 +- .../presentation/pages/player_page.tv.dart | 7 + .../pages/player_page.widgets.dart | 58 +++- .../widgets/alternate_source_sheet.dart | 287 ++++++++++++------ .../detail/alternate_source_sheet_test.dart | 69 +++++ .../services/auto_resolve_walk_test.dart | 180 +++++++++++ test/features/detail/server_switch_test.dart | 50 +++ 28 files changed, 1043 insertions(+), 127 deletions(-) create mode 100644 lib/features/detail/domain/services/auto_resolve_walk.dart create mode 100644 test/features/detail/domain/services/auto_resolve_walk_test.dart create mode 100644 test/features/detail/server_switch_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 6e6bbb79..b3e9da20 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -533,6 +533,18 @@ "alt_auto_hint": "يشغّل من أول مصدر يتوفر فيه هذا", "alt_auto_trying": "جارٍ تجربة {}…", "alt_auto_missed": "لم يتمكن أي مصدر مطابق من تشغيله. اختر واحدًا أدناه أو ابحث في مصدر.", + "alt_auto_checking": "جارٍ التحقق من {} · {} من {}", + "alt_auto_waiting": "بانتظار مصادر أخرى…", + "alt_auto_stop": "إيقاف", + "alt_auto_stopped": "تم الإيقاف.", + "alt_auto_capped": "تم التحقق من {} مصادر ولم يتمكن أي منها من تشغيل هذا. اختر واحدًا أدناه أو حاول مجددًا.", + "alt_auto_tried": "تم التحقق", + "alt_skip_no_match": "لا يوجد تطابق قريب", + "alt_skip_no_episode": "الحلقة غير موجودة", + "alt_skip_error": "تعذر التحميل", + "auto_switch_attempt": "المحاولة {} من {}", + "auto_switch_stop": "إيقاف", + "auto_switch_stopped": "توقف تبديل الخوادم. حاول مجددًا أو اختر خادمًا أو مصدرًا آخر.", "alt_wrong_title": "العنوان خاطئ؟", "alt_wrong_title_hint": "ابحث بنفسك في {} واختر العنوان الصحيح", "alt_corrected": "يُستخدم الآن «{}»", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 6cef5071..a7e0a220 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -533,6 +533,18 @@ "alt_auto_hint": "لە یەکەم سەرچاوە کە ئەمەی هەیە لێدەدات", "alt_auto_trying": "تاقیکردنەوەی {}…", "alt_auto_missed": "هیچ سەرچاوەیەکی گونجاو نەیتوانی لێیبدات. یەکێک لە خوارەوە هەڵبژێرە یان لە سەرچاوەیەک بگەڕێ.", + "alt_auto_checking": "پشکنینی {} · {} لە {}", + "alt_auto_waiting": "چاوەڕوانی سەرچاوەی زیاتر…", + "alt_auto_stop": "وەستاندن", + "alt_auto_stopped": "وەستێنرا.", + "alt_auto_capped": "{} سەرچاوە پشکنران و هیچیان نەیتوانی ئەمە لێبدات. یەکێک لە خوارەوە هەڵبژێرە یان دووبارە هەوڵ بدەرەوە.", + "alt_auto_tried": "پشکنراوەکان", + "alt_skip_no_match": "هاوتای نزیک نییە", + "alt_skip_no_episode": "ئەڵقە نەدۆزرایەوە", + "alt_skip_error": "بار نەکرا", + "auto_switch_attempt": "هەوڵی {} لە {}", + "auto_switch_stop": "وەستاندن", + "auto_switch_stopped": "گۆڕینی ڕاژەکار وەستێنرا. دووبارە هەوڵ بدەرەوە، یان ڕاژەکار یان سەرچاوەیەکی تر هەڵبژێرە.", "alt_wrong_title": "ناونیشانی هەڵە؟", "alt_wrong_title_hint": "خۆت لە {} بگەڕێ و ناونیشانی دروست هەڵبژێرە", "alt_corrected": "ئێستا “{}” بەکاردێت", diff --git a/assets/translations/de.json b/assets/translations/de.json index 0970f03c..487864a5 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -533,6 +533,18 @@ "alt_auto_hint": "Spielt von der ersten Quelle ab, die es hat", "alt_auto_trying": "{} wird versucht…", "alt_auto_missed": "Keine passende Quelle konnte es abspielen. Wähle unten eine oder durchsuche eine Quelle.", + "alt_auto_checking": "Prüfe {} · {} von {}", + "alt_auto_waiting": "Warte auf weitere Quellen…", + "alt_auto_stop": "Stopp", + "alt_auto_stopped": "Gestoppt.", + "alt_auto_capped": "{} Quellen geprüft, keine konnte das abspielen. Wähle unten eine aus oder versuche es erneut.", + "alt_auto_tried": "Geprüft", + "alt_skip_no_match": "keine genaue Übereinstimmung", + "alt_skip_no_episode": "Folge nicht gefunden", + "alt_skip_error": "konnte nicht geladen werden", + "auto_switch_attempt": "Versuch {} von {}", + "auto_switch_stop": "Stopp", + "auto_switch_stopped": "Serverwechsel gestoppt. Versuche es erneut oder wähle einen anderen Server oder eine andere Quelle.", "alt_wrong_title": "Falscher Titel?", "alt_wrong_title_hint": "Suche selbst in {} und wähle den richtigen Titel", "alt_corrected": "Verwendet jetzt „{}“", diff --git a/assets/translations/en.json b/assets/translations/en.json index 8fbca5aa..b08e5ee3 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -533,6 +533,18 @@ "alt_auto_hint": "Plays from the first source that has this", "alt_auto_trying": "Trying {}…", "alt_auto_missed": "None of the matches could play this. Pick one below or search a source.", + "alt_auto_checking": "Checking {} · {} of {}", + "alt_auto_waiting": "Waiting for more sources…", + "alt_auto_stop": "Stop", + "alt_auto_stopped": "Stopped.", + "alt_auto_capped": "Checked {} sources and none could play this. Pick one below or try again.", + "alt_auto_tried": "Checked", + "alt_skip_no_match": "not a close match", + "alt_skip_no_episode": "episode not found", + "alt_skip_error": "could not load", + "auto_switch_attempt": "Attempt {} of {}", + "auto_switch_stop": "Stop", + "auto_switch_stopped": "Stopped switching servers. Try again, or pick another server or source.", "alt_wrong_title": "Wrong title?", "alt_wrong_title_hint": "Search {} yourself and pick the right title", "alt_corrected": "Now using “{}”", diff --git a/assets/translations/es.json b/assets/translations/es.json index 319ddb48..f8f85a5f 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -533,6 +533,18 @@ "alt_auto_hint": "Reproduce desde la primera fuente que lo tenga", "alt_auto_trying": "Probando {}…", "alt_auto_missed": "Ninguna fuente coincidente pudo reproducirlo. Elige una abajo o busca en una fuente.", + "alt_auto_checking": "Comprobando {} · {} de {}", + "alt_auto_waiting": "Esperando más fuentes…", + "alt_auto_stop": "Detener", + "alt_auto_stopped": "Detenido.", + "alt_auto_capped": "Se comprobaron {} fuentes y ninguna pudo reproducir esto. Elige una abajo o vuelve a intentarlo.", + "alt_auto_tried": "Comprobadas", + "alt_skip_no_match": "sin coincidencia cercana", + "alt_skip_no_episode": "episodio no encontrado", + "alt_skip_error": "no se pudo cargar", + "auto_switch_attempt": "Intento {} de {}", + "auto_switch_stop": "Detener", + "auto_switch_stopped": "Se detuvo el cambio de servidor. Vuelve a intentarlo o elige otro servidor o fuente.", "alt_wrong_title": "¿Título incorrecto?", "alt_wrong_title_hint": "Busca tú mismo en {} y elige el título correcto", "alt_corrected": "Ahora se usa «{}»", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 599703a9..4f10856b 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -533,6 +533,18 @@ "alt_auto_hint": "Lit depuis la première source qui l’a", "alt_auto_trying": "Essai de {}…", "alt_auto_missed": "Aucune source correspondante n’a pu le lire. Choisissez-en une ci-dessous ou cherchez dans une source.", + "alt_auto_checking": "Vérification de {} · {} sur {}", + "alt_auto_waiting": "En attente d’autres sources…", + "alt_auto_stop": "Arrêter", + "alt_auto_stopped": "Arrêté.", + "alt_auto_capped": "{} sources vérifiées, aucune n’a pu lire ceci. Choisissez-en une ci-dessous ou réessayez.", + "alt_auto_tried": "Vérifiées", + "alt_skip_no_match": "pas de correspondance proche", + "alt_skip_no_episode": "épisode introuvable", + "alt_skip_error": "chargement impossible", + "auto_switch_attempt": "Tentative {} sur {}", + "auto_switch_stop": "Arrêter", + "auto_switch_stopped": "Changement de serveur arrêté. Réessayez, ou choisissez un autre serveur ou une autre source.", "alt_wrong_title": "Mauvais titre ?", "alt_wrong_title_hint": "Cherchez vous-même dans {} et choisissez le bon titre", "alt_corrected": "Utilise maintenant « {} »", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 12d0cb70..0c5bd82f 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -533,6 +533,18 @@ "alt_auto_hint": "जिस सोर्स में यह सबसे पहले मिले, वहीं से चलाता है", "alt_auto_trying": "{} आज़माया जा रहा है…", "alt_auto_missed": "कोई भी मैच इसे नहीं चला पाया। नीचे से कोई एक चुनें या किसी सोर्स में खोजें।", + "alt_auto_checking": "{} जाँचा जा रहा है · {} / {}", + "alt_auto_waiting": "और स्रोतों की प्रतीक्षा…", + "alt_auto_stop": "रोकें", + "alt_auto_stopped": "रोक दिया गया।", + "alt_auto_capped": "{} स्रोत जाँचे गए, कोई भी इसे चला नहीं सका। नीचे से एक चुनें या फिर से कोशिश करें।", + "alt_auto_tried": "जाँचे गए", + "alt_skip_no_match": "करीबी मिलान नहीं", + "alt_skip_no_episode": "एपिसोड नहीं मिला", + "alt_skip_error": "लोड नहीं हो सका", + "auto_switch_attempt": "प्रयास {} / {}", + "auto_switch_stop": "रोकें", + "auto_switch_stopped": "सर्वर बदलना रोक दिया गया। फिर से कोशिश करें, या कोई दूसरा सर्वर या स्रोत चुनें।", "alt_wrong_title": "ग़लत टाइटल?", "alt_wrong_title_hint": "{} में ख़ुद खोजें और सही टाइटल चुनें", "alt_corrected": "अब “{}” इस्तेमाल हो रहा है", diff --git a/assets/translations/id.json b/assets/translations/id.json index 28442646..45cbcb53 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -533,6 +533,18 @@ "alt_auto_hint": "Memutar dari sumber pertama yang memilikinya", "alt_auto_trying": "Mencoba {}…", "alt_auto_missed": "Tidak ada sumber yang cocok bisa memutarnya. Pilih salah satu di bawah atau cari di sumber.", + "alt_auto_checking": "Memeriksa {} · {} dari {}", + "alt_auto_waiting": "Menunggu sumber lain…", + "alt_auto_stop": "Berhenti", + "alt_auto_stopped": "Dihentikan.", + "alt_auto_capped": "{} sumber diperiksa dan tidak ada yang bisa memutar ini. Pilih salah satu di bawah atau coba lagi.", + "alt_auto_tried": "Sudah diperiksa", + "alt_skip_no_match": "tidak cukup cocok", + "alt_skip_no_episode": "episode tidak ditemukan", + "alt_skip_error": "gagal dimuat", + "auto_switch_attempt": "Percobaan {} dari {}", + "auto_switch_stop": "Berhenti", + "auto_switch_stopped": "Pergantian server dihentikan. Coba lagi, atau pilih server atau sumber lain.", "alt_wrong_title": "Judul salah?", "alt_wrong_title_hint": "Cari sendiri di {} dan pilih judul yang benar", "alt_corrected": "Sekarang memakai “{}”", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index e876ed5b..93aaeef5 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -533,6 +533,18 @@ "alt_auto_hint": "Ji çavkaniya yekem a ku ev heye dilîze", "alt_auto_trying": "{} tê ceribandin…", "alt_auto_missed": "Tu çavkaniyek lihevhatî nikarî wê bilîze. Yekê li jêr hilbijêre an di çavkaniyekê de bigere.", + "alt_auto_checking": "{} tê kontrolkirin · {} ji {}", + "alt_auto_waiting": "Li benda çavkaniyên din…", + "alt_auto_stop": "Rawestîne", + "alt_auto_stopped": "Hate rawestandin.", + "alt_auto_capped": "{} çavkanî hatin kontrolkirin û yek jî nikarîbû vê lê bide. Yekê ji jêr hilbijêre an jî dîsa biceribîne.", + "alt_auto_tried": "Kontrolkirî", + "alt_skip_no_match": "lihevhatineke nêzîk nîne", + "alt_skip_no_episode": "beş nehate dîtin", + "alt_skip_error": "nehate barkirin", + "auto_switch_attempt": "Hewl {} ji {}", + "auto_switch_stop": "Rawestîne", + "auto_switch_stopped": "Guhertina serveran hate rawestandin. Dîsa biceribîne, an serverek an çavkaniyek din hilbijêre.", "alt_wrong_title": "Sernav şaş e?", "alt_wrong_title_hint": "Bi xwe li {} bigere û sernavê rast hilbijêre", "alt_corrected": "Niha “{}” tê bikaranîn", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 8ca39779..cc77cd23 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -533,6 +533,18 @@ "alt_auto_hint": "Speelt af van de eerste bron die het heeft", "alt_auto_trying": "{} proberen…", "alt_auto_missed": "Geen passende bron kon het afspelen. Kies er hieronder een of doorzoek een bron.", + "alt_auto_checking": "{} controleren · {} van {}", + "alt_auto_waiting": "Wachten op meer bronnen…", + "alt_auto_stop": "Stoppen", + "alt_auto_stopped": "Gestopt.", + "alt_auto_capped": "{} bronnen gecontroleerd, geen enkele kon dit afspelen. Kies er hieronder een of probeer het opnieuw.", + "alt_auto_tried": "Gecontroleerd", + "alt_skip_no_match": "geen goede overeenkomst", + "alt_skip_no_episode": "aflevering niet gevonden", + "alt_skip_error": "kon niet laden", + "auto_switch_attempt": "Poging {} van {}", + "auto_switch_stop": "Stoppen", + "auto_switch_stopped": "Wisselen van server gestopt. Probeer het opnieuw, of kies een andere server of bron.", "alt_wrong_title": "Verkeerde titel?", "alt_wrong_title_hint": "Zoek zelf in {} en kies de juiste titel", "alt_corrected": "Gebruikt nu “{}”", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index f79b5482..58bad377 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -533,6 +533,18 @@ "alt_auto_hint": "Reproduz da primeira fonte que o tiver", "alt_auto_trying": "A tentar {}…", "alt_auto_missed": "Nenhuma fonte correspondente conseguiu reproduzi-lo. Escolha uma abaixo ou pesquise numa fonte.", + "alt_auto_checking": "Verificando {} · {} de {}", + "alt_auto_waiting": "Aguardando mais fontes…", + "alt_auto_stop": "Parar", + "alt_auto_stopped": "Parado.", + "alt_auto_capped": "{} fontes verificadas e nenhuma conseguiu reproduzir isto. Escolha uma abaixo ou tente novamente.", + "alt_auto_tried": "Verificadas", + "alt_skip_no_match": "sem correspondência próxima", + "alt_skip_no_episode": "episódio não encontrado", + "alt_skip_error": "não foi possível carregar", + "auto_switch_attempt": "Tentativa {} de {}", + "auto_switch_stop": "Parar", + "auto_switch_stopped": "A troca de servidor foi interrompida. Tente novamente ou escolha outro servidor ou fonte.", "alt_wrong_title": "Título errado?", "alt_wrong_title_hint": "Procure você mesmo em {} e escolha o título certo", "alt_corrected": "Agora a usar «{}»", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index c85aef09..c07582ea 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -533,6 +533,18 @@ "alt_auto_hint": "Включит из первого источника, где это есть", "alt_auto_trying": "Пробуем {}…", "alt_auto_missed": "Ни один подходящий источник не смог это воспроизвести. Выберите ниже или поищите в источнике.", + "alt_auto_checking": "Проверяем {} · {} из {}", + "alt_auto_waiting": "Ждём другие источники…", + "alt_auto_stop": "Стоп", + "alt_auto_stopped": "Остановлено.", + "alt_auto_capped": "Проверено источников: {}, ни один не смог это воспроизвести. Выберите один ниже или попробуйте снова.", + "alt_auto_tried": "Проверено", + "alt_skip_no_match": "нет точного совпадения", + "alt_skip_no_episode": "серия не найдена", + "alt_skip_error": "не удалось загрузить", + "auto_switch_attempt": "Попытка {} из {}", + "auto_switch_stop": "Стоп", + "auto_switch_stopped": "Переключение серверов остановлено. Попробуйте снова или выберите другой сервер или источник.", "alt_wrong_title": "Не то название?", "alt_wrong_title_hint": "Найдите сами в {} и выберите нужное название", "alt_corrected": "Теперь используется «{}»", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index f22f299e..66eeb96a 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -533,6 +533,18 @@ "alt_auto_hint": "Bunu bulunduran ilk kaynaktan oynatır", "alt_auto_trying": "{} deneniyor…", "alt_auto_missed": "Eşleşen kaynakların hiçbiri bunu oynatamadı. Aşağıdan birini seçin veya bir kaynakta arayın.", + "alt_auto_checking": "{} kontrol ediliyor · {} / {}", + "alt_auto_waiting": "Başka kaynaklar bekleniyor…", + "alt_auto_stop": "Durdur", + "alt_auto_stopped": "Durduruldu.", + "alt_auto_capped": "{} kaynak kontrol edildi, hiçbiri bunu oynatamadı. Aşağıdan birini seçin ya da tekrar deneyin.", + "alt_auto_tried": "Kontrol edilenler", + "alt_skip_no_match": "yakın eşleşme yok", + "alt_skip_no_episode": "bölüm bulunamadı", + "alt_skip_error": "yüklenemedi", + "auto_switch_attempt": "Deneme {} / {}", + "auto_switch_stop": "Durdur", + "auto_switch_stopped": "Sunucu değiştirme durduruldu. Tekrar deneyin ya da başka bir sunucu veya kaynak seçin.", "alt_wrong_title": "Yanlış başlık mı?", "alt_wrong_title_hint": "{} içinde kendiniz arayıp doğru başlığı seçin", "alt_corrected": "Artık “{}” kullanılıyor", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index a0ec0494..c41a6705 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -533,6 +533,18 @@ "alt_auto_hint": "Bu bor birinchi manbadan qo‘yadi", "alt_auto_trying": "{} sinab ko‘rilmoqda…", "alt_auto_missed": "Mos manbalarning hech biri buni qo‘ya olmadi. Pastdan birini tanlang yoki manbadan qidiring.", + "alt_auto_checking": "{} tekshirilmoqda · {} / {}", + "alt_auto_waiting": "Boshqa manbalar kutilmoqda…", + "alt_auto_stop": "To‘xtatish", + "alt_auto_stopped": "To‘xtatildi.", + "alt_auto_capped": "{} ta manba tekshirildi, hech biri buni qo‘ya olmadi. Pastdan birini tanlang yoki qayta urinib ko‘ring.", + "alt_auto_tried": "Tekshirilganlar", + "alt_skip_no_match": "mos kelmadi", + "alt_skip_no_episode": "qism topilmadi", + "alt_skip_error": "yuklab bo‘lmadi", + "auto_switch_attempt": "Urinish {} / {}", + "auto_switch_stop": "To‘xtatish", + "auto_switch_stopped": "Server almashtirish to‘xtatildi. Qayta urinib ko‘ring yoki boshqa server yoki manba tanlang.", "alt_wrong_title": "Sarlavha noto‘g‘rimi?", "alt_wrong_title_hint": "{} ichidan o‘zingiz qidirib, to‘g‘ri sarlavhani tanlang", "alt_corrected": "Endi “{}” ishlatilyapti", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 5a8f5b47..de16d406 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -533,6 +533,18 @@ "alt_auto_hint": "Phát từ nguồn đầu tiên có phim này", "alt_auto_trying": "Đang thử {}…", "alt_auto_missed": "Không kết quả nào phát được. Hãy chọn bên dưới hoặc tìm trong một nguồn.", + "alt_auto_checking": "Đang kiểm tra {} · {} / {}", + "alt_auto_waiting": "Đang chờ thêm nguồn…", + "alt_auto_stop": "Dừng", + "alt_auto_stopped": "Đã dừng.", + "alt_auto_capped": "Đã kiểm tra {} nguồn nhưng không nguồn nào phát được. Hãy chọn một nguồn bên dưới hoặc thử lại.", + "alt_auto_tried": "Đã kiểm tra", + "alt_skip_no_match": "không khớp đủ gần", + "alt_skip_no_episode": "không tìm thấy tập", + "alt_skip_error": "không tải được", + "auto_switch_attempt": "Lần thử {} / {}", + "auto_switch_stop": "Dừng", + "auto_switch_stopped": "Đã dừng chuyển máy chủ. Hãy thử lại, hoặc chọn máy chủ hay nguồn khác.", "alt_wrong_title": "Sai phim?", "alt_wrong_title_hint": "Tự tìm trong {} và chọn đúng phim", "alt_corrected": "Đang dùng “{}”", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 91e3e54a..472d1d8a 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -533,6 +533,18 @@ "alt_auto_hint": "由第一個有呢套嘅來源播放", "alt_auto_trying": "試緊 {}…", "alt_auto_missed": "冇一個相符嘅來源播到。喺下面揀一個或者喺來源度搜尋。", + "alt_auto_checking": "檢查緊 {} · 第 {} 個,共 {} 個", + "alt_auto_waiting": "等緊更多來源…", + "alt_auto_stop": "停止", + "alt_auto_stopped": "已停止。", + "alt_auto_capped": "檢查咗 {} 個來源,冇一個播到。喺下面揀一個或者再試。", + "alt_auto_tried": "已檢查", + "alt_skip_no_match": "唔夠相似", + "alt_skip_no_episode": "搵唔到呢集", + "alt_skip_error": "載入唔到", + "auto_switch_attempt": "第 {} 次嘗試,共 {} 次", + "auto_switch_stop": "停止", + "auto_switch_stopped": "已停止轉換伺服器。再試一次,或者揀第二個伺服器或來源。", "alt_wrong_title": "揀錯標題?", "alt_wrong_title_hint": "自己喺 {} 搵,再揀返啱嘅標題", "alt_corrected": "而家用緊「{}」", diff --git a/lib/features/detail/domain/services/alternate_source_service.dart b/lib/features/detail/domain/services/alternate_source_service.dart index 0d949ae1..81ad99cc 100644 --- a/lib/features/detail/domain/services/alternate_source_service.dart +++ b/lib/features/detail/domain/services/alternate_source_service.dart @@ -303,6 +303,19 @@ class AlternateSourceService { required int? episodeNumber, SeasonEpisode? seasonEpisode, Duration resumeAt = Duration.zero, + }) async => (await resolveArgs( + source: source, + episodeNumber: episodeNumber, + seasonEpisode: seasonEpisode, + resumeAt: resumeAt, + )).args; + + /// [buildArgs], with the reason when there is nothing to play. + Future resolveArgs({ + required AlternateSource source, + required int? episodeNumber, + SeasonEpisode? seasonEpisode, + Duration resumeAt = Duration.zero, }) async { final playback = (await _episodes( source.item.url, @@ -310,12 +323,14 @@ class AlternateSourceService { )).getOrNull(); if (playback == null) { debugPrint('$_tag episodes failed for ${source.provider.id}'); - return null; + return const AlternateArgs.failed(AlternateArgsFailure.unreachable); } final List episodes = playback.episodes; if (playback.isSerial || episodes.isNotEmpty) { - if (episodes.isEmpty) return null; + if (episodes.isEmpty) { + return const AlternateArgs.failed(AlternateArgsFailure.noEpisode); + } var window = playback; var index = 0; final bySeason = seasonEpisode == null @@ -330,12 +345,14 @@ class AlternateSourceService { // of a long run is on a later one, and "this source does not have // it" was the answer every long show got. final found = await _findOnLaterPage(source, playback, episodeNumber); - if (found == null) return null; + if (found == null) { + return const AlternateArgs.failed(AlternateArgsFailure.noEpisode); + } (window, index) = found; } } final size = playback.size > 0 ? playback.size : episodes.length; - return PlayerArgs( + return AlternateArgs(PlayerArgs( title: source.item.title, provider: source.provider.id, // The NEW provider's headers. The caller used to pass the ones the @@ -357,11 +374,13 @@ class AlternateSourceService { totalEpisodes: playback.total, pageSize: size, sort: playback.sort, - ); + )); } - if (playback.videoSources.isEmpty) return null; - return PlayerArgs( + if (playback.videoSources.isEmpty) { + return const AlternateArgs.failed(AlternateArgsFailure.nothingPlayable); + } + return AlternateArgs(PlayerArgs( title: source.item.title, provider: source.provider.id, headers: playback.headers, @@ -370,7 +389,7 @@ class AlternateSourceService { videoSources: List.of(playback.videoSources), movieUrl: playback.playerSrc, resumePosition: resumeAt, - ); + )); } /// Finds episode [number] on a page past the first. @@ -408,6 +427,27 @@ class AlternateSourceService { } } +/// Why [AlternateSourceService.resolveArgs] came back empty. +enum AlternateArgsFailure { + /// The source's page for the title could not be loaded. + unreachable, + + /// The source has the show but not this episode. + noEpisode, + + /// A film page with no stream on it. + nothingPlayable, +} + +/// What [AlternateSourceService.resolveArgs] produced: arguments, or why not. +class AlternateArgs { + const AlternateArgs(PlayerArgs this.args) : failure = null; + const AlternateArgs.failed(AlternateArgsFailure this.failure) : args = null; + + final PlayerArgs? args; + final AlternateArgsFailure? failure; +} + /// An episode as a season and its number in that season: S2E1. typedef SeasonEpisode = ({int season, int number}); diff --git a/lib/features/detail/domain/services/auto_resolve_walk.dart b/lib/features/detail/domain/services/auto_resolve_walk.dart new file mode 100644 index 00000000..1f99a6da --- /dev/null +++ b/lib/features/detail/domain/services/auto_resolve_walk.dart @@ -0,0 +1,173 @@ +import 'dart:async'; +import 'dart:math' as math; + +/// Why Auto moved past a source. +enum AutoSkipReason { + /// The title it found was not close enough to pick for someone. + noMatch, + + /// It has the show but not this episode. + noEpisode, + + /// It could not be reached, or had nothing to play. + error, +} + +/// What one try at a candidate produced: something to play, or why not. +class AutoAttempt { + const AutoAttempt.found(R this.value) : reason = null; + const AutoAttempt.skipped(AutoSkipReason this.reason) : value = null; + + final R? value; + final AutoSkipReason? reason; +} + +/// A candidate Auto moved past, and why. +class AutoSkip { + const AutoSkip(this.candidate, this.reason); + + final T candidate; + final AutoSkipReason reason; +} + +enum AutoWalkEnd { found, exhausted, capped, stopped } + +class AutoWalkResult { + const AutoWalkResult(this.end, [this.value]); + + final AutoWalkEnd end; + + /// Set only when [end] is [AutoWalkEnd.found]. + final R? value; +} + +/// Goes down a list that may still be growing and returns the first candidate +/// that plays. +/// +/// The list is read again on every step because search results keep arriving +/// while the walk runs. While the list has nothing new and [searching] is +/// still true, the walk waits for more. +class AutoResolveWalk { + AutoResolveWalk({ + required this.candidates, + required this.searching, + required this.idOf, + required this.isEligible, + required this.attempt, + this.cap = maxCandidates, + this.poll = const Duration(milliseconds: 250), + this.onChange, + }); + + /// More than this and the viewer has waited long enough to pick by hand. + static const int maxCandidates = 20; + + final List Function() candidates; + final bool Function() searching; + final String Function(T) idOf; + + /// Whether a candidate is good enough to try without asking. + final bool Function(T) isEligible; + final Future> Function(T) attempt; + final int cap; + final Duration poll; + + /// Called whenever [current], [index], [total] or [skipped] may have changed. + final void Function()? onChange; + + final Set _seen = {}; + final List> _skipped = []; + final Completer _stop = Completer(); + T? _current; + int _attempts = 0; + bool _running = false; + + /// The candidate being tried right now, or null while waiting for more. + T? get current => _current; + + /// Which try this is, from 1. + int get index => _attempts; + + /// The tries this walk will make with what is known so far. Grows while + /// search results arrive, and never passes [cap]. + int get total { + var waiting = 0; + for (final c in candidates()) { + if (isEligible(c) && !_seen.contains(idOf(c))) waiting++; + } + return math.min(cap, _attempts + waiting); + } + + List> get skipped => List.unmodifiable(_skipped); + + bool get stopped => _stop.isCompleted; + + /// Ends the walk now. A try still in flight is ignored when it lands. + void stop() { + if (_stop.isCompleted) return; + _stop.complete(); + _current = null; + onChange?.call(); + } + + Future> run() async { + if (_running) throw StateError('an AutoResolveWalk runs once'); + _running = true; + while (!stopped) { + if (_attempts >= cap) return _end(AutoWalkEnd.capped); + T? next; + for (final c in candidates()) { + if (_seen.contains(idOf(c)) || !isEligible(c)) continue; + next = c; + break; + } + if (next == null) { + if (!searching()) { + _skipIneligible(); + return _end(AutoWalkEnd.exhausted); + } + await Future.any([Future.delayed(poll), _stop.future]); + continue; + } + final candidate = next; + _seen.add(idOf(candidate)); + _attempts++; + _current = candidate; + onChange?.call(); + + AutoAttempt? outcome; + try { + outcome = await Future.any?>([ + attempt(candidate), + _stop.future.then((_) => null), + ]); + } catch (_) { + outcome = AutoAttempt.skipped(AutoSkipReason.error); + } + if (stopped || outcome == null) break; + _current = null; + final reason = outcome.reason; + if (reason == null) return _end(AutoWalkEnd.found, outcome.value); + _skipped.add(AutoSkip(candidate, reason)); + onChange?.call(); + } + return AutoWalkResult(AutoWalkEnd.stopped); + } + + /// The ones never tried because the match was too weak, so the end state + /// can still say why they were passed over. + void _skipIneligible() { + for (final c in candidates()) { + final id = idOf(c); + if (isEligible(c) || _seen.contains(id)) continue; + _seen.add(id); + _skipped.add(AutoSkip(c, AutoSkipReason.noMatch)); + } + } + + AutoWalkResult _end(AutoWalkEnd end, [R? value]) { + _current = null; + onChange?.call(); + return AutoWalkResult(end, value); + } +} diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index 6eefaaec..591f8a5f 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -455,6 +455,8 @@ extension _PlayerControls on _PlayerPageState { stage: _stage, title: _episodeTitle(), serverSwitch: _serverSwitch, + onStopSwitch: _stopAutoSwitch, + stopFocus: _tvStopSwitchFocus, ), ); } diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index 31055dfb..252baa58 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -720,6 +720,7 @@ class _PlayerPageState extends State debugLabel: 'tvPlayerPanel', ); final FocusNode _tvPlayFocus = FocusNode(debugLabel: 'tvPlayerPlay'); + final FocusNode _tvStopSwitchFocus = FocusNode(debugLabel: 'tvStopSwitch'); final FocusNode _tvSeekFocus = FocusNode(debugLabel: 'tvPlayerSeek'); /// Debounce that turns a burst of left/right presses into a single seek. @@ -921,6 +922,7 @@ class _PlayerPageState extends State _tvRootFocus.dispose(); _tvPanelFocus.dispose(); _tvPlayFocus.dispose(); + _tvStopSwitchFocus.dispose(); _tvSeekFocus.dispose(); _tvPanelScroll?.dispose(); _controlsAnimation.dispose(); diff --git a/lib/features/detail/presentation/pages/player_page.media.dart b/lib/features/detail/presentation/pages/player_page.media.dart index 3ed1ed7f..0372a9ab 100644 --- a/lib/features/detail/presentation/pages/player_page.media.dart +++ b/lib/features/detail/presentation/pages/player_page.media.dart @@ -2244,6 +2244,13 @@ extension _PlayerMedia on _PlayerPageState { ).next(); if (nextIdx != null) { final next = _videoSources[nextIdx]; + // The same from -> to screen a manual pick gets, with the attempt count + // and a way out. Read before _currentQuality moves to the new row. + final step = ServerSwitch.automatic( + from: _currentServer, + to: VideoOptionGroups.serverOf(next.quality), + attempt: _lifetimeRetries, + ); setState(() { _initializing = true; _stage = _LoadingStage.loading; @@ -2252,28 +2259,26 @@ extension _PlayerMedia on _PlayerPageState { _isCodecError = false; _currentSourceIndex = nextIdx; _currentQuality = next.quality; + _serverSwitch = step; }); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('player.switching_to'.tr(args: [next.quality])), - backgroundColor: Colors.black87, - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), - ), + try { + final generation = await _disposeController(); + await Future.delayed(const Duration(milliseconds: 350)); + if (!mounted || generation != _mediaGeneration) return; + await _initializeWith( + intentGeneration: generation, + url: next.videoUrl, + headers: next.headers.isNotEmpty ? next.headers : _resolveHeaders, + type: _typeOf(next), + resumeAt: keepPosition, ); + if (mounted && generation == _mediaGeneration) _autoRetrying = false; + } finally { + // Not a later step's overlay, which a failure here may have set up. + if (mounted && identical(_serverSwitch, step)) { + setState(() => _serverSwitch = null); + } } - final generation = await _disposeController(); - await Future.delayed(const Duration(milliseconds: 350)); - if (!mounted || generation != _mediaGeneration) return; - await _initializeWith( - intentGeneration: generation, - url: next.videoUrl, - headers: next.headers.isNotEmpty ? next.headers : _resolveHeaders, - type: _typeOf(next), - resumeAt: keepPosition, - ); - if (mounted && generation == _mediaGeneration) _autoRetrying = false; return; } @@ -2316,6 +2321,26 @@ extension _PlayerMedia on _PlayerPageState { if (mounted && generation == _mediaGeneration) _autoRetrying = false; } + /// The viewer's Stop on an automatic server switch. + /// + /// Drops the stream being opened and shows the error screen, which already + /// offers Try again, the server list and other sources. + Future _stopAutoSwitch() async { + if (!mounted || _serverSwitch?.automatic != true) return; + _plog('automatic server switch stopped by the viewer'); + setState(() { + _serverSwitch = null; + _initializing = false; + _autoRetrying = false; + _isCodecError = false; + _errorMessage = 'player.auto_switch_stopped'.tr(); + _errorRaw = null; + }); + // Bumps the generation, so the start in flight bails at its next check + // and disposes whatever it had opened. + await _disposeController(); + } + /// Where a retry picks up: the playhead, else the last position the stream /// reported while playing, else where the start was headed. /// diff --git a/lib/features/detail/presentation/pages/player_page.models.dart b/lib/features/detail/presentation/pages/player_page.models.dart index 38662f83..d215e715 100644 --- a/lib/features/detail/presentation/pages/player_page.models.dart +++ b/lib/features/detail/presentation/pages/player_page.models.dart @@ -26,12 +26,41 @@ enum _LoadingStage { resolving, loading } /// server, and that it is on its way — not a generic spinner that looks /// identical to the failure they were trying to escape. class ServerSwitch { - const ServerSwitch({required this.from, required this.to}); + const ServerSwitch({ + required this.from, + required this.to, + this.attempt, + this.maxAttempts, + }); + + /// One step of the automatic ladder after a failure, counted against + /// [RetryPolicy.maxLifetimeRetries] so the viewer can see the end coming. + factory ServerSwitch.automatic({ + required String? from, + required String to, + required int attempt, + }) { + final shown = attempt < 1 ? 1 : attempt; + return ServerSwitch( + from: from, + to: to, + attempt: shown, + maxAttempts: shown > RetryPolicy.maxLifetimeRetries + ? shown + : RetryPolicy.maxLifetimeRetries, + ); + } /// Null when nothing was playing yet, so the overlay shows a destination /// rather than a journey. final String? from; final String to; + + /// Set only for an automatic switch, which the viewer can stop. + final int? attempt; + final int? maxAttempts; + + bool get automatic => attempt != null; } /// `zoom` carries a scale factor (1.0-3.0), not a 0-1 level like the other diff --git a/lib/features/detail/presentation/pages/player_page.tv.dart b/lib/features/detail/presentation/pages/player_page.tv.dart index 57af7316..210a009c 100644 --- a/lib/features/detail/presentation/pages/player_page.tv.dart +++ b/lib/features/detail/presentation/pages/player_page.tv.dart @@ -270,6 +270,13 @@ extension _PlayerTv on _PlayerPageState { return KeyEventResult.handled; } + // The loading screen's Stop, while the ladder walks on its own. The + // handler below would otherwise read OK as play/pause. + if (_isTvSelect(k) && _tvStopSwitchFocus.hasPrimaryFocus) { + unawaited(_stopAutoSwitch()); + return KeyEventResult.handled; + } + if (_locked) { // The lock affordance is not built on TV (see _buildControlsOverlay), so // this is only reachable if the state leaks in. Any key unlocks rather diff --git a/lib/features/detail/presentation/pages/player_page.widgets.dart b/lib/features/detail/presentation/pages/player_page.widgets.dart index 7af53132..9b78702a 100644 --- a/lib/features/detail/presentation/pages/player_page.widgets.dart +++ b/lib/features/detail/presentation/pages/player_page.widgets.dart @@ -149,6 +149,8 @@ class _LoadingOverlay extends StatelessWidget { required this.stage, required this.title, this.serverSwitch, + this.onStopSwitch, + this.stopFocus, }); final _LoadingStage stage; @@ -157,6 +159,10 @@ class _LoadingOverlay extends StatelessWidget { /// Non-null only while moving between mirrors, which gets its own screen. final ServerSwitch? serverSwitch; + /// Stops an automatic switch; see [_ServerSwitchOverlay.onStop]. + final VoidCallback? onStopSwitch; + final FocusNode? stopFocus; + String get _label { switch (stage) { case _LoadingStage.resolving: @@ -187,7 +193,13 @@ class _LoadingOverlay extends StatelessWidget { @override Widget build(BuildContext context) { final switching = serverSwitch; - if (switching != null) return _ServerSwitchOverlay(switch_: switching); + if (switching != null) { + return _ServerSwitchOverlay( + switch_: switching, + onStop: switching.automatic ? onStopSwitch : null, + stopFocus: stopFocus, + ); + } return SafeArea( child: Padding( @@ -2005,13 +2017,23 @@ class _CaretPainter extends CustomPainter { } class _ServerSwitchOverlay extends StatelessWidget { - const _ServerSwitchOverlay({required this.switch_}); + const _ServerSwitchOverlay({ + required this.switch_, + this.onStop, + this.stopFocus, + }); final ServerSwitch switch_; + /// Ends the automatic walk and leaves the player on its error screen. + final VoidCallback? onStop; + final FocusNode? stopFocus; + @override Widget build(BuildContext context) { final from = switch_.from; + final attempt = switch_.attempt; + final stop = onStop; return SafeArea( child: Center( @@ -2056,6 +2078,19 @@ class _ServerSwitchOverlay extends StatelessWidget { fontWeight: FontWeight.w500, ), ), + if (attempt != null) ...[ + const SizedBox(height: 4), + Text( + 'player.auto_switch_attempt'.tr( + args: ['$attempt', '${switch_.maxAttempts ?? attempt}'], + ), + style: const TextStyle( + color: Colors.white38, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], const SizedBox(height: 22), SizedBox( width: 148, @@ -2072,6 +2107,25 @@ class _ServerSwitchOverlay extends StatelessWidget { ), ), ), + if (stop != null) ...[ + const SizedBox(height: 18), + _tvRing( + OutlinedButton.icon( + onPressed: stop, + focusNode: stopFocus, + // The only control on this screen; a remote needs it + // focused to reach it at all. + autofocus: isTvPlatform, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.white38), + ), + icon: const Icon(Icons.stop_rounded, size: 18), + label: Text('player.auto_switch_stop'.tr()), + ), + radius: 20, + ), + ], ], ), ), diff --git a/lib/features/detail/presentation/widgets/alternate_source_sheet.dart b/lib/features/detail/presentation/widgets/alternate_source_sheet.dart index 67897278..b8b28383 100644 --- a/lib/features/detail/presentation/widgets/alternate_source_sheet.dart +++ b/lib/features/detail/presentation/widgets/alternate_source_sheet.dart @@ -19,6 +19,7 @@ import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/features/detail/data/source_choice_store.dart'; import 'package:soplay/features/detail/domain/entities/player_args.dart'; import 'package:soplay/features/detail/domain/services/alternate_source_service.dart'; +import 'package:soplay/features/detail/domain/services/auto_resolve_walk.dart'; import 'package:soplay/features/detail/presentation/widgets/source_search_sheet.dart'; import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; @@ -223,11 +224,14 @@ class _AlternateSourceSheetState extends State { /// Play button that did nothing at all. ({String provider, String message})? _failed; - /// True while Auto is going down the list looking for one that plays. - bool _auto = false; + /// Non-null while Auto is going down the list looking for one that plays. + AutoResolveWalk<_Row, PlayerArgs>? _walk; - /// Auto went through every match and none could be played from here. - bool _autoMissed = false; + /// How the last Auto run ended, when it ended without playing anything. + AutoWalkEnd? _autoEnd; + + /// What the current or last Auto run moved past, and why. + List> _autoSkipped = const []; /// How the run ended, so the empty state can say which kind of empty. AlternateSearchOutcome? _outcome; @@ -318,6 +322,9 @@ class _AlternateSourceSheetState extends State { @override void dispose() { + final walk = _walk; + _walk = null; + walk?.stop(); _sub?.cancel(); super.dispose(); } @@ -418,7 +425,7 @@ class _AlternateSourceSheetState extends State { bool get _reads => widget.provider.contentMode != ContentMode.video; Future _pick(AlternateSource source) async { - if (_preparing != null) return; + if (_preparing != null || _walk != null) return; if (_reads) { Navigator.of(context).pop(source); return; @@ -457,111 +464,194 @@ class _AlternateSourceSheetState extends State { /// source had the show but not the episode, to pick the next. Auto does /// that walk itself — corrections first, then the matches it can trust in /// score order, taking each source as it answers rather than waiting for - /// the slowest. + /// the slowest. [AutoResolveWalk] holds the cap and the stop. Future _runAuto() async { - if (_auto || _preparing != null) return; + if (_walk != null || _preparing != null) return; + final service = getIt(); + late final AutoResolveWalk<_Row, PlayerArgs> walk; + walk = AutoResolveWalk<_Row, PlayerArgs>( + candidates: () => _rows, + searching: () => _searching, + idOf: (r) => r.provider.id, + isEligible: (r) => r.chosen || r.source.isTrustworthy, + attempt: (r) async { + final result = await service.resolveArgs( + source: r.source, + episodeNumber: widget.episodeNumber, + seasonEpisode: widget.seasonEpisode, + resumeAt: widget.resumeAt, + ); + final args = result.args; + if (args != null) return AutoAttempt.found(args); + return AutoAttempt.skipped( + result.failure == AlternateArgsFailure.noEpisode + ? AutoSkipReason.noEpisode + : AutoSkipReason.error, + ); + }, + onChange: () { + if (mounted && identical(_walk, walk)) setState(() {}); + }, + ); setState(() { - _auto = true; - _autoMissed = false; + _walk = walk; + _autoEnd = null; + _autoSkipped = const []; _failed = null; }); - final service = getIt(); - final tried = {}; - while (mounted) { - _Row? next; - for (final r in _rows) { - if (tried.contains(r.provider.id)) continue; - if (r.chosen || r.source.isTrustworthy) { - next = r; - break; - } - } - if (next == null) { - if (!_searching) break; - await Future.delayed(const Duration(milliseconds: 250)); - continue; - } - tried.add(next.provider.id); - setState(() => _preparing = next!.provider.id); - final args = await service.buildArgs( - source: next.source, - episodeNumber: widget.episodeNumber, - seasonEpisode: widget.seasonEpisode, - resumeAt: widget.resumeAt, - ); - if (!mounted) return; - if (args != null) { - Navigator.of(context).pop(args); - return; - } - } + final result = await walk.run(); if (!mounted) return; + if (result.end == AutoWalkEnd.found) { + Navigator.of(context).pop(result.value); + return; + } setState(() { - _auto = false; - _preparing = null; - _autoMissed = true; + _walk = null; + _autoEnd = result.end; + _autoSkipped = walk.skipped; }); } + void _stopAuto() => _walk?.stop(); + + String _autoSubtitle() { + final walk = _walk; + if (walk != null) { + final current = walk.current; + if (current == null) return 'player.alt_auto_waiting'.tr(); + return 'player.alt_auto_checking'.tr( + args: [current.provider.name, '${walk.index}', '${walk.total}'], + ); + } + return switch (_autoEnd) { + AutoWalkEnd.exhausted => 'player.alt_auto_missed'.tr(), + AutoWalkEnd.capped => 'player.alt_auto_capped'.tr( + args: ['${AutoResolveWalk.maxCandidates}'], + ), + AutoWalkEnd.stopped => 'player.alt_auto_stopped'.tr(), + _ => 'player.alt_auto_hint'.tr(), + }; + } + /// The row that offers [_runAuto], above the matches. Widget _autoTile() { - final subtitle = _autoMissed - ? 'player.alt_auto_missed'.tr() - : _auto && _preparing != null - ? 'player.alt_auto_trying'.tr(args: [_nameOf(_preparing!)]) - : 'player.alt_auto_hint'.tr(); - return ListTile( - onTap: _auto || _preparing != null ? null : _runAuto, - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.16), - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - Icons.auto_awesome_rounded, - color: AppColors.primary, - size: 20, - ), - ), - title: Text( - 'player.alt_auto'.tr(), - style: const TextStyle( - color: Colors.white, - fontSize: 14.5, - fontWeight: FontWeight.w700, - ), - ), - subtitle: Text( - subtitle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: _autoMissed ? AppColors.error : Colors.white54, - fontSize: 12, + final running = _walk != null; + final ended = _autoEnd != null; + final missed = + _autoEnd == AutoWalkEnd.exhausted || _autoEnd == AutoWalkEnd.capped; + final Widget trailing; + if (running) { + trailing = Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ), + const SizedBox(width: 4), + TextButton( + onPressed: _stopAuto, + style: TextButton.styleFrom(foregroundColor: Colors.white), + child: Text('player.alt_auto_stop'.tr()), + ), + ], + ); + } else if (ended) { + trailing = TextButton.icon( + onPressed: _preparing != null ? null : _runAuto, + style: TextButton.styleFrom(foregroundColor: AppColors.primary), + icon: const Icon(Icons.refresh_rounded, size: 18), + label: Text('general.try_again'.tr()), + ); + } else { + trailing = const Icon(Icons.play_arrow_rounded, color: Colors.white70); + } + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + onTap: running || _preparing != null ? null : _runAuto, + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + Icons.auto_awesome_rounded, + color: AppColors.primary, + size: 20, + ), + ), + title: Text( + 'player.alt_auto'.tr(), + style: const TextStyle( + color: Colors.white, + fontSize: 14.5, + fontWeight: FontWeight.w700, + ), + ), + subtitle: Text( + _autoSubtitle(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: missed ? AppColors.error : Colors.white54, + fontSize: 12, + ), + ), + trailing: trailing, ), - ), - trailing: _auto - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.primary, - ), - ) - : const Icon(Icons.play_arrow_rounded, color: Colors.white70), + if ((running ? _walk!.skipped : _autoSkipped).isNotEmpty) + _autoSkippedList(running ? _walk!.skipped : _autoSkipped), + ], ); } - String _nameOf(String providerId) { - for (final r in _rows) { - if (r.provider.id == providerId) return r.provider.name; - } - return providerId; + /// What Auto already moved past, one short line each. + Widget _autoSkippedList(List> skipped) { + const shown = 8; + const style = TextStyle(color: Colors.white54, fontSize: 11.5); + return Padding( + padding: const EdgeInsetsDirectional.fromSTEB(72, 0, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'player.alt_auto_tried'.tr(), + style: const TextStyle( + color: Colors.white70, + fontSize: 11.5, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + for (final s in skipped.take(shown)) + Text( + '${s.candidate.provider.name} — ${_skipLabel(s.reason)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: style, + ), + if (skipped.length > shown) + Text('+${skipped.length - shown}', style: style), + ], + ), + ); } + static String _skipLabel(AutoSkipReason reason) => switch (reason) { + AutoSkipReason.noMatch => 'player.alt_skip_no_match'.tr(), + AutoSkipReason.noEpisode => 'player.alt_skip_no_episode'.tr(), + AutoSkipReason.error => 'player.alt_skip_error'.tr(), + }; + /// Hand the question to the viewer: search this one source, take their pick. /// /// Remembered before the row is redrawn, so the correction is already on disk @@ -701,8 +791,9 @@ class _AlternateSourceSheetState extends State { ); Widget _matchTile(_Row row) { - final busy = _preparing == row.provider.id; - final enabled = _preparing == null; + final busy = + (_preparing ?? _walk?.current?.provider.id) == row.provider.id; + final enabled = _preparing == null && _walk == null; final failure = _failed?.provider == row.provider.id ? _failed!.message : null; @@ -910,7 +1001,9 @@ class _AlternateSourceSheetState extends State { label: label, child: ExcludeSemantics( child: InkWell( - onTap: _preparing == null ? () => _correct(provider) : null, + onTap: _preparing == null && _walk == null + ? () => _correct(provider) + : null, customBorder: const CircleBorder(), child: const SizedBox( width: 48, @@ -971,7 +1064,7 @@ class _AlternateSourceSheetState extends State { for (final p in quiet) ListTile( dense: true, - enabled: _preparing == null, + enabled: _preparing == null && _walk == null, onTap: () => _correct(ProviderRef.fromEntity(p)), leading: const Icon( Icons.search_off_rounded, diff --git a/test/features/detail/alternate_source_sheet_test.dart b/test/features/detail/alternate_source_sheet_test.dart index 72863a50..08c26070 100644 --- a/test/features/detail/alternate_source_sheet_test.dart +++ b/test/features/detail/alternate_source_sheet_test.dart @@ -29,8 +29,12 @@ class _Service implements AlternateSourceService { this.sources = const [], this.outcome = const AlternateSearchOutcome(asked: 2), this.results = const {}, + this.resolve, }); + /// What Auto gets for each source it tries. Null: none has the episode. + final Future Function(AlternateSource source)? resolve; + /// What the automatic fan-out "found", in the order it finds it. final List sources; @@ -78,6 +82,16 @@ class _Service implements AlternateSourceService { Duration resumeAt = Duration.zero, }) async => null; + @override + Future resolveArgs({ + required AlternateSource source, + required int? episodeNumber, + SeasonEpisode? seasonEpisode, + Duration resumeAt = Duration.zero, + }) => + resolve?.call(source) ?? + Future.value(const AlternateArgs.failed(AlternateArgsFailure.noEpisode)); + /// Anything else is a call these tests did not expect the sheet to make, and /// a thrown NoSuchMethodError names it. @override @@ -521,6 +535,61 @@ void main() { }); }); + group('Auto', () { + final sources = [ + _source('an:one', 'AniOne', _title), + _source('an:two', 'AniTwo', 'Return of the Blade'), + ]; + final providers = [ + _provider('an:one', 'AniOne'), + _provider('an:two', 'AniTwo'), + ]; + + testWidgets('lists what it moved past and offers to try again', ( + tester, + ) async { + await pump(tester, _Service(sources: sources), candidates: providers); + await tester.tap(find.text('player.alt_auto')); + await tester.pumpAndSettle(); + + expect(find.text('player.alt_auto_missed'), findsOneWidget); + expect(find.text('player.alt_auto_tried'), findsOneWidget); + expect( + find.text('AniOne — player.alt_skip_no_episode'), + findsOneWidget, + ); + // The weak match is never tried, but the list still says why. + expect(find.text('AniTwo — player.alt_skip_no_match'), findsOneWidget); + expect(find.text('general.try_again'), findsOneWidget); + }); + + testWidgets('Stop ends the run while a source is still answering', ( + tester, + ) async { + final pending = Completer(); + await pump( + tester, + _Service(sources: sources, resolve: (_) => pending.future), + candidates: providers, + ); + await tester.tap(find.text('player.alt_auto')); + await tester.pump(); + expect(find.text('player.alt_auto_checking'), findsOneWidget); + + await tester.tap(find.text('player.alt_auto_stop')); + await tester.pumpAndSettle(); + expect(find.text('player.alt_auto_stopped'), findsOneWidget); + expect(find.text('general.try_again'), findsOneWidget); + + // The answer that arrives afterwards does not close the sheet. + pending.complete( + const AlternateArgs.failed(AlternateArgsFailure.unreachable), + ); + await tester.pumpAndSettle(); + expect(find.text('player.alt_auto_stopped'), findsOneWidget); + }); + }); + group('the words themselves', () { test('every string the switcher shows has English text', () { final en = diff --git a/test/features/detail/domain/services/auto_resolve_walk_test.dart b/test/features/detail/domain/services/auto_resolve_walk_test.dart new file mode 100644 index 00000000..78d1ba7f --- /dev/null +++ b/test/features/detail/domain/services/auto_resolve_walk_test.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/services/auto_resolve_walk.dart'; + +class _C { + const _C(this.id, {this.trusted = true}); + final String id; + final bool trusted; +} + +AutoResolveWalk<_C, String> _walk( + List<_C> list, + Future> Function(_C) attempt, { + bool Function()? searching, + int cap = AutoResolveWalk.maxCandidates, + void Function()? onChange, +}) => AutoResolveWalk<_C, String>( + candidates: () => list, + searching: searching ?? () => false, + idOf: (c) => c.id, + isEligible: (c) => c.trusted, + attempt: attempt, + cap: cap, + poll: const Duration(milliseconds: 1), + onChange: onChange, +); + +void main() { + test('returns the first candidate that plays, in list order', () async { + final tried = []; + final walk = _walk([const _C('a'), const _C('b'), const _C('c')], (c) async { + tried.add(c.id); + return c.id == 'b' + ? AutoAttempt.found('args-b') + : AutoAttempt.skipped(AutoSkipReason.noEpisode); + }); + final result = await walk.run(); + expect(result.end, AutoWalkEnd.found); + expect(result.value, 'args-b'); + expect(tried, ['a', 'b']); + expect(walk.skipped.single.candidate.id, 'a'); + expect(walk.skipped.single.reason, AutoSkipReason.noEpisode); + }); + + test('records why each source was skipped', () async { + final walk = _walk( + [const _C('a'), const _C('b'), const _C('weak', trusted: false)], + (c) async { + if (c.id == 'a') return AutoAttempt.skipped(AutoSkipReason.noEpisode); + throw StateError('network'); + }, + ); + final result = await walk.run(); + expect(result.end, AutoWalkEnd.exhausted); + expect([for (final s in walk.skipped) (s.candidate.id, s.reason)], [ + ('a', AutoSkipReason.noEpisode), + ('b', AutoSkipReason.error), + ('weak', AutoSkipReason.noMatch), + ]); + }); + + test('never tries a candidate that is not trustworthy', () async { + final tried = []; + final walk = _walk([const _C('weak', trusted: false)], (c) async { + tried.add(c.id); + return AutoAttempt.found(c.id); + }); + expect((await walk.run()).end, AutoWalkEnd.exhausted); + expect(tried, isEmpty); + }); + + test('stops after the cap', () async { + final list = [for (var i = 0; i < 30; i++) _C('$i')]; + var calls = 0; + final walk = _walk(list, (c) async { + calls++; + return AutoAttempt.skipped(AutoSkipReason.error); + }); + final result = await walk.run(); + expect(result.end, AutoWalkEnd.capped); + expect(calls, AutoResolveWalk.maxCandidates); + expect(walk.index, 20); + expect(walk.total, 20); + }); + + test('counts i of total, and the total grows as results arrive', () async { + final list = <_C>[const _C('a')]; + var searching = true; + final seen = <(int, int)>[]; + late final AutoResolveWalk<_C, String> walk; + walk = _walk( + list, + (c) async { + seen.add((walk.index, walk.total)); + if (c.id == 'a') { + list.addAll([const _C('b'), const _C('c')]); + } + if (c.id == 'c') searching = false; + return AutoAttempt.skipped(AutoSkipReason.noEpisode); + }, + searching: () => searching, + ); + final result = await walk.run(); + expect(result.end, AutoWalkEnd.exhausted); + expect(seen, [(1, 1), (2, 3), (3, 3)]); + }); + + test('waits for more results while search is still running', () async { + final list = <_C>[]; + var searching = true; + final walk = _walk( + list, + (c) async => AutoAttempt.found(c.id), + searching: () => searching, + ); + final running = walk.run(); + await Future.delayed(const Duration(milliseconds: 10)); + expect(walk.current, isNull); + list.add(const _C('late')); + searching = false; + final result = await running; + expect(result.end, AutoWalkEnd.found); + expect(result.value, 'late'); + }); + + test('stop ends the walk at once and ignores the result in flight', () async { + final pending = Completer>(); + final walk = _walk([const _C('a'), const _C('b')], (_) => pending.future); + final running = walk.run(); + await Future.delayed(Duration.zero); + expect(walk.current?.id, 'a'); + + walk.stop(); + final result = await running; + expect(result.end, AutoWalkEnd.stopped); + expect(result.value, isNull); + expect(walk.current, isNull); + + // The answer that lands afterwards changes nothing. + pending.complete(AutoAttempt.found('too late')); + await Future.delayed(Duration.zero); + expect(walk.skipped, isEmpty); + expect(walk.index, 1); + }); + + test('stop while waiting for search ends the walk too', () async { + final walk = _walk( + [], + (c) async => AutoAttempt.found(c.id), + searching: () => true, + ); + final running = walk.run(); + await Future.delayed(const Duration(milliseconds: 5)); + walk.stop(); + expect((await running).end, AutoWalkEnd.stopped); + }); + + test('an error after stop is swallowed', () async { + final pending = Completer>(); + final walk = _walk([const _C('a')], (_) => pending.future); + final running = walk.run(); + await Future.delayed(Duration.zero); + walk.stop(); + expect((await running).end, AutoWalkEnd.stopped); + pending.completeError(StateError('late failure')); + await Future.delayed(Duration.zero); + }); + + test('tells the caller when its state changes', () async { + var changes = 0; + final walk = _walk( + [const _C('a')], + (c) async => AutoAttempt.skipped(AutoSkipReason.error), + onChange: () => changes++, + ); + await walk.run(); + expect(changes, greaterThanOrEqualTo(3)); + }); +} diff --git a/test/features/detail/server_switch_test.dart b/test/features/detail/server_switch_test.dart new file mode 100644 index 00000000..be2ea21c --- /dev/null +++ b/test/features/detail/server_switch_test.dart @@ -0,0 +1,50 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/detail/domain/playback/retry_policy.dart'; +import 'package:soplay/features/detail/presentation/pages/player_page.dart'; + +void main() { + group('an automatic server switch', () { + test('counts against the lifetime retry budget', () { + final step = ServerSwitch.automatic( + from: 'Server 1', + to: 'Server 2', + attempt: 2, + ); + expect(step.automatic, isTrue); + expect(step.attempt, 2); + expect(step.maxAttempts, RetryPolicy.maxLifetimeRetries); + }); + + test('never reads "0 of 4" or "5 of 4"', () { + // The sniff fallback walks without spending the budget, and the codec + // path can spend past it. + expect( + ServerSwitch.automatic(from: null, to: 'B', attempt: 0).attempt, + 1, + ); + final over = ServerSwitch.automatic( + from: 'A', + to: 'B', + attempt: RetryPolicy.maxLifetimeRetries + 1, + ); + expect(over.attempt, over.maxAttempts); + }); + + test('a manual pick has no counter and no Stop', () { + expect(const ServerSwitch(from: 'A', to: 'B').automatic, isFalse); + }); + }); + + test('the ladder shows the switch screen instead of a snackbar', () { + final media = File( + 'lib/features/detail/presentation/pages/player_page.media.dart', + ).readAsStringSync(); + final start = media.indexOf('Future _autoRetry()'); + final end = media.indexOf('\n }\n', start); + final body = media.substring(start, end); + expect(body, contains('ServerSwitch.automatic')); + expect(body, isNot(contains('showSnackBar'))); + }); +} From d833df2f4892b8b78e30d143710868ed125226b1 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:35:14 +0500 Subject: [PATCH 04/16] Translate manga pages inside the reader On-device OCR with ML Kit (Android and iOS), bubbles merged from nearby text blocks, one translateLines request per page with docKey manga::. A preview offers Apply, Show original and Cancel; applied overlays are kept per chapter and page in memory and on disk. The reader settings gain a page translation language and an optional translate-as-I-read switch. --- assets/translations/ar.json | 14 +- assets/translations/ckb.json | 14 +- assets/translations/de.json | 14 +- assets/translations/en.json | 14 +- assets/translations/es.json | 14 +- assets/translations/fr.json | 14 +- assets/translations/hi.json | 14 +- assets/translations/id.json | 14 +- assets/translations/ku.json | 14 +- assets/translations/nl.json | 14 +- assets/translations/pt.json | 14 +- assets/translations/ru.json | 14 +- assets/translations/tr.json | 14 +- assets/translations/uz.json | 14 +- assets/translations/vi.json | 14 +- assets/translations/yue.json | 14 +- ios/Podfile | 10 +- ios/Runner.xcodeproj/project.pbxproj | 6 +- lib/core/constants/app_constants.dart | 2 + lib/core/storage/hive_service.dart | 25 ++ .../subtitle_translation_service.dart | 15 + lib/features/manga/data/page_translator.dart | 177 +++++++++ .../domain/translate/page_translation.dart | 373 ++++++++++++++++++ .../manga/presentation/pages/reader_page.dart | 248 +++++++++++- .../widgets/page_translation_overlay.dart | 231 +++++++++++ pubspec.lock | 16 + pubspec.yaml | 3 + .../features/manga/page_translation_test.dart | 249 ++++++++++++ 28 files changed, 1553 insertions(+), 26 deletions(-) create mode 100644 lib/features/manga/data/page_translator.dart create mode 100644 lib/features/manga/domain/translate/page_translation.dart create mode 100644 lib/features/manga/presentation/widgets/page_translation_overlay.dart create mode 100644 test/features/manga/page_translation_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 6e6bbb79..3b9de592 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -1293,7 +1293,19 @@ "highlights": "التظليلات", "remove_highlight": "إزالة التظليل", "highlight_note": "أضف ملاحظة (اختياري)", - "no_highlights": "لا توجد تظليلات بعد. حدّد نصًا واختر «تظليل»." + "no_highlights": "لا توجد تظليلات بعد. حدّد نصًا واختر «تظليل».", + "translate_page": "ترجمة الصفحة", + "translate_preview": "الصفحة المترجمة", + "translate_apply": "تطبيق", + "translate_show_original": "عرض الأصل", + "translate_no_text": "لم يُعثر على نص في هذه الصفحة.", + "translate_daily_limit": "لقد استهلكت ترجمات اليوم. حاول مجددًا غدًا.", + "translate_offline": "أنت غير متصل. اتصل بالإنترنت لترجمة هذه الصفحة.", + "translate_sign_in": "سجّل الدخول لترجمة الصفحات.", + "translate_failed": "تعذّرت ترجمة هذه الصفحة. حاول مرة أخرى.", + "translate_language": "ترجمة الصفحات إلى", + "translate_auto": "ترجمة كل صفحة أثناء القراءة", + "translate_auto_desc": "كل صفحة جديدة تستهلك ترجمة واحدة من رصيد اليوم." }, "cloudflare": { "title": "تأكيد أنك لست روبوتاً", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 6cef5071..675a2d7c 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -1293,7 +1293,19 @@ "highlights": "دیارکراوەکان", "remove_highlight": "لابردنی دیارکردن", "highlight_note": "تێبینییەک زیاد بکە (ئارەزوومەندانە)", - "no_highlights": "هێشتا هیچ دیارکراوێک نییە. هەندێک دەق هەڵبژێرە و دیارکردن هەڵبژێرە." + "no_highlights": "هێشتا هیچ دیارکراوێک نییە. هەندێک دەق هەڵبژێرە و دیارکردن هەڵبژێرە.", + "translate_page": "وەرگێڕانی پەڕە", + "translate_preview": "پەڕەی وەرگێڕدراو", + "translate_apply": "جێبەجێکردن", + "translate_show_original": "پیشاندانی ڕەسەن", + "translate_no_text": "هیچ دەقێک لەم پەڕەیەدا نەدۆزرایەوە.", + "translate_daily_limit": "وەرگێڕانەکانی ئەمڕۆت تەواو بوون. سبەی دووبارە هەوڵ بدەرەوە.", + "translate_offline": "پەیوەندیت بە ئینتەرنێتەوە نییە. بۆ وەرگێڕانی ئەم پەڕەیە پەیوەست بە.", + "translate_sign_in": "بۆ وەرگێڕانی پەڕەکان بچۆ ژوورەوە.", + "translate_failed": "نەتوانرا ئەم پەڕەیە وەربگێڕدرێت. دووبارە هەوڵ بدەرەوە.", + "translate_language": "وەرگێڕانی پەڕەکان بۆ", + "translate_auto": "وەرگێڕانی هەموو پەڕەیەک لە کاتی خوێندنەوەدا", + "translate_auto_desc": "هەر پەڕەیەکی نوێ یەکێک لە وەرگێڕانەکانی ئەمڕۆ بەکاردەهێنێت." }, "cloudflare": { "title": "پشتڕاستی بکەرەوە کە مرۆڤیت", diff --git a/assets/translations/de.json b/assets/translations/de.json index 0970f03c..4ef2979f 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -1293,7 +1293,19 @@ "highlights": "Markierungen", "remove_highlight": "Markierung entfernen", "highlight_note": "Notiz hinzufügen (optional)", - "no_highlights": "Noch keine Markierungen. Text auswählen und „Markieren“ wählen." + "no_highlights": "Noch keine Markierungen. Text auswählen und „Markieren“ wählen.", + "translate_page": "Seite übersetzen", + "translate_preview": "Übersetzte Seite", + "translate_apply": "Übernehmen", + "translate_show_original": "Original anzeigen", + "translate_no_text": "Auf dieser Seite wurde kein Text gefunden.", + "translate_daily_limit": "Du hast die Übersetzungen für heute aufgebraucht. Versuch es morgen wieder.", + "translate_offline": "Du bist offline. Verbinde dich mit dem Internet, um diese Seite zu übersetzen.", + "translate_sign_in": "Melde dich an, um Seiten zu übersetzen.", + "translate_failed": "Diese Seite konnte nicht übersetzt werden. Versuch es noch einmal.", + "translate_language": "Seiten übersetzen in", + "translate_auto": "Jede Seite beim Lesen übersetzen", + "translate_auto_desc": "Jede neue Seite verbraucht eine der heutigen Übersetzungen." }, "cloudflare": { "title": "Bestätige, dass du ein Mensch bist", diff --git a/assets/translations/en.json b/assets/translations/en.json index 8fbca5aa..c1cabeb9 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -1293,7 +1293,19 @@ "highlights": "Highlights", "remove_highlight": "Remove highlight", "highlight_note": "Add a note (optional)", - "no_highlights": "No highlights yet. Select some text and choose Highlight." + "no_highlights": "No highlights yet. Select some text and choose Highlight.", + "translate_page": "Translate page", + "translate_preview": "Translated page", + "translate_apply": "Apply", + "translate_show_original": "Show original", + "translate_no_text": "No text found on this page.", + "translate_daily_limit": "You've used today's translations. Try again tomorrow.", + "translate_offline": "You're offline. Connect to the internet to translate this page.", + "translate_sign_in": "Sign in to translate pages.", + "translate_failed": "Couldn't translate this page. Try again.", + "translate_language": "Translate pages into", + "translate_auto": "Translate every page as I read", + "translate_auto_desc": "Each new page uses one of today's translations." }, "cloudflare": { "title": "Verify you're human", diff --git a/assets/translations/es.json b/assets/translations/es.json index 319ddb48..eebf7509 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -1293,7 +1293,19 @@ "highlights": "Resaltados", "remove_highlight": "Quitar resaltado", "highlight_note": "Añade una nota (opcional)", - "no_highlights": "Aún no hay resaltados. Selecciona texto y elige Resaltar." + "no_highlights": "Aún no hay resaltados. Selecciona texto y elige Resaltar.", + "translate_page": "Traducir página", + "translate_preview": "Página traducida", + "translate_apply": "Aplicar", + "translate_show_original": "Mostrar original", + "translate_no_text": "No se encontró texto en esta página.", + "translate_daily_limit": "Ya usaste las traducciones de hoy. Vuelve a intentarlo mañana.", + "translate_offline": "Estás sin conexión. Conéctate a internet para traducir esta página.", + "translate_sign_in": "Inicia sesión para traducir páginas.", + "translate_failed": "No se pudo traducir esta página. Inténtalo de nuevo.", + "translate_language": "Traducir páginas al", + "translate_auto": "Traducir cada página mientras leo", + "translate_auto_desc": "Cada página nueva usa una de las traducciones de hoy." }, "cloudflare": { "title": "Verifica que eres humano", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 599703a9..d00bb576 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -1293,7 +1293,19 @@ "highlights": "Surlignages", "remove_highlight": "Retirer le surlignage", "highlight_note": "Ajouter une note (facultatif)", - "no_highlights": "Aucun surlignage pour l’instant. Sélectionnez du texte et choisissez Surligner." + "no_highlights": "Aucun surlignage pour l’instant. Sélectionnez du texte et choisissez Surligner.", + "translate_page": "Traduire la page", + "translate_preview": "Page traduite", + "translate_apply": "Appliquer", + "translate_show_original": "Afficher l’original", + "translate_no_text": "Aucun texte trouvé sur cette page.", + "translate_daily_limit": "Vous avez utilisé toutes les traductions du jour. Réessayez demain.", + "translate_offline": "Vous êtes hors ligne. Connectez-vous à Internet pour traduire cette page.", + "translate_sign_in": "Connectez-vous pour traduire des pages.", + "translate_failed": "Impossible de traduire cette page. Réessayez.", + "translate_language": "Traduire les pages en", + "translate_auto": "Traduire chaque page pendant la lecture", + "translate_auto_desc": "Chaque nouvelle page utilise une des traductions du jour." }, "cloudflare": { "title": "Confirme que tu es humain", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 12d0cb70..c0d916a3 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -1293,7 +1293,19 @@ "highlights": "हाइलाइट", "remove_highlight": "हाइलाइट हटाएँ", "highlight_note": "नोट जोड़ें (वैकल्पिक)", - "no_highlights": "अभी कोई हाइलाइट नहीं है। कुछ टेक्स्ट चुनें और “हाइलाइट करें” चुनें।" + "no_highlights": "अभी कोई हाइलाइट नहीं है। कुछ टेक्स्ट चुनें और “हाइलाइट करें” चुनें।", + "translate_page": "पेज का अनुवाद करें", + "translate_preview": "अनुवादित पेज", + "translate_apply": "लागू करें", + "translate_show_original": "मूल दिखाएँ", + "translate_no_text": "इस पेज पर कोई टेक्स्ट नहीं मिला।", + "translate_daily_limit": "आज के अनुवाद खत्म हो गए हैं। कल फिर कोशिश करें।", + "translate_offline": "आप ऑफ़लाइन हैं। इस पेज का अनुवाद करने के लिए इंटरनेट से जुड़ें।", + "translate_sign_in": "पेजों का अनुवाद करने के लिए साइन इन करें।", + "translate_failed": "इस पेज का अनुवाद नहीं हो सका। फिर से कोशिश करें।", + "translate_language": "पेजों का अनुवाद इस भाषा में करें", + "translate_auto": "पढ़ते समय हर पेज का अनुवाद करें", + "translate_auto_desc": "हर नया पेज आज के अनुवादों में से एक इस्तेमाल करता है।" }, "cloudflare": { "title": "पुष्टि करें कि आप इंसान हैं", diff --git a/assets/translations/id.json b/assets/translations/id.json index 28442646..e292de5c 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -1293,7 +1293,19 @@ "highlights": "Sorotan", "remove_highlight": "Hapus sorotan", "highlight_note": "Tambahkan catatan (opsional)", - "no_highlights": "Belum ada sorotan. Pilih teks lalu pilih Sorot." + "no_highlights": "Belum ada sorotan. Pilih teks lalu pilih Sorot.", + "translate_page": "Terjemahkan halaman", + "translate_preview": "Halaman terjemahan", + "translate_apply": "Terapkan", + "translate_show_original": "Tampilkan asli", + "translate_no_text": "Tidak ada teks di halaman ini.", + "translate_daily_limit": "Jatah terjemahan hari ini sudah habis. Coba lagi besok.", + "translate_offline": "Kamu sedang offline. Sambungkan ke internet untuk menerjemahkan halaman ini.", + "translate_sign_in": "Masuk untuk menerjemahkan halaman.", + "translate_failed": "Halaman ini tidak dapat diterjemahkan. Coba lagi.", + "translate_language": "Terjemahkan halaman ke", + "translate_auto": "Terjemahkan setiap halaman saat membaca", + "translate_auto_desc": "Setiap halaman baru memakai satu jatah terjemahan hari ini." }, "cloudflare": { "title": "Verifikasi bahwa kamu manusia", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index e876ed5b..d04bcf8d 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -1293,7 +1293,19 @@ "highlights": "Ronîkirin", "remove_highlight": "Ronîkirinê rake", "highlight_note": "Notekê zêde bike (bijarte)", - "no_highlights": "Hîn ronîkirin tune. Hinek nivîsê hilbijêre û Ronî bike bitikîne." + "no_highlights": "Hîn ronîkirin tune. Hinek nivîsê hilbijêre û Ronî bike bitikîne.", + "translate_page": "Rûpelê wergerîne", + "translate_preview": "Rûpela wergerandî", + "translate_apply": "Bisepîne", + "translate_show_original": "Orîjînalê nîşan bide", + "translate_no_text": "Di vê rûpelê de tu nivîs nehat dîtin.", + "translate_daily_limit": "Wergerên te yên îro qediyan. Sibê dîsa biceribîne.", + "translate_offline": "Tu ne girêdayî yî. Ji bo wergerandina vê rûpelê bi înternetê ve girê bide.", + "translate_sign_in": "Ji bo wergerandina rûpelan têkeve.", + "translate_failed": "Ev rûpel nehat wergerandin. Dîsa biceribîne.", + "translate_language": "Rûpelan wergerîne bo", + "translate_auto": "Dema dixwînim her rûpelê wergerîne", + "translate_auto_desc": "Her rûpela nû yek ji wergerên îro bi kar tîne." }, "cloudflare": { "title": "Piştrast bike ku tu mirov î", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 8ca39779..6e02e9f2 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -1293,7 +1293,19 @@ "highlights": "Markeringen", "remove_highlight": "Markering verwijderen", "highlight_note": "Notitie toevoegen (optioneel)", - "no_highlights": "Nog geen markeringen. Selecteer tekst en kies Markeren." + "no_highlights": "Nog geen markeringen. Selecteer tekst en kies Markeren.", + "translate_page": "Pagina vertalen", + "translate_preview": "Vertaalde pagina", + "translate_apply": "Toepassen", + "translate_show_original": "Origineel tonen", + "translate_no_text": "Geen tekst gevonden op deze pagina.", + "translate_daily_limit": "Je hebt de vertalingen van vandaag opgebruikt. Probeer het morgen opnieuw.", + "translate_offline": "Je bent offline. Maak verbinding met internet om deze pagina te vertalen.", + "translate_sign_in": "Log in om pagina's te vertalen.", + "translate_failed": "Deze pagina kon niet worden vertaald. Probeer het opnieuw.", + "translate_language": "Pagina's vertalen naar", + "translate_auto": "Elke pagina vertalen tijdens het lezen", + "translate_auto_desc": "Elke nieuwe pagina gebruikt een van de vertalingen van vandaag." }, "cloudflare": { "title": "Bevestig dat je een mens bent", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index f79b5482..d4461431 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -1293,7 +1293,19 @@ "highlights": "Destaques", "remove_highlight": "Remover destaque", "highlight_note": "Adicionar uma nota (opcional)", - "no_highlights": "Nenhum destaque ainda. Selecione um texto e escolha Destacar." + "no_highlights": "Nenhum destaque ainda. Selecione um texto e escolha Destacar.", + "translate_page": "Traduzir página", + "translate_preview": "Página traduzida", + "translate_apply": "Aplicar", + "translate_show_original": "Mostrar original", + "translate_no_text": "Nenhum texto encontrado nesta página.", + "translate_daily_limit": "Você usou as traduções de hoje. Tente novamente amanhã.", + "translate_offline": "Você está offline. Conecte-se à internet para traduzir esta página.", + "translate_sign_in": "Entre na sua conta para traduzir páginas.", + "translate_failed": "Não foi possível traduzir esta página. Tente novamente.", + "translate_language": "Traduzir páginas para", + "translate_auto": "Traduzir cada página enquanto leio", + "translate_auto_desc": "Cada página nova usa uma das traduções de hoje." }, "cloudflare": { "title": "Confirme que você é humano", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index c85aef09..60f2a5b0 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -1293,7 +1293,19 @@ "highlights": "Выделения", "remove_highlight": "Убрать выделение", "highlight_note": "Добавить заметку (необязательно)", - "no_highlights": "Выделений пока нет. Выделите текст и выберите «Выделить»." + "no_highlights": "Выделений пока нет. Выделите текст и выберите «Выделить».", + "translate_page": "Перевести страницу", + "translate_preview": "Переведённая страница", + "translate_apply": "Применить", + "translate_show_original": "Показать оригинал", + "translate_no_text": "На этой странице не найден текст.", + "translate_daily_limit": "Переводы на сегодня закончились. Попробуйте завтра.", + "translate_offline": "Нет подключения к интернету. Подключитесь, чтобы перевести страницу.", + "translate_sign_in": "Войдите в аккаунт, чтобы переводить страницы.", + "translate_failed": "Не удалось перевести страницу. Попробуйте ещё раз.", + "translate_language": "Язык перевода страниц", + "translate_auto": "Переводить каждую страницу во время чтения", + "translate_auto_desc": "Каждая новая страница расходует один перевод из дневного лимита." }, "cloudflare": { "title": "Подтвердите, что вы человек", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index f22f299e..a74075a2 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -1293,7 +1293,19 @@ "highlights": "Vurgular", "remove_highlight": "Vurguyu kaldır", "highlight_note": "Not ekle (isteğe bağlı)", - "no_highlights": "Henüz vurgu yok. Metin seçip Vurgula'yı seçin." + "no_highlights": "Henüz vurgu yok. Metin seçip Vurgula'yı seçin.", + "translate_page": "Sayfayı çevir", + "translate_preview": "Çevrilmiş sayfa", + "translate_apply": "Uygula", + "translate_show_original": "Orijinali göster", + "translate_no_text": "Bu sayfada metin bulunamadı.", + "translate_daily_limit": "Bugünkü çeviri hakkın doldu. Yarın tekrar dene.", + "translate_offline": "Çevrimdışısın. Bu sayfayı çevirmek için internete bağlan.", + "translate_sign_in": "Sayfaları çevirmek için giriş yap.", + "translate_failed": "Bu sayfa çevrilemedi. Tekrar dene.", + "translate_language": "Sayfaları şu dile çevir", + "translate_auto": "Okurken her sayfayı çevir", + "translate_auto_desc": "Her yeni sayfa bugünkü çeviri hakkından birini kullanır." }, "cloudflare": { "title": "İnsan olduğunu doğrula", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index a0ec0494..e015299f 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -1293,7 +1293,19 @@ "highlights": "Belgilanganlar", "remove_highlight": "Belgini olib tashlash", "highlight_note": "Izoh qo'shing (ixtiyoriy)", - "no_highlights": "Hali belgilanganlar yo'q. Matnni tanlang va «Belgilash»ni bosing." + "no_highlights": "Hali belgilanganlar yo'q. Matnni tanlang va «Belgilash»ni bosing.", + "translate_page": "Sahifani tarjima qilish", + "translate_preview": "Tarjima qilingan sahifa", + "translate_apply": "Qo'llash", + "translate_show_original": "Asl matnni ko'rsatish", + "translate_no_text": "Bu sahifada matn topilmadi.", + "translate_daily_limit": "Bugungi tarjimalaringiz tugadi. Ertaga qayta urinib ko'ring.", + "translate_offline": "Internet yo'q. Sahifani tarjima qilish uchun internetga ulaning.", + "translate_sign_in": "Sahifalarni tarjima qilish uchun hisobingizga kiring.", + "translate_failed": "Sahifani tarjima qilib bo'lmadi. Qayta urinib ko'ring.", + "translate_language": "Sahifalarni qaysi tilga tarjima qilish", + "translate_auto": "O'qiyotganimda har bir sahifani tarjima qilish", + "translate_auto_desc": "Har bir yangi sahifa bugungi tarjimalardan bittasini sarflaydi." }, "cloudflare": { "title": "Siz odam ekanligingizni tasdiqlang", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 5a8f5b47..179a4d92 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -1293,7 +1293,19 @@ "highlights": "Đoạn đánh dấu", "remove_highlight": "Bỏ đánh dấu", "highlight_note": "Thêm ghi chú (tùy chọn)", - "no_highlights": "Chưa có đoạn đánh dấu. Hãy chọn đoạn văn rồi chọn Đánh dấu." + "no_highlights": "Chưa có đoạn đánh dấu. Hãy chọn đoạn văn rồi chọn Đánh dấu.", + "translate_page": "Dịch trang", + "translate_preview": "Trang đã dịch", + "translate_apply": "Áp dụng", + "translate_show_original": "Hiện bản gốc", + "translate_no_text": "Không tìm thấy chữ trên trang này.", + "translate_daily_limit": "Bạn đã dùng hết lượt dịch hôm nay. Hãy thử lại vào ngày mai.", + "translate_offline": "Bạn đang ngoại tuyến. Hãy kết nối internet để dịch trang này.", + "translate_sign_in": "Đăng nhập để dịch trang.", + "translate_failed": "Không dịch được trang này. Hãy thử lại.", + "translate_language": "Dịch trang sang", + "translate_auto": "Dịch từng trang khi đang đọc", + "translate_auto_desc": "Mỗi trang mới dùng một lượt dịch của hôm nay." }, "cloudflare": { "title": "Xác minh bạn là người", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 91e3e54a..1cf7b5e4 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -1293,7 +1293,19 @@ "highlights": "標記清單", "remove_highlight": "移除標記", "highlight_note": "加個備註(可選)", - "no_highlights": "仲未有標記。揀選一段文字,再撳「標記」。" + "no_highlights": "仲未有標記。揀選一段文字,再撳「標記」。", + "translate_page": "翻譯呢版", + "translate_preview": "已翻譯嘅版", + "translate_apply": "套用", + "translate_show_original": "睇返原文", + "translate_no_text": "呢版搵唔到文字。", + "translate_daily_limit": "你今日嘅翻譯次數用晒喇,聽日再試啦。", + "translate_offline": "你而家離線。連上網先可以翻譯呢版。", + "translate_sign_in": "登入之後先可以翻譯漫畫版面。", + "translate_failed": "翻譯唔到呢版,請再試一次。", + "translate_language": "將版面翻譯成", + "translate_auto": "睇嘅時候自動翻譯每一版", + "translate_auto_desc": "每翻譯一版新嘅會用一次今日嘅翻譯次數。" }, "cloudflare": { "title": "驗證你係真人", diff --git a/ios/Podfile b/ios/Podfile index 0d8be780..f9c89d9d 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,6 +1,6 @@ -# iOS 15: Xcode 27 builds nothing older, and plugins (workmanager_apple) -# already ask for 14. -platform :ios, '15.0' +# iOS 15.5: ML Kit text recognition (reader page translation) asks for it, and +# Xcode 27 builds nothing older than 15 anyway. +platform :ios, '15.5' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -43,8 +43,8 @@ post_install do |installer| # Pods that still declare an older floor would build below the app's, # which Xcode 27 refuses. target.build_configurations.each do |config| - if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 15.0 - config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0' + if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 15.5 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.5' end end end diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 59a51e96..69ac5087 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -496,7 +496,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -631,7 +631,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -682,7 +682,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.5; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 777deeff..1a869493 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -266,6 +266,8 @@ class AppConstants { static const String subtitleStyleKey = 'subtitle_style'; static const String subtitleAutoTranslateKey = 'subtitle_auto_translate'; static const String subtitleTranslateLangKey = 'subtitle_translate_lang'; + static const String mangaTranslateLangKey = 'manga_translate_lang'; + static const String mangaAutoTranslateKey = 'manga_auto_translate'; /// Ask which engine to use every time playback starts, instead of silently /// using [playerEngineKey]. Absent ⇒ off, so an existing install keeps the diff --git a/lib/core/storage/hive_service.dart b/lib/core/storage/hive_service.dart index 443c85cd..5c6793fc 100644 --- a/lib/core/storage/hive_service.dart +++ b/lib/core/storage/hive_service.dart @@ -1446,6 +1446,31 @@ class HiveService { await _settingsBox.put(AppConstants.subtitleTranslateLangKey, lang.trim()); } + /// Target language for manga page translation. Follows the subtitle one + /// until chosen. + String getMangaTranslateLang() { + final saved = _settingsBox.get(AppConstants.mangaTranslateLangKey); + if (saved is String && saved.isNotEmpty) return saved; + return getSubtitleTranslateLang(); + } + + Future setMangaTranslateLang(String lang) async { + await _settingsBox.put(AppConstants.mangaTranslateLangKey, lang.trim()); + } + + /// Translate each manga page as it comes on screen. Off by default: every + /// new page spends one of the day's translations. + bool get mangaAutoTranslate => + _settingsBox.get( + AppConstants.mangaAutoTranslateKey, + defaultValue: false, + ) == + true; + + Future setMangaAutoTranslate(bool value) async { + await _settingsBox.put(AppConstants.mangaAutoTranslateKey, value); + } + SubtitleStyle getSubtitleStyle() { final raw = _settingsBox.get(AppConstants.subtitleStyleKey); if (raw is String && raw.isNotEmpty) { diff --git a/lib/core/subtitles/subtitle_translation_service.dart b/lib/core/subtitles/subtitle_translation_service.dart index ffeae1bc..ab87b8f5 100644 --- a/lib/core/subtitles/subtitle_translation_service.dart +++ b/lib/core/subtitles/subtitle_translation_service.dart @@ -55,6 +55,16 @@ class SubtitleDailyLimitReached implements Exception { String toString() => message; } +/// Thrown when the translation request never reached the server. +class TranslationOffline implements Exception { + const TranslationOffline(); +} + +/// Thrown when translating needs an account and there is none signed in. +class TranslationNeedsSignIn implements Exception { + const TranslationNeedsSignIn(); +} + /// Asks the backend to translate a subtitle the source has none of in the /// target language. /// @@ -171,7 +181,12 @@ class SubtitleTranslationService { } return out; } on DioException catch (e) { + if (e.type == DioExceptionType.connectionError || + e.type == DioExceptionType.connectionTimeout) { + throw const TranslationOffline(); + } final status = e.response?.statusCode; + if (status == 401) throw const TranslationNeedsSignIn(); final message = _messageFrom(e); if (status == 429) throw SubtitleDailyLimitReached(message); throw Exception(message); diff --git a/lib/features/manga/data/page_translator.dart b/lib/features/manga/data/page_translator.dart new file mode 100644 index 00000000..36a0a8bf --- /dev/null +++ b/lib/features/manga/data/page_translator.dart @@ -0,0 +1,177 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:soplay/core/subtitles/subtitle_translation_service.dart'; +import 'package:soplay/features/manga/domain/entities/manga_page_entity.dart'; +import 'package:soplay/features/manga/domain/translate/page_translation.dart'; + +/// Reads the lettering off a manga page on the device and has the backend +/// translate it, one request per page. +/// +/// Android and iOS only: ML Kit has no desktop build. [isSupported] is what +/// the reader branches on before showing anything. +class PageTranslator { + PageTranslator({SubtitleTranslationService service = + const SubtitleTranslationService()}) + : _service = service; + + final SubtitleTranslationService _service; + TextRecognizer? _recognizer; + + /// The real host, not [defaultTargetPlatform]: widget tests report Android + /// there and have no ML Kit. + static bool get isSupported => + !kIsWeb && (Platform.isAndroid || Platform.isIOS); + + /// A translation made before, from disk. Costs nothing. + Future cached({ + required String chapterUrl, + required int pageIndex, + required String targetLang, + }) async { + try { + final file = await _cacheFile(chapterUrl, pageIndex, targetLang); + if (!await file.exists()) return null; + return PageTranslation.tryFromJson(jsonDecode(await file.readAsString())); + } catch (_) { + return null; + } + } + + /// Recognises, merges and translates [page]. Throws [PageTranslateException]. + Future translate({ + required MangaPageEntity page, + required Map headers, + required String chapterUrl, + required int pageIndex, + required String targetLang, + }) async { + final path = await _imagePath(page, headers); + final size = await _imageSize(path); + + final recognizer = _recognizer ??= TextRecognizer( + script: TextRecognitionScript.latin, + ); + final RecognizedText found; + try { + found = await recognizer.processImage(InputImage.fromFilePath(path)); + } catch (e) { + throw PageTranslateException(PageTranslateFailure.failed, '$e'); + } + + final blocks = mergeBlocks([ + for (final b in found.blocks) + OcrBlock( + rect: b.boundingBox, + text: b.lines.map((l) => l.text).join('\n'), + lineHeight: b.lines.isEmpty + ? b.boundingBox.height + : b.boundingBox.height / b.lines.length, + ), + ]); + final texts = [for (final b in blocks) normalizeOcrText(b.text)]; + if (texts.every((t) => t.isEmpty)) { + throw const PageTranslateException(PageTranslateFailure.noText); + } + + final List out; + try { + out = await _service.translateLines( + lines: texts, + targetLang: targetLang, + docKey: pageTranslateDocKey(chapterUrl, pageIndex), + count: true, + ); + } on SubtitleDailyLimitReached catch (e) { + throw PageTranslateException(PageTranslateFailure.dailyLimit, e.message); + } on TranslationOffline { + throw const PageTranslateException(PageTranslateFailure.offline); + } on TranslationNeedsSignIn { + throw const PageTranslateException(PageTranslateFailure.signIn); + } catch (e) { + throw PageTranslateException(PageTranslateFailure.failed, '$e'); + } + + final translation = PageTranslation( + imageSize: size, + targetLang: targetLang, + blocks: [ + for (var i = 0; i < blocks.length; i++) + if (texts[i].isNotEmpty) + TranslatedBlock(rect: blocks[i].rect, source: texts[i], text: out[i]), + ], + ); + try { + final file = await _cacheFile(chapterUrl, pageIndex, targetLang); + await file.parent.create(recursive: true); + await file.writeAsString(jsonEncode(translation.toJson())); + } catch (_) {} + return translation; + } + + Future dispose() async { + await _recognizer?.close(); + _recognizer = null; + } + + /// The page as a file: a downloaded page already is one, and a network page + /// is in the image cache once it has been on screen. + Future _imagePath( + MangaPageEntity page, + Map headers, + ) async { + final url = page.imageUrl; + if (!url.startsWith('http')) return url; + final key = page.cacheKey ?? url; + try { + final hit = await DefaultCacheManager().getFileFromCache(key); + if (hit != null) return hit.file.path; + final cookie = page.cookie; + final file = await DefaultCacheManager().getSingleFile( + url, + key: key, + headers: { + ...headers, + ...page.headers, + if (cookie != null && cookie.isNotEmpty) 'Cookie': cookie, + }, + ); + return file.path; + } on SocketException { + throw const PageTranslateException(PageTranslateFailure.offline); + } catch (e) { + throw PageTranslateException(PageTranslateFailure.failed, '$e'); + } + } + + /// Width and height from the file header, without decoding the page. + Future _imageSize(String path) async { + try { + final buffer = await ui.ImmutableBuffer.fromFilePath(path); + final descriptor = await ui.ImageDescriptor.encoded(buffer); + final size = ui.Size( + descriptor.width.toDouble(), + descriptor.height.toDouble(), + ); + descriptor.dispose(); + buffer.dispose(); + return size; + } catch (e) { + throw PageTranslateException(PageTranslateFailure.failed, '$e'); + } + } + + Future _cacheFile(String chapterUrl, int pageIndex, String lang) async { + final dir = await getApplicationSupportDirectory(); + final name = sha1 + .convert(utf8.encode('$chapterUrl\n$pageIndex\n$lang')) + .toString(); + return File('${dir.path}/manga_translations/$name.json'); + } +} diff --git a/lib/features/manga/domain/translate/page_translation.dart b/lib/features/manga/domain/translate/page_translation.dart new file mode 100644 index 00000000..8e38e75d --- /dev/null +++ b/lib/features/manga/domain/translate/page_translation.dart @@ -0,0 +1,373 @@ +import 'dart:convert'; +import 'dart:math' as math; +import 'dart:ui' show Rect, Size; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; + +/// One piece of text found on a page, in the image's own pixels. +class OcrBlock { + const OcrBlock({ + required this.rect, + required this.text, + required this.lineHeight, + }); + + final Rect rect; + final String text; + + /// Average height of one recognised line, which is what "close together" + /// is measured in: lettering size varies by page far more than by bubble. + final double lineHeight; +} + +/// A bubble's worth of text and what it says in the target language. +class TranslatedBlock { + const TranslatedBlock({ + required this.rect, + required this.source, + required this.text, + }); + + final Rect rect; + final String source; + final String text; + + Map toJson() => { + 'l': rect.left, + 't': rect.top, + 'r': rect.right, + 'b': rect.bottom, + 's': source, + 'x': text, + }; + + static TranslatedBlock fromJson(Map m) => TranslatedBlock( + rect: Rect.fromLTRB( + (m['l'] as num).toDouble(), + (m['t'] as num).toDouble(), + (m['r'] as num).toDouble(), + (m['b'] as num).toDouble(), + ), + source: '${m['s'] ?? ''}', + text: '${m['x'] ?? ''}', + ); +} + +/// A translated page: the boxes to cover, in the coordinates of an image of +/// [imageSize]. +class PageTranslation { + const PageTranslation({ + required this.imageSize, + required this.targetLang, + required this.blocks, + }); + + final Size imageSize; + final String targetLang; + final List blocks; + + Map toJson() => { + 'w': imageSize.width, + 'h': imageSize.height, + 'lang': targetLang, + 'blocks': [for (final b in blocks) b.toJson()], + }; + + static PageTranslation? tryFromJson(Object? raw) { + if (raw is! Map) return null; + try { + return PageTranslation( + imageSize: Size( + (raw['w'] as num).toDouble(), + (raw['h'] as num).toDouble(), + ), + targetLang: '${raw['lang']}', + blocks: [ + for (final b in raw['blocks'] as List) + TranslatedBlock.fromJson(b as Map), + ], + ); + } catch (_) { + return null; + } + } +} + +/// The server keeps 200 characters of a docKey. A long chapter url cut there +/// would lose the page number and make every page of the chapter one document. +const int kDocKeyLimit = 200; + +/// The quota key for one page: the backend charges once per key per day. +String pageTranslateDocKey(String chapterUrl, int pageIndex) { + final plain = 'manga:$chapterUrl:$pageIndex'; + if (plain.length <= kDocKeyLimit) return plain; + final digest = sha1.convert(utf8.encode(chapterUrl)).toString(); + return 'manga:$digest:$pageIndex'; +} + +/// Languages written right to left, among those a page can be translated into. +const Set kRtlLanguages = {'ar', 'fa', 'he', 'ur', 'ckb', 'ps', 'yi'}; + +bool isRtlLanguage(String lang) => + kRtlLanguages.contains(lang.toLowerCase().split(RegExp('[-_]')).first); + +/// Joins blocks whose boxes come within [gapFactor] line heights of each +/// other, so a bubble the recogniser split in two is translated as one +/// sentence. Blocks with no letters (page numbers, stray marks) are dropped. +/// +/// Reading order is top to bottom, then left to right, both for the blocks +/// returned and for the lines inside a merged one. +List mergeBlocks(List blocks, {double gapFactor = 0.8}) { + final kept = [ + for (final b in blocks) + if (_hasLetters(b.text) && !b.rect.isEmpty) b, + ]; + final n = kept.length; + final parent = List.generate(n, (i) => i); + int find(int i) { + while (parent[i] != i) { + parent[i] = parent[parent[i]]; + i = parent[i]; + } + return i; + } + + for (var i = 0; i < n; i++) { + for (var j = i + 1; j < n; j++) { + if (_close(kept[i], kept[j], gapFactor)) { + parent[find(i)] = find(j); + } + } + } + + final groups = >{}; + for (var i = 0; i < n; i++) { + groups.putIfAbsent(find(i), () => []).add(kept[i]); + } + + final merged = [ + for (final group in groups.values) + if (group.length == 1) group.single else _union(group), + ]; + merged.sort(_readingOrder); + return merged; +} + +bool _close(OcrBlock a, OcrBlock b, double gapFactor) { + final gap = gapFactor * math.min(a.lineHeight, b.lineHeight); + final dx = math.max( + 0.0, + math.max(a.rect.left, b.rect.left) - math.min(a.rect.right, b.rect.right), + ); + final dy = math.max( + 0.0, + math.max(a.rect.top, b.rect.top) - math.min(a.rect.bottom, b.rect.bottom), + ); + if (dx > gap || dy > gap) return false; + // Stacked lines of one bubble share a column. Two bubbles side by side + // touch at the edges but barely overlap horizontally. + if (dy > 0) { + final overlap = + math.min(a.rect.right, b.rect.right) - + math.max(a.rect.left, b.rect.left); + return overlap >= 0.3 * math.min(a.rect.width, b.rect.width); + } + return true; +} + +OcrBlock _union(List group) { + group.sort(_readingOrder); + var rect = group.first.rect; + var lines = 0.0; + for (final b in group) { + rect = rect.expandToInclude(b.rect); + lines += b.lineHeight; + } + return OcrBlock( + rect: rect, + text: group.map((b) => b.text).join('\n'), + lineHeight: lines / group.length, + ); +} + +int _readingOrder(OcrBlock a, OcrBlock b) { + // Within half a line counts as the same row, so a slightly lower block to + // the left still reads first. + final tolerance = 0.5 * math.min(a.lineHeight, b.lineHeight); + if ((a.rect.top - b.rect.top).abs() > tolerance) { + return a.rect.top.compareTo(b.rect.top); + } + return a.rect.left.compareTo(b.rect.left); +} + +bool _hasLetters(String text) => RegExp(r'\p{L}', unicode: true).hasMatch(text); + +/// Prepares recognised text for the translator: line breaks become spaces, +/// a word split by a hyphen at a line end is joined again, and ALL-CAPS +/// lettering becomes sentence case, which translators handle far better. +String normalizeOcrText(String text) { + var out = text.replaceAllMapped( + RegExp(r'(\p{L})-\s*\n\s*(\p{L})', unicode: true), + (m) => '${m[1]}${m[2]}', + ); + out = out.replaceAll(RegExp(r'\s+'), ' ').trim(); + final letters = out.replaceAll(RegExp(r'[^\p{L}]', unicode: true), ''); + if (letters.length > 1 && letters == letters.toUpperCase()) { + out = _sentenceCase(out.toLowerCase()); + } + return out; +} + +String _sentenceCase(String lower) { + final buffer = StringBuffer(); + var capitalize = true; + for (final rune in lower.runes) { + final ch = String.fromCharCode(rune); + if (capitalize && RegExp(r'\p{L}', unicode: true).hasMatch(ch)) { + buffer.write(ch.toUpperCase()); + capitalize = false; + } else { + buffer.write(ch); + } + if ('.!?'.contains(ch)) capitalize = true; + } + // The pronoun survives lower-casing as "i". + return buffer.toString().replaceAllMapped( + RegExp(r"\bi\b(?!\.)"), + (_) => 'I', + ); +} + +/// The largest font size between [min] and [max] at which [measure] says the +/// text fits [box], to within [precision]. [measure] lays the text out at a +/// font size, wrapped to the box width, and returns the size it took. +/// +/// Returns [min] when nothing fits; the caller lets that overflow rather than +/// draw text nobody can read. +double fitFontSize({ + required Size box, + required Size Function(double fontSize) measure, + double min = 6, + double max = 32, + double precision = 0.5, +}) { + bool fits(double size) { + final used = measure(size); + return used.width <= box.width + 0.01 && used.height <= box.height + 0.01; + } + + if (box.isEmpty) return min; + if (fits(max)) return max; + if (!fits(min)) return min; + var lo = min; + var hi = max; + while (hi - lo > precision) { + final mid = (lo + hi) / 2; + if (fits(mid)) { + lo = mid; + } else { + hi = mid; + } + } + return lo; +} + +/// What a page shows, kept per chapter and page while the reader is open. +class PageOverlayEntry { + const PageOverlayEntry({ + required this.translation, + this.applied = false, + this.showOriginal = false, + }); + + final PageTranslation translation; + final bool applied; + final bool showOriginal; + + PageOverlayEntry copyWith({bool? applied, bool? showOriginal}) => + PageOverlayEntry( + translation: translation, + applied: applied ?? this.applied, + showOriginal: showOriginal ?? this.showOriginal, + ); +} + +/// Overlay state for every translated page of the open reader. +/// +/// A translation is stored before it is shown: the preview offers it, and +/// only Apply puts it over the page. Cancel leaves whatever was there. +class PageOverlays extends ChangeNotifier { + final Map _entries = {}; + + static String keyFor(String chapterUrl, int pageIndex) => + '$chapterUrl#$pageIndex'; + + PageOverlayEntry? entry(String key) => _entries[key]; + + /// The stored translation for [key], if it was made into [lang]. + PageTranslation? translationFor(String key, String lang) { + final t = _entries[key]?.translation; + return t != null && t.targetLang == lang ? t : null; + } + + /// What to draw over the page, or null for the original. + PageTranslation? visible(String key) { + final e = _entries[key]; + if (e == null || !e.applied || e.showOriginal) return null; + return e.translation; + } + + bool isApplied(String key) => _entries[key]?.applied ?? false; + + /// Keeps [translation] without showing it, unless it replaces one that was + /// already applied in the same language. + void store(String key, PageTranslation translation) { + final old = _entries[key]; + final keepApplied = + old != null && + old.applied && + old.translation.targetLang == translation.targetLang; + _entries[key] = PageOverlayEntry( + translation: translation, + applied: keepApplied, + showOriginal: keepApplied && old.showOriginal, + ); + notifyListeners(); + } + + /// Puts the stored translation over the page; [showOriginal] keeps it + /// hidden but one toggle away. + void apply(String key, {bool showOriginal = false}) { + final e = _entries[key]; + if (e == null) return; + _entries[key] = e.copyWith(applied: true, showOriginal: showOriginal); + notifyListeners(); + } + + void toggleOriginal(String key) { + final e = _entries[key]; + if (e == null || !e.applied) return; + _entries[key] = e.copyWith(showOriginal: !e.showOriginal); + notifyListeners(); + } + + /// Takes the overlay off the page and keeps the translation for later. + void remove(String key) { + final e = _entries[key]; + if (e == null || !e.applied) return; + _entries[key] = e.copyWith(applied: false, showOriginal: false); + notifyListeners(); + } +} + +/// Why a page could not be translated, for the message the reader shows. +enum PageTranslateFailure { noText, dailyLimit, offline, signIn, failed } + +class PageTranslateException implements Exception { + const PageTranslateException(this.reason, [this.message]); + final PageTranslateFailure reason; + final String? message; + @override + String toString() => message ?? reason.name; +} diff --git a/lib/features/manga/presentation/pages/reader_page.dart b/lib/features/manga/presentation/pages/reader_page.dart index 8e4e3f02..4d15d11f 100644 --- a/lib/features/manga/presentation/pages/reader_page.dart +++ b/lib/features/manga/presentation/pages/reader_page.dart @@ -44,6 +44,10 @@ import 'package:soplay/features/manga/data/chapter_read_store.dart'; import 'package:soplay/features/manga/data/novel_highlight_store.dart'; import 'package:soplay/features/manga/domain/reading/novel_highlight.dart'; import 'package:soplay/features/manga/data/page_tiles.dart'; +import 'package:soplay/features/manga/data/page_translator.dart'; +import 'package:soplay/features/manga/domain/translate/page_translation.dart'; +import 'package:soplay/features/manga/presentation/widgets/page_translation_overlay.dart'; +import 'package:soplay/core/subtitles/subtitle_languages.dart'; import 'package:soplay/features/manga/presentation/widgets/pinch_zoom_scroll.dart'; import 'package:soplay/features/manga/presentation/widgets/tiled_zoom.dart'; import 'package:soplay/features/manga/domain/reading/chapter_progress.dart'; @@ -239,6 +243,23 @@ class _ReaderPageState extends State { /// read off a chapter in this file went unchecked. List get _chapters => widget.args.chapters; + /// Page translation: recognised on the device, translated by the backend. + /// Nothing here is touched where [PageTranslator.isSupported] is false. + final PageOverlays _overlays = PageOverlays(); + PageTranslator? _translator; + final Set _translating = {}; + bool _autoTranslate = false; + Timer? _autoTranslateTimer; + + bool get _canTranslate => + PageTranslator.isSupported && _html == null && _pages.isNotEmpty; + + PageOverlays? get _pageOverlays => + PageTranslator.isSupported ? _overlays : null; + + String _overlayKey(int page) => + PageOverlays.keyFor(_chapters[_chapterIndex].mediaRef, page); + @override void initState() { super.initState(); @@ -259,6 +280,10 @@ class _ReaderPageState extends State { _novelMargin = _hive.getNovelMargin(); _itemPositionsListener.itemPositions.addListener(_onItemPositions); _page.addListener(_maybePrefetchNextChapter); + if (PageTranslator.isSupported) { + _autoTranslate = _hive.mangaAutoTranslate; + _page.addListener(_scheduleAutoTranslate); + } _novelProgress.addListener(_maybePrefetchNextChapter); _novelScrollController.addListener(_onNovelScroll); if (!isDesktopPlatform) { @@ -331,6 +356,9 @@ class _ReaderPageState extends State { // Reading stops with the reader: nothing else on screen shows what is // being read or offers a way to stop it. _ttsLifecycle?.dispose(); + _autoTranslateTimer?.cancel(); + _overlays.dispose(); + unawaited(_translator?.dispose()); _tts?.removeListener(_onTtsChanged); _tts?.dispose(); _spreadController.dispose(); @@ -424,6 +452,7 @@ class _ReaderPageState extends State { _loading = false; }); _scheduleSave(); + _scheduleAutoTranslate(); return; } _localChapter = false; @@ -453,6 +482,7 @@ class _ReaderPageState extends State { }); if (isText) _afterTextLoaded(); _scheduleSave(); + _scheduleAutoTranslate(); case Failure(:final error): _pendingJump = null; setState(() { @@ -1537,6 +1567,8 @@ class _ReaderPageState extends State { page: _pages[i], headers: _headers, zoomable: false, + overlays: _pageOverlays, + overlayKey: _overlayKey(i), ); }, ), @@ -1588,6 +1620,8 @@ class _ReaderPageState extends State { page: _pages[i], headers: _headers, zoomable: true, + overlays: _pageOverlays, + overlayKey: _overlayKey(i), ), ), ), @@ -1638,6 +1672,8 @@ class _ReaderPageState extends State { // Not zoomable in a spread: a pinch would zoom one half // out from under the other. zoomable: false, + overlays: _pageOverlays, + overlayKey: _overlayKey(i), ), ), ], @@ -2164,6 +2200,7 @@ class _ReaderPageState extends State { ? () => _tts!.stop() : _startTts, ), + if (_canTranslate) _translateButton(), _downloadButton(ch), // Only when there is somewhere to go. A provider with no web page // for this chapter gets no button rather than a button that @@ -2205,6 +2242,183 @@ class _ReaderPageState extends State { ); } + Widget _translateButton() => ListenableBuilder( + listenable: Listenable.merge([_page, _overlays]), + builder: (context, _) { + final key = _overlayKey(_currentPage); + if (_translating.contains(key)) { + return const Padding( + padding: EdgeInsets.all(14), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + ); + } + final shown = _overlays.visible(key) != null; + return IconButton( + tooltip: 'manga.translate_page'.tr(), + icon: Icon( + Icons.translate_rounded, + color: shown ? _accent : Colors.white, + ), + onPressed: () => _translatePage(_currentPage), + ); + }, + ); + + void _scheduleAutoTranslate() { + _autoTranslateTimer?.cancel(); + if (!_autoTranslate) return; + // Only a page that stays on screen: flicking past ten pages should not + // spend ten translations. + _autoTranslateTimer = Timer(const Duration(milliseconds: 700), () { + if (!mounted || !_autoTranslate || !_canTranslate || _loading) return; + final page = _currentPage; + if (_overlays.entry(_overlayKey(page)) != null) return; + _translatePage(page, auto: true); + }); + } + + /// Translates [page] and offers it in a preview, or, for [auto], lays it + /// straight over the page. + Future _translatePage(int page, {bool auto = false}) async { + if (page < 0 || page >= _pages.length) return; + final chapterUrl = _chapters[_chapterIndex].mediaRef; + final key = _overlayKey(page); + final lang = _hive.getMangaTranslateLang(); + var translation = _overlays.translationFor(key, lang); + if (translation == null) { + if (_translating.contains(key)) return; + setState(() => _translating.add(key)); + final translator = _translator ??= PageTranslator(); + try { + translation = + await translator.cached( + chapterUrl: chapterUrl, + pageIndex: page, + targetLang: lang, + ) ?? + await translator.translate( + page: _pages[page], + headers: _headers, + chapterUrl: chapterUrl, + pageIndex: page, + targetLang: lang, + ); + _overlays.store(key, translation); + } on PageTranslateException catch (e) { + if (!mounted) return; + if (auto) { + if (e.reason == PageTranslateFailure.noText) return; + // One message, not one per page turned while the problem lasts. + _autoTranslate = false; + } + _translateSnack(e.reason); + return; + } finally { + if (mounted) setState(() => _translating.remove(key)); + } + } + if (!mounted || translation == null) return; + if (auto) { + _overlays.apply(key); + return; + } + final ready = translation; + final result = await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => PageTranslatePreview( + image: _imageProviderFor(_pages[page]), + translation: ready, + showOriginal: _overlays.entry(key)?.showOriginal ?? false, + ), + ), + ); + if (result == null || !mounted) return; + _overlays.apply(key, showOriginal: result.showOriginal); + } + + ImageProvider _imageProviderFor(MangaPageEntity page) { + final url = page.imageUrl; + if (!url.startsWith('http')) return FileImage(File(url)); + final cookie = page.cookie; + return CachedNetworkImageProvider( + url, + cacheKey: page.cacheKey, + headers: mergeHttpHeaders([ + _headers, + page.headers, + if (cookie != null && cookie.isNotEmpty) {'Cookie': cookie}, + ]), + ); + } + + void _translateSnack(PageTranslateFailure reason) { + final message = switch (reason) { + PageTranslateFailure.noText => 'manga.translate_no_text'.tr(), + PageTranslateFailure.dailyLimit => 'manga.translate_daily_limit'.tr(), + PageTranslateFailure.offline => 'manga.translate_offline'.tr(), + PageTranslateFailure.signIn => 'manga.translate_sign_in'.tr(), + PageTranslateFailure.failed => 'manga.translate_failed'.tr(), + }; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 3)), + ); + } + + Widget _translateSettings(StateSetter setSheet) { + final lang = _hive.getMangaTranslateLang(); + final known = kSubtitleTranslateLanguages.any((l) => l.$1 == lang); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sheetLabel('manga.translate_language'), + const SizedBox(height: 4), + DropdownButton( + value: known ? lang : null, + isExpanded: true, + dropdownColor: const Color(0xFF1E1E1E), + style: const TextStyle(color: Colors.white, fontSize: 13.5), + underline: const SizedBox.shrink(), + items: [ + for (final l in kSubtitleTranslateLanguages) + DropdownMenuItem(value: l.$1, child: Text(l.$2)), + ], + onChanged: (v) { + if (v == null) return; + _hive.setMangaTranslateLang(v); + setSheet(() {}); + }, + ), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + activeTrackColor: _accent, + value: _autoTranslate, + title: Text( + 'manga.translate_auto'.tr(), + style: const TextStyle(color: Colors.white, fontSize: 13.5), + ), + subtitle: Text( + 'manga.translate_auto_desc'.tr(), + style: const TextStyle(color: Colors.white38, fontSize: 11), + ), + onChanged: (v) { + _hive.setMangaAutoTranslate(v); + setState(() => _autoTranslate = v); + setSheet(() {}); + if (v) _scheduleAutoTranslate(); + }, + ), + ], + ); + } + /// Prose has more to offer than fits along the bar on a phone. Widget _novelMenu() { final site = _chapterOnSite; @@ -2792,6 +3006,10 @@ class _ReaderPageState extends State { setSheet(() {}); }, ), + if (_canTranslate) ...[ + const SizedBox(height: 18), + _translateSettings(setSheet), + ], ], if (!isDesktopPlatform) ...[ const SizedBox(height: 18), @@ -3089,12 +3307,18 @@ class _PageImage extends StatefulWidget { required this.page, required this.headers, required this.zoomable, + this.overlays, + this.overlayKey = '', }); final MangaPageEntity page; final Map headers; final bool zoomable; + /// Translated lettering to lay over this page, under [overlayKey]. + final PageOverlays? overlays; + final String overlayKey; + @override State<_PageImage> createState() => _PageImageState(); } @@ -3208,7 +3432,27 @@ class _PageImageState extends State<_PageImage> { errorWidget: (_, _, _) => _errorTile(width), ); - if (!widget.zoomable) return img; + final overlays = widget.overlays; + final Widget drawn = overlays == null + ? img + : Stack( + children: [ + img, + Positioned.fill( + child: ListenableBuilder( + listenable: overlays, + builder: (_, _) { + final t = overlays.visible(widget.overlayKey); + return t == null + ? const SizedBox.shrink() + : PageTranslationOverlay(translation: t); + }, + ), + ), + ], + ); + + if (!widget.zoomable) return drawn; // Zoomed, the image above is a bitmap decoded at column width being // magnified — which is exactly when a dense page or small lettering turns // to mush. [TiledZoom] re-reads the part that is on screen from the file @@ -3217,7 +3461,7 @@ class _PageImageState extends State<_PageImage> { return TiledZoom( sourcePath: _sourcePath, maxScale: 4, - child: SizedBox(width: width, child: img), + child: SizedBox(width: width, child: drawn), ); }, ); diff --git a/lib/features/manga/presentation/widgets/page_translation_overlay.dart b/lib/features/manga/presentation/widgets/page_translation_overlay.dart new file mode 100644 index 00000000..af10dabb --- /dev/null +++ b/lib/features/manga/presentation/widgets/page_translation_overlay.dart @@ -0,0 +1,231 @@ +import 'dart:math' as math; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/manga/domain/translate/page_translation.dart'; + +/// The translated lettering, laid over a page drawn at any size. +/// +/// Sized by its parent, which is the page image: block rectangles are in the +/// image's pixels and are scaled to whatever the page is drawn at. +class PageTranslationOverlay extends StatelessWidget { + const PageTranslationOverlay({super.key, required this.translation}); + + final PageTranslation translation; + + @override + Widget build(BuildContext context) { + final rtl = isRtlLanguage(translation.targetLang); + return IgnorePointer( + child: LayoutBuilder( + builder: (context, constraints) { + final image = translation.imageSize; + if (image.isEmpty || !constraints.maxWidth.isFinite) { + return const SizedBox.shrink(); + } + final sx = constraints.maxWidth / image.width; + final sy = constraints.maxHeight.isFinite + ? constraints.maxHeight / image.height + : sx; + return Stack( + children: [ + for (final block in translation.blocks) + _box(block, sx, sy, constraints.biggest, rtl), + ], + ); + }, + ), + ); + } + + Widget _box(TranslatedBlock block, double sx, double sy, Size area, bool rtl) { + final r = block.rect; + // A little wider than the letters, so their edges do not peek out. + final pad = math.max(3.0, 0.15 * math.min(r.width * sx, r.height * sy)); + final left = math.max(0.0, r.left * sx - pad); + final top = math.max(0.0, r.top * sy - pad); + final right = area.width.isFinite + ? math.min(area.width, r.right * sx + pad) + : r.right * sx + pad; + final bottom = area.height.isFinite + ? math.min(area.height, r.bottom * sy + pad) + : r.bottom * sy + pad; + return Positioned( + left: left, + top: top, + width: math.max(0.0, right - left), + height: math.max(0.0, bottom - top), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.97), + borderRadius: BorderRadius.circular( + math.min(12.0, 0.3 * math.min(right - left, bottom - top)), + ), + ), + child: Padding( + padding: const EdgeInsets.all(2), + child: FittedLettering( + text: block.text, + direction: rtl ? TextDirection.rtl : TextDirection.ltr, + ), + ), + ), + ); + } +} + +/// Text at the largest size that fits its box. +class FittedLettering extends StatelessWidget { + const FittedLettering({ + super.key, + required this.text, + required this.direction, + }); + + final String text; + final TextDirection direction; + + static TextStyle styleAt(double size) => TextStyle( + color: Colors.black, + fontSize: size, + height: 1.15, + fontWeight: FontWeight.w600, + ); + + @override + Widget build(BuildContext context) { + final scaler = MediaQuery.textScalerOf(context); + return LayoutBuilder( + builder: (context, constraints) { + final box = constraints.biggest; + final size = fitFontSize( + box: box, + max: math.max(6.0, math.min(40.0, box.height)), + measure: (fontSize) { + final painter = TextPainter( + text: TextSpan(text: text, style: styleAt(fontSize)), + textAlign: TextAlign.center, + textDirection: direction, + textScaler: scaler, + )..layout(maxWidth: box.width); + final used = Size( + math.max(painter.width, painter.minIntrinsicWidth), + painter.height, + ); + painter.dispose(); + return used; + }, + ); + return Center( + child: Text( + text, + textAlign: TextAlign.center, + textDirection: direction, + overflow: TextOverflow.clip, + style: styleAt(size), + ), + ); + }, + ); + } +} + +/// What the person chose in the preview. Null from the route means Cancel. +class PageTranslatePreviewResult { + const PageTranslatePreviewResult({required this.showOriginal}); + final bool showOriginal; +} + +/// The page with its translation over it, before anything changes in the +/// reader: Apply, Show original, Cancel. +class PageTranslatePreview extends StatefulWidget { + const PageTranslatePreview({ + super.key, + required this.image, + required this.translation, + this.showOriginal = false, + }); + + final ImageProvider image; + final PageTranslation translation; + final bool showOriginal; + + @override + State createState() => _PageTranslatePreviewState(); +} + +class _PageTranslatePreviewState extends State { + late bool _original = widget.showOriginal; + + @override + Widget build(BuildContext context) { + final accent = AppColors.primary; + return Scaffold( + backgroundColor: Colors.black, + appBar: AppBar( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + title: Text( + 'manga.translate_preview'.tr(), + style: const TextStyle(fontSize: 16), + ), + ), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + child: Stack( + children: [ + Image( + image: widget.image, + fit: BoxFit.fitWidth, + width: double.infinity, + ), + if (!_original) + Positioned.fill( + child: PageTranslationOverlay( + translation: widget.translation, + ), + ), + ], + ), + ), + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), + child: Row( + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'general.cancel'.tr(), + style: const TextStyle(color: Colors.white70), + ), + ), + const Spacer(), + FilterChip( + selected: _original, + onSelected: (v) => setState(() => _original = v), + label: Text('manga.translate_show_original'.tr()), + selectedColor: accent.withValues(alpha: 0.3), + ), + const SizedBox(width: 10), + FilledButton( + style: FilledButton.styleFrom(backgroundColor: accent), + onPressed: () => Navigator.of(context).pop( + PageTranslatePreviewResult(showOriginal: _original), + ), + child: Text('manga.translate_apply'.tr()), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index d3343c34..33502ce9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -887,6 +887,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.3+1" + google_mlkit_commons: + dependency: transitive + description: + name: google_mlkit_commons + sha256: c7fc384bdb66c3b83e24e3a41818e61d7a378d900f65f5e775344fbca95a0917 + url: "https://pub.dev" + source: hosted + version: "0.12.0" + google_mlkit_text_recognition: + dependency: "direct main" + description: + name: google_mlkit_text_recognition + sha256: "8324fdc2628ebeb63568ad06437b984b716d938d87e4ce63ab24dfd3eeebf08a" + url: "https://pub.dev" + source: hosted + version: "0.16.0" google_sign_in: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 494106ba..86b1e90f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -142,6 +142,9 @@ dependencies: # Exact pin: a dev release, and lib/core/glass is written against its API. liquid_glass_renderer: 0.2.0-dev.4 mobile_scanner: ^7.4.0 + # Reader page translation, Android and iOS only. 0.17+ needs AGP 9 and 1.x + # needs Swift Package Manager; the app is on AGP 8 and CocoaPods. + google_mlkit_text_recognition: ^0.16.0 # The TV's pairing code, painted rather than pulled in as a widget package. qr: ^3.0.2 firebase_auth: ^5.7.0 diff --git a/test/features/manga/page_translation_test.dart b/test/features/manga/page_translation_test.dart new file mode 100644 index 00000000..43677f08 --- /dev/null +++ b/test/features/manga/page_translation_test.dart @@ -0,0 +1,249 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/manga/domain/translate/page_translation.dart'; + +OcrBlock block(double l, double t, double r, double b, String text, + {double line = 20}) => + OcrBlock(rect: Rect.fromLTRB(l, t, r, b), text: text, lineHeight: line); + +PageTranslation translation(String lang) => PageTranslation( + imageSize: const Size(800, 1200), + targetLang: lang, + blocks: [ + TranslatedBlock( + rect: const Rect.fromLTRB(10, 10, 100, 50), + source: 'Hello', + text: 'مرحبا', + ), + ], +); + +void main() { + group('mergeBlocks', () { + test('joins a bubble split into stacked blocks', () { + final out = mergeBlocks([ + block(100, 100, 300, 140, 'WHERE ARE'), + block(110, 150, 290, 190, 'YOU GOING?'), + ]); + expect(out, hasLength(1)); + expect(out.single.rect, const Rect.fromLTRB(100, 100, 300, 190)); + expect(out.single.text, 'WHERE ARE\nYOU GOING?'); + }); + + test('keeps bubbles apart when the gap is wider than a line', () { + final out = mergeBlocks([ + block(100, 100, 300, 140, 'FIRST'), + block(100, 200, 300, 240, 'SECOND'), + ]); + expect(out.map((b) => b.text), ['FIRST', 'SECOND']); + }); + + test('does not join side-by-side bubbles that barely overlap', () { + final out = mergeBlocks([ + block(100, 100, 300, 140, 'LEFT'), + block(290, 150, 500, 190, 'RIGHT'), + ]); + expect(out, hasLength(2)); + }); + + test('joins transitively, in reading order', () { + final out = mergeBlocks([ + block(100, 200, 300, 240, 'C'), + block(100, 100, 300, 140, 'A'), + block(100, 150, 300, 190, 'B'), + ]); + expect(out.single.text, 'A\nB\nC'); + }); + + test('orders separate blocks top to bottom, then left to right', () { + final out = mergeBlocks([ + block(500, 105, 700, 140, 'RIGHT'), + block(10, 100, 200, 140, 'LEFT'), + block(10, 600, 200, 640, 'BELOW'), + ]); + expect(out.map((b) => b.text), ['LEFT', 'RIGHT', 'BELOW']); + }); + + test('drops blocks with no letters', () { + final out = mergeBlocks([ + block(10, 10, 40, 30, '12'), + block(10, 500, 200, 540, '...'), + block(300, 300, 500, 340, 'HEY'), + ]); + expect(out.map((b) => b.text), ['HEY']); + }); + }); + + group('normalizeOcrText', () { + test('turns all caps into sentence case and joins lines', () { + expect( + normalizeOcrText("I DON'T\nKNOW. WHERE\nIS HE?"), + "I don't know. Where is he?", + ); + }); + + test('rejoins a word hyphenated at a line end', () { + expect(normalizeOcrText('SOME-\nTHING'), 'Something'); + }); + + test('leaves mixed case alone', () { + expect(normalizeOcrText('Hello there\nTokyo'), 'Hello there Tokyo'); + }); + }); + + group('fitFontSize', () { + // A fake layout: every character is 0.6em wide, lines wrap at the width. + Size Function(double) measurer(String text, double width) => (size) { + final perLine = (width / (0.6 * size)).floor().clamp(1, 1 << 20); + final lines = (text.length / perLine).ceil(); + final w = text.length >= perLine ? width : text.length * 0.6 * size; + return Size(w, lines * size * 1.2); + }; + + test('returns the largest size that fits', () { + const box = Size(120, 60); + final size = fitFontSize( + box: box, + measure: measurer('Hello there friend', box.width), + ); + expect(measurer('Hello there friend', box.width)(size).height, + lessThanOrEqualTo(box.height)); + expect(measurer('Hello there friend', box.width)(size + 0.5).height, + greaterThan(box.height)); + }); + + test('is capped at max for short text', () { + expect( + fitFontSize( + box: const Size(500, 500), + max: 24, + measure: measurer('Hi', 500), + ), + 24, + ); + }); + + test('falls back to min when nothing fits', () { + expect( + fitFontSize( + box: const Size(10, 4), + min: 6, + measure: measurer('A very long sentence indeed', 10), + ), + 6, + ); + }); + + test('an empty box gets min', () { + expect( + fitFontSize(box: Size.zero, min: 7, measure: (_) => Size.zero), + 7, + ); + }); + }); + + group('pageTranslateDocKey', () { + test('names chapter and page', () { + expect( + pageTranslateDocKey('https://x.example/ch/12', 3), + 'manga:https://x.example/ch/12:3', + ); + }); + + test('hashes a chapter url too long for the server to keep whole', () { + final url = 'https://x.example/${'a' * 300}'; + final a = pageTranslateDocKey(url, 1); + final b = pageTranslateDocKey(url, 2); + expect(a.length, lessThanOrEqualTo(kDocKeyLimit)); + expect(a, endsWith(':1')); + expect(a, isNot(b)); + expect(a, pageTranslateDocKey(url, 1)); + }); + }); + + test('isRtlLanguage', () { + expect(isRtlLanguage('ar'), isTrue); + expect(isRtlLanguage('AR-eg'), isTrue); + expect(isRtlLanguage('en'), isFalse); + }); + + test('PageTranslation survives a JSON round trip', () { + final back = PageTranslation.tryFromJson(translation('ar').toJson())!; + expect(back.imageSize, const Size(800, 1200)); + expect(back.targetLang, 'ar'); + expect(back.blocks.single.rect, const Rect.fromLTRB(10, 10, 100, 50)); + expect(back.blocks.single.text, 'مرحبا'); + expect(PageTranslation.tryFromJson({'w': 'x'}), isNull); + }); + + group('PageOverlays', () { + const key = 'ch#0'; + + test('a stored translation is not shown until applied', () { + final o = PageOverlays()..store(key, translation('ar')); + expect(o.visible(key), isNull); + expect(o.translationFor(key, 'ar'), isNotNull); + expect(o.translationFor(key, 'uz'), isNull); + o.apply(key); + expect(o.visible(key), isNotNull); + expect(o.isApplied(key), isTrue); + }); + + test('show original hides it and toggles back', () { + final o = PageOverlays() + ..store(key, translation('ar')) + ..apply(key); + o.toggleOriginal(key); + expect(o.visible(key), isNull); + expect(o.isApplied(key), isTrue); + o.toggleOriginal(key); + expect(o.visible(key), isNotNull); + }); + + test('apply can keep the original showing', () { + final o = PageOverlays() + ..store(key, translation('ar')) + ..apply(key, showOriginal: true); + expect(o.visible(key), isNull); + expect(o.entry(key)!.showOriginal, isTrue); + }); + + test('toggling a page that was never applied does nothing', () { + final o = PageOverlays()..store(key, translation('ar')); + o.toggleOriginal(key); + expect(o.entry(key)!.showOriginal, isFalse); + expect(o.visible(key), isNull); + }); + + test('remove takes it off and keeps the translation', () { + final o = PageOverlays() + ..store(key, translation('ar')) + ..apply(key); + o.remove(key); + expect(o.visible(key), isNull); + expect(o.translationFor(key, 'ar'), isNotNull); + }); + + test('a fresh translation in the same language stays applied', () { + final o = PageOverlays() + ..store(key, translation('ar')) + ..apply(key); + o.store(key, translation('ar')); + expect(o.visible(key), isNotNull); + o.store(key, translation('uz')); + expect(o.visible(key), isNull); + }); + + test('notifies listeners on every change', () { + var calls = 0; + final o = PageOverlays()..addListener(() => calls++); + o + ..store(key, translation('ar')) + ..apply(key) + ..toggleOriginal(key) + ..remove(key); + expect(calls, 4); + }); + }); +} From 2634256d183bc9ea2d76e428ac9859235fd39c5f Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:35:26 +0500 Subject: [PATCH 05/16] Check profile PINs offline, add biometric unlock and a skip-picker setting, shorten the splash - Keep a salted PBKDF2 verifier per account and profile after the server accepts a PIN; use it, with the app lock's backoff, only when the server can't be reached. Cleared on sign-out, PIN change or removal, and delete. - Offer fingerprint / Face ID per profile after the first PIN entry. - Add 'Ask who's watching on launch' to Manage profiles; off opens the last profile, unless it has a PIN. - Don't re-ask the active profile's PIN within the same run. - Splash hurries to its fade once the route is known (800 ms minimum) and doesn't wait on the profile list when offline. - Move PIN hashing, backoff and biometrics into shared app_lock helpers. --- assets/translations/ar.json | 13 +- assets/translations/ckb.json | 13 +- assets/translations/de.json | 13 +- assets/translations/en.json | 13 +- assets/translations/es.json | 13 +- assets/translations/fr.json | 13 +- assets/translations/hi.json | 13 +- assets/translations/id.json | 13 +- assets/translations/ku.json | 13 +- assets/translations/nl.json | 13 +- assets/translations/pt.json | 13 +- assets/translations/ru.json | 13 +- assets/translations/tr.json | 13 +- assets/translations/uz.json | 13 +- assets/translations/vi.json | 13 +- assets/translations/yue.json | 13 +- lib/core/di/injection.dart | 10 +- .../app_lock_local_data_source.dart | 133 +------ lib/features/app_lock/data/pin_secrets.dart | 134 +++++++ .../profiles/data/profile_pin_store.dart | 223 ++++++++++++ .../profiles/data/profile_session.dart | 151 +++++++- .../profiles/domain/household_profile.dart | 20 +- .../pages/household_profile_edit_page.dart | 1 + .../pages/manage_profiles_page.dart | 59 +++ .../pages/profile_picker_page.dart | 2 +- .../profiles/presentation/profile_flows.dart | 100 ++++- .../widgets/profile_pin_page.dart | 35 +- .../presentation/pages/splash_page.dart | 38 +- .../presentation/widgets/sozo_splash.dart | 107 +++++- .../profiles/profile_pin_offline_test.dart | 341 ++++++++++++++++++ .../profiles/profile_pin_store_test.dart | 173 +++++++++ test/features/splash/splash_hurry_test.dart | 49 +++ 32 files changed, 1608 insertions(+), 176 deletions(-) create mode 100644 lib/features/app_lock/data/pin_secrets.dart create mode 100644 lib/features/profiles/data/profile_pin_store.dart create mode 100644 test/features/profiles/profile_pin_offline_test.dart create mode 100644 test/features/profiles/profile_pin_store_test.dart create mode 100644 test/features/splash/splash_hurry_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 6e6bbb79..c8be3516 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -2553,7 +2553,18 @@ "card_single": "أضف ملفات شخصية لمن تشاركهم هذا الحساب", "load_failed": "تعذّر تحميل الملفات الشخصية", "offline": "تحقق من اتصالك وحاول مرة أخرى.", - "error_generic": "حدث خطأ ما. حاول مرة أخرى." + "error_generic": "حدث خطأ ما. حاول مرة أخرى.", + "on_this_device": "على هذا الجهاز", + "ask_on_launch": "اسأل من يشاهد عند التشغيل", + "ask_on_launch_desc": "عند الإيقاف، يفتح Sozo آخر ملف شخصي مستخدم. الملفات المحمية برمز PIN ستطلبه دائمًا.", + "pin_locked_until": "محاولات كثيرة جدًا. حاول مجددًا بعد {}.", + "pin_not_on_device": "أنت غير متصل، ولم يُدخل رمز PIN لهذا الملف على هذا الجهاز بعد. اتصل بالإنترنت مرة واحدة لفتحه.", + "biometric_reason": "فتح {}", + "biometric_offer_title": "استخدام البصمة / Face ID؟", + "biometric_offer_body": "استخدم البصمة / Face ID لـ {} على هذا الجهاز. سيبقى رمز PIN يعمل أيضًا.", + "biometric_offer_yes": "استخدامها", + "biometric_offer_no": "ليس الآن", + "biometric_toggle": "البصمة / Face ID لـ {}" }, "jellyfin": { "connect_server": "ربط خادم وسائط (Jellyfin)", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 6cef5071..6d8bae72 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -2549,7 +2549,18 @@ "card_single": "پرۆفایل زیاد بکە بۆ ئەو کەسانەی ئەم هەژمارەیان لەگەڵ هاوبەش دەکەیت", "load_failed": "نەتوانرا پرۆفایلەکان بار بکرێن", "offline": "پەیوەندییەکەت بپشکنە و دووبارە هەوڵبدەرەوە.", - "error_generic": "هەڵەیەک ڕوویدا. تکایە دووبارە هەوڵبدەرەوە." + "error_generic": "هەڵەیەک ڕوویدا. تکایە دووبارە هەوڵبدەرەوە.", + "on_this_device": "لەسەر ئەم ئامێرە", + "ask_on_launch": "لە کاتی کردنەوەدا بپرسە کێ سەیر دەکات", + "ask_on_launch_desc": "ئەگەر کوژایەوە، Sozo دوایین پرۆفایلی بەکارهاتوو دەکاتەوە. پرۆفایلەکانی خاوەن PIN هێشتا دەپرسن.", + "pin_locked_until": "هەوڵی زۆر. دوای {} دووبارە هەوڵ بدەرەوە.", + "pin_not_on_device": "تۆ ئۆفلاینیت، و PINی ئەم پرۆفایلە هێشتا لەسەر ئەم ئامێرە نەنووسراوە. بۆ کردنەوەی جارێک بە ئینتەرنێتەوە پەیوەست بە.", + "biometric_reason": "کردنەوەی {}", + "biometric_offer_title": "پەنجەمۆر / Face ID بەکاربهێنرێت؟", + "biometric_offer_body": "پەنجەمۆر / Face ID بۆ {} لەسەر ئەم ئامێرە بەکاربهێنە. PIN-یش هەر کار دەکات.", + "biometric_offer_yes": "بەکارهێنان", + "biometric_offer_no": "ئێستا نا", + "biometric_toggle": "پەنجەمۆر / Face ID بۆ {}" }, "jellyfin": { "connect_server": "بەستنەوەی سێرڤەری میدیا (Jellyfin)", diff --git a/assets/translations/de.json b/assets/translations/de.json index 0970f03c..afb6fc9a 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -2549,7 +2549,18 @@ "card_single": "Füge Profile für die Personen hinzu, mit denen du dieses Konto teilst", "load_failed": "Profile konnten nicht geladen werden", "offline": "Prüfe deine Verbindung und versuch es erneut.", - "error_generic": "Etwas ist schiefgelaufen. Bitte versuch es erneut." + "error_generic": "Etwas ist schiefgelaufen. Bitte versuch es erneut.", + "on_this_device": "Auf diesem Gerät", + "ask_on_launch": "Beim Start fragen, wer zuschaut", + "ask_on_launch_desc": "Wenn aus, öffnet Sozo das zuletzt genutzte Profil. Profile mit PIN fragen trotzdem.", + "pin_locked_until": "Zu viele Versuche. Versuche es nach {} erneut.", + "pin_not_on_device": "Du bist offline, und die PIN dieses Profils wurde auf diesem Gerät noch nie eingegeben. Verbinde dich einmal mit dem Internet, um es zu entsperren.", + "biometric_reason": "{} entsperren", + "biometric_offer_title": "Fingerabdruck / Face ID verwenden?", + "biometric_offer_body": "Fingerabdruck / Face ID für {} auf diesem Gerät verwenden. Die PIN funktioniert weiterhin.", + "biometric_offer_yes": "Verwenden", + "biometric_offer_no": "Nicht jetzt", + "biometric_toggle": "Fingerabdruck / Face ID für {}" }, "jellyfin": { "connect_server": "Medienserver verbinden (Jellyfin)", diff --git a/assets/translations/en.json b/assets/translations/en.json index 8fbca5aa..d504946a 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -2549,7 +2549,18 @@ "card_single": "Add profiles for the people you share this account with", "load_failed": "Couldn't load profiles", "offline": "Check your connection and try again.", - "error_generic": "Something went wrong. Please try again." + "error_generic": "Something went wrong. Please try again.", + "on_this_device": "On this device", + "ask_on_launch": "Ask who's watching on launch", + "ask_on_launch_desc": "When off, Sozo opens the last profile used. Profiles with a PIN still ask.", + "pin_locked_until": "Too many attempts. Try again after {}.", + "pin_not_on_device": "You're offline, and this profile's PIN hasn't been entered on this device yet. Connect to the internet once to unlock it.", + "biometric_reason": "Unlock {}", + "biometric_offer_title": "Use fingerprint / Face ID?", + "biometric_offer_body": "Use fingerprint / Face ID for {} on this device. The PIN keeps working too.", + "biometric_offer_yes": "Use it", + "biometric_offer_no": "Not now", + "biometric_toggle": "Fingerprint / Face ID for {}" }, "jellyfin": { "connect_server": "Connect a media server (Jellyfin)", diff --git a/assets/translations/es.json b/assets/translations/es.json index 319ddb48..a38a442e 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -2549,7 +2549,18 @@ "card_single": "Añade perfiles para las personas con las que compartes esta cuenta", "load_failed": "No se pudieron cargar los perfiles", "offline": "Comprueba tu conexión e inténtalo de nuevo.", - "error_generic": "Algo salió mal. Inténtalo de nuevo." + "error_generic": "Algo salió mal. Inténtalo de nuevo.", + "on_this_device": "En este dispositivo", + "ask_on_launch": "Preguntar quién está viendo al iniciar", + "ask_on_launch_desc": "Si está desactivado, Sozo abre el último perfil usado. Los perfiles con PIN lo seguirán pidiendo.", + "pin_locked_until": "Demasiados intentos. Inténtalo de nuevo después de las {}.", + "pin_not_on_device": "No tienes conexión y el PIN de este perfil aún no se ha introducido en este dispositivo. Conéctate a internet una vez para desbloquearlo.", + "biometric_reason": "Desbloquear {}", + "biometric_offer_title": "¿Usar huella / Face ID?", + "biometric_offer_body": "Usa huella / Face ID para {} en este dispositivo. El PIN seguirá funcionando.", + "biometric_offer_yes": "Usar", + "biometric_offer_no": "Ahora no", + "biometric_toggle": "Huella / Face ID para {}" }, "jellyfin": { "connect_server": "Conectar un servidor multimedia (Jellyfin)", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 599703a9..73f86914 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -2549,7 +2549,18 @@ "card_single": "Ajoutez des profils pour les personnes avec qui vous partagez ce compte", "load_failed": "Impossible de charger les profils", "offline": "Vérifiez votre connexion et réessayez.", - "error_generic": "Une erreur s'est produite. Veuillez réessayer." + "error_generic": "Une erreur s'est produite. Veuillez réessayer.", + "on_this_device": "Sur cet appareil", + "ask_on_launch": "Demander qui regarde au lancement", + "ask_on_launch_desc": "Désactivé, Sozo ouvre le dernier profil utilisé. Les profils avec un code PIN le demandent toujours.", + "pin_locked_until": "Trop de tentatives. Réessayez après {}.", + "pin_not_on_device": "Vous êtes hors ligne et le code PIN de ce profil n'a pas encore été saisi sur cet appareil. Connectez-vous une fois à Internet pour le déverrouiller.", + "biometric_reason": "Déverrouiller {}", + "biometric_offer_title": "Utiliser l'empreinte / Face ID ?", + "biometric_offer_body": "Utilisez l'empreinte / Face ID pour {} sur cet appareil. Le code PIN reste valable.", + "biometric_offer_yes": "Utiliser", + "biometric_offer_no": "Pas maintenant", + "biometric_toggle": "Empreinte / Face ID pour {}" }, "jellyfin": { "connect_server": "Connecter un serveur multimédia (Jellyfin)", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 12d0cb70..1b7c79f6 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -2549,7 +2549,18 @@ "card_single": "जिनके साथ आप यह अकाउंट शेयर करते हैं, उनके लिए प्रोफ़ाइल जोड़ें", "load_failed": "प्रोफ़ाइल लोड नहीं हो पाईं", "offline": "अपना कनेक्शन जाँचें और फिर से कोशिश करें।", - "error_generic": "कुछ गड़बड़ हो गई। कृपया फिर से कोशिश करें।" + "error_generic": "कुछ गड़बड़ हो गई। कृपया फिर से कोशिश करें।", + "on_this_device": "इस डिवाइस पर", + "ask_on_launch": "शुरू होने पर पूछें कि कौन देख रहा है", + "ask_on_launch_desc": "बंद होने पर, Sozo आख़िरी इस्तेमाल की गई प्रोफ़ाइल खोलता है। PIN वाली प्रोफ़ाइल फिर भी पूछेंगी।", + "pin_locked_until": "बहुत ज़्यादा कोशिशें। {} के बाद फिर कोशिश करें।", + "pin_not_on_device": "आप ऑफ़लाइन हैं, और इस प्रोफ़ाइल का PIN अभी तक इस डिवाइस पर डाला नहीं गया है। इसे खोलने के लिए एक बार इंटरनेट से जुड़ें।", + "biometric_reason": "{} खोलें", + "biometric_offer_title": "फ़िंगरप्रिंट / Face ID इस्तेमाल करें?", + "biometric_offer_body": "इस डिवाइस पर {} के लिए फ़िंगरप्रिंट / Face ID इस्तेमाल करें। PIN भी काम करता रहेगा।", + "biometric_offer_yes": "इस्तेमाल करें", + "biometric_offer_no": "अभी नहीं", + "biometric_toggle": "{} के लिए फ़िंगरप्रिंट / Face ID" }, "jellyfin": { "connect_server": "मीडिया सर्वर कनेक्ट करें (Jellyfin)", diff --git a/assets/translations/id.json b/assets/translations/id.json index 28442646..45389f62 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -2549,7 +2549,18 @@ "card_single": "Tambahkan profil untuk orang yang berbagi akun ini denganmu", "load_failed": "Gagal memuat profil", "offline": "Periksa koneksimu lalu coba lagi.", - "error_generic": "Terjadi kesalahan. Silakan coba lagi." + "error_generic": "Terjadi kesalahan. Silakan coba lagi.", + "on_this_device": "Di perangkat ini", + "ask_on_launch": "Tanya siapa yang menonton saat dibuka", + "ask_on_launch_desc": "Jika nonaktif, Sozo membuka profil terakhir yang dipakai. Profil dengan PIN tetap akan meminta PIN.", + "pin_locked_until": "Terlalu banyak percobaan. Coba lagi setelah {}.", + "pin_not_on_device": "Kamu sedang offline, dan PIN profil ini belum pernah dimasukkan di perangkat ini. Sambungkan ke internet sekali untuk membukanya.", + "biometric_reason": "Buka {}", + "biometric_offer_title": "Pakai sidik jari / Face ID?", + "biometric_offer_body": "Pakai sidik jari / Face ID untuk {} di perangkat ini. PIN tetap berfungsi.", + "biometric_offer_yes": "Pakai", + "biometric_offer_no": "Nanti saja", + "biometric_toggle": "Sidik jari / Face ID untuk {}" }, "jellyfin": { "connect_server": "Hubungkan server media (Jellyfin)", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index e876ed5b..341dc1c2 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -2549,7 +2549,18 @@ "card_single": "Ji bo kesên ku tu vî hesabî bi wan re parve dikî profîlan zêde bike", "load_failed": "Profîl nehatin barkirin", "offline": "Girêdana xwe kontrol bike û dîsa biceribîne.", - "error_generic": "Tiştek xelet çû. Ji kerema xwe dîsa biceribîne." + "error_generic": "Tiştek xelet çû. Ji kerema xwe dîsa biceribîne.", + "on_this_device": "Li ser vê cîhazê", + "ask_on_launch": "Di destpêkê de bipirse kî temaşe dike", + "ask_on_launch_desc": "Dema girtî be, Sozo profîla dawî ya hatî bikaranîn vedike. Profîlên bi PIN dîsa dipirsin.", + "pin_locked_until": "Gelek hewl. Piştî {} dîsa biceribîne.", + "pin_not_on_device": "Tu offline yî, û PIN-a vê profîlê hê li ser vê cîhazê nehatiye nivîsandin. Ji bo vekirinê carekê bi înternetê ve girê bide.", + "biometric_reason": "{} veke", + "biometric_offer_title": "Şopa tilîyê / Face ID bi kar bîne?", + "biometric_offer_body": "Ji bo {} li ser vê cîhazê şopa tilîyê / Face ID bi kar bîne. PIN jî dê bixebite.", + "biometric_offer_yes": "Bi kar bîne", + "biometric_offer_no": "Ne niha", + "biometric_toggle": "Şopa tilîyê / Face ID ji bo {}" }, "jellyfin": { "connect_server": "Serverekî medyayê girê bide (Jellyfin)", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 8ca39779..ce9b85c9 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -2549,7 +2549,18 @@ "card_single": "Voeg profielen toe voor de mensen met wie je dit account deelt", "load_failed": "Profielen konden niet worden geladen", "offline": "Controleer je verbinding en probeer het opnieuw.", - "error_generic": "Er ging iets mis. Probeer het opnieuw." + "error_generic": "Er ging iets mis. Probeer het opnieuw.", + "on_this_device": "Op dit apparaat", + "ask_on_launch": "Bij het starten vragen wie er kijkt", + "ask_on_launch_desc": "Uit opent Sozo het laatst gebruikte profiel. Profielen met een pincode vragen er nog steeds om.", + "pin_locked_until": "Te veel pogingen. Probeer het opnieuw na {}.", + "pin_not_on_device": "Je bent offline en de pincode van dit profiel is op dit apparaat nog niet ingevoerd. Maak één keer verbinding met internet om het te ontgrendelen.", + "biometric_reason": "{} ontgrendelen", + "biometric_offer_title": "Vingerafdruk / Face ID gebruiken?", + "biometric_offer_body": "Gebruik vingerafdruk / Face ID voor {} op dit apparaat. De pincode blijft ook werken.", + "biometric_offer_yes": "Gebruiken", + "biometric_offer_no": "Niet nu", + "biometric_toggle": "Vingerafdruk / Face ID voor {}" }, "jellyfin": { "connect_server": "Mediaserver koppelen (Jellyfin)", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index f79b5482..622a53ae 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -2549,7 +2549,18 @@ "card_single": "Adicione perfis para as pessoas com quem você compartilha esta conta", "load_failed": "Não foi possível carregar os perfis", "offline": "Verifique sua conexão e tente novamente.", - "error_generic": "Algo deu errado. Tente novamente." + "error_generic": "Algo deu errado. Tente novamente.", + "on_this_device": "Neste dispositivo", + "ask_on_launch": "Perguntar quem está assistindo ao abrir", + "ask_on_launch_desc": "Desativado, o Sozo abre o último perfil usado. Perfis com PIN continuam pedindo.", + "pin_locked_until": "Muitas tentativas. Tente novamente depois das {}.", + "pin_not_on_device": "Você está offline e o PIN deste perfil ainda não foi digitado neste dispositivo. Conecte-se à internet uma vez para desbloqueá-lo.", + "biometric_reason": "Desbloquear {}", + "biometric_offer_title": "Usar digital / Face ID?", + "biometric_offer_body": "Use digital / Face ID para {} neste dispositivo. O PIN continua funcionando.", + "biometric_offer_yes": "Usar", + "biometric_offer_no": "Agora não", + "biometric_toggle": "Digital / Face ID para {}" }, "jellyfin": { "connect_server": "Conectar servidor de mídia (Jellyfin)", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index c85aef09..5daf5388 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -2550,7 +2550,18 @@ "card_single": "Добавьте профили для тех, с кем вы делите аккаунт", "load_failed": "Не удалось загрузить профили", "offline": "Проверьте подключение и попробуйте снова.", - "error_generic": "Что-то пошло не так. Попробуйте ещё раз." + "error_generic": "Что-то пошло не так. Попробуйте ещё раз.", + "on_this_device": "На этом устройстве", + "ask_on_launch": "Спрашивать, кто смотрит, при запуске", + "ask_on_launch_desc": "Если выключено, Sozo откроет последний профиль. Профили с PIN-кодом всё равно спросят его.", + "pin_locked_until": "Слишком много попыток. Повторите после {}.", + "pin_not_on_device": "Нет подключения, а PIN-код этого профиля ещё не вводился на этом устройстве. Подключитесь к интернету один раз, чтобы его открыть.", + "biometric_reason": "Открыть профиль {}", + "biometric_offer_title": "Использовать отпечаток / Face ID?", + "biometric_offer_body": "Использовать отпечаток / Face ID для {} на этом устройстве. PIN-код тоже продолжит работать.", + "biometric_offer_yes": "Использовать", + "biometric_offer_no": "Не сейчас", + "biometric_toggle": "Отпечаток / Face ID для {}" }, "jellyfin": { "connect_server": "Подключить медиасервер (Jellyfin)", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index f22f299e..5d4c2af0 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -2549,7 +2549,18 @@ "card_single": "Bu hesabı paylaştığın kişiler için profil ekle", "load_failed": "Profiller yüklenemedi", "offline": "Bağlantını kontrol edip tekrar dene.", - "error_generic": "Bir sorun oluştu. Lütfen tekrar deneyin." + "error_generic": "Bir sorun oluştu. Lütfen tekrar deneyin.", + "on_this_device": "Bu cihazda", + "ask_on_launch": "Açılışta kimin izlediğini sor", + "ask_on_launch_desc": "Kapalıyken Sozo son kullanılan profili açar. PIN'li profiller yine de sorar.", + "pin_locked_until": "Çok fazla deneme. {} sonrasında tekrar dene.", + "pin_not_on_device": "Çevrimdışısın ve bu profilin PIN'i bu cihazda henüz girilmedi. Kilidi açmak için bir kez internete bağlan.", + "biometric_reason": "{} kilidini aç", + "biometric_offer_title": "Parmak izi / Face ID kullanılsın mı?", + "biometric_offer_body": "Bu cihazda {} için parmak izi / Face ID kullan. PIN de çalışmaya devam eder.", + "biometric_offer_yes": "Kullan", + "biometric_offer_no": "Şimdi değil", + "biometric_toggle": "{} için parmak izi / Face ID" }, "jellyfin": { "connect_server": "Medya sunucusu bağla (Jellyfin)", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index a0ec0494..7a13e5d8 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -2549,7 +2549,18 @@ "card_single": "Hisobingizdan birga foydalanadiganlar uchun profil qo'shing", "load_failed": "Profillarni yuklab bo'lmadi", "offline": "Internet aloqasini tekshirib, qayta urinib ko'ring.", - "error_generic": "Xatolik yuz berdi. Qayta urinib ko'ring." + "error_generic": "Xatolik yuz berdi. Qayta urinib ko'ring.", + "on_this_device": "Shu qurilmada", + "ask_on_launch": "Ishga tushganda kim ko'rayotganini so'rash", + "ask_on_launch_desc": "O'chirilsa, Sozo oxirgi ishlatilgan profilni ochadi. PIN-kodli profillar baribir so'raladi.", + "pin_locked_until": "Urinishlar juda ko'p. {} dan keyin qayta urinib ko'ring.", + "pin_not_on_device": "Internet yo'q, bu profilning PIN-kodi esa shu qurilmada hali kiritilmagan. Ochish uchun bir marta internetga ulaning.", + "biometric_reason": "{} profilini ochish", + "biometric_offer_title": "Barmoq izi / Face ID ishlatilsinmi?", + "biometric_offer_body": "Shu qurilmada {} uchun barmoq izi / Face ID ishlatilsin. PIN-kod ham ishlashda davom etadi.", + "biometric_offer_yes": "Ishlatish", + "biometric_offer_no": "Hozir emas", + "biometric_toggle": "{} uchun barmoq izi / Face ID" }, "jellyfin": { "connect_server": "Media serverni ulash (Jellyfin)", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 5a8f5b47..4a6e7589 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -2549,7 +2549,18 @@ "card_single": "Thêm hồ sơ cho những người dùng chung tài khoản này", "load_failed": "Không tải được hồ sơ", "offline": "Hãy kiểm tra kết nối và thử lại.", - "error_generic": "Đã xảy ra lỗi. Vui lòng thử lại." + "error_generic": "Đã xảy ra lỗi. Vui lòng thử lại.", + "on_this_device": "Trên thiết bị này", + "ask_on_launch": "Hỏi ai đang xem khi mở ứng dụng", + "ask_on_launch_desc": "Khi tắt, Sozo mở hồ sơ dùng gần nhất. Hồ sơ có mã PIN vẫn sẽ hỏi.", + "pin_locked_until": "Thử quá nhiều lần. Hãy thử lại sau {}.", + "pin_not_on_device": "Bạn đang ngoại tuyến và mã PIN của hồ sơ này chưa từng được nhập trên thiết bị này. Hãy kết nối Internet một lần để mở khóa.", + "biometric_reason": "Mở khóa {}", + "biometric_offer_title": "Dùng vân tay / Face ID?", + "biometric_offer_body": "Dùng vân tay / Face ID cho {} trên thiết bị này. Mã PIN vẫn dùng được.", + "biometric_offer_yes": "Dùng", + "biometric_offer_no": "Để sau", + "biometric_toggle": "Vân tay / Face ID cho {}" }, "jellyfin": { "connect_server": "Kết nối máy chủ media (Jellyfin)", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 91e3e54a..bb22cddc 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -2549,7 +2549,18 @@ "card_single": "幫同你共用呢個帳戶嘅人加個人檔案", "load_failed": "載入唔到個人檔案", "offline": "檢查吓網絡連線再試。", - "error_generic": "出咗問題,請再試。" + "error_generic": "出咗問題,請再試。", + "on_this_device": "喺呢部裝置", + "ask_on_launch": "開App時問邊個睇緊", + "ask_on_launch_desc": "關咗嘅話,Sozo會直接開返上次用嘅個人檔案。有PIN碼嘅個人檔案照樣會問。", + "pin_locked_until": "試得太多次。請喺{}之後再試。", + "pin_not_on_device": "你而家離線,而呢個個人檔案嘅PIN碼未喺呢部裝置輸入過。請連一次網嚟解鎖。", + "biometric_reason": "解鎖{}", + "biometric_offer_title": "用指紋 / Face ID?", + "biometric_offer_body": "喺呢部裝置用指紋 / Face ID開{}。PIN碼都照用得。", + "biometric_offer_yes": "用", + "biometric_offer_no": "遲啲先", + "biometric_toggle": "{}嘅指紋 / Face ID" }, "jellyfin": { "connect_server": "連接媒體伺服器(Jellyfin)", diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index f2f65fee..fe10bf70 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -84,6 +84,7 @@ import 'package:soplay/features/history/data/history_sync_remote_data_source.dar import 'package:soplay/features/history/data/history_sync_service.dart'; import 'package:soplay/core/network/profile_interceptor.dart'; import 'package:soplay/core/storage/profile_storage.dart'; +import 'package:soplay/features/profiles/data/profile_pin_store.dart'; import 'package:soplay/features/profiles/data/profile_session.dart'; import 'package:soplay/features/profiles/data/profiles_remote_data_source.dart'; import 'package:soplay/features/anilist/data/anilist_link_store.dart'; @@ -106,6 +107,7 @@ import 'package:soplay/features/auth/domain/usecases/verify_otp_usecase.dart'; import 'package:soplay/features/auth/presentation/bloc/auth_bloc.dart'; import 'package:soplay/features/auth/presentation/bloc/auth_event.dart'; import 'package:soplay/features/app_lock/data/datasources/app_lock_local_data_source.dart'; +import 'package:soplay/features/app_lock/data/pin_secrets.dart'; import 'package:soplay/features/app_lock/data/repositories/app_lock_repository_impl.dart'; import 'package:soplay/features/app_lock/domain/repositories/app_lock_repository.dart'; import 'package:soplay/features/app_lock/presentation/app_lock_gate.dart'; @@ -407,10 +409,13 @@ Future configureDependencies() async { getIt.registerSingleton( ProfilesRemoteDataSource(dio: getIt()), ); + getIt.registerSingleton(DeviceBiometrics()); getIt.registerSingleton( ProfileSession( remote: getIt(), isLoggedIn: () => getIt().isLoggedIn, + pins: ProfilePinStore(biometrics: getIt()), + accountId: () => getIt().getUser()?.id, // The corner player first: closing it saves its history, which has to // land in the profile it was watched in, before the sync settles. beforeSwitch: () async { @@ -1217,7 +1222,10 @@ Future configureDependencies() async { ); getIt.registerSingleton( - AppLockLocalDataSource(hiveService: getIt()), + AppLockLocalDataSource( + hiveService: getIt(), + biometrics: getIt(), + ), ); getIt.registerSingleton( AppLockRepositoryImpl( diff --git a/lib/features/app_lock/data/datasources/app_lock_local_data_source.dart b/lib/features/app_lock/data/datasources/app_lock_local_data_source.dart index 1a908937..ec342d03 100644 --- a/lib/features/app_lock/data/datasources/app_lock_local_data_source.dart +++ b/lib/features/app_lock/data/datasources/app_lock_local_data_source.dart @@ -1,13 +1,11 @@ import 'dart:convert'; -import 'dart:isolate'; -import 'dart:math'; -import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:local_auth/local_auth.dart'; import 'package:soplay/core/constants/app_constants.dart'; import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/features/app_lock/data/pin_secrets.dart'; import 'package:soplay/features/app_lock/domain/entities/pin_check.dart'; class AppLockLocalDataSource { @@ -15,6 +13,7 @@ class AppLockLocalDataSource { required HiveService hiveService, FlutterSecureStorage? secureStorage, LocalAuthentication? localAuth, + DeviceBiometrics? biometrics, DateTime Function()? clock, }) : _hive = hiveService, _secure = @@ -25,29 +24,14 @@ class AppLockLocalDataSource { accessibility: KeychainAccessibility.first_unlock, ), ), - _localAuth = localAuth ?? LocalAuthentication(), + _biometrics = biometrics ?? DeviceBiometrics(localAuth), _now = clock ?? DateTime.now; final HiveService _hive; final FlutterSecureStorage _secure; - final LocalAuthentication _localAuth; + final DeviceBiometrics _biometrics; final DateTime Function() _now; - /// PBKDF2 rounds for a new hash. A PIN has at most a million values, so the - /// hash is only ever a speed bump for someone holding a copy of the stored - /// secret; the real protection is that the secret sits in the platform - /// keystore and that guessing on the device is rate-limited below. The - /// count is stored with the hash, so it can be raised later without - /// invalidating anyone's PIN. - static const int pbkdf2Iterations = 60000; - - /// Wrong guesses allowed before a wait is imposed. - static const int freeAttempts = 5; - - /// The first wait; it doubles with each further wrong guess, up to [maxWait]. - static const Duration firstWait = Duration(seconds: 30); - static const Duration maxWait = Duration(hours: 1); - static const String _failuresKey = 'app_lock_failures'; static const String _lockedUntilKey = 'app_lock_locked_until'; @@ -56,8 +40,8 @@ class AppLockLocalDataSource { bool get isBiometricPreferred => _hive.isAppLockBiometricEnabled; Future setPin(String pin) async { - final salt = _generateSalt(); - final hash = await _hashPbkdf2(pin, salt, pbkdf2Iterations); + final salt = PinHash.newSalt(); + final hash = await PinHash.hash(pin, salt); await _secure.write(key: AppConstants.appLockPinSaltSecureKey, value: salt); await _secure.write(key: AppConstants.appLockPinHashSecureKey, value: hash); await _clearFailures(); @@ -90,14 +74,14 @@ class AppLockLocalDataSource { } final bool ok; - if (stored.startsWith('pbkdf2\$')) { - ok = await _verifyPbkdf2(pin, salt, stored); + if (PinHash.isPbkdf2(stored)) { + ok = await PinHash.verify(pin, salt, stored); } else { - ok = _constantTimeEquals(_legacyHash(pin, salt), stored); + ok = PinHash.constantTimeEquals(_legacyHash(pin, salt), stored); // Upgrade in place, now that the PIN is in hand to re-hash. if (ok) { try { - final upgraded = await _hashPbkdf2(pin, salt, pbkdf2Iterations); + final upgraded = await PinHash.hash(pin, salt); await _secure.write( key: AppConstants.appLockPinHashSecureKey, value: upgraded, @@ -161,33 +145,12 @@ class AppLockLocalDataSource { Future setBiometricPreferred(bool value) => _hive.setAppLockBiometricEnabled(value); - Future isBiometricAvailable() async { - try { - final supported = await _localAuth.isDeviceSupported(); - if (!supported) return false; - final canCheck = await _localAuth.canCheckBiometrics; - if (!canCheck) return false; - final available = await _localAuth.getAvailableBiometrics(); - return available.isNotEmpty; - } catch (_) { - return false; - } - } + Future isBiometricAvailable() => _biometrics.isAvailable(); Future authenticateWithBiometrics(String reason) async { - try { - final ok = await _localAuth.authenticate( - localizedReason: reason, - options: const AuthenticationOptions( - stickyAuth: true, - biometricOnly: true, - ), - ); - if (ok) await _clearFailures(); - return ok; - } catch (_) { - return false; - } + final ok = await _biometrics.authenticate(reason); + if (ok) await _clearFailures(); + return ok; } Future _recordFailure() async { @@ -199,27 +162,16 @@ class AppLockLocalDataSource { try { await _secure.write(key: _failuresKey, value: '$failures'); } catch (_) {} - final wait = waitAfter(failures); - if (wait == null) { - return PinCheckResult.wrong(freeAttempts - failures); - } - final until = _now().add(wait); + final result = PinBackoff.afterFailure(failures, _now()); + final until = result.retryAt; + if (until == null) return result; try { await _secure.write( key: _lockedUntilKey, value: '${until.millisecondsSinceEpoch}', ); } catch (_) {} - return PinCheckResult.lockedOut(until); - } - - /// The wait imposed after the [failures]-th wrong guess in a row, or null - /// while guesses are still free. - static Duration? waitAfter(int failures) { - if (failures < freeAttempts) return null; - final doublings = min(failures - freeAttempts, 20); - final wait = firstWait * pow(2, doublings).toInt(); - return wait > maxWait ? maxWait : wait; + return result; } Future _clearFailures() async { @@ -229,12 +181,6 @@ class AppLockLocalDataSource { } catch (_) {} } - String _generateSalt() { - final rnd = Random.secure(); - final bytes = List.generate(16, (_) => rnd.nextInt(256)); - return base64Url.encode(bytes); - } - /// The original single-round hash. Kept only to recognise PINs set before /// PBKDF2, which [checkPin] upgrades on their next unlock. The `soplay` /// suffix is part of those stored hashes — changing it would lock every @@ -243,47 +189,4 @@ class AppLockLocalDataSource { final bytes = utf8.encode('$salt::$pin::soplay'); return sha256.convert(bytes).toString(); } - - Future _verifyPbkdf2(String pin, String salt, String stored) async { - // pbkdf2$sha256$$ - final parts = stored.split('\$'); - if (parts.length != 4 || parts[1] != 'sha256') return false; - final iterations = int.tryParse(parts[2]); - if (iterations == null || iterations <= 0) return false; - final candidate = await _hashPbkdf2(pin, salt, iterations); - return _constantTimeEquals(candidate, stored); - } - - /// Runs off the UI isolate: tens of thousands of HMAC rounds would drop - /// frames on the keypad animation. - static Future _hashPbkdf2(String pin, String salt, int iterations) { - return Isolate.run(() => hashPbkdf2Sync(pin, salt, iterations)); - } - - /// PBKDF2-HMAC-SHA256 with one 32-byte block, encoded with its parameters. - static String hashPbkdf2Sync(String pin, String salt, int iterations) { - final hmac = Hmac(sha256, utf8.encode(pin)); - final saltBytes = utf8.encode(salt); - final block = Uint8List(saltBytes.length + 4) - ..setAll(0, saltBytes) - ..setAll(saltBytes.length, const [0, 0, 0, 1]); - var u = hmac.convert(block).bytes; - final out = Uint8List.fromList(u); - for (var i = 1; i < iterations; i++) { - u = hmac.convert(u).bytes; - for (var j = 0; j < out.length; j++) { - out[j] ^= u[j]; - } - } - return 'pbkdf2\$sha256\$$iterations\$${base64.encode(out)}'; - } - - static bool _constantTimeEquals(String a, String b) { - if (a.length != b.length) return false; - var diff = 0; - for (var i = 0; i < a.length; i++) { - diff |= a.codeUnitAt(i) ^ b.codeUnitAt(i); - } - return diff == 0; - } } diff --git a/lib/features/app_lock/data/pin_secrets.dart b/lib/features/app_lock/data/pin_secrets.dart new file mode 100644 index 00000000..f9235e92 --- /dev/null +++ b/lib/features/app_lock/data/pin_secrets.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; +import 'dart:isolate'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:local_auth/local_auth.dart'; +import 'package:soplay/features/app_lock/domain/entities/pin_check.dart'; + +/// Salted PBKDF2 for a PIN kept on the device: the app lock's and the offline +/// check of a profile PIN. +abstract final class PinHash { + /// PBKDF2 rounds for a new hash. A PIN has at most a million values, so the + /// hash is only ever a speed bump for someone holding a copy of the stored + /// secret; the real protection is that the secret sits in the platform + /// keystore and that guessing on the device is rate-limited. The count is + /// stored with the hash, so it can be raised later without invalidating + /// anyone's PIN. + static const int iterations = 60000; + + static String newSalt() { + final rnd = Random.secure(); + final bytes = List.generate(16, (_) => rnd.nextInt(256)); + return base64Url.encode(bytes); + } + + /// Runs off the UI isolate: tens of thousands of HMAC rounds would drop + /// frames on the keypad animation. + static Future hash( + String pin, + String salt, [ + int rounds = iterations, + ]) => Isolate.run(() => hashSync(pin, salt, rounds)); + + /// PBKDF2-HMAC-SHA256 with one 32-byte block, encoded with its parameters. + static String hashSync(String pin, String salt, int rounds) { + final hmac = Hmac(sha256, utf8.encode(pin)); + final saltBytes = utf8.encode(salt); + final block = Uint8List(saltBytes.length + 4) + ..setAll(0, saltBytes) + ..setAll(saltBytes.length, const [0, 0, 0, 1]); + var u = hmac.convert(block).bytes; + final out = Uint8List.fromList(u); + for (var i = 1; i < rounds; i++) { + u = hmac.convert(u).bytes; + for (var j = 0; j < out.length; j++) { + out[j] ^= u[j]; + } + } + return 'pbkdf2\$sha256\$$rounds\$${base64.encode(out)}'; + } + + static bool isPbkdf2(String stored) => stored.startsWith('pbkdf2\$'); + + /// Whether [pin] matches a [stored] value made by [hash]. + static Future verify(String pin, String salt, String stored) async { + // pbkdf2$sha256$$ + final parts = stored.split('\$'); + if (parts.length != 4 || parts[1] != 'sha256') return false; + final rounds = int.tryParse(parts[2]); + if (rounds == null || rounds <= 0) return false; + final candidate = await hash(pin, salt, rounds); + return constantTimeEquals(candidate, stored); + } + + static bool constantTimeEquals(String a, String b) { + if (a.length != b.length) return false; + var diff = 0; + for (var i = 0; i < a.length; i++) { + diff |= a.codeUnitAt(i) ^ b.codeUnitAt(i); + } + return diff == 0; + } +} + +/// How many wrong guesses in a row are free, and the growing wait after them. +abstract final class PinBackoff { + static const int freeAttempts = 5; + + /// The first wait; it doubles with each further wrong guess, up to [maxWait]. + static const Duration firstWait = Duration(seconds: 30); + static const Duration maxWait = Duration(hours: 1); + + /// The wait imposed after the [failures]-th wrong guess in a row, or null + /// while guesses are still free. + static Duration? waitAfter(int failures) { + if (failures < freeAttempts) return null; + final doublings = min(failures - freeAttempts, 20); + final wait = firstWait * pow(2, doublings).toInt(); + return wait > maxWait ? maxWait : wait; + } + + /// What the [failures]-th wrong guess in a row, made at [now], comes to. + static PinCheckResult afterFailure(int failures, DateTime now) { + final wait = waitAfter(failures); + if (wait == null) return PinCheckResult.wrong(freeAttempts - failures); + return PinCheckResult.lockedOut(now.add(wait)); + } +} + +/// Fingerprint or face, through `local_auth`. Never throws. +class DeviceBiometrics { + DeviceBiometrics([LocalAuthentication? localAuth]) + : _localAuth = localAuth ?? LocalAuthentication(); + + final LocalAuthentication _localAuth; + + Future isAvailable() async { + try { + final supported = await _localAuth.isDeviceSupported(); + if (!supported) return false; + final canCheck = await _localAuth.canCheckBiometrics; + if (!canCheck) return false; + final available = await _localAuth.getAvailableBiometrics(); + return available.isNotEmpty; + } catch (_) { + return false; + } + } + + Future authenticate(String reason) async { + try { + return await _localAuth.authenticate( + localizedReason: reason, + options: const AuthenticationOptions( + stickyAuth: true, + biometricOnly: true, + ), + ); + } catch (_) { + return false; + } + } +} diff --git a/lib/features/profiles/data/profile_pin_store.dart b/lib/features/profiles/data/profile_pin_store.dart new file mode 100644 index 00000000..c010b400 --- /dev/null +++ b/lib/features/profiles/data/profile_pin_store.dart @@ -0,0 +1,223 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:soplay/features/app_lock/data/pin_secrets.dart'; +import 'package:soplay/features/app_lock/domain/entities/pin_check.dart'; + +/// Lets a profile PIN be checked while the server can't be asked. +/// +/// After the server accepts a profile's PIN on this device, a salted PBKDF2 +/// hash of it is kept in secure storage, per account and profile, with its +/// own wrong-guess backoff and the biometric choice for that profile. The PIN +/// itself is never stored. +class ProfilePinStore { + ProfilePinStore({ + FlutterSecureStorage? secureStorage, + DeviceBiometrics? biometrics, + DateTime Function()? clock, + this.iterations = PinHash.iterations, + }) : _secure = + secureStorage ?? + const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock, + ), + ), + _biometrics = biometrics ?? DeviceBiometrics(), + _now = clock ?? DateTime.now; + + /// One secure entry for every profile, so sign-out can clear them all + /// without knowing which account they belonged to. + static const String storageKey = 'profile_pin_verifiers'; + + final FlutterSecureStorage _secure; + final DeviceBiometrics _biometrics; + final DateTime Function() _now; + final int iterations; + + Future _queue = Future.value(); + + /// Runs [run] after every earlier read-modify-write has finished. + Future _locked(Future Function() run) { + final next = _queue.then((_) => run()); + _queue = next.then((_) {}, onError: (_) {}); + return next; + } + + static String _key(String account, String profile) => '$account:$profile'; + + Future>> _readAll() async { + try { + final raw = await _secure.read(key: storageKey); + if (raw == null || raw.isEmpty) return {}; + final data = jsonDecode(raw); + if (data is! Map) return {}; + return { + for (final e in data.entries) + if (e.value is Map) + e.key.toString(): (e.value as Map).cast(), + }; + } catch (_) { + return {}; + } + } + + Future _writeAll(Map> all) async { + try { + if (all.isEmpty) { + await _secure.delete(key: storageKey); + } else { + await _secure.write(key: storageKey, value: jsonEncode(all)); + } + } catch (_) {} + } + + Future _edit( + T Function(Map> all) change, + ) => _locked(() async { + final all = await _readAll(); + final out = change(all); + await _writeAll(all); + return out; + }); + + Future?> _entry(String account, String profile) async => + (await _readAll())[_key(account, profile)]; + + /// Keeps a verifier for [pin], replacing any older one. The biometric + /// choice survives a PIN change; the wrong-guess count does not. + Future remember(String account, String profile, String pin) async { + final salt = PinHash.newSalt(); + final hash = await PinHash.hash(pin, salt, iterations); + await _edit((all) { + final key = _key(account, profile); + final old = all[key] ?? const {}; + all[key] = { + 's': salt, + 'h': hash, + if (old['b'] == true) 'b': true, + if (old['o'] == true) 'o': true, + }; + }); + } + + Future has(String account, String profile) async { + final e = await _entry(account, profile); + return e != null && e['s'] is String && e['h'] is String; + } + + /// [PinCheck.unavailable] when no PIN has been accepted here for it. + Future check( + String account, + String profile, + String pin, + ) async { + final key = _key(account, profile); + final e = await _entry(account, profile); + final salt = e?['s']; + final hash = e?['h']; + if (salt is! String || hash is! String) { + return const PinCheckResult.unavailable(); + } + final until = _lockedUntil(e!); + if (until != null) return PinCheckResult.lockedOut(until); + + final ok = await PinHash.verify(pin, salt, hash); + return _edit((all) { + final current = all[key]; + if (current == null) return const PinCheckResult.unavailable(); + if (ok) { + current + ..remove('f') + ..remove('u'); + return const PinCheckResult.ok(); + } + final failures = ((current['f'] as num?)?.toInt() ?? 0) + 1; + final result = PinBackoff.afterFailure(failures, _now()); + current['f'] = failures; + final retryAt = result.retryAt; + if (retryAt != null) current['u'] = retryAt.millisecondsSinceEpoch; + return result; + }); + } + + DateTime? _lockedUntil(Map e) { + final ms = (e['u'] as num?)?.toInt(); + if (ms == null) return null; + final until = DateTime.fromMillisecondsSinceEpoch(ms); + return until.isAfter(_now()) ? until : null; + } + + Future forget(String account, String profile) => + _edit((all) => all.remove(_key(account, profile))); + + /// Drops [account]'s verifiers for every profile not in [withPin]: deleted + /// elsewhere, or no longer protected. + Future retain(String account, Set withPin) => _edit((all) { + all.removeWhere((key, _) { + if (!key.startsWith('$account:')) return false; + return !withPin.contains(key.substring(account.length + 1)); + }); + }); + + /// Sign-out. + Future clear() => _locked(() => _writeAll({})); + + Future biometricsAvailable() => _biometrics.isAvailable(); + + Future biometricEnabled(String account, String profile) async { + final e = await _entry(account, profile); + return e != null && e['b'] == true && e['h'] is String; + } + + /// Only for a profile that already has a verifier here. + Future setBiometric(String account, String profile, bool on) => + _edit((all) { + final e = all[_key(account, profile)]; + if (e == null) return; + if (on) { + e['b'] = true; + } else { + e.remove('b'); + } + e['o'] = true; + }); + + /// Whether to suggest biometric unlock for [profile]: a PIN was accepted + /// here, it isn't on yet, it hasn't been suggested before, and the device + /// can do it. + Future shouldOfferBiometric(String account, String profile) async { + final e = await _entry(account, profile); + if (e == null || e['h'] is! String) return false; + if (e['b'] == true || e['o'] == true) return false; + return _biometrics.isAvailable(); + } + + Future markBiometricOffered(String account, String profile) => + _edit((all) => all[_key(account, profile)]?['o'] = true); + + Future canUnlockWithBiometric(String account, String profile) async { + if (!await biometricEnabled(account, profile)) return false; + return _biometrics.isAvailable(); + } + + Future authenticate(String reason) => _biometrics.authenticate(reason); + + Future unlockWithBiometric( + String account, + String profile, + String reason, + ) async { + if (!await biometricEnabled(account, profile)) return false; + final ok = await _biometrics.authenticate(reason); + if (ok) { + await _edit((all) { + all[_key(account, profile)] + ?..remove('f') + ..remove('u'); + }); + } + return ok; + } +} diff --git a/lib/features/profiles/data/profile_session.dart b/lib/features/profiles/data/profile_session.dart index 1c7363ee..8d4e749e 100644 --- a/lib/features/profiles/data/profile_session.dart +++ b/lib/features/profiles/data/profile_session.dart @@ -6,6 +6,8 @@ import 'package:hive_flutter/hive_flutter.dart'; import 'package:soplay/core/constants/app_constants.dart'; import 'package:soplay/core/storage/profile_scope.dart'; import 'package:soplay/core/storage/profile_storage.dart'; +import 'package:soplay/features/app_lock/domain/entities/pin_check.dart'; +import 'package:soplay/features/profiles/data/profile_pin_store.dart'; import 'package:soplay/features/profiles/data/profiles_remote_data_source.dart'; import 'package:soplay/features/profiles/domain/household_profile.dart'; @@ -21,8 +23,12 @@ class ProfileSession extends ChangeNotifier { Future Function()? beforeSwitch, Future Function({required bool resync})? onScopeChanged, Box? settings, + ProfilePinStore? pins, + String? Function()? accountId, }) : _remote = remote, _isLoggedIn = isLoggedIn, + _pins = pins, + _accountId = accountId, _beforeSwitch = beforeSwitch, _onScopeChanged = onScopeChanged, _settingsOverride = settings { @@ -31,12 +37,17 @@ class ProfileSession extends ChangeNotifier { static const String cacheKey = 'household_profiles'; static const String activeKey = 'household_active'; + static const String askOnLaunchKey = 'household_ask_on_launch'; final ProfilesRemoteDataSource _remote; final bool Function() _isLoggedIn; final Future Function()? _beforeSwitch; final Future Function({required bool resync})? _onScopeChanged; final Box? _settingsOverride; + final ProfilePinStore? _pins; + final String? Function()? _accountId; + + String get _account => _accountId?.call() ?? ''; Box get _settings => _settingsOverride ?? Hive.box(AppConstants.settingsBox); @@ -46,6 +57,13 @@ class ProfileSession extends ChangeNotifier { bool _chosenThisRun = false; bool _loaded = false; + /// The active profile came from the previous run rather than from a choice + /// or a sign-in in this one. + bool _restored = false; + + /// Profiles whose PIN was entered (or biometrics passed) in this run. + final Set _unlocked = {}; + /// Bumped when the app should show the picker without being asked: the /// active profile was deleted elsewhere, or a sign-in found several. final ValueNotifier pickRequests = ValueNotifier(0); @@ -68,7 +86,35 @@ class ProfileSession extends ChangeNotifier { bool get signedIn => _isLoggedIn(); bool get shouldPick => - _isLoggedIn() && _profiles.length > 1 && !_chosenThisRun; + _isLoggedIn() && + _profiles.length > 1 && + !_chosenThisRun && + !opensLastProfile( + askOnLaunch: askOnLaunch, + restored: _restored ? _active : null, + ); + + /// "Ask who's watching on launch". Off, a launch goes straight into the + /// profile the previous run ended in. + bool get askOnLaunch => _settings.get(askOnLaunchKey) != false; + + Future setAskOnLaunch(bool value) async { + await _settings.put(askOnLaunchKey, value); + notifyListeners(); + } + + /// Whether a launch skips the picker for the profile the last run ended in. + /// Never for one with a PIN: the picker is where that PIN is asked. + static bool opensLastProfile({ + required bool askOnLaunch, + required HouseholdProfile? restored, + }) => !askOnLaunch && restored != null && !restored.hasPin; + + /// Whether opening [profile] has to ask for its PIN. Not for the active + /// profile once it has been unlocked in this run. + bool needsPin(HouseholdProfile profile) => + profile.hasPin && + !(profile.id == _active?.id && _unlocked.contains(profile.id)); HouseholdProfile? get defaultProfile { for (final p in _profiles) { @@ -122,6 +168,7 @@ class ProfileSession extends ChangeNotifier { _active = active != null && active.id == ProfileScope.remoteId ? active : null; + _restored = _active != null; } catch (_) { _profiles = const []; _active = null; @@ -186,10 +233,16 @@ class ProfileSession extends ChangeNotifier { } else if (fresh == null || listing.activeProfileId == null) { await _switchTo(defaultProfile, resync: true); _chosenThisRun = false; + _restored = false; if (shouldPick) pickRequests.value++; } else if (fresh != current) { await _adopt(fresh); } + _unlocked.removeWhere((id) => !(byId(id)?.hasPin ?? false)); + await _pins?.retain(_account, { + for (final p in _profiles) + if (p.hasPin) p.id, + }); await _persist(); notifyListeners(); return true; @@ -239,8 +292,84 @@ class ProfileSession extends ChangeNotifier { if (kidsChanged) await _onScopeChanged?.call(resync: false); } - Future verifyPin(String id, String pin) => - _remote.verifyPin(id, pin); + /// Checks [pin] with the server, or, only when the server can't be + /// reached, with what this device kept from the last time it was accepted. + Future verifyPin(String id, String pin) async { + try { + final profile = await _remote.verifyPin(id, pin); + _unlocked.add(id); + await _remember(id, pin); + return profile; + } on ProfileException catch (e) { + final pins = _pins; + final known = byId(id); + if (!e.isUnreachable || pins == null || known == null) rethrow; + final result = await pins.check(_account, id, pin); + switch (result.outcome) { + case PinCheck.ok: + _unlocked.add(id); + return known; + case PinCheck.wrong: + throw const ProfileException('Wrong PIN', code: 'PIN_INVALID'); + case PinCheck.lockedOut: + throw ProfileException( + 'Too many attempts', + code: 'PIN_LOCKED', + retryAt: result.retryAt, + ); + case PinCheck.unavailable: + throw const ProfileException( + 'PIN not known offline', + code: 'PIN_NOT_ON_DEVICE', + ); + } + } + } + + Future _remember(String id, String pin) async { + try { + await _pins?.remember(_account, id, pin); + } catch (_) {} + } + + /// Whether [id] can be opened with a fingerprint or face on this device. + Future canUnlockWithBiometric(String id) async => + await _pins?.canUnlockWithBiometric(_account, id) ?? false; + + Future unlockWithBiometric(String id, String reason) async { + final ok = + await _pins?.unlockWithBiometric(_account, id, reason) ?? false; + if (ok) _unlocked.add(id); + return ok; + } + + Future shouldOfferBiometric(String id) async => + await _pins?.shouldOfferBiometric(_account, id) ?? false; + + Future markBiometricOffered(String id) async { + await _pins?.markBiometricOffered(_account, id); + } + + /// Whether [id]'s PIN has been accepted here, so biometrics can stand in. + Future hasLocalPin(String id) async => + await _pins?.has(_account, id) ?? false; + + Future biometricEnabled(String id) async => + await _pins?.biometricEnabled(_account, id) ?? false; + + Future biometricsAvailable() async => + await _pins?.biometricsAvailable() ?? false; + + /// Turning it on asks for a fingerprint or face first, so it is the person + /// holding the device who is enrolled. Returns whether it is now on. + Future setBiometric(String id, bool on, {String? reason}) async { + final pins = _pins; + if (pins == null) return false; + if (on && !await pins.authenticate(reason ?? '')) return false; + await pins.setBiometric(_account, id, on); + notifyListeners(); + return on; + } Future create({ required String name, @@ -256,6 +385,7 @@ class ProfileSession extends ChangeNotifier { isKids: isKids, pin: pin, ); + if (pin != null) await _remember(created.id, pin); _profiles = List.unmodifiable([..._profiles, created]); await _persist(); notifyListeners(); @@ -268,6 +398,15 @@ class ProfileSession extends ChangeNotifier { String? currentPin, }) async { final updated = await _remote.update(id, changes, currentPin: currentPin); + if (changes.containsKey('pin')) { + final pin = changes['pin']; + if (pin is String) { + await _remember(id, pin); + } else { + _unlocked.remove(id); + await _pins?.forget(_account, id); + } + } _profiles = List.unmodifiable([ for (final p in _profiles) p.id == id ? updated : p, ]); @@ -280,9 +419,12 @@ class ProfileSession extends ChangeNotifier { Future delete(String id, {String? currentPin}) async { final target = byId(id); await _remote.delete(id, currentPin: currentPin); + _unlocked.remove(id); + await _pins?.forget(_account, id); if (_active?.id == id) { await _switchTo(defaultProfile, resync: true); _chosenThisRun = false; + _restored = false; } _profiles = List.unmodifiable(_profiles.where((p) => p.id != id)); final ns = target?.namespace ?? id; @@ -304,6 +446,9 @@ class ProfileSession extends ChangeNotifier { _active = null; _chosenThisRun = false; _loaded = false; + _restored = false; + _unlocked.clear(); + await _pins?.clear(); await ProfileStorage.deleteAll(_settings, known: known); await _settings.delete(cacheKey); await _settings.delete(activeKey); diff --git a/lib/features/profiles/domain/household_profile.dart b/lib/features/profiles/domain/household_profile.dart index c2e5954d..c5a4f7dd 100644 --- a/lib/features/profiles/domain/household_profile.dart +++ b/lib/features/profiles/domain/household_profile.dart @@ -64,15 +64,29 @@ class HouseholdProfile { } /// What the profiles endpoints refuse with. [code] is the server's machine -/// code (`PIN_INVALID`, `PROFILE_LIMIT`, ...); null for network failures. +/// code (`PIN_INVALID`, `PROFILE_LIMIT`, ...), or one the device checked the +/// PIN with (`PIN_LOCKED`, `PIN_NOT_ON_DEVICE`); null for network failures. class ProfileException implements Exception { - const ProfileException(this.message, {this.code, this.status}); + const ProfileException( + this.message, { + this.code, + this.status, + this.retryAt, + }); final String message; final String? code; final int? status; - bool get isOffline => status == null; + /// When the next guess is allowed, after too many wrong ones on the device. + final DateTime? retryAt; + + bool get isOffline => status == null && code == null; + + /// The server could not be asked at all, or its gateway said so. A refusal + /// (a wrong PIN, a rate limit) is an answer and never counts. + bool get isUnreachable => + isOffline || status == 502 || status == 503 || status == 504; @override String toString() => message; diff --git a/lib/features/profiles/presentation/pages/household_profile_edit_page.dart b/lib/features/profiles/presentation/pages/household_profile_edit_page.dart index 9a8771db..ffbb8f6d 100644 --- a/lib/features/profiles/presentation/pages/household_profile_edit_page.dart +++ b/lib/features/profiles/presentation/pages/household_profile_edit_page.dart @@ -137,6 +137,7 @@ class _HouseholdProfileEditPageState extends State { original, title: 'profiles.pin_current_title'.tr(), subtitle: 'profiles.pin_current_subtitle'.tr(), + allowBiometric: false, ); Future _save() async { diff --git a/lib/features/profiles/presentation/pages/manage_profiles_page.dart b/lib/features/profiles/presentation/pages/manage_profiles_page.dart index 6e9e9946..73b6b14b 100644 --- a/lib/features/profiles/presentation/pages/manage_profiles_page.dart +++ b/lib/features/profiles/presentation/pages/manage_profiles_page.dart @@ -7,6 +7,7 @@ import 'package:soplay/core/widgets/app_buttons.dart'; import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; import 'package:soplay/features/profiles/data/profile_session.dart'; import 'package:soplay/features/profiles/domain/household_profile.dart'; +import 'package:soplay/features/profiles/presentation/profile_flows.dart'; import 'package:soplay/features/profiles/presentation/widgets/profile_avatar.dart'; class ManageProfilesPage extends StatefulWidget { @@ -24,6 +25,10 @@ class _ManageProfilesPageState extends State { bool _loading = false; bool _failed = false; + /// Profiles whose PIN this device knows, and whether each opens with + /// biometrics. Empty where biometrics aren't available. + Map _biometric = const {}; + @override void initState() { super.initState(); @@ -52,6 +57,37 @@ class _ManageProfilesPageState extends State { _loading = false; _failed = !ok; }); + await _loadBiometric(); + } + + Future _loadBiometric() async { + final out = {}; + if (await _session.biometricsAvailable()) { + for (final p in _session.profiles) { + if (p.hasPin && await _session.hasLocalPin(p.id)) { + out[p.id] = await _session.biometricEnabled(p.id); + } + } + } + if (mounted) setState(() => _biometric = out); + } + + Future _toggleBiometric(HouseholdProfile p, bool on) async { + if (on) { + final pin = await askProfilePin( + context, + _session, + p, + allowBiometric: false, + ); + if (pin == null || !mounted) return; + } + await _session.setBiometric( + p.id, + on, + reason: 'app_lock.biometric_enable_reason'.tr(), + ); + await _loadBiometric(); } String? _tags(HouseholdProfile p) { @@ -120,6 +156,29 @@ class _ManageProfilesPageState extends State { ], ), SettingsFootnote('profiles.limit'.tr(args: ['${_session.max}'])), + const SizedBox(height: 20), + SettingsLabel('profiles.on_this_device'.tr()), + SettingsCard( + children: [ + SettingsSwitchTile( + icon: Icons.switch_account_rounded, + title: 'profiles.ask_on_launch'.tr(), + subtitle: 'profiles.ask_on_launch_desc'.tr(), + value: _session.askOnLaunch, + onChanged: _session.setAskOnLaunch, + ), + for (final p in profiles) + if (_biometric.containsKey(p.id)) ...[ + const SettingsDivider(), + SettingsSwitchTile( + icon: Icons.fingerprint_rounded, + title: 'profiles.biometric_toggle'.tr(args: [p.name]), + value: _biometric[p.id]!, + onChanged: (on) => _toggleBiometric(p, on), + ), + ], + ], + ), ], ], ); diff --git a/lib/features/profiles/presentation/pages/profile_picker_page.dart b/lib/features/profiles/presentation/pages/profile_picker_page.dart index 258a8e80..239d9084 100644 --- a/lib/features/profiles/presentation/pages/profile_picker_page.dart +++ b/lib/features/profiles/presentation/pages/profile_picker_page.dart @@ -101,7 +101,7 @@ class _ProfilePickerPageState extends State { Future _open(HouseholdProfile profile) async { if (_opening != null) return; - if (profile.hasPin) { + if (_session.needsPin(profile)) { final pin = await askProfilePin(context, _session, profile); if (pin == null || !mounted) return; } diff --git a/lib/features/profiles/presentation/profile_flows.dart b/lib/features/profiles/presentation/profile_flows.dart index 98cfa2e2..b737233f 100644 --- a/lib/features/profiles/presentation/profile_flows.dart +++ b/lib/features/profiles/presentation/profile_flows.dart @@ -10,6 +10,10 @@ String profileErrorText(Object error, {int max = 5}) { switch (error.code) { case 'PIN_INVALID': return 'profiles.pin_wrong'.tr(); + case 'PIN_LOCKED': + return _lockedText(error.retryAt); + case 'PIN_NOT_ON_DEVICE': + return 'profiles.pin_not_on_device'.tr(); case 'PROFILE_LIMIT': return 'profiles.limit'.tr(args: ['$max']); case 'PROFILE_NAME_TAKEN': @@ -25,28 +29,92 @@ String profileErrorText(Object error, {int max = 5}) { return 'profiles.error_generic'.tr(); } -/// Asks for [profile]'s PIN and checks it with the server. Returns the PIN, -/// or null if the viewer gave up. +String _lockedText(DateTime? at) { + if (at == null) return 'profiles.pin_too_many'.tr(); + final hh = at.hour.toString().padLeft(2, '0'); + final mm = at.minute.toString().padLeft(2, '0'); + return 'profiles.pin_locked_until'.tr(args: ['$hh:$mm']); +} + +/// Asks for [profile]'s PIN and checks it (with the server, or on the device +/// when offline). Returns the PIN, an empty string when biometrics unlocked +/// it instead, or null if the viewer gave up. +/// +/// [allowBiometric] is false where the PIN itself is needed afterwards. Future askProfilePin( BuildContext context, ProfileSession session, HouseholdProfile profile, { String? title, String? subtitle, -}) => ProfilePinPage.verify( - context, - title: title ?? 'profiles.pin_enter_title'.tr(), - subtitle: subtitle ?? 'profiles.pin_enter_subtitle'.tr(args: [profile.name]), - header: ProfileAvatar.of(profile, size: 84, showLock: false), - onSubmit: (pin) async { - try { - await session.verifyPin(profile.id, pin); - return null; - } catch (e) { - return profileErrorText(e); - } - }, -); + bool allowBiometric = true, +}) async { + final biometric = + allowBiometric && await session.canUnlockWithBiometric(profile.id); + if (!context.mounted) return null; + final pin = await ProfilePinPage.verify( + context, + title: title ?? 'profiles.pin_enter_title'.tr(), + subtitle: + subtitle ?? 'profiles.pin_enter_subtitle'.tr(args: [profile.name]), + header: ProfileAvatar.of(profile, size: 84, showLock: false), + onSubmit: (pin) async { + try { + await session.verifyPin(profile.id, pin); + return null; + } catch (e) { + return profileErrorText(e); + } + }, + onBiometric: biometric + ? () => session.unlockWithBiometric( + profile.id, + 'profiles.biometric_reason'.tr(args: [profile.name]), + ) + : null, + ); + if (allowBiometric && pin != null && pin.isNotEmpty && context.mounted) { + await offerProfileBiometric(context, session, profile); + } + return pin; +} + +/// Once per profile and device, after its PIN was accepted: offer to open it +/// with a fingerprint or face from now on. +Future offerProfileBiometric( + BuildContext context, + ProfileSession session, + HouseholdProfile profile, +) async { + if (!await session.shouldOfferBiometric(profile.id)) return; + if (!context.mounted) return; + await session.markBiometricOffered(profile.id); + if (!context.mounted) return; + final yes = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('profiles.biometric_offer_title'.tr()), + content: Text('profiles.biometric_offer_body'.tr(args: [profile.name])), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text('profiles.biometric_offer_no'.tr()), + ), + TextButton( + autofocus: true, + onPressed: () => Navigator.of(ctx).pop(true), + child: Text('profiles.biometric_offer_yes'.tr()), + ), + ], + ), + ); + if (yes != true) return; + await session.setBiometric( + profile.id, + true, + reason: 'app_lock.biometric_enable_reason'.tr(), + ); +} /// Whether changing [target]'s PIN or kids flag, or deleting it, needs its /// current PIN: the server waives it only for the main profile acting on diff --git a/lib/features/profiles/presentation/widgets/profile_pin_page.dart b/lib/features/profiles/presentation/widgets/profile_pin_page.dart index 55598b6d..8ed009fc 100644 --- a/lib/features/profiles/presentation/widgets/profile_pin_page.dart +++ b/lib/features/profiles/presentation/widgets/profile_pin_page.dart @@ -12,6 +12,7 @@ class ProfilePinPage extends StatefulWidget { required this.subtitle, this.header, this.onSubmit, + this.onBiometric, }); static const int length = 4; @@ -24,13 +25,18 @@ class ProfilePinPage extends StatefulWidget { /// Checks a PIN; returns an error message, or null when it was accepted. final Future Function(String pin)? onSubmit; - /// Returns the accepted PIN, or null if the page was closed. + /// Unlocks without the PIN; offered on the keypad and tried once on open. + final Future Function()? onBiometric; + + /// Returns the accepted PIN, an empty string when [onBiometric] unlocked + /// it, or null if the page was closed. static Future verify( BuildContext context, { required String title, required String subtitle, Widget? header, required Future Function(String pin) onSubmit, + Future Function()? onBiometric, }) => Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, @@ -40,6 +46,7 @@ class ProfilePinPage extends StatefulWidget { subtitle: subtitle, header: header, onSubmit: onSubmit, + onBiometric: onBiometric, ), ), ); @@ -71,6 +78,24 @@ class _ProfilePinPageState extends State { bool get _confirming => widget.creating && _first != null; + @override + void initState() { + super.initState(); + if (widget.onBiometric != null) { + WidgetsBinding.instance.addPostFrameCallback((_) => _biometric()); + } + } + + Future _biometric() async { + final unlock = widget.onBiometric; + if (unlock == null || _busy || !mounted) return; + setState(() => _busy = true); + final ok = await unlock(); + if (!mounted) return; + setState(() => _busy = false); + if (ok) Navigator.of(context).pop(''); + } + void _digit(String d) { if (_busy || _entered.length >= ProfilePinPage.length) return; setState(() { @@ -222,7 +247,13 @@ class _ProfilePinPageState extends State { ), ), const Spacer(), - PinKeypad(onDigit: _digit, onBackspace: _backspace), + PinKeypad( + onDigit: _digit, + onBackspace: _backspace, + onBiometric: widget.onBiometric == null + ? null + : _biometric, + ), const SizedBox(height: 24), ], ), diff --git a/lib/features/splash/presentation/pages/splash_page.dart b/lib/features/splash/presentation/pages/splash_page.dart index 73cc7944..74b7a6a6 100644 --- a/lib/features/splash/presentation/pages/splash_page.dart +++ b/lib/features/splash/presentation/pages/splash_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; @@ -18,18 +19,15 @@ class SplashPage extends StatefulWidget { } class _SplashPageState extends State { - /// Where to go, decided while the animation is still finishing. + /// Where to go, decided while the animation plays. /// - /// The lock check reads a file and the onboarding check reads Hive. Starting - /// both when the letter lands rather than when the animation ends overlaps - /// them with the last 800ms, so the handover is a fade into a screen that is - /// already resolved instead of a fade into a wait. - Future? _target; - - void _onSettled() => _target ??= _resolveRoute(); + /// Started with the splash: once it is known, the splash hurries to its + /// fade instead of playing the full three seconds over an app that is + /// already waiting. + late final Future _target = _resolveRoute(); Future _onDone() async { - final target = await (_target ??= _resolveRoute()); + final target = await _target; if (!mounted) return; context.go(target); } @@ -58,12 +56,13 @@ class _SplashPageState extends State { } /// The cached list decides when there is one; otherwise the account is - /// asked, but only briefly — a slow network must not hold the splash. + /// asked, but only briefly — a slow network must not hold the splash, and + /// no network is not waited for at all. Future _profilesDue() async { final session = getIt(); if (!getIt().isLoggedIn) return false; final refreshed = session.refresh(); - if (session.profiles.isEmpty) { + if (session.profiles.isEmpty && !await _offline()) { await refreshed.timeout( const Duration(milliseconds: 1500), onTimeout: () => false, @@ -72,12 +71,27 @@ class _SplashPageState extends State { return session.shouldPick; } + Future _offline() async { + try { + final kinds = await Connectivity().checkConnectivity().timeout( + const Duration(milliseconds: 300), + ); + return isOffline(kinds); + } catch (_) { + return false; + } + } + @override Widget build(BuildContext context) { - return SozoSplash(onSettled: _onSettled, onDone: _onDone); + return SozoSplash(onDone: _onDone, ready: _target); } } +/// No connection of any kind. An empty answer is not taken as offline. +bool isOffline(List kinds) => + kinds.isNotEmpty && kinds.every((k) => k == ConnectivityResult.none); + /// The setup a launch should open, or null for the app itself. /// /// Once per install, on a device nobody has signed in on. A setup already diff --git a/lib/features/splash/presentation/widgets/sozo_splash.dart b/lib/features/splash/presentation/widgets/sozo_splash.dart index 1c1e7345..d05199f2 100644 --- a/lib/features/splash/presentation/widgets/sozo_splash.dart +++ b/lib/features/splash/presentation/widgets/sozo_splash.dart @@ -49,16 +49,28 @@ import 'package:soplay/core/widgets/sozo_mark.dart'; /// The artwork's own, never the accent the user picked. The accent is /// theirs; the logo is the brand. class SozoSplash extends StatefulWidget { - const SozoSplash({super.key, required this.onSettled, required this.onDone}); + const SozoSplash({ + super.key, + this.onSettled, + required this.onDone, + this.ready, + }); - /// The letter is complete and the rest is the hold. The route resolves from - /// here rather than at the end, so the work overlaps the last second instead - /// of starting after it. - final VoidCallback onSettled; + /// The letter is complete and the rest is the hold. + final VoidCallback? onSettled; /// The animation is over and the screen may be replaced. final VoidCallback onDone; + /// Completes when the app behind the splash is ready. From then on, and + /// after [minimum], the rest of the performance plays faster: the drawing + /// hurries to the finished logo, the foot is skipped unless it is already + /// moving, and the logo fades. Null plays it in full. + final Future? ready; + + /// The least the splash is on screen, however early the app is ready. + static const Duration minimum = Duration(milliseconds: 800); + @override State createState() => _SozoSplashState(); } @@ -71,6 +83,9 @@ class _SozoSplashState extends State ); bool _settled = false; + bool _hurrying = false; + bool _done = false; + final Stopwatch _shown = Stopwatch()..start(); SozoMarkGeometry? _geometry; // TEMPORARY review loop — replays forever so the animation can be judged. @@ -98,6 +113,18 @@ class _SozoSplashState extends State } _c.addListener(_watch); _play(); + final ready = widget.ready; + if (ready != null) _whenReady(ready); + } + + /// A failure is the route's to report, when the full performance ends. + Future _whenReady(Future ready) async { + try { + await ready; + } catch (_) { + return; + } + await _readyNow(); } void _play() { @@ -108,10 +135,42 @@ class _SozoSplashState extends State if (mounted) _play(); return; } - widget.onDone(); + _finish(); }); } + void _finish() { + if (_done) return; + _done = true; + widget.onDone(); + } + + Future _readyNow() async { + if (_reviewLoop) return; + final wait = SozoSplash.minimum - _shown.elapsed; + if (wait > Duration.zero) await Future.delayed(wait); + if (!mounted || _hurrying || _done) return; + _hurrying = true; + final plan = hurryPlan(_c.value * _Beats.total); + if (plan == null) return; + if (plan.catchUp != null) { + await _c.animateTo( + plan.catchUpTo / _Beats.total, + duration: plan.catchUp!, + curve: Curves.linear, + ); + if (!mounted) return; + } + // A still frame to a still frame: the logo at rest looks the same either + // side of the foot, so the jump is not seen. + _c.value = _Beats.fadeFrom / _Beats.total; + await _c.animateTo( + 1, + duration: const Duration(milliseconds: _Beats.total - _Beats.fadeFrom), + ); + if (mounted) _finish(); + } + /// The letter completing, reported once. /// /// From the listener rather than a timer: a controller that is behind — @@ -120,7 +179,7 @@ class _SozoSplashState extends State void _watch() { if (_settled || _c.value * _Beats.total < _Beats.paintTo) return; _settled = true; - widget.onSettled(); + widget.onSettled?.call(); } @override @@ -262,6 +321,40 @@ abstract final class _Beats { static const int total = fadeFrom + 240; } +/// How a splash at [ms] reaches its fade once the app is ready, or null when +/// it is already fading. +/// +/// Before the logo is finished, the drawing and paint play out at up to +/// [hurryCap] in total; during the foot, the foot finishes at its own speed — +/// cutting a blow off mid-swing reads as a glitch. Otherwise straight to the +/// fade. +@visibleForTesting +({Duration? catchUp, int catchUpTo})? hurryPlan(double ms) { + if (ms >= _Beats.fadeFrom) return null; + if (ms < _Beats.holdFrom) { + final left = (_Beats.holdFrom - ms).round(); + return ( + catchUp: Duration(milliseconds: math.min(left, hurryCap)), + catchUpTo: _Beats.holdFrom, + ); + } + if (ms > _Beats.pressFrom && ms < _Beats.settleTo) { + return ( + catchUp: Duration(milliseconds: (_Beats.settleTo - ms).round()), + catchUpTo: _Beats.settleTo, + ); + } + return (catchUp: null, catchUpTo: _Beats.fadeFrom); +} + +/// The longest the drawing takes to finish once the app is ready. +@visibleForTesting +const int hurryCap = 380; + +/// The full performance, for tests. +@visibleForTesting +const int splashTotalMs = _Beats.total; + /// The black field the logo sits on. Separated so both branches share it and /// neither can drift from the window's own background colour. class _Stage extends StatelessWidget { diff --git a/test/features/profiles/profile_pin_offline_test.dart b/test/features/profiles/profile_pin_offline_test.dart new file mode 100644 index 00000000..2b3e451d --- /dev/null +++ b/test/features/profiles/profile_pin_offline_test.dart @@ -0,0 +1,341 @@ +// A profile PIN with the server out of reach, and launching straight into the +// last profile. The fallback must only ever stand in for a server that could +// not be asked: a server that said no is never second-guessed by the device. +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/storage/profile_scope.dart'; +import 'package:soplay/core/storage/profile_storage.dart'; +import 'package:soplay/features/app_lock/data/pin_secrets.dart'; +import 'package:soplay/features/profiles/data/profile_pin_store.dart'; +import 'package:soplay/features/profiles/data/profile_session.dart'; +import 'package:soplay/features/profiles/data/profiles_remote_data_source.dart'; +import 'package:soplay/features/profiles/domain/household_profile.dart'; + +const _main = HouseholdProfile(id: 'main1', name: 'Aziz', isDefault: true); +const _kid = HouseholdProfile(id: 'kid1', name: 'Lola', isKids: true); +const _guest = HouseholdProfile(id: 'guest1', name: 'Guest', hasPin: true); + +enum _Net { up, down, gateway } + +class _FakeRemote implements ProfilesRemoteDataSource { + List profiles = [_main, _kid, _guest]; + String serverPin = '1234'; + _Net net = _Net.up; + bool rateLimited = false; + int verifies = 0; + + void _reach() { + if (net == _Net.down) throw const ProfileException('offline'); + if (net == _Net.gateway) { + throw const ProfileException('Bad gateway', status: 502); + } + } + + @override + Future list() async { + _reach(); + return ProfilesListing( + profiles: profiles, + activeProfileId: ProfileScope.remoteId ?? _main.id, + max: 4, + ); + } + + @override + Future verifyPin(String id, String pin) async { + verifies++; + _reach(); + if (rateLimited) throw const ProfileException('slow down', status: 429); + if (pin != serverPin) { + throw const ProfileException('wrong', code: 'PIN_INVALID', status: 403); + } + return profiles.firstWhere((p) => p.id == id); + } + + @override + Future update( + String id, + Map changes, { + String? currentPin, + }) async { + _reach(); + final old = profiles.firstWhere((p) => p.id == id); + final updated = HouseholdProfile( + id: old.id, + name: old.name, + isDefault: old.isDefault, + hasPin: changes.containsKey('pin') ? changes['pin'] != null : old.hasPin, + ); + profiles = [for (final p in profiles) p.id == id ? updated : p]; + return updated; + } + + @override + Future delete(String id, {String? currentPin}) async { + _reach(); + profiles = profiles.where((p) => p.id != id).toList(); + } + + @override + dynamic noSuchMethod(Invocation i) => super.noSuchMethod(i); +} + +class _NoBiometrics extends DeviceBiometrics { + @override + Future isAvailable() async => false; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory dir; + late Box settings; + late _FakeRemote remote; + late ProfilePinStore pins; + late DateTime now; + var loggedIn = true; + + ProfileSession session() => ProfileSession( + remote: remote, + isLoggedIn: () => loggedIn, + settings: settings, + pins: pins, + accountId: () => 'acc1', + ); + + setUp(() async { + dir = await Directory.systemTemp.createTemp('sozo_pin_offline_'); + Hive.init(dir.path); + ProfileStorage.directory = dir.path; + ProfileScope.reset(); + Future memory(String name) => Hive.openBox(name, bytes: Uint8List(0)); + settings = await memory(AppConstants.settingsBox); + for (final name in ProfileScope.profileBoxes) { + await memory(name); + await memory(ProfileScope.boxFor(name, _guest.id)); + } + FlutterSecureStorage.setMockInitialValues({}); + now = DateTime(2026, 11, 7, 12); + pins = ProfilePinStore( + biometrics: _NoBiometrics(), + clock: () => now, + iterations: 10, + ); + remote = _FakeRemote(); + loggedIn = true; + }); + + tearDown(() async { + ProfileScope.reset(); + await Hive.close(); + await dir.delete(recursive: true); + }); + + Future refused(Future f) async { + try { + await f; + } on ProfileException catch (e) { + return e; + } + fail('expected a ProfileException'); + } + + group('offline PIN', () { + test('a PIN accepted online opens the profile offline', () async { + final s = session(); + await s.refresh(); + await s.verifyPin(_guest.id, '1234'); + expect(await pins.has('acc1', _guest.id), isTrue); + + remote.net = _Net.down; + expect((await s.verifyPin(_guest.id, '1234')).id, _guest.id); + }); + + test('a gateway error counts as unreachable', () async { + final s = session(); + await s.refresh(); + await s.verifyPin(_guest.id, '1234'); + remote.net = _Net.gateway; + expect((await s.verifyPin(_guest.id, '1234')).id, _guest.id); + }); + + test('offline, a wrong PIN is wrong', () async { + final s = session(); + await s.refresh(); + await s.verifyPin(_guest.id, '1234'); + remote.net = _Net.down; + final e = await refused(s.verifyPin(_guest.id, '9999')); + expect(e.code, 'PIN_INVALID'); + }); + + test('a server that said no is never overruled by the device', () async { + final s = session(); + await s.refresh(); + await s.verifyPin(_guest.id, '1234'); + // Changed on another device: the old PIN is still what this one knows. + remote.serverPin = '5555'; + final e = await refused(s.verifyPin(_guest.id, '1234')); + expect(e.code, 'PIN_INVALID'); + expect(e.status, 403); + + remote.rateLimited = true; + final limited = await refused(s.verifyPin(_guest.id, '5555')); + expect(limited.status, 429); + }); + + test('offline with nothing kept says so', () async { + final s = session(); + await s.refresh(); + remote.net = _Net.down; + final e = await refused(s.verifyPin(_guest.id, '1234')); + expect(e.code, 'PIN_NOT_ON_DEVICE'); + }); + + test('offline guessing locks out', () async { + final s = session(); + await s.refresh(); + await s.verifyPin(_guest.id, '1234'); + remote.net = _Net.down; + for (var i = 1; i < PinBackoff.freeAttempts; i++) { + expect((await refused(s.verifyPin(_guest.id, '0000'))).code, + 'PIN_INVALID'); + } + final locked = await refused(s.verifyPin(_guest.id, '0000')); + expect(locked.code, 'PIN_LOCKED'); + expect(locked.retryAt, now.add(PinBackoff.firstWait)); + expect( + (await refused(s.verifyPin(_guest.id, '1234'))).code, + 'PIN_LOCKED', + ); + now = now.add(const Duration(minutes: 1)); + expect((await s.verifyPin(_guest.id, '1234')).id, _guest.id); + }); + + test('a new PIN set here replaces the kept one', () async { + final s = session(); + await s.refresh(); + await s.verifyPin(_guest.id, '1234'); + await s.update(_guest.id, {'pin': '7777'}, currentPin: '1234'); + remote.net = _Net.down; + expect((await refused(s.verifyPin(_guest.id, '1234'))).code, + 'PIN_INVALID'); + expect((await s.verifyPin(_guest.id, '7777')).id, _guest.id); + }); + + test('removing the PIN forgets it', () async { + final s = session(); + await s.refresh(); + await s.verifyPin(_guest.id, '1234'); + await s.update(_guest.id, {'pin': null}, currentPin: '1234'); + expect(await pins.has('acc1', _guest.id), isFalse); + }); + + test('a PIN removed elsewhere is forgotten on the next listing', () async { + final s = session(); + await s.refresh(); + await s.verifyPin(_guest.id, '1234'); + remote.profiles = [ + _main, + _kid, + const HouseholdProfile(id: 'guest1', name: 'Guest'), + ]; + await s.refresh(); + expect(await pins.has('acc1', _guest.id), isFalse); + }); + + test('deleting the profile forgets it', () async { + final s = session(); + await s.refresh(); + await s.verifyPin(_guest.id, '1234'); + await s.delete(_guest.id, currentPin: '1234'); + expect(await pins.has('acc1', _guest.id), isFalse); + }); + + test('sign-out forgets every one', () async { + final s = session(); + await s.refresh(); + await s.verifyPin(_guest.id, '1234'); + loggedIn = false; + await s.forgetAll(); + expect(await pins.has('acc1', _guest.id), isFalse); + }); + }); + + group('launch', () { + test('the picker is skipped only when asked to, and never for a PIN', () { + expect( + ProfileSession.opensLastProfile(askOnLaunch: true, restored: _kid), + isFalse, + ); + expect( + ProfileSession.opensLastProfile(askOnLaunch: false, restored: _kid), + isTrue, + ); + expect( + ProfileSession.opensLastProfile(askOnLaunch: false, restored: _guest), + isFalse, + ); + expect( + ProfileSession.opensLastProfile(askOnLaunch: false, restored: null), + isFalse, + ); + }); + + Future relaunchAs(HouseholdProfile last) async { + await session().refresh(); + await settings.put(ProfileSession.activeKey, jsonEncode(last.toJson())); + ProfileScope.set(namespace: last.namespace, remoteId: last.id); + return session(); + } + + test('asks by default', () async { + final s = await relaunchAs(_main); + expect(s.askOnLaunch, isTrue); + expect(s.shouldPick, isTrue); + }); + + test('with the setting off, opens the last profile', () async { + await session().setAskOnLaunch(false); + final s = await relaunchAs(_main); + expect(s.active?.id, _main.id); + expect(s.shouldPick, isFalse); + }); + + test('with the setting off, a PIN profile still goes through the picker', + () async { + await session().setAskOnLaunch(false); + final s = await relaunchAs(_guest); + expect(s.shouldPick, isTrue); + }); + + test('a fresh sign-in still picks', () async { + await session().setAskOnLaunch(false); + final s = session(); + await s.refresh(); + expect(s.shouldPick, isTrue); + }); + + test('the active profile is not asked for again once unlocked', () async { + final s = session(); + await s.refresh(); + expect(s.needsPin(_guest), isTrue); + await s.verifyPin(_guest.id, '1234'); + expect(s.needsPin(_guest), isTrue, reason: 'not the active one yet'); + await s.activate(_guest); + expect(s.needsPin(_guest), isFalse); + expect(s.needsPin(_kid), isFalse); + }); + + test('a fresh run asks for the active profile again', () async { + final s = await relaunchAs(_guest); + expect(s.active?.id, _guest.id); + expect(s.needsPin(_guest), isTrue); + }); + }); +} diff --git a/test/features/profiles/profile_pin_store_test.dart b/test/features/profiles/profile_pin_store_test.dart new file mode 100644 index 00000000..5e44fbd8 --- /dev/null +++ b/test/features/profiles/profile_pin_store_test.dart @@ -0,0 +1,173 @@ +// The offline check of a profile PIN: what is kept, when it is dropped, and +// that guessing on the device is slowed down the way the app lock is. +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/app_lock/data/pin_secrets.dart'; +import 'package:soplay/features/app_lock/domain/entities/pin_check.dart'; +import 'package:soplay/features/profiles/data/profile_pin_store.dart'; + +class _FakeBiometrics extends DeviceBiometrics { + bool available = true; + bool accept = true; + int asked = 0; + + @override + Future isAvailable() async => available; + + @override + Future authenticate(String reason) async { + asked++; + return accept; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late DateTime now; + late _FakeBiometrics bio; + late FlutterSecureStorage secure; + late ProfilePinStore store; + + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + secure = const FlutterSecureStorage(); + now = DateTime(2026, 11, 7, 12); + bio = _FakeBiometrics(); + store = ProfilePinStore( + secureStorage: secure, + biometrics: bio, + clock: () => now, + iterations: 10, + ); + }); + + test('nothing is known before a PIN was accepted here', () async { + expect(await store.has('acc', 'p1'), isFalse); + expect( + (await store.check('acc', 'p1', '1234')).outcome, + PinCheck.unavailable, + ); + }); + + test('keeps a salted hash, never the PIN', () async { + await store.remember('acc', 'p1', '4821'); + final raw = await secure.read(key: ProfilePinStore.storageKey); + expect(raw, isNotNull); + expect(raw, isNot(contains('4821'))); + expect(raw, contains('pbkdf2')); + expect(await store.has('acc', 'p1'), isTrue); + }); + + test('checks the PIN it was given', () async { + await store.remember('acc', 'p1', '4821'); + expect((await store.check('acc', 'p1', '4821')).isOk, isTrue); + expect((await store.check('acc', 'p1', '1111')).outcome, PinCheck.wrong); + }); + + test('is per account and per profile', () async { + await store.remember('acc', 'p1', '4821'); + expect( + (await store.check('other', 'p1', '4821')).outcome, + PinCheck.unavailable, + ); + expect( + (await store.check('acc', 'p2', '4821')).outcome, + PinCheck.unavailable, + ); + }); + + test('a new PIN replaces the old one', () async { + await store.remember('acc', 'p1', '4821'); + await store.remember('acc', 'p1', '9090'); + expect((await store.check('acc', 'p1', '4821')).outcome, PinCheck.wrong); + expect((await store.check('acc', 'p1', '9090')).isOk, isTrue); + }); + + test('forget, retain and clear drop verifiers', () async { + await store.remember('acc', 'p1', '1111'); + await store.remember('acc', 'p2', '2222'); + await store.remember('acc', 'p3', '3333'); + await store.remember('other', 'p1', '4444'); + + await store.forget('acc', 'p1'); + expect(await store.has('acc', 'p1'), isFalse); + expect(await store.has('acc', 'p2'), isTrue); + + await store.retain('acc', {'p3'}); + expect(await store.has('acc', 'p2'), isFalse); + expect(await store.has('acc', 'p3'), isTrue); + expect(await store.has('other', 'p1'), isTrue); + + await store.clear(); + expect(await store.has('acc', 'p3'), isFalse); + expect(await store.has('other', 'p1'), isFalse); + expect(await secure.read(key: ProfilePinStore.storageKey), isNull); + }); + + test('wrong guesses lock it out, as the app lock does', () async { + await store.remember('acc', 'p1', '4821'); + for (var i = 1; i < PinBackoff.freeAttempts; i++) { + final r = await store.check('acc', 'p1', '0000'); + expect(r.outcome, PinCheck.wrong); + expect(r.attemptsLeft, PinBackoff.freeAttempts - i); + } + final locked = await store.check('acc', 'p1', '0000'); + expect(locked.outcome, PinCheck.lockedOut); + expect(locked.retryAt, now.add(PinBackoff.firstWait)); + + // Even the right PIN waits. + expect( + (await store.check('acc', 'p1', '4821')).outcome, + PinCheck.lockedOut, + ); + + now = now.add(PinBackoff.firstWait + const Duration(seconds: 1)); + expect((await store.check('acc', 'p1', '4821')).isOk, isTrue); + + // A right PIN starts the count again. + expect((await store.check('acc', 'p1', '0000')).attemptsLeft, 4); + }); + + test('the wait doubles and is capped', () { + expect(PinBackoff.waitAfter(4), isNull); + expect(PinBackoff.waitAfter(5), const Duration(seconds: 30)); + expect(PinBackoff.waitAfter(6), const Duration(seconds: 60)); + expect(PinBackoff.waitAfter(40), PinBackoff.maxWait); + }); + + group('biometrics', () { + test('offered once, only with a verifier and a sensor', () async { + expect(await store.shouldOfferBiometric('acc', 'p1'), isFalse); + await store.remember('acc', 'p1', '4821'); + bio.available = false; + expect(await store.shouldOfferBiometric('acc', 'p1'), isFalse); + bio.available = true; + expect(await store.shouldOfferBiometric('acc', 'p1'), isTrue); + await store.markBiometricOffered('acc', 'p1'); + expect(await store.shouldOfferBiometric('acc', 'p1'), isFalse); + }); + + test('unlocks only when turned on for that profile', () async { + await store.remember('acc', 'p1', '4821'); + expect(await store.unlockWithBiometric('acc', 'p1', 'r'), isFalse); + expect(bio.asked, 0); + + await store.setBiometric('acc', 'p1', true); + expect(await store.canUnlockWithBiometric('acc', 'p1'), isTrue); + expect(await store.unlockWithBiometric('acc', 'p1', 'r'), isTrue); + + bio.accept = false; + expect(await store.unlockWithBiometric('acc', 'p1', 'r'), isFalse); + }); + + test('survives a PIN change, not a forget', () async { + await store.remember('acc', 'p1', '4821'); + await store.setBiometric('acc', 'p1', true); + await store.remember('acc', 'p1', '9090'); + expect(await store.biometricEnabled('acc', 'p1'), isTrue); + await store.forget('acc', 'p1'); + expect(await store.biometricEnabled('acc', 'p1'), isFalse); + }); + }); +} diff --git a/test/features/splash/splash_hurry_test.dart b/test/features/splash/splash_hurry_test.dart new file mode 100644 index 00000000..b77cdbf4 --- /dev/null +++ b/test/features/splash/splash_hurry_test.dart @@ -0,0 +1,49 @@ +// A splash over an app that is already ready should end soon, but end the way +// it always does: the logo finished, then the fade. +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/splash/presentation/pages/splash_page.dart'; +import 'package:soplay/features/splash/presentation/widgets/sozo_splash.dart'; + +void main() { + test('mid-drawing, it finishes the logo quickly, then fades', () { + final plan = hurryPlan(800)!; + expect(plan.catchUp, isNotNull); + expect(plan.catchUp!.inMilliseconds, lessThanOrEqualTo(hurryCap)); + expect(plan.catchUpTo, greaterThan(800)); + }); + + test('with the logo finished and still, it goes straight to the fade', () { + final plan = hurryPlan(1500)!; + expect(plan.catchUp, isNull); + }); + + test('a foot already moving finishes its gesture first', () { + final plan = hurryPlan(1900)!; + expect(plan.catchUp, isNotNull); + expect(plan.catchUpTo, greaterThan(1900)); + }); + + test('already fading, nothing changes', () { + expect(hurryPlan(splashTotalMs - 10.0), isNull); + }); + + test('the earliest finish is well under the full performance', () { + final plan = hurryPlan(SozoSplash.minimum.inMilliseconds.toDouble())!; + final total = + SozoSplash.minimum.inMilliseconds + + plan.catchUp!.inMilliseconds + + 300; + expect(total, lessThan(splashTotalMs ~/ 2)); + }); + + test('offline only when there is no connection of any kind', () { + expect(isOffline([ConnectivityResult.none]), isTrue); + expect(isOffline([ConnectivityResult.wifi]), isFalse); + expect( + isOffline([ConnectivityResult.none, ConnectivityResult.mobile]), + isFalse, + ); + expect(isOffline(const []), isFalse); + }); +} From b670d960242f3b31f6ab3b29c3186a8758d1baf1 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:35:43 +0500 Subject: [PATCH 06/16] Add external Zangetsu repos, run on the device Repos are added by their index.json link (raw GitHub or gist only) and read through /extension-repos/zangetsu. Installed sources register as client providers whose extractor is the composed /extractors/zangetsu-ext bundle. Sozo's own providers win on any id clash, and sources the backend marks as built in are shown but never installed. --- assets/translations/ar.json | 22 + assets/translations/ckb.json | 22 + assets/translations/de.json | 22 + assets/translations/en.json | 22 + assets/translations/es.json | 22 + assets/translations/fr.json | 22 + assets/translations/hi.json | 22 + assets/translations/id.json | 22 + assets/translations/ku.json | 22 + assets/translations/nl.json | 22 + assets/translations/pt.json | 22 + assets/translations/ru.json | 22 + assets/translations/tr.json | 22 + assets/translations/uz.json | 22 + assets/translations/vi.json | 22 + assets/translations/yue.json | 22 + lib/core/di/injection.dart | 12 +- lib/core/js/extractor_cache.dart | 6 + lib/core/js/extractor_remote.dart | 9 +- lib/core/js/js_runtime_service.dart | 18 +- lib/core/js/provider_registry.dart | 7 +- .../data/extension_repo_defaults.dart | 11 + .../extensions/data/source_installer.dart | 2 + .../extensions/data/zangetsu_repo_store.dart | 321 +++++++++ .../entities/extension_repo_entity.dart | 4 +- .../domain/entities/zangetsu_source.dart | 100 +++ .../pages/source_catalog_page.dart | 2 + .../pages/zangetsu_sources_page.dart | 644 ++++++++++++++++++ .../presentation/bloc/provider_bloc.dart | 29 +- .../presentation/pages/sources_page.dart | 11 + .../extensions/zangetsu_repo_store_test.dart | 359 ++++++++++ 31 files changed, 1876 insertions(+), 11 deletions(-) create mode 100644 lib/features/extensions/data/zangetsu_repo_store.dart create mode 100644 lib/features/extensions/domain/entities/zangetsu_source.dart create mode 100644 lib/features/extensions/presentation/pages/zangetsu_sources_page.dart create mode 100644 test/features/extensions/zangetsu_repo_store_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 6e6bbb79..d3cc5ed8 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -3031,5 +3031,27 @@ "error_attachment_size": "يجب ألا يتجاوز حجم كل صورة 5 ميغابايت.", "error_attachment_count": "5 لقطات شاشة كحد أقصى في الرسالة.", "suggest": "تتكرر المشكلة؟ راسل الدعم" + }, + "zangetsu": { + "title": "مصادر Zangetsu", + "row_subtitle": "مصادر JavaScript من مستودعات خارجية، تعمل على جهازك", + "desc": "أضف مستودع Zangetsu لتثبيت مصادر إضافية للأنمي والأفلام. تعمل على جهازك. المصادر الموجودة في Sozo بالفعل تظهر بعلامة «مدمج» ويُستخدم إصدار Sozo الخاص بها.", + "loading_repo": "جارٍ تحميل المستودع…", + "repo_added": "تمت إضافة المستودع · {} مصدر.", + "checking": "جارٍ التحقق من تحديثات المستودعات…", + "updates_available": "{} تحديث متاح.", + "installed_one": "تم تثبيت {}.", + "removed_one": "تمت إزالة {}.", + "updated": "تم تحديث {} مصدر.", + "update_all": "تحديث الكل ({})", + "update_to": "التحديث إلى v{}", + "built_in": "مدمج", + "search": "ابحث في المصادر…", + "all_types": "الكل", + "type_movies": "أفلام", + "all_languages": "كل اللغات", + "error_invalid_url": "يمكن إضافة روابط https على raw.githubusercontent.com أو gist.githubusercontent.com فقط.", + "error_unreachable": "تعذّر الوصول إلى هذا المستودع. تحقّق من الرابط ومن اتصالك.", + "error_invalid_index": "هذا الرابط لا يشير إلى ملف index.json صالح لـ Zangetsu." } } diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 6cef5071..95ae0199 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -3027,5 +3027,27 @@ "error_attachment_size": "هەر وێنەیەک دەبێت 5 MB یان کەمتر بێت.", "error_attachment_count": "لە هەر نامەیەکدا زۆرترین 5 وێنەی شاشە.", "suggest": "دووبارە دەبێتەوە؟ بۆ پشتگیری بنووسە" + }, + "zangetsu": { + "title": "سەرچاوەکانی Zangetsu", + "row_subtitle": "سەرچاوەی JavaScript لە ڕیپۆی دەرەکییەوە، لەسەر ئامێرەکەت کار دەکەن", + "desc": "ڕیپۆیەکی Zangetsu زیاد بکە بۆ دامەزراندنی سەرچاوەی زیاتری ئەنیمێ و فیلم. لەسەر ئامێرەکەت کار دەکەن. ئەو سەرچاوانەی Sozo پێشتر هەیەتی بە «ناوخۆیی» نیشان دەکرێن و وەشانی خودی Sozo بەکاردێت.", + "loading_repo": "ڕیپۆ بار دەکرێت…", + "repo_added": "ڕیپۆ زیاد کرا · {} سەرچاوە.", + "checking": "پشکنینی نوێکردنەوەی ڕیپۆکان…", + "updates_available": "{} نوێکردنەوە بەردەستە.", + "installed_one": "{} دامەزرا.", + "removed_one": "{} لابرا.", + "updated": "{} سەرچاوە نوێکرانەوە.", + "update_all": "هەمووی نوێ بکەرەوە ({})", + "update_to": "نوێکردنەوە بۆ v{}", + "built_in": "ناوخۆیی", + "search": "گەڕان لە سەرچاوەکان…", + "all_types": "هەموو", + "type_movies": "فیلمەکان", + "all_languages": "هەموو زمانەکان", + "error_invalid_url": "تەنها بەستەری https لەسەر raw.githubusercontent.com یان gist.githubusercontent.com زیاد دەکرێت.", + "error_unreachable": "نەتوانرا بگەیتە ئەم ڕیپۆیە. بەستەرەکە و پەیوەندییەکەت بپشکنە.", + "error_invalid_index": "ئەم بەستەرە ئاماژە بە index.json ـێکی دروستی Zangetsu ناکات." } } diff --git a/assets/translations/de.json b/assets/translations/de.json index 0970f03c..9ea216d5 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -3027,5 +3027,27 @@ "error_attachment_size": "Jedes Bild darf höchstens 5 MB groß sein.", "error_attachment_count": "Höchstens 5 Screenshots pro Nachricht.", "suggest": "Passiert das öfter? Schreib dem Support" + }, + "zangetsu": { + "title": "Zangetsu-Quellen", + "row_subtitle": "JavaScript-Quellen aus externen Repos, laufen auf deinem Gerät", + "desc": "Füge ein Zangetsu-Repo hinzu, um zusätzliche Anime- und Filmquellen zu installieren. Sie laufen auf deinem Gerät. Quellen, die Sozo bereits mitbringt, sind als „Integriert“ markiert und nutzen weiter Sozos eigene Version.", + "loading_repo": "Repo wird geladen…", + "repo_added": "Repo hinzugefügt · {} Quellen.", + "checking": "Repos werden auf Updates geprüft…", + "updates_available": "{} Update(s) verfügbar.", + "installed_one": "{} installiert.", + "removed_one": "{} entfernt.", + "updated": "{} Quelle(n) aktualisiert.", + "update_all": "Alle aktualisieren ({})", + "update_to": "Auf v{} aktualisieren", + "built_in": "Integriert", + "search": "Quellen durchsuchen…", + "all_types": "Alle", + "type_movies": "Filme", + "all_languages": "Alle Sprachen", + "error_invalid_url": "Nur https-Links auf raw.githubusercontent.com oder gist.githubusercontent.com können hinzugefügt werden.", + "error_unreachable": "Dieses Repo ist nicht erreichbar. Prüfe den Link und deine Verbindung.", + "error_invalid_index": "Dieser Link verweist nicht auf eine gültige Zangetsu-index.json." } } diff --git a/assets/translations/en.json b/assets/translations/en.json index 8fbca5aa..13066023 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -3027,5 +3027,27 @@ "error_attachment_size": "Each image must be 5 MB or less.", "error_attachment_count": "At most 5 screenshots per message.", "suggest": "Keeps happening? Write to support" + }, + "zangetsu": { + "title": "Zangetsu Sources", + "row_subtitle": "JavaScript sources from external repos, run on your device", + "desc": "Add a Zangetsu repo to install extra anime and movie sources. They run on your device. Sources Sozo already ships are marked Built in and keep using Sozo's own version.", + "loading_repo": "Loading repo…", + "repo_added": "Repo added · {} sources.", + "checking": "Checking repos for updates…", + "updates_available": "{} update(s) available.", + "installed_one": "Installed {}.", + "removed_one": "Removed {}.", + "updated": "Updated {} source(s).", + "update_all": "Update all ({})", + "update_to": "Update to v{}", + "built_in": "Built in", + "search": "Search sources…", + "all_types": "All", + "type_movies": "Movies", + "all_languages": "All languages", + "error_invalid_url": "Only https links on raw.githubusercontent.com or gist.githubusercontent.com can be added.", + "error_unreachable": "Could not reach this repo. Check the link and your connection.", + "error_invalid_index": "This link does not point to a valid Zangetsu index.json." } } diff --git a/assets/translations/es.json b/assets/translations/es.json index 319ddb48..44f63fe5 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -3027,5 +3027,27 @@ "error_attachment_size": "Cada imagen debe pesar 5 MB o menos.", "error_attachment_count": "Como máximo 5 capturas por mensaje.", "suggest": "¿Sigue pasando? Escribe a soporte" + }, + "zangetsu": { + "title": "Fuentes de Zangetsu", + "row_subtitle": "Fuentes JavaScript de repos externos, se ejecutan en tu dispositivo", + "desc": "Añade un repo de Zangetsu para instalar más fuentes de anime y películas. Se ejecutan en tu dispositivo. Las fuentes que Sozo ya incluye aparecen como «Integrada» y siguen usando la versión propia de Sozo.", + "loading_repo": "Cargando repo…", + "repo_added": "Repo añadido · {} fuentes.", + "checking": "Buscando actualizaciones de los repos…", + "updates_available": "{} actualización(es) disponible(s).", + "installed_one": "{} instalada.", + "removed_one": "{} eliminada.", + "updated": "{} fuente(s) actualizada(s).", + "update_all": "Actualizar todo ({})", + "update_to": "Actualizar a v{}", + "built_in": "Integrada", + "search": "Buscar fuentes…", + "all_types": "Todo", + "type_movies": "Películas", + "all_languages": "Todos los idiomas", + "error_invalid_url": "Solo se pueden añadir enlaces https de raw.githubusercontent.com o gist.githubusercontent.com.", + "error_unreachable": "No se pudo acceder a este repo. Revisa el enlace y tu conexión.", + "error_invalid_index": "Este enlace no apunta a un index.json de Zangetsu válido." } } diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 599703a9..6df44e7c 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -3027,5 +3027,27 @@ "error_attachment_size": "Chaque image doit faire 5 Mo maximum.", "error_attachment_count": "5 captures maximum par message.", "suggest": "Ça se reproduit ? Écris au support" + }, + "zangetsu": { + "title": "Sources Zangetsu", + "row_subtitle": "Sources JavaScript de dépôts externes, exécutées sur votre appareil", + "desc": "Ajoutez un dépôt Zangetsu pour installer d'autres sources d'anime et de films. Elles s'exécutent sur votre appareil. Les sources déjà fournies par Sozo sont marquées « Intégrée » et continuent d'utiliser la version de Sozo.", + "loading_repo": "Chargement du dépôt…", + "repo_added": "Dépôt ajouté · {} sources.", + "checking": "Recherche de mises à jour des dépôts…", + "updates_available": "{} mise(s) à jour disponible(s).", + "installed_one": "{} installée.", + "removed_one": "{} supprimée.", + "updated": "{} source(s) mise(s) à jour.", + "update_all": "Tout mettre à jour ({})", + "update_to": "Mettre à jour vers v{}", + "built_in": "Intégrée", + "search": "Rechercher des sources…", + "all_types": "Tout", + "type_movies": "Films", + "all_languages": "Toutes les langues", + "error_invalid_url": "Seuls les liens https vers raw.githubusercontent.com ou gist.githubusercontent.com peuvent être ajoutés.", + "error_unreachable": "Impossible de joindre ce dépôt. Vérifiez le lien et votre connexion.", + "error_invalid_index": "Ce lien ne mène pas à un index.json Zangetsu valide." } } diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 12d0cb70..4c597244 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -3027,5 +3027,27 @@ "error_attachment_size": "हर इमेज 5 MB या उससे छोटी होनी चाहिए।", "error_attachment_count": "एक मैसेज में ज़्यादा से ज़्यादा 5 स्क्रीनशॉट।", "suggest": "बार-बार हो रहा है? सपोर्ट को लिखें" + }, + "zangetsu": { + "title": "Zangetsu स्रोत", + "row_subtitle": "बाहरी रिपो से JavaScript स्रोत, आपके डिवाइस पर चलते हैं", + "desc": "अतिरिक्त ऐनिमे और फ़िल्म स्रोत इंस्टॉल करने के लिए Zangetsu रिपो जोड़ें। ये आपके डिवाइस पर चलते हैं। जो स्रोत Sozo में पहले से हैं, उन्हें «बिल्ट-इन» दिखाया जाता है और उनका Sozo वाला संस्करण ही इस्तेमाल होता है।", + "loading_repo": "रिपो लोड हो रहा है…", + "repo_added": "रिपो जोड़ा गया · {} स्रोत।", + "checking": "रिपो के अपडेट जाँचे जा रहे हैं…", + "updates_available": "{} अपडेट उपलब्ध हैं।", + "installed_one": "{} इंस्टॉल हुआ।", + "removed_one": "{} हटाया गया।", + "updated": "{} स्रोत अपडेट हुए।", + "update_all": "सभी अपडेट करें ({})", + "update_to": "v{} पर अपडेट करें", + "built_in": "बिल्ट-इन", + "search": "स्रोत खोजें…", + "all_types": "सभी", + "type_movies": "फ़िल्में", + "all_languages": "सभी भाषाएँ", + "error_invalid_url": "केवल raw.githubusercontent.com या gist.githubusercontent.com के https लिंक जोड़े जा सकते हैं।", + "error_unreachable": "इस रिपो तक नहीं पहुँच सके। लिंक और अपना कनेक्शन जाँचें।", + "error_invalid_index": "यह लिंक किसी मान्य Zangetsu index.json की ओर नहीं ले जाता।" } } diff --git a/assets/translations/id.json b/assets/translations/id.json index 28442646..22820cda 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -3027,5 +3027,27 @@ "error_attachment_size": "Setiap gambar maksimal 5 MB.", "error_attachment_count": "Maksimal 5 tangkapan layar per pesan.", "suggest": "Terus terjadi? Hubungi dukungan" + }, + "zangetsu": { + "title": "Sumber Zangetsu", + "row_subtitle": "Sumber JavaScript dari repo eksternal, berjalan di perangkatmu", + "desc": "Tambahkan repo Zangetsu untuk memasang sumber anime dan film tambahan. Sumber ini berjalan di perangkatmu. Sumber yang sudah ada di Sozo ditandai «Bawaan» dan tetap memakai versi Sozo sendiri.", + "loading_repo": "Memuat repo…", + "repo_added": "Repo ditambahkan · {} sumber.", + "checking": "Memeriksa pembaruan repo…", + "updates_available": "{} pembaruan tersedia.", + "installed_one": "{} terpasang.", + "removed_one": "{} dihapus.", + "updated": "{} sumber diperbarui.", + "update_all": "Perbarui semua ({})", + "update_to": "Perbarui ke v{}", + "built_in": "Bawaan", + "search": "Cari sumber…", + "all_types": "Semua", + "type_movies": "Film", + "all_languages": "Semua bahasa", + "error_invalid_url": "Hanya tautan https di raw.githubusercontent.com atau gist.githubusercontent.com yang bisa ditambahkan.", + "error_unreachable": "Repo ini tidak dapat dijangkau. Periksa tautan dan koneksimu.", + "error_invalid_index": "Tautan ini tidak mengarah ke index.json Zangetsu yang valid." } } diff --git a/assets/translations/ku.json b/assets/translations/ku.json index e876ed5b..13799492 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -3027,5 +3027,27 @@ "error_attachment_size": "Divê her wêne herî zêde 5 MB be.", "error_attachment_count": "Di peyamekê de herî zêde 5 dîmenên ekranê.", "suggest": "Dubare dibe? Ji piştgiriyê re binivîse" + }, + "zangetsu": { + "title": "Çavkaniyên Zangetsu", + "row_subtitle": "Çavkaniyên JavaScript ji repoyên derveyî, li ser cîhaza te dixebitin", + "desc": "Repoyeke Zangetsu lê zêde bike da ku çavkaniyên anime û fîlman ên zêdetir saz bikî. Ew li ser cîhaza te dixebitin. Çavkaniyên ku Sozo jixwe heye wekî «Navxweyî» têne nîşankirin û guhertoya Sozo bi xwe tê bikaranîn.", + "loading_repo": "Repo tê barkirin…", + "repo_added": "Repo hat zêdekirin · {} çavkanî.", + "checking": "Nûvekirinên repoyan têne kontrolkirin…", + "updates_available": "{} nûvekirin hene.", + "installed_one": "{} hat sazkirin.", + "removed_one": "{} hat rakirin.", + "updated": "{} çavkanî hatin nûvekirin.", + "update_all": "Hemûyan nû bike ({})", + "update_to": "Nû bike bo v{}", + "built_in": "Navxweyî", + "search": "Li çavkaniyan bigere…", + "all_types": "Hemû", + "type_movies": "Fîlm", + "all_languages": "Hemû ziman", + "error_invalid_url": "Tenê girêdanên https li ser raw.githubusercontent.com an gist.githubusercontent.com dikarin werin zêdekirin.", + "error_unreachable": "Negihîşt vê repoyê. Girêdan û pêwendiya xwe kontrol bike.", + "error_invalid_index": "Ev girêdan ne index.json-eke derbasdar a Zangetsu ye." } } diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 8ca39779..6bdbabc9 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -3027,5 +3027,27 @@ "error_attachment_size": "Elke afbeelding mag maximaal 5 MB zijn.", "error_attachment_count": "Maximaal 5 screenshots per bericht.", "suggest": "Blijft het gebeuren? Schrijf support" + }, + "zangetsu": { + "title": "Zangetsu-bronnen", + "row_subtitle": "JavaScript-bronnen uit externe repo's, draaien op je apparaat", + "desc": "Voeg een Zangetsu-repo toe om extra anime- en filmbronnen te installeren. Ze draaien op je apparaat. Bronnen die Sozo al meelevert, zijn gemarkeerd als ‘Ingebouwd’ en blijven Sozo's eigen versie gebruiken.", + "loading_repo": "Repo laden…", + "repo_added": "Repo toegevoegd · {} bronnen.", + "checking": "Repo's controleren op updates…", + "updates_available": "{} update(s) beschikbaar.", + "installed_one": "{} geïnstalleerd.", + "removed_one": "{} verwijderd.", + "updated": "{} bron(nen) bijgewerkt.", + "update_all": "Alles bijwerken ({})", + "update_to": "Bijwerken naar v{}", + "built_in": "Ingebouwd", + "search": "Bronnen zoeken…", + "all_types": "Alles", + "type_movies": "Films", + "all_languages": "Alle talen", + "error_invalid_url": "Alleen https-links op raw.githubusercontent.com of gist.githubusercontent.com kunnen worden toegevoegd.", + "error_unreachable": "Deze repo is niet bereikbaar. Controleer de link en je verbinding.", + "error_invalid_index": "Deze link verwijst niet naar een geldige Zangetsu-index.json." } } diff --git a/assets/translations/pt.json b/assets/translations/pt.json index f79b5482..fd302a58 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -3027,5 +3027,27 @@ "error_attachment_size": "Cada imagem pode ter no máximo 5 MB.", "error_attachment_count": "No máximo 5 capturas por mensagem.", "suggest": "Continua acontecendo? Fale com o suporte" + }, + "zangetsu": { + "title": "Fontes Zangetsu", + "row_subtitle": "Fontes JavaScript de repositórios externos, executadas no seu dispositivo", + "desc": "Adicione um repositório Zangetsu para instalar mais fontes de anime e filmes. Elas rodam no seu dispositivo. As fontes que o Sozo já inclui aparecem como «Integrada» e continuam usando a versão do próprio Sozo.", + "loading_repo": "Carregando repositório…", + "repo_added": "Repositório adicionado · {} fontes.", + "checking": "Verificando atualizações dos repositórios…", + "updates_available": "{} atualização(ões) disponível(is).", + "installed_one": "{} instalada.", + "removed_one": "{} removida.", + "updated": "{} fonte(s) atualizada(s).", + "update_all": "Atualizar tudo ({})", + "update_to": "Atualizar para v{}", + "built_in": "Integrada", + "search": "Pesquisar fontes…", + "all_types": "Tudo", + "type_movies": "Filmes", + "all_languages": "Todos os idiomas", + "error_invalid_url": "Só é possível adicionar links https de raw.githubusercontent.com ou gist.githubusercontent.com.", + "error_unreachable": "Não foi possível acessar este repositório. Verifique o link e sua conexão.", + "error_invalid_index": "Este link não aponta para um index.json válido do Zangetsu." } } diff --git a/assets/translations/ru.json b/assets/translations/ru.json index c85aef09..90671023 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -3028,5 +3028,27 @@ "error_attachment_size": "Каждое изображение — не больше 5 МБ.", "error_attachment_count": "Не больше 5 скриншотов в сообщении.", "suggest": "Повторяется? Напишите в поддержку" + }, + "zangetsu": { + "title": "Источники Zangetsu", + "row_subtitle": "JavaScript-источники из внешних репозиториев, работают на устройстве", + "desc": "Добавьте репозиторий Zangetsu, чтобы установить дополнительные источники аниме и фильмов. Они работают на вашем устройстве. Источники, которые уже есть в Sozo, помечены как «Встроенный» и используют версию Sozo.", + "loading_repo": "Загрузка репозитория…", + "repo_added": "Репозиторий добавлен · источников: {}.", + "checking": "Проверка обновлений репозиториев…", + "updates_available": "Доступно обновлений: {}.", + "installed_one": "{} установлен.", + "removed_one": "{} удалён.", + "updated": "Обновлено источников: {}.", + "update_all": "Обновить все ({})", + "update_to": "Обновить до v{}", + "built_in": "Встроенный", + "search": "Поиск источников…", + "all_types": "Все", + "type_movies": "Фильмы", + "all_languages": "Все языки", + "error_invalid_url": "Можно добавлять только https-ссылки на raw.githubusercontent.com или gist.githubusercontent.com.", + "error_unreachable": "Не удалось подключиться к репозиторию. Проверьте ссылку и подключение к интернету.", + "error_invalid_index": "Эта ссылка не ведёт на корректный index.json Zangetsu." } } diff --git a/assets/translations/tr.json b/assets/translations/tr.json index f22f299e..60d92d60 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -3027,5 +3027,27 @@ "error_attachment_size": "Her görsel en fazla 5 MB olmalı.", "error_attachment_count": "Mesaj başına en fazla 5 ekran görüntüsü.", "suggest": "Tekrarlıyor mu? Desteğe yaz" + }, + "zangetsu": { + "title": "Zangetsu Kaynakları", + "row_subtitle": "Harici depolardan JavaScript kaynakları, cihazında çalışır", + "desc": "Ek anime ve film kaynakları yüklemek için bir Zangetsu deposu ekle. Bunlar cihazında çalışır. Sozo'da zaten olan kaynaklar «Yerleşik» olarak işaretlenir ve Sozo'nun kendi sürümü kullanılmaya devam eder.", + "loading_repo": "Depo yükleniyor…", + "repo_added": "Depo eklendi · {} kaynak.", + "checking": "Depolarda güncelleme aranıyor…", + "updates_available": "{} güncelleme mevcut.", + "installed_one": "{} yüklendi.", + "removed_one": "{} kaldırıldı.", + "updated": "{} kaynak güncellendi.", + "update_all": "Tümünü güncelle ({})", + "update_to": "v{} sürümüne güncelle", + "built_in": "Yerleşik", + "search": "Kaynak ara…", + "all_types": "Tümü", + "type_movies": "Filmler", + "all_languages": "Tüm diller", + "error_invalid_url": "Yalnızca raw.githubusercontent.com veya gist.githubusercontent.com üzerindeki https bağlantıları eklenebilir.", + "error_unreachable": "Bu depoya ulaşılamadı. Bağlantıyı ve internet bağlantını kontrol et.", + "error_invalid_index": "Bu bağlantı geçerli bir Zangetsu index.json dosyasına gitmiyor." } } diff --git a/assets/translations/uz.json b/assets/translations/uz.json index a0ec0494..e390f903 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -3027,5 +3027,27 @@ "error_attachment_size": "Har bir rasm 5 MB dan oshmasin.", "error_attachment_count": "Bitta xabarga ko‘pi bilan 5 ta skrinshot.", "suggest": "Takrorlanyaptimi? Support’ga yozing" + }, + "zangetsu": { + "title": "Zangetsu manbalari", + "row_subtitle": "Tashqi repolardagi JavaScript manbalar, qurilmangizda ishlaydi", + "desc": "Qo'shimcha anime va film manbalarini o'rnatish uchun Zangetsu reposini qo'shing. Ular qurilmangizda ishlaydi. Sozo'da allaqachon bor manbalar \"Ichki\" deb belgilanadi va Sozo'ning o'z versiyasi ishlatiladi.", + "loading_repo": "Repo yuklanmoqda…", + "repo_added": "Repo qo'shildi · {} ta manba.", + "checking": "Repolar yangilanishga tekshirilmoqda…", + "updates_available": "{} ta yangilanish mavjud.", + "installed_one": "{} o'rnatildi.", + "removed_one": "{} o'chirildi.", + "updated": "{} ta manba yangilandi.", + "update_all": "Hammasini yangilash ({})", + "update_to": "v{} ga yangilash", + "built_in": "Ichki", + "search": "Manbalarni qidirish…", + "all_types": "Hammasi", + "type_movies": "Filmlar", + "all_languages": "Barcha tillar", + "error_invalid_url": "Faqat raw.githubusercontent.com yoki gist.githubusercontent.com dagi https havolalarni qo'shish mumkin.", + "error_unreachable": "Repoga ulanib bo'lmadi. Havolani va internet aloqasini tekshiring.", + "error_invalid_index": "Bu havola to'g'ri Zangetsu index.json fayliga olib bormaydi." } } diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 5a8f5b47..5cc8a0f8 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -3027,5 +3027,27 @@ "error_attachment_size": "Mỗi ảnh tối đa 5 MB.", "error_attachment_count": "Tối đa 5 ảnh mỗi tin nhắn.", "suggest": "Vẫn tiếp diễn? Hãy viết cho bộ phận hỗ trợ" + }, + "zangetsu": { + "title": "Nguồn Zangetsu", + "row_subtitle": "Nguồn JavaScript từ kho bên ngoài, chạy trên thiết bị của bạn", + "desc": "Thêm một kho Zangetsu để cài thêm nguồn anime và phim. Chúng chạy trên thiết bị của bạn. Những nguồn Sozo đã có sẵn được đánh dấu «Tích hợp» và vẫn dùng phiên bản của chính Sozo.", + "loading_repo": "Đang tải kho…", + "repo_added": "Đã thêm kho · {} nguồn.", + "checking": "Đang kiểm tra cập nhật cho các kho…", + "updates_available": "Có {} bản cập nhật.", + "installed_one": "Đã cài {}.", + "removed_one": "Đã gỡ {}.", + "updated": "Đã cập nhật {} nguồn.", + "update_all": "Cập nhật tất cả ({})", + "update_to": "Cập nhật lên v{}", + "built_in": "Tích hợp", + "search": "Tìm nguồn…", + "all_types": "Tất cả", + "type_movies": "Phim", + "all_languages": "Mọi ngôn ngữ", + "error_invalid_url": "Chỉ có thể thêm liên kết https trên raw.githubusercontent.com hoặc gist.githubusercontent.com.", + "error_unreachable": "Không truy cập được kho này. Hãy kiểm tra liên kết và kết nối mạng.", + "error_invalid_index": "Liên kết này không trỏ tới một index.json Zangetsu hợp lệ." } } diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 91e3e54a..ba85fdf6 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -3027,5 +3027,27 @@ "error_attachment_size": "每張圖唔可以大過 5 MB。", "error_attachment_count": "每條訊息最多 5 張截圖。", "suggest": "成日都係咁?搵支援" + }, + "zangetsu": { + "title": "Zangetsu 來源", + "row_subtitle": "嚟自外部 repo 嘅 JavaScript 來源,喺你部機度運行", + "desc": "加一個 Zangetsu repo 嚟安裝更多動畫同電影來源。佢哋喺你部機度運行。Sozo 本身已經有嘅來源會標示為「內置」,繼續用 Sozo 自己嘅版本。", + "loading_repo": "載入緊 repo…", + "repo_added": "已加入 repo · {} 個來源。", + "checking": "檢查緊 repo 有冇更新…", + "updates_available": "有 {} 個更新。", + "installed_one": "已安裝 {}。", + "removed_one": "已移除 {}。", + "updated": "已更新 {} 個來源。", + "update_all": "全部更新({})", + "update_to": "更新到 v{}", + "built_in": "內置", + "search": "搜尋來源…", + "all_types": "全部", + "type_movies": "電影", + "all_languages": "所有語言", + "error_invalid_url": "只可以加 raw.githubusercontent.com 或者 gist.githubusercontent.com 上面嘅 https 連結。", + "error_unreachable": "連唔到呢個 repo。請檢查連結同網絡連線。", + "error_invalid_index": "呢個連結唔係指向有效嘅 Zangetsu index.json。" } } diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index f2f65fee..4ace3ca3 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -135,6 +135,7 @@ import 'package:soplay/features/jellyfin/data/jellyfin_bridge.dart'; import 'package:soplay/features/jellyfin/data/jellyfin_reporter.dart'; import 'package:soplay/features/jellyfin/data/jellyfin_server_store.dart'; import 'package:soplay/features/extensions/data/mangayomi_repo_store.dart'; +import 'package:soplay/features/extensions/data/zangetsu_repo_store.dart'; import 'package:soplay/features/sources/data/source_browse_repository.dart'; import 'package:soplay/features/sources/data/source_check_store.dart'; import 'package:soplay/features/sources/domain/source_check_service.dart'; @@ -531,7 +532,13 @@ Future configureDependencies() async { ProviderDataSource(dio: getIt()), ); getIt.registerSingleton( - ProviderRegistry(source: getIt()), + ProviderRegistry( + source: getIt(), + external: (id) => getIt().providerById( + id, + allowAdult: getIt().showAdultContent, + ), + ), ); getIt.registerSingleton(ExtractorRemote(dio: getIt())); getIt.registerSingleton(ExtractorCache()); @@ -599,6 +606,9 @@ Future configureDependencies() async { getIt.registerLazySingleton( () => MangayomiRepoStore(dio: getIt()), ); + getIt.registerLazySingleton( + () => ZangetsuRepoStore(dio: getIt(), cache: getIt()), + ); getIt.registerLazySingleton( () => MangayomiRuntime( store: getIt(), diff --git a/lib/core/js/extractor_cache.dart b/lib/core/js/extractor_cache.dart index 88bd3a5f..b884fe5b 100644 --- a/lib/core/js/extractor_cache.dart +++ b/lib/core/js/extractor_cache.dart @@ -29,4 +29,10 @@ class ExtractorCache { await _box.put(_codeKey(name, version), code); await _box.put(_versionKey(name), version); } + + Future clear(String name) async { + final version = readVersion(name); + if (version != null) await _box.delete(_codeKey(name, version)); + await _box.delete(_versionKey(name)); + } } diff --git a/lib/core/js/extractor_remote.dart b/lib/core/js/extractor_remote.dart index f835eafc..3daa6d27 100644 --- a/lib/core/js/extractor_remote.dart +++ b/lib/core/js/extractor_remote.dart @@ -60,9 +60,14 @@ class ExtractorRemote { return (code: response.data ?? '', version: _versionHeader(response)); } - Future<({String code, int version})> fetchExtractor(String name) async { + /// [path] overrides `/extractors/` for an extractor the manifest does + /// not list, such as a source from an external Zangetsu repo. + Future<({String code, int version})> fetchExtractor( + String name, { + String? path, + }) async { final response = await dio.get( - '/extractors/$name', + path ?? '/extractors/$name', options: Options( responseType: ResponseType.plain, extra: const {'skipAuthInterceptor': true}, diff --git a/lib/core/js/js_runtime_service.dart b/lib/core/js/js_runtime_service.dart index 09c41bfd..e6de5904 100644 --- a/lib/core/js/js_runtime_service.dart +++ b/lib/core/js/js_runtime_service.dart @@ -172,6 +172,7 @@ class JsRuntimeService { await _ensureExtractor( extractor.name, extractor.version, + extractor.url, ).timeout(kJsCallTimeout); // Refusals and challenges are numbered on DartFetch; everything after // this mark happened while THIS call was running. Other calls may be in @@ -406,15 +407,19 @@ class JsRuntimeService { /// Evaluates [name] into the page if it is not already there. /// /// Returns as soon as the extractor is registered; repeat calls are free. - Future _ensureExtractor(String name, int wantedVersion) { + Future _ensureExtractor(String name, int wantedVersion, String url) { final pending = _registering[name]; if (pending != null) return pending; - final run = _registerExtractor(name, wantedVersion); + final run = _registerExtractor(name, wantedVersion, url); _registering[name] = run; return run.whenComplete(() => _registering.remove(name)); } - Future _registerExtractor(String name, int wantedVersion) async { + Future _registerExtractor( + String name, + int wantedVersion, + String url, + ) async { final manifest = _manifest ??= await remote.fetchManifest(); final entry = manifest.byName(name); final version = entry?.version ?? wantedVersion; @@ -426,7 +431,12 @@ class JsRuntimeService { code = cache.readCode(name, version); } if (code == null || code.isEmpty) { - final fetched = await remote.fetchExtractor(name); + // External Zangetsu sources are not in the manifest; their ref carries + // the composed bundle's path. + final fetched = await remote.fetchExtractor( + name, + path: entry == null && name.startsWith('zx-') ? url : null, + ); code = fetched.code; final effective = version > 0 ? version : fetched.version; await cache.writeCode( diff --git a/lib/core/js/provider_registry.dart b/lib/core/js/provider_registry.dart index a5e681e2..ce1dc6b4 100644 --- a/lib/core/js/provider_registry.dart +++ b/lib/core/js/provider_registry.dart @@ -6,7 +6,10 @@ class ProviderRegistry { List? _cache; Future>? _loading; - ProviderRegistry({required this.source}); + ProviderRegistry({required this.source, this.external}); + + /// Providers installed on the device that the backend list does not carry. + final ProviderModel? Function(String id)? external; Future getById(String id) async { if (id.isEmpty) return null; @@ -14,7 +17,7 @@ class ProviderRegistry { for (final p in list) { if (p.id == id) return p; } - return null; + return external?.call(id); } Future preload() async { diff --git a/lib/features/extensions/data/extension_repo_defaults.dart b/lib/features/extensions/data/extension_repo_defaults.dart index d14ca1d0..2f64510c 100644 --- a/lib/features/extensions/data/extension_repo_defaults.dart +++ b/lib/features/extensions/data/extension_repo_defaults.dart @@ -26,6 +26,17 @@ class ExtensionRepoDefaults { const ExtensionRepoDefaults._(); static const List all = [ + // --- Zangetsu --------------------------------------------------------- + ExtensionRepoEntity( + kind: ExtensionRepoKind.zangetsu, + name: 'Zangetsu Sources', + description: 'Anime, movies and live TV · 90 sources', + url: + 'https://raw.githubusercontent.com/Spyhell/zangetsu-experiment-repo/main/index.json', + badge: '90', + order: 0, + ), + // --- CloudStream ------------------------------------------------------ // CloudStream's own, and the only entry marked `verified` in the project's // repo database (recloudstream/cs-repos). Small — five providers — but diff --git a/lib/features/extensions/data/source_installer.dart b/lib/features/extensions/data/source_installer.dart index 6af01232..38acc7e8 100644 --- a/lib/features/extensions/data/source_installer.dart +++ b/lib/features/extensions/data/source_installer.dart @@ -62,6 +62,8 @@ Future installCatalogSource( case ExtensionRepoKind.mangayomi: result = await store.addRepo(url); countKey = 'total'; + case ExtensionRepoKind.zangetsu: + throw UnsupportedError('Zangetsu sources install from their own page'); } if (result['error'] case final String error when error.isNotEmpty) { throw StateError(error); diff --git a/lib/features/extensions/data/zangetsu_repo_store.dart b/lib/features/extensions/data/zangetsu_repo_store.dart new file mode 100644 index 00000000..d7211359 --- /dev/null +++ b/lib/features/extensions/data/zangetsu_repo_store.dart @@ -0,0 +1,321 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/js/extractor_cache.dart'; +import 'package:soplay/features/extensions/domain/entities/zangetsu_source.dart'; +import 'package:soplay/features/profile/data/models/provider_model.dart'; +import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; +import 'package:soplay/features/sources/domain/source_scope.dart'; + +enum ZangetsuRepoError { invalidUrl, unreachable, invalidIndex } + +class ZangetsuRepoException implements Exception { + const ZangetsuRepoException(this.error, [this.detail]); + + final ZangetsuRepoError error; + final String? detail; + + @override + String toString() => detail == null ? error.name : '${error.name}: $detail'; +} + +/// External Zangetsu repos and the sources installed from them. +/// +/// The backend reads the repo's `index.json` and composes each source into a +/// device bundle (`/extractors/zangetsu-ext`); the device only keeps which +/// repos were added, their last manifest, and which sources are installed. +class ZangetsuRepoStore { + ZangetsuRepoStore({required this.dio, required this.cache}); + + final Dio dio; + final ExtractorCache cache; + + static const allowedHosts = { + 'raw.githubusercontent.com', + 'gist.githubusercontent.com', + }; + static const externalPrefix = 'zx-'; + + static const _reposKey = 'zangetsu_repos'; + static const _installedKey = 'zangetsu_installed'; + static const _manifestPrefix = 'zangetsu_manifest:'; + + Box get _box => Hive.box(AppConstants.settingsBox); + + static bool isAllowedUrl(String raw) { + final uri = Uri.tryParse(raw.trim()); + return uri != null && + uri.scheme == 'https' && + allowedHosts.contains(uri.host.toLowerCase()) && + uri.userInfo.isEmpty && + uri.pathSegments.where((s) => s.isNotEmpty).isNotEmpty; + } + + static bool isExternalId(String id) => id.startsWith(externalPrefix); + + /// A source's version string as the int extractor caches key on: + /// `1.0.3` → 10003. Never below 1, which the caches treat as "unknown". + static int versionCode(String version) { + final parts = RegExp(r'\d+') + .allMatches(version) + .take(3) + .map((m) => int.tryParse(m.group(0)!) ?? 0) + .toList(); + while (parts.length < 3) { + parts.add(0); + } + final code = + parts[0].clamp(0, 99999) * 10000 + + parts[1].clamp(0, 99) * 100 + + parts[2].clamp(0, 99); + return code < 1 ? 1 : code; + } + + // --- repos --------------------------------------------------------------- + + List repos() => _readList(_reposKey).whereType().toList(); + + /// The repo's sources as last fetched, adult ones included. + List manifest(String repoUrl) => + _decodeSources(_readList('$_manifestPrefix${repoUrl.trim()}')); + + /// Adds [url] (or refreshes it) and returns its sources. + Future> addRepo(String url) async { + final repo = url.trim(); + if (!isAllowedUrl(repo)) { + throw const ZangetsuRepoException(ZangetsuRepoError.invalidUrl); + } + final sources = await _fetch(repo); + await _box.put( + '$_manifestPrefix$repo', + jsonEncode(sources.map((s) => s.toJson()).toList()), + ); + final list = repos(); + if (!list.contains(repo)) { + await _box.put(_reposKey, jsonEncode([...list, repo])); + } + // A source Sozo starts shipping stops registering from the repo. + final byId = {for (final s in sources) s.providerId: s}; + var changed = false; + final installed = this.installed().map((s) { + final fresh = s.repoUrl == repo ? byId[s.providerId] : null; + if (fresh == null || fresh.builtIn == s.builtIn) return s; + changed = true; + return s.copyWith(builtIn: fresh.builtIn); + }).toList(); + if (changed) await _saveInstalled(installed); + return sources; + } + + Future removeRepo(String url) async { + final repo = url.trim(); + final all = installed(); + for (final s in all.where((s) => s.repoUrl == repo)) { + await _clearCode(s.providerId); + } + await _saveInstalled(all.where((s) => s.repoUrl != repo).toList()); + await _box.delete('$_manifestPrefix$repo'); + await _box.put( + _reposKey, + jsonEncode(repos().where((r) => r != repo).toList()), + ); + } + + /// Re-reads every repo. One that fails keeps its last manifest. + Future refreshAll() async { + for (final repo in repos()) { + try { + await addRepo(repo); + } catch (_) {} + } + } + + Future> _fetch(String repo) async { + final Response resp; + try { + resp = await dio.get( + '/extension-repos/zangetsu', + queryParameters: {'url': repo}, + ); + } on DioException catch (e) { + final data = e.response?.data; + final code = data is Map ? data['code'] : null; + final message = data is Map ? data['message']?.toString() : null; + throw ZangetsuRepoException(switch (code) { + 'REPO_URL_INVALID' => ZangetsuRepoError.invalidUrl, + 'REPO_INDEX_INVALID' => ZangetsuRepoError.invalidIndex, + _ => ZangetsuRepoError.unreachable, + }, message ?? e.message); + } + final data = resp.data is String ? _tryDecode(resp.data) : resp.data; + final raw = data is Map ? data['sources'] : null; + if (raw is! List) { + throw const ZangetsuRepoException(ZangetsuRepoError.invalidIndex); + } + return [ + for (final e in raw.whereType()) + ?ZangetsuSource.fromJson(Map.from(e), repoUrl: repo), + ]; + } + + // --- installed sources --------------------------------------------------- + + List installed() => _decodeSources(_readList(_installedKey)); + + ZangetsuSource? installedById(String providerId) { + for (final s in installed()) { + if (s.providerId == providerId) return s; + } + return null; + } + + Future install(ZangetsuSource source) async { + if (source.builtIn) return; + final current = installedById(source.providerId); + final kept = installed() + .where((s) => s.providerId != source.providerId) + .toList(); + await _saveInstalled([ + ...kept, + source.copyWith(enabled: current?.enabled ?? true), + ]); + } + + Future remove(String providerId) async { + await _clearCode(providerId); + await _saveInstalled( + installed().where((s) => s.providerId != providerId).toList(), + ); + } + + Future setEnabled(String providerId, bool enabled) async { + await _saveInstalled([ + for (final s in installed()) + s.providerId == providerId ? s.copyWith(enabled: enabled) : s, + ]); + } + + /// The manifest entry that would replace [source], when its version differs. + ZangetsuSource? updateFor(ZangetsuSource source) { + for (final m in manifest(source.repoUrl)) { + if (m.providerId != source.providerId) continue; + return m.version != source.version || m.file != source.file ? m : null; + } + return null; + } + + List updates() => [ + for (final s in installed()) + if (updateFor(s) != null) s, + ]; + + /// Moves [providerId] to its repo's current version. + Future update(String providerId) async { + final current = installedById(providerId); + final next = current == null ? null : updateFor(current); + if (current == null || next == null) return false; + await _clearCode(providerId); + await install(next.copyWith(enabled: current.enabled)); + return true; + } + + Future updateAll() async { + var count = 0; + for (final s in updates()) { + if (await update(s.providerId)) count++; + } + return count; + } + + // --- providers ----------------------------------------------------------- + + /// Installed, enabled sources as providers run on the device. + List providers({required bool allowAdult}) => [ + for (final s in installed()) + if (s.enabled && !s.builtIn && (allowAdult || !s.nsfw)) toProvider(s), + ]; + + ProviderModel? providerById(String id, {required bool allowAdult}) { + if (!isExternalId(id)) return null; + for (final p in providers(allowAdult: allowAdult)) { + if (p.id == id) return p; + } + return null; + } + + static ProviderModel toProvider(ZangetsuSource s) { + final url = + '/extractors/zangetsu-ext?src=${Uri.encodeQueryComponent(s.file)}' + '&id=${Uri.encodeQueryComponent(s.providerId)}' + '${s.isLive ? '&live=1' : ''}'; + return ProviderModel( + id: s.providerId, + name: s.name, + image: s.icon ?? '', + url: '', + description: 'Zangetsu · ${repoLabel(s.repoUrl)}', + repo: s.repoUrl, + domains: const [], + mode: 'client', + category: s.isAnime ? 'anime' : 'movies', + lang: s.lang, + nsfw: s.nsfw, + extractor: ExtractorRef( + name: s.providerId, + version: versionCode(s.version), + scope: 'all', + url: url, + ), + ); + } + + /// [builtIn] plus the [external] ones whose id is not taken. Sozo's own + /// providers always win, under either the `zx-` id or the plain one. + static List merge( + List builtIn, + List external, + ) { + final taken = {for (final p in builtIn) p.id}; + return [ + ...builtIn, + for (final p in external) + if (!taken.contains(p.id) && + !(isExternalId(p.id) && + taken.contains(p.id.substring(externalPrefix.length)))) + p, + ]; + } + + // --- storage ------------------------------------------------------------- + + Future _clearCode(String providerId) => cache.clear(providerId); + + Future _saveInstalled(List list) => _box.put( + _installedKey, + jsonEncode(list.map((s) => s.toJson()).toList()), + ); + + List _readList(String key) { + final raw = _box.get(key); + if (raw is! String || raw.isEmpty) return const []; + final decoded = _tryDecode(raw); + return decoded is List ? decoded : const []; + } + + static Object? _tryDecode(Object? raw) { + if (raw is! String) return null; + try { + return jsonDecode(raw); + } catch (_) { + return null; + } + } + + static List _decodeSources(List raw) => [ + for (final e in raw.whereType()) + ?ZangetsuSource.fromJson(Map.from(e)), + ]; +} diff --git a/lib/features/extensions/domain/entities/extension_repo_entity.dart b/lib/features/extensions/domain/entities/extension_repo_entity.dart index 39e5756d..5cc14caf 100644 --- a/lib/features/extensions/domain/entities/extension_repo_entity.dart +++ b/lib/features/extensions/domain/entities/extension_repo_entity.dart @@ -4,13 +4,15 @@ enum ExtensionRepoKind { cloudstream, aniyomi, manga, - mangayomi; + mangayomi, + zangetsu; static ExtensionRepoKind? parse(String? raw) => switch (raw?.trim()) { 'cloudstream' => ExtensionRepoKind.cloudstream, 'aniyomi' => ExtensionRepoKind.aniyomi, 'manga' => ExtensionRepoKind.manga, 'mangayomi' => ExtensionRepoKind.mangayomi, + 'zangetsu' => ExtensionRepoKind.zangetsu, _ => null, }; diff --git a/lib/features/extensions/domain/entities/zangetsu_source.dart b/lib/features/extensions/domain/entities/zangetsu_source.dart new file mode 100644 index 00000000..8c5fe670 --- /dev/null +++ b/lib/features/extensions/domain/entities/zangetsu_source.dart @@ -0,0 +1,100 @@ +/// One source of an external Zangetsu repo, as `/extension-repos/zangetsu` +/// lists it, plus what the device keeps once it is installed. +class ZangetsuSource { + const ZangetsuSource({ + required this.id, + required this.providerId, + required this.name, + required this.repoUrl, + required this.file, + this.lang = 'en', + this.type = 'movie', + this.version = '0', + this.icon, + this.nsfw = false, + this.builtIn = false, + this.enabled = true, + }); + + final String id; + + /// `zx-`: namespaced so it can never take over a built-in provider. + final String providerId; + final String name; + final String repoUrl; + final String file; + final String lang; + final String type; + final String version; + final String? icon; + final bool nsfw; + + /// Sozo already ships this source; its own provider is the one used. + final bool builtIn; + final bool enabled; + + bool get isAnime => type == 'anime'; + + bool get isLive => type == 'live' || type == 'tv'; + + static ZangetsuSource? fromJson(Map json, {String? repoUrl}) { + final id = (json['id'] as String?)?.trim() ?? ''; + final file = (json['file'] as String?)?.trim() ?? ''; + final repo = repoUrl ?? (json['repoUrl'] as String?)?.trim() ?? ''; + if (id.isEmpty || file.isEmpty || repo.isEmpty) return null; + final providerId = (json['providerId'] as String?)?.trim(); + final icon = (json['icon'] as String?)?.trim(); + return ZangetsuSource( + id: id, + providerId: providerId == null || providerId.isEmpty + ? 'zx-$id' + : providerId, + name: ((json['name'] as String?)?.trim().isNotEmpty ?? false) + ? (json['name'] as String).trim() + : id, + repoUrl: repo, + file: file, + lang: ((json['lang'] as String?)?.trim().isNotEmpty ?? false) + ? (json['lang'] as String).trim().toLowerCase() + : 'en', + type: ((json['type'] as String?)?.trim().isNotEmpty ?? false) + ? (json['type'] as String).trim().toLowerCase() + : 'movie', + version: json['version']?.toString().trim() ?? '0', + icon: icon == null || icon.isEmpty ? null : icon, + nsfw: json['nsfw'] == true, + builtIn: json['builtIn'] == true, + enabled: json['enabled'] != false, + ); + } + + Map toJson() => { + 'id': id, + 'providerId': providerId, + 'name': name, + 'repoUrl': repoUrl, + 'file': file, + 'lang': lang, + 'type': type, + 'version': version, + if (icon != null) 'icon': icon, + 'nsfw': nsfw, + 'builtIn': builtIn, + 'enabled': enabled, + }; + + ZangetsuSource copyWith({bool? enabled, bool? builtIn}) => ZangetsuSource( + id: id, + providerId: providerId, + name: name, + repoUrl: repoUrl, + file: file, + lang: lang, + type: type, + version: version, + icon: icon, + nsfw: nsfw, + builtIn: builtIn ?? this.builtIn, + enabled: enabled ?? this.enabled, + ); +} diff --git a/lib/features/extensions/presentation/pages/source_catalog_page.dart b/lib/features/extensions/presentation/pages/source_catalog_page.dart index 4568f5d7..d7779c87 100644 --- a/lib/features/extensions/presentation/pages/source_catalog_page.dart +++ b/lib/features/extensions/presentation/pages/source_catalog_page.dart @@ -93,6 +93,7 @@ class _SourceCatalogPageState extends State { ExtensionRepoKind.manga => MangaChannel.isSupported, ExtensionRepoKind.mangayomi => source.jsRuntime && MangayomiRuntime.isSupported, + ExtensionRepoKind.zangetsu => false, }; ProviderEntity? _installed(CatalogSourceEntity source) { @@ -103,6 +104,7 @@ class _SourceCatalogPageState extends State { ExtensionRepoKind.aniyomi => 'an:', ExtensionRepoKind.manga => 'mn:', ExtensionRepoKind.mangayomi => 'my:', + ExtensionRepoKind.zangetsu => 'zx-', }; for (final provider in state.providers) { if (!provider.id.startsWith(prefix)) continue; diff --git a/lib/features/extensions/presentation/pages/zangetsu_sources_page.dart b/lib/features/extensions/presentation/pages/zangetsu_sources_page.dart new file mode 100644 index 00000000..1f029a04 --- /dev/null +++ b/lib/features/extensions/presentation/pages/zangetsu_sources_page.dart @@ -0,0 +1,644 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/extensions/data/zangetsu_repo_store.dart'; +import 'package:soplay/features/extensions/domain/entities/extension_repo_entity.dart'; +import 'package:soplay/features/extensions/domain/entities/zangetsu_source.dart'; +import 'package:soplay/features/extensions/presentation/widgets/recommended_repos_section.dart'; +import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; +import 'package:soplay/features/profile/presentation/bloc/provider_event.dart'; +import 'package:soplay/features/sources/domain/source_scope.dart'; + +/// Sources from external Zangetsu repos, run on the device. Sources Sozo +/// already ships are listed as built in and keep using Sozo's own copy. +class ZangetsuSourcesPage extends StatefulWidget { + const ZangetsuSourcesPage({super.key}); + + @override + State createState() => _ZangetsuSourcesPageState(); +} + +class _ZangetsuSourcesPageState extends State { + static const Color _accent = Color(0xFF7C5CFF); + + late final ZangetsuRepoStore _store = getIt(); + late final HiveService _hive = getIt(); + + final _urlController = TextEditingController(); + final _searchController = TextEditingController(); + bool _busy = false; + String? _status; + bool _statusError = false; + String _type = 'all'; + String _lang = 'all'; + + @override + void initState() { + super.initState(); + if (_store.repos().isNotEmpty) _refresh(silent: true); + } + + @override + void dispose() { + _urlController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + bool get _adult => _hive.showAdultContent; + + List get _allSources { + final out = []; + for (final repo in _store.repos()) { + out.addAll(_store.manifest(repo).where((s) => _adult || !s.nsfw)); + } + return out; + } + + List _visible(List all) { + final q = _searchController.text.trim().toLowerCase(); + final list = all.where((s) { + if (_type == 'anime' && !s.isAnime) return false; + if (_type == 'movies' && s.isAnime) return false; + if (_lang != 'all' && s.lang != _lang) return false; + return q.isEmpty || + s.name.toLowerCase().contains(q) || + s.id.contains(q); + }).toList(); + list.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + return list; + } + + void _reloadProviders() { + try { + context.read().add(const ProviderLoad()); + } catch (_) {} + } + + void _setStatus(String text, {bool error = false}) { + if (!mounted) return; + setState(() { + _status = text; + _statusError = error; + }); + } + + String _errorText(Object e) { + final kind = e is ZangetsuRepoException + ? e.error + : ZangetsuRepoError.unreachable; + return switch (kind) { + ZangetsuRepoError.invalidUrl => 'zangetsu.error_invalid_url'.tr(), + ZangetsuRepoError.unreachable => 'zangetsu.error_unreachable'.tr(), + ZangetsuRepoError.invalidIndex => 'zangetsu.error_invalid_index'.tr(), + }; + } + + Future _addRepo(String raw) async { + final url = raw.trim(); + if (url.isEmpty || _busy) return; + FocusScope.of(context).unfocus(); + if (!ZangetsuRepoStore.isAllowedUrl(url)) { + _setStatus('zangetsu.error_invalid_url'.tr(), error: true); + return; + } + setState(() { + _busy = true; + _status = 'zangetsu.loading_repo'.tr(); + _statusError = false; + }); + try { + final sources = await _store.addRepo(url); + if (!mounted) return; + _urlController.clear(); + final count = sources.where((s) => _adult || !s.nsfw).length; + _setStatus('zangetsu.repo_added'.tr(args: ['$count'])); + } catch (e) { + _setStatus(_errorText(e), error: true); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _removeRepo(String url) async { + await _store.removeRepo(url); + _reloadProviders(); + _setStatus('ext.repo_removed'.tr()); + } + + Future _refresh({bool silent = false}) async { + if (_busy) return; + setState(() { + _busy = true; + if (!silent) { + _status = 'zangetsu.checking'.tr(); + _statusError = false; + } + }); + await _store.refreshAll(); + if (!mounted) return; + setState(() => _busy = false); + if (silent) return; + final pending = _store.updates().length; + _setStatus( + pending == 0 + ? 'ext.all_up_to_date'.tr() + : 'zangetsu.updates_available'.tr(args: ['$pending']), + ); + } + + Future _install(ZangetsuSource s) async { + await _store.install(s); + _reloadProviders(); + _setStatus('zangetsu.installed_one'.tr(args: [s.name])); + } + + Future _remove(ZangetsuSource s) async { + await _store.remove(s.providerId); + _reloadProviders(); + _setStatus('zangetsu.removed_one'.tr(args: [s.name])); + } + + Future _toggle(ZangetsuSource s, bool enabled) async { + await _store.setEnabled(s.providerId, enabled); + _reloadProviders(); + if (mounted) setState(() {}); + } + + Future _update(ZangetsuSource s) async { + if (await _store.update(s.providerId)) { + _reloadProviders(); + _setStatus('zangetsu.updated'.tr(args: ['1'])); + } + } + + Future _updateAll() async { + if (_busy) return; + setState(() => _busy = true); + final count = await _store.updateAll(); + if (!mounted) return; + setState(() => _busy = false); + if (count > 0) _reloadProviders(); + _setStatus( + count == 0 + ? 'ext.all_up_to_date'.tr() + : 'zangetsu.updated'.tr(args: ['$count']), + ); + } + + @override + Widget build(BuildContext context) { + final repos = _store.repos(); + final all = _allSources; + final langs = {for (final s in all) s.lang}.toList()..sort(); + if (_lang != 'all' && !langs.contains(_lang)) _lang = 'all'; + final visible = _visible(all); + final installed = {for (final s in _store.installed()) s.providerId: s}; + final updates = _store.updates(); + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + elevation: 0, + title: Text('zangetsu.title'.tr()), + actions: [ + IconButton( + tooltip: 'ext.check_updates'.tr(), + icon: const Icon(Icons.refresh_rounded), + onPressed: (_busy || repos.isEmpty) ? null : _refresh, + ), + ], + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: [ + Text( + 'zangetsu.desc'.tr(), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 12.5, + height: 1.35, + ), + ), + const SizedBox(height: 16), + _addCard(), + if (_status != null) ...[ + const SizedBox(height: 12), + _statusBanner(), + ], + const SizedBox(height: 24), + RecommendedReposSection( + kind: ExtensionRepoKind.zangetsu, + installedUrls: repos.toSet(), + busy: _busy, + accent: _accent, + fallbackIcon: '', + nsfwAllowed: _adult, + onInstall: (repo) => _addRepo(repo.url), + ), + const SizedBox(height: 24), + _label('ext.installed_repos'.tr(), repos.length), + const SizedBox(height: 8), + if (repos.isEmpty) _empty() else ...repos.map(_repoTile), + if (all.isNotEmpty) ...[ + const SizedBox(height: 24), + Row( + children: [ + Expanded(child: _label('ext.sources_heading'.tr(), all.length)), + if (updates.isNotEmpty) + TextButton.icon( + onPressed: _busy ? null : _updateAll, + icon: const Icon(Icons.system_update_alt_rounded, size: 16), + label: Text( + 'zangetsu.update_all'.tr(args: ['${updates.length}']), + ), + ), + ], + ), + const SizedBox(height: 8), + _filters(langs), + const SizedBox(height: 8), + if (visible.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Center( + child: Text( + 'ext.no_matches'.tr(), + style: const TextStyle(color: AppColors.textHint), + ), + ), + ) + else + ...visible.map((s) => _sourceTile(s, installed[s.providerId])), + ], + ], + ), + ); + } + + Widget _label(String text, int count) => Row( + children: [ + Text( + text, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppColors.textHint, + letterSpacing: 1, + ), + ), + const Spacer(), + if (count > 0) + Text('$count', style: const TextStyle(color: AppColors.textHint)), + ], + ); + + Widget _addCard() => Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + ), + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _urlController, + enabled: !_busy, + cursorColor: _accent, + keyboardType: TextInputType.url, + style: const TextStyle(color: Colors.white), + decoration: _inputDecoration( + label: 'ext.index_url'.tr(), + hint: 'https://raw.githubusercontent.com/…/index.json', + ), + onSubmitted: _addRepo, + ), + const SizedBox(height: 12), + SizedBox( + height: 46, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: _accent, + foregroundColor: Colors.white, + ), + onPressed: _busy ? null : () => _addRepo(_urlController.text), + icon: _busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.add), + label: Text('ext.add_repo'.tr()), + ), + ), + ], + ), + ); + + InputDecoration _inputDecoration({ + String? label, + required String hint, + Widget? prefix, + }) => InputDecoration( + labelText: label, + labelStyle: const TextStyle(color: AppColors.textHint), + floatingLabelStyle: const TextStyle(color: _accent), + hintText: hint, + hintStyle: const TextStyle(color: AppColors.textHint), + prefixIcon: prefix, + isDense: true, + filled: true, + fillColor: AppColors.background, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: _accent.withValues(alpha: 0.6), width: 1.4), + ), + ); + + Widget _statusBanner() { + final color = _statusError ? Colors.redAccent : Colors.green; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.4)), + ), + child: Row( + children: [ + if (_busy) + const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + Icon( + _statusError ? Icons.error_outline : Icons.check_circle_outline, + size: 16, + color: color, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _status!, + style: TextStyle(color: color, fontSize: 12.5, height: 1.3), + ), + ), + ], + ), + ); + } + + Widget _empty() => Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 28), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + ), + child: Column( + children: [ + const Icon( + Icons.cloud_off_outlined, + color: AppColors.textHint, + size: 32, + ), + const SizedBox(height: 8), + Text( + 'ext.no_repos_yet'.tr(), + style: const TextStyle(color: AppColors.textHint), + ), + ], + ), + ); + + Widget _repoTile(String url) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Material( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: ListTile( + leading: const Icon(Icons.folder_outlined, color: _accent), + title: Text( + repoLabel(url), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + url, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textHint, fontSize: 11), + ), + trailing: IconButton( + tooltip: 'general.remove'.tr(), + icon: const Icon(Icons.delete_outline, color: Colors.redAccent), + onPressed: _busy ? null : () => _removeRepo(url), + ), + ), + ), + ); + + Widget _filters(List langs) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _searchController, + cursorColor: _accent, + style: const TextStyle(color: Colors.white), + onChanged: (_) => setState(() {}), + decoration: _inputDecoration( + hint: 'zangetsu.search'.tr(), + prefix: const Icon(Icons.search, color: AppColors.textHint), + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final (value, label) in [ + ('all', 'zangetsu.all_types'.tr()), + ('anime', 'ext.type_anime'.tr()), + ('movies', 'zangetsu.type_movies'.tr()), + ]) + ChoiceChip( + label: Text(label), + selected: _type == value, + selectedColor: _accent.withValues(alpha: 0.25), + onSelected: (_) => setState(() => _type = value), + ), + if (langs.length > 1) + for (final lang in ['all', ...langs]) + ChoiceChip( + label: Text( + lang == 'all' + ? 'zangetsu.all_languages'.tr() + : lang.toUpperCase(), + ), + selected: _lang == lang, + selectedColor: _accent.withValues(alpha: 0.25), + onSelected: (_) => setState(() => _lang = lang), + ), + ], + ), + ], + ); + + Widget _sourceTile(ZangetsuSource s, ZangetsuSource? installed) { + final update = installed == null ? null : _store.updateFor(installed); + final shown = installed ?? s; + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + _icon(s.icon), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + s.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + ), + ), + ), + if (s.nsfw) ...[ + const SizedBox(width: 6), + const _Badge(text: '18+', color: Colors.redAccent), + ], + ], + ), + Text( + '${s.lang.toUpperCase()} · ' + '${s.isAnime ? 'ext.type_anime'.tr() : 'zangetsu.type_movies'.tr()}' + ' · v${shown.version}', + style: const TextStyle( + color: AppColors.textHint, + fontSize: 10.5, + ), + ), + ], + ), + ), + ..._actions(s, installed, update), + ], + ), + ), + ); + } + + List _actions( + ZangetsuSource s, + ZangetsuSource? installed, + ZangetsuSource? update, + ) { + if (s.builtIn) { + return [_Badge(text: 'zangetsu.built_in'.tr(), color: Colors.green)]; + } + if (installed == null) { + return [ + TextButton( + onPressed: _busy ? null : () => _install(s), + child: Text('general.install'.tr()), + ), + ]; + } + return [ + if (update != null) + IconButton( + tooltip: 'zangetsu.update_to'.tr(args: [update.version]), + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.system_update_alt_rounded, color: _accent), + onPressed: _busy ? null : () => _update(installed), + ), + Switch( + value: installed.enabled, + activeThumbColor: _accent, + onChanged: _busy ? null : (v) => _toggle(installed, v), + ), + IconButton( + tooltip: 'general.remove'.tr(), + visualDensity: VisualDensity.compact, + icon: const Icon(Icons.delete_outline, color: Colors.redAccent), + onPressed: _busy ? null : () => _remove(installed), + ), + ]; + } + + Widget _icon(String? url) { + final fallback = Container( + width: 26, + height: 26, + color: Colors.white10, + child: const Icon(Icons.bolt_rounded, size: 14, color: Colors.white54), + ); + return ClipRRect( + borderRadius: BorderRadius.circular(7), + child: url == null || url.isEmpty + ? fallback + : Image.network( + url, + width: 26, + height: 26, + cacheWidth: 78, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => fallback, + ), + ); + } +} + +class _Badge extends StatelessWidget { + const _Badge({required this.text, required this.color}); + + final String text; + final Color color; + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(5), + ), + child: Text( + text, + style: TextStyle(color: color, fontSize: 10, fontWeight: FontWeight.w700), + ), + ); +} diff --git a/lib/features/profile/presentation/bloc/provider_bloc.dart b/lib/features/profile/presentation/bloc/provider_bloc.dart index 79993115..d10b94a5 100644 --- a/lib/features/profile/presentation/bloc/provider_bloc.dart +++ b/lib/features/profile/presentation/bloc/provider_bloc.dart @@ -7,8 +7,10 @@ import 'package:soplay/core/manga/manga_channel.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/features/extensions/data/mangayomi_bridge.dart'; +import 'package:soplay/features/extensions/data/zangetsu_repo_store.dart'; import 'package:soplay/features/jellyfin/data/jellyfin_bridge.dart'; import 'package:soplay/core/extractor/provider_manager.dart'; +import 'package:soplay/core/js/js_runtime_service.dart'; import 'package:soplay/core/js/provider_registry.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/features/profile/data/models/provider_model.dart'; @@ -90,7 +92,11 @@ class ProviderBloc extends Bloc { Success( ProvidersSnapshot( providers: previous.providers - .where((p) => !p.isServerIndependent) + .where( + (p) => + !p.isServerIndependent && + !ZangetsuRepoStore.isExternalId(p.id), + ) .toList(), fromCache: previous.offline, cachedAt: previous.cachedAt, @@ -134,6 +140,13 @@ class ProviderBloc extends Bloc { ..addAll(localManga) ..addAll(localMangayomi) ..addAll(localJellyfin); + final withZangetsu = ZangetsuRepoStore.merge( + providers, + _zangetsuProviders(), + ); + providers + ..clear() + ..addAll(withZangetsu); if (providers.isEmpty) { if (previous is! ProviderLoaded) { @@ -353,6 +366,20 @@ class ProviderBloc extends Bloc { } catch (_) {} } + /// Sources installed from external Zangetsu repos. They run in the same + /// WebView runtime as Sozo's own device extractors, whose bundles the backend + /// composes, so they are not server-independent. + List _zangetsuProviders() { + if (!JsRuntimeService.isSupported) return const []; + try { + return getIt().providers( + allowAdult: hiveService.showAdultContent, + ); + } catch (_) { + return const []; + } + } + /// Signed-in Jellyfin servers, one source each. Read from the local store, /// so they list with the backend down and on every platform. Future _appendJellyfinProviders(List into) async { diff --git a/lib/features/profile/presentation/pages/sources_page.dart b/lib/features/profile/presentation/pages/sources_page.dart index ce4b6a88..e1094ae4 100644 --- a/lib/features/profile/presentation/pages/sources_page.dart +++ b/lib/features/profile/presentation/pages/sources_page.dart @@ -14,6 +14,8 @@ import 'package:soplay/features/cloudstream/presentation/pages/cloudstream_sourc import 'package:soplay/features/extensions/data/mangayomi_runtime.dart'; import 'package:soplay/features/extensions/presentation/pages/mangayomi_sources_page.dart'; import 'package:soplay/features/extensions/presentation/pages/source_catalog_page.dart'; +import 'package:soplay/features/extensions/presentation/pages/zangetsu_sources_page.dart'; +import 'package:soplay/core/js/js_runtime_service.dart'; import 'package:soplay/features/jellyfin/data/jellyfin_bridge.dart'; import 'package:soplay/features/jellyfin/presentation/pages/jellyfin_servers_page.dart'; import 'package:soplay/features/manga/presentation/pages/manga_sources_page.dart'; @@ -89,6 +91,15 @@ class SourcesPage extends StatelessWidget { MaterialPageRoute(builder: (_) => const MangayomiSourcesPage()), ), ), + if (JsRuntimeService.isSupported) + SettingsNavTile( + icon: Icons.bolt_rounded, + title: 'zangetsu.title'.tr(), + subtitle: 'zangetsu.row_subtitle'.tr(), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ZangetsuSourcesPage()), + ), + ), // Talks to the server directly, so it works on every platform. SettingsNavTile( leading: const SettingsTileLogo( diff --git a/test/features/extensions/zangetsu_repo_store_test.dart b/test/features/extensions/zangetsu_repo_store_test.dart new file mode 100644 index 00000000..eb825385 --- /dev/null +++ b/test/features/extensions/zangetsu_repo_store_test.dart @@ -0,0 +1,359 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/js/extractor_cache.dart'; +import 'package:soplay/features/extensions/data/zangetsu_repo_store.dart'; +import 'package:soplay/features/extensions/domain/entities/zangetsu_source.dart'; +import 'package:soplay/features/profile/domain/entities/provider_entity.dart'; + +const repo = 'https://raw.githubusercontent.com/owner/repo/main/index.json'; + +Map entry( + String id, { + String version = '1.0.0', + String type = 'movie', + bool nsfw = false, + bool builtIn = false, +}) => { + 'id': id, + 'providerId': 'zx-$id', + 'name': id.toUpperCase(), + 'lang': 'en', + 'type': type, + 'version': version, + 'file': 'https://raw.githubusercontent.com/owner/repo/main/providers/$id.js', + 'icon': null, + 'nsfw': nsfw, + 'builtIn': builtIn, +}; + +ProviderEntity provider(String id) => ProviderEntity( + id: id, + name: id, + image: '', + url: '', + description: '', + domains: const [], +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + HttpOverrides.global = null; + late Directory dir; + late HttpServer server; + late ZangetsuRepoStore store; + late ExtractorCache cache; + var status = 200; + Object body = {}; + final asked = []; + + setUp(() async { + dir = await Directory.systemTemp.createTemp('sozo-zangetsu-'); + Hive.init(dir.path); + await Hive.openBox(AppConstants.settingsBox); + await Hive.openBox(AppConstants.extractorsBox); + server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + status = 200; + body = {}; + asked.clear(); + server.listen((request) async { + asked.add(request.uri.queryParameters['url'] ?? ''); + request.response + ..statusCode = request.uri.path == '/extension-repos/zangetsu' + ? status + : 404 + ..headers.contentType = ContentType.json + ..write(jsonEncode(body)); + await request.response.close(); + }); + cache = ExtractorCache(); + store = ZangetsuRepoStore( + dio: Dio(BaseOptions(baseUrl: 'http://127.0.0.1:${server.port}')), + cache: cache, + ); + }); + + tearDown(() async { + await server.close(force: true); + await Hive.close(); + await dir.delete(recursive: true); + }); + + void serve(List> sources) => + body = {'url': repo, 'sources': sources}; + + group('url validation', () { + test('only https raw GitHub and gist links pass', () { + expect(ZangetsuRepoStore.isAllowedUrl(repo), isTrue); + expect( + ZangetsuRepoStore.isAllowedUrl( + 'https://gist.githubusercontent.com/u/abc/raw/index.json', + ), + isTrue, + ); + expect( + ZangetsuRepoStore.isAllowedUrl( + 'http://raw.githubusercontent.com/a/b/index.json', + ), + isFalse, + ); + expect( + ZangetsuRepoStore.isAllowedUrl('https://evil.example/index.json'), + isFalse, + ); + expect( + ZangetsuRepoStore.isAllowedUrl( + 'https://user:pw@raw.githubusercontent.com/a/b/index.json', + ), + isFalse, + ); + expect(ZangetsuRepoStore.isAllowedUrl('not a url'), isFalse); + expect( + ZangetsuRepoStore.isAllowedUrl('https://raw.githubusercontent.com/'), + isFalse, + ); + }); + + test('a bad url is refused before any request', () async { + await expectLater( + store.addRepo('https://evil.example/index.json'), + throwsA( + isA().having( + (e) => e.error, + 'error', + ZangetsuRepoError.invalidUrl, + ), + ), + ); + expect(asked, isEmpty); + expect(store.repos(), isEmpty); + }); + }); + + test('version strings become stable ints', () { + expect(ZangetsuRepoStore.versionCode('1.0.3'), 10003); + expect(ZangetsuRepoStore.versionCode('2.10.0'), 21000); + expect(ZangetsuRepoStore.versionCode('1.2'), 10200); + expect(ZangetsuRepoStore.versionCode('v3'), 30000); + expect(ZangetsuRepoStore.versionCode('0'), 1); + expect(ZangetsuRepoStore.versionCode(''), 1); + expect( + ZangetsuRepoStore.versionCode('1.0.3'), + ZangetsuRepoStore.versionCode('1.0.3'), + ); + }); + + group('repos', () { + test('adding a repo stores it and its manifest', () async { + serve([entry('alpha'), entry('beta', type: 'anime')]); + final sources = await store.addRepo(repo); + expect(asked, [repo]); + expect(sources.map((s) => s.providerId), ['zx-alpha', 'zx-beta']); + expect(store.repos(), [repo]); + expect(store.manifest(repo).length, 2); + expect(store.manifest(repo).first.repoUrl, repo); + + await store.addRepo(repo); + expect(store.repos(), [repo]); + }); + + test('an unreachable repo and an invalid index are told apart', () async { + status = 502; + body = {'message': 'could not read', 'code': 'REPO_UNREACHABLE'}; + await expectLater( + store.addRepo(repo), + throwsA( + isA().having( + (e) => e.error, + 'error', + ZangetsuRepoError.unreachable, + ), + ), + ); + status = 422; + body = {'message': 'not JSON', 'code': 'REPO_INDEX_INVALID'}; + await expectLater( + store.addRepo(repo), + throwsA( + isA().having( + (e) => e.error, + 'error', + ZangetsuRepoError.invalidIndex, + ), + ), + ); + status = 200; + body = {'hello': 1}; + await expectLater( + store.addRepo(repo), + throwsA( + isA().having( + (e) => e.error, + 'error', + ZangetsuRepoError.invalidIndex, + ), + ), + ); + expect(store.repos(), isEmpty); + }); + + test('removing a repo drops its sources and their cached code', () async { + serve([entry('alpha')]); + final sources = await store.addRepo(repo); + await store.install(sources.single); + await cache.writeCode(name: 'zx-alpha', version: 10000, code: 'x'); + + await store.removeRepo(repo); + expect(store.repos(), isEmpty); + expect(store.installed(), isEmpty); + expect(store.manifest(repo), isEmpty); + expect(cache.readCode('zx-alpha', 10000), isNull); + expect(cache.readVersion('zx-alpha'), isNull); + }); + }); + + group('installed sources', () { + test('install, disable and remove', () async { + serve([entry('alpha')]); + final alpha = (await store.addRepo(repo)).single; + await store.install(alpha); + expect(store.installed().single.enabled, isTrue); + + await store.setEnabled('zx-alpha', false); + expect(store.installedById('zx-alpha')!.enabled, isFalse); + + await cache.writeCode(name: 'zx-alpha', version: 10000, code: 'x'); + await store.remove('zx-alpha'); + expect(store.installed(), isEmpty); + expect(cache.readCode('zx-alpha', 10000), isNull); + }); + + test('a built-in source is never installed', () async { + serve([entry('nyaa', builtIn: true)]); + await store.install((await store.addRepo(repo)).single); + expect(store.installed(), isEmpty); + }); + + test('a manifest version change is an update', () async { + serve([entry('alpha'), entry('beta')]); + final first = await store.addRepo(repo); + for (final s in first) { + await store.install(s); + } + await store.setEnabled('zx-beta', false); + expect(store.updates(), isEmpty); + + serve([entry('alpha', version: '1.0.1'), entry('beta', version: '2.0.0')]); + await store.refreshAll(); + expect(store.updates().map((s) => s.providerId), ['zx-alpha', 'zx-beta']); + expect(store.installedById('zx-alpha')!.version, '1.0.0'); + + await cache.writeCode(name: 'zx-alpha', version: 10000, code: 'old'); + expect(await store.update('zx-alpha'), isTrue); + expect(store.installedById('zx-alpha')!.version, '1.0.1'); + expect(cache.readCode('zx-alpha', 10000), isNull); + + expect(await store.updateAll(), 1); + expect(store.updates(), isEmpty); + final beta = store.installedById('zx-beta')!; + expect(beta.version, '2.0.0'); + expect(beta.enabled, isFalse); + }); + }); + + group('providers', () { + Future installAll(List> entries) async { + serve(entries); + for (final s in await store.addRepo(repo)) { + await store.install(s); + } + } + + test('an installed source runs on the device through its bundle', () async { + await installAll([entry('alpha', version: '1.0.3', type: 'anime')]); + final p = store.providers(allowAdult: false).single; + expect(p.id, 'zx-alpha'); + expect(p.mode, 'client'); + expect(p.category, 'anime'); + expect(p.description, contains('owner')); + expect(p.extractor!.name, 'zx-alpha'); + expect(p.extractor!.scope, 'all'); + expect(p.extractor!.version, 10003); + expect( + p.extractor!.url, + '/extractors/zangetsu-ext?src=' + '${Uri.encodeQueryComponent('https://raw.githubusercontent.com/owner/repo/main/providers/alpha.js')}' + '&id=zx-alpha', + ); + }); + + test('disabled and adult sources are hidden', () async { + await installAll([ + entry('alpha'), + entry('beta'), + entry('hot', nsfw: true), + ]); + await store.setEnabled('zx-beta', false); + expect(store.providers(allowAdult: false).map((p) => p.id), [ + 'zx-alpha', + ]); + expect(store.providers(allowAdult: true).map((p) => p.id), [ + 'zx-alpha', + 'zx-hot', + ]); + expect(store.providerById('zx-beta', allowAdult: true), isNull); + expect(store.providerById('zx-hot', allowAdult: false), isNull); + expect(store.providerById('zx-hot', allowAdult: true)?.id, 'zx-hot'); + expect(store.providerById('alpha', allowAdult: true), isNull); + }); + + test('a source that becomes built in stops registering', () async { + await installAll([entry('alpha')]); + serve([entry('alpha', builtIn: true)]); + await store.refreshAll(); + expect(store.providers(allowAdult: true), isEmpty); + }); + + test('built-in providers always win', () { + final external = [ + ZangetsuRepoStore.toProvider( + const ZangetsuSource( + id: 'nyaa', + providerId: 'zx-nyaa', + name: 'Nyaa', + repoUrl: repo, + file: 'x.js', + ), + ), + ZangetsuRepoStore.toProvider( + const ZangetsuSource( + id: 'taken', + providerId: 'zx-taken', + name: 'Taken', + repoUrl: repo, + file: 'x.js', + ), + ), + ZangetsuRepoStore.toProvider( + const ZangetsuSource( + id: 'fresh', + providerId: 'zx-fresh', + name: 'Fresh', + repoUrl: repo, + file: 'x.js', + ), + ), + ]; + final merged = ZangetsuRepoStore.merge([ + provider('nyaa'), + provider('zx-taken'), + ], external); + expect(merged.map((p) => p.id), ['nyaa', 'zx-taken', 'zx-fresh']); + expect(merged[1].name, 'zx-taken'); + }); + }); +} From 40df888838276f9bd1601c99c8e9ccc787b6e096 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:39:16 +0500 Subject: [PATCH 07/16] Add a live Contributors section to About and a GitHub star prompt About lists everyone who has committed to the public Sozo repos, from GET /api/contributors, with the last list cached in Hive for offline. After seven finished episodes a dismissible card on Home or Detail asks for a GitHub star. Later snoozes it for twenty more episodes; Star or Don't ask again ends it. The update dialog gets a small star button. --- assets/translations/ar.json | 14 +- assets/translations/ckb.json | 14 +- assets/translations/de.json | 14 +- assets/translations/en.json | 14 +- assets/translations/es.json | 14 +- assets/translations/fr.json | 14 +- assets/translations/hi.json | 14 +- assets/translations/id.json | 14 +- assets/translations/ku.json | 14 +- assets/translations/nl.json | 14 +- assets/translations/pt.json | 14 +- assets/translations/ru.json | 14 +- assets/translations/tr.json | 14 +- assets/translations/uz.json | 14 +- assets/translations/vi.json | 14 +- assets/translations/yue.json | 14 +- lib/core/constants/app_constants.dart | 8 + lib/core/di/injection.dart | 4 + lib/core/system/star_prompt.dart | 116 +++++++++ lib/core/widgets/star_prompt_banner.dart | 208 ++++++++++++++++ .../presentation/widgets/update_dialog.dart | 74 +++++- .../presentation/pages/detail_page.dart | 5 + .../presentation/pages/player_page.dart | 1 + .../presentation/pages/player_page.media.dart | 7 +- .../home/presentation/pages/home_page.dart | 3 + .../profile/data/contributors_service.dart | 77 ++++++ .../profile/domain/entities/contributor.dart | 60 +++++ .../presentation/pages/about_page.dart | 3 + .../widgets/contributors_section.dart | 222 ++++++++++++++++++ test/core/system/star_prompt_test.dart | 70 ++++++ test/features/profile/contributor_test.dart | 68 ++++++ 31 files changed, 1121 insertions(+), 29 deletions(-) create mode 100644 lib/core/system/star_prompt.dart create mode 100644 lib/core/widgets/star_prompt_banner.dart create mode 100644 lib/features/profile/data/contributors_service.dart create mode 100644 lib/features/profile/domain/entities/contributor.dart create mode 100644 lib/features/profile/presentation/widgets/contributors_section.dart create mode 100644 test/core/system/star_prompt_test.dart create mode 100644 test/features/profile/contributor_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 6e6bbb79..f82ab7cf 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -751,7 +751,11 @@ "library_update": "التحقق من الحلقات الجديدة", "library_update_desc": "يتفقد العناوين التي تتابعها ويُعلمك عند صدور جديد", "library_update_every": "كل {} ساعات", - "library_update_daily": "مرة يوميًا" + "library_update_daily": "مرة يوميًا", + "contributors": "المساهمون", + "contributors_error": "تعذّر تحميل المساهمين", + "become_contributor": "كن مساهمًا", + "become_contributor_body": "Sozo مفتوح المصدر. الكود والترجمات وتقارير الأخطاء كلها تساعد." }, "appearance": { "title": "المظهر", @@ -3031,5 +3035,13 @@ "error_attachment_size": "يجب ألا يتجاوز حجم كل صورة 5 ميغابايت.", "error_attachment_count": "5 لقطات شاشة كحد أقصى في الرسالة.", "suggest": "تتكرر المشكلة؟ راسل الدعم" + }, + "star": { + "title": "هل تريد مساعدة Sozo على النمو؟", + "body": "أضف له نجمة على GitHub ⭐", + "action": "أضف نجمة على GitHub", + "later": "لاحقًا", + "never": "لا تسألني مجددًا", + "update_button": "أضف نجمة لـ Sozo على GitHub" } } diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 6cef5071..900adac8 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -751,7 +751,11 @@ "library_update": "پشکنین بۆ ئەڵقە نوێکان", "library_update_desc": "سەیری ئەو ناونیشانانە دەکات کە بەدوایاندا دەچیت و کاتێک نوێیەک دەرچوو ئاگادارت دەکاتەوە", "library_update_every": "هەر {} کاتژمێر جارێک", - "library_update_daily": "ڕۆژی یەک جار" + "library_update_daily": "ڕۆژی یەک جار", + "contributors": "بەشداربووان", + "contributors_error": "نەتوانرا بەشداربووان باربکرێن", + "become_contributor": "ببە بە بەشداربوو", + "become_contributor_body": "Sozo سەرچاوە-کراوەیە. کۆد، وەرگێڕان و ڕاپۆرتی هەڵەکان هەموویان یارمەتیدەرن." }, "appearance": { "title": "ڕووکار", @@ -3027,5 +3031,13 @@ "error_attachment_size": "هەر وێنەیەک دەبێت 5 MB یان کەمتر بێت.", "error_attachment_count": "لە هەر نامەیەکدا زۆرترین 5 وێنەی شاشە.", "suggest": "دووبارە دەبێتەوە؟ بۆ پشتگیری بنووسە" + }, + "star": { + "title": "دەتەوێت یارمەتی گەشەکردنی Sozo بدەیت؟", + "body": "لە GitHub ئەستێرەیەکی پێ بدە ⭐", + "action": "ئەستێرە بدە لە GitHub", + "later": "دواتر", + "never": "جارێکی تر مەپرسە", + "update_button": "ئەستێرە بدە بە Sozo لە GitHub" } } diff --git a/assets/translations/de.json b/assets/translations/de.json index 0970f03c..9db038ba 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -751,7 +751,11 @@ "library_update": "Nach neuen Folgen suchen", "library_update_desc": "Prüft deine gefolgten Titel und meldet neue Folgen", "library_update_every": "Alle {} Stunden", - "library_update_daily": "Einmal täglich" + "library_update_daily": "Einmal täglich", + "contributors": "Mitwirkende", + "contributors_error": "Mitwirkende konnten nicht geladen werden", + "become_contributor": "Mitwirken", + "become_contributor_body": "Sozo ist Open Source. Code, Übersetzungen und Fehlerberichte helfen gleichermaßen." }, "appearance": { "title": "Darstellung", @@ -3027,5 +3031,13 @@ "error_attachment_size": "Jedes Bild darf höchstens 5 MB groß sein.", "error_attachment_count": "Höchstens 5 Screenshots pro Nachricht.", "suggest": "Passiert das öfter? Schreib dem Support" + }, + "star": { + "title": "Möchtest du Sozo beim Wachsen helfen?", + "body": "Gib uns einen Stern auf GitHub ⭐", + "action": "Stern auf GitHub vergeben", + "later": "Später", + "never": "Nicht mehr fragen", + "update_button": "Sozo auf GitHub einen Stern geben" } } diff --git a/assets/translations/en.json b/assets/translations/en.json index 8fbca5aa..3ce3cabf 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -751,7 +751,11 @@ "library_update": "Check for new episodes", "library_update_desc": "Looks at the titles you follow and notifies you when a new one is out", "library_update_every": "Every {} hours", - "library_update_daily": "Once a day" + "library_update_daily": "Once a day", + "contributors": "Contributors", + "contributors_error": "Couldn't load contributors", + "become_contributor": "Become a contributor", + "become_contributor_body": "Sozo is open source. Code, translations and bug reports all help." }, "appearance": { "title": "Appearance", @@ -3027,5 +3031,13 @@ "error_attachment_size": "Each image must be 5 MB or less.", "error_attachment_count": "At most 5 screenshots per message.", "suggest": "Keeps happening? Write to support" + }, + "star": { + "title": "Want to help Sozo grow?", + "body": "Star it on GitHub ⭐", + "action": "Star on GitHub", + "later": "Later", + "never": "Don't ask again", + "update_button": "Star Sozo on GitHub" } } diff --git a/assets/translations/es.json b/assets/translations/es.json index 319ddb48..aa4ef0d7 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -751,7 +751,11 @@ "library_update": "Buscar episodios nuevos", "library_update_desc": "Revisa los títulos que sigues y te avisa cuando sale uno nuevo", "library_update_every": "Cada {} horas", - "library_update_daily": "Una vez al día" + "library_update_daily": "Una vez al día", + "contributors": "Colaboradores", + "contributors_error": "No se pudieron cargar los colaboradores", + "become_contributor": "Hazte colaborador", + "become_contributor_body": "Sozo es de código abierto. El código, las traducciones y los informes de errores ayudan." }, "appearance": { "title": "Apariencia", @@ -3027,5 +3031,13 @@ "error_attachment_size": "Cada imagen debe pesar 5 MB o menos.", "error_attachment_count": "Como máximo 5 capturas por mensaje.", "suggest": "¿Sigue pasando? Escribe a soporte" + }, + "star": { + "title": "¿Quieres ayudar a que Sozo crezca?", + "body": "Dale una estrella en GitHub ⭐", + "action": "Dar estrella en GitHub", + "later": "Más tarde", + "never": "No volver a preguntar", + "update_button": "Dale una estrella a Sozo en GitHub" } } diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 599703a9..366c1512 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -751,7 +751,11 @@ "library_update": "Vérifier les nouveaux épisodes", "library_update_desc": "Vérifie les titres suivis et vous prévient à chaque nouveauté", "library_update_every": "Toutes les {} heures", - "library_update_daily": "Une fois par jour" + "library_update_daily": "Une fois par jour", + "contributors": "Contributeurs", + "contributors_error": "Impossible de charger les contributeurs", + "become_contributor": "Devenir contributeur", + "become_contributor_body": "Sozo est open source. Le code, les traductions et les signalements de bugs, tout aide." }, "appearance": { "title": "Apparence", @@ -3027,5 +3031,13 @@ "error_attachment_size": "Chaque image doit faire 5 Mo maximum.", "error_attachment_count": "5 captures maximum par message.", "suggest": "Ça se reproduit ? Écris au support" + }, + "star": { + "title": "Envie d'aider Sozo à grandir ?", + "body": "Mettez-lui une étoile sur GitHub ⭐", + "action": "Étoile sur GitHub", + "later": "Plus tard", + "never": "Ne plus demander", + "update_button": "Mettre une étoile à Sozo sur GitHub" } } diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 12d0cb70..15051b7b 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -751,7 +751,11 @@ "library_update": "नए एपिसोड चेक करें", "library_update_desc": "आपके फ़ॉलो किए गए टाइटल देखता है और नया एपिसोड आने पर आपको नोटिफ़िकेशन भेजता है", "library_update_every": "हर {} घंटे में", - "library_update_daily": "दिन में एक बार" + "library_update_daily": "दिन में एक बार", + "contributors": "योगदानकर्ता", + "contributors_error": "योगदानकर्ता लोड नहीं हो सके", + "become_contributor": "योगदानकर्ता बनें", + "become_contributor_body": "Sozo ओपन सोर्स है। कोड, अनुवाद और बग रिपोर्ट — सब मदद करते हैं।" }, "appearance": { "title": "दिखावट", @@ -3027,5 +3031,13 @@ "error_attachment_size": "हर इमेज 5 MB या उससे छोटी होनी चाहिए।", "error_attachment_count": "एक मैसेज में ज़्यादा से ज़्यादा 5 स्क्रीनशॉट।", "suggest": "बार-बार हो रहा है? सपोर्ट को लिखें" + }, + "star": { + "title": "क्या आप Sozo को बढ़ने में मदद करना चाहते हैं?", + "body": "GitHub पर इसे स्टार दें ⭐", + "action": "GitHub पर स्टार दें", + "later": "बाद में", + "never": "फिर से न पूछें", + "update_button": "GitHub पर Sozo को स्टार दें" } } diff --git a/assets/translations/id.json b/assets/translations/id.json index 28442646..eb789fb1 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -751,7 +751,11 @@ "library_update": "Periksa episode baru", "library_update_desc": "Memeriksa judul yang kamu ikuti dan memberi tahu saat ada yang baru", "library_update_every": "Setiap {} jam", - "library_update_daily": "Sekali sehari" + "library_update_daily": "Sekali sehari", + "contributors": "Kontributor", + "contributors_error": "Gagal memuat kontributor", + "become_contributor": "Jadi kontributor", + "become_contributor_body": "Sozo bersifat open source. Kode, terjemahan, dan laporan bug semuanya membantu." }, "appearance": { "title": "Tampilan", @@ -3027,5 +3031,13 @@ "error_attachment_size": "Setiap gambar maksimal 5 MB.", "error_attachment_count": "Maksimal 5 tangkapan layar per pesan.", "suggest": "Terus terjadi? Hubungi dukungan" + }, + "star": { + "title": "Ingin membantu Sozo berkembang?", + "body": "Beri bintang di GitHub ⭐", + "action": "Beri bintang di GitHub", + "later": "Nanti", + "never": "Jangan tanya lagi", + "update_button": "Beri bintang Sozo di GitHub" } } diff --git a/assets/translations/ku.json b/assets/translations/ku.json index e876ed5b..a9e164c5 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -751,7 +751,11 @@ "library_update": "Li beşên nû kontrol bike", "library_update_desc": "Li sernavên ku tu dişopînî dinêre û dema yekî nû derkeve te agahdar dike", "library_update_every": "Her {} saetan", - "library_update_daily": "Rojê carekê" + "library_update_daily": "Rojê carekê", + "contributors": "Beşdar", + "contributors_error": "Beşdar nehatin barkirin", + "become_contributor": "Bibe beşdar", + "become_contributor_body": "Sozo çavkaniya vekirî ye. Kod, werger û raporên çewtiyan hemû alîkar in." }, "appearance": { "title": "Xuyang", @@ -3027,5 +3031,13 @@ "error_attachment_size": "Divê her wêne herî zêde 5 MB be.", "error_attachment_count": "Di peyamekê de herî zêde 5 dîmenên ekranê.", "suggest": "Dubare dibe? Ji piştgiriyê re binivîse" + }, + "star": { + "title": "Tu dixwazî alîkariya mezinbûna Sozo bikî?", + "body": "Li GitHubê stêrkekê bidê ⭐", + "action": "Li GitHubê stêrk bide", + "later": "Paşê", + "never": "Careke din nepirse", + "update_button": "Li GitHubê stêrkekê bide Sozo" } } diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 8ca39779..72389bd4 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -751,7 +751,11 @@ "library_update": "Zoeken naar nieuwe afleveringen", "library_update_desc": "Controleert de titels die je volgt en meldt nieuwe afleveringen", "library_update_every": "Elke {} uur", - "library_update_daily": "Eén keer per dag" + "library_update_daily": "Eén keer per dag", + "contributors": "Bijdragers", + "contributors_error": "Bijdragers konden niet worden geladen", + "become_contributor": "Word bijdrager", + "become_contributor_body": "Sozo is open source. Code, vertalingen en bugmeldingen helpen allemaal." }, "appearance": { "title": "Weergave", @@ -3027,5 +3031,13 @@ "error_attachment_size": "Elke afbeelding mag maximaal 5 MB zijn.", "error_attachment_count": "Maximaal 5 screenshots per bericht.", "suggest": "Blijft het gebeuren? Schrijf support" + }, + "star": { + "title": "Wil je Sozo helpen groeien?", + "body": "Geef het een ster op GitHub ⭐", + "action": "Ster geven op GitHub", + "later": "Later", + "never": "Niet meer vragen", + "update_button": "Geef Sozo een ster op GitHub" } } diff --git a/assets/translations/pt.json b/assets/translations/pt.json index f79b5482..f6412e53 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -751,7 +751,11 @@ "library_update": "Procurar novos episódios", "library_update_desc": "Verifica os títulos que você segue e avisa quando sai um novo", "library_update_every": "A cada {} horas", - "library_update_daily": "Uma vez por dia" + "library_update_daily": "Uma vez por dia", + "contributors": "Colaboradores", + "contributors_error": "Não foi possível carregar os colaboradores", + "become_contributor": "Torne-se colaborador", + "become_contributor_body": "O Sozo é de código aberto. Código, traduções e relatos de bugs, tudo ajuda." }, "appearance": { "title": "Aparência", @@ -3027,5 +3031,13 @@ "error_attachment_size": "Cada imagem pode ter no máximo 5 MB.", "error_attachment_count": "No máximo 5 capturas por mensagem.", "suggest": "Continua acontecendo? Fale com o suporte" + }, + "star": { + "title": "Quer ajudar o Sozo a crescer?", + "body": "Dê uma estrela no GitHub ⭐", + "action": "Dar estrela no GitHub", + "later": "Mais tarde", + "never": "Não perguntar novamente", + "update_button": "Dê uma estrela ao Sozo no GitHub" } } diff --git a/assets/translations/ru.json b/assets/translations/ru.json index c85aef09..f8aebefc 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -751,7 +751,11 @@ "library_update": "Проверять новые серии", "library_update_desc": "Проверяет отслеживаемые тайтлы и сообщает о новых сериях", "library_update_every": "Каждые {} ч", - "library_update_daily": "Раз в день" + "library_update_daily": "Раз в день", + "contributors": "Участники", + "contributors_error": "Не удалось загрузить участников", + "become_contributor": "Стать участником", + "become_contributor_body": "Sozo — проект с открытым исходным кодом. Помогает всё: код, переводы и сообщения об ошибках." }, "appearance": { "title": "Внешний вид", @@ -3028,5 +3032,13 @@ "error_attachment_size": "Каждое изображение — не больше 5 МБ.", "error_attachment_count": "Не больше 5 скриншотов в сообщении.", "suggest": "Повторяется? Напишите в поддержку" + }, + "star": { + "title": "Хотите помочь Sozo расти?", + "body": "Поставьте звезду на GitHub ⭐", + "action": "Поставить звезду на GitHub", + "later": "Позже", + "never": "Больше не спрашивать", + "update_button": "Поставить Sozo звезду на GitHub" } } diff --git a/assets/translations/tr.json b/assets/translations/tr.json index f22f299e..cefccdf7 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -751,7 +751,11 @@ "library_update": "Yeni bölümleri denetle", "library_update_desc": "Takip ettiğin yapımları denetler, yenisi çıkınca haber verir", "library_update_every": "{} saatte bir", - "library_update_daily": "Günde bir" + "library_update_daily": "Günde bir", + "contributors": "Katkıda bulunanlar", + "contributors_error": "Katkıda bulunanlar yüklenemedi", + "become_contributor": "Katkıda bulun", + "become_contributor_body": "Sozo açık kaynaklıdır. Kod, çeviri ve hata bildirimleri; hepsi yardımcı olur." }, "appearance": { "title": "Görünüm", @@ -3027,5 +3031,13 @@ "error_attachment_size": "Her görsel en fazla 5 MB olmalı.", "error_attachment_count": "Mesaj başına en fazla 5 ekran görüntüsü.", "suggest": "Tekrarlıyor mu? Desteğe yaz" + }, + "star": { + "title": "Sozo'nun büyümesine yardım etmek ister misin?", + "body": "GitHub'da yıldız ver ⭐", + "action": "GitHub'da yıldız ver", + "later": "Sonra", + "never": "Bir daha sorma", + "update_button": "Sozo'ya GitHub'da yıldız ver" } } diff --git a/assets/translations/uz.json b/assets/translations/uz.json index a0ec0494..6d473f09 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -751,7 +751,11 @@ "library_update": "Yangi qismlarni tekshirish", "library_update_desc": "Kuzatayotgan titullaringizni tekshirib, yangi qism chiqsa xabar beradi", "library_update_every": "Har {} soatda", - "library_update_daily": "Kuniga bir marta" + "library_update_daily": "Kuniga bir marta", + "contributors": "Hissa qo'shganlar", + "contributors_error": "Hissa qo'shganlarni yuklab bo'lmadi", + "become_contributor": "Hissa qo'shing", + "become_contributor_body": "Sozo ochiq kodli loyiha. Kod, tarjima va xatolar haqida xabarlar — hammasi yordam beradi." }, "appearance": { "title": "Ko'rinish", @@ -3027,5 +3031,13 @@ "error_attachment_size": "Har bir rasm 5 MB dan oshmasin.", "error_attachment_count": "Bitta xabarga ko‘pi bilan 5 ta skrinshot.", "suggest": "Takrorlanyaptimi? Support’ga yozing" + }, + "star": { + "title": "Sozo rivojlanishiga yordam berishni xohlaysizmi?", + "body": "GitHub'da yulduzcha qo'ying ⭐", + "action": "GitHub'da yulduzcha qo'yish", + "later": "Keyinroq", + "never": "Boshqa so'ramang", + "update_button": "GitHub'da Sozo'ga yulduzcha qo'ying" } } diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 5a8f5b47..0905c5a5 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -751,7 +751,11 @@ "library_update": "Kiểm tra tập mới", "library_update_desc": "Kiểm tra các phim bạn theo dõi và báo khi có tập mới", "library_update_every": "Mỗi {} giờ", - "library_update_daily": "Mỗi ngày một lần" + "library_update_daily": "Mỗi ngày một lần", + "contributors": "Người đóng góp", + "contributors_error": "Không tải được danh sách người đóng góp", + "become_contributor": "Trở thành người đóng góp", + "become_contributor_body": "Sozo là mã nguồn mở. Mã nguồn, bản dịch và báo lỗi đều giúp ích." }, "appearance": { "title": "Giao diện", @@ -3027,5 +3031,13 @@ "error_attachment_size": "Mỗi ảnh tối đa 5 MB.", "error_attachment_count": "Tối đa 5 ảnh mỗi tin nhắn.", "suggest": "Vẫn tiếp diễn? Hãy viết cho bộ phận hỗ trợ" + }, + "star": { + "title": "Bạn muốn giúp Sozo phát triển?", + "body": "Gắn sao trên GitHub ⭐", + "action": "Gắn sao trên GitHub", + "later": "Để sau", + "never": "Không hỏi lại", + "update_button": "Gắn sao cho Sozo trên GitHub" } } diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 91e3e54a..33e44d56 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -751,7 +751,11 @@ "library_update": "檢查新集數", "library_update_desc": "檢查你追緊嘅作品,有新集就通知你", "library_update_every": "每 {} 小時", - "library_update_daily": "每日一次" + "library_update_daily": "每日一次", + "contributors": "貢獻者", + "contributors_error": "載入唔到貢獻者", + "become_contributor": "成為貢獻者", + "become_contributor_body": "Sozo 係開源項目。寫 code、翻譯、報 bug 都幫到手。" }, "appearance": { "title": "外觀", @@ -3027,5 +3031,13 @@ "error_attachment_size": "每張圖唔可以大過 5 MB。", "error_attachment_count": "每條訊息最多 5 張截圖。", "suggest": "成日都係咁?搵支援" + }, + "star": { + "title": "想幫 Sozo 發展?", + "body": "喺 GitHub 畀粒星佢 ⭐", + "action": "喺 GitHub 畀星", + "later": "遲啲先", + "never": "唔使再問", + "update_button": "喺 GitHub 畀 Sozo 粒星" } } diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index 777deeff..b9f768e6 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -326,6 +326,14 @@ class AppConstants { /// Feature ids the viewer has already opened. static const String whatsNewSeenKey = 'whats_new_seen'; + /// Completed-episode counter and answers for the GitHub star prompt. + static const String starPromptKey = 'star_prompt'; + + /// Last contributor list from the backend, for the About page offline. + static const String contributorsCacheKey = 'contributors_cache'; + + static const String sozoRepoUrl = 'https://github.com/professorDeveloper/sozo'; + static const String streakBox = 'streak_box'; static const String streakStateKey = 'streak_state'; } diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index f2f65fee..6a7c51ee 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -95,6 +95,7 @@ import 'package:soplay/features/anilist/data/anilist_tracker.dart'; import 'package:soplay/features/tracker/data/tracker_outbox.dart'; import 'package:soplay/features/detail/data/aniskip_service.dart'; import 'package:soplay/features/profile/data/backup_service.dart'; +import 'package:soplay/features/profile/data/contributors_service.dart'; import 'package:soplay/features/detail/domain/services/alternate_source_service.dart'; import 'package:soplay/features/profile/domain/services/provider_probe.dart'; import 'package:soplay/features/auth/data/services/google_auth_service.dart'; @@ -436,6 +437,9 @@ Future configureDependencies() async { getIt.registerLazySingleton( () => RemoteControlService(dio: getIt()), ); + getIt.registerLazySingleton( + () => ContributorsService(dio: getIt()), + ); getIt.registerSingleton(AnilistLinkStore()); getIt.registerSingleton( AnilistService( diff --git a/lib/core/system/star_prompt.dart b/lib/core/system/star_prompt.dart new file mode 100644 index 00000000..42966450 --- /dev/null +++ b/lib/core/system/star_prompt.dart @@ -0,0 +1,116 @@ +import 'package:flutter/foundation.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import 'package:soplay/core/constants/app_constants.dart'; + +/// Where the GitHub star prompt stands: episodes finished, the count at which +/// it may show next, and whether the viewer has answered for good. +@immutable +class StarPromptState { + const StarPromptState({ + this.completed = 0, + this.nextAt = StarPromptRules.firstAt, + this.done = false, + }); + + final int completed; + final int nextAt; + final bool done; + + factory StarPromptState.fromMap(Object? raw) { + if (raw is! Map) return const StarPromptState(); + int asInt(Object? v, int fallback) => v is num ? v.toInt() : fallback; + return StarPromptState( + completed: asInt(raw['completed'], 0), + nextAt: asInt(raw['nextAt'], StarPromptRules.firstAt), + done: raw['done'] == true, + ); + } + + Map toMap() => { + 'completed': completed, + 'nextAt': nextAt, + 'done': done, + }; + + StarPromptState copyWith({int? completed, int? nextAt, bool? done}) => + StarPromptState( + completed: completed ?? this.completed, + nextAt: nextAt ?? this.nextAt, + done: done ?? this.done, + ); + + @override + bool operator ==(Object other) => + other is StarPromptState && + other.completed == completed && + other.nextAt == nextAt && + other.done == done; + + @override + int get hashCode => Object.hash(completed, nextAt, done); +} + +/// The decisions, with no storage and no clock. +abstract final class StarPromptRules { + static const int firstAt = 7; + static const int snoozeEpisodes = 20; + + static bool shouldShow(StarPromptState s) => + !s.done && s.completed >= s.nextAt; + + static StarPromptState episodeCompleted(StarPromptState s) => + s.copyWith(completed: s.completed + 1); + + /// Showing it counts as "Later" until the viewer says otherwise, so a + /// banner scrolled past or left behind does not come back on every screen. + static StarPromptState shown(StarPromptState s) => + s.copyWith(nextAt: s.completed + snoozeEpisodes); + + static StarPromptState later(StarPromptState s) => shown(s); + + /// Starred, or "Don't ask again": either way, never again. + static StarPromptState answered(StarPromptState s) => s.copyWith(done: true); +} + +/// [StarPromptRules] over the settings box. App-wide, not per profile: it is +/// a question about the app, asked of whoever holds the device. +abstract final class StarPrompt { + static final ValueNotifier revision = ValueNotifier(0); + + static Box? get _box { + try { + return Hive.box(AppConstants.settingsBox); + } catch (_) { + return null; + } + } + + static StarPromptState get state => + StarPromptState.fromMap(_box?.get(AppConstants.starPromptKey)); + + static bool get shouldShow => StarPromptRules.shouldShow(state); + + static Future _apply( + StarPromptState Function(StarPromptState) change, + ) async { + final box = _box; + if (box == null) return; + final before = state; + final after = change(before); + if (after == before) return; + try { + await box.put(AppConstants.starPromptKey, after.toMap()); + } catch (_) {} + revision.value++; + } + + static Future episodeCompleted() => + _apply(StarPromptRules.episodeCompleted); + + static Future markShown() => _apply(StarPromptRules.shown); + + static Future later() => _apply(StarPromptRules.later); + + static Future answered() => _apply(StarPromptRules.answered); +} diff --git a/lib/core/widgets/star_prompt_banner.dart b/lib/core/widgets/star_prompt_banner.dart new file mode 100644 index 00000000..46ab792e --- /dev/null +++ b/lib/core/widgets/star_prompt_banner.dart @@ -0,0 +1,208 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/navigation/app_tab.dart'; +import 'package:soplay/core/navigation/nav_controller.dart'; +import 'package:soplay/core/player/player_dock.dart'; +import 'package:soplay/core/system/star_prompt.dart'; +import 'package:soplay/core/theme/app_colors.dart'; + +/// The "star Sozo on GitHub" card. Nothing at all until [StarPrompt] says it +/// is due, and only on the route on top: the player is a route of its own, so +/// a banner on Home or Detail never appears over playback. +class StarPromptBanner extends StatefulWidget { + const StarPromptBanner({ + super.key, + this.margin = const EdgeInsets.fromLTRB(12, 8, 12, 0), + this.safeBottom = false, + this.tab, + }); + + final EdgeInsetsGeometry margin; + final bool safeBottom; + + /// For a banner inside a main tab: tabs stay mounted behind the visible + /// one, so the route alone does not say it can be seen. + final TabId? tab; + + @override + State createState() => _StarPromptBannerState(); +} + +class _StarPromptBannerState extends State { + bool _visible = false; + NavController? _nav; + ModalRoute? _route; + + @override + void initState() { + super.initState(); + StarPrompt.revision.addListener(_schedule); + PlayerDock.instance.addListener(_schedule); + if (widget.tab != null && getIt.isRegistered()) { + _nav = getIt()..index.addListener(_schedule); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Subscribes to isCurrent, so returning from the player re-checks. + _route = ModalRoute.of(context); + _schedule(); + } + + @override + void dispose() { + StarPrompt.revision.removeListener(_schedule); + PlayerDock.instance.removeListener(_schedule); + _nav?.index.removeListener(_schedule); + super.dispose(); + } + + void _schedule() { + WidgetsBinding.instance + ..addPostFrameCallback((_) => _check()) + ..ensureVisualUpdate(); + } + + void _check() { + if (!mounted || _visible) return; + final route = _route; + if (route != null && !route.isCurrent) return; + if (PlayerDock.instance.isDocked) return; + final tab = widget.tab; + if (tab != null && !(_nav?.isActive(tab) ?? false)) return; + if (!StarPrompt.shouldShow) return; + unawaited(StarPrompt.markShown()); + setState(() => _visible = true); + } + + void _hide() { + if (mounted) setState(() => _visible = false); + } + + Future _star() async { + _hide(); + await StarPrompt.answered(); + try { + await launchUrl( + Uri.parse(AppConstants.sozoRepoUrl), + mode: LaunchMode.externalApplication, + ); + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + return AnimatedSize( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _visible ? _card(context) : const SizedBox(width: double.infinity), + ); + } + + Widget _card(BuildContext context) { + final card = Padding( + padding: widget.margin, + child: Material( + color: AppColors.surface, + elevation: 6, + shadowColor: Colors.black54, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsetsDirectional.fromSTEB(14, 12, 8, 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.08)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.star_rounded, + color: Color(0xFFFFC83D), + size: 22, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'star.title'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'star.body'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 4), + Wrap( + alignment: WrapAlignment.end, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 2, + children: [ + TextButton( + onPressed: () { + _hide(); + unawaited(StarPrompt.answered()); + }, + style: TextButton.styleFrom( + foregroundColor: AppColors.textHint, + ), + child: Text('star.never'.tr()), + ), + TextButton( + onPressed: () { + _hide(); + unawaited(StarPrompt.later()); + }, + style: TextButton.styleFrom( + foregroundColor: AppColors.textSecondary, + ), + child: Text('star.later'.tr()), + ), + TextButton.icon( + onPressed: _star, + icon: const Icon(Icons.star_rounded, size: 18), + label: Text('star.action'.tr()), + style: TextButton.styleFrom( + foregroundColor: AppColors.primary, + ), + ), + ], + ), + ], + ), + ), + ), + ); + return SizedBox( + width: double.infinity, + child: widget.safeBottom ? SafeArea(top: false, child: card) : card, + ); + } +} diff --git a/lib/features/app_updater/presentation/widgets/update_dialog.dart b/lib/features/app_updater/presentation/widgets/update_dialog.dart index ebc5e3a0..f6642720 100644 --- a/lib/features/app_updater/presentation/widgets/update_dialog.dart +++ b/lib/features/app_updater/presentation/widgets/update_dialog.dart @@ -3,9 +3,12 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/system/star_prompt.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/app_updater/domain/entities/app_version_check.dart'; import 'package:soplay/features/app_updater/presentation/widgets/release_notes_view.dart'; +import 'package:url_launcher/url_launcher.dart'; const Color _androidAccent = Color(0xFF10B981); const Color _iosAccent = Color(0xFF0F172A); @@ -45,15 +48,23 @@ class _MaterialUpdateDialog extends StatelessWidget { ), ), content: SingleChildScrollView( - child: (check.releaseNotes ?? '').trim().isEmpty - ? Text( - 'update.available_body'.tr(), - style: const TextStyle( - color: AppColors.textSecondary, - height: 1.4, - ), - ) - : ReleaseNotesView(text: check.releaseNotes!, fontSize: 14), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + (check.releaseNotes ?? '').trim().isEmpty + ? Text( + 'update.available_body'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + height: 1.4, + ), + ) + : ReleaseNotesView(text: check.releaseNotes!, fontSize: 14), + const SizedBox(height: 8), + const _StarOnGitHubButton(), + ], + ), ), actions: [ TextButton( @@ -84,9 +95,16 @@ class _CupertinoUpdateDialog extends StatelessWidget { title: Text('update.available_title'.tr(args: ['${check.version}'])), content: Padding( padding: const EdgeInsets.only(top: 8), - child: (check.releaseNotes ?? '').trim().isEmpty - ? Text('update.available_body'.tr()) - : ReleaseNotesView(text: check.releaseNotes!, fontSize: 14), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + (check.releaseNotes ?? '').trim().isEmpty + ? Text('update.available_body'.tr()) + : ReleaseNotesView(text: check.releaseNotes!, fontSize: 14), + const SizedBox(height: 6), + const _StarOnGitHubButton(), + ], + ), ), actions: [ if (hasStore) @@ -111,3 +129,35 @@ class _CupertinoUpdateDialog extends StatelessWidget { ); } } + +class _StarOnGitHubButton extends StatelessWidget { + const _StarOnGitHubButton(); + + Future _star() async { + await StarPrompt.answered(); + try { + await launchUrl( + Uri.parse(AppConstants.sozoRepoUrl), + mode: LaunchMode.externalApplication, + ); + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: TextButton.icon( + onPressed: _star, + icon: const Icon(Icons.star_rounded, size: 16), + label: Text('star.update_button'.tr()), + style: TextButton.styleFrom( + foregroundColor: AppColors.textSecondary, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 8), + textStyle: const TextStyle(fontSize: 12.5), + ), + ), + ); + } +} diff --git a/lib/features/detail/presentation/pages/detail_page.dart b/lib/features/detail/presentation/pages/detail_page.dart index e031e61d..c6c5bc50 100644 --- a/lib/features/detail/presentation/pages/detail_page.dart +++ b/lib/features/detail/presentation/pages/detail_page.dart @@ -17,6 +17,7 @@ import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/widgets/star_prompt_banner.dart'; import 'package:soplay/core/tv/tv.dart'; import 'package:soplay/features/banners/domain/entities/banner_item.dart'; import 'package:soplay/features/banners/presentation/widgets/banners_carousel.dart'; @@ -149,6 +150,10 @@ class _DetailScaffold extends StatelessWidget { value: SystemUiOverlayStyle.light, child: Scaffold( backgroundColor: AppColors.background, + bottomNavigationBar: const StarPromptBanner( + margin: EdgeInsets.fromLTRB(12, 0, 12, 12), + safeBottom: true, + ), body: BlocListener( listenWhen: (prev, curr) { if (curr is! DetailLoaded) return false; diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index 31055dfb..26f94b64 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -70,6 +70,7 @@ import 'package:soplay/core/widgets/server_badge.dart'; import 'package:soplay/core/system/app_orientation.dart'; import 'package:soplay/core/system/desktop_window.dart'; import 'package:soplay/core/system/responsive.dart'; +import 'package:soplay/core/system/star_prompt.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/core/glass/media_glass.dart'; import 'package:soplay/features/anilist/data/anilist_tracker.dart'; diff --git a/lib/features/detail/presentation/pages/player_page.media.dart b/lib/features/detail/presentation/pages/player_page.media.dart index 3ed1ed7f..68bd17eb 100644 --- a/lib/features/detail/presentation/pages/player_page.media.dart +++ b/lib/features/detail/presentation/pages/player_page.media.dart @@ -2113,7 +2113,12 @@ extension _PlayerMedia on _PlayerPageState { // not add a completion each time they cross it. if (!_countedComplete) { _countedComplete = true; - if (!_hive.isIncognito) unawaited(_watchStats.recordCompleted()); + if (!_hive.isIncognito) { + unawaited(_watchStats.recordCompleted()); + if (widget.args.provider != 'trailer') { + unawaited(StarPrompt.episodeCompleted()); + } + } } } diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 3965da73..bb660c30 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/navigation/app_tab.dart'; +import 'package:soplay/core/widgets/star_prompt_banner.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:soplay/features/home/domain/catalogue_status.dart'; import 'package:soplay/features/home/presentation/bloc/home/home_bloc.dart'; @@ -103,6 +105,7 @@ class _HomePageState extends State { // Collapses to nothing while the backend is healthy, so the bar // keeps its exact previous geometry in the normal case. const BackendOutageBanner(), + const StarPromptBanner(tab: TabId.home), ], ), ), diff --git a/lib/features/profile/data/contributors_service.dart b/lib/features/profile/data/contributors_service.dart new file mode 100644 index 00000000..f9fc5a8a --- /dev/null +++ b/lib/features/profile/data/contributors_service.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/features/profile/domain/entities/contributor.dart'; + +/// The About page's contributor list: the last one seen, from Hive, at once, +/// then whatever `/contributors` says. A failed refresh keeps the old list. +class ContributorsService { + ContributorsService({required Dio dio}) : _dio = dio { + list.value = _readCache(); + } + + final Dio _dio; + + /// Null until something is known, from the cache or the network. + final ValueNotifier?> list = ValueNotifier(null); + + /// True once a refresh has failed with nothing cached to show. + final ValueNotifier failed = ValueNotifier(false); + + DateTime? _fetchedAt; + bool _inFlight = false; + + static const Duration _minRefresh = Duration(minutes: 30); + + Box? get _box { + try { + return Hive.box(AppConstants.settingsBox); + } catch (_) { + return null; + } + } + + List? _readCache() { + try { + final raw = _box?.get(AppConstants.contributorsCacheKey); + if (raw is String && raw.isNotEmpty) { + final rows = Contributor.listFrom(jsonDecode(raw)); + if (rows.isNotEmpty) return rows; + } + } catch (_) {} + return null; + } + + Future refresh() async { + if (_inFlight) return; + final at = _fetchedAt; + if (at != null && DateTime.now().difference(at) < _minRefresh) return; + _inFlight = true; + failed.value = false; + try { + final res = await _dio.get( + '/contributors', + options: Options(extra: const {'skipAuthInterceptor': true}), + ); + final rows = Contributor.listFrom(res.data); + if (rows.isEmpty) throw StateError('empty contributors'); + _fetchedAt = DateTime.now(); + list.value = rows; + try { + await _box?.put( + AppConstants.contributorsCacheKey, + jsonEncode([for (final c in rows) c.toJson()]), + ); + } catch (_) {} + } catch (e) { + debugPrint('[contributors] refresh failed: $e'); + if (list.value == null) failed.value = true; + } finally { + _inFlight = false; + } + } +} diff --git a/lib/features/profile/domain/entities/contributor.dart b/lib/features/profile/domain/entities/contributor.dart new file mode 100644 index 00000000..0aa3ae60 --- /dev/null +++ b/lib/features/profile/domain/entities/contributor.dart @@ -0,0 +1,60 @@ +/// Someone who has committed to one of the open-source Sozo repositories. +class Contributor { + const Contributor({ + required this.login, + required this.avatarUrl, + required this.htmlUrl, + required this.contributions, + }); + + final String login; + final String avatarUrl; + final String htmlUrl; + final int contributions; + + /// Null for anything without a login. A profile link that is not on + /// github.com is replaced, so a tap can only ever open GitHub. + static Contributor? fromJson(Object? raw) { + if (raw is! Map) return null; + final login = raw['login']; + if (login is! String || login.trim().isEmpty) return null; + final name = login.trim(); + final html = raw['htmlUrl']; + final avatar = raw['avatarUrl']; + final count = raw['contributions']; + return Contributor( + login: name, + avatarUrl: avatar is String && avatar.startsWith('https://') + ? avatar + : '', + htmlUrl: html is String && html.startsWith('https://github.com/') + ? html + : 'https://github.com/$name', + contributions: count is num ? count.toInt() : 0, + ); + } + + /// Reads `{contributors: [...]}` or a bare list, skipping bad rows. + static List listFrom(Object? data) { + final rows = data is Map ? data['contributors'] : data; + if (rows is! List) return const []; + return [for (final row in rows) ?Contributor.fromJson(row)]; + } + + /// GitHub serves an avatar at any size through `s`; the default is 460 px. + static String sizedAvatar(String url, int px) { + if (url.isEmpty) return url; + final uri = Uri.tryParse(url); + if (uri == null) return url; + return uri + .replace(queryParameters: {...uri.queryParameters, 's': '$px'}) + .toString(); + } + + Map toJson() => { + 'login': login, + 'avatarUrl': avatarUrl, + 'htmlUrl': htmlUrl, + 'contributions': contributions, + }; +} diff --git a/lib/features/profile/presentation/pages/about_page.dart b/lib/features/profile/presentation/pages/about_page.dart index a9daa18a..4dd30e1c 100644 --- a/lib/features/profile/presentation/pages/about_page.dart +++ b/lib/features/profile/presentation/pages/about_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/profile/presentation/widgets/contributors_section.dart'; import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -203,6 +204,8 @@ class AboutSection extends StatelessWidget { ), ], ), + const SizedBox(height: 24), + const ContributorsSection(), ], ); } diff --git a/lib/features/profile/presentation/widgets/contributors_section.dart b/lib/features/profile/presentation/widgets/contributors_section.dart new file mode 100644 index 00000000..27db71ee --- /dev/null +++ b/lib/features/profile/presentation/widgets/contributors_section.dart @@ -0,0 +1,222 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/system/responsive.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/profile/data/contributors_service.dart'; +import 'package:soplay/features/profile/domain/entities/contributor.dart'; +import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; + +/// Everyone who has committed to Sozo, and a way in for the next one. +class ContributorsSection extends StatefulWidget { + const ContributorsSection({super.key}); + + static const String contributingUrl = + '${AppConstants.sozoRepoUrl}/blob/master/CONTRIBUTING.md'; + + @override + State createState() => _ContributorsSectionState(); +} + +class _ContributorsSectionState extends State { + final ContributorsService _service = getIt(); + + @override + void initState() { + super.initState(); + _service.refresh(); + } + + static Future _open(String url) async { + try { + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder?>( + valueListenable: _service.list, + builder: (context, people, _) { + final title = 'profile.contributors'.tr(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingsLabel( + people == null ? title : '$title · ${people.length}', + ), + SettingsCard( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 14, 12, 12), + child: people != null + ? _Grid(people: people, onOpen: _open) + : ValueListenableBuilder( + valueListenable: _service.failed, + builder: (_, failed, _) => failed + ? const _QuietError() + : const _Skeleton(), + ), + ), + const SettingsDivider(), + SettingsNavTile( + icon: Icons.volunteer_activism_outlined, + title: 'profile.become_contributor'.tr(), + subtitle: 'profile.become_contributor_body'.tr(), + onTap: () => _open(ContributorsSection.contributingUrl), + ), + ], + ), + ], + ); + }, + ); + } +} + +const double _avatar = 44; +const double _cell = 64; + +class _Grid extends StatelessWidget { + const _Grid({required this.people, required this.onOpen}); + + final List people; + final ValueChanged onOpen; + + @override + Widget build(BuildContext context) { + final px = (_avatar * MediaQuery.devicePixelRatioOf(context)).round(); + return Wrap( + spacing: 6, + runSpacing: 12, + children: [ + for (final c in people) + HoverTap( + borderRadius: 12, + onTap: () => onOpen(c.htmlUrl), + child: Semantics( + button: true, + label: c.login, + child: SizedBox( + width: _cell, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipOval( + child: c.avatarUrl.isEmpty + ? const _AvatarFallback() + : CachedNetworkImage( + imageUrl: Contributor.sizedAvatar( + c.avatarUrl, + px, + ), + width: _avatar, + height: _avatar, + memCacheWidth: px, + fit: BoxFit.cover, + placeholder: (_, _) => const _AvatarFallback(), + errorWidget: (_, _, _) => const _AvatarFallback(), + ), + ), + const SizedBox(height: 5), + Text( + c.login, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 10.5, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} + +class _AvatarFallback extends StatelessWidget { + const _AvatarFallback(); + + @override + Widget build(BuildContext context) { + return Container( + width: _avatar, + height: _avatar, + color: AppColors.surfaceVariant, + child: const Icon( + Icons.person_rounded, + color: AppColors.textHint, + size: 22, + ), + ); + } +} + +class _Skeleton extends StatelessWidget { + const _Skeleton(); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 6, + runSpacing: 12, + children: [ + for (var i = 0; i < 6; i++) + SizedBox( + width: _cell, + child: Column( + children: [ + Container( + width: _avatar, + height: _avatar, + decoration: const BoxDecoration( + color: AppColors.surfaceVariant, + shape: BoxShape.circle, + ), + ), + const SizedBox(height: 7), + Container( + width: 40, + height: 8, + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _QuietError extends StatelessWidget { + const _QuietError(); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Icon(Icons.cloud_off_rounded, color: AppColors.textHint, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + 'profile.contributors_error'.tr(), + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ), + ), + ], + ); + } +} diff --git a/test/core/system/star_prompt_test.dart b/test/core/system/star_prompt_test.dart new file mode 100644 index 00000000..ed1624ac --- /dev/null +++ b/test/core/system/star_prompt_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/core/system/star_prompt.dart'; + +void main() { + StarPromptState finish(StarPromptState s, int episodes) { + for (var i = 0; i < episodes; i++) { + s = StarPromptRules.episodeCompleted(s); + } + return s; + } + + group('StarPromptRules', () { + test('stays hidden before the seventh finished episode', () { + final s = finish(const StarPromptState(), 6); + expect(s.completed, 6); + expect(StarPromptRules.shouldShow(s), isFalse); + }); + + test('shows at seven', () { + expect( + StarPromptRules.shouldShow(finish(const StarPromptState(), 7)), + isTrue, + ); + }); + + test('showing it snoozes it, so it shows once per threshold', () { + final s = StarPromptRules.shown(finish(const StarPromptState(), 7)); + expect(StarPromptRules.shouldShow(s), isFalse); + expect(s.nextAt, 27); + }); + + test('Later waits for twenty more episodes', () { + var s = StarPromptRules.later(finish(const StarPromptState(), 9)); + s = finish(s, 19); + expect(StarPromptRules.shouldShow(s), isFalse); + s = finish(s, 1); + expect(s.completed, 29); + expect(StarPromptRules.shouldShow(s), isTrue); + }); + + test('answering is final', () { + var s = StarPromptRules.answered(finish(const StarPromptState(), 7)); + expect(StarPromptRules.shouldShow(s), isFalse); + s = finish(s, 500); + expect(StarPromptRules.shouldShow(s), isFalse); + expect(StarPromptRules.shouldShow(StarPromptRules.later(s)), isFalse); + }); + + test('answering before the threshold also prevents it', () { + final s = finish(StarPromptRules.answered(const StarPromptState()), 7); + expect(StarPromptRules.shouldShow(s), isFalse); + }); + }); + + group('StarPromptState storage', () { + test('round-trips through a map', () { + const s = StarPromptState(completed: 12, nextAt: 31, done: true); + expect(StarPromptState.fromMap(s.toMap()), s); + }); + + test('anything unreadable starts from scratch', () { + expect(StarPromptState.fromMap(null), const StarPromptState()); + expect(StarPromptState.fromMap('x'), const StarPromptState()); + expect( + StarPromptState.fromMap({'completed': 'a', 'done': 'yes'}), + const StarPromptState(), + ); + }); + }); +} diff --git a/test/features/profile/contributor_test.dart b/test/features/profile/contributor_test.dart new file mode 100644 index 00000000..bf2c4de8 --- /dev/null +++ b/test/features/profile/contributor_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/profile/domain/entities/contributor.dart'; + +void main() { + group('Contributor.listFrom', () { + test('reads the backend shape', () { + final list = Contributor.listFrom({ + 'contributors': [ + { + 'login': 'professorDeveloper', + 'avatarUrl': 'https://avatars.githubusercontent.com/u/1?v=4', + 'htmlUrl': 'https://github.com/professorDeveloper', + 'contributions': 252, + 'repos': ['sozo'], + }, + ], + 'updatedAt': '2026-11-07T00:00:00Z', + }); + expect(list, hasLength(1)); + expect(list.single.login, 'professorDeveloper'); + expect(list.single.contributions, 252); + }); + + test('accepts a bare list and skips rows without a login', () { + final list = Contributor.listFrom([ + {'login': 'a'}, + {'login': ''}, + {'avatarUrl': 'https://x'}, + 'junk', + null, + ]); + expect(list.map((c) => c.login), ['a']); + expect(list.single.htmlUrl, 'https://github.com/a'); + expect(list.single.avatarUrl, ''); + expect(list.single.contributions, 0); + }); + + test('only ever links to github.com', () { + final c = Contributor.fromJson({ + 'login': 'eve', + 'htmlUrl': 'https://evil.example/eve', + 'avatarUrl': 'http://insecure.example/a.png', + })!; + expect(c.htmlUrl, 'https://github.com/eve'); + expect(c.avatarUrl, ''); + }); + + test('anything else is an empty list', () { + expect(Contributor.listFrom(null), isEmpty); + expect(Contributor.listFrom({'contributors': 'x'}), isEmpty); + }); + + test('survives its own cache format', () { + final c = Contributor.fromJson({'login': 'a', 'contributions': 3})!; + final back = Contributor.listFrom([c.toJson()]).single; + expect(back.login, 'a'); + expect(back.contributions, 3); + }); + }); + + test('sizedAvatar keeps existing query and sets s', () { + expect( + Contributor.sizedAvatar('https://avatars.githubusercontent.com/u/1?v=4', 96), + 'https://avatars.githubusercontent.com/u/1?v=4&s=96', + ); + expect(Contributor.sizedAvatar('', 96), ''); + }); +} From 0d69c7eadf3ed4e13081076913189070c045fc51 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:42:10 +0500 Subject: [PATCH 08/16] Drop a const around a theme colour in the contributors skeleton --- .../profile/presentation/widgets/contributors_section.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/profile/presentation/widgets/contributors_section.dart b/lib/features/profile/presentation/widgets/contributors_section.dart index 27db71ee..86875e57 100644 --- a/lib/features/profile/presentation/widgets/contributors_section.dart +++ b/lib/features/profile/presentation/widgets/contributors_section.dart @@ -179,7 +179,7 @@ class _Skeleton extends StatelessWidget { Container( width: _avatar, height: _avatar, - decoration: const BoxDecoration( + decoration: BoxDecoration( color: AppColors.surfaceVariant, shape: BoxShape.circle, ), From 5c6d1799d7696455f4633386c374c1df5a6a7db4 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:45:00 +0500 Subject: [PATCH 09/16] Hide intl's TextDirection in the overlay; drop a redundant null check --- lib/features/manga/presentation/pages/reader_page.dart | 2 +- .../manga/presentation/widgets/page_translation_overlay.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/features/manga/presentation/pages/reader_page.dart b/lib/features/manga/presentation/pages/reader_page.dart index 4d15d11f..073e596d 100644 --- a/lib/features/manga/presentation/pages/reader_page.dart +++ b/lib/features/manga/presentation/pages/reader_page.dart @@ -2324,7 +2324,7 @@ class _ReaderPageState extends State { if (mounted) setState(() => _translating.remove(key)); } } - if (!mounted || translation == null) return; + if (!mounted) return; if (auto) { _overlays.apply(key); return; diff --git a/lib/features/manga/presentation/widgets/page_translation_overlay.dart b/lib/features/manga/presentation/widgets/page_translation_overlay.dart index af10dabb..a524c356 100644 --- a/lib/features/manga/presentation/widgets/page_translation_overlay.dart +++ b/lib/features/manga/presentation/widgets/page_translation_overlay.dart @@ -1,6 +1,6 @@ import 'dart:math' as math; -import 'package:easy_localization/easy_localization.dart'; +import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/manga/domain/translate/page_translation.dart'; From e3c6560fba8b895f6c1394178c3258d26c995ad7 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:47:16 +0500 Subject: [PATCH 10/16] Add episode comments, spoiler hiding and timed reactions The player gets React and Comments buttons. Comments opens the episode's own thread (side panel on landscape, TV and desktop, bottom sheet on a portrait phone). React opens an eight-emoji bar with a short text option and posts at the current second. Other viewers' reactions pop up in a lane at the start edge, grouped and capped at three on screen, with a heatmap strip over the seek bar. Both can be turned off in player settings. Comments can be marked as spoilers and are blurred until tapped. A new setting, on by default, also blurs comments about episodes the viewer has not finished, using watch history. The episode list shows a comment count per episode when there are any. Title-level comments, and apps without this change, behave as before. --- assets/translations/ar.json | 21 +- assets/translations/ckb.json | 21 +- assets/translations/de.json | 21 +- assets/translations/en.json | 21 +- assets/translations/es.json | 21 +- assets/translations/fr.json | 21 +- assets/translations/hi.json | 21 +- assets/translations/id.json | 21 +- assets/translations/ku.json | 21 +- assets/translations/nl.json | 21 +- assets/translations/pt.json | 21 +- assets/translations/ru.json | 21 +- assets/translations/tr.json | 21 +- assets/translations/uz.json | 21 +- assets/translations/vi.json | 21 +- assets/translations/yue.json | 21 +- lib/core/di/injection.dart | 4 + .../datasources/comments_data_source.dart | 78 ++++++ .../timed_reactions_data_source.dart | 47 ++++ .../comments/data/models/comment_model.dart | 6 + .../comments_repository_impl.dart | 58 +++- .../comments/data/social_playback_prefs.dart | 37 +++ .../domain/entities/comment_entity.dart | 15 + lib/features/comments/domain/episode_key.dart | 31 +++ .../episode_comments_repository.dart | 39 +++ .../comments/domain/spoiler_rules.dart | 74 +++++ .../comments/domain/timed_reactions.dart | 260 ++++++++++++++++++ .../blocs/comments_bloc/comments_bloc.dart | 76 ++++- .../blocs/comments_bloc/comments_event.dart | 23 +- .../presentation/widgets/comment_compose.dart | 68 +++++ .../presentation/widgets/comments_panel.dart | 101 ++++++- .../widgets/episode_comments_sheet.dart | 131 +++++++++ .../widgets/reaction_heatmap.dart | 48 ++++ .../presentation/widgets/reaction_lane.dart | 150 ++++++++++ .../presentation/widgets/reaction_picker.dart | 159 +++++++++++ .../presentation/widgets/spoiler_gate.dart | 86 ++++++ .../presentation/pages/episodes_page.dart | 97 +++++++ .../pages/player_page.controls.dart | 1 + .../presentation/pages/player_page.dart | 14 + .../presentation/pages/player_page.media.dart | 3 + .../pages/player_page.reactions.dart | 241 ++++++++++++++++ .../pages/player_settings_page.dart | 28 ++ test/features/comments/episode_key_test.dart | 35 +++ .../comments/reaction_scheduler_test.dart | 146 ++++++++++ .../features/comments/spoiler_rules_test.dart | 110 ++++++++ 45 files changed, 2431 insertions(+), 71 deletions(-) create mode 100644 lib/features/comments/data/datasources/timed_reactions_data_source.dart create mode 100644 lib/features/comments/data/social_playback_prefs.dart create mode 100644 lib/features/comments/domain/episode_key.dart create mode 100644 lib/features/comments/domain/repositories/episode_comments_repository.dart create mode 100644 lib/features/comments/domain/spoiler_rules.dart create mode 100644 lib/features/comments/domain/timed_reactions.dart create mode 100644 lib/features/comments/presentation/widgets/episode_comments_sheet.dart create mode 100644 lib/features/comments/presentation/widgets/reaction_heatmap.dart create mode 100644 lib/features/comments/presentation/widgets/reaction_lane.dart create mode 100644 lib/features/comments/presentation/widgets/reaction_picker.dart create mode 100644 lib/features/comments/presentation/widgets/spoiler_gate.dart create mode 100644 lib/features/detail/presentation/pages/player_page.reactions.dart create mode 100644 test/features/comments/episode_key_test.dart create mode 100644 test/features/comments/reaction_scheduler_test.dart create mode 100644 test/features/comments/spoiler_rules_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 6e6bbb79..f8c03386 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -565,7 +565,15 @@ "reconnecting": "انقطع الاتصال — جارٍ إعادة الاتصال…", "all_servers_failed": "لم يتمكن أي خادم لهذا العنوان من تشغيله. حاول لاحقًا أو اختر مصدرًا آخر.", "shortcut_pip": "مشغّل مصغّر (Shift: فوق التطبيقات الأخرى)", - "mini_popout_hint": "مشغّل مصغّر · اضغط مطولًا ليطفو فوق التطبيقات الأخرى" + "mini_popout_hint": "مشغّل مصغّر · اضغط مطولًا ليطفو فوق التطبيقات الأخرى", + "react": "تفاعل", + "comments": "التعليقات", + "episode_comments": "تعليقات الحلقة {}", + "reaction_text": "اكتب تفاعلاً", + "reaction_text_hint": "اكتب شيئاً قصيراً…", + "reaction_wait": "انتظر بضع ثوانٍ قبل التفاعل مجدداً", + "reaction_limit": "وصلت إلى حد التفاعلات لهذه الحلقة", + "reaction_failed": "تعذّر إرسال التفاعل" }, "profile": { "title": "حسابي", @@ -751,7 +759,11 @@ "library_update": "التحقق من الحلقات الجديدة", "library_update_desc": "يتفقد العناوين التي تتابعها ويُعلمك عند صدور جديد", "library_update_every": "كل {} ساعات", - "library_update_daily": "مرة يوميًا" + "library_update_daily": "مرة يوميًا", + "reactions_overlay": "تفاعلات المشاهدين", + "reactions_overlay_desc": "عرض تفاعلات المشاهدين الآخرين في لحظة إرسالها، وأماكن تجمّعها على شريط التقديم", + "hide_spoilers": "إخفاء الحرق للحلقات التي لم أشاهدها", + "hide_spoilers_desc": "تبقى التعليقات عن حلقة لم تُكملها ضبابية حتى تضغط عليها" }, "appearance": { "title": "المظهر", @@ -1525,7 +1537,10 @@ "show_more": "عرض المزيد", "delete_confirm_title": "حذف هذا التعليق؟", "delete_confirm_body": "لا يمكن التراجع عن هذا الإجراء.", - "sign_in_prompt": "سجّل الدخول لكتابة تعليق" + "sign_in_prompt": "سجّل الدخول لكتابة تعليق", + "spoiler": "حرق", + "spoiler_hidden": "حرق للأحداث · اضغط للعرض", + "unwatched_hidden": "لم تشاهده بعد · اضغط للعرض" }, "downloads": { "view_titles": "العناوين", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 6cef5071..d4b40da9 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -565,7 +565,15 @@ "reconnecting": "پەیوەندی پچڕا — دووبارە پەیوەندی دەکرێتەوە…", "all_servers_failed": "هیچ سێرڤەرێکی ئەم ناونیشانە نەیتوانی لێیبدات. دواتر هەوڵ بدەرەوە یان سەرچاوەیەکی تر هەڵبژێرە.", "shortcut_pip": "لێدەری بچووک (Shift: لەسەر ئەپەکانی تر)", - "mini_popout_hint": "لێدەری بچووک · پەنجە ڕابگرە بۆ ئەوەی لەسەر ئەپەکانی تر بمێنێتەوە" + "mini_popout_hint": "لێدەری بچووک · پەنجە ڕابگرە بۆ ئەوەی لەسەر ئەپەکانی تر بمێنێتەوە", + "react": "کاردانەوە", + "comments": "لێدوانەکان", + "episode_comments": "لێدوانەکانی ئەڵقەی {}", + "reaction_text": "کاردانەوەیەک بنووسە", + "reaction_text_hint": "شتێکی کورت بنووسە…", + "reaction_wait": "چەند چرکەیەک چاوەڕێ بکە پێش کاردانەوەی دووبارە", + "reaction_limit": "گەیشتیتە سنووری کاردانەوەکان بۆ ئەم ئەڵقەیە", + "reaction_failed": "نەتوانرا کاردانەوەکە بنێردرێت" }, "profile": { "title": "پرۆفایل", @@ -751,7 +759,11 @@ "library_update": "پشکنین بۆ ئەڵقە نوێکان", "library_update_desc": "سەیری ئەو ناونیشانانە دەکات کە بەدوایاندا دەچیت و کاتێک نوێیەک دەرچوو ئاگادارت دەکاتەوە", "library_update_every": "هەر {} کاتژمێر جارێک", - "library_update_daily": "ڕۆژی یەک جار" + "library_update_daily": "ڕۆژی یەک جار", + "reactions_overlay": "کاردانەوەی بینەران", + "reactions_overlay_desc": "پیشاندانی کاردانەوەی بینەرانی تر لەو ساتەی نێردران، و شوێنی کۆبوونەوەیان لەسەر هێڵی کات", + "hide_spoilers": "شاردنەوەی سپۆیلەر بۆ ئەو ئەڵقانەی نەمبینیون", + "hide_spoilers_desc": "لێدوانەکان دەربارەی ئەڵقەیەک کە تەواوت نەکردووە لێڵ دەمێننەوە تا دەستیان لێ دەدەیت" }, "appearance": { "title": "ڕووکار", @@ -1525,7 +1537,10 @@ "show_more": "زیاتر پیشان بدە", "delete_confirm_title": "ئەم لێدوانە بسڕیتەوە؟", "delete_confirm_body": "ئەم کارە ناگەڕێتەوە.", - "sign_in_prompt": "بچۆ ژوورەوە بۆ نووسینی لێدوان" + "sign_in_prompt": "بچۆ ژوورەوە بۆ نووسینی لێدوان", + "spoiler": "سپۆیلەر", + "spoiler_hidden": "سپۆیلەر · بۆ پیشاندان دەستی لێ بدە", + "unwatched_hidden": "هێشتا نەتبینیوە · بۆ پیشاندان دەستی لێ بدە" }, "downloads": { "view_titles": "ناونیشانەکان", diff --git a/assets/translations/de.json b/assets/translations/de.json index 0970f03c..b0ed6a0f 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -565,7 +565,15 @@ "reconnecting": "Verbindung verloren — neu verbinden…", "all_servers_failed": "Keiner der Server dieses Titels konnte ihn abspielen. Versuch es später oder wähl eine andere Quelle.", "shortcut_pip": "Mini-Player (Umschalt: über anderen Apps)", - "mini_popout_hint": "Mini-Player · lange drücken, um über anderen Apps zu schweben" + "mini_popout_hint": "Mini-Player · lange drücken, um über anderen Apps zu schweben", + "react": "Reagieren", + "comments": "Kommentare", + "episode_comments": "Kommentare zu Folge {}", + "reaction_text": "Reaktion schreiben", + "reaction_text_hint": "Kurz etwas sagen…", + "reaction_wait": "Warte ein paar Sekunden, bevor du erneut reagierst", + "reaction_limit": "Du hast das Reaktionslimit für diese Folge erreicht", + "reaction_failed": "Reaktion konnte nicht gesendet werden" }, "profile": { "title": "Profil", @@ -751,7 +759,11 @@ "library_update": "Nach neuen Folgen suchen", "library_update_desc": "Prüft deine gefolgten Titel und meldet neue Folgen", "library_update_every": "Alle {} Stunden", - "library_update_daily": "Einmal täglich" + "library_update_daily": "Einmal täglich", + "reactions_overlay": "Zuschauerreaktionen", + "reactions_overlay_desc": "Reaktionen anderer Zuschauer im Moment ihres Sendens zeigen, und wo sie sich auf der Zeitleiste häufen", + "hide_spoilers": "Spoiler zu Folgen ausblenden, die ich nicht gesehen habe", + "hide_spoilers_desc": "Kommentare zu einer Folge, die du nicht zu Ende gesehen hast, bleiben verschwommen, bis du sie antippst" }, "appearance": { "title": "Darstellung", @@ -1525,7 +1537,10 @@ "show_more": "Mehr anzeigen", "delete_confirm_title": "Diesen Kommentar löschen?", "delete_confirm_body": "Das lässt sich nicht rückgängig machen.", - "sign_in_prompt": "Melde dich an, um zu kommentieren" + "sign_in_prompt": "Melde dich an, um zu kommentieren", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · zum Anzeigen tippen", + "unwatched_hidden": "Noch nicht gesehen · zum Anzeigen tippen" }, "downloads": { "view_titles": "Titel", diff --git a/assets/translations/en.json b/assets/translations/en.json index 8fbca5aa..b463cac4 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -565,7 +565,15 @@ "reconnecting": "Connection lost — reconnecting…", "all_servers_failed": "None of this title's servers would play it. Try again later, or pick another source.", "shortcut_pip": "Mini player (Shift: above other apps)", - "mini_popout_hint": "Mini player · long-press to float above other apps" + "mini_popout_hint": "Mini player · long-press to float above other apps", + "react": "React", + "comments": "Comments", + "episode_comments": "Episode {} comments", + "reaction_text": "Write a reaction", + "reaction_text_hint": "Say something short…", + "reaction_wait": "Wait a few seconds before reacting again", + "reaction_limit": "You've reached the reaction limit for this episode", + "reaction_failed": "Couldn't send the reaction" }, "profile": { "title": "Profile", @@ -751,7 +759,11 @@ "library_update": "Check for new episodes", "library_update_desc": "Looks at the titles you follow and notifies you when a new one is out", "library_update_every": "Every {} hours", - "library_update_daily": "Once a day" + "library_update_daily": "Once a day", + "reactions_overlay": "Viewer reactions", + "reactions_overlay_desc": "Show other viewers' reactions at the moment they were sent, and where they cluster on the seek bar", + "hide_spoilers": "Hide spoilers for episodes I haven't watched", + "hide_spoilers_desc": "Comments about an episode you haven't finished stay blurred until you tap them" }, "appearance": { "title": "Appearance", @@ -1525,7 +1537,10 @@ "show_more": "Show more", "delete_confirm_title": "Delete this comment?", "delete_confirm_body": "This action cannot be undone.", - "sign_in_prompt": "Sign in to leave a comment" + "sign_in_prompt": "Sign in to leave a comment", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · tap to reveal", + "unwatched_hidden": "Not watched yet · tap to reveal" }, "downloads": { "view_titles": "Titles", diff --git a/assets/translations/es.json b/assets/translations/es.json index 319ddb48..8ff4c886 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -565,7 +565,15 @@ "reconnecting": "Conexión perdida — reconectando…", "all_servers_failed": "Ninguno de los servidores de este título pudo reproducirlo. Inténtalo más tarde o elige otra fuente.", "shortcut_pip": "Minirreproductor (Mayús: sobre otras apps)", - "mini_popout_hint": "Minirreproductor · mantén pulsado para que flote sobre otras apps" + "mini_popout_hint": "Minirreproductor · mantén pulsado para que flote sobre otras apps", + "react": "Reaccionar", + "comments": "Comentarios", + "episode_comments": "Comentarios del episodio {}", + "reaction_text": "Escribir una reacción", + "reaction_text_hint": "Di algo breve…", + "reaction_wait": "Espera unos segundos antes de volver a reaccionar", + "reaction_limit": "Has alcanzado el límite de reacciones de este episodio", + "reaction_failed": "No se pudo enviar la reacción" }, "profile": { "title": "Perfil", @@ -751,7 +759,11 @@ "library_update": "Buscar episodios nuevos", "library_update_desc": "Revisa los títulos que sigues y te avisa cuando sale uno nuevo", "library_update_every": "Cada {} horas", - "library_update_daily": "Una vez al día" + "library_update_daily": "Una vez al día", + "reactions_overlay": "Reacciones de espectadores", + "reactions_overlay_desc": "Mostrar las reacciones de otros espectadores en el momento en que se enviaron y dónde se agrupan en la barra de progreso", + "hide_spoilers": "Ocultar spoilers de episodios que no he visto", + "hide_spoilers_desc": "Los comentarios sobre un episodio que no has terminado se ven borrosos hasta que los tocas" }, "appearance": { "title": "Apariencia", @@ -1525,7 +1537,10 @@ "show_more": "Ver más", "delete_confirm_title": "¿Eliminar este comentario?", "delete_confirm_body": "Esta acción no se puede deshacer.", - "sign_in_prompt": "Inicia sesión para dejar un comentario" + "sign_in_prompt": "Inicia sesión para dejar un comentario", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · toca para ver", + "unwatched_hidden": "Aún no lo has visto · toca para ver" }, "downloads": { "view_titles": "Títulos", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 599703a9..bca3f45a 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -565,7 +565,15 @@ "reconnecting": "Connexion perdue — reconnexion…", "all_servers_failed": "Aucun serveur de ce titre n’a pu le lire. Réessayez plus tard ou choisissez une autre source.", "shortcut_pip": "Mini-lecteur (Maj : au-dessus des autres apps)", - "mini_popout_hint": "Mini-lecteur · appui long pour flotter au-dessus des autres apps" + "mini_popout_hint": "Mini-lecteur · appui long pour flotter au-dessus des autres apps", + "react": "Réagir", + "comments": "Commentaires", + "episode_comments": "Commentaires de l'épisode {}", + "reaction_text": "Écrire une réaction", + "reaction_text_hint": "Un mot rapide…", + "reaction_wait": "Attendez quelques secondes avant de réagir à nouveau", + "reaction_limit": "Vous avez atteint la limite de réactions pour cet épisode", + "reaction_failed": "Impossible d'envoyer la réaction" }, "profile": { "title": "Profil", @@ -751,7 +759,11 @@ "library_update": "Vérifier les nouveaux épisodes", "library_update_desc": "Vérifie les titres suivis et vous prévient à chaque nouveauté", "library_update_every": "Toutes les {} heures", - "library_update_daily": "Une fois par jour" + "library_update_daily": "Une fois par jour", + "reactions_overlay": "Réactions des spectateurs", + "reactions_overlay_desc": "Afficher les réactions des autres spectateurs au moment où elles ont été envoyées, et où elles se concentrent sur la barre de lecture", + "hide_spoilers": "Masquer les spoilers des épisodes que je n'ai pas vus", + "hide_spoilers_desc": "Les commentaires sur un épisode que vous n'avez pas terminé restent floutés jusqu'à ce que vous les touchiez" }, "appearance": { "title": "Apparence", @@ -1525,7 +1537,10 @@ "show_more": "Afficher plus", "delete_confirm_title": "Supprimer ce commentaire ?", "delete_confirm_body": "Cette action est irréversible.", - "sign_in_prompt": "Connecte-toi pour laisser un commentaire" + "sign_in_prompt": "Connecte-toi pour laisser un commentaire", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · touchez pour afficher", + "unwatched_hidden": "Pas encore vu · touchez pour afficher" }, "downloads": { "view_titles": "Titres", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 12d0cb70..19eb39a3 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -565,7 +565,15 @@ "reconnecting": "कनेक्शन टूट गया — फिर से कनेक्ट हो रहा है…", "all_servers_failed": "इस टाइटल का कोई भी सर्वर इसे नहीं चला पाया। बाद में फिर से कोशिश करें, या कोई दूसरा सोर्स चुनें।", "shortcut_pip": "मिनी प्लेयर (Shift: दूसरे ऐप के ऊपर)", - "mini_popout_hint": "मिनी प्लेयर · दूसरे ऐप के ऊपर रखने के लिए देर तक दबाएँ" + "mini_popout_hint": "मिनी प्लेयर · दूसरे ऐप के ऊपर रखने के लिए देर तक दबाएँ", + "react": "प्रतिक्रिया दें", + "comments": "कमेंट्स", + "episode_comments": "एपिसोड {} के कमेंट्स", + "reaction_text": "प्रतिक्रिया लिखें", + "reaction_text_hint": "कुछ छोटा लिखें…", + "reaction_wait": "फिर से प्रतिक्रिया देने से पहले कुछ सेकंड रुकें", + "reaction_limit": "इस एपिसोड के लिए प्रतिक्रियाओं की सीमा पूरी हो गई", + "reaction_failed": "प्रतिक्रिया नहीं भेजी जा सकी" }, "profile": { "title": "प्रोफ़ाइल", @@ -751,7 +759,11 @@ "library_update": "नए एपिसोड चेक करें", "library_update_desc": "आपके फ़ॉलो किए गए टाइटल देखता है और नया एपिसोड आने पर आपको नोटिफ़िकेशन भेजता है", "library_update_every": "हर {} घंटे में", - "library_update_daily": "दिन में एक बार" + "library_update_daily": "दिन में एक बार", + "reactions_overlay": "दर्शकों की प्रतिक्रियाएं", + "reactions_overlay_desc": "दूसरे दर्शकों की प्रतिक्रियाएं उसी पल दिखाएं जब भेजी गई थीं, और सीक बार पर वे कहां ज़्यादा हैं", + "hide_spoilers": "जो एपिसोड नहीं देखे उनके स्पॉइलर छिपाएं", + "hide_spoilers_desc": "जो एपिसोड पूरा नहीं देखा उसके कमेंट्स तब तक धुंधले रहेंगे जब तक आप उन पर टैप न करें" }, "appearance": { "title": "दिखावट", @@ -1525,7 +1537,10 @@ "show_more": "और दिखाएँ", "delete_confirm_title": "यह कमेंट हटाएँ?", "delete_confirm_body": "यह कार्रवाई वापस नहीं ली जा सकती।", - "sign_in_prompt": "कमेंट करने के लिए साइन इन करें" + "sign_in_prompt": "कमेंट करने के लिए साइन इन करें", + "spoiler": "स्पॉइलर", + "spoiler_hidden": "स्पॉइलर · देखने के लिए टैप करें", + "unwatched_hidden": "अभी तक नहीं देखा · देखने के लिए टैप करें" }, "downloads": { "view_titles": "टाइटल", diff --git a/assets/translations/id.json b/assets/translations/id.json index 28442646..aad01655 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -565,7 +565,15 @@ "reconnecting": "Koneksi terputus — menyambungkan ulang…", "all_servers_failed": "Tidak ada server judul ini yang bisa memutarnya. Coba lagi nanti atau pilih sumber lain.", "shortcut_pip": "Pemutar mini (Shift: di atas aplikasi lain)", - "mini_popout_hint": "Pemutar mini · tekan lama agar melayang di atas aplikasi lain" + "mini_popout_hint": "Pemutar mini · tekan lama agar melayang di atas aplikasi lain", + "react": "Bereaksi", + "comments": "Komentar", + "episode_comments": "Komentar episode {}", + "reaction_text": "Tulis reaksi", + "reaction_text_hint": "Tulis sesuatu yang singkat…", + "reaction_wait": "Tunggu beberapa detik sebelum bereaksi lagi", + "reaction_limit": "Kamu sudah mencapai batas reaksi untuk episode ini", + "reaction_failed": "Gagal mengirim reaksi" }, "profile": { "title": "Profil", @@ -751,7 +759,11 @@ "library_update": "Periksa episode baru", "library_update_desc": "Memeriksa judul yang kamu ikuti dan memberi tahu saat ada yang baru", "library_update_every": "Setiap {} jam", - "library_update_daily": "Sekali sehari" + "library_update_daily": "Sekali sehari", + "reactions_overlay": "Reaksi penonton", + "reactions_overlay_desc": "Tampilkan reaksi penonton lain pada saat dikirim, dan di mana reaksi itu berkumpul di bilah waktu", + "hide_spoilers": "Sembunyikan spoiler untuk episode yang belum kutonton", + "hide_spoilers_desc": "Komentar tentang episode yang belum selesai kamu tonton tetap buram sampai kamu mengetuknya" }, "appearance": { "title": "Tampilan", @@ -1525,7 +1537,10 @@ "show_more": "Tampilkan lebih banyak", "delete_confirm_title": "Hapus komentar ini?", "delete_confirm_body": "Tindakan ini tidak bisa dibatalkan.", - "sign_in_prompt": "Masuk untuk meninggalkan komentar" + "sign_in_prompt": "Masuk untuk meninggalkan komentar", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · ketuk untuk melihat", + "unwatched_hidden": "Belum ditonton · ketuk untuk melihat" }, "downloads": { "view_titles": "Judul", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index e876ed5b..ac67d7fc 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -565,7 +565,15 @@ "reconnecting": "Girêdan qut bû — ji nû ve tê girêdan…", "all_servers_failed": "Tu serverê vê sernavê nikarî wê bilîze. Paşê dîsa biceribîne an çavkaniyek din hilbijêre.", "shortcut_pip": "Lîstikvana biçûk (Shift: li ser sepanên din)", - "mini_popout_hint": "Lîstikvana biçûk · dirêj bitikîne da ku li ser sepanên din bisekine" + "mini_popout_hint": "Lîstikvana biçûk · dirêj bitikîne da ku li ser sepanên din bisekine", + "react": "Bertek", + "comments": "Şîrove", + "episode_comments": "Şîroveyên beşa {}", + "reaction_text": "Bertekek binivîse", + "reaction_text_hint": "Tiştekî kurt binivîse…", + "reaction_wait": "Berî ku dîsa bertek bidî çend saniyan bisekine", + "reaction_limit": "Te sînorê berteka vê beşê tije kir", + "reaction_failed": "Bertek nehat şandin" }, "profile": { "title": "Profîl", @@ -751,7 +759,11 @@ "library_update": "Li beşên nû kontrol bike", "library_update_desc": "Li sernavên ku tu dişopînî dinêre û dema yekî nû derkeve te agahdar dike", "library_update_every": "Her {} saetan", - "library_update_daily": "Rojê carekê" + "library_update_daily": "Rojê carekê", + "reactions_overlay": "Bertekên temaşevanan", + "reactions_overlay_desc": "Bertekên temaşevanên din di wê kêliyê de ku hatine şandin nîşan bide, û cihê ku li ser darika demê kom dibin", + "hide_spoilers": "Spoilerên beşên ku min temaşe nekirine veşêre", + "hide_spoilers_desc": "Şîroveyên li ser beşeke ku te neqedandiye heta ku tu wan bitikînî şêlû dimînin" }, "appearance": { "title": "Xuyang", @@ -1525,7 +1537,10 @@ "show_more": "Bêtir nîşan bide", "delete_confirm_title": "Vê şîroveyê jê bibî?", "delete_confirm_body": "Ev çalakî nayê vegerandin.", - "sign_in_prompt": "Ji bo şîroveyekê têkeve" + "sign_in_prompt": "Ji bo şîroveyekê têkeve", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · ji bo dîtinê bitikîne", + "unwatched_hidden": "Hîn nehatiye temaşekirin · ji bo dîtinê bitikîne" }, "downloads": { "view_titles": "Sernav", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 8ca39779..696702f3 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -565,7 +565,15 @@ "reconnecting": "Verbinding verbroken — opnieuw verbinden…", "all_servers_failed": "Geen van de servers van deze titel kon hem afspelen. Probeer het later of kies een andere bron.", "shortcut_pip": "Minispeler (Shift: boven andere apps)", - "mini_popout_hint": "Minispeler · lang indrukken om boven andere apps te zweven" + "mini_popout_hint": "Minispeler · lang indrukken om boven andere apps te zweven", + "react": "Reageren", + "comments": "Reacties", + "episode_comments": "Reacties op aflevering {}", + "reaction_text": "Reactie typen", + "reaction_text_hint": "Iets korts…", + "reaction_wait": "Wacht een paar seconden voordat je opnieuw reageert", + "reaction_limit": "Je hebt het reactielimiet voor deze aflevering bereikt", + "reaction_failed": "Kon de reactie niet versturen" }, "profile": { "title": "Profiel", @@ -751,7 +759,11 @@ "library_update": "Zoeken naar nieuwe afleveringen", "library_update_desc": "Controleert de titels die je volgt en meldt nieuwe afleveringen", "library_update_every": "Elke {} uur", - "library_update_daily": "Eén keer per dag" + "library_update_daily": "Eén keer per dag", + "reactions_overlay": "Reacties van kijkers", + "reactions_overlay_desc": "Reacties van andere kijkers tonen op het moment dat ze zijn verstuurd, en waar ze zich op de tijdbalk ophopen", + "hide_spoilers": "Spoilers verbergen voor afleveringen die ik niet heb gezien", + "hide_spoilers_desc": "Reacties over een aflevering die je nog niet hebt uitgekeken blijven wazig tot je erop tikt" }, "appearance": { "title": "Weergave", @@ -1525,7 +1537,10 @@ "show_more": "Meer tonen", "delete_confirm_title": "Deze reactie verwijderen?", "delete_confirm_body": "Dit kan niet ongedaan worden gemaakt.", - "sign_in_prompt": "Log in om een reactie te plaatsen" + "sign_in_prompt": "Log in om een reactie te plaatsen", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · tik om te tonen", + "unwatched_hidden": "Nog niet gezien · tik om te tonen" }, "downloads": { "view_titles": "Titels", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index f79b5482..26509f48 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -565,7 +565,15 @@ "reconnecting": "Conexão perdida — reconectando…", "all_servers_failed": "Nenhum servidor deste título conseguiu reproduzi-lo. Tente mais tarde ou escolha outra fonte.", "shortcut_pip": "Miniplayer (Shift: acima de outros apps)", - "mini_popout_hint": "Miniplayer · toque e segure para flutuar acima de outros apps" + "mini_popout_hint": "Miniplayer · toque e segure para flutuar acima de outros apps", + "react": "Reagir", + "comments": "Comentários", + "episode_comments": "Comentários do episódio {}", + "reaction_text": "Escrever uma reação", + "reaction_text_hint": "Diga algo curto…", + "reaction_wait": "Espere alguns segundos antes de reagir de novo", + "reaction_limit": "Você atingiu o limite de reações deste episódio", + "reaction_failed": "Não foi possível enviar a reação" }, "profile": { "title": "Perfil", @@ -751,7 +759,11 @@ "library_update": "Procurar novos episódios", "library_update_desc": "Verifica os títulos que você segue e avisa quando sai um novo", "library_update_every": "A cada {} horas", - "library_update_daily": "Uma vez por dia" + "library_update_daily": "Uma vez por dia", + "reactions_overlay": "Reações dos espectadores", + "reactions_overlay_desc": "Mostrar as reações de outros espectadores no momento em que foram enviadas e onde se concentram na barra de progresso", + "hide_spoilers": "Ocultar spoilers de episódios que não assisti", + "hide_spoilers_desc": "Comentários sobre um episódio que você não terminou ficam desfocados até você tocar neles" }, "appearance": { "title": "Aparência", @@ -1525,7 +1537,10 @@ "show_more": "Ver mais", "delete_confirm_title": "Excluir este comentário?", "delete_confirm_body": "Esta ação não pode ser desfeita.", - "sign_in_prompt": "Entre para deixar um comentário" + "sign_in_prompt": "Entre para deixar um comentário", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · toque para ver", + "unwatched_hidden": "Ainda não assistido · toque para ver" }, "downloads": { "view_titles": "Títulos", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index c85aef09..7323ab7c 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -565,7 +565,15 @@ "reconnecting": "Соединение потеряно — переподключение…", "all_servers_failed": "Ни один сервер этого тайтла не смог его воспроизвести. Попробуйте позже или выберите другой источник.", "shortcut_pip": "Мини-плеер (Shift: поверх других приложений)", - "mini_popout_hint": "Мини-плеер · удерживайте, чтобы закрепить поверх других приложений" + "mini_popout_hint": "Мини-плеер · удерживайте, чтобы закрепить поверх других приложений", + "react": "Реакция", + "comments": "Комментарии", + "episode_comments": "Комментарии к серии {}", + "reaction_text": "Написать реакцию", + "reaction_text_hint": "Коротко…", + "reaction_wait": "Подождите несколько секунд перед следующей реакцией", + "reaction_limit": "Лимит реакций для этой серии исчерпан", + "reaction_failed": "Не удалось отправить реакцию" }, "profile": { "title": "Профиль", @@ -751,7 +759,11 @@ "library_update": "Проверять новые серии", "library_update_desc": "Проверяет отслеживаемые тайтлы и сообщает о новых сериях", "library_update_every": "Каждые {} ч", - "library_update_daily": "Раз в день" + "library_update_daily": "Раз в день", + "reactions_overlay": "Реакции зрителей", + "reactions_overlay_desc": "Показывать реакции других зрителей в момент, когда их отправили, и где они собираются на шкале перемотки", + "hide_spoilers": "Скрывать спойлеры к непросмотренным сериям", + "hide_spoilers_desc": "Комментарии к серии, которую вы не досмотрели, остаются размытыми, пока вы на них не нажмёте" }, "appearance": { "title": "Внешний вид", @@ -1525,7 +1537,10 @@ "show_more": "Показать ещё", "delete_confirm_title": "Удалить этот комментарий?", "delete_confirm_body": "Это действие нельзя отменить.", - "sign_in_prompt": "Войдите, чтобы оставить комментарий" + "sign_in_prompt": "Войдите, чтобы оставить комментарий", + "spoiler": "Спойлер", + "spoiler_hidden": "Спойлер · нажмите, чтобы показать", + "unwatched_hidden": "Ещё не просмотрено · нажмите, чтобы показать" }, "downloads": { "view_titles": "Тайтлы", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index f22f299e..fe36315b 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -565,7 +565,15 @@ "reconnecting": "Bağlantı koptu — yeniden bağlanılıyor…", "all_servers_failed": "Bu içeriğin hiçbir sunucusu oynatamadı. Daha sonra tekrar dene veya başka bir kaynak seç.", "shortcut_pip": "Mini oynatıcı (Shift: diğer uygulamaların üstünde)", - "mini_popout_hint": "Mini oynatıcı · diğer uygulamaların üstünde kalması için basılı tut" + "mini_popout_hint": "Mini oynatıcı · diğer uygulamaların üstünde kalması için basılı tut", + "react": "Tepki ver", + "comments": "Yorumlar", + "episode_comments": "{}. bölüm yorumları", + "reaction_text": "Tepki yaz", + "reaction_text_hint": "Kısa bir şey yaz…", + "reaction_wait": "Tekrar tepki vermeden önce birkaç saniye bekle", + "reaction_limit": "Bu bölüm için tepki sınırına ulaştın", + "reaction_failed": "Tepki gönderilemedi" }, "profile": { "title": "Profil", @@ -751,7 +759,11 @@ "library_update": "Yeni bölümleri denetle", "library_update_desc": "Takip ettiğin yapımları denetler, yenisi çıkınca haber verir", "library_update_every": "{} saatte bir", - "library_update_daily": "Günde bir" + "library_update_daily": "Günde bir", + "reactions_overlay": "İzleyici tepkileri", + "reactions_overlay_desc": "Diğer izleyicilerin tepkilerini gönderildikleri anda ve ilerleme çubuğunda yoğunlaştıkları yerleri göster", + "hide_spoilers": "İzlemediğim bölümlerin spoilerlarını gizle", + "hide_spoilers_desc": "Bitirmediğin bir bölümle ilgili yorumlar sen dokunana kadar bulanık kalır" }, "appearance": { "title": "Görünüm", @@ -1525,7 +1537,10 @@ "show_more": "Daha fazla göster", "delete_confirm_title": "Bu yorum silinsin mi?", "delete_confirm_body": "Bu işlem geri alınamaz.", - "sign_in_prompt": "Yorum bırakmak için giriş yap" + "sign_in_prompt": "Yorum bırakmak için giriş yap", + "spoiler": "Spoiler", + "spoiler_hidden": "Spoiler · görmek için dokun", + "unwatched_hidden": "Henüz izlemedin · görmek için dokun" }, "downloads": { "view_titles": "Başlıklar", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index a0ec0494..d41eaba1 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -565,7 +565,15 @@ "reconnecting": "Aloqa uzildi — qayta ulanmoqda…", "all_servers_failed": "Bu kontentning birorta serveri ochilmadi. Keyinroq urinib ko‘ring yoki boshqa manbani tanlang.", "shortcut_pip": "Mini pleer (Shift: boshqa ilovalar ustida)", - "mini_popout_hint": "Mini pleer · boshqa ilovalar ustida suzishi uchun bosib turing" + "mini_popout_hint": "Mini pleer · boshqa ilovalar ustida suzishi uchun bosib turing", + "react": "Reaksiya", + "comments": "Izohlar", + "episode_comments": "{}-qism izohlari", + "reaction_text": "Reaksiya yozish", + "reaction_text_hint": "Qisqa yozing…", + "reaction_wait": "Qayta reaksiya bildirishdan oldin bir necha soniya kuting", + "reaction_limit": "Bu qism uchun reaksiyalar limiti tugadi", + "reaction_failed": "Reaksiyani yuborib bo'lmadi" }, "profile": { "title": "Profil", @@ -751,7 +759,11 @@ "library_update": "Yangi qismlarni tekshirish", "library_update_desc": "Kuzatayotgan titullaringizni tekshirib, yangi qism chiqsa xabar beradi", "library_update_every": "Har {} soatda", - "library_update_daily": "Kuniga bir marta" + "library_update_daily": "Kuniga bir marta", + "reactions_overlay": "Tomoshabinlar reaksiyalari", + "reactions_overlay_desc": "Boshqa tomoshabinlar reaksiyalarini yuborilgan lahzada va ular to'plangan joylarni vaqt chizig'ida ko'rsatish", + "hide_spoilers": "Ko'rmagan qismlarim uchun spoylerlarni yashirish", + "hide_spoilers_desc": "Tugatmagan qismingiz haqidagi izohlar siz bosmaguningizcha xira turadi" }, "appearance": { "title": "Ko'rinish", @@ -1525,7 +1537,10 @@ "show_more": "Ko'proq ko'rsatish", "delete_confirm_title": "Bu izoh o'chirilsinmi?", "delete_confirm_body": "Bu amalni bekor qilib bo'lmaydi.", - "sign_in_prompt": "Izoh qoldirish uchun tizimga kiring" + "sign_in_prompt": "Izoh qoldirish uchun tizimga kiring", + "spoiler": "Spoyler", + "spoiler_hidden": "Spoyler · ochish uchun bosing", + "unwatched_hidden": "Hali ko'rilmagan · ochish uchun bosing" }, "downloads": { "view_titles": "Nomlar", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 5a8f5b47..64c1d51e 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -565,7 +565,15 @@ "reconnecting": "Mất kết nối — đang kết nối lại…", "all_servers_failed": "Không máy chủ nào của phim này phát được. Hãy thử lại sau hoặc chọn nguồn khác.", "shortcut_pip": "Trình phát mini (Shift: nổi trên ứng dụng khác)", - "mini_popout_hint": "Trình phát mini · nhấn giữ để nổi trên ứng dụng khác" + "mini_popout_hint": "Trình phát mini · nhấn giữ để nổi trên ứng dụng khác", + "react": "Bày tỏ cảm xúc", + "comments": "Bình luận", + "episode_comments": "Bình luận tập {}", + "reaction_text": "Viết cảm xúc", + "reaction_text_hint": "Viết ngắn thôi…", + "reaction_wait": "Chờ vài giây trước khi bày tỏ tiếp", + "reaction_limit": "Bạn đã đạt giới hạn cảm xúc cho tập này", + "reaction_failed": "Không gửi được cảm xúc" }, "profile": { "title": "Hồ sơ", @@ -751,7 +759,11 @@ "library_update": "Kiểm tra tập mới", "library_update_desc": "Kiểm tra các phim bạn theo dõi và báo khi có tập mới", "library_update_every": "Mỗi {} giờ", - "library_update_daily": "Mỗi ngày một lần" + "library_update_daily": "Mỗi ngày một lần", + "reactions_overlay": "Cảm xúc người xem", + "reactions_overlay_desc": "Hiện cảm xúc của người xem khác đúng lúc họ gửi, và chỗ tập trung nhiều trên thanh tua", + "hide_spoilers": "Ẩn tiết lộ của các tập tôi chưa xem", + "hide_spoilers_desc": "Bình luận về tập bạn chưa xem hết sẽ bị làm mờ cho đến khi bạn chạm vào" }, "appearance": { "title": "Giao diện", @@ -1525,7 +1537,10 @@ "show_more": "Xem thêm", "delete_confirm_title": "Xóa bình luận này?", "delete_confirm_body": "Không thể hoàn tác thao tác này.", - "sign_in_prompt": "Đăng nhập để bình luận" + "sign_in_prompt": "Đăng nhập để bình luận", + "spoiler": "Tiết lộ", + "spoiler_hidden": "Tiết lộ nội dung · chạm để xem", + "unwatched_hidden": "Chưa xem · chạm để xem" }, "downloads": { "view_titles": "Theo phim", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 91e3e54a..f4cb4f80 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -565,7 +565,15 @@ "reconnecting": "連線中斷 — 重新連線緊…", "all_servers_failed": "呢個節目冇一個伺服器播到。遲啲再試,或者揀第二個來源。", "shortcut_pip": "迷你播放器(Shift:喺其他 App 上面)", - "mini_popout_hint": "迷你播放器 · 長按令佢浮喺其他 App 上面" + "mini_popout_hint": "迷你播放器 · 長按令佢浮喺其他 App 上面", + "react": "反應", + "comments": "留言", + "episode_comments": "第 {} 集留言", + "reaction_text": "寫個反應", + "reaction_text_hint": "講句簡短嘅…", + "reaction_wait": "等幾秒先再反應", + "reaction_limit": "呢集嘅反應已經到上限", + "reaction_failed": "送唔到個反應" }, "profile": { "title": "個人檔案", @@ -751,7 +759,11 @@ "library_update": "檢查新集數", "library_update_desc": "檢查你追緊嘅作品,有新集就通知你", "library_update_every": "每 {} 小時", - "library_update_daily": "每日一次" + "library_update_daily": "每日一次", + "reactions_overlay": "觀眾反應", + "reactions_overlay_desc": "喺觀眾送出反應嗰刻顯示,同埋喺進度條標出反應集中嘅位置", + "hide_spoilers": "隱藏未睇集數嘅劇透", + "hide_spoilers_desc": "未睇完嗰集嘅留言會保持模糊,撳一下先顯示" }, "appearance": { "title": "外觀", @@ -1525,7 +1537,10 @@ "show_more": "顯示更多", "delete_confirm_title": "刪除呢則留言?", "delete_confirm_body": "呢個動作冇得還原。", - "sign_in_prompt": "登入嚟留言" + "sign_in_prompt": "登入嚟留言", + "spoiler": "劇透", + "spoiler_hidden": "劇透 · 撳一下顯示", + "unwatched_hidden": "未睇 · 撳一下顯示" }, "downloads": { "view_titles": "作品", diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index f2f65fee..81f3f0e3 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -118,6 +118,7 @@ import 'package:soplay/features/banners/data/repositories/banners_repository_imp import 'package:soplay/features/banners/domain/repositories/banners_repository.dart'; import 'package:soplay/features/banners/presentation/bloc/banners_bloc.dart'; import 'package:soplay/features/comments/data/datasources/comments_data_source.dart'; +import 'package:soplay/features/comments/data/datasources/timed_reactions_data_source.dart'; import 'package:soplay/features/comments/data/repositories/comments_repository_impl.dart'; import 'package:soplay/features/comments/domain/repositories/comments_repository.dart'; import 'package:soplay/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart'; @@ -755,6 +756,9 @@ Future configureDependencies() async { getIt.registerSingleton( CommentsDataSource(dio: getIt()), ); + getIt.registerSingleton( + TimedReactionsDataSource(dio: getIt()), + ); getIt.registerSingleton( ShortsRemoteDataSource(dio: getIt()), ); diff --git a/lib/features/comments/data/datasources/comments_data_source.dart b/lib/features/comments/data/datasources/comments_data_source.dart index d60bf809..5b4c3cb2 100644 --- a/lib/features/comments/data/datasources/comments_data_source.dart +++ b/lib/features/comments/data/datasources/comments_data_source.dart @@ -73,6 +73,84 @@ class CommentsDataSource { return (body['cascadeDeleted'] as num?)?.toInt() ?? 0; } + Future getScopedComments({ + required String provider, + required String contentUrl, + String? episodeKey, + int page = 1, + int limit = 20, + }) async { + final res = await dio.get( + '/comments', + queryParameters: { + 'provider': provider, + 'contentUrl': contentUrl, + 'episodeKey': ?episodeKey, + 'page': page, + 'limit': limit, + }, + ); + return CommentListModel.fromJson(res.data as Map); + } + + Future createScoped({ + required String provider, + required String contentUrl, + required String text, + String? parentId, + String? episodeKey, + bool spoiler = false, + int? atSec, + }) async { + final res = await dio.post( + '/comments', + data: { + 'provider': provider, + 'contentUrl': contentUrl, + 'text': text, + 'parentId': ?parentId, + 'episodeKey': ?episodeKey, + if (spoiler) 'spoiler': true, + 'atSec': ?atSec, + }, + ); + final body = res.data as Map; + return CommentModel.fromJson( + (body['item'] as Map).cast(), + ); + } + + Future editScoped({ + required String id, + required String text, + bool? spoiler, + }) async { + final res = await dio.put( + '/comments/$id', + data: {'text': text, 'spoiler': ?spoiler}, + ); + final body = res.data as Map; + return CommentModel.fromJson( + (body['item'] as Map).cast(), + ); + } + + Future> episodeCounts({ + required String provider, + required String contentUrl, + }) async { + final res = await dio.get( + '/comments/episode-counts', + queryParameters: {'provider': provider, 'contentUrl': contentUrl}, + ); + final counts = (res.data as Map?)?['counts']; + if (counts is! Map) return const {}; + return { + for (final e in counts.entries) + if (e.value is num) e.key.toString(): (e.value as num).toInt(), + }; + } + Future toggleLike(String id) async { final res = await dio.post('/comments/$id/like'); final body = res.data as Map; diff --git a/lib/features/comments/data/datasources/timed_reactions_data_source.dart b/lib/features/comments/data/datasources/timed_reactions_data_source.dart new file mode 100644 index 00000000..aa2264d0 --- /dev/null +++ b/lib/features/comments/data/datasources/timed_reactions_data_source.dart @@ -0,0 +1,47 @@ +import 'package:dio/dio.dart'; +import 'package:soplay/features/comments/domain/timed_reactions.dart'; + +class TimedReactionsDataSource { + const TimedReactionsDataSource({required this.dio}); + + final Dio dio; + + Future fetch({ + required String provider, + required String contentUrl, + required String episodeKey, + }) async { + final res = await dio.get( + '/reactions', + queryParameters: { + 'provider': provider, + 'contentUrl': contentUrl, + 'episodeKey': episodeKey, + }, + ); + final data = res.data; + if (data is! Map) return ReactionTimeline.empty; + return ReactionTimeline.fromJson(data.cast()); + } + + Future post({ + required String provider, + required String contentUrl, + required String episodeKey, + required int atSec, + required String kind, + required String value, + }) async { + await dio.post( + '/reactions', + data: { + 'provider': provider, + 'contentUrl': contentUrl, + 'episodeKey': episodeKey, + 'atSec': atSec, + 'kind': kind, + 'value': value, + }, + ); + } +} diff --git a/lib/features/comments/data/models/comment_model.dart b/lib/features/comments/data/models/comment_model.dart index 5bd05dab..069c07ea 100644 --- a/lib/features/comments/data/models/comment_model.dart +++ b/lib/features/comments/data/models/comment_model.dart @@ -15,6 +15,9 @@ class CommentModel extends CommentEntity { required super.replyCount, required super.createdAt, required super.updatedAt, + super.episodeKey, + super.spoiler, + super.atSec, }); factory CommentModel.fromJson(Map json) { @@ -40,6 +43,9 @@ class CommentModel extends CommentEntity { replyCount: (json['replyCount'] as num?)?.toInt() ?? 0, createdAt: parse(json['createdAt']), updatedAt: parse(json['updatedAt'] ?? json['createdAt']), + episodeKey: json['episodeKey'] as String?, + spoiler: json['spoiler'] as bool? ?? false, + atSec: (json['atSec'] as num?)?.toInt(), ); } } diff --git a/lib/features/comments/data/repositories/comments_repository_impl.dart b/lib/features/comments/data/repositories/comments_repository_impl.dart index 7a87bd3a..b788aef8 100644 --- a/lib/features/comments/data/repositories/comments_repository_impl.dart +++ b/lib/features/comments/data/repositories/comments_repository_impl.dart @@ -5,8 +5,10 @@ import 'package:soplay/features/comments/domain/entities/comment_entity.dart'; import 'package:soplay/features/comments/domain/entities/comment_list.dart'; import 'package:soplay/features/comments/domain/entities/like_result.dart'; import 'package:soplay/features/comments/domain/repositories/comments_repository.dart'; +import 'package:soplay/features/comments/domain/repositories/episode_comments_repository.dart'; -class CommentsRepositoryImpl implements CommentsRepository { +class CommentsRepositoryImpl + implements CommentsRepository, EpisodeCommentsRepository { final CommentsDataSource dataSource; const CommentsRepositoryImpl(this.dataSource); @@ -60,6 +62,60 @@ class CommentsRepositoryImpl implements CommentsRepository { @override Future> delete(String id) => _wrap(() => dataSource.delete(id)); + @override + Future> getScopedComments({ + required String provider, + required String contentUrl, + String? episodeKey, + int page = 1, + int limit = 20, + }) => + _wrap(() => dataSource.getScopedComments( + provider: provider, + contentUrl: contentUrl, + episodeKey: episodeKey, + page: page, + limit: limit, + )); + + @override + Future> createScoped({ + required String provider, + required String contentUrl, + required String text, + String? parentId, + String? episodeKey, + bool spoiler = false, + int? atSec, + }) => + _wrap(() => dataSource.createScoped( + provider: provider, + contentUrl: contentUrl, + text: text, + parentId: parentId, + episodeKey: episodeKey, + spoiler: spoiler, + atSec: atSec, + )); + + @override + Future> editScoped({ + required String id, + required String text, + bool? spoiler, + }) => + _wrap(() => dataSource.editScoped(id: id, text: text, spoiler: spoiler)); + + @override + Future>> episodeCounts({ + required String provider, + required String contentUrl, + }) => + _wrap(() => dataSource.episodeCounts( + provider: provider, + contentUrl: contentUrl, + )); + @override Future> toggleLike(String id) => _wrap(() => dataSource.toggleLike(id)); diff --git a/lib/features/comments/data/social_playback_prefs.dart b/lib/features/comments/data/social_playback_prefs.dart new file mode 100644 index 00000000..55b20563 --- /dev/null +++ b/lib/features/comments/data/social_playback_prefs.dart @@ -0,0 +1,37 @@ +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; + +/// Spoiler and timed-reaction preferences. +/// +/// Best-effort like the other small stores: with no box (widget tests, the +/// moments before Hive opens) every getter answers its default. +class SocialPlaybackPrefs { + const SocialPlaybackPrefs(); + + static const String _hideSpoilersKey = 'hide_unwatched_spoilers'; + static const String _reactionsOverlayKey = 'timed_reactions_overlay'; + + Box? get _box { + try { + return Hive.box(AppConstants.settingsBox); + } catch (_) { + return null; + } + } + + /// Hide comments about episodes the viewer has not finished. On by default. + bool get hideUnwatchedSpoilers => + _box?.get(_hideSpoilersKey, defaultValue: true) != false; + + Future setHideUnwatchedSpoilers(bool value) async { + await _box?.put(_hideSpoilersKey, value); + } + + /// Other viewers' reactions over the video. On by default. + bool get reactionsOverlay => + _box?.get(_reactionsOverlayKey, defaultValue: true) != false; + + Future setReactionsOverlay(bool value) async { + await _box?.put(_reactionsOverlayKey, value); + } +} diff --git a/lib/features/comments/domain/entities/comment_entity.dart b/lib/features/comments/domain/entities/comment_entity.dart index e20346f8..cd63c5e2 100644 --- a/lib/features/comments/domain/entities/comment_entity.dart +++ b/lib/features/comments/domain/entities/comment_entity.dart @@ -14,6 +14,14 @@ class CommentEntity { final DateTime createdAt; final DateTime updatedAt; + /// Null for a title-level comment. + final String? episodeKey; + final bool spoiler; + + /// The second of the episode the comment was written at, when it was + /// written from the player. + final int? atSec; + const CommentEntity({ required this.id, required this.provider, @@ -27,6 +35,9 @@ class CommentEntity { required this.replyCount, required this.createdAt, required this.updatedAt, + this.episodeKey, + this.spoiler = false, + this.atSec, }); bool get isTopLevel => parentId == null; @@ -38,6 +49,7 @@ class CommentEntity { bool? edited, int? replyCount, DateTime? updatedAt, + bool? spoiler, }) => CommentEntity( id: id, @@ -52,5 +64,8 @@ class CommentEntity { replyCount: replyCount ?? this.replyCount, createdAt: createdAt, updatedAt: updatedAt ?? this.updatedAt, + episodeKey: episodeKey, + spoiler: spoiler ?? this.spoiler, + atSec: atSec, ); } diff --git a/lib/features/comments/domain/episode_key.dart b/lib/features/comments/domain/episode_key.dart new file mode 100644 index 00000000..0f7d11f5 --- /dev/null +++ b/lib/features/comments/domain/episode_key.dart @@ -0,0 +1,31 @@ +/// Names an episode within a title for its comment thread and reactions. +/// +/// The same number watch history keys an episode by — `episodeNumber`, else +/// the index — so "have I finished this one" and "what do people say about +/// it" agree on which episode is meant. Null for a film: its comments are the +/// title's comments. +class EpisodeKey { + const EpisodeKey._(); + + static const String _prefix = 'e'; + + static String? of({ + required bool isSerial, + int? episodeNumber, + int? episodeIndex, + }) { + if (!isSerial) return null; + final n = episodeNumber ?? episodeIndex; + if (n == null || n < 0) return null; + return '$_prefix$n'; + } + + /// The episode number inside [key], or null when it is not one of ours. + static int? numberOf(String? key) { + if (key == null || !key.startsWith(_prefix)) return null; + return int.tryParse(key.substring(_prefix.length)); + } + + /// What a reaction is filed under: a film reacts under the empty key. + static String forReactions(String? key) => key ?? ''; +} diff --git a/lib/features/comments/domain/repositories/episode_comments_repository.dart b/lib/features/comments/domain/repositories/episode_comments_repository.dart new file mode 100644 index 00000000..bc44f33d --- /dev/null +++ b/lib/features/comments/domain/repositories/episode_comments_repository.dart @@ -0,0 +1,39 @@ +import 'package:soplay/core/error/result.dart'; +import '../entities/comment_entity.dart'; +import '../entities/comment_list.dart'; + +/// Episode threads and spoiler flags. +/// +/// Separate from [CommentsRepository] so the fakes that implement that one +/// keep compiling; [CommentsBloc] uses this when its repository offers it. +abstract class EpisodeCommentsRepository { + Future> getScopedComments({ + required String provider, + required String contentUrl, + String? episodeKey, + int page, + int limit, + }); + + Future> createScoped({ + required String provider, + required String contentUrl, + required String text, + String? parentId, + String? episodeKey, + bool spoiler, + int? atSec, + }); + + Future> editScoped({ + required String id, + required String text, + bool? spoiler, + }); + + /// Top-level comment count per episode key. + Future>> episodeCounts({ + required String provider, + required String contentUrl, + }); +} diff --git a/lib/features/comments/domain/spoiler_rules.dart b/lib/features/comments/domain/spoiler_rules.dart new file mode 100644 index 00000000..ce7cf4eb --- /dev/null +++ b/lib/features/comments/domain/spoiler_rules.dart @@ -0,0 +1,74 @@ +import 'package:soplay/features/comments/domain/episode_key.dart'; +import 'package:soplay/features/detail/domain/playback/watch_progress.dart'; +import 'package:soplay/features/history/domain/entities/history_item.dart'; + +/// How a comment is drawn with spoilers in mind. +enum SpoilerVisibility { + visible, + + /// Its author marked it a spoiler. + spoiler, + + /// It is about an episode the viewer has not finished. + unwatched, +} + +class SpoilerRules { + const SpoilerRules._(); + + /// Own comments and ones the viewer already tapped open always show. + /// + /// Watching the episode right now ([playheadSec] set) only hides what was + /// written about a later moment than the playhead; a comment with no + /// moment is the episode's general thread and shows. + static SpoilerVisibility visibility({ + required bool spoiler, + required bool isOwn, + required bool revealed, + required bool hideUnwatched, + required String? episodeKey, + required bool episodeFinished, + int? atSec, + int? playheadSec, + }) { + if (isOwn || revealed) return SpoilerVisibility.visible; + if (spoiler) return SpoilerVisibility.spoiler; + if (!hideUnwatched || episodeKey == null || episodeFinished) { + return SpoilerVisibility.visible; + } + if (playheadSec != null) { + return atSec != null && atSec > playheadSec + ? SpoilerVisibility.unwatched + : SpoilerVisibility.visible; + } + return SpoilerVisibility.unwatched; + } + + /// Whether history says the viewer has finished the episode [episodeKey]. + /// + /// Finished means watched past the threshold the trackers use, or moved on + /// to a later episode of the same title: people skip a recap episode and + /// still know what happened in it. + static bool episodeFinished( + String? episodeKey, + String contentUrl, + Iterable history, + ) { + final n = EpisodeKey.numberOf(episodeKey); + if (n == null) return true; + for (final row in history) { + if (row.contentUrl != contentUrl) continue; + final rowN = row.episodeNumber ?? row.episodeIndex; + if (rowN == null) continue; + if (rowN > n) return true; + if (rowN == n && + WatchProgress.isWatched( + Duration(milliseconds: row.positionMs), + Duration(milliseconds: row.durationMs), + )) { + return true; + } + } + return false; + } +} diff --git a/lib/features/comments/domain/timed_reactions.dart b/lib/features/comments/domain/timed_reactions.dart new file mode 100644 index 00000000..cbfda3d4 --- /dev/null +++ b/lib/features/comments/domain/timed_reactions.dart @@ -0,0 +1,260 @@ +/// The quick-bar emoji. The backend accepts exactly these +/// (src/services/timedReactionService.js). +const List kTimedReactionEmoji = [ + '🔥', + '😂', + '😮', + '😢', + '😍', + '👏', + '💀', + '😡', +]; + +const int kTimedReactionMaxText = 60; + +class ReactionItem { + const ReactionItem({ + required this.kind, + required this.value, + required this.count, + }); + + /// 'emoji' or 'text'. + final String kind; + final String value; + final int count; + + bool get isEmoji => kind == 'emoji'; + + factory ReactionItem.fromJson(Map json) => ReactionItem( + kind: json['kind'] as String? ?? 'emoji', + value: json['value'] as String? ?? '', + count: (json['count'] as num?)?.toInt() ?? 1, + ); +} + +/// Everything reacted at one second of the episode. +class ReactionBucket { + const ReactionBucket({ + required this.t, + required this.total, + required this.items, + }); + + final int t; + + /// All reactions at [t], including the ones [items] leaves out. + final int total; + final List items; + + factory ReactionBucket.fromJson(Map json) => ReactionBucket( + t: (json['t'] as num?)?.toInt() ?? 0, + total: (json['total'] as num?)?.toInt() ?? 0, + items: [ + for (final i in (json['items'] as List? ?? const [])) + if (i is Map) ReactionItem.fromJson(i.cast()), + ], + ); +} + +class ReactionTimeline { + ReactionTimeline(List buckets) + : buckets = List.unmodifiable( + [...buckets]..sort((a, b) => a.t.compareTo(b.t)), + ); + + static final ReactionTimeline empty = ReactionTimeline(const []); + + /// Sorted by second. + final List buckets; + + bool get isEmpty => buckets.isEmpty; + + factory ReactionTimeline.fromJson(Map json) => + ReactionTimeline([ + for (final b in (json['buckets'] as List? ?? const [])) + if (b is Map) ReactionBucket.fromJson(b.cast()), + ]); + + /// The timeline with one more reaction at [t] — the viewer's own, shown + /// without waiting for the next fetch. + ReactionTimeline withReaction(int t, String kind, String value) { + final out = []; + var placed = false; + for (final b in buckets) { + if (b.t != t) { + out.add(b); + continue; + } + placed = true; + final items = [...b.items]; + final i = items.indexWhere((x) => x.kind == kind && x.value == value); + if (i >= 0) { + items[i] = ReactionItem(kind: kind, value: value, count: items[i].count + 1); + } else { + items.add(ReactionItem(kind: kind, value: value, count: 1)); + } + out.add(ReactionBucket(t: t, total: b.total + 1, items: items)); + } + if (!placed) { + out.add( + ReactionBucket( + t: t, + total: 1, + items: [ReactionItem(kind: kind, value: value, count: 1)], + ), + ); + } + return ReactionTimeline(out); + } + + /// Reaction density in [bins] equal slices of [durationSec], scaled so the + /// busiest slice is 1. All zeros when there is nothing to draw. + List heatmap(int durationSec, {int bins = 100}) { + final out = List.filled(bins, 0); + if (durationSec <= 0 || bins <= 0) return out; + for (final b in buckets) { + if (b.t < 0 || b.t > durationSec) continue; + final i = b.t * bins ~/ durationSec; + out[i < bins ? i : bins - 1] += b.total.toDouble(); + } + var peak = 0.0; + for (final v in out) { + if (v > peak) peak = v; + } + if (peak <= 0) return out; + for (var i = 0; i < bins; i++) { + out[i] = out[i] / peak; + } + return out; + } +} + +/// One pill in the reaction lane: an emoji or text and how many people sent +/// it in the last few seconds — "🔥 ×12". +class ReactionPop { + const ReactionPop({ + required this.kind, + required this.value, + required this.count, + required this.lastSec, + }); + + final String kind; + final String value; + final int count; + + /// The latest second in the window this pill counts. + final int lastSec; + + /// Stable while the pill is on screen, so it updates in place. + String get id => '$kind:$value'; + + @override + bool operator ==(Object other) => + other is ReactionPop && + other.kind == kind && + other.value == value && + other.count == count && + other.lastSec == lastSec; + + @override + int get hashCode => Object.hash(kind, value, count, lastSec); + + @override + String toString() => 'ReactionPop($value x$count @$lastSec)'; +} + +/// Which reactions are on screen at a playhead position. +/// +/// A reaction shows for [window] after its second. A jump — a seek either +/// way, or a gap longer than [jumpTolerance] between ticks — starts the +/// window afresh at the new position, so landing somewhere does not burst +/// the last few seconds before it onto the screen at once. The same emoji +/// within the window groups into one pill, and at most [maxVisible] show: +/// the most-sent first, the most recent among equals. +/// +/// Pure: positions in, pills out. +class ReactionScheduler { + ReactionScheduler({ + this.maxVisible = 3, + this.window = const Duration(seconds: 4), + this.jumpTolerance = const Duration(seconds: 2), + }); + + final int maxVisible; + final Duration window; + final Duration jumpTolerance; + + ReactionTimeline _timeline = ReactionTimeline.empty; + Duration? _last; + int _anchorSec = 0; + + ReactionTimeline get timeline => _timeline; + + void load(ReactionTimeline timeline) => _timeline = timeline; + + void clear() { + _timeline = ReactionTimeline.empty; + _last = null; + _anchorSec = 0; + } + + /// The pills for [position]. + List at(Duration position) { + final last = _last; + final posSec = position.inSeconds; + if (last == null || + position < last || + position - last > jumpTolerance) { + _anchorSec = posSec; + } + _last = position; + if (_timeline.isEmpty || maxVisible <= 0) return const []; + + final fromSec = (posSec - window.inSeconds + 1) < _anchorSec + ? _anchorSec + : posSec - window.inSeconds + 1; + + final grouped = {}; + for (final b in _bucketsBetween(fromSec, posSec)) { + for (final item in b.items) { + final key = '${item.kind}:${item.value}'; + final prev = grouped[key]; + grouped[key] = ReactionPop( + kind: item.kind, + value: item.value, + count: (prev?.count ?? 0) + item.count, + lastSec: b.t, + ); + } + } + final pops = grouped.values.toList() + ..sort((a, b) { + final byCount = b.count.compareTo(a.count); + if (byCount != 0) return byCount; + final byTime = b.lastSec.compareTo(a.lastSec); + if (byTime != 0) return byTime; + return a.value.compareTo(b.value); + }); + return pops.length > maxVisible ? pops.sublist(0, maxVisible) : pops; + } + + Iterable _bucketsBetween(int fromSec, int toSec) sync* { + final list = _timeline.buckets; + var lo = 0; + var hi = list.length; + while (lo < hi) { + final mid = (lo + hi) >> 1; + if (list[mid].t < fromSec) { + lo = mid + 1; + } else { + hi = mid; + } + } + for (var i = lo; i < list.length && list[i].t <= toSec; i++) { + yield list[i]; + } + } +} diff --git a/lib/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart b/lib/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart index 6aa8bf4a..e648cc6b 100644 --- a/lib/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart +++ b/lib/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart @@ -3,6 +3,8 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/features/comments/domain/entities/comment_entity.dart'; +import 'package:soplay/features/comments/domain/entities/comment_list.dart'; +import 'package:soplay/features/comments/domain/repositories/episode_comments_repository.dart'; import 'package:soplay/features/comments/domain/repositories/comments_repository.dart'; part 'comments_event.dart'; @@ -14,6 +16,7 @@ class CommentsBloc extends Bloc { String _provider = ''; String _contentUrl = ''; + String? _episodeKey; CommentsBloc({required this.repository, required this.hiveService}) : super(const CommentsState.initial()) { @@ -30,12 +33,14 @@ class CommentsBloc extends Bloc { String? get currentUserId => hiveService.getUser()?.id; bool get isLoggedIn => hiveService.isLoggedIn; + String? get episodeKey => _episodeKey; Future _onInit(CommentsInit event, Emitter emit) async { _provider = event.provider; _contentUrl = event.contentUrl; + _episodeKey = event.episodeKey; emit(state.copyWith(loading: true, error: null, items: const [])); - final result = await repository.getComments( + final result = await _getComments( provider: _provider, contentUrl: _contentUrl, page: 1, @@ -63,7 +68,7 @@ class CommentsBloc extends Bloc { Emitter emit, ) async { emit(state.copyWith(refreshing: true, error: null)); - final result = await repository.getComments( + final result = await _getComments( provider: _provider, contentUrl: _contentUrl, page: 1, @@ -89,7 +94,7 @@ class CommentsBloc extends Bloc { ) async { if (state.loadingMore || state.page >= state.totalPages) return; emit(state.copyWith(loadingMore: true)); - final result = await repository.getComments( + final result = await _getComments( provider: _provider, contentUrl: _contentUrl, page: state.page + 1, @@ -160,12 +165,7 @@ class CommentsBloc extends Bloc { return; } emit(state.copyWith(submitting: true, error: null)); - final result = await repository.create( - provider: _provider, - contentUrl: _contentUrl, - text: event.text, - parentId: event.parentId, - ); + final result = await _create(event); switch (result) { case Success(:final value): if (event.parentId == null) { @@ -216,7 +216,7 @@ class CommentsBloc extends Bloc { // the panel knows an edit has LANDED, so it can stop holding on to the // text it would otherwise have to put back. emit(state.copyWith(submitting: true, error: null)); - final result = await repository.edit(id: event.id, text: event.text); + final result = await _edit(event); switch (result) { case Success(:final value): emit(_replaceComment(state, value).copyWith(submitting: false)); @@ -260,6 +260,62 @@ class CommentsBloc extends Bloc { } } + Future> _getComments({ + required String provider, + required String contentUrl, + required int page, + }) { + final repo = repository; + if (_episodeKey != null && repo is EpisodeCommentsRepository) { + return repo.getScopedComments( + provider: provider, + contentUrl: contentUrl, + episodeKey: _episodeKey, + page: page, + ); + } + return repository.getComments( + provider: provider, + contentUrl: contentUrl, + page: page, + ); + } + + Future> _create(CommentsCreate event) { + final repo = repository; + final scoped = + _episodeKey != null || event.spoiler || event.atSec != null; + if (scoped && repo is EpisodeCommentsRepository) { + return repo.createScoped( + provider: _provider, + contentUrl: _contentUrl, + text: event.text, + parentId: event.parentId, + episodeKey: _episodeKey, + spoiler: event.spoiler, + atSec: event.atSec, + ); + } + return repository.create( + provider: _provider, + contentUrl: _contentUrl, + text: event.text, + parentId: event.parentId, + ); + } + + Future> _edit(CommentsEdit event) { + final repo = repository; + if (event.spoiler != null && repo is EpisodeCommentsRepository) { + return repo.editScoped( + id: event.id, + text: event.text, + spoiler: event.spoiler, + ); + } + return repository.edit(id: event.id, text: event.text); + } + CommentsState _replaceComment(CommentsState s, CommentEntity replaced) { final items = s.items.map((c) => c.id == replaced.id ? replaced : c).toList(); final map = >{}; diff --git a/lib/features/comments/presentation/blocs/comments_bloc/comments_event.dart b/lib/features/comments/presentation/blocs/comments_bloc/comments_event.dart index d1d368ca..7295c9ce 100644 --- a/lib/features/comments/presentation/blocs/comments_bloc/comments_event.dart +++ b/lib/features/comments/presentation/blocs/comments_bloc/comments_event.dart @@ -7,7 +7,14 @@ abstract class CommentsEvent { class CommentsInit extends CommentsEvent { final String provider; final String contentUrl; - const CommentsInit({required this.provider, required this.contentUrl}); + + /// Null keeps the title-level thread. + final String? episodeKey; + const CommentsInit({ + required this.provider, + required this.contentUrl, + this.episodeKey, + }); } class CommentsRefresh extends CommentsEvent { @@ -31,13 +38,23 @@ class CommentsLoadReplies extends CommentsEvent { class CommentsCreate extends CommentsEvent { final String text; final String? parentId; - const CommentsCreate({required this.text, this.parentId}); + final bool spoiler; + final int? atSec; + const CommentsCreate({ + required this.text, + this.parentId, + this.spoiler = false, + this.atSec, + }); } class CommentsEdit extends CommentsEvent { final String id; final String text; - const CommentsEdit({required this.id, required this.text}); + + /// Null leaves the flag as it was. + final bool? spoiler; + const CommentsEdit({required this.id, required this.text, this.spoiler}); } class CommentsDelete extends CommentsEvent { diff --git a/lib/features/comments/presentation/widgets/comment_compose.dart b/lib/features/comments/presentation/widgets/comment_compose.dart index 7893c0f2..b607e440 100644 --- a/lib/features/comments/presentation/widgets/comment_compose.dart +++ b/lib/features/comments/presentation/widgets/comment_compose.dart @@ -12,6 +12,7 @@ class CommentCompose extends StatefulWidget { this.editTarget, this.initialText = '', this.onCancel, + this.spoiler, }); final Future Function(String text) onSubmit; @@ -22,6 +23,9 @@ class CommentCompose extends StatefulWidget { final String initialText; final VoidCallback? onCancel; + /// When set, a Spoiler toggle sits beside Send and drives this. + final ValueNotifier? spoiler; + @override State createState() => _CommentComposeState(); } @@ -167,6 +171,13 @@ class _CommentComposeState extends State { ), ), const SizedBox(width: 4), + if (widget.spoiler != null) ...[ + _SpoilerToggle( + value: widget.spoiler!, + enabled: widget.enabled && !widget.submitting, + ), + const SizedBox(width: 4), + ], Material( color: widget.enabled && !widget.submitting ? AppColors.primary @@ -205,3 +216,60 @@ class _CommentComposeState extends State { ); } } + +class _SpoilerToggle extends StatelessWidget { + const _SpoilerToggle({required this.value, required this.enabled}); + + final ValueNotifier value; + final bool enabled; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: value, + builder: (context, on, _) { + final color = on ? AppColors.primary : AppColors.textHint; + return Semantics( + toggled: on, + button: true, + child: InkWell( + onTap: enabled ? () => value.value = !on : null, + borderRadius: BorderRadius.circular(14), + child: Container( + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: on + ? AppColors.primary.withValues(alpha: 0.15) + : Colors.transparent, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: color, width: 0.8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + on + ? Icons.visibility_off_rounded + : Icons.visibility_outlined, + size: 13, + color: color, + ), + const SizedBox(width: 4), + Text( + 'comments.spoiler'.tr(), + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/comments/presentation/widgets/comments_panel.dart b/lib/features/comments/presentation/widgets/comments_panel.dart index 462bda49..a030bbb7 100644 --- a/lib/features/comments/presentation/widgets/comments_panel.dart +++ b/lib/features/comments/presentation/widgets/comments_panel.dart @@ -5,33 +5,63 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/core/theme/app_theme.dart'; +import 'package:soplay/features/comments/data/social_playback_prefs.dart'; import 'package:soplay/features/comments/domain/entities/comment_entity.dart'; +import 'package:soplay/features/comments/domain/spoiler_rules.dart'; +import 'package:soplay/features/history/data/history_service.dart'; import 'package:soplay/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart'; import 'package:soplay/features/comments/presentation/widgets/comment_card.dart'; import 'package:soplay/features/comments/presentation/widgets/comment_compose.dart'; +import 'package:soplay/features/comments/presentation/widgets/spoiler_gate.dart'; class CommentsPanel extends StatelessWidget { const CommentsPanel({ super.key, required this.provider, required this.contentUrl, + this.episodeKey, + this.playheadSec, }); final String provider; final String contentUrl; + /// Null for the title's thread; an [EpisodeKey] for one episode's. + final String? episodeKey; + + /// The player's current second, when the panel is open over playback. + /// New comments are stamped with it, and comments about later moments stay + /// hidden until the viewer gets there. + final int? Function()? playheadSec; + @override Widget build(BuildContext context) { return BlocProvider( create: (_) => getIt() - ..add(CommentsInit(provider: provider, contentUrl: contentUrl)), - child: const _CommentsView(), + ..add(CommentsInit( + provider: provider, + contentUrl: contentUrl, + episodeKey: episodeKey, + )), + child: _CommentsView( + contentUrl: contentUrl, + episodeKey: episodeKey, + playheadSec: playheadSec, + ), ); } } class _CommentsView extends StatefulWidget { - const _CommentsView(); + const _CommentsView({ + required this.contentUrl, + this.episodeKey, + this.playheadSec, + }); + + final String contentUrl; + final String? episodeKey; + final int? Function()? playheadSec; @override State<_CommentsView> createState() => _CommentsViewState(); @@ -53,6 +83,42 @@ class _CommentsViewState extends State<_CommentsView> { /// did not. _PendingWrite? _pending; + final ValueNotifier _spoiler = ValueNotifier(false); + final Set _revealed = {}; + late final bool _hideUnwatched = const SocialPlaybackPrefs() + .hideUnwatchedSpoilers; + late final bool _episodeFinished = widget.episodeKey == null || + !getIt.isRegistered() || + SpoilerRules.episodeFinished( + widget.episodeKey, + widget.contentUrl, + getIt().getAll(), + ); + + @override + void dispose() { + _spoiler.dispose(); + super.dispose(); + } + + Widget _gate(CommentEntity c, String? currentUserId, Widget card) { + final visibility = SpoilerRules.visibility( + spoiler: c.spoiler, + isOwn: currentUserId != null && currentUserId == c.user.id, + revealed: _revealed.contains(c.id), + hideUnwatched: _hideUnwatched, + episodeKey: c.episodeKey, + episodeFinished: _episodeFinished, + atSec: c.atSec, + playheadSec: widget.playheadSec?.call(), + ); + return SpoilerGate( + visibility: visibility, + onReveal: () => setState(() => _revealed.add(c.id)), + child: card, + ); + } + void _startReply(CommentEntity c) { setState(() { _replyTo = c.id; @@ -66,6 +132,7 @@ class _CommentsViewState extends State<_CommentsView> { setState(() { _editId = c.id; _initialText = c.text; + _spoiler.value = c.spoiler; _replyTo = null; _replyToName = null; }); @@ -78,6 +145,7 @@ class _CommentsViewState extends State<_CommentsView> { _editId = null; _initialText = ''; }); + _spoiler.value = false; } Future _submit(String text) async { @@ -91,11 +159,17 @@ class _CommentsViewState extends State<_CommentsView> { editId: _editId, replyTo: _replyTo, replyToName: _replyToName, + spoiler: _spoiler.value, ); if (_editId != null) { - bloc.add(CommentsEdit(id: _editId!, text: text)); + bloc.add(CommentsEdit(id: _editId!, text: text, spoiler: _spoiler.value)); } else { - bloc.add(CommentsCreate(text: text, parentId: _replyTo)); + bloc.add(CommentsCreate( + text: text, + parentId: _replyTo, + spoiler: _spoiler.value, + atSec: widget.playheadSec?.call(), + )); } _cancel(); } @@ -111,6 +185,7 @@ class _CommentsViewState extends State<_CommentsView> { _replyTo = pending.replyTo; _replyToName = pending.replyToName; }); + _spoiler.value = pending.spoiler; } Widget _buildList( @@ -233,6 +308,7 @@ class _CommentsViewState extends State<_CommentsView> { onReplyLike: (id) => bloc.add(CommentsToggleLike(id)), onReplyEdit: _startEdit, onReplyDelete: _confirmDelete, + gate: (comment, card) => _gate(comment, currentUserId, card), ); }, ); @@ -321,6 +397,7 @@ class _CommentsViewState extends State<_CommentsView> { editTarget: _editId, initialText: _initialText, onCancel: _cancel, + spoiler: _spoiler, ), ), ], @@ -395,6 +472,7 @@ class _CommentTree extends StatelessWidget { required this.onReplyLike, required this.onReplyEdit, required this.onReplyDelete, + this.gate, }); final CommentEntity comment; @@ -411,12 +489,15 @@ class _CommentTree extends StatelessWidget { final void Function(String id) onReplyLike; final void Function(CommentEntity c) onReplyEdit; final void Function(CommentEntity c) onReplyDelete; + final Widget Function(CommentEntity c, Widget card)? gate; + + Widget _gated(CommentEntity c, Widget card) => gate?.call(c, card) ?? card; @override Widget build(BuildContext context) { return Column( children: [ - CommentCard( + _gated(comment, CommentCard( comment: comment, isOwner: currentUserId != null && currentUserId == comment.user.id, canInteract: canInteract, @@ -426,7 +507,7 @@ class _CommentTree extends StatelessWidget { onDelete: onDelete, onToggleReplies: onToggle, repliesExpanded: expanded, - ), + )), if (expanded) Padding( padding: const EdgeInsetsDirectional.only(start: 46), @@ -445,7 +526,7 @@ class _CommentTree extends StatelessWidget { ), ), for (final r in replies) - CommentCard( + _gated(r, CommentCard( comment: r, compact: true, isOwner: @@ -457,7 +538,7 @@ class _CommentTree extends StatelessWidget { onDelete: () => onReplyDelete(r), onToggleReplies: () {}, repliesExpanded: false, - ), + )), ], ), ), @@ -473,10 +554,12 @@ class _PendingWrite { required this.editId, required this.replyTo, required this.replyToName, + this.spoiler = false, }); final String text; final String? editId; final String? replyTo; final String? replyToName; + final bool spoiler; } diff --git a/lib/features/comments/presentation/widgets/episode_comments_sheet.dart b/lib/features/comments/presentation/widgets/episode_comments_sheet.dart new file mode 100644 index 00000000..f2975416 --- /dev/null +++ b/lib/features/comments/presentation/widgets/episode_comments_sheet.dart @@ -0,0 +1,131 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/system/platform_utils.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/comments/presentation/widgets/comments_panel.dart'; + +/// One episode's comment thread, as a side panel where there is width for one +/// (landscape, TV, desktop) and a bottom sheet on a portrait phone. +/// +/// Opened as a route so the thread's text field and its D-pad focus are its +/// own: the player's keyboard shortcuts never see the typing, and BACK closes +/// the thread rather than the player. +Future showEpisodeComments( + BuildContext context, { + required String provider, + required String contentUrl, + required String? episodeKey, + required String title, + int? Function()? playheadSec, +}) { + final size = MediaQuery.sizeOf(context); + final side = isTvPlatform || isDesktopPlatform || size.width > size.height; + final panel = EpisodeCommentsPanel( + provider: provider, + contentUrl: contentUrl, + episodeKey: episodeKey, + title: title, + playheadSec: playheadSec, + ); + if (!side) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppColors.background, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => SizedBox(height: size.height * 0.75, child: panel), + ); + } + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.black38, + transitionDuration: const Duration(milliseconds: 200), + pageBuilder: (context, _, _) => Align( + alignment: AlignmentDirectional.centerEnd, + child: SizedBox( + width: isTvPlatform ? 460 : 380, + height: double.infinity, + child: Material( + color: AppColors.background, + child: SafeArea(left: false, child: panel), + ), + ), + ), + transitionBuilder: (context, animation, _, child) => SlideTransition( + position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ), + child: child, + ), + ); +} + +class EpisodeCommentsPanel extends StatelessWidget { + const EpisodeCommentsPanel({ + super.key, + required this.provider, + required this.contentUrl, + required this.episodeKey, + required this.title, + this.playheadSec, + }); + + final String provider; + final String contentUrl; + final String? episodeKey; + final String title; + final int? Function()? playheadSec; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsetsDirectional.fromSTEB(16, 12, 4, 4), + child: Row( + children: [ + const Icon( + Icons.forum_rounded, + color: AppColors.textSecondary, + size: 18, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + tooltip: 'general.close'.tr(), + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon( + Icons.close_rounded, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Expanded( + child: CommentsPanel( + provider: provider, + contentUrl: contentUrl, + episodeKey: episodeKey, + playheadSec: playheadSec, + ), + ), + ], + ); + } +} diff --git a/lib/features/comments/presentation/widgets/reaction_heatmap.dart b/lib/features/comments/presentation/widgets/reaction_heatmap.dart new file mode 100644 index 00000000..8b66a90e --- /dev/null +++ b/lib/features/comments/presentation/widgets/reaction_heatmap.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:soplay/core/theme/app_colors.dart'; + +/// A thin strip over the seek bar, brighter where reactions cluster. +class ReactionHeatmap extends StatelessWidget { + const ReactionHeatmap({super.key, required this.bins}); + + /// 0..1 per slice of the episode; see [ReactionTimeline.heatmap]. + final List bins; + + @override + Widget build(BuildContext context) { + if (bins.isEmpty) return const SizedBox.shrink(); + return IgnorePointer( + child: CustomPaint( + painter: _HeatmapPainter(bins, AppColors.primary), + size: Size.infinite, + ), + ); + } +} + +class _HeatmapPainter extends CustomPainter { + _HeatmapPainter(this.bins, this.color); + + final List bins; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final w = size.width / bins.length; + final paint = Paint(); + for (var i = 0; i < bins.length; i++) { + final v = bins[i]; + if (v <= 0.04) continue; + paint.color = color.withValues(alpha: 0.25 + 0.75 * v); + final h = size.height * (0.4 + 0.6 * v); + canvas.drawRect( + Rect.fromLTWH(i * w, size.height - h, w + 0.5, h), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _HeatmapPainter old) => + !identical(old.bins, bins) || old.color != color; +} diff --git a/lib/features/comments/presentation/widgets/reaction_lane.dart b/lib/features/comments/presentation/widgets/reaction_lane.dart new file mode 100644 index 00000000..ed5321a2 --- /dev/null +++ b/lib/features/comments/presentation/widgets/reaction_lane.dart @@ -0,0 +1,150 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/features/comments/domain/timed_reactions.dart'; + +/// Other viewers' reactions as pills that fade in at their second and out a +/// few seconds later, in a column at the start edge of the video, clear of +/// the subtitles at the bottom and the controls along the edges. +class ReactionLane extends StatefulWidget { + const ReactionLane({super.key, required this.pops}); + + final ValueListenable> pops; + + @override + State createState() => _ReactionLaneState(); +} + +class _ReactionLaneState extends State { + static const Duration _fade = Duration(milliseconds: 280); + + /// On screen, including pills still fading out. + final List<_Shown> _shown = []; + + @override + void initState() { + super.initState(); + widget.pops.addListener(_sync); + _sync(); + } + + @override + void didUpdateWidget(covariant ReactionLane old) { + super.didUpdateWidget(old); + if (old.pops != widget.pops) { + old.pops.removeListener(_sync); + widget.pops.addListener(_sync); + _sync(); + } + } + + @override + void dispose() { + widget.pops.removeListener(_sync); + super.dispose(); + } + + void _sync() { + if (!mounted) return; + final next = {for (final p in widget.pops.value) p.id: p}; + setState(() { + for (final s in _shown) { + final p = next.remove(s.pop.id); + if (p != null) { + s.pop = p; + s.leaving = false; + } else if (!s.leaving) { + s.leaving = true; + Future.delayed(_fade, () { + if (!mounted) return; + setState(() => _shown.removeWhere((x) => x.leaving && x == s)); + }); + } + } + for (final p in next.values) { + _shown.add(_Shown(p)); + } + }); + } + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final s in _shown) + TweenAnimationBuilder( + key: ValueKey(s.pop.id), + tween: Tween(begin: 0, end: s.leaving ? 0 : 1), + duration: _fade, + builder: (context, t, child) => Opacity( + opacity: t, + child: Transform.translate( + offset: Offset(0, (1 - t) * 10), + child: child, + ), + ), + child: Padding( + padding: const EdgeInsets.only(bottom: 6), + child: _Pill(pop: s.pop), + ), + ), + ], + ), + ); + } +} + +class _Shown { + _Shown(this.pop); + ReactionPop pop; + bool leaving = false; +} + +class _Pill extends StatelessWidget { + const _Pill({required this.pop}); + + final ReactionPop pop; + + @override + Widget build(BuildContext context) { + final emoji = pop.kind == 'emoji'; + return Container( + constraints: const BoxConstraints(maxWidth: 220), + padding: EdgeInsets.symmetric(horizontal: emoji ? 8 : 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + pop.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: emoji ? 20 : 12, + fontWeight: FontWeight.w600, + ), + ), + ), + if (pop.count > 1) ...[ + const SizedBox(width: 4), + Text( + '×${pop.count}', + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/features/comments/presentation/widgets/reaction_picker.dart b/lib/features/comments/presentation/widgets/reaction_picker.dart new file mode 100644 index 00000000..29268701 --- /dev/null +++ b/lib/features/comments/presentation/widgets/reaction_picker.dart @@ -0,0 +1,159 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/comments/domain/timed_reactions.dart'; + +typedef ReactionChoice = ({String kind, String value}); + +/// The quick bar: eight emoji and a short text option, over the bottom of the +/// video. Its own route, so typing never reaches the player's shortcuts and a +/// remote's BACK closes it. +Future showReactionPicker(BuildContext context) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.black26, + transitionDuration: const Duration(milliseconds: 160), + pageBuilder: (context, _, _) => const _ReactionPicker(), + transitionBuilder: (context, animation, _, child) => FadeTransition( + opacity: animation, + child: child, + ), + ); +} + +class _ReactionPicker extends StatefulWidget { + const _ReactionPicker(); + + @override + State<_ReactionPicker> createState() => _ReactionPickerState(); +} + +class _ReactionPickerState extends State<_ReactionPicker> { + final TextEditingController _text = TextEditingController(); + bool _typing = false; + + @override + void dispose() { + _text.dispose(); + super.dispose(); + } + + void _sendText() { + final value = _text.text.trim(); + if (value.isEmpty) return; + Navigator.of(context).pop((kind: 'text', value: value)); + } + + @override + Widget build(BuildContext context) { + final bottom = MediaQuery.viewInsetsOf(context).bottom; + return SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, bottom + 96), + child: Material( + color: const Color(0xEE111111), + borderRadius: BorderRadius.circular(28), + child: Padding( + padding: const EdgeInsets.all(6), + child: _typing ? _buildTextRow() : _buildEmojiRow(), + ), + ), + ), + ), + ); + } + + Widget _buildEmojiRow() { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < kTimedReactionEmoji.length; i++) + _RoundButton( + autofocus: i == 0, + onTap: () => Navigator.of(context).pop( + (kind: 'emoji', value: kTimedReactionEmoji[i]), + ), + child: Text( + kTimedReactionEmoji[i], + style: const TextStyle(fontSize: 24), + ), + ), + _RoundButton( + tooltip: 'player.reaction_text'.tr(), + onTap: () => setState(() => _typing = true), + child: const Icon( + Icons.short_text_rounded, + color: Colors.white, + size: 22, + ), + ), + ], + ), + ); + } + + Widget _buildTextRow() { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 10), + Flexible( + child: TextField( + controller: _text, + autofocus: true, + maxLength: kTimedReactionMaxText, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendText(), + style: const TextStyle(color: Colors.white, fontSize: 14), + decoration: InputDecoration( + hintText: 'player.reaction_text_hint'.tr(), + hintStyle: const TextStyle(color: Colors.white54), + border: InputBorder.none, + counterText: '', + isCollapsed: true, + ), + ), + ), + _RoundButton( + onTap: _sendText, + child: Icon(Icons.send_rounded, color: AppColors.primary, size: 20), + ), + ], + ), + ); + } +} + +class _RoundButton extends StatelessWidget { + const _RoundButton({ + required this.onTap, + required this.child, + this.autofocus = false, + this.tooltip, + }); + + final VoidCallback onTap; + final Widget child; + final bool autofocus; + final String? tooltip; + + @override + Widget build(BuildContext context) { + final button = InkWell( + onTap: onTap, + autofocus: autofocus, + customBorder: const CircleBorder(), + focusColor: Colors.white24, + child: SizedBox(width: 44, height: 44, child: Center(child: child)), + ); + return tooltip == null ? button : Tooltip(message: tooltip!, child: button); + } +} diff --git a/lib/features/comments/presentation/widgets/spoiler_gate.dart b/lib/features/comments/presentation/widgets/spoiler_gate.dart new file mode 100644 index 00000000..797fb23a --- /dev/null +++ b/lib/features/comments/presentation/widgets/spoiler_gate.dart @@ -0,0 +1,86 @@ +import 'dart:ui' show ImageFilter; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/comments/domain/spoiler_rules.dart'; + +/// A comment blurred behind "tap to reveal" unless [visibility] says it can +/// show. Focusable, so a remote can reveal it too. +class SpoilerGate extends StatelessWidget { + const SpoilerGate({ + super.key, + required this.visibility, + required this.onReveal, + required this.child, + }); + + final SpoilerVisibility visibility; + final VoidCallback onReveal; + final Widget child; + + @override + Widget build(BuildContext context) { + if (visibility == SpoilerVisibility.visible) return child; + final label = visibility == SpoilerVisibility.spoiler + ? 'comments.spoiler_hidden'.tr() + : 'comments.unwatched_hidden'.tr(); + return Stack( + children: [ + ExcludeSemantics( + child: IgnorePointer( + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 7, sigmaY: 7), + child: child, + ), + ), + ), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onReveal, + focusColor: AppColors.primary.withValues(alpha: 0.18), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.surface.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border, width: 0.6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.visibility_off_rounded, + size: 14, + color: AppColors.textSecondary, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/detail/presentation/pages/episodes_page.dart b/lib/features/detail/presentation/pages/episodes_page.dart index c0cf34f1..c8a46e15 100644 --- a/lib/features/detail/presentation/pages/episodes_page.dart +++ b/lib/features/detail/presentation/pages/episodes_page.dart @@ -37,6 +37,10 @@ import 'package:soplay/features/download/domain/usecases/get_downloads_usecase.d import 'package:soplay/features/download/presentation/download_messages.dart'; import 'package:soplay/features/history/data/history_service.dart'; import 'package:soplay/features/history/domain/entities/history_item.dart'; +import 'package:soplay/features/comments/domain/episode_key.dart'; +import 'package:soplay/features/comments/domain/repositories/comments_repository.dart'; +import 'package:soplay/features/comments/domain/repositories/episode_comments_repository.dart'; +import 'package:soplay/features/comments/presentation/widgets/episode_comments_sheet.dart'; /// Focus highlight for the full-width rows on this page. /// @@ -104,6 +108,45 @@ class _EpisodesPageState extends State { HistoryItem? _historyItem; + /// Top-level comments per episode key; empty until (and unless) it loads. + Map _commentCounts = const {}; + + Future _loadCommentCounts() async { + if (_isManga || widget.args.offline || widget.args.contentUrl.isEmpty) { + return; + } + if (!getIt.isRegistered()) return; + final repo = getIt(); + if (repo is! EpisodeCommentsRepository) return; + final result = await repo.episodeCounts( + provider: widget.args.provider, + contentUrl: widget.args.contentUrl, + ); + if (!mounted) return; + if (result case Success(:final value) when value.isNotEmpty) { + setState(() => _commentCounts = value); + } + } + + int _commentCountFor(int i) => + _commentCounts[EpisodeKey.of( + isSerial: true, + episodeNumber: _episodes[i].episode, + )] ?? + 0; + + Future _openEpisodeComments(int i) async { + final episode = _episodes[i]; + await showEpisodeComments( + context, + provider: widget.args.provider, + contentUrl: widget.args.contentUrl, + episodeKey: EpisodeKey.of(isSerial: true, episodeNumber: episode.episode), + title: 'player.episode_comments'.tr(args: [episode.numberLabel]), + ); + if (mounted) unawaited(_loadCommentCounts()); + } + /// Indices picked in multi-select. Empty means normal browsing. /// /// Downloading a season one tap at a time is the kind of chore that makes @@ -282,6 +325,7 @@ class _EpisodesPageState extends State { _refreshHistory(); _refreshRead(); _maybeAutoFill(); + unawaited(_loadCommentCounts()); if (widget.args.resumeFromHistory) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(_resumeFromHistory()); @@ -1402,6 +1446,10 @@ class _EpisodesPageState extends State { read: _isManga && _read.contains(_episodes[i].episode), + commentCount: _commentCountFor(i), + onComments: _isManga || widget.args.offline + ? null + : () => _openEpisodeComments(i), ); if (!widget.args.offline || _isDownloaded(i)) { return row; @@ -2132,6 +2180,8 @@ class _EpisodeRow extends StatelessWidget { this.progress, this.onDownload, this.read = false, + this.commentCount = 0, + this.onComments, }); final EpisodeEntity episode; @@ -2175,6 +2225,10 @@ class _EpisodeRow extends StatelessWidget { /// ticks. The row stays fully tappable — re-reading is normal. final bool read; + /// Shown as a small chip when there is at least one; a tap opens the thread. + final int commentCount; + final VoidCallback? onComments; + @override Widget build(BuildContext context) { final hasSub = episode.hasSub == true; @@ -2281,6 +2335,8 @@ class _EpisodeRow extends StatelessWidget { ], ), ), + if (commentCount > 0 && onComments != null && !selecting) + _CommentCountChip(count: commentCount, onTap: onComments!), if (hasSub) const _LangChip(label: 'SUB', primary: true), if (hasSub && hasDub) const SizedBox(width: 4), if (hasDub) const _LangChip(label: 'DUB', primary: false), @@ -2364,6 +2420,47 @@ class _EpisodeRow extends StatelessWidget { } } +class _CommentCountChip extends StatelessWidget { + const _CommentCountChip({required this.count, required this.onTap}); + + final int count; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + focusColor: _kTvRowFocusFill, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.chat_bubble_outline_rounded, + size: 13, + color: AppColors.textHint, + ), + const SizedBox(width: 3), + Text( + count > 999 ? '999+' : '$count', + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ); + } +} + /// The bottom action bar shown while episodes are selected. /// /// Bottom rather than in the app bar: on a tall phone the primary action of a diff --git a/lib/features/detail/presentation/pages/player_page.controls.dart b/lib/features/detail/presentation/pages/player_page.controls.dart index 6eefaaec..d10cefdf 100644 --- a/lib/features/detail/presentation/pages/player_page.controls.dart +++ b/lib/features/detail/presentation/pages/player_page.controls.dart @@ -1759,6 +1759,7 @@ extension _PlayerControls on _PlayerPageState { clipBehavior: Clip.none, children: [ bar, + _buildReactionHeatmap(inset), if (showPreview) ValueListenableBuilder( valueListenable: _seekBarDragging, diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index 31055dfb..a521d52b 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -132,6 +132,14 @@ import 'package:soplay/features/jellyfin/data/jellyfin_reporter.dart'; import 'package:soplay/features/automation/data/auto_download_service.dart'; import 'package:soplay/features/automation/data/automation_settings.dart'; import 'package:soplay/features/automation/domain/prefetch_slot.dart'; +import 'package:soplay/features/comments/data/datasources/timed_reactions_data_source.dart'; +import 'package:soplay/features/comments/data/social_playback_prefs.dart'; +import 'package:soplay/features/comments/domain/episode_key.dart'; +import 'package:soplay/features/comments/domain/timed_reactions.dart'; +import 'package:soplay/features/comments/presentation/widgets/episode_comments_sheet.dart'; +import 'package:soplay/features/comments/presentation/widgets/reaction_heatmap.dart'; +import 'package:soplay/features/comments/presentation/widgets/reaction_lane.dart'; +import 'package:soplay/features/comments/presentation/widgets/reaction_picker.dart'; part 'player_page.models.dart'; part 'player_page.widgets.dart'; @@ -149,6 +157,7 @@ part 'player_page.party.dart'; part 'player_page.tv.dart'; part 'player_page.jellyfin.dart'; part 'player_page.upnext.dart'; +part 'player_page.reactions.dart'; /// Hard ceiling on auto-retries per episode — see [_PlayerPageState._lifetimeRetries]. const int _kMaxLifetimeRetries = RetryPolicy.maxLifetimeRetries; @@ -623,6 +632,8 @@ class _PlayerPageState extends State Timer? _historyTimer; late final AnimationController _controlsAnimation; + final _EpisodeSocial _social = _EpisodeSocial(); + late final AnimationController _seekRippleController; Timer? _seekRippleTimer; int _seekRippleDirection = 0; @@ -924,6 +935,7 @@ class _PlayerPageState extends State _tvSeekFocus.dispose(); _tvPanelScroll?.dispose(); _controlsAnimation.dispose(); + _social.dispose(); _seekRippleController.dispose(); _scrub.dispose(); _seekBarDragging.dispose(); @@ -1055,6 +1067,7 @@ class _PlayerPageState extends State children: [ _buildVideoLayer(), _buildSubtitleOverlay(), + if (!_locked) _buildReactionLane(), if (!_locked) _buildSeekRipple(), if (_locked) _buildLockOverlay() @@ -1079,6 +1092,7 @@ class _PlayerPageState extends State if (!_locked) _buildPlayerInfoOverlay(), if (!_locked) _buildSkipButton(), if (!_locked) _buildUpNextPrompt(), + if (!_locked) _buildEpisodeSocialRail(), if (!_locked && _panel != _SidePanel.none) _buildSidePanel(), if (!_locked && _inParty) _buildPartyReactionsLayer(), diff --git a/lib/features/detail/presentation/pages/player_page.media.dart b/lib/features/detail/presentation/pages/player_page.media.dart index 3ed1ed7f..e340353e 100644 --- a/lib/features/detail/presentation/pages/player_page.media.dart +++ b/lib/features/detail/presentation/pages/player_page.media.dart @@ -258,6 +258,7 @@ extension _PlayerMedia on _PlayerPageState { // The previous episode's opening/ending times do not apply to this one, // and leaving them would offer a skip at the wrong minute. _resetSkipTimes(); + _resetEpisodeSocial(); // A new episode is a new thing to finish. Without this, watching six in a // row would count as one. _countedComplete = false; @@ -1779,6 +1780,7 @@ extension _PlayerMedia on _PlayerPageState { // submissions timed against a different cut. Unawaited because a skip // offer is an extra — playback must never wait on a third-party lookup. unawaited(_loadSkipTimes()); + unawaited(_loadEpisodeSocial()); // After the provider's own tracks have been set, so it can see whether // one of them already reads in the viewer's language. Unawaited and // silent unless it finds something: playback must never wait on it. @@ -2118,6 +2120,7 @@ extension _PlayerMedia on _PlayerPageState { } _updateActiveSkip(v.position); + _tickEpisodeSocial(v.position); _maybePrefetchNext(v.position, v.duration); final remaining = v.duration - v.position; diff --git a/lib/features/detail/presentation/pages/player_page.reactions.dart b/lib/features/detail/presentation/pages/player_page.reactions.dart new file mode 100644 index 00000000..bf6924ab --- /dev/null +++ b/lib/features/detail/presentation/pages/player_page.reactions.dart @@ -0,0 +1,241 @@ +// ignore_for_file: invalid_use_of_protected_member +part of 'player_page.dart'; + +/// Per-episode comments and timed reactions in the player. +/// +/// Reactions are fetched once per episode, after the duration is known (the +/// heatmap needs it), and replayed from the position listener the skip offers +/// already use. Everything here is optional: a failed fetch leaves the +/// episode without reactions and playback does not notice. +class _EpisodeSocial { + final ReactionScheduler scheduler = ReactionScheduler(); + final ValueNotifier> pops = ValueNotifier(const []); + final ValueNotifier> heatmap = ValueNotifier(const []); + final bool overlayOn = const SocialPlaybackPrefs().reactionsOverlay; + + /// Bumped per episode, so a late answer for the previous one is dropped. + int generation = 0; + int durationSec = 0; + DateTime? lastPost; + + void dispose() { + pops.dispose(); + heatmap.dispose(); + } +} + +extension _PlayerEpisodeSocial on _PlayerPageState { + String? get _socialContentUrl { + final url = widget.args.contentUrl; + if (url == null || url.isEmpty || _isLive) return null; + return url; + } + + EpisodeEntity? get _socialEpisode => + widget.args.isSerial && + _episodeIndex >= 0 && + _episodeIndex < _episodes.length + ? _episodes[_episodeIndex] + : null; + + String? get _socialEpisodeKey => EpisodeKey.of( + isSerial: widget.args.isSerial, + episodeNumber: _socialEpisode?.episode, + episodeIndex: _episodeIndex, + ); + + int? _socialPlayheadSec() { + final c = _controller; + if (c == null || !c.value.isInitialized) return null; + return c.value.position.inSeconds; + } + + void _resetEpisodeSocial() { + _social.generation++; + _social.scheduler.clear(); + _social.durationSec = 0; + _social.pops.value = const []; + _social.heatmap.value = const []; + } + + Future _loadEpisodeSocial() async { + if (!_social.overlayOn) return; + final url = _socialContentUrl; + if (url == null) return; + final generation = _social.generation; + try { + final timeline = await getIt().fetch( + provider: widget.args.provider, + contentUrl: url, + episodeKey: EpisodeKey.forReactions(_socialEpisodeKey), + ); + if (!mounted || generation != _social.generation) return; + _applyReactionTimeline(timeline); + } catch (e) { + _plog('reactions unavailable: $e'); + } + } + + void _applyReactionTimeline(ReactionTimeline timeline) { + if (_social.durationSec <= 0) { + final c = _controller; + if (c != null && c.value.isInitialized) { + _social.durationSec = c.value.duration.inSeconds; + } + } + _social.scheduler.load(timeline); + _social.heatmap.value = timeline.isEmpty + ? const [] + : timeline.heatmap(_social.durationSec); + } + + void _tickEpisodeSocial(Duration position) { + if (!_social.overlayOn || _social.scheduler.timeline.isEmpty) return; + final next = _social.scheduler.at(position); + final current = _social.pops.value; + if (next.length == current.length) { + var same = true; + for (var i = 0; i < next.length && same; i++) { + same = next[i] == current[i]; + } + if (same) return; + } + _social.pops.value = next; + } + + void _socialSnack(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), + ); + } + + Future _openReactionPicker() async { + final url = _socialContentUrl; + if (url == null) return; + if (!_hive.isLoggedIn) { + _socialSnack('comments.please_sign_in'.tr()); + return; + } + _hideTimer?.cancel(); + final choice = await showReactionPicker(context); + if (!mounted) return; + _scheduleHide(); + if (choice == null) return; + final last = _social.lastPost; + if (last != null && + DateTime.now().difference(last) < const Duration(seconds: 5)) { + _socialSnack('player.reaction_wait'.tr()); + return; + } + final atSec = _socialPlayheadSec() ?? 0; + _social.lastPost = DateTime.now(); + final episodeKey = EpisodeKey.forReactions(_socialEpisodeKey); + final generation = _social.generation; + _applyReactionTimeline( + _social.scheduler.timeline.withReaction(atSec, choice.kind, choice.value), + ); + try { + await getIt().post( + provider: widget.args.provider, + contentUrl: url, + episodeKey: episodeKey, + atSec: atSec, + kind: choice.kind, + value: choice.value, + ); + } on DioException catch (e) { + if (generation != _social.generation) return; + final data = e.response?.data; + final message = data is Map ? data['message']?.toString() : null; + _socialSnack( + e.response?.statusCode == 429 + ? 'player.reaction_limit'.tr() + : (message ?? 'player.reaction_failed'.tr()), + ); + } catch (_) { + _socialSnack('player.reaction_failed'.tr()); + } + } + + Future _openEpisodeComments() async { + final url = _socialContentUrl; + if (url == null) return; + final ep = _socialEpisode; + _hideTimer?.cancel(); + await showEpisodeComments( + context, + provider: widget.args.provider, + contentUrl: url, + episodeKey: _socialEpisodeKey, + title: ep == null + ? widget.args.title + : 'player.episode_comments'.tr(args: [ep.numberLabel]), + playheadSec: _socialPlayheadSec, + ); + if (mounted) _scheduleHide(); + } + + /// Other viewers' reactions, at the start edge between the top bar and the + /// subtitles. + Widget _buildReactionLane() { + if (!_social.overlayOn || _compact) return const SizedBox.shrink(); + return PositionedDirectional( + start: 16, + top: 0, + bottom: 0, + child: Align( + alignment: const AlignmentDirectional(-1, -0.15), + child: ReactionLane(pops: _social.pops), + ), + ); + } + + /// React and Comments, on the end edge while the controls are up. + Widget _buildEpisodeSocialRail() { + if (_compact || !_controlsVisible || _socialContentUrl == null) { + return const SizedBox.shrink(); + } + return PositionedDirectional( + end: 8, + top: 0, + bottom: 0, + child: Center( + child: FadeTransition( + opacity: _controlsAnimation, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _IconButton( + icon: Icons.add_reaction_outlined, + tooltip: 'player.react'.tr(), + onTap: _openReactionPicker, + ), + const SizedBox(height: 8), + _IconButton( + icon: Icons.forum_outlined, + tooltip: 'player.comments'.tr(), + onTap: _openEpisodeComments, + ), + ], + ), + ), + ), + ); + } + + /// The strip over the seek bar; [inset] is the track's own inset. + Widget _buildReactionHeatmap(double inset) { + if (!_social.overlayOn || _isLive) return const SizedBox.shrink(); + return Positioned( + left: inset, + right: inset, + top: 2, + height: 4, + child: ValueListenableBuilder>( + valueListenable: _social.heatmap, + builder: (_, bins, _) => ReactionHeatmap(bins: bins), + ), + ); + } +} diff --git a/lib/features/profile/presentation/pages/player_settings_page.dart b/lib/features/profile/presentation/pages/player_settings_page.dart index b135eff7..2a0f07da 100644 --- a/lib/features/profile/presentation/pages/player_settings_page.dart +++ b/lib/features/profile/presentation/pages/player_settings_page.dart @@ -17,6 +17,7 @@ import 'package:soplay/features/detail/domain/entities/subtitle_style.dart'; import 'package:soplay/features/detail/presentation/widgets/player_engine_sheet.dart'; import 'package:soplay/features/detail/presentation/widgets/subtitle_size_control.dart'; import 'package:soplay/features/profile/presentation/widgets/settings_tiles.dart'; +import 'package:soplay/features/comments/data/social_playback_prefs.dart'; /// Settings → Player. Every control here seeds a knob that already exists /// inside the fullscreen player. @@ -61,6 +62,9 @@ class _PlayerSettingsPageState extends State { late bool _keepScreenOn; late bool _incognito; late bool _autoSkipIntro; + final SocialPlaybackPrefs _social = const SocialPlaybackPrefs(); + late bool _reactionsOverlay; + late bool _hideSpoilers; late SubtitleStyle _subtitle; late bool _autoTranslate; late String _translateLang; @@ -96,6 +100,8 @@ class _PlayerSettingsPageState extends State { _startPaused = _hive.startPaused; _incognito = _hive.isIncognito; _autoSkipIntro = _hive.autoSkipIntro; + _reactionsOverlay = _social.reactionsOverlay; + _hideSpoilers = _social.hideUnwatchedSpoilers; _seekSeconds = _hive.getDoubleTapSeekSeconds(); _boost = _hive.getLongPressBoost(); _brightnessGesture = _hive.brightnessGestureEnabled; @@ -292,6 +298,28 @@ class _PlayerSettingsPageState extends State { }, ), const SettingsDivider(), + SettingsSwitchTile( + icon: Icons.add_reaction_outlined, + title: 'profile.reactions_overlay'.tr(), + subtitle: 'profile.reactions_overlay_desc'.tr(), + value: _reactionsOverlay, + onChanged: (v) { + setState(() => _reactionsOverlay = v); + _social.setReactionsOverlay(v); + }, + ), + const SettingsDivider(), + SettingsSwitchTile( + icon: Icons.visibility_off_rounded, + title: 'profile.hide_spoilers'.tr(), + subtitle: 'profile.hide_spoilers_desc'.tr(), + value: _hideSpoilers, + onChanged: (v) { + setState(() => _hideSpoilers = v); + _social.setHideUnwatchedSpoilers(v); + }, + ), + const SettingsDivider(), SettingsSwitchTile( icon: Icons.visibility_off_outlined, title: 'profile.incognito'.tr(), diff --git a/test/features/comments/episode_key_test.dart b/test/features/comments/episode_key_test.dart new file mode 100644 index 00000000..443884d2 --- /dev/null +++ b/test/features/comments/episode_key_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/comments/domain/episode_key.dart'; +import 'package:soplay/features/history/domain/entities/history_item.dart'; + +void main() { + test('a film has no episode key and reacts under the empty one', () { + expect(EpisodeKey.of(isSerial: false, episodeNumber: 3), isNull); + expect(EpisodeKey.forReactions(null), ''); + }); + + test('an episode is keyed by its number, else its index, like history', () { + expect(EpisodeKey.of(isSerial: true, episodeNumber: 12), 'e12'); + expect(EpisodeKey.of(isSerial: true, episodeIndex: 4), 'e4'); + expect( + EpisodeKey.of(isSerial: true, episodeNumber: 7, episodeIndex: 2), + 'e7', + ); + expect(EpisodeKey.of(isSerial: true), isNull); + + final storage = HistoryItem.buildStorageKey( + contentUrl: 'u', + isSerial: true, + episodeNumber: 7, + episodeIndex: 2, + ); + expect(storage.endsWith('::7'), isTrue); + }); + + test('the number round-trips', () { + expect(EpisodeKey.numberOf('e12'), 12); + expect(EpisodeKey.numberOf(null), isNull); + expect(EpisodeKey.numberOf('s1e2'), isNull); + expect(EpisodeKey.numberOf('ex'), isNull); + }); +} diff --git a/test/features/comments/reaction_scheduler_test.dart b/test/features/comments/reaction_scheduler_test.dart new file mode 100644 index 00000000..7257163c --- /dev/null +++ b/test/features/comments/reaction_scheduler_test.dart @@ -0,0 +1,146 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/comments/domain/timed_reactions.dart'; + +ReactionBucket _b(int t, List<(String, int)> items) => ReactionBucket( + t: t, + total: items.fold(0, (n, i) => n + i.$2), + items: [ + for (final i in items) ReactionItem(kind: 'emoji', value: i.$1, count: i.$2), + ], +); + +Duration _s(int s) => Duration(seconds: s); + +/// Plays from [from] to [to] a second at a time, as the position listener +/// would, and returns what is on screen at [to]. +List _play(ReactionScheduler s, int from, int to) { + var out = const []; + for (var t = from; t <= to; t++) { + out = s.at(_s(t)); + } + return out; +} + +void main() { + test('a reaction shows from its second for the window, then goes', () { + final s = ReactionScheduler() + ..load(ReactionTimeline([_b(10, [('🔥', 1)])])); + expect(_play(s, 0, 9), isEmpty); + expect(s.at(_s(10)).single.value, '🔥'); + expect(s.at(_s(13)), hasLength(1)); + expect(s.at(_s(14)), isEmpty); + }); + + test('the same emoji in the window groups into one pill with a count', () { + final s = ReactionScheduler() + ..load( + ReactionTimeline([ + _b(10, [('🔥', 5)]), + _b(11, [('🔥', 7), ('😂', 1)]), + ]), + ); + final pops = _play(s, 9, 11); + expect(pops.first.value, '🔥'); + expect(pops.first.count, 12); + expect(pops.first.lastSec, 11); + expect(pops[1].value, '😂'); + }); + + test('no more than maxVisible, the most-sent first', () { + final s = ReactionScheduler(maxVisible: 2) + ..load( + ReactionTimeline([ + _b(5, [('😢', 1), ('🔥', 9), ('😂', 4), ('👏', 2)]), + ]), + ); + final pops = _play(s, 4, 5); + expect(pops.map((p) => p.value), ['🔥', '😂']); + }); + + test('after a seek forward nothing from before the landing point bursts in', + () { + final s = ReactionScheduler() + ..load( + ReactionTimeline([ + _b(98, [('😮', 3)]), + _b(101, [('🔥', 1)]), + ]), + ); + _play(s, 0, 5); + expect(s.at(_s(100)), isEmpty, reason: 'landed after 98; it stays unseen'); + expect(s.at(_s(101)).single.value, '🔥'); + }); + + test('after a seek back reactions replay from the new position', () { + final s = ReactionScheduler() + ..load(ReactionTimeline([_b(20, [('👏', 2)])])); + _play(s, 18, 30); + expect(s.at(_s(19)), isEmpty); + expect(s.at(_s(20)).single.count, 2); + }); + + test('landing exactly on a reaction shows it', () { + final s = ReactionScheduler() + ..load(ReactionTimeline([_b(50, [('💀', 1)])])); + _play(s, 0, 3); + expect(s.at(_s(50)).single.value, '💀'); + }); + + test('normal ticks within the jump tolerance are playback, not a seek', () { + final s = ReactionScheduler() + ..load(ReactionTimeline([_b(10, [('🔥', 1)])])); + s.at(_s(10)); + s.at(const Duration(seconds: 11, milliseconds: 500)); + expect(s.at(_s(13)).single.value, '🔥'); + }); + + test('clear forgets the timeline and the playhead', () { + final s = ReactionScheduler() + ..load(ReactionTimeline([_b(1, [('🔥', 1)])])); + s.clear(); + expect(s.at(_s(1)), isEmpty); + }); + + test('the viewer\'s own reaction is added at its second', () { + final t = ReactionTimeline([_b(10, [('🔥', 2)])]) + .withReaction(10, 'emoji', '🔥') + .withReaction(4, 'text', 'no way'); + expect(t.buckets.map((b) => b.t), [4, 10]); + expect(t.buckets[1].items.single.count, 3); + expect(t.buckets[1].total, 3); + expect(t.buckets[0].items.single.kind, 'text'); + }); + + test('the heatmap scales the busiest slice to one', () { + final t = ReactionTimeline([ + _b(0, [('🔥', 2)]), + _b(50, [('🔥', 8)]), + _b(99, [('🔥', 4)]), + ]); + final bins = t.heatmap(100, bins: 10); + expect(bins, hasLength(10)); + expect(bins[5], 1.0); + expect(bins[0], 0.25); + expect(bins[9], 0.5); + expect(bins[3], 0); + expect(ReactionTimeline.empty.heatmap(0), everyElement(0)); + }); + + test('parses the server shape', () { + final t = ReactionTimeline.fromJson({ + 'total': 3, + 'buckets': [ + { + 't': 7, + 'total': 3, + 'items': [ + {'kind': 'emoji', 'value': '🔥', 'count': 2}, + {'kind': 'text', 'value': 'wow', 'count': 1}, + ], + }, + ], + }); + expect(t.buckets.single.t, 7); + expect(t.buckets.single.items.last.value, 'wow'); + }); +} diff --git a/test/features/comments/spoiler_rules_test.dart b/test/features/comments/spoiler_rules_test.dart new file mode 100644 index 00000000..ad69c08d --- /dev/null +++ b/test/features/comments/spoiler_rules_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/comments/domain/spoiler_rules.dart'; +import 'package:soplay/features/history/domain/entities/history_item.dart'; + +SpoilerVisibility _v({ + bool spoiler = false, + bool isOwn = false, + bool revealed = false, + bool hideUnwatched = true, + String? episodeKey = 'e3', + bool episodeFinished = false, + int? atSec, + int? playheadSec, +}) => SpoilerRules.visibility( + spoiler: spoiler, + isOwn: isOwn, + revealed: revealed, + hideUnwatched: hideUnwatched, + episodeKey: episodeKey, + episodeFinished: episodeFinished, + atSec: atSec, + playheadSec: playheadSec, +); + +HistoryItem _row(int episode, {int pos = 0, int dur = 1000}) => HistoryItem( + contentUrl: 'show', + provider: 'p', + title: 'Show', + isSerial: true, + episodeNumber: episode, + positionMs: pos, + durationMs: dur, + watchedAt: 1, +); + +void main() { + group('visibility', () { + test('a flagged spoiler is blurred even on a finished episode', () { + expect( + _v(spoiler: true, episodeFinished: true), + SpoilerVisibility.spoiler, + ); + expect( + _v(spoiler: true, episodeKey: null, hideUnwatched: false), + SpoilerVisibility.spoiler, + ); + }); + + test('own comments and revealed ones always show', () { + expect(_v(spoiler: true, isOwn: true), SpoilerVisibility.visible); + expect(_v(spoiler: true, revealed: true), SpoilerVisibility.visible); + expect(_v(revealed: true), SpoilerVisibility.visible); + }); + + test('an unfinished episode hides its thread while the setting is on', () { + expect(_v(), SpoilerVisibility.unwatched); + expect(_v(hideUnwatched: false), SpoilerVisibility.visible); + expect(_v(episodeFinished: true), SpoilerVisibility.visible); + }); + + test('title-level comments are never hidden as unwatched', () { + expect(_v(episodeKey: null), SpoilerVisibility.visible); + }); + + test('while watching, only comments past the playhead stay hidden', () { + expect(_v(playheadSec: 300, atSec: 120), SpoilerVisibility.visible); + expect(_v(playheadSec: 300, atSec: 300), SpoilerVisibility.visible); + expect(_v(playheadSec: 300, atSec: 301), SpoilerVisibility.unwatched); + expect(_v(playheadSec: 300), SpoilerVisibility.visible); + }); + }); + + group('episodeFinished', () { + test('watched past the tracker threshold', () { + expect( + SpoilerRules.episodeFinished('e3', 'show', [_row(3, pos: 900)]), + isTrue, + ); + expect( + SpoilerRules.episodeFinished('e3', 'show', [_row(3, pos: 500)]), + isFalse, + ); + }); + + test('moving on to a later episode counts', () { + expect(SpoilerRules.episodeFinished('e3', 'show', [_row(5)]), isTrue); + expect(SpoilerRules.episodeFinished('e3', 'show', [_row(2)]), isFalse); + }); + + test('another title does not count, and nothing watched is unfinished', + () { + final other = HistoryItem( + contentUrl: 'other', + provider: 'p', + title: 'Other', + isSerial: true, + episodeNumber: 9, + positionMs: 1000, + durationMs: 1000, + watchedAt: 1, + ); + expect(SpoilerRules.episodeFinished('e3', 'show', [other]), isFalse); + expect(SpoilerRules.episodeFinished('e3', 'show', const []), isFalse); + }); + + test('a key that is not an episode is treated as finished', () { + expect(SpoilerRules.episodeFinished(null, 'show', const []), isTrue); + }); + }); +} From 4654213355e1231b369f38ed2fef8bf6ce97695d Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:47:30 +0500 Subject: [PATCH 11/16] Add XP, levels and profile cosmetics A progress page with the level ring, recent XP and a locker for avatar frames, titles and name colours. Watch time goes to the server every ten minutes while playing and when the app goes to the background, as each day's running total so a retry adds nothing. Comment authors, social cards, activity cards and the profile header show the frame, name colour and level. A level-up is celebrated by the achievement celebration host. Nothing shows until the server answers /progress. --- assets/translations/ar.json | 62 ++ assets/translations/ckb.json | 62 ++ assets/translations/de.json | 62 ++ assets/translations/en.json | 62 ++ assets/translations/es.json | 62 ++ assets/translations/fr.json | 62 ++ assets/translations/hi.json | 62 ++ assets/translations/id.json | 62 ++ assets/translations/ku.json | 62 ++ assets/translations/nl.json | 62 ++ assets/translations/pt.json | 62 ++ assets/translations/ru.json | 62 ++ assets/translations/tr.json | 62 ++ assets/translations/uz.json | 62 ++ assets/translations/vi.json | 62 ++ assets/translations/yue.json | 62 ++ lib/core/di/injection.dart | 16 + lib/core/router/app_router.dart | 5 + lib/core/storage/profile_scope.dart | 3 + .../widgets/achievement_celebration_host.dart | 41 +- .../repositories/auth_repository_impl.dart | 8 + .../data/models/comment_author_model.dart | 3 + .../domain/entities/comment_author.dart | 4 + .../presentation/widgets/comment_card.dart | 17 +- .../presentation/pages/player_page.dart | 1 + .../pages/player_page.history.dart | 8 +- lib/features/profile/data/backup_service.dart | 3 + .../presentation/pages/profile_page.dart | 3 + .../pages/profile_page.header.dart | 70 ++- .../data/progress_remote_data_source.dart | 31 + .../progress/data/progress_service.dart | 147 +++++ .../progress/data/watch_time_uploader.dart | 193 ++++++ lib/features/progress/domain/progress.dart | 221 +++++++ .../presentation/dialogs/level_up_dialog.dart | 150 +++++ .../presentation/pages/progress_page.dart | 583 ++++++++++++++++++ .../presentation/widgets/level_ring.dart | 108 ++++ .../presentation/widgets/user_badge_line.dart | 221 +++++++ lib/features/social/domain/social_models.dart | 6 + .../presentation/pages/user_profile_page.dart | 9 +- .../presentation/widgets/activity_card.dart | 16 +- .../presentation/widgets/social_widgets.dart | 15 +- test/features/progress/progress_test.dart | 281 +++++++++ 42 files changed, 3127 insertions(+), 28 deletions(-) create mode 100644 lib/features/progress/data/progress_remote_data_source.dart create mode 100644 lib/features/progress/data/progress_service.dart create mode 100644 lib/features/progress/data/watch_time_uploader.dart create mode 100644 lib/features/progress/domain/progress.dart create mode 100644 lib/features/progress/presentation/dialogs/level_up_dialog.dart create mode 100644 lib/features/progress/presentation/pages/progress_page.dart create mode 100644 lib/features/progress/presentation/widgets/level_ring.dart create mode 100644 lib/features/progress/presentation/widgets/user_badge_line.dart create mode 100644 test/features/progress/progress_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 6e6bbb79..6a15c211 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -3031,5 +3031,67 @@ "error_attachment_size": "يجب ألا يتجاوز حجم كل صورة 5 ميغابايت.", "error_attachment_count": "5 لقطات شاشة كحد أقصى في الرسالة.", "suggest": "تتكرر المشكلة؟ راسل الدعم" + }, + "progress": { + "title": "المستوى والخبرة", + "level": "المستوى", + "level_short": "مس {}", + "level_up": "ارتقاء مستوى", + "level_reached": "وصلت إلى المستوى {}", + "unlocked_new": "جديد في خزانتك", + "open_locker": "افتح الخزانة", + "unavailable": "المستويات غير متاحة بعد. عد قريبًا.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP حتى المستوى {}", + "to_next_short": "{} XP حتى المستوى التالي", + "watch_today": "مشاهدة اليوم: {} من {} XP", + "how_to_earn": "كيف تكسب XP", + "rule_watch": "المشاهدة: 1 XP لكل دقيقة، حتى 300 يوميًا", + "rule_comment": "تعليق: 5 XP، حتى 10 تعليقات يوميًا", + "rule_like": "إعجاب بتعليقك: 1 XP، حتى 50 يوميًا", + "rule_streak": "يوم سلسلة: 10 XP، وأكثر في أيام الإنجاز", + "rule_achievement": "إنجاز: من 50 إلى 400 XP حسب الدرجة", + "recent": "أحدث XP", + "reason_watch": "المشاهدة", + "reason_comment": "التعليقات", + "reason_like": "إعجابات مستلمة", + "reason_streak": "يوم سلسلة", + "reason_achievement": "إنجاز", + "reason_other": "XP", + "locker": "الخزانة", + "slot_frame": "إطارات الصورة", + "slot_title": "الألقاب", + "slot_color": "ألوان الاسم", + "equip": "اضغط للارتداء", + "equipped": "مرتدى", + "unlock_level": "المستوى {}", + "unlock_badge": "شارة: {}", + "frame_bronze": "برونزي", + "frame_silver": "فضي", + "frame_gold": "ذهبي", + "frame_neon": "نيون", + "frame_sakura": "ساكورا", + "frame_ember": "جمر", + "frame_flame": "لهب", + "frame_moon": "قمر", + "title_newcomer": "وافد جديد", + "title_regular": "معتاد", + "title_veteran": "مخضرم", + "title_legend": "أسطورة", + "title_binger": "مدمن المسلسلات", + "title_cinephile": "عاشق السينما", + "title_otaku": "أوتاكو", + "title_bookworm": "دودة كتب", + "title_night_owl": "بومة الليل", + "title_host": "مضيف الحفلات", + "color_sky": "سماوي", + "color_mint": "نعناعي", + "color_gold": "ذهبي", + "color_rose": "وردي", + "color_violet": "بنفسجي", + "color_crimson": "قرمزي", + "color_aurora": "شفق", + "color_ember": "جمر" } } diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 6cef5071..65eaa472 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "هەر وێنەیەک دەبێت 5 MB یان کەمتر بێت.", "error_attachment_count": "لە هەر نامەیەکدا زۆرترین 5 وێنەی شاشە.", "suggest": "دووبارە دەبێتەوە؟ بۆ پشتگیری بنووسە" + }, + "progress": { + "title": "ئاست و XP", + "level": "ئاست", + "level_short": "ئاست {}", + "level_up": "بەرزبوونەوەی ئاست", + "level_reached": "گەیشتیتە ئاستی {}", + "unlocked_new": "نوێ لە کەبینەتەکەتدا", + "open_locker": "کردنەوەی کەبینەت", + "unavailable": "ئاستەکان هێشتا بەردەست نین. دواتر سەیر بکەرەوە.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP بۆ ئاستی {}", + "to_next_short": "{} XP بۆ ئاستی داهاتوو", + "watch_today": "سەیرکردنی ئەمڕۆ: {} لە {} XP", + "how_to_earn": "چۆن XP بەدەست بهێنیت", + "rule_watch": "سەیرکردن: هەر خولەکێک 1 XP، تا 300 لە ڕۆژێکدا", + "rule_comment": "لێدوان: 5 XP، تا 10 لێدوان لە ڕۆژێکدا", + "rule_like": "لایک لەسەر لێدوانەکەت: 1 XP، تا 50 لە ڕۆژێکدا", + "rule_streak": "ڕۆژی زنجیرە: 10 XP، زیاتر لە ڕۆژە گرنگەکاندا", + "rule_achievement": "دەستکەوت: 50 تا 400 XP بەپێی پلە", + "recent": "دوایین XP", + "reason_watch": "سەیرکردن", + "reason_comment": "لێدوانەکان", + "reason_like": "لایکی وەرگیراو", + "reason_streak": "ڕۆژی زنجیرە", + "reason_achievement": "دەستکەوت", + "reason_other": "XP", + "locker": "کەبینەت", + "slot_frame": "چوارچێوەی وێنە", + "slot_title": "نازناوەکان", + "slot_color": "ڕەنگی ناو", + "equip": "بۆ لەبەرکردن دەست لێبدە", + "equipped": "لەبەرکراوە", + "unlock_level": "ئاستی {}", + "unlock_badge": "نیشانە: {}", + "frame_bronze": "برۆنز", + "frame_silver": "زیو", + "frame_gold": "زێڕ", + "frame_neon": "نیۆن", + "frame_sakura": "ساکورا", + "frame_ember": "پشکۆ", + "frame_flame": "بڵێسە", + "frame_moon": "مانگ", + "title_newcomer": "تازەهاتوو", + "title_regular": "هەمیشەیی", + "title_veteran": "بەتەمەن", + "title_legend": "ئەفسانە", + "title_binger": "سەیرکەری بێوچان", + "title_cinephile": "خولیای سینەما", + "title_otaku": "ئۆتاکۆ", + "title_bookworm": "کتێبخوێن", + "title_night_owl": "کوندەپەپووی شەو", + "title_host": "خانەخوێی ئاهەنگ", + "color_sky": "ئاسمانی", + "color_mint": "پونگ", + "color_gold": "زێڕین", + "color_rose": "گوڵی", + "color_violet": "مۆر", + "color_crimson": "سووری تۆخ", + "color_aurora": "ئۆرۆرا", + "color_ember": "پشکۆ" } } diff --git a/assets/translations/de.json b/assets/translations/de.json index 0970f03c..b1df671b 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "Jedes Bild darf höchstens 5 MB groß sein.", "error_attachment_count": "Höchstens 5 Screenshots pro Nachricht.", "suggest": "Passiert das öfter? Schreib dem Support" + }, + "progress": { + "title": "Level & XP", + "level": "Level", + "level_short": "Lv. {}", + "level_up": "Level-Aufstieg", + "level_reached": "Du hast Level {} erreicht", + "unlocked_new": "Neu in deinem Spind", + "open_locker": "Spind öffnen", + "unavailable": "Level sind noch nicht verfügbar. Schau bald wieder vorbei.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP bis Level {}", + "to_next_short": "{} XP bis zum nächsten Level", + "watch_today": "Heute geschaut: {} von {} XP", + "how_to_earn": "So bekommst du XP", + "rule_watch": "Schauen: 1 XP pro Minute, bis zu 300 am Tag", + "rule_comment": "Ein Kommentar: 5 XP, bis zu 10 Kommentare am Tag", + "rule_like": "Ein Like auf deinen Kommentar: 1 XP, bis zu 50 am Tag", + "rule_streak": "Ein Streak-Tag: 10 XP, mehr an Meilenstein-Tagen", + "rule_achievement": "Ein Erfolg: 50 bis 400 XP je nach Stufe", + "recent": "Letzte XP", + "reason_watch": "Schauen", + "reason_comment": "Kommentare", + "reason_like": "Erhaltene Likes", + "reason_streak": "Streak-Tag", + "reason_achievement": "Erfolg", + "reason_other": "XP", + "locker": "Spind", + "slot_frame": "Avatar-Rahmen", + "slot_title": "Titel", + "slot_color": "Namensfarben", + "equip": "Zum Ausrüsten tippen", + "equipped": "Ausgerüstet", + "unlock_level": "Level {}", + "unlock_badge": "Abzeichen: {}", + "frame_bronze": "Bronze", + "frame_silver": "Silber", + "frame_gold": "Gold", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Glut", + "frame_flame": "Flamme", + "frame_moon": "Mond", + "title_newcomer": "Neuling", + "title_regular": "Stammgast", + "title_veteran": "Veteran", + "title_legend": "Legende", + "title_binger": "Binge-Watcher", + "title_cinephile": "Cineast", + "title_otaku": "Otaku", + "title_bookworm": "Bücherwurm", + "title_night_owl": "Nachteule", + "title_host": "Party-Gastgeber", + "color_sky": "Himmel", + "color_mint": "Minze", + "color_gold": "Gold", + "color_rose": "Rose", + "color_violet": "Violett", + "color_crimson": "Karmesin", + "color_aurora": "Polarlicht", + "color_ember": "Glut" } } diff --git a/assets/translations/en.json b/assets/translations/en.json index 8fbca5aa..d1a8a4e8 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "Each image must be 5 MB or less.", "error_attachment_count": "At most 5 screenshots per message.", "suggest": "Keeps happening? Write to support" + }, + "progress": { + "title": "Level & XP", + "level": "Level", + "level_short": "Lv {}", + "level_up": "Level up", + "level_reached": "You reached level {}", + "unlocked_new": "New in your locker", + "open_locker": "Open locker", + "unavailable": "Levels are not available yet. Check back soon.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP to level {}", + "to_next_short": "{} XP to the next level", + "watch_today": "Watching today: {} of {} XP", + "how_to_earn": "How to earn XP", + "rule_watch": "Watching: 1 XP a minute, up to 300 a day", + "rule_comment": "A comment: 5 XP, up to 10 comments a day", + "rule_like": "A like on your comment: 1 XP, up to 50 a day", + "rule_streak": "A streak day: 10 XP, more on milestone days", + "rule_achievement": "An achievement: 50 to 400 XP by tier", + "recent": "Recent XP", + "reason_watch": "Watching", + "reason_comment": "Comments", + "reason_like": "Likes received", + "reason_streak": "Streak day", + "reason_achievement": "Achievement", + "reason_other": "XP", + "locker": "Locker", + "slot_frame": "Avatar frames", + "slot_title": "Titles", + "slot_color": "Name colours", + "equip": "Tap to equip", + "equipped": "Equipped", + "unlock_level": "Level {}", + "unlock_badge": "Badge: {}", + "frame_bronze": "Bronze", + "frame_silver": "Silver", + "frame_gold": "Gold", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Ember", + "frame_flame": "Flame", + "frame_moon": "Moon", + "title_newcomer": "Newcomer", + "title_regular": "Regular", + "title_veteran": "Veteran", + "title_legend": "Legend", + "title_binger": "Binge-watcher", + "title_cinephile": "Cinephile", + "title_otaku": "Otaku", + "title_bookworm": "Bookworm", + "title_night_owl": "Night owl", + "title_host": "Party host", + "color_sky": "Sky", + "color_mint": "Mint", + "color_gold": "Gold", + "color_rose": "Rose", + "color_violet": "Violet", + "color_crimson": "Crimson", + "color_aurora": "Aurora", + "color_ember": "Ember" } } diff --git a/assets/translations/es.json b/assets/translations/es.json index 319ddb48..755b64d3 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "Cada imagen debe pesar 5 MB o menos.", "error_attachment_count": "Como máximo 5 capturas por mensaje.", "suggest": "¿Sigue pasando? Escribe a soporte" + }, + "progress": { + "title": "Nivel y XP", + "level": "Nivel", + "level_short": "Nv. {}", + "level_up": "Subida de nivel", + "level_reached": "Has alcanzado el nivel {}", + "unlocked_new": "Novedades en tu taquilla", + "open_locker": "Abrir taquilla", + "unavailable": "Los niveles aún no están disponibles. Vuelve pronto.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP para el nivel {}", + "to_next_short": "{} XP para el siguiente nivel", + "watch_today": "Viendo hoy: {} de {} XP", + "how_to_earn": "Cómo ganar XP", + "rule_watch": "Ver: 1 XP por minuto, hasta 300 al día", + "rule_comment": "Un comentario: 5 XP, hasta 10 comentarios al día", + "rule_like": "Un me gusta en tu comentario: 1 XP, hasta 50 al día", + "rule_streak": "Un día de racha: 10 XP, más en días hito", + "rule_achievement": "Un logro: de 50 a 400 XP según el nivel", + "recent": "XP reciente", + "reason_watch": "Viendo", + "reason_comment": "Comentarios", + "reason_like": "Me gusta recibidos", + "reason_streak": "Día de racha", + "reason_achievement": "Logro", + "reason_other": "XP", + "locker": "Taquilla", + "slot_frame": "Marcos de avatar", + "slot_title": "Títulos", + "slot_color": "Colores de nombre", + "equip": "Toca para equipar", + "equipped": "Equipado", + "unlock_level": "Nivel {}", + "unlock_badge": "Insignia: {}", + "frame_bronze": "Bronce", + "frame_silver": "Plata", + "frame_gold": "Oro", + "frame_neon": "Neón", + "frame_sakura": "Sakura", + "frame_ember": "Brasa", + "frame_flame": "Llama", + "frame_moon": "Luna", + "title_newcomer": "Recién llegado", + "title_regular": "Habitual", + "title_veteran": "Veterano", + "title_legend": "Leyenda", + "title_binger": "Maratonista", + "title_cinephile": "Cinéfilo", + "title_otaku": "Otaku", + "title_bookworm": "Ratón de biblioteca", + "title_night_owl": "Búho nocturno", + "title_host": "Anfitrión", + "color_sky": "Cielo", + "color_mint": "Menta", + "color_gold": "Oro", + "color_rose": "Rosa", + "color_violet": "Violeta", + "color_crimson": "Carmesí", + "color_aurora": "Aurora", + "color_ember": "Brasa" } } diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 599703a9..f7e4bfcc 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "Chaque image doit faire 5 Mo maximum.", "error_attachment_count": "5 captures maximum par message.", "suggest": "Ça se reproduit ? Écris au support" + }, + "progress": { + "title": "Niveau et XP", + "level": "Niveau", + "level_short": "Niv. {}", + "level_up": "Niveau supérieur", + "level_reached": "Vous avez atteint le niveau {}", + "unlocked_new": "Nouveau dans votre casier", + "open_locker": "Ouvrir le casier", + "unavailable": "Les niveaux ne sont pas encore disponibles. Revenez bientôt.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP avant le niveau {}", + "to_next_short": "{} XP avant le niveau suivant", + "watch_today": "Visionnage aujourd’hui : {} sur {} XP", + "how_to_earn": "Comment gagner de l’XP", + "rule_watch": "Regarder : 1 XP par minute, jusqu’à 300 par jour", + "rule_comment": "Un commentaire : 5 XP, jusqu’à 10 commentaires par jour", + "rule_like": "Un j’aime sur votre commentaire : 1 XP, jusqu’à 50 par jour", + "rule_streak": "Un jour de série : 10 XP, plus les jours d’étape", + "rule_achievement": "Un succès : de 50 à 400 XP selon le palier", + "recent": "XP récente", + "reason_watch": "Visionnage", + "reason_comment": "Commentaires", + "reason_like": "J’aime reçus", + "reason_streak": "Jour de série", + "reason_achievement": "Succès", + "reason_other": "XP", + "locker": "Casier", + "slot_frame": "Cadres d’avatar", + "slot_title": "Titres", + "slot_color": "Couleurs du nom", + "equip": "Touchez pour équiper", + "equipped": "Équipé", + "unlock_level": "Niveau {}", + "unlock_badge": "Badge : {}", + "frame_bronze": "Bronze", + "frame_silver": "Argent", + "frame_gold": "Or", + "frame_neon": "Néon", + "frame_sakura": "Sakura", + "frame_ember": "Braise", + "frame_flame": "Flamme", + "frame_moon": "Lune", + "title_newcomer": "Nouveau venu", + "title_regular": "Habitué", + "title_veteran": "Vétéran", + "title_legend": "Légende", + "title_binger": "Binge-watcheur", + "title_cinephile": "Cinéphile", + "title_otaku": "Otaku", + "title_bookworm": "Rat de bibliothèque", + "title_night_owl": "Oiseau de nuit", + "title_host": "Hôte de soirée", + "color_sky": "Ciel", + "color_mint": "Menthe", + "color_gold": "Or", + "color_rose": "Rose", + "color_violet": "Violet", + "color_crimson": "Cramoisi", + "color_aurora": "Aurore", + "color_ember": "Braise" } } diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 12d0cb70..bf31dc1a 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "हर इमेज 5 MB या उससे छोटी होनी चाहिए।", "error_attachment_count": "एक मैसेज में ज़्यादा से ज़्यादा 5 स्क्रीनशॉट।", "suggest": "बार-बार हो रहा है? सपोर्ट को लिखें" + }, + "progress": { + "title": "लेवल और XP", + "level": "लेवल", + "level_short": "Lv {}", + "level_up": "लेवल अप", + "level_reached": "आप लेवल {} पर पहुँच गए", + "unlocked_new": "आपके लॉकर में नया", + "open_locker": "लॉकर खोलें", + "unavailable": "लेवल अभी उपलब्ध नहीं हैं। थोड़ी देर बाद देखें।", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP और, फिर लेवल {}", + "to_next_short": "अगले लेवल तक {} XP", + "watch_today": "आज देखा: {} / {} XP", + "how_to_earn": "XP कैसे कमाएँ", + "rule_watch": "देखना: हर मिनट 1 XP, दिन में 300 तक", + "rule_comment": "कमेंट: 5 XP, दिन में 10 कमेंट तक", + "rule_like": "आपके कमेंट पर लाइक: 1 XP, दिन में 50 तक", + "rule_streak": "स्ट्रीक का दिन: 10 XP, माइलस्टोन वाले दिन ज़्यादा", + "rule_achievement": "अचीवमेंट: टियर के हिसाब से 50 से 400 XP", + "recent": "हाल के XP", + "reason_watch": "देखना", + "reason_comment": "कमेंट", + "reason_like": "मिले लाइक", + "reason_streak": "स्ट्रीक का दिन", + "reason_achievement": "अचीवमेंट", + "reason_other": "XP", + "locker": "लॉकर", + "slot_frame": "अवतार फ़्रेम", + "slot_title": "टाइटल", + "slot_color": "नाम के रंग", + "equip": "पहनने के लिए टैप करें", + "equipped": "पहना हुआ", + "unlock_level": "लेवल {}", + "unlock_badge": "बैज: {}", + "frame_bronze": "कांस्य", + "frame_silver": "चाँदी", + "frame_gold": "सोना", + "frame_neon": "नियॉन", + "frame_sakura": "साकुरा", + "frame_ember": "अंगारा", + "frame_flame": "ज्वाला", + "frame_moon": "चाँद", + "title_newcomer": "नवागंतुक", + "title_regular": "नियमित", + "title_veteran": "अनुभवी", + "title_legend": "लीजेंड", + "title_binger": "बिंज-वॉचर", + "title_cinephile": "सिनेप्रेमी", + "title_otaku": "ओटाकू", + "title_bookworm": "किताबी कीड़ा", + "title_night_owl": "रात का उल्लू", + "title_host": "पार्टी होस्ट", + "color_sky": "आसमानी", + "color_mint": "पुदीना", + "color_gold": "सुनहरा", + "color_rose": "गुलाबी", + "color_violet": "बैंगनी", + "color_crimson": "गहरा लाल", + "color_aurora": "ऑरोरा", + "color_ember": "अंगारा" } } diff --git a/assets/translations/id.json b/assets/translations/id.json index 28442646..e6da719b 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "Setiap gambar maksimal 5 MB.", "error_attachment_count": "Maksimal 5 tangkapan layar per pesan.", "suggest": "Terus terjadi? Hubungi dukungan" + }, + "progress": { + "title": "Level & XP", + "level": "Level", + "level_short": "Lv {}", + "level_up": "Naik level", + "level_reached": "Kamu mencapai level {}", + "unlocked_new": "Baru di lokermu", + "open_locker": "Buka loker", + "unavailable": "Level belum tersedia. Cek lagi nanti.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP menuju level {}", + "to_next_short": "{} XP menuju level berikutnya", + "watch_today": "Menonton hari ini: {} dari {} XP", + "how_to_earn": "Cara mendapatkan XP", + "rule_watch": "Menonton: 1 XP per menit, hingga 300 per hari", + "rule_comment": "Komentar: 5 XP, hingga 10 komentar per hari", + "rule_like": "Suka pada komentarmu: 1 XP, hingga 50 per hari", + "rule_streak": "Hari beruntun: 10 XP, lebih banyak di hari pencapaian", + "rule_achievement": "Pencapaian: 50 hingga 400 XP sesuai tingkat", + "recent": "XP terbaru", + "reason_watch": "Menonton", + "reason_comment": "Komentar", + "reason_like": "Suka diterima", + "reason_streak": "Hari beruntun", + "reason_achievement": "Pencapaian", + "reason_other": "XP", + "locker": "Loker", + "slot_frame": "Bingkai avatar", + "slot_title": "Gelar", + "slot_color": "Warna nama", + "equip": "Ketuk untuk memakai", + "equipped": "Dipakai", + "unlock_level": "Level {}", + "unlock_badge": "Lencana: {}", + "frame_bronze": "Perunggu", + "frame_silver": "Perak", + "frame_gold": "Emas", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Bara", + "frame_flame": "Api", + "frame_moon": "Bulan", + "title_newcomer": "Pendatang baru", + "title_regular": "Pelanggan tetap", + "title_veteran": "Veteran", + "title_legend": "Legenda", + "title_binger": "Maraton tontonan", + "title_cinephile": "Pencinta film", + "title_otaku": "Otaku", + "title_bookworm": "Kutu buku", + "title_night_owl": "Burung hantu malam", + "title_host": "Tuan rumah pesta", + "color_sky": "Langit", + "color_mint": "Mint", + "color_gold": "Emas", + "color_rose": "Mawar", + "color_violet": "Ungu", + "color_crimson": "Merah tua", + "color_aurora": "Aurora", + "color_ember": "Bara" } } diff --git a/assets/translations/ku.json b/assets/translations/ku.json index e876ed5b..2e46aba2 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "Divê her wêne herî zêde 5 MB be.", "error_attachment_count": "Di peyamekê de herî zêde 5 dîmenên ekranê.", "suggest": "Dubare dibe? Ji piştgiriyê re binivîse" + }, + "progress": { + "title": "Ast û XP", + "level": "Ast", + "level_short": "Ast {}", + "level_up": "Ast bilind bû", + "level_reached": "Tu gihîştî asta {}", + "unlocked_new": "Nû di dolaba te de", + "open_locker": "Dolabê veke", + "unavailable": "Ast hêj ne amade ne. Paşê dîsa binêre.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP heta asta {}", + "to_next_short": "Heta asta din {} XP", + "watch_today": "Temaşeya îro: {} ji {} XP", + "how_to_earn": "Meriv çawa XP qezenc dike", + "rule_watch": "Temaşe: her deqîqe 1 XP, rojê heta 300", + "rule_comment": "Şîrove: 5 XP, rojê heta 10 şîroveyan", + "rule_like": "Ecibandinek li şîroveya te: 1 XP, rojê heta 50", + "rule_streak": "Roja rêzê: 10 XP, di rojên girîng de bêtir", + "rule_achievement": "Serkeftin: li gorî astê ji 50 heta 400 XP", + "recent": "XP-ya dawî", + "reason_watch": "Temaşe", + "reason_comment": "Şîrove", + "reason_like": "Ecibandinên hatine", + "reason_streak": "Roja rêzê", + "reason_achievement": "Serkeftin", + "reason_other": "XP", + "locker": "Dolab", + "slot_frame": "Çarçoveyên avatarê", + "slot_title": "Nasnav", + "slot_color": "Rengên navî", + "equip": "Ji bo lixwekirinê bitikîne", + "equipped": "Li xwe kirî", + "unlock_level": "Ast {}", + "unlock_badge": "Nîşan: {}", + "frame_bronze": "Bronz", + "frame_silver": "Zîv", + "frame_gold": "Zêr", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Pelû", + "frame_flame": "Agir", + "frame_moon": "Heyv", + "title_newcomer": "Nûhatî", + "title_regular": "Domdar", + "title_veteran": "Kevnar", + "title_legend": "Efsane", + "title_binger": "Temaşevanê bêrawestan", + "title_cinephile": "Evîndarê sînemayê", + "title_otaku": "Otaku", + "title_bookworm": "Pirtûkxwîn", + "title_night_owl": "Kundê şevê", + "title_host": "Mazûvanê şahiyê", + "color_sky": "Asîmanî", + "color_mint": "Pûng", + "color_gold": "Zêrîn", + "color_rose": "Gulî", + "color_violet": "Mor", + "color_crimson": "Sorê tarî", + "color_aurora": "Ronahiya bakur", + "color_ember": "Pelû" } } diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 8ca39779..af5b3875 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "Elke afbeelding mag maximaal 5 MB zijn.", "error_attachment_count": "Maximaal 5 screenshots per bericht.", "suggest": "Blijft het gebeuren? Schrijf support" + }, + "progress": { + "title": "Level en XP", + "level": "Level", + "level_short": "Lv {}", + "level_up": "Level omhoog", + "level_reached": "Je hebt level {} bereikt", + "unlocked_new": "Nieuw in je kluisje", + "open_locker": "Kluisje openen", + "unavailable": "Levels zijn nog niet beschikbaar. Kom binnenkort terug.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP tot level {}", + "to_next_short": "{} XP tot het volgende level", + "watch_today": "Vandaag gekeken: {} van {} XP", + "how_to_earn": "Zo verdien je XP", + "rule_watch": "Kijken: 1 XP per minuut, tot 300 per dag", + "rule_comment": "Een reactie: 5 XP, tot 10 reacties per dag", + "rule_like": "Een like op je reactie: 1 XP, tot 50 per dag", + "rule_streak": "Een streakdag: 10 XP, meer op mijlpaaldagen", + "rule_achievement": "Een prestatie: 50 tot 400 XP per niveau", + "recent": "Recente XP", + "reason_watch": "Kijken", + "reason_comment": "Reacties", + "reason_like": "Ontvangen likes", + "reason_streak": "Streakdag", + "reason_achievement": "Prestatie", + "reason_other": "XP", + "locker": "Kluisje", + "slot_frame": "Avatarranden", + "slot_title": "Titels", + "slot_color": "Naamkleuren", + "equip": "Tik om te dragen", + "equipped": "Gedragen", + "unlock_level": "Level {}", + "unlock_badge": "Badge: {}", + "frame_bronze": "Brons", + "frame_silver": "Zilver", + "frame_gold": "Goud", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Gloed", + "frame_flame": "Vlam", + "frame_moon": "Maan", + "title_newcomer": "Nieuwkomer", + "title_regular": "Vaste gast", + "title_veteran": "Veteraan", + "title_legend": "Legende", + "title_binger": "Bingewatcher", + "title_cinephile": "Filmliefhebber", + "title_otaku": "Otaku", + "title_bookworm": "Boekenwurm", + "title_night_owl": "Nachtuil", + "title_host": "Feestgastheer", + "color_sky": "Lucht", + "color_mint": "Munt", + "color_gold": "Goud", + "color_rose": "Roos", + "color_violet": "Violet", + "color_crimson": "Karmozijn", + "color_aurora": "Poollicht", + "color_ember": "Gloed" } } diff --git a/assets/translations/pt.json b/assets/translations/pt.json index f79b5482..b7c337ec 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "Cada imagem pode ter no máximo 5 MB.", "error_attachment_count": "No máximo 5 capturas por mensagem.", "suggest": "Continua acontecendo? Fale com o suporte" + }, + "progress": { + "title": "Nível e XP", + "level": "Nível", + "level_short": "Nv. {}", + "level_up": "Subiu de nível", + "level_reached": "Você chegou ao nível {}", + "unlocked_new": "Novidades no seu armário", + "open_locker": "Abrir armário", + "unavailable": "Os níveis ainda não estão disponíveis. Volte em breve.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP até o nível {}", + "to_next_short": "{} XP até o próximo nível", + "watch_today": "Assistido hoje: {} de {} XP", + "how_to_earn": "Como ganhar XP", + "rule_watch": "Assistir: 1 XP por minuto, até 300 por dia", + "rule_comment": "Um comentário: 5 XP, até 10 comentários por dia", + "rule_like": "Uma curtida no seu comentário: 1 XP, até 50 por dia", + "rule_streak": "Um dia de sequência: 10 XP, mais em dias marcantes", + "rule_achievement": "Uma conquista: de 50 a 400 XP conforme o nível", + "recent": "XP recente", + "reason_watch": "Assistindo", + "reason_comment": "Comentários", + "reason_like": "Curtidas recebidas", + "reason_streak": "Dia de sequência", + "reason_achievement": "Conquista", + "reason_other": "XP", + "locker": "Armário", + "slot_frame": "Molduras de avatar", + "slot_title": "Títulos", + "slot_color": "Cores do nome", + "equip": "Toque para equipar", + "equipped": "Equipado", + "unlock_level": "Nível {}", + "unlock_badge": "Medalha: {}", + "frame_bronze": "Bronze", + "frame_silver": "Prata", + "frame_gold": "Ouro", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Brasa", + "frame_flame": "Chama", + "frame_moon": "Lua", + "title_newcomer": "Novato", + "title_regular": "Frequentador", + "title_veteran": "Veterano", + "title_legend": "Lenda", + "title_binger": "Maratonista", + "title_cinephile": "Cinéfilo", + "title_otaku": "Otaku", + "title_bookworm": "Rato de biblioteca", + "title_night_owl": "Coruja noturna", + "title_host": "Anfitrião", + "color_sky": "Céu", + "color_mint": "Menta", + "color_gold": "Ouro", + "color_rose": "Rosa", + "color_violet": "Violeta", + "color_crimson": "Carmesim", + "color_aurora": "Aurora", + "color_ember": "Brasa" } } diff --git a/assets/translations/ru.json b/assets/translations/ru.json index c85aef09..98253a90 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -3028,5 +3028,67 @@ "error_attachment_size": "Каждое изображение — не больше 5 МБ.", "error_attachment_count": "Не больше 5 скриншотов в сообщении.", "suggest": "Повторяется? Напишите в поддержку" + }, + "progress": { + "title": "Уровень и XP", + "level": "Уровень", + "level_short": "Ур. {}", + "level_up": "Новый уровень", + "level_reached": "Вы достигли уровня {}", + "unlocked_new": "Новое в вашем шкафчике", + "open_locker": "Открыть шкафчик", + "unavailable": "Уровни пока недоступны. Загляните позже.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP до уровня {}", + "to_next_short": "{} XP до следующего уровня", + "watch_today": "Просмотр сегодня: {} из {} XP", + "how_to_earn": "Как получить XP", + "rule_watch": "Просмотр: 1 XP в минуту, до 300 в день", + "rule_comment": "Комментарий: 5 XP, до 10 комментариев в день", + "rule_like": "Лайк на ваш комментарий: 1 XP, до 50 в день", + "rule_streak": "День серии: 10 XP, больше в юбилейные дни", + "rule_achievement": "Достижение: от 50 до 400 XP по уровню", + "recent": "Недавние XP", + "reason_watch": "Просмотр", + "reason_comment": "Комментарии", + "reason_like": "Полученные лайки", + "reason_streak": "День серии", + "reason_achievement": "Достижение", + "reason_other": "XP", + "locker": "Шкафчик", + "slot_frame": "Рамки аватара", + "slot_title": "Титулы", + "slot_color": "Цвета имени", + "equip": "Нажмите, чтобы надеть", + "equipped": "Надето", + "unlock_level": "Уровень {}", + "unlock_badge": "Значок: {}", + "frame_bronze": "Бронза", + "frame_silver": "Серебро", + "frame_gold": "Золото", + "frame_neon": "Неон", + "frame_sakura": "Сакура", + "frame_ember": "Угли", + "frame_flame": "Пламя", + "frame_moon": "Луна", + "title_newcomer": "Новичок", + "title_regular": "Завсегдатай", + "title_veteran": "Ветеран", + "title_legend": "Легенда", + "title_binger": "Запойный зритель", + "title_cinephile": "Киноман", + "title_otaku": "Отаку", + "title_bookworm": "Книжный червь", + "title_night_owl": "Ночная сова", + "title_host": "Хозяин вечеринок", + "color_sky": "Небо", + "color_mint": "Мята", + "color_gold": "Золото", + "color_rose": "Роза", + "color_violet": "Фиалка", + "color_crimson": "Багрянец", + "color_aurora": "Сияние", + "color_ember": "Угли" } } diff --git a/assets/translations/tr.json b/assets/translations/tr.json index f22f299e..657b9b31 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "Her görsel en fazla 5 MB olmalı.", "error_attachment_count": "Mesaj başına en fazla 5 ekran görüntüsü.", "suggest": "Tekrarlıyor mu? Desteğe yaz" + }, + "progress": { + "title": "Seviye ve XP", + "level": "Seviye", + "level_short": "Sv. {}", + "level_up": "Seviye atladın", + "level_reached": "{}. seviyeye ulaştın", + "unlocked_new": "Dolabında yeni", + "open_locker": "Dolabı aç", + "unavailable": "Seviyeler henüz kullanılamıyor. Yakında tekrar bak.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP sonra {}. seviye", + "to_next_short": "Sonraki seviyeye {} XP", + "watch_today": "Bugün izleme: {} / {} XP", + "how_to_earn": "XP nasıl kazanılır", + "rule_watch": "İzlemek: dakikada 1 XP, günde en fazla 300", + "rule_comment": "Yorum: 5 XP, günde en fazla 10 yorum", + "rule_like": "Yorumuna beğeni: 1 XP, günde en fazla 50", + "rule_streak": "Seri günü: 10 XP, dönüm noktası günlerinde daha fazla", + "rule_achievement": "Başarım: kademeye göre 50 ile 400 XP", + "recent": "Son XP", + "reason_watch": "İzleme", + "reason_comment": "Yorumlar", + "reason_like": "Alınan beğeniler", + "reason_streak": "Seri günü", + "reason_achievement": "Başarım", + "reason_other": "XP", + "locker": "Dolap", + "slot_frame": "Avatar çerçeveleri", + "slot_title": "Unvanlar", + "slot_color": "İsim renkleri", + "equip": "Kuşanmak için dokun", + "equipped": "Kuşanıldı", + "unlock_level": "Seviye {}", + "unlock_badge": "Rozet: {}", + "frame_bronze": "Bronz", + "frame_silver": "Gümüş", + "frame_gold": "Altın", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Kor", + "frame_flame": "Alev", + "frame_moon": "Ay", + "title_newcomer": "Yeni gelen", + "title_regular": "Müdavim", + "title_veteran": "Kıdemli", + "title_legend": "Efsane", + "title_binger": "Dizi kurdu", + "title_cinephile": "Sinemasever", + "title_otaku": "Otaku", + "title_bookworm": "Kitap kurdu", + "title_night_owl": "Gece kuşu", + "title_host": "Parti ev sahibi", + "color_sky": "Gök", + "color_mint": "Nane", + "color_gold": "Altın", + "color_rose": "Gül", + "color_violet": "Menekşe", + "color_crimson": "Kızıl", + "color_aurora": "Kutup ışığı", + "color_ember": "Kor" } } diff --git a/assets/translations/uz.json b/assets/translations/uz.json index a0ec0494..6ea9c4e9 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "Har bir rasm 5 MB dan oshmasin.", "error_attachment_count": "Bitta xabarga ko‘pi bilan 5 ta skrinshot.", "suggest": "Takrorlanyaptimi? Support’ga yozing" + }, + "progress": { + "title": "Daraja va XP", + "level": "Daraja", + "level_short": "{}-dar.", + "level_up": "Yangi daraja", + "level_reached": "Siz {}-darajaga yetdingiz", + "unlocked_new": "Shkafingizda yangilik", + "open_locker": "Shkafni ochish", + "unavailable": "Darajalar hali mavjud emas. Keyinroq qarab ko‘ring.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "{} XP qoldi, keyin {}-daraja", + "to_next_short": "Keyingi darajagacha {} XP", + "watch_today": "Bugun tomosha: {} / {} XP", + "how_to_earn": "XP qanday olinadi", + "rule_watch": "Tomosha: daqiqasiga 1 XP, kuniga 300 tagacha", + "rule_comment": "Izoh: 5 XP, kuniga 10 ta izohgacha", + "rule_like": "Izohingizga layk: 1 XP, kuniga 50 tagacha", + "rule_streak": "Streak kuni: 10 XP, muhim kunlarda ko‘proq", + "rule_achievement": "Yutuq: darajasiga qarab 50 dan 400 XP gacha", + "recent": "So‘nggi XP", + "reason_watch": "Tomosha", + "reason_comment": "Izohlar", + "reason_like": "Olingan layklar", + "reason_streak": "Streak kuni", + "reason_achievement": "Yutuq", + "reason_other": "XP", + "locker": "Shkaf", + "slot_frame": "Avatar ramkalari", + "slot_title": "Unvonlar", + "slot_color": "Ism ranglari", + "equip": "Kiyish uchun bosing", + "equipped": "Kiyilgan", + "unlock_level": "{}-daraja", + "unlock_badge": "Nishon: {}", + "frame_bronze": "Bronza", + "frame_silver": "Kumush", + "frame_gold": "Oltin", + "frame_neon": "Neon", + "frame_sakura": "Sakura", + "frame_ember": "Cho‘g‘", + "frame_flame": "Alanga", + "frame_moon": "Oy", + "title_newcomer": "Yangi kelgan", + "title_regular": "Doimiy", + "title_veteran": "Faxriy", + "title_legend": "Afsona", + "title_binger": "Serial ishqibozi", + "title_cinephile": "Kinoshunos", + "title_otaku": "Otaku", + "title_bookworm": "Kitobxon", + "title_night_owl": "Tungi boyqush", + "title_host": "Bazm egasi", + "color_sky": "Osmon", + "color_mint": "Yalpiz", + "color_gold": "Oltin", + "color_rose": "Atirgul", + "color_violet": "Binafsha", + "color_crimson": "Qirmizi", + "color_aurora": "Qutb shafag‘i", + "color_ember": "Cho‘g‘" } } diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 5a8f5b47..c78127a9 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "Mỗi ảnh tối đa 5 MB.", "error_attachment_count": "Tối đa 5 ảnh mỗi tin nhắn.", "suggest": "Vẫn tiếp diễn? Hãy viết cho bộ phận hỗ trợ" + }, + "progress": { + "title": "Cấp độ & XP", + "level": "Cấp", + "level_short": "Cấp {}", + "level_up": "Lên cấp", + "level_reached": "Bạn đã đạt cấp {}", + "unlocked_new": "Mới trong tủ đồ của bạn", + "open_locker": "Mở tủ đồ", + "unavailable": "Cấp độ chưa khả dụng. Hãy quay lại sau.", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "Còn {} XP lên cấp {}", + "to_next_short": "Còn {} XP lên cấp tiếp theo", + "watch_today": "Xem hôm nay: {} / {} XP", + "how_to_earn": "Cách nhận XP", + "rule_watch": "Xem: 1 XP mỗi phút, tối đa 300 mỗi ngày", + "rule_comment": "Bình luận: 5 XP, tối đa 10 bình luận mỗi ngày", + "rule_like": "Lượt thích bình luận của bạn: 1 XP, tối đa 50 mỗi ngày", + "rule_streak": "Ngày chuỗi: 10 XP, nhiều hơn vào ngày cột mốc", + "rule_achievement": "Thành tích: 50 đến 400 XP theo bậc", + "recent": "XP gần đây", + "reason_watch": "Xem phim", + "reason_comment": "Bình luận", + "reason_like": "Lượt thích nhận được", + "reason_streak": "Ngày chuỗi", + "reason_achievement": "Thành tích", + "reason_other": "XP", + "locker": "Tủ đồ", + "slot_frame": "Khung ảnh đại diện", + "slot_title": "Danh hiệu", + "slot_color": "Màu tên", + "equip": "Chạm để trang bị", + "equipped": "Đã trang bị", + "unlock_level": "Cấp {}", + "unlock_badge": "Huy hiệu: {}", + "frame_bronze": "Đồng", + "frame_silver": "Bạc", + "frame_gold": "Vàng", + "frame_neon": "Neon", + "frame_sakura": "Hoa anh đào", + "frame_ember": "Than hồng", + "frame_flame": "Ngọn lửa", + "frame_moon": "Mặt trăng", + "title_newcomer": "Người mới", + "title_regular": "Khách quen", + "title_veteran": "Kỳ cựu", + "title_legend": "Huyền thoại", + "title_binger": "Mọt phim bộ", + "title_cinephile": "Người mê điện ảnh", + "title_otaku": "Otaku", + "title_bookworm": "Mọt sách", + "title_night_owl": "Cú đêm", + "title_host": "Chủ tiệc", + "color_sky": "Bầu trời", + "color_mint": "Bạc hà", + "color_gold": "Vàng", + "color_rose": "Hồng", + "color_violet": "Tím", + "color_crimson": "Đỏ thẫm", + "color_aurora": "Cực quang", + "color_ember": "Than hồng" } } diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 91e3e54a..71fa0083 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -3027,5 +3027,67 @@ "error_attachment_size": "每張圖唔可以大過 5 MB。", "error_attachment_count": "每條訊息最多 5 張截圖。", "suggest": "成日都係咁?搵支援" + }, + "progress": { + "title": "等級同 XP", + "level": "等級", + "level_short": "Lv {}", + "level_up": "升級", + "level_reached": "你升到第 {} 級", + "unlocked_new": "儲物櫃有新嘢", + "open_locker": "打開儲物櫃", + "unavailable": "等級暫時未有,遲啲再睇吓。", + "xp_total": "{} XP", + "xp_gain": "+{} XP", + "to_next": "仲差 {} XP 到第 {} 級", + "to_next_short": "仲差 {} XP 到下一級", + "watch_today": "今日睇咗:{} / {} XP", + "how_to_earn": "點樣賺 XP", + "rule_watch": "睇片:每分鐘 1 XP,每日最多 300", + "rule_comment": "留言:5 XP,每日最多計 10 個留言", + "rule_like": "你嘅留言有人讚:1 XP,每日最多 50", + "rule_streak": "連續日:10 XP,里程碑嗰日仲多", + "rule_achievement": "成就:按等級 50 至 400 XP", + "recent": "最近 XP", + "reason_watch": "睇片", + "reason_comment": "留言", + "reason_like": "收到嘅讚", + "reason_streak": "連續日", + "reason_achievement": "成就", + "reason_other": "XP", + "locker": "儲物櫃", + "slot_frame": "頭像框", + "slot_title": "稱號", + "slot_color": "名字顏色", + "equip": "撳一下裝備", + "equipped": "已裝備", + "unlock_level": "第 {} 級", + "unlock_badge": "徽章:{}", + "frame_bronze": "銅", + "frame_silver": "銀", + "frame_gold": "金", + "frame_neon": "霓虹", + "frame_sakura": "櫻花", + "frame_ember": "餘燼", + "frame_flame": "火焰", + "frame_moon": "月亮", + "title_newcomer": "新人", + "title_regular": "熟客", + "title_veteran": "老鳥", + "title_legend": "傳奇", + "title_binger": "追劇狂", + "title_cinephile": "電影迷", + "title_otaku": "御宅族", + "title_bookworm": "書蟲", + "title_night_owl": "夜貓", + "title_host": "派對主人", + "color_sky": "天藍", + "color_mint": "薄荷", + "color_gold": "金色", + "color_rose": "玫瑰", + "color_violet": "紫羅蘭", + "color_crimson": "深紅", + "color_aurora": "極光", + "color_ember": "餘燼" } } diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index f2f65fee..b2d65ba9 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -246,6 +246,9 @@ import 'package:soplay/features/social/data/social_service.dart'; import 'package:soplay/features/achievements/data/achievements_remote_data_source.dart'; import 'package:soplay/features/achievements/data/achievements_service.dart'; import 'package:soplay/features/achievements/domain/achievements.dart'; +import 'package:soplay/features/progress/data/progress_remote_data_source.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/data/watch_time_uploader.dart'; import 'package:soplay/features/home_widget/home_widget_sync.dart'; final getIt = GetIt.instance; @@ -397,6 +400,17 @@ Future configureDependencies() async { hive: getIt(), ), ); + final progressRemote = ProgressRemoteDataSource(dio: getIt()); + getIt.registerSingleton( + ProgressService(remote: progressRemote, hive: getIt()), + ); + getIt.registerSingleton( + WatchTimeUploader( + remote: progressRemote, + hive: getIt(), + progress: getIt(), + )..start(), + ); getIt.registerSingleton( HistorySyncService( remote: getIt(), @@ -415,6 +429,7 @@ Future configureDependencies() async { // land in the profile it was watched in, before the sync settles. beforeSwitch: () async { await PlayerDock.instance.close(); + await getIt().flush(); await getIt().settle(); }, onScopeChanged: _onProfileScopeChanged, @@ -1251,6 +1266,7 @@ Future _onProfileScopeChanged({required bool resync}) async { getIt().revision.value++; getIt().onProfileChanged(); getIt().reload(); + getIt().reload(); if (!resync || !hive.isLoggedIn) return; // Push goes to whichever profile registered last, so every switch // re-registers under the new X-Sozo-Profile. diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index e3b68b08..6ff57e9d 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -110,6 +110,7 @@ import '../../features/home/presentation/pages/home_view_all_page.dart'; import '../../features/home/presentation/pages/genres_page.dart'; import '../../features/search/domain/entities/genre_entity.dart'; import 'package:soplay/features/achievements/presentation/pages/achievements_page.dart'; +import 'package:soplay/features/progress/presentation/pages/progress_page.dart'; /// The arrival every "opening a thing" route shares. /// @@ -443,6 +444,10 @@ class AppRouter { builder: (context, state) => const NotificationsPage(), ), GoRoute(path: '/streak', builder: (context, state) => const StreakPage()), + GoRoute( + path: '/progress', + builder: (context, state) => const ProgressPage(), + ), GoRoute( path: '/achievements', builder: (context, state) => const AchievementsPage(), diff --git a/lib/core/storage/profile_scope.dart b/lib/core/storage/profile_scope.dart index 386b2a68..dc68c2b4 100644 --- a/lib/core/storage/profile_scope.dart +++ b/lib/core/storage/profile_scope.dart @@ -66,6 +66,9 @@ class ProfileScope { 'follow_sync_outbox', 'follow_sync_tombstones', 'follow_sync_at', + 'progress_view', + 'progress_seen_level', + 'progress_watch_sent', }; static String box(String base) => boxFor(base, _namespace); diff --git a/lib/features/achievements/presentation/widgets/achievement_celebration_host.dart b/lib/features/achievements/presentation/widgets/achievement_celebration_host.dart index 6be659a7..c86d0587 100644 --- a/lib/features/achievements/presentation/widgets/achievement_celebration_host.dart +++ b/lib/features/achievements/presentation/widgets/achievement_celebration_host.dart @@ -6,6 +6,8 @@ import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/router/app_router.dart'; import 'package:soplay/features/achievements/data/achievements_service.dart'; import 'package:soplay/features/achievements/presentation/dialogs/achievement_unlocked_dialog.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/presentation/dialogs/level_up_dialog.dart'; /// Shows badges as they are earned — but never over something being watched /// or read. @@ -37,12 +39,17 @@ class AchievementCelebrationHost extends StatefulWidget { class _AchievementCelebrationHostState extends State { AchievementsService? _service; + ProgressService? _progress; bool _showing = false; Timer? _settle; @override void initState() { super.initState(); + if (getIt.isRegistered()) { + _progress = getIt() + ..pendingLevelUp.addListener(_schedule); + } if (!getIt.isRegistered()) return; _service = getIt()..pending.addListener(_schedule); AppRouter.router.routerDelegate.addListener(_schedule); @@ -52,6 +59,7 @@ class _AchievementCelebrationHostState void dispose() { _settle?.cancel(); _service?.pending.removeListener(_schedule); + _progress?.pendingLevelUp.removeListener(_schedule); AppRouter.router.routerDelegate.removeListener(_schedule); super.dispose(); } @@ -66,7 +74,8 @@ class _AchievementCelebrationHostState Future _maybeShow() async { final service = _service; if (service == null || _showing || !mounted) return; - if (service.pending.value.isEmpty) return; + final levelUp = _progress?.pendingLevelUp.value; + if (service.pending.value.isEmpty && levelUp == null) return; final path = AppRouter.router.routerDelegate.currentConfiguration.uri.path; if (AchievementCelebrationHost.quietPaths.contains(path)) return; final context = AppRouter.router.routerDelegate.navigatorKey.currentContext; @@ -82,17 +91,31 @@ class _AchievementCelebrationHostState ); if (!context.mounted) return; final unlocks = service.takePending(); - if (unlocks.isEmpty) return; - await AchievementUnlockedDialog.show( - context, - unlocks, - view: service.state.value, - onSeeAll: () => AppRouter.router.push('/achievements'), - ); + if (unlocks.isNotEmpty) { + await AchievementUnlockedDialog.show( + context, + unlocks, + view: service.state.value, + onSeeAll: () => AppRouter.router.push('/achievements'), + ); + } + // A badge often brings the level with it; one after the other. + final up = _progress?.takeLevelUp(); + if (up != null && context.mounted) { + await LevelUpDialog.show( + context, + up, + onOpen: () => AppRouter.router.push('/progress'), + ); + } } finally { _showing = false; } - if (mounted && service.pending.value.isNotEmpty) _schedule(); + if (mounted && + (service.pending.value.isNotEmpty || + _progress?.pendingLevelUp.value != null)) { + _schedule(); + } } @override diff --git a/lib/features/auth/data/repositories/auth_repository_impl.dart b/lib/features/auth/data/repositories/auth_repository_impl.dart index ad5b2e29..ce4b5a52 100644 --- a/lib/features/auth/data/repositories/auth_repository_impl.dart +++ b/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -8,6 +8,8 @@ import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/player/player_dock.dart'; import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/features/my_list/data/datasources/my_list_local_data_source.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/data/watch_time_uploader.dart'; import 'package:soplay/features/stats/data/watch_stats_store.dart'; import 'package:soplay/features/streak/data/streak_service.dart'; import 'package:soplay/features/auth/data/models/user_model.dart'; @@ -325,6 +327,12 @@ class AuthRepositoryImpl implements AuthRepository { if (getIt.isRegistered()) { getIt().reset(); } + if (getIt.isRegistered()) { + await getIt().reset(); + } + if (getIt.isRegistered()) { + await getIt().reset(); + } // Not server-backed, and cleared for exactly that reason: nothing would // ever remove it otherwise, and a watch-time total is as personal as the // history it was counted from. diff --git a/lib/features/comments/data/models/comment_author_model.dart b/lib/features/comments/data/models/comment_author_model.dart index 513ecae5..101bfbba 100644 --- a/lib/features/comments/data/models/comment_author_model.dart +++ b/lib/features/comments/data/models/comment_author_model.dart @@ -1,4 +1,5 @@ import 'package:soplay/features/comments/domain/entities/comment_author.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; class CommentAuthorModel extends CommentAuthor { const CommentAuthorModel({ @@ -6,6 +7,7 @@ class CommentAuthorModel extends CommentAuthor { required super.username, super.displayName, super.photoURL, + super.decoration, }); factory CommentAuthorModel.fromJson(Map json) => @@ -14,5 +16,6 @@ class CommentAuthorModel extends CommentAuthor { username: json['username'] as String? ?? '', displayName: json['displayName'] as String?, photoURL: json['photoURL'] as String?, + decoration: UserDecoration.fromJson(json), ); } diff --git a/lib/features/comments/domain/entities/comment_author.dart b/lib/features/comments/domain/entities/comment_author.dart index 94f2c633..5ecae7ca 100644 --- a/lib/features/comments/domain/entities/comment_author.dart +++ b/lib/features/comments/domain/entities/comment_author.dart @@ -1,14 +1,18 @@ +import 'package:soplay/features/progress/domain/progress.dart'; + class CommentAuthor { final String id; final String username; final String? displayName; final String? photoURL; + final UserDecoration? decoration; const CommentAuthor({ required this.id, required this.username, this.displayName, this.photoURL, + this.decoration, }); String get nameOrUsername => diff --git a/lib/features/comments/presentation/widgets/comment_card.dart b/lib/features/comments/presentation/widgets/comment_card.dart index ed66865b..b9bfedde 100644 --- a/lib/features/comments/presentation/widgets/comment_card.dart +++ b/lib/features/comments/presentation/widgets/comment_card.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/comments/domain/entities/comment_entity.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; import 'package:soplay/features/reports/domain/entities/report_payload.dart'; import 'package:soplay/features/reports/presentation/widgets/report_sheet.dart'; import 'comment_avatar.dart'; @@ -61,7 +62,14 @@ class CommentCard extends StatelessWidget { '/u/${Uri.encodeComponent(comment.user.username)}', ) : null, - child: CommentAvatar(author: comment.user, size: compact ? 30 : 36), + child: FramedAvatar( + size: compact ? 30 : 36, + frame: comment.user.decoration?.frame, + child: CommentAvatar( + author: comment.user, + size: compact ? 30 : 36, + ), + ), ), const SizedBox(width: 10), Expanded( @@ -71,10 +79,9 @@ class CommentCard extends StatelessWidget { Row( children: [ Flexible( - child: Text( - comment.user.nameOrUsername, - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: UserBadgeLine( + name: comment.user.nameOrUsername, + decoration: comment.user.decoration, style: const TextStyle( color: AppColors.textPrimary, fontSize: 13, diff --git a/lib/features/detail/presentation/pages/player_page.dart b/lib/features/detail/presentation/pages/player_page.dart index 31055dfb..6f25d259 100644 --- a/lib/features/detail/presentation/pages/player_page.dart +++ b/lib/features/detail/presentation/pages/player_page.dart @@ -14,6 +14,7 @@ import 'package:soplay/core/player/color_profile.dart'; import 'package:soplay/core/player/shader_presets.dart'; import 'package:soplay/core/player/shader_store.dart'; import 'package:soplay/features/detail/data/title_prefs_store.dart'; +import 'package:soplay/features/progress/data/watch_time_uploader.dart'; import 'package:soplay/features/stats/data/watch_stats_store.dart'; import 'package:soplay/core/network/external_dio.dart'; import 'package:soplay/core/network/cf_bypass_service.dart'; diff --git a/lib/features/detail/presentation/pages/player_page.history.dart b/lib/features/detail/presentation/pages/player_page.history.dart index 5ea409e3..71434454 100644 --- a/lib/features/detail/presentation/pages/player_page.history.dart +++ b/lib/features/detail/presentation/pages/player_page.history.dart @@ -161,7 +161,13 @@ extension _PlayerHistory on _PlayerPageState { if (elapsed <= 0) return; if (_hive.isIncognito) return; unawaited( - _watchStats.record(seconds: elapsed, provider: widget.args.provider), + _watchStats + .record(seconds: elapsed, provider: widget.args.provider) + .then((_) { + if (getIt.isRegistered()) { + getIt().onWatched(); + } + }), ); } diff --git a/lib/features/profile/data/backup_service.dart b/lib/features/profile/data/backup_service.dart index 58102c13..5aa758be 100644 --- a/lib/features/profile/data/backup_service.dart +++ b/lib/features/profile/data/backup_service.dart @@ -107,6 +107,9 @@ class BackupService { // Which household profile this device acts as; the account decides that. ProfileSession.cacheKey, ProfileSession.activeKey, + // Names this device to the XP counter; a copy on a second device would + // make the two share one watch-time total. + 'progress_device_id', }; /// The file always holds one person's data under the plain names, so it diff --git a/lib/features/profile/presentation/pages/profile_page.dart b/lib/features/profile/presentation/pages/profile_page.dart index f3a28419..5177a08e 100644 --- a/lib/features/profile/presentation/pages/profile_page.dart +++ b/lib/features/profile/presentation/pages/profile_page.dart @@ -50,6 +50,9 @@ import 'package:soplay/core/storage/profile_scope.dart'; import 'package:soplay/features/social/data/social_service.dart'; import 'package:soplay/features/social/domain/social_models.dart'; import 'package:soplay/features/achievements/presentation/widgets/achievements_entry_card.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; // Split into parts the way player_page.dart is: the page was 2721 lines // across nine sections, which is past the point where anyone can find diff --git a/lib/features/profile/presentation/pages/profile_page.header.dart b/lib/features/profile/presentation/pages/profile_page.header.dart index f1ea1594..fde32c19 100644 --- a/lib/features/profile/presentation/pages/profile_page.header.dart +++ b/lib/features/profile/presentation/pages/profile_page.header.dart @@ -186,11 +186,23 @@ class _UserContent extends StatelessWidget { if (user.authProvider != 'local') 'Google', ].join(' · '); - return Padding( + return ValueListenableBuilder( + valueListenable: getIt.isRegistered() + ? getIt().state + : _noProgress, + builder: (context, progress, _) => Padding( padding: const EdgeInsets.all(16), child: Row( children: [ - _Avatar(user: user), + GestureDetector( + onTap: progress == null ? null : () => context.push('/progress'), + child: FramedAvatar( + size: 72, + radius: 20, + frame: progress?.frame, + child: _Avatar(user: user), + ), + ), const SizedBox(width: 14), Expanded( child: Column( @@ -199,7 +211,9 @@ class _UserContent extends StatelessWidget { Text( name, style: TextStyle( - color: AppColors.textPrimary, + color: + Cosmetics.nameColor(progress?.nameColor) ?? + AppColors.textPrimary, fontSize: 19, fontWeight: FontWeight.w800, height: lineHeightFor(context, 1.15), @@ -229,6 +243,10 @@ class _UserContent extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ], + if (progress != null) ...[ + const SizedBox(height: 8), + _LevelLink(view: progress), + ], ], ), ), @@ -236,6 +254,52 @@ class _UserContent extends StatelessWidget { _EditProfileButton(user: user), ], ), + ), + ); + } +} + +final ValueNotifier _noProgress = ValueNotifier(null); + +/// The level under the name; the way into the progress page. +class _LevelLink extends StatelessWidget { + const _LevelLink({required this.view}); + + final ProgressView view; + + @override + Widget build(BuildContext context) { + final p = view.progress; + final title = Cosmetics.titleText(view.title); + return InkWell( + onTap: () => context.push('/progress'), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + LevelChip(level: p.level, fontSize: 11), + const SizedBox(width: 8), + Flexible( + child: Text( + title ?? 'progress.to_next_short'.tr(args: ['${p.toNext}']), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12, + ), + ), + ), + const Icon( + Icons.chevron_right_rounded, + size: 16, + color: AppColors.textHint, + ), + ], + ), + ), ); } } diff --git a/lib/features/progress/data/progress_remote_data_source.dart b/lib/features/progress/data/progress_remote_data_source.dart new file mode 100644 index 00000000..19d50c45 --- /dev/null +++ b/lib/features/progress/data/progress_remote_data_source.dart @@ -0,0 +1,31 @@ +import 'package:dio/dio.dart'; + +class ProgressRemoteDataSource { + const ProgressRemoteDataSource({required this.dio}); + + final Dio dio; + + Map _body(Response res) { + final data = res.data; + return data is Map ? data.cast() : const {}; + } + + Future> me() async => + _body(await dio.get('/progress/me')); + + /// [seconds] is this device's running total for local [day], not a delta: + /// the server credits only the rise, so a retry adds nothing. + Future> watchTime({ + required String day, + required int seconds, + required String deviceId, + }) async => _body( + await dio.post( + '/progress/watch-time', + data: {'day': day, 'seconds': seconds, 'deviceId': deviceId}, + ), + ); + + Future> equip(Map slots) async => + _body(await dio.put('/progress/cosmetics', data: slots)); +} diff --git a/lib/features/progress/data/progress_service.dart b/lib/features/progress/data/progress_service.dart new file mode 100644 index 00000000..0a229497 --- /dev/null +++ b/lib/features/progress/data/progress_service.dart @@ -0,0 +1,147 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/storage/profile_scope.dart'; +import 'package:soplay/features/progress/data/progress_remote_data_source.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; + +/// A level reached since the viewer last saw their level, and what it opened. +class LevelUp { + const LevelUp({required this.from, required this.to, this.unlocked = const []}); + + final int from; + final int to; + final List unlocked; + + /// What reaching [level] means against the [seen] level, if anything. The + /// first level ever seen is a baseline, not a celebration. + static LevelUp? between(int? seen, ProgressView view) { + final level = view.level; + if (seen == null || level <= seen) return null; + return LevelUp( + from: seen, + to: level, + unlocked: [ + for (final c in view.catalog) + if (c.level != null && c.level! > seen && c.level! <= level) c, + ], + ); + } +} + +/// The active profile's XP, level and cosmetics. +/// +/// Null [state] means nothing is known — signed out, or a server without +/// `/progress` yet — and every decoration hides. +class ProgressService { + ProgressService({ + required ProgressRemoteDataSource remote, + required HiveService hive, + }) : _remote = remote, + _hive = hive { + state.value = _readCache(); + } + + final ProgressRemoteDataSource _remote; + final HiveService _hive; + + final ValueNotifier state = ValueNotifier(null); + final ValueNotifier pendingLevelUp = ValueNotifier(null); + + static const String viewKey = 'progress_view'; + static const String seenLevelKey = 'progress_seen_level'; + + Box? get _box { + try { + return Hive.box(AppConstants.settingsBox); + } catch (_) { + return null; + } + } + + ProgressView? _readCache() { + try { + final raw = _box?.get(ProfileScope.key(viewKey)); + if (raw is String && raw.isNotEmpty) { + return ProgressView.fromJson( + (jsonDecode(raw) as Map).cast(), + ); + } + } catch (_) {} + return null; + } + + /// Takes a progress body from any `/progress` answer. + Future apply(Map raw) async { + if (raw['xp'] is! num) return; + final view = ProgressView.fromJson(raw); + state.value = view; + final box = _box; + try { + await box?.put(ProfileScope.key(viewKey), jsonEncode(raw)); + final seen = box?.get(ProfileScope.key(seenLevelKey)); + final up = LevelUp.between(seen is int ? seen : null, view); + if (up != null) { + final had = pendingLevelUp.value; + pendingLevelUp.value = had == null + ? up + : LevelUp( + from: had.from, + to: up.to, + unlocked: [...had.unlocked, ...up.unlocked], + ); + } + if (seen is! int || view.level > seen) { + await box?.put(ProfileScope.key(seenLevelKey), view.level); + } + } catch (_) {} + } + + bool _inFlight = false; + + Future refresh() async { + if (!_hive.isLoggedIn || _inFlight) return; + _inFlight = true; + try { + await apply(await _remote.me()); + } catch (e) { + debugPrint('[progress] refresh failed: $e'); + } finally { + _inFlight = false; + } + } + + /// Puts [id] on [slot], or takes the slot off with null. Throws on failure + /// so the locker can say so. + Future equip(CosmeticSlot slot, String? id) async { + await apply(await _remote.equip({slot.name: id})); + } + + LevelUp? takeLevelUp() { + final out = pendingLevelUp.value; + pendingLevelUp.value = null; + return out; + } + + /// A profile switch: what is on screen belonged to someone else. + void reload() { + pendingLevelUp.value = null; + state.value = _readCache(); + unawaited(refresh()); + } + + /// Sign-out: nothing of the account stays on the device. + Future reset() async { + pendingLevelUp.value = null; + state.value = null; + try { + await _box?.delete(ProfileScope.key(viewKey)); + await _box?.delete(ProfileScope.key(seenLevelKey)); + } catch (_) {} + } +} diff --git a/lib/features/progress/data/watch_time_uploader.dart b/lib/features/progress/data/watch_time_uploader.dart new file mode 100644 index 00000000..3d377eb4 --- /dev/null +++ b/lib/features/progress/data/watch_time_uploader.dart @@ -0,0 +1,193 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:dio/dio.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/storage/profile_scope.dart'; +import 'package:soplay/features/progress/data/progress_remote_data_source.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/stats/data/watch_stats_store.dart'; + +/// Which days' watch time still has to reach the server. +/// +/// The server is sent each day's running total, and it credits the rise since +/// the last total it saw. So what is pending is just the gap between +/// [WatchStatsStore.byDay] and what was last sent — it survives being offline +/// with nothing extra to keep, and a retry after a lost answer adds nothing. +class WatchTimeBatch { + WatchTimeBatch._(); + + static const Duration interval = Duration(minutes: 10); + + /// Today and yesterday: the server takes nothing older. + static List> due({ + required Map byDay, + required Map sent, + required DateTime now, + }) { + final days = [dayKey(now.subtract(const Duration(days: 1))), dayKey(now)]; + return [ + for (final d in days) + if ((byDay[d] ?? 0) > (sent[d] ?? 0)) MapEntry(d, byDay[d]!), + ]; + } + + /// [sent] without the days that can no longer be sent. + static Map pruned(Map sent, DateTime now) { + final oldest = dayKey(now.subtract(const Duration(days: 7))); + return { + for (final e in sent.entries) + if (e.key.compareTo(oldest) >= 0) e.key: e.value, + }; + } + + /// Whether a tick while watching should send now. + static bool shouldFlush(DateTime? last, DateTime now) => + last == null || now.difference(last) >= interval; + + static String dayKey(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-' + '${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; +} + +/// Sends watch time for XP: every ten minutes while something plays, and when +/// the app goes to the background. +class WatchTimeUploader with WidgetsBindingObserver { + WatchTimeUploader({ + required ProgressRemoteDataSource remote, + required HiveService hive, + required ProgressService progress, + WatchStatsStore? stats, + DateTime Function()? clock, + }) : _remote = remote, + _hive = hive, + _progress = progress, + _stats = stats ?? WatchStatsStore(), + _clock = clock ?? DateTime.now; + + final ProgressRemoteDataSource _remote; + final HiveService _hive; + final ProgressService _progress; + final WatchStatsStore _stats; + final DateTime Function() _clock; + + static const String sentKey = 'progress_watch_sent'; + static const String _deviceKey = 'progress_device_id'; + + DateTime? _lastFlush; + Future? _running; + + Box? get _box { + try { + return Hive.box(AppConstants.settingsBox); + } catch (_) { + return null; + } + } + + String get _deviceId { + final box = _box; + final had = box?.get(_deviceKey); + if (had is String && had.isNotEmpty) return had; + final rnd = math.Random.secure(); + final id = List.generate( + 16, + (_) => rnd.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + unawaited(box?.put(_deviceKey, id)); + return id; + } + + Map _readSent() { + final raw = _box?.get(ProfileScope.key(sentKey)); + if (raw is! Map) return {}; + return { + for (final e in raw.entries) + if (e.value is num) e.key.toString(): (e.value as num).toInt(), + }; + } + + void start() { + WidgetsBinding.instance.addObserver(this); + _lastFlush = _clock(); + unawaited(flush()); + unawaited(_progress.refresh()); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + unawaited(flush()); + case AppLifecycleState.resumed: + unawaited(_progress.refresh()); + default: + break; + } + } + + /// A tick of watching was just recorded. + void onWatched() { + if (WatchTimeBatch.shouldFlush(_lastFlush, _clock())) unawaited(flush()); + } + + /// Sends whatever is pending. A failure leaves it pending for next time. + Future flush() => _running ??= _flush().whenComplete(() { + _running = null; + }); + + Future _flush() async { + _lastFlush = _clock(); + if (!_hive.isLoggedIn) return; + final now = _clock(); + var sent = WatchTimeBatch.pruned(_readSent(), now); + final due = WatchTimeBatch.due( + byDay: _stats.byDay, + sent: sent, + now: now, + ); + if (due.isEmpty) return; + final device = _deviceId; + final key = ProfileScope.key(sentKey); + for (final entry in due) { + try { + final res = await _remote.watchTime( + day: entry.key, + seconds: entry.value, + deviceId: device, + ); + sent = {...sent, entry.key: entry.value}; + await _box?.put(key, sent); + final progress = res['progress']; + if (progress is Map) { + await _progress.apply(progress.cast()); + } + } on DioException catch (e) { + // A day the server will never take would block every day after it. + if (e.response?.statusCode == 400) { + sent = {...sent, entry.key: entry.value}; + await _box?.put(key, sent); + continue; + } + debugPrint('[progress] watch-time upload failed: $e'); + return; + } catch (e) { + debugPrint('[progress] watch-time upload failed: $e'); + return; + } + } + } + + /// Sign-out: the totals this was measured against are gone too. + Future reset() async { + try { + await _box?.delete(ProfileScope.key(sentKey)); + } catch (_) {} + } +} diff --git a/lib/features/progress/domain/progress.dart b/lib/features/progress/domain/progress.dart new file mode 100644 index 00000000..5c3137d9 --- /dev/null +++ b/lib/features/progress/domain/progress.dart @@ -0,0 +1,221 @@ +/// XP, levels and cosmetics, as `/progress` describes them. Parsing is +/// lenient for the same reason as everywhere else: an old app keeps talking to +/// a newer server. +library; + +import 'dart:math' as math; + +/// Cumulative XP level [n] needs: 100·n^1.5, the server's own curve. +int xpForLevel(int n) => n <= 0 ? 0 : (100 * math.pow(n, 1.5)).floor(); + +/// The level [xp] cumulative XP reaches. +int levelFor(int xp) { + if (xp <= 0) return 0; + var n = math.pow(xp / 100, 2 / 3).floor(); + while (xpForLevel(n + 1) <= xp) { + n++; + } + while (n > 0 && xpForLevel(n) > xp) { + n--; + } + return n; +} + +/// Where an XP total sits inside its level, for a ring or a bar. +class LevelProgress { + const LevelProgress._(this.level, this.xp, this.floor, this.next); + + factory LevelProgress.of(int xp) { + final x = math.max(0, xp); + final level = levelFor(x); + return LevelProgress._(level, x, xpForLevel(level), xpForLevel(level + 1)); + } + + final int level; + final int xp; + + /// XP the current level started at. + final int floor; + + /// XP the next level starts at. + final int next; + + int get intoLevel => xp - floor; + int get span => next - floor; + int get toNext => next - xp; + double get fraction => + span <= 0 ? 0 : (intoLevel / span).clamp(0.0, 1.0).toDouble(); +} + +int _int(Object? v) => v is num ? v.toInt() : 0; + +String? _str(Object? v) { + if (v is! String) return null; + return v.isEmpty ? null : v; +} + +Map _map(Object? v) => + v is Map ? v.cast() : const {}; + +/// What others see beside a name. Null wherever the server sent no level, +/// which is also what a server without this feature sends. +class UserDecoration { + const UserDecoration({ + required this.level, + this.frame, + this.title, + this.nameColor, + }); + + final int level; + final String? frame; + final String? title; + final String? nameColor; + + static UserDecoration? fromJson(Map json) { + final level = json['level']; + if (level is! num) return null; + return UserDecoration( + level: level.toInt(), + frame: _str(json['frame']), + title: _str(json['title']), + nameColor: _str(json['nameColor']), + ); + } +} + +enum CosmeticSlot { + frame, + title, + nameColor; + + static CosmeticSlot? parse(String raw) => switch (raw) { + 'frame' => frame, + 'title' => title, + 'nameColor' => nameColor, + _ => null, + }; +} + +/// One frame, title or colour, and what unlocks it. +class CosmeticItem { + const CosmeticItem({ + required this.slot, + required this.id, + required this.unlocked, + this.level, + this.badge, + this.tier = 1, + }); + + final CosmeticSlot slot; + final String id; + final bool unlocked; + + /// The level that unlocks it, or null when a badge does. + final int? level; + final String? badge; + final int tier; + + factory CosmeticItem.fromJson(CosmeticSlot slot, Map json) { + final unlock = _map(json['unlock']); + return CosmeticItem( + slot: slot, + id: _str(json['id']) ?? '', + unlocked: json['unlocked'] == true, + level: unlock['level'] is num ? _int(unlock['level']) : null, + badge: _str(unlock['badge']), + tier: unlock['tier'] is num ? _int(unlock['tier']) : 1, + ); + } +} + +class XpGain { + const XpGain({required this.reason, required this.xp, this.at}); + + final String reason; + final int xp; + final DateTime? at; + + factory XpGain.fromJson(Map json) => XpGain( + reason: _str(json['reason']) ?? '', + xp: _int(json['xp']), + at: json['at'] is String + ? DateTime.tryParse(json['at'] as String)?.toLocal() + : null, + ); +} + +class ProgressView { + const ProgressView({ + required this.xp, + this.watchSeconds = 0, + this.commentCount = 0, + this.frame, + this.title, + this.nameColor, + this.recent = const [], + this.catalog = const [], + this.watchXpToday = 0, + this.watchCap = 300, + }); + + final int xp; + final int watchSeconds; + final int commentCount; + final String? frame; + final String? title; + final String? nameColor; + final List recent; + final List catalog; + final int watchXpToday; + final int watchCap; + + LevelProgress get progress => LevelProgress.of(xp); + int get level => progress.level; + + UserDecoration get decoration => UserDecoration( + level: level, + frame: frame, + title: title, + nameColor: nameColor, + ); + + List slot(CosmeticSlot s) => + catalog.where((c) => c.slot == s && c.id.isNotEmpty).toList(); + + String? equipped(CosmeticSlot s) => switch (s) { + CosmeticSlot.frame => frame, + CosmeticSlot.title => title, + CosmeticSlot.nameColor => nameColor, + }; + + factory ProgressView.fromJson(Map json) { + final equipped = _map(json['equipped']); + final today = _map(json['today']); + final catalog = []; + for (final e in _map(json['catalog']).entries) { + final slot = CosmeticSlot.parse(e.key); + if (slot == null || e.value is! List) continue; + for (final item in (e.value as List).whereType()) { + catalog.add(CosmeticItem.fromJson(slot, item.cast())); + } + } + return ProgressView( + xp: _int(json['xp']), + watchSeconds: _int(json['watchSeconds']), + commentCount: _int(json['commentCount']), + frame: _str(equipped['frame']), + title: _str(equipped['title']), + nameColor: _str(equipped['nameColor']), + recent: [ + if (json['recent'] is List) + for (final g in (json['recent'] as List).whereType()) + XpGain.fromJson(g.cast()), + ], + catalog: catalog, + watchXpToday: _int(today['watchXp']), + watchCap: today['watchCap'] is num ? _int(today['watchCap']) : 300, + ); + } +} diff --git a/lib/features/progress/presentation/dialogs/level_up_dialog.dart b/lib/features/progress/presentation/dialogs/level_up_dialog.dart new file mode 100644 index 00000000..b99ec033 --- /dev/null +++ b/lib/features/progress/presentation/dialogs/level_up_dialog.dart @@ -0,0 +1,150 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/presentation/widgets/level_ring.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; + +/// "Level 12": the new level, and what it opened in the locker. +class LevelUpDialog extends StatelessWidget { + const LevelUpDialog({super.key, required this.levelUp, this.onOpen}); + + final LevelUp levelUp; + final VoidCallback? onOpen; + + static Future show( + BuildContext context, + LevelUp levelUp, { + VoidCallback? onOpen, + }) { + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'Level up', + barrierColor: Colors.black.withValues(alpha: 0.8), + transitionDuration: const Duration(milliseconds: 320), + pageBuilder: (_, _, _) => LevelUpDialog(levelUp: levelUp, onOpen: onOpen), + transitionBuilder: (_, anim, _, child) { + final t = Curves.easeOutBack.transform(anim.value.clamp(0.0, 1.0)); + return Opacity( + opacity: anim.value, + child: Transform.scale(scale: 0.85 + 0.15 * t, child: child), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final unlocked = levelUp.unlocked + .where((c) => Cosmetics.isKnown(c.slot, c.id)) + .take(4) + .toList(); + return Material( + type: MaterialType.transparency, + child: Center( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 28), + constraints: const BoxConstraints(maxWidth: 360), + padding: const EdgeInsets.fromLTRB(24, 28, 24, 18), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.surface, AppColors.background], + ), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.3), + width: 0.8, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.18), + blurRadius: 34, + spreadRadius: 2, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'progress.level_up'.tr().toUpperCase(), + style: TextStyle( + color: AppColors.primaryLight, + fontSize: 12, + fontWeight: FontWeight.w800, + letterSpacing: 1.8, + ), + ), + const SizedBox(height: 18), + LevelRing(level: levelUp.to, fraction: 1, size: 132), + const SizedBox(height: 16), + Text( + 'progress.level_reached'.tr(args: ['${levelUp.to}']), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + if (unlocked.isNotEmpty) ...[ + const SizedBox(height: 14), + Text( + 'progress.unlocked_new'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + ), + ), + const SizedBox(height: 8), + Wrap( + alignment: WrapAlignment.center, + spacing: 6, + runSpacing: 6, + children: [ + for (final c in unlocked) + Chip( + label: Text(Cosmetics.label(c.slot, c.id)), + visualDensity: VisualDensity.compact, + backgroundColor: Colors.white.withValues(alpha: 0.06), + side: BorderSide.none, + labelStyle: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + ), + ), + ], + ), + ], + const SizedBox(height: 18), + Row( + children: [ + if (onOpen != null) + Expanded( + child: TextButton( + onPressed: () { + Navigator.of(context).pop(); + onOpen!(); + }, + child: Text('progress.open_locker'.tr()), + ), + ), + Expanded( + child: FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('achievements.great'.tr()), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/progress/presentation/pages/progress_page.dart b/lib/features/progress/presentation/pages/progress_page.dart new file mode 100644 index 00000000..d2a57b18 --- /dev/null +++ b/lib/features/progress/presentation/pages/progress_page.dart @@ -0,0 +1,583 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/achievements/domain/achievements.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; +import 'package:soplay/features/progress/presentation/widgets/level_ring.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; +import 'package:soplay/features/streak/presentation/widgets/streak_badge.dart'; + +/// The level, how it was earned, and the locker of what it unlocks. +class ProgressPage extends StatefulWidget { + const ProgressPage({super.key}); + + @override + State createState() => _ProgressPageState(); +} + +class _ProgressPageState extends State { + final ProgressService _service = getIt(); + bool _loading = true; + String? _saving; + + @override + void initState() { + super.initState(); + _service.state.addListener(_onChange); + _service.refresh().whenComplete(() { + if (mounted) setState(() => _loading = false); + }); + } + + void _onChange() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _service.state.removeListener(_onChange); + super.dispose(); + } + + Future _equip(CosmeticItem item, bool equipped) async { + if (_saving != null) return; + setState(() => _saving = '${item.slot.name}:${item.id}'); + try { + await _service.equip(item.slot, equipped ? null : item.id); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text('achievements.save_failed'.tr())), + ); + } + } finally { + if (mounted) setState(() => _saving = null); + } + } + + @override + Widget build(BuildContext context) { + final view = _service.state.value; + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + title: Text('progress.title'.tr()), + actions: const [StreakBadge(), SizedBox(width: 8)], + ), + body: view == null + ? Center( + child: _loading + ? const CircularProgressIndicator() + : Padding( + padding: const EdgeInsets.all(32), + child: Text( + 'progress.unavailable'.tr(), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.textSecondary), + ), + ), + ) + : RefreshIndicator( + onRefresh: _service.refresh, + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + children: [ + _LevelCard(view: view), + const SizedBox(height: 16), + _SectionTitle('progress.how_to_earn'.tr()), + const _Rules(), + if (view.recent.isNotEmpty) ...[ + const SizedBox(height: 16), + _SectionTitle('progress.recent'.tr()), + _Recent(gains: view.recent.take(8).toList()), + ], + const SizedBox(height: 16), + _SectionTitle('progress.locker'.tr()), + for (final slot in CosmeticSlot.values) + _LockerSlot( + slot: slot, + view: view, + saving: _saving, + onTap: _equip, + ), + ], + ), + ), + ); + } +} + +class _SectionTitle extends StatelessWidget { + const _SectionTitle(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), + child: Text( + text, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ); + } +} + +BoxDecoration _card() => BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withValues(alpha: 0.06), width: 0.5), +); + +class _LevelCard extends StatelessWidget { + const _LevelCard({required this.view}); + + final ProgressView view; + + @override + Widget build(BuildContext context) { + final p = view.progress; + final user = getIt().getUser(); + final display = user?.displayName?.trim(); + final name = display != null && display.isNotEmpty + ? display + : user?.displayIdentifier ?? ''; + final photo = user?.photoURL; + return Container( + padding: const EdgeInsets.all(18), + decoration: _card(), + child: Column( + children: [ + Row( + children: [ + LevelRing(level: p.level, fraction: p.fraction, size: 112), + const SizedBox(width: 18), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'progress.xp_total'.tr(args: ['${p.xp}']), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 4), + Text( + 'progress.to_next'.tr( + args: ['${p.toNext}', '${p.level + 1}'], + ), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + ), + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: view.watchCap <= 0 + ? 0.0 + : (view.watchXpToday / view.watchCap).clamp( + 0.0, + 1.0, + ), + minHeight: 5, + backgroundColor: Colors.white.withValues(alpha: 0.08), + ), + ), + const SizedBox(height: 4), + Text( + 'progress.watch_today'.tr( + args: ['${view.watchXpToday}', '${view.watchCap}'], + ), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + ), + ), + ], + ), + ), + ], + ), + if (name.isNotEmpty) ...[ + const SizedBox(height: 16), + Row( + children: [ + FramedAvatar( + size: 40, + frame: view.frame, + child: CircleAvatar( + radius: 20, + backgroundColor: AppColors.surfaceVariant, + backgroundImage: photo != null && photo.isNotEmpty + ? NetworkImage(photo) + : null, + child: photo != null && photo.isNotEmpty + ? null + : Text(name.characters.first.toUpperCase()), + ), + ), + const SizedBox(width: 12), + Expanded( + child: UserBadgeLine( + name: name, + decoration: view.decoration, + showTitle: true, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ], + ], + ), + ); + } +} + +class _Rules extends StatelessWidget { + const _Rules(); + + @override + Widget build(BuildContext context) { + final rules = [ + (Icons.play_circle_outline_rounded, 'progress.rule_watch'.tr()), + (Icons.chat_bubble_outline_rounded, 'progress.rule_comment'.tr()), + (Icons.favorite_border_rounded, 'progress.rule_like'.tr()), + (Icons.local_fire_department_rounded, 'progress.rule_streak'.tr()), + (Icons.emoji_events_outlined, 'progress.rule_achievement'.tr()), + ]; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: _card(), + child: Column( + children: [ + for (final (icon, text) in rules) + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Icon(icon, size: 18, color: AppColors.primaryLight), + const SizedBox(width: 12), + Expanded( + child: Text( + text, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 13, + height: 1.3, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +String _reasonKey(String reason) => switch (reason) { + 'watch' => 'progress.reason_watch', + 'comment' => 'progress.reason_comment', + 'like' => 'progress.reason_like', + 'streak' => 'progress.reason_streak', + 'achievement' => 'progress.reason_achievement', + _ => 'progress.reason_other', +}; + +class _Recent extends StatelessWidget { + const _Recent({required this.gains}); + + final List gains; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: _card(), + child: Column( + children: [ + for (final g in gains) + Padding( + padding: const EdgeInsets.symmetric(vertical: 7), + child: Row( + children: [ + Expanded( + child: Text( + _reasonKey(g.reason).tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 13.5, + ), + ), + ), + if (g.at != null) + Padding( + padding: const EdgeInsets.only(right: 10), + child: Text( + DateFormat.MMMd( + context.locale.toString(), + ).format(g.at!), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + ), + ), + ), + Text( + 'progress.xp_gain'.tr(args: ['${g.xp}']), + style: TextStyle( + color: AppColors.primaryLight, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +String _slotKey(CosmeticSlot slot) => switch (slot) { + CosmeticSlot.frame => 'progress.slot_frame', + CosmeticSlot.title => 'progress.slot_title', + CosmeticSlot.nameColor => 'progress.slot_color', +}; + +/// How to get a locked item: a level, or a badge at a tier. +String unlockHint(CosmeticItem item) { + final badge = item.badge; + if (item.level != null || badge == null) { + return 'progress.unlock_level'.tr(args: ['${item.level ?? 0}']); + } + final def = AchievementDef.of(badge); + final name = def.nameKey.tr(); + if (def.isSingle) return 'progress.unlock_badge'.tr(args: [name]); + return 'progress.unlock_badge'.tr( + args: ['$name · ${MedalTier.ofLevel(item.tier).labelKey.tr()}'], + ); +} + +class _LockerSlot extends StatelessWidget { + const _LockerSlot({ + required this.slot, + required this.view, + required this.saving, + required this.onTap, + }); + + final CosmeticSlot slot; + final ProgressView view; + final String? saving; + final Future Function(CosmeticItem item, bool equipped) onTap; + + @override + Widget build(BuildContext context) { + final items = view + .slot(slot) + .where((c) => Cosmetics.isKnown(slot, c.id)) + .toList(); + if (items.isEmpty) return const SizedBox.shrink(); + final current = view.equipped(slot); + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Container( + padding: const EdgeInsets.all(12), + decoration: _card(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(2, 0, 2, 10), + child: Text( + _slotKey(slot).tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + fontWeight: FontWeight.w700, + ), + ), + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final item in items) + _LockerItem( + item: item, + equipped: current == item.id, + busy: saving == '${slot.name}:${item.id}', + onTap: item.unlocked + ? () => onTap(item, current == item.id) + : null, + ), + ], + ), + ], + ), + ), + ); + } +} + +class _LockerItem extends StatelessWidget { + const _LockerItem({ + required this.item, + required this.equipped, + required this.busy, + required this.onTap, + }); + + final CosmeticItem item; + final bool equipped; + final bool busy; + final VoidCallback? onTap; + + Widget _preview() { + switch (item.slot) { + case CosmeticSlot.frame: + return FramedAvatar( + size: 34, + frame: item.id, + child: const CircleAvatar( + radius: 17, + backgroundColor: Color(0xFF2A2A2A), + child: Icon(Icons.person_rounded, size: 20, color: Colors.white54), + ), + ); + case CosmeticSlot.nameColor: + return Container( + width: 26, + height: 26, + decoration: BoxDecoration( + color: Cosmetics.nameColor(item.id), + shape: BoxShape.circle, + ), + ); + case CosmeticSlot.title: + return const Icon( + Icons.workspace_premium_outlined, + size: 24, + color: AppColors.textSecondary, + ); + } + } + + @override + Widget build(BuildContext context) { + final locked = !item.unlocked; + final label = Cosmetics.label(item.slot, item.id); + return Tooltip( + message: locked ? unlockHint(item) : label, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + width: 100, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), + decoration: BoxDecoration( + color: equipped + ? AppColors.primary.withValues(alpha: 0.14) + : Colors.white.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: equipped + ? AppColors.primary.withValues(alpha: 0.6) + : Colors.white.withValues(alpha: 0.06), + ), + ), + child: Opacity( + opacity: locked ? 0.5 : 1, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 44, + child: Center( + child: busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : _preview(), + ), + ), + const SizedBox(height: 6), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: item.slot == CosmeticSlot.nameColor + ? Cosmetics.nameColor(item.id) + : AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (locked) ...[ + const Icon( + Icons.lock_outline_rounded, + size: 11, + color: AppColors.textHint, + ), + const SizedBox(width: 3), + ], + Flexible( + child: Text( + locked + ? unlockHint(item) + : equipped + ? 'progress.equipped'.tr() + : 'progress.equip'.tr(), + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 10.5, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/progress/presentation/widgets/level_ring.dart b/lib/features/progress/presentation/widgets/level_ring.dart new file mode 100644 index 00000000..98509181 --- /dev/null +++ b/lib/features/progress/presentation/widgets/level_ring.dart @@ -0,0 +1,108 @@ +import 'dart:math' as math; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/theme/app_colors.dart'; + +/// The level in the middle, the way through it around the edge. +class LevelRing extends StatelessWidget { + const LevelRing({ + super.key, + required this.level, + required this.fraction, + this.size = 120, + }); + + final int level; + final double fraction; + final double size; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size, + height: size, + child: TweenAnimationBuilder( + tween: Tween(begin: 0, end: fraction), + duration: const Duration(milliseconds: 700), + curve: Curves.easeOutCubic, + builder: (_, t, child) => CustomPaint( + painter: _RingPainter( + fraction: t, + color: AppColors.primary, + track: Colors.white.withValues(alpha: 0.08), + stroke: size * 0.075, + ), + child: child, + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'progress.level'.tr().toUpperCase(), + style: TextStyle( + color: AppColors.textSecondary, + fontSize: size * 0.09, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + Text( + '$level', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: size * 0.32, + fontWeight: FontWeight.w900, + height: 1.05, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _RingPainter extends CustomPainter { + _RingPainter({ + required this.fraction, + required this.color, + required this.track, + required this.stroke, + }); + + final double fraction; + final Color color; + final Color track; + final double stroke; + + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + final arc = rect.deflate(stroke / 2); + final base = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..color = track; + canvas.drawArc(arc, 0, math.pi * 2, false, base); + if (fraction <= 0) return; + final fill = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round + ..shader = SweepGradient( + startAngle: -math.pi / 2, + endAngle: math.pi * 1.5, + colors: [color.withValues(alpha: 0.6), color], + transform: const GradientRotation(-math.pi / 2), + ).createShader(rect); + canvas.drawArc(arc, -math.pi / 2, math.pi * 2 * fraction, false, fill); + } + + @override + bool shouldRepaint(_RingPainter old) => + old.fraction != fraction || old.color != color || old.stroke != stroke; +} diff --git a/lib/features/progress/presentation/widgets/user_badge_line.dart b/lib/features/progress/presentation/widgets/user_badge_line.dart new file mode 100644 index 00000000..59399cc2 --- /dev/null +++ b/lib/features/progress/presentation/widgets/user_badge_line.dart @@ -0,0 +1,221 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; + +/// How each cosmetic id looks. The server only stores ids; an id this build +/// does not know draws as nothing rather than as a broken frame. +class Cosmetics { + Cosmetics._(); + + static const Map> frames = { + 'bronze': [Color(0xFFE0A374), Color(0xFF9C5B2E)], + 'silver': [Color(0xFFF2F2F2), Color(0xFF9DA3AB)], + 'gold': [Color(0xFFFFE58A), Color(0xFFD4A017)], + 'neon': [Color(0xFF3DF5FF), Color(0xFFB44DFF)], + 'sakura': [Color(0xFFFFC6DD), Color(0xFFFF6FA5)], + 'ember': [Color(0xFFFFC078), Color(0xFFEF4A1A)], + 'flame': [Color(0xFFFFE066), Color(0xFFFF7A1A), Color(0xFFE5243B)], + 'moon': [Color(0xFFDDE6FF), Color(0xFF4C5BD4)], + }; + + static const Map nameColors = { + 'sky': Color(0xFF6EC1FF), + 'mint': Color(0xFF5EE6B0), + 'gold': Color(0xFFFFD45E), + 'rose': Color(0xFFFF7FA8), + 'violet': Color(0xFFB98CFF), + 'crimson': Color(0xFFFF5A5F), + 'aurora': Color(0xFF7CF2D8), + 'ember': Color(0xFFFF9F43), + }; + + static const Set titles = { + 'newcomer', + 'regular', + 'veteran', + 'legend', + 'binger', + 'cinephile', + 'otaku', + 'bookworm', + 'night_owl', + 'host', + }; + + static List? frame(String? id) => id == null ? null : frames[id]; + + static Color? nameColor(String? id) => id == null ? null : nameColors[id]; + + /// The words for a title, or null for one this build has none for. + static String? titleText(String? id) => + id != null && titles.contains(id) ? 'progress.title_$id'.tr() : null; + + static bool isKnown(CosmeticSlot slot, String id) => switch (slot) { + CosmeticSlot.frame => frames.containsKey(id), + CosmeticSlot.title => titles.contains(id), + CosmeticSlot.nameColor => nameColors.containsKey(id), + }; + + /// The name a locker shows for any cosmetic. + static String label(CosmeticSlot slot, String id) => switch (slot) { + CosmeticSlot.frame => 'progress.frame_$id'.tr(), + CosmeticSlot.title => 'progress.title_$id'.tr(), + CosmeticSlot.nameColor => 'progress.color_$id'.tr(), + }; +} + +/// An avatar inside the frame its owner equipped; the avatar alone without +/// one. +class FramedAvatar extends StatelessWidget { + const FramedAvatar({ + super.key, + required this.size, + required this.child, + this.frame, + this.radius, + }); + + /// The avatar's own size; the frame is drawn around it, not into it. + final double size; + final Widget child; + final String? frame; + + /// Corner radius for a square avatar; null for a circle. + final double? radius; + + static double ringFor(double size) => + (size * 0.07).clamp(2.0, 5.0).toDouble(); + + @override + Widget build(BuildContext context) { + final colors = Cosmetics.frame(frame); + if (colors == null) return child; + final ring = ringFor(size); + final shape = radius == null + ? const BoxDecoration(shape: BoxShape.circle) + : BoxDecoration(borderRadius: BorderRadius.circular(radius! + ring)); + return Container( + width: size + ring * 2, + height: size + ring * 2, + padding: EdgeInsets.all(ring), + decoration: shape.copyWith( + gradient: SweepGradient(colors: [...colors, colors.first]), + boxShadow: [ + BoxShadow( + color: colors.last.withValues(alpha: 0.35), + blurRadius: ring * 2.5, + ), + ], + ), + child: child, + ); + } +} + +/// "Lv 12" as a small pill. +class LevelChip extends StatelessWidget { + const LevelChip({super.key, required this.level, this.fontSize = 10}); + + final int level; + final double fontSize; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: fontSize * 0.55, + vertical: fontSize * 0.15, + ), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(fontSize), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.45), + width: 0.6, + ), + ), + child: Text( + 'progress.level_short'.tr(args: ['$level']), + style: TextStyle( + color: AppColors.textPrimary, + fontSize: fontSize, + fontWeight: FontWeight.w800, + height: 1.2, + ), + ), + ); + } +} + +/// A name in its owner's colour, their level beside it and, when asked, their +/// title under it. With no [decoration] it is just the name. +class UserBadgeLine extends StatelessWidget { + const UserBadgeLine({ + super.key, + required this.name, + required this.decoration, + required this.style, + this.showTitle = false, + this.center = false, + this.chipSize, + }); + + final String name; + final UserDecoration? decoration; + final TextStyle style; + final bool showTitle; + final bool center; + final double? chipSize; + + @override + Widget build(BuildContext context) { + final d = decoration; + final color = Cosmetics.nameColor(d?.nameColor); + final line = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: center ? TextAlign.center : null, + style: color == null ? style : style.copyWith(color: color), + ), + ), + if (d != null && d.level > 0) ...[ + const SizedBox(width: 6), + LevelChip( + level: d.level, + fontSize: chipSize ?? ((style.fontSize ?? 13) * 0.72), + ), + ], + ], + ); + final title = showTitle ? Cosmetics.titleText(d?.title) : null; + if (title == null) return line; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: center + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + line, + const SizedBox(height: 2), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: (color ?? AppColors.textSecondary).withValues(alpha: 0.85), + fontSize: (style.fontSize ?? 13) * 0.78, + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + ], + ); + } +} diff --git a/lib/features/social/domain/social_models.dart b/lib/features/social/domain/social_models.dart index 8f177f97..4f75b7a3 100644 --- a/lib/features/social/domain/social_models.dart +++ b/lib/features/social/domain/social_models.dart @@ -4,6 +4,7 @@ library; import 'package:soplay/features/achievements/domain/achievements.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; enum SocialRelation { self, @@ -65,6 +66,7 @@ class SocialUser { required this.username, this.displayName, this.photoURL, + this.decoration, }); final String id; @@ -72,6 +74,9 @@ class SocialUser { final String? displayName; final String? photoURL; + /// Level and cosmetics; null from a server that sends none. + final UserDecoration? decoration; + String get name => (displayName?.trim().isNotEmpty ?? false) ? displayName!.trim() : username; @@ -81,6 +86,7 @@ class SocialUser { username: _str(json['username']) ?? '', displayName: _str(json['displayName']), photoURL: _str(json['photoURL']), + decoration: UserDecoration.fromJson(json), ); } diff --git a/lib/features/social/presentation/pages/user_profile_page.dart b/lib/features/social/presentation/pages/user_profile_page.dart index 13c84d91..48f6007a 100644 --- a/lib/features/social/presentation/pages/user_profile_page.dart +++ b/lib/features/social/presentation/pages/user_profile_page.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; import 'package:soplay/features/social/data/social_service.dart'; import 'package:soplay/features/social/domain/social_models.dart'; import 'package:soplay/features/social/presentation/cursor_pager.dart'; @@ -449,9 +450,11 @@ class _Header extends StatelessWidget { children: [ SocialAvatar(user: user, size: 84), const SizedBox(height: 12), - Text( - user.name, - textAlign: TextAlign.center, + UserBadgeLine( + name: user.name, + decoration: user.decoration, + showTitle: true, + center: true, style: const TextStyle( color: AppColors.textPrimary, fontSize: 19, diff --git a/lib/features/social/presentation/widgets/activity_card.dart b/lib/features/social/presentation/widgets/activity_card.dart index cdf33661..d836b0b3 100644 --- a/lib/features/social/presentation/widgets/activity_card.dart +++ b/lib/features/social/presentation/widgets/activity_card.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/detail/domain/entities/detail_args.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; import 'package:soplay/features/social/domain/social_models.dart'; import 'package:soplay/features/social/presentation/widgets/social_widgets.dart'; import 'package:soplay/features/achievements/domain/achievements.dart'; @@ -257,6 +258,7 @@ class _ActorLine extends StatelessWidget { @override Widget build(BuildContext context) { + final level = actor.decoration?.level ?? 0; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => context.push('/u/${Uri.encodeComponent(actor.username)}'), @@ -270,11 +272,21 @@ class _ActorLine extends StatelessWidget { children: [ TextSpan( text: actor.name, - style: const TextStyle( - color: AppColors.textPrimary, + style: TextStyle( + color: + Cosmetics.nameColor(actor.decoration?.nameColor) ?? + AppColors.textPrimary, fontWeight: FontWeight.w700, ), ), + if (level > 0) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 5), + child: LevelChip(level: level, fontSize: 9), + ), + ), TextSpan(text: ' $verb'), ], ), diff --git a/lib/features/social/presentation/widgets/social_widgets.dart b/lib/features/social/presentation/widgets/social_widgets.dart index 67237f95..17052fa6 100644 --- a/lib/features/social/presentation/widgets/social_widgets.dart +++ b/lib/features/social/presentation/widgets/social_widgets.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:soplay/core/system/app_dates.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/progress/presentation/widgets/user_badge_line.dart'; import 'package:soplay/features/social/domain/social_models.dart'; String socialErrorText(Object? error) { @@ -103,7 +104,7 @@ class SocialAvatar extends StatelessWidget { ), ), ); - return Container( + final avatar = Container( width: size, height: size, decoration: BoxDecoration( @@ -122,6 +123,11 @@ class SocialAvatar extends StatelessWidget { errorWidget: (_, _, _) => placeholder, ), ); + return FramedAvatar( + size: size, + frame: user.decoration?.frame, + child: avatar, + ); } } @@ -155,10 +161,9 @@ class SocialUserTile extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - user.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, + UserBadgeLine( + name: user.name, + decoration: user.decoration, style: const TextStyle( color: AppColors.textPrimary, fontSize: 14.5, diff --git a/test/features/progress/progress_test.dart b/test/features/progress/progress_test.dart new file mode 100644 index 00000000..12e58786 --- /dev/null +++ b/test/features/progress/progress_test.dart @@ -0,0 +1,281 @@ +// XP and levels: the curve the server uses, what a progress answer parses +// to, and how watch time is batched up to the server. +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:soplay/core/constants/app_constants.dart'; +import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/features/progress/data/progress_remote_data_source.dart'; +import 'package:soplay/features/progress/data/progress_service.dart'; +import 'package:soplay/features/progress/data/watch_time_uploader.dart'; +import 'package:soplay/features/progress/domain/progress.dart'; +import 'package:soplay/features/stats/data/watch_stats_store.dart'; + +class _Hive implements HiveService { + @override + bool get isLoggedIn => true; + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _Remote extends ProgressRemoteDataSource { + _Remote() : super(dio: Dio()); + + final List> sent = []; + bool offline = false; + int xp = 0; + + @override + Future> me() async => {'xp': xp}; + + @override + Future> watchTime({ + required String day, + required int seconds, + required String deviceId, + }) async { + if (offline) throw DioException(requestOptions: RequestOptions()); + sent.add({'day': day, 'seconds': seconds, 'deviceId': deviceId}); + xp += seconds ~/ 60; + return { + 'progress': {'xp': xp}, + }; + } +} + +void main() { + group('the level curve', () { + test('level n needs 100·n^1.5 XP, as on the server', () { + expect(xpForLevel(0), 0); + expect(xpForLevel(1), 100); + expect(xpForLevel(2), 282); + expect(xpForLevel(3), 519); + expect(xpForLevel(10), 3162); + expect(levelFor(0), 0); + expect(levelFor(99), 0); + expect(levelFor(100), 1); + expect(levelFor(281), 1); + expect(levelFor(282), 2); + for (var n = 1; n < 200; n++) { + expect(levelFor(xpForLevel(n)), n, reason: 'at level $n'); + expect(levelFor(xpForLevel(n) - 1), n - 1, reason: 'short of $n'); + } + }); + + test('the ring shows the way through the current level', () { + final p = LevelProgress.of(400); + expect(p.level, 2); + expect(p.floor, 282); + expect(p.next, 519); + expect(p.intoLevel, 118); + expect(p.toNext, 119); + expect(p.fraction, closeTo(118 / 237, 1e-9)); + expect(LevelProgress.of(0).fraction, 0); + expect(LevelProgress.of(-5).level, 0); + }); + }); + + group('parsing', () { + test('no level means no decoration', () { + expect(UserDecoration.fromJson({'username': 'a'}), isNull); + expect(UserDecoration.fromJson({'level': null}), isNull); + final d = UserDecoration.fromJson({ + 'level': 7, + 'frame': 'gold', + 'title': '', + 'nameColor': 'mint', + })!; + expect(d.level, 7); + expect(d.frame, 'gold'); + expect(d.title, isNull); + expect(d.nameColor, 'mint'); + }); + + test('a progress answer parses its catalogue and gains', () { + final v = ProgressView.fromJson({ + 'xp': 300, + 'equipped': {'frame': 'bronze', 'title': null, 'nameColor': null}, + 'today': {'watchXp': 40, 'watchCap': 300}, + 'recent': [ + {'reason': 'watch', 'xp': 40, 'at': '2026-11-07T10:00:00Z'}, + ], + 'catalog': { + 'frame': [ + { + 'id': 'bronze', + 'unlock': {'level': 2}, + 'unlocked': true, + }, + { + 'id': 'flame', + 'unlock': {'badge': 'streak', 'tier': 2}, + 'unlocked': false, + }, + ], + 'title': [ + { + 'id': 'newcomer', + 'unlock': {'level': 0}, + 'unlocked': true, + }, + ], + 'future_slot': [ + {'id': 'x'}, + ], + }, + }); + expect(v.level, 2); + expect(v.decoration.frame, 'bronze'); + expect(v.slot(CosmeticSlot.frame).map((c) => c.id), ['bronze', 'flame']); + final flame = v.slot(CosmeticSlot.frame).last; + expect(flame.level, isNull); + expect(flame.badge, 'streak'); + expect(flame.tier, 2); + expect(v.catalog.length, 3); + expect(v.recent.single.xp, 40); + expect(v.watchXpToday, 40); + }); + + test('a level-up is only what rose past the level last seen', () { + final v = ProgressView.fromJson({ + 'xp': 600, + 'catalog': { + 'frame': [ + { + 'id': 'bronze', + 'unlock': {'level': 2}, + }, + { + 'id': 'silver', + 'unlock': {'level': 5}, + }, + ], + }, + }); + expect(LevelUp.between(null, v), isNull, reason: 'first sight'); + expect(LevelUp.between(3, v), isNull); + final up = LevelUp.between(1, v)!; + expect(up.from, 1); + expect(up.to, 3); + expect(up.unlocked.map((c) => c.id), ['bronze']); + }); + }); + + group('batching', () { + final now = DateTime(2026, 11, 7, 21); + + test('only days that grew are due, today and yesterday only', () { + final due = WatchTimeBatch.due( + byDay: {'2026-11-05': 900, '2026-11-06': 600, '2026-11-07': 300}, + sent: {'2026-11-06': 600, '2026-11-07': 120}, + now: now, + ); + expect(due.map((e) => '${e.key}=${e.value}'), ['2026-11-07=300']); + }); + + test('nothing is due once everything was sent', () { + expect( + WatchTimeBatch.due( + byDay: {'2026-11-07': 300}, + sent: {'2026-11-07': 300}, + now: now, + ), + isEmpty, + ); + }); + + test('old days are forgotten', () { + final kept = WatchTimeBatch.pruned({ + '2026-10-01': 1, + '2026-10-31': 2, + '2026-11-06': 3, + }, now); + expect(kept.keys, ['2026-10-31', '2026-11-06']); + }); + + test('a tick sends at most every ten minutes', () { + expect(WatchTimeBatch.shouldFlush(null, now), isTrue); + expect( + WatchTimeBatch.shouldFlush( + now.subtract(const Duration(minutes: 9)), + now, + ), + isFalse, + ); + expect( + WatchTimeBatch.shouldFlush( + now.subtract(const Duration(minutes: 10)), + now, + ), + isTrue, + ); + }); + }); + + group('the uploader', () { + late Directory dir; + + setUpAll(() async { + dir = await Directory.systemTemp.createTemp('sozo_progress_'); + Hive.init(dir.path); + await Hive.openBox(AppConstants.settingsBox); + }); + + tearDownAll(() async { + await Hive.close(); + await dir.delete(recursive: true); + }); + + setUp(() async { + await WatchStatsStore().clear(); + await Hive.box(AppConstants.settingsBox).clear(); + }); + + test('sends the day total, survives being offline, and never twice', () async { + final remote = _Remote(); + final progress = ProgressService(remote: remote, hive: _Hive()); + final uploader = WatchTimeUploader( + remote: remote, + hive: _Hive(), + progress: progress, + ); + final stats = WatchStatsStore(); + final today = WatchTimeBatch.dayKey(DateTime.now()); + + await stats.record(seconds: 300, provider: 'p'); + remote.offline = true; + await uploader.flush(); + expect(remote.sent, isEmpty); + + await stats.record(seconds: 120, provider: 'p'); + remote.offline = false; + await uploader.flush(); + expect(remote.sent.single['day'], today); + expect(remote.sent.single['seconds'], 420); + expect(progress.state.value?.xp, 7); + + await uploader.flush(); + expect(remote.sent.length, 1, reason: 'nothing new to send'); + + await stats.record(seconds: 60, provider: 'p'); + await uploader.flush(); + expect(remote.sent.last['seconds'], 480); + expect(remote.sent.last['deviceId'], remote.sent.first['deviceId']); + }); + + test('a level gained is waiting for the celebration', () async { + final remote = _Remote(); + final progress = ProgressService(remote: remote, hive: _Hive()); + remote.xp = 50; + await progress.refresh(); + expect(progress.pendingLevelUp.value, isNull); + remote.xp = 300; + await progress.refresh(); + expect(progress.pendingLevelUp.value?.to, 2); + expect(progress.takeLevelUp()?.from, 0); + expect(progress.pendingLevelUp.value, isNull); + }); + }); +} From 4e27dfc3a108573e5f977a459130f1e2fb464119 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:48:29 +0500 Subject: [PATCH 12/16] Watch party: host controls, friend invites, chat history, better sync - Lobby host card: room size, host-only playback, lock room, wait for everyone, all live over party:settings - Invite friends sheet on the existing invite endpoint, with per-friend cooldowns - Make host / remove from party from the member bar (tap, long-press or D-pad), and a buffering badge per member - Chat history from party:state plus joined/left/host/removed lines - party:ping clock offset re-bases each sync so a slow link no longer trails the host by its one-way trip - Guests report buffering; with wait for everyone on, the host holds up to 15 s for a guest stuck over 3 s - A party invite opened while signed out is kept through sign-in or first-run setup and opens once afterwards - apple-app-site-association now lists /party/*, /link/* and /pair/* --- assets/translations/ar.json | 26 +- assets/translations/ckb.json | 26 +- assets/translations/de.json | 26 +- assets/translations/en.json | 26 +- assets/translations/es.json | 26 +- assets/translations/fr.json | 26 +- assets/translations/hi.json | 26 +- assets/translations/id.json | 26 +- assets/translations/ku.json | 26 +- assets/translations/nl.json | 26 +- assets/translations/pt.json | 26 +- assets/translations/ru.json | 26 +- assets/translations/tr.json | 26 +- assets/translations/uz.json | 26 +- assets/translations/vi.json | 26 +- assets/translations/yue.json | 26 +- deeplink/apple-app-site-association | 5 +- lib/core/deeplink/deeplink_service.dart | 7 + lib/core/di/injection.dart | 11 + .../presentation/pages/player_page.party.dart | 30 ++ .../main/presentation/pages/main_page.dart | 3 + .../presentation/onboarding_navigation.dart | 2 + .../data/watch_party_remote_data_source.dart | 48 ++- .../watch_party/data/watch_party_service.dart | 302 ++++++++++++++++-- .../domain/entities/party_chat_message.dart | 36 ++- .../domain/entities/party_member.dart | 12 + .../domain/entities/party_room.dart | 25 ++ .../watch_party/domain/invite_cooldowns.dart | 50 +++ .../watch_party/domain/party_clock.dart | 69 ++++ .../watch_party/domain/party_wait_policy.dart | 70 ++++ .../domain/pending_party_code.dart | 58 ++++ .../presentation/pages/watch_party_page.dart | 81 ++++- .../watch_party/presentation/party_entry.dart | 19 ++ .../widgets/party_chat_panel.dart | 76 +++-- .../widgets/party_error_views.dart | 10 +- .../widgets/party_host_settings.dart | 142 ++++++++ .../widgets/party_invite_sheet.dart | 277 ++++++++++++++++ .../widgets/party_member_bar.dart | 121 ++++++- .../features/watch_party/party_sync_test.dart | 220 +++++++++++++ .../watch_party/pending_party_code_test.dart | 63 ++++ 40 files changed, 2079 insertions(+), 74 deletions(-) create mode 100644 lib/features/watch_party/domain/invite_cooldowns.dart create mode 100644 lib/features/watch_party/domain/party_clock.dart create mode 100644 lib/features/watch_party/domain/party_wait_policy.dart create mode 100644 lib/features/watch_party/domain/pending_party_code.dart create mode 100644 lib/features/watch_party/presentation/widgets/party_host_settings.dart create mode 100644 lib/features/watch_party/presentation/widgets/party_invite_sheet.dart create mode 100644 test/features/watch_party/party_sync_test.dart create mode 100644 test/features/watch_party/pending_party_code_test.dart diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 6e6bbb79..ee5e014d 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -1172,7 +1172,31 @@ "reconnecting": "جارٍ إعادة الاتصال…", "connecting": "جارٍ الاتصال…", "connect_failed_title": "تعذّر الاتصال", - "connect_failed_msg": "تحقّق من اتصالك بالإنترنت وحاول مرة أخرى." + "connect_failed_msg": "تحقّق من اتصالك بالإنترنت وحاول مرة أخرى.", + "settings_title": "إعدادات الغرفة", + "room_size": "حجم الغرفة", + "only_host_setting": "المضيف وحده يتحكم في التشغيل", + "lock_room": "قفل الغرفة", + "lock_room_hint": "لا يمكن لأحد جديد الانضمام", + "wait_for_all": "انتظار الجميع", + "wait_for_all_hint": "إيقاف مؤقت قصير عندما يكون أحدهم في التخزين المؤقت", + "invite_friends": "دعوة الأصدقاء", + "invite_sent": "تم إرسال الدعوة", + "invite_failed": "تعذّر إرسال الدعوة", + "invite_again_in": "مرة أخرى بعد {}", + "invite_no_friends": "أضف أصدقاء لتتمكن من دعوتهم هنا", + "make_host": "تعيين كمضيف", + "remove_member": "إزالة من الحفلة", + "remove_confirm": "إزالة {} من هذه الحفلة؟ لن يتمكن من الانضمام مجددًا.", + "buffering": "جارٍ التخزين المؤقت…", + "sys_joined": "انضم {}", + "sys_left": "غادر {}", + "sys_host": "{} هو المضيف الآن", + "sys_removed": "تمت إزالة {}", + "error_banned": "تمت إزالتك من هذه الحفلة", + "error_locked": "هذه الحفلة مقفلة", + "room_closed_kicked": "أزالك المضيف من هذه الحفلة", + "waiting_for_others": "بانتظار أن يلحق الجميع…" }, "notifications": { "title": "الإشعارات", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 6cef5071..77ac86bd 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -1172,7 +1172,31 @@ "reconnecting": "دووبارە پەیوەستبوونەوە…", "connecting": "پەیوەستبوون…", "connect_failed_title": "نەتوانرا پەیوەست بیت", - "connect_failed_msg": "هێڵی ئینتەرنێتەکەت بپشکنە و دووبارە هەوڵبدەرەوە." + "connect_failed_msg": "هێڵی ئینتەرنێتەکەت بپشکنە و دووبارە هەوڵبدەرەوە.", + "settings_title": "ڕێکخستنەکانی ژوور", + "room_size": "قەبارەی ژوور", + "only_host_setting": "تەنها خانەخوێ لێدان کۆنترۆڵ دەکات", + "lock_room": "قفڵکردنی ژوور", + "lock_room_hint": "کەسی نوێ ناتوانێت بەشداربێت", + "wait_for_all": "چاوەڕوانی هەمووان", + "wait_for_all_hint": "کاتێک کەسێک بارکردن دەکات کەمێک ڕابگرە", + "invite_friends": "بانگهێشتکردنی هاوڕێیان", + "invite_sent": "بانگهێشتەکە نێردرا", + "invite_failed": "نەتوانرا بانگهێشتەکە بنێردرێت", + "invite_again_in": "دووبارە دوای {}", + "invite_no_friends": "هاوڕێ زیاد بکە بۆ ئەوەی لێرە بانگهێشتیان بکەیت", + "make_host": "کردن بە خانەخوێ", + "remove_member": "لابردن لە کۆڕ", + "remove_confirm": "{} لەم کۆڕە لاببرێت؟ ناتوانێت دووبارە بگەڕێتەوە.", + "buffering": "بارکردن…", + "sys_joined": "{} بەشداربوو", + "sys_left": "{} ڕۆیشت", + "sys_host": "{} ئێستا خانەخوێیە", + "sys_removed": "{} لابرا", + "error_banned": "تۆ لەم کۆڕە لابرایت", + "error_locked": "ئەم کۆڕە قفڵدراوە", + "room_closed_kicked": "خانەخوێ تۆی لەم کۆڕە لابرد", + "waiting_for_others": "چاوەڕوانی ئەوەین هەمووان بگەنەوە…" }, "notifications": { "title": "ئاگادارکردنەوەکان", diff --git a/assets/translations/de.json b/assets/translations/de.json index 0970f03c..37fe0ed9 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -1172,7 +1172,31 @@ "reconnecting": "Verbindung wird wiederhergestellt …", "connecting": "Verbindung wird aufgebaut …", "connect_failed_title": "Verbindung fehlgeschlagen", - "connect_failed_msg": "Prüfe deine Internetverbindung und versuche es erneut." + "connect_failed_msg": "Prüfe deine Internetverbindung und versuche es erneut.", + "settings_title": "Raumeinstellungen", + "room_size": "Raumgröße", + "only_host_setting": "Nur der Host steuert die Wiedergabe", + "lock_room": "Raum sperren", + "lock_room_hint": "Niemand Neues kann beitreten", + "wait_for_all": "Auf alle warten", + "wait_for_all_hint": "Kurz pausieren, wenn jemand puffert", + "invite_friends": "Freunde einladen", + "invite_sent": "Einladung gesendet", + "invite_failed": "Einladung konnte nicht gesendet werden", + "invite_again_in": "Wieder in {}", + "invite_no_friends": "Füge Freunde hinzu, um sie hierher einzuladen", + "make_host": "Zum Host machen", + "remove_member": "Aus der Party entfernen", + "remove_confirm": "{} aus dieser Party entfernen? Ein erneuter Beitritt ist dann nicht möglich.", + "buffering": "Puffert…", + "sys_joined": "{} ist beigetreten", + "sys_left": "{} hat die Party verlassen", + "sys_host": "{} ist jetzt Host", + "sys_removed": "{} wurde entfernt", + "error_banned": "Du wurdest aus dieser Party entfernt", + "error_locked": "Diese Party ist gesperrt", + "room_closed_kicked": "Der Host hat dich aus dieser Party entfernt", + "waiting_for_others": "Warte, bis alle aufgeholt haben…" }, "notifications": { "title": "Benachrichtigungen", diff --git a/assets/translations/en.json b/assets/translations/en.json index 8fbca5aa..fb0a2b6d 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -1172,7 +1172,31 @@ "reconnecting": "Reconnecting…", "connecting": "Connecting…", "connect_failed_title": "Couldn't connect", - "connect_failed_msg": "Check your internet connection and try again." + "connect_failed_msg": "Check your internet connection and try again.", + "settings_title": "Room settings", + "room_size": "Room size", + "only_host_setting": "Only host controls playback", + "lock_room": "Lock room", + "lock_room_hint": "No one new can join", + "wait_for_all": "Wait for everyone", + "wait_for_all_hint": "Pause briefly when someone is buffering", + "invite_friends": "Invite friends", + "invite_sent": "Invite sent", + "invite_failed": "Couldn't send the invite", + "invite_again_in": "Again in {}", + "invite_no_friends": "Add friends to invite them here", + "make_host": "Make host", + "remove_member": "Remove from party", + "remove_confirm": "Remove {} from this party? They won't be able to rejoin.", + "buffering": "Buffering…", + "sys_joined": "{} joined", + "sys_left": "{} left", + "sys_host": "{} is now the host", + "sys_removed": "{} was removed", + "error_banned": "You were removed from this party", + "error_locked": "This party is locked", + "room_closed_kicked": "The host removed you from this party", + "waiting_for_others": "Waiting for everyone to catch up…" }, "notifications": { "title": "Notifications", diff --git a/assets/translations/es.json b/assets/translations/es.json index 319ddb48..27d43ca5 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -1172,7 +1172,31 @@ "reconnecting": "Reconectando…", "connecting": "Conectando…", "connect_failed_title": "No se pudo conectar", - "connect_failed_msg": "Comprueba tu conexión a internet e inténtalo de nuevo." + "connect_failed_msg": "Comprueba tu conexión a internet e inténtalo de nuevo.", + "settings_title": "Ajustes de la sala", + "room_size": "Tamaño de la sala", + "only_host_setting": "Solo el anfitrión controla la reproducción", + "lock_room": "Bloquear sala", + "lock_room_hint": "Nadie nuevo puede unirse", + "wait_for_all": "Esperar a todos", + "wait_for_all_hint": "Pausar un momento cuando alguien esté cargando", + "invite_friends": "Invitar amigos", + "invite_sent": "Invitación enviada", + "invite_failed": "No se pudo enviar la invitación", + "invite_again_in": "De nuevo en {}", + "invite_no_friends": "Añade amigos para invitarlos aquí", + "make_host": "Hacer anfitrión", + "remove_member": "Quitar de la fiesta", + "remove_confirm": "¿Quitar a {} de esta fiesta? No podrá volver a unirse.", + "buffering": "Cargando…", + "sys_joined": "{} se unió", + "sys_left": "{} salió", + "sys_host": "{} ahora es el anfitrión", + "sys_removed": "{} fue quitado", + "error_banned": "Te quitaron de esta fiesta", + "error_locked": "Esta fiesta está bloqueada", + "room_closed_kicked": "El anfitrión te quitó de esta fiesta", + "waiting_for_others": "Esperando a que todos se pongan al día…" }, "notifications": { "title": "Notificaciones", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 599703a9..83faf859 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -1172,7 +1172,31 @@ "reconnecting": "Reconnexion…", "connecting": "Connexion…", "connect_failed_title": "Connexion impossible", - "connect_failed_msg": "Vérifie ta connexion internet et réessaie." + "connect_failed_msg": "Vérifie ta connexion internet et réessaie.", + "settings_title": "Paramètres du salon", + "room_size": "Taille du salon", + "only_host_setting": "Seul l'hôte contrôle la lecture", + "lock_room": "Verrouiller le salon", + "lock_room_hint": "Personne de nouveau ne peut rejoindre", + "wait_for_all": "Attendre tout le monde", + "wait_for_all_hint": "Faire une courte pause quand quelqu'un charge", + "invite_friends": "Inviter des amis", + "invite_sent": "Invitation envoyée", + "invite_failed": "Impossible d'envoyer l'invitation", + "invite_again_in": "De nouveau dans {}", + "invite_no_friends": "Ajoutez des amis pour les inviter ici", + "make_host": "Nommer hôte", + "remove_member": "Retirer de la soirée", + "remove_confirm": "Retirer {} de cette soirée ? Cette personne ne pourra plus revenir.", + "buffering": "Chargement…", + "sys_joined": "{} a rejoint", + "sys_left": "{} est parti", + "sys_host": "{} est maintenant l'hôte", + "sys_removed": "{} a été retiré", + "error_banned": "Vous avez été retiré de cette soirée", + "error_locked": "Cette soirée est verrouillée", + "room_closed_kicked": "L'hôte vous a retiré de cette soirée", + "waiting_for_others": "On attend que tout le monde rattrape…" }, "notifications": { "title": "Notifications", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 12d0cb70..5fe22c0b 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -1172,7 +1172,31 @@ "reconnecting": "फिर से कनेक्ट हो रहा है…", "connecting": "कनेक्ट हो रहा है…", "connect_failed_title": "कनेक्ट नहीं हो सका", - "connect_failed_msg": "अपना इंटरनेट कनेक्शन जाँचें और फिर से कोशिश करें।" + "connect_failed_msg": "अपना इंटरनेट कनेक्शन जाँचें और फिर से कोशिश करें।", + "settings_title": "रूम सेटिंग्स", + "room_size": "रूम का आकार", + "only_host_setting": "सिर्फ़ होस्ट प्लेबैक नियंत्रित करता है", + "lock_room": "रूम लॉक करें", + "lock_room_hint": "कोई नया शामिल नहीं हो सकता", + "wait_for_all": "सबका इंतज़ार करें", + "wait_for_all_hint": "किसी के बफ़र होने पर थोड़ी देर रोकें", + "invite_friends": "दोस्तों को आमंत्रित करें", + "invite_sent": "आमंत्रण भेजा गया", + "invite_failed": "आमंत्रण नहीं भेजा जा सका", + "invite_again_in": "{} बाद फिर से", + "invite_no_friends": "यहाँ आमंत्रित करने के लिए दोस्त जोड़ें", + "make_host": "होस्ट बनाएँ", + "remove_member": "पार्टी से हटाएँ", + "remove_confirm": "{} को इस पार्टी से हटाएँ? वे दोबारा शामिल नहीं हो पाएँगे।", + "buffering": "बफ़र हो रहा है…", + "sys_joined": "{} शामिल हुए", + "sys_left": "{} चले गए", + "sys_host": "{} अब होस्ट हैं", + "sys_removed": "{} को हटाया गया", + "error_banned": "आपको इस पार्टी से हटा दिया गया", + "error_locked": "यह पार्टी लॉक है", + "room_closed_kicked": "होस्ट ने आपको इस पार्टी से हटा दिया", + "waiting_for_others": "सबके साथ आने का इंतज़ार…" }, "notifications": { "title": "नोटिफ़िकेशन", diff --git a/assets/translations/id.json b/assets/translations/id.json index 28442646..0c4fcb74 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -1172,7 +1172,31 @@ "reconnecting": "Menyambung ulang…", "connecting": "Menyambung…", "connect_failed_title": "Tidak bisa tersambung", - "connect_failed_msg": "Periksa koneksi internetmu lalu coba lagi." + "connect_failed_msg": "Periksa koneksi internetmu lalu coba lagi.", + "settings_title": "Pengaturan ruang", + "room_size": "Ukuran ruang", + "only_host_setting": "Hanya host yang mengontrol pemutaran", + "lock_room": "Kunci ruang", + "lock_room_hint": "Tidak ada orang baru yang bisa bergabung", + "wait_for_all": "Tunggu semua", + "wait_for_all_hint": "Jeda sebentar saat ada yang buffering", + "invite_friends": "Undang teman", + "invite_sent": "Undangan terkirim", + "invite_failed": "Gagal mengirim undangan", + "invite_again_in": "Lagi dalam {}", + "invite_no_friends": "Tambahkan teman untuk mengundang mereka ke sini", + "make_host": "Jadikan host", + "remove_member": "Keluarkan dari party", + "remove_confirm": "Keluarkan {} dari party ini? Mereka tidak bisa bergabung lagi.", + "buffering": "Buffering…", + "sys_joined": "{} bergabung", + "sys_left": "{} keluar", + "sys_host": "{} sekarang host", + "sys_removed": "{} dikeluarkan", + "error_banned": "Kamu dikeluarkan dari party ini", + "error_locked": "Party ini dikunci", + "room_closed_kicked": "Host mengeluarkanmu dari party ini", + "waiting_for_others": "Menunggu semua menyusul…" }, "notifications": { "title": "Notifikasi", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index e876ed5b..c3a70dc7 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -1172,7 +1172,31 @@ "reconnecting": "Ji nû ve tê girêdan…", "connecting": "Tê girêdan…", "connect_failed_title": "Girêdan pêk nehat", - "connect_failed_msg": "Girêdana înternetê kontrol bike û dîsa biceribîne." + "connect_failed_msg": "Girêdana înternetê kontrol bike û dîsa biceribîne.", + "settings_title": "Mîhengên odeyê", + "room_size": "Mezinahiya odeyê", + "only_host_setting": "Tenê mazûvan lêdanê kontrol dike", + "lock_room": "Odeyê kilît bike", + "lock_room_hint": "Kesekî nû nikare tevlî bibe", + "wait_for_all": "Li hemûyan bisekine", + "wait_for_all_hint": "Dema yek bar dike, kurt rawestîne", + "invite_friends": "Hevalan vexwîne", + "invite_sent": "Vexwendin hat şandin", + "invite_failed": "Vexwendin nehat şandin", + "invite_again_in": "Dîsa piştî {}", + "invite_no_friends": "Hevalan lê zêde bike da ku li vir wan vexwînî", + "make_host": "Bike mazûvan", + "remove_member": "Ji şahiyê derxe", + "remove_confirm": "{} ji vê şahiyê were derxistin? Ew nikare dîsa tevlî bibe.", + "buffering": "Tê barkirin…", + "sys_joined": "{} tevlî bû", + "sys_left": "{} derket", + "sys_host": "{} niha mazûvan e", + "sys_removed": "{} hat derxistin", + "error_banned": "Tu ji vê şahiyê hatî derxistin", + "error_locked": "Ev şahî kilîtkirî ye", + "room_closed_kicked": "Mazûvan tu ji vê şahiyê derxistî", + "waiting_for_others": "Li benda hemûyan in ku bigihîjin…" }, "notifications": { "title": "Agahdarî", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 8ca39779..03ee2d84 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -1172,7 +1172,31 @@ "reconnecting": "Opnieuw verbinden…", "connecting": "Verbinden…", "connect_failed_title": "Verbinden mislukt", - "connect_failed_msg": "Controleer je internetverbinding en probeer het opnieuw." + "connect_failed_msg": "Controleer je internetverbinding en probeer het opnieuw.", + "settings_title": "Kamerinstellingen", + "room_size": "Kamergrootte", + "only_host_setting": "Alleen de host bedient het afspelen", + "lock_room": "Kamer vergrendelen", + "lock_room_hint": "Niemand nieuw kan meedoen", + "wait_for_all": "Op iedereen wachten", + "wait_for_all_hint": "Kort pauzeren als iemand aan het bufferen is", + "invite_friends": "Vrienden uitnodigen", + "invite_sent": "Uitnodiging verstuurd", + "invite_failed": "Kon de uitnodiging niet versturen", + "invite_again_in": "Weer over {}", + "invite_no_friends": "Voeg vrienden toe om ze hier uit te nodigen", + "make_host": "Host maken", + "remove_member": "Uit de party verwijderen", + "remove_confirm": "{} uit deze party verwijderen? Opnieuw meedoen is dan niet mogelijk.", + "buffering": "Bufferen…", + "sys_joined": "{} doet mee", + "sys_left": "{} is vertrokken", + "sys_host": "{} is nu de host", + "sys_removed": "{} is verwijderd", + "error_banned": "Je bent uit deze party verwijderd", + "error_locked": "Deze party is vergrendeld", + "room_closed_kicked": "De host heeft je uit deze party verwijderd", + "waiting_for_others": "Wachten tot iedereen bij is…" }, "notifications": { "title": "Meldingen", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index f79b5482..c4260ccc 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -1172,7 +1172,31 @@ "reconnecting": "Reconectando…", "connecting": "Conectando…", "connect_failed_title": "Não foi possível conectar", - "connect_failed_msg": "Verifique sua conexão com a internet e tente de novo." + "connect_failed_msg": "Verifique sua conexão com a internet e tente de novo.", + "settings_title": "Configurações da sala", + "room_size": "Tamanho da sala", + "only_host_setting": "Só o anfitrião controla a reprodução", + "lock_room": "Trancar sala", + "lock_room_hint": "Ninguém novo pode entrar", + "wait_for_all": "Esperar todos", + "wait_for_all_hint": "Pausar um pouco quando alguém estiver carregando", + "invite_friends": "Convidar amigos", + "invite_sent": "Convite enviado", + "invite_failed": "Não foi possível enviar o convite", + "invite_again_in": "De novo em {}", + "invite_no_friends": "Adicione amigos para convidá-los aqui", + "make_host": "Tornar anfitrião", + "remove_member": "Remover da festa", + "remove_confirm": "Remover {} desta festa? A pessoa não poderá voltar.", + "buffering": "Carregando…", + "sys_joined": "{} entrou", + "sys_left": "{} saiu", + "sys_host": "{} agora é o anfitrião", + "sys_removed": "{} foi removido", + "error_banned": "Você foi removido desta festa", + "error_locked": "Esta festa está trancada", + "room_closed_kicked": "O anfitrião removeu você desta festa", + "waiting_for_others": "Esperando todos alcançarem…" }, "notifications": { "title": "Notificações", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index c85aef09..af1768e8 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -1172,7 +1172,31 @@ "reconnecting": "Переподключение…", "connecting": "Подключение…", "connect_failed_title": "Не удалось подключиться", - "connect_failed_msg": "Проверьте подключение к интернету и повторите попытку." + "connect_failed_msg": "Проверьте подключение к интернету и повторите попытку.", + "settings_title": "Настройки комнаты", + "room_size": "Размер комнаты", + "only_host_setting": "Воспроизведением управляет только хост", + "lock_room": "Закрыть комнату", + "lock_room_hint": "Новые участники не смогут войти", + "wait_for_all": "Ждать всех", + "wait_for_all_hint": "Ненадолго ставить паузу, когда у кого-то буферизация", + "invite_friends": "Пригласить друзей", + "invite_sent": "Приглашение отправлено", + "invite_failed": "Не удалось отправить приглашение", + "invite_again_in": "Снова через {}", + "invite_no_friends": "Добавьте друзей, чтобы приглашать их сюда", + "make_host": "Сделать хостом", + "remove_member": "Удалить из комнаты", + "remove_confirm": "Удалить {} из комнаты? Вернуться он не сможет.", + "buffering": "Буферизация…", + "sys_joined": "{} присоединился", + "sys_left": "{} вышел", + "sys_host": "{} теперь хост", + "sys_removed": "{} удалён из комнаты", + "error_banned": "Вас удалили из этой комнаты", + "error_locked": "Эта комната закрыта", + "room_closed_kicked": "Хост удалил вас из комнаты", + "waiting_for_others": "Ждём, пока все догонят…" }, "notifications": { "title": "Уведомления", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index f22f299e..e9000c0e 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -1172,7 +1172,31 @@ "reconnecting": "Yeniden bağlanılıyor…", "connecting": "Bağlanılıyor…", "connect_failed_title": "Bağlanılamadı", - "connect_failed_msg": "İnternet bağlantını kontrol edip tekrar dene." + "connect_failed_msg": "İnternet bağlantını kontrol edip tekrar dene.", + "settings_title": "Oda ayarları", + "room_size": "Oda boyutu", + "only_host_setting": "Oynatmayı yalnızca host kontrol eder", + "lock_room": "Odayı kilitle", + "lock_room_hint": "Yeni kimse katılamaz", + "wait_for_all": "Herkesi bekle", + "wait_for_all_hint": "Biri arabelleğe alırken kısa süre duraklat", + "invite_friends": "Arkadaşlarını davet et", + "invite_sent": "Davet gönderildi", + "invite_failed": "Davet gönderilemedi", + "invite_again_in": "{} sonra tekrar", + "invite_no_friends": "Buradan davet etmek için arkadaş ekle", + "make_host": "Host yap", + "remove_member": "Partiden çıkar", + "remove_confirm": "{} bu partiden çıkarılsın mı? Tekrar katılamaz.", + "buffering": "Arabelleğe alınıyor…", + "sys_joined": "{} katıldı", + "sys_left": "{} ayrıldı", + "sys_host": "{} artık host", + "sys_removed": "{} çıkarıldı", + "error_banned": "Bu partiden çıkarıldın", + "error_locked": "Bu parti kilitli", + "room_closed_kicked": "Host seni bu partiden çıkardı", + "waiting_for_others": "Herkesin yetişmesi bekleniyor…" }, "notifications": { "title": "Bildirimler", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index a0ec0494..e0d63db6 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -1172,7 +1172,31 @@ "reconnecting": "Qayta ulanmoqda…", "connecting": "Ulanmoqda…", "connect_failed_title": "Ulanib bo'lmadi", - "connect_failed_msg": "Internet aloqangizni tekshirib, qayta urinib ko'ring." + "connect_failed_msg": "Internet aloqangizni tekshirib, qayta urinib ko'ring.", + "settings_title": "Xona sozlamalari", + "room_size": "Xona hajmi", + "only_host_setting": "Ijroni faqat host boshqaradi", + "lock_room": "Xonani qulflash", + "lock_room_hint": "Yangi hech kim qo'shila olmaydi", + "wait_for_all": "Hammani kutish", + "wait_for_all_hint": "Kimdir yuklanayotganda biroz pauza qilish", + "invite_friends": "Do'stlarni taklif qilish", + "invite_sent": "Taklif yuborildi", + "invite_failed": "Taklifni yuborib bo'lmadi", + "invite_again_in": "{} dan keyin yana", + "invite_no_friends": "Bu yerda taklif qilish uchun do'st qo'shing", + "make_host": "Host qilish", + "remove_member": "Xonadan chiqarish", + "remove_confirm": "{} xonadan chiqarilsinmi? U qayta qo'shila olmaydi.", + "buffering": "Yuklanmoqda…", + "sys_joined": "{} qo'shildi", + "sys_left": "{} chiqib ketdi", + "sys_host": "{} endi host", + "sys_removed": "{} chiqarildi", + "error_banned": "Siz bu xonadan chiqarildingiz", + "error_locked": "Bu xona qulflangan", + "room_closed_kicked": "Host sizni xonadan chiqardi", + "waiting_for_others": "Hamma yetib olishini kutyapmiz…" }, "notifications": { "title": "Bildirishnomalar", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 5a8f5b47..3b04ba2a 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -1172,7 +1172,31 @@ "reconnecting": "Đang kết nối lại…", "connecting": "Đang kết nối…", "connect_failed_title": "Không kết nối được", - "connect_failed_msg": "Hãy kiểm tra kết nối mạng và thử lại." + "connect_failed_msg": "Hãy kiểm tra kết nối mạng và thử lại.", + "settings_title": "Cài đặt phòng", + "room_size": "Kích thước phòng", + "only_host_setting": "Chỉ chủ phòng điều khiển phát", + "lock_room": "Khóa phòng", + "lock_room_hint": "Không ai mới có thể tham gia", + "wait_for_all": "Chờ mọi người", + "wait_for_all_hint": "Tạm dừng một chút khi có người đang tải", + "invite_friends": "Mời bạn bè", + "invite_sent": "Đã gửi lời mời", + "invite_failed": "Không gửi được lời mời", + "invite_again_in": "Lại sau {}", + "invite_no_friends": "Thêm bạn bè để mời họ vào đây", + "make_host": "Đặt làm chủ phòng", + "remove_member": "Xóa khỏi phòng", + "remove_confirm": "Xóa {} khỏi phòng này? Họ sẽ không thể vào lại.", + "buffering": "Đang tải…", + "sys_joined": "{} đã tham gia", + "sys_left": "{} đã rời đi", + "sys_host": "{} giờ là chủ phòng", + "sys_removed": "{} đã bị xóa", + "error_banned": "Bạn đã bị xóa khỏi phòng này", + "error_locked": "Phòng này đã bị khóa", + "room_closed_kicked": "Chủ phòng đã xóa bạn khỏi phòng này", + "waiting_for_others": "Đang chờ mọi người theo kịp…" }, "notifications": { "title": "Thông báo", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 91e3e54a..7adcb5b4 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -1172,7 +1172,31 @@ "reconnecting": "重新連線緊…", "connecting": "連線緊…", "connect_failed_title": "連線唔到", - "connect_failed_msg": "檢查下你嘅網絡連線再試一次。" + "connect_failed_msg": "檢查下你嘅網絡連線再試一次。", + "settings_title": "房間設定", + "room_size": "房間人數", + "only_host_setting": "淨係房主可以控制播放", + "lock_room": "鎖住房間", + "lock_room_hint": "新人唔可以加入", + "wait_for_all": "等埋大家", + "wait_for_all_hint": "有人緩衝嗰陣暫停一陣", + "invite_friends": "邀請朋友", + "invite_sent": "已經發送邀請", + "invite_failed": "發送唔到邀請", + "invite_again_in": "{} 後再試", + "invite_no_friends": "加咗朋友就可以喺度邀請佢哋", + "make_host": "設為房主", + "remove_member": "移出房間", + "remove_confirm": "將 {} 移出呢個房間?佢之後唔可以再加入。", + "buffering": "緩衝緊…", + "sys_joined": "{} 加入咗", + "sys_left": "{} 離開咗", + "sys_host": "{} 而家係房主", + "sys_removed": "{} 已被移出", + "error_banned": "你已經被移出呢個房間", + "error_locked": "呢個房間已經鎖咗", + "room_closed_kicked": "房主將你移出咗呢個房間", + "waiting_for_others": "等緊大家追上…" }, "notifications": { "title": "通知", diff --git a/deeplink/apple-app-site-association b/deeplink/apple-app-site-association index f8136f94..4c45fa7e 100644 --- a/deeplink/apple-app-site-association +++ b/deeplink/apple-app-site-association @@ -8,7 +8,10 @@ "/detail", "/detail/*", "/play", - "/play/*" + "/play/*", + "/party/*", + "/link/*", + "/pair/*" ] } ] diff --git a/lib/core/deeplink/deeplink_service.dart b/lib/core/deeplink/deeplink_service.dart index 7ad3d38a..c082f70f 100644 --- a/lib/core/deeplink/deeplink_service.dart +++ b/lib/core/deeplink/deeplink_service.dart @@ -10,6 +10,7 @@ import 'package:soplay/features/anilist/data/anilist_constants.dart'; import 'package:soplay/features/anilist/data/anilist_service.dart'; import 'package:soplay/features/mal/data/mal_constants.dart'; import 'package:soplay/features/mal/data/mal_service.dart'; +import 'package:soplay/features/watch_party/domain/pending_party_code.dart'; class DeeplinkService { DeeplinkService({AppLinks? appLinks}) : _appLinks = appLinks ?? AppLinks(); @@ -131,6 +132,12 @@ class DeeplinkService { // Code lives in the PATH segment: /party/ — not a query param. final code = segments.length > 1 ? segments[1].trim() : ''; if (code.isEmpty) return null; + // A signed-out or first-run user is sent through sign-in or setup + // before the room can open; the code waits for them. + if (!getIt().isLoggedIn && + getIt.isRegistered()) { + unawaited(getIt().remember(code)); + } return '/watch-party?code=${Uri.encodeComponent(code)}'; default: return null; diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index f2f65fee..c1b2bb71 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -58,6 +58,9 @@ import 'package:soplay/features/streak/data/streak_remote_data_source.dart'; import 'package:soplay/features/streak/data/streak_service.dart'; import 'package:soplay/features/watch_party/data/watch_party_remote_data_source.dart'; import 'package:soplay/features/watch_party/data/watch_party_service.dart'; +import 'package:soplay/features/watch_party/domain/pending_party_code.dart'; +import 'package:hive_flutter/hive_flutter.dart' show Hive; +import 'package:soplay/core/constants/app_constants.dart'; import 'package:soplay/features/download/data/datasources/download_local_data_source.dart'; import 'package:soplay/features/download/data/datasources/download_native_data_source.dart'; import 'package:soplay/features/download/data/datasources/download_transfer_data_source.dart'; @@ -573,6 +576,14 @@ Future configureDependencies() async { tokenRefresher: getIt(), ), ); + getIt.registerLazySingleton(() { + const key = 'pending_party_code'; + final box = Hive.box(AppConstants.settingsBox); + return PendingPartyCode( + read: () => box.get(key) as String?, + write: (v) => v == null ? box.delete(key) : box.put(key, v), + ); + }); getIt.registerLazySingleton( () => WatchServicesDataSource(dio: getIt()), ); diff --git a/lib/features/detail/presentation/pages/player_page.party.dart b/lib/features/detail/presentation/pages/player_page.party.dart index b0d14510..92206a58 100644 --- a/lib/features/detail/presentation/pages/player_page.party.dart +++ b/lib/features/detail/presentation/pages/player_page.party.dart @@ -70,6 +70,7 @@ extension _PlayerParty on _PlayerPageState { void _stopPartyBinding() { _partyBindingActive = false; + _party.reportBuffering(false); _partyHeartbeat?.cancel(); _partyHeartbeat = null; _partyDrift?.cancel(); @@ -185,8 +186,37 @@ extension _PlayerParty on _PlayerPageState { } void _onDriftTick() { + _partyReportBuffering(); final pb = _lastPartyPlayback; if (pb != null && _followsParty) unawaited(_applyRemoteSync(pb)); + _partyWaitForAllTick(); + } + + /// Loading a new episode counts as buffering: from the room's side it is the + /// same wait. An error or a missing plugin does not, since waiting on it + /// would never end. + void _partyReportBuffering() { + if (!_inParty) return; + final c = _controller; + final stalled = + _errorMessage == null && + _pluginRequired == null && + (c == null || !c.value.isInitialized || c.value.isBuffering); + _party.reportBuffering(stalled); + } + + /// Host side of "wait for everyone": hold while a guest is stuck, then go + /// on. Not routed through [_partyEmit] because it is not a user action. + void _partyWaitForAllTick() { + final c = _controller; + if (c == null || !c.value.isInitialized || _isLive) return; + final paused = _party.waitForAllTick( + localPlaying: c.value.isPlaying, + positionSec: c.value.position.inMilliseconds / 1000.0, + pause: () => unawaited(c.pause()), + play: () => unawaited(c.play()), + ); + if (paused) _partyToast('watch_party.waiting_for_others'); } // --------------------------------------------------------------------------- diff --git a/lib/features/main/presentation/pages/main_page.dart b/lib/features/main/presentation/pages/main_page.dart index 5b60b0fb..a6d5f20e 100644 --- a/lib/features/main/presentation/pages/main_page.dart +++ b/lib/features/main/presentation/pages/main_page.dart @@ -12,6 +12,7 @@ import 'package:adaptive_platform_ui/adaptive_platform_ui.dart' import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:liquid_glass_widgets/liquid_glass_widgets.dart'; import 'package:soplay/core/deeplink/deeplink_opt_in.dart'; import 'package:soplay/core/di/injection.dart'; @@ -27,6 +28,7 @@ import 'package:soplay/features/home/presentation/bloc/home/home_state.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_state.dart'; import 'package:soplay/features/search/presentation/blocs/search_bloc.dart'; +import 'package:soplay/features/watch_party/presentation/party_entry.dart'; import 'package:showcaseview/showcaseview.dart'; import '../../../../core/navigation/app_tab.dart'; @@ -118,6 +120,7 @@ class _MainPageState extends State with WidgetsBindingObserver { ); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + unawaited(openPendingParty(GoRouter.of(context))); if (!isDesktopPlatform) { // An update is never held back — only queued, so nothing stacks on it. StartupPrompts.run( diff --git a/lib/features/onboarding/presentation/onboarding_navigation.dart b/lib/features/onboarding/presentation/onboarding_navigation.dart index 148bc776..8428a8b6 100644 --- a/lib/features/onboarding/presentation/onboarding_navigation.dart +++ b/lib/features/onboarding/presentation/onboarding_navigation.dart @@ -20,6 +20,7 @@ import 'package:soplay/features/profile/presentation/bloc/provider_event.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_state.dart'; import 'package:soplay/features/profiles/data/profile_session.dart'; import 'package:soplay/features/profiles/domain/household_profile.dart'; +import 'package:soplay/features/watch_party/presentation/party_entry.dart'; OnboardingController get _controller => getIt(); @@ -146,6 +147,7 @@ Future _goAfterAuth(BuildContext context) async { final c = _controller; if (!c.active || c.flow != OnboardingFlow.firstRun) { router.go('/main'); + await openPendingParty(router); return; } const beforeAuth = {OnboardingStep.welcome, OnboardingStep.account}; diff --git a/lib/features/watch_party/data/watch_party_remote_data_source.dart b/lib/features/watch_party/data/watch_party_remote_data_source.dart index eab4cab3..fb02f9e8 100644 --- a/lib/features/watch_party/data/watch_party_remote_data_source.dart +++ b/lib/features/watch_party/data/watch_party_remote_data_source.dart @@ -2,6 +2,12 @@ import 'package:dio/dio.dart'; import 'package:soplay/features/watch_party/domain/entities/party_content.dart'; +/// `429` from the invite endpoint: this friend was invited moments ago. +class PartyInviteCooldown implements Exception { + const PartyInviteCooldown(this.retryAfterSec); + final int retryAfterSec; +} + /// Thin REST layer for watch party rooms. Mirrors `StreakRemoteDataSource`: /// a single injected [Dio] whose baseUrl already ends with `/api`. class WatchPartyRemoteDataSource { @@ -9,11 +15,22 @@ class WatchPartyRemoteDataSource { const WatchPartyRemoteDataSource({required this.dio}); /// `POST /watch-party` → `{ code, party }`. - Future> createParty({PartyContent? content}) async { + Future> createParty({ + PartyContent? content, + int? maxMembers, + bool? onlyHostControls, + bool? waitForAll, + }) async { final res = await dio.post( '/watch-party', data: { if (content != null) 'content': content.toJson(), + 'maxMembers': ?maxMembers, + if (onlyHostControls != null || waitForAll != null) + 'settings': { + 'onlyHostControls': ?onlyHostControls, + 'waitForAll': ?waitForAll, + }, }, ); return _asMap(res.data); @@ -25,12 +42,29 @@ class WatchPartyRemoteDataSource { return _asMap(res.data); } - /// `POST /watch-party/:code/invite` `{ userId }` (host only). - Future invite(String code, String userId) async { - await dio.post( - '/watch-party/$code/invite', - data: {'userId': userId}, - ); + /// `POST /watch-party/:code/invite` `{ userId }` (host only). Returns the + /// cooldown the server started, in seconds. + Future invite(String code, String userId) async { + try { + final res = await dio.post( + '/watch-party/$code/invite', + data: {'userId': userId}, + ); + return (_asMap(res.data)['retryAfterSec'] as num?)?.ceil() ?? 0; + } on DioException catch (e) { + if (e.response?.statusCode == 429) { + final left = _asMap(e.response?.data)['retryAfterSec'] as num?; + throw PartyInviteCooldown(left?.ceil() ?? 600); + } + rethrow; + } + } + + /// `GET /watch-party/:code/invites` → `items: [{userId, retryAfterSec}]`. + Future> inviteStatus(String code) async { + final res = await dio.get('/watch-party/$code/invites'); + final items = _asMap(res.data)['items']; + return items is List ? items : const []; } /// `DELETE /watch-party/:code` (host only). diff --git a/lib/features/watch_party/data/watch_party_service.dart b/lib/features/watch_party/data/watch_party_service.dart index a82003b8..4a3b609c 100644 --- a/lib/features/watch_party/data/watch_party_service.dart +++ b/lib/features/watch_party/data/watch_party_service.dart @@ -15,6 +15,8 @@ import 'package:soplay/features/watch_party/domain/entities/party_playback.dart' import 'package:soplay/features/watch_party/domain/entities/party_reaction.dart'; import 'package:soplay/features/watch_party/domain/entities/party_room.dart'; import 'package:soplay/features/watch_party/domain/entities/party_state.dart'; +import 'package:soplay/features/watch_party/domain/party_clock.dart'; +import 'package:soplay/features/watch_party/domain/party_wait_policy.dart'; /// Live watch-party session. Registered as an eager get_it singleton and is the /// single source of truth via [state]. Mirrors `StreakService`'s shape @@ -38,6 +40,20 @@ class WatchPartyService with WidgetsBindingObserver { final ValueNotifier state = ValueNotifier(PartyState.empty); + /// The room's chat as shown: history from `party:state`, then live lines. + final ValueNotifier> chatLog = + ValueNotifier>(const []); + static const int _chatLogMax = 200; + + final PartyClock clock = PartyClock(); + final PartyWaitPolicy waitPolicy = PartyWaitPolicy(); + static const Duration _pingPeriod = Duration(seconds: 10); + Timer? _pingTimer; + + // When each member was first seen buffering, on this device's clock. + final Map _bufferingSince = {}; + bool? _reportedBuffering; + final StreamController _chatCtrl = StreamController.broadcast(); final StreamController _reactionCtrl = @@ -81,15 +97,26 @@ class WatchPartyService with WidgetsBindingObserver { // --------------------------------------------------------------------------- /// REST create → connect → join (join emitted on the `connect` event). - Future createParty({PartyContent? content}) async { + Future createParty({ + PartyContent? content, + int? maxMembers, + bool? onlyHostControls, + bool? waitForAll, + }) async { // New session gets a fresh one-shot handshake recovery. _handshakeRetried = false; - final body = await remote.createParty(content: content); + final body = await remote.createParty( + content: content, + maxMembers: maxMembers, + onlyHostControls: onlyHostControls, + waitForAll: waitForAll, + ); final rawParty = body['party']; final partyJson = rawParty is Map ? Map.from(rawParty) : body; final room = PartyRoom.fromRest(partyJson); _currentCode = room.code; + _resetSession(); state.value = state.value.copyWith( room: room, phase: PartyPhase.joining, @@ -111,6 +138,7 @@ class WatchPartyService with WidgetsBindingObserver { Future joinParty(String code) async { // New session gets a fresh one-shot handshake recovery. _handshakeRetried = false; + if (_currentCode != code) _resetSession(); _currentCode = code; state.value = state.value.copyWith( phase: PartyPhase.joining, @@ -138,7 +166,9 @@ class WatchPartyService with WidgetsBindingObserver { _socket.emit('party:leave', {'code': code}); } _currentCode = null; + _stopPing(); _socket.disconnect(); + _resetSession(); state.value = PartyState.empty; } @@ -154,15 +184,115 @@ class WatchPartyService with WidgetsBindingObserver { rethrow; } finally { _currentCode = null; + _stopPing(); _socket.disconnect(); + _resetSession(); state.value = PartyState.empty; } } - Future invite(String userId) async { + /// Host-only push invite. Returns the seconds until [userId] can be invited + /// again; throws [PartyInviteCooldown] when they still are cooling down. + Future invite(String userId) async { final code = _currentCode ?? state.value.code; - if (code == null) return; - await remote.invite(code, userId); + if (code == null) return 0; + return remote.invite(code, userId); + } + + /// The caller's invite cooldowns as `[{userId, retryAfterSec}]`. + Future> inviteCooldowns() async { + final code = _currentCode ?? state.value.code; + if (code == null) return const []; + return remote.inviteStatus(code); + } + + /// Host-only. Only the fields given change; the server clamps [maxMembers]. + void updateSettings({ + int? maxMembers, + bool? onlyHostControls, + bool? locked, + bool? waitForAll, + }) { + if (!_canEmit) return; + _socket.emit('party:settings', { + 'code': _currentCode, + 'maxMembers': ?maxMembers, + 'onlyHostControls': ?onlyHostControls, + 'locked': ?locked, + 'waitForAll': ?waitForAll, + }); + } + + /// Host-only: removes [userId] and keeps them out of this room. + void kick(String userId) { + if (!_canEmit) return; + _socket.emit('party:kick', { + 'code': _currentCode, + 'userId': userId, + }); + } + + /// Host-only: hands the host role to [userId]. + void transferHost(String userId) { + if (!_canEmit) return; + _socket.emit('party:transfer', { + 'code': _currentCode, + 'userId': userId, + }); + } + + /// Tells the room whether this device's player is stalled. Sent only on + /// change. + void reportBuffering(bool buffering) { + if (!_canEmit || _reportedBuffering == buffering) return; + _reportedBuffering = buffering; + _socket.emit('party:buffering', { + 'code': _currentCode, + 'buffering': buffering, + }); + } + + /// Host side of "wait for everyone", run from the player's tick. Drives the + /// local player through [pause]/[play] and tells the room. Returns true when + /// it has just paused. + bool waitForAllTick({ + required bool localPlaying, + required double positionSec, + required void Function() pause, + required void Function() play, + }) { + final s = state.value; + final action = waitPolicy.tick( + enabled: s.room?.waitForAll ?? false, + isHost: s.isHost && s.inParty, + localPlaying: localPlaying, + bufferingSince: othersBufferingSince(), + now: DateTime.now(), + ); + switch (action) { + case PartyWaitAction.pause: + pause(); + sendControl(action: 'pause', positionSec: positionSec); + return true; + case PartyWaitAction.resume: + play(); + sendControl(action: 'play', positionSec: positionSec); + return false; + case PartyWaitAction.none: + return false; + } + } + + /// When each OTHER online member started buffering. + Iterable othersBufferingSince() { + final me = _myUserId; + final online = { + for (final m in state.value.room?.members ?? const []) + if (m.online) m.userId, + }; + return _bufferingSince.entries + .where((e) => e.key != me && online.contains(e.key)) + .map((e) => e.value); } void sendControl({required String action, double? positionSec, double? rate}) { @@ -209,22 +339,22 @@ class WatchPartyService with WidgetsBindingObserver { 'clientId': clientId, }); } + final me = hive.getUser(); + final local = PartyChatMessage( + userId: _myUserId ?? '', + username: me?.username ?? me?.displayName, + photoURL: me?.photoURL, + text: capped, + ts: DateTime.now().millisecondsSinceEpoch, + ); + _appendLog(local); // Optimistic local echo: show instantly instead of waiting for the server // round-trip (which can be slow on a distant server), and — crucially — // render the sender's own message even during a brief reconnect so it never // looks like the chat is dead. The server echoes our clientId back; _onChat // suppresses only the matching pending entry on THIS device, so a second // device on the same account still renders the message. - if (!_chatCtrl.isClosed) { - final me = hive.getUser(); - _chatCtrl.add(PartyChatMessage( - userId: _myUserId ?? '', - username: me?.username ?? me?.displayName, - photoURL: me?.photoURL, - text: capped, - ts: DateTime.now().millisecondsSinceEpoch, - )); - } + if (!_chatCtrl.isClosed) _chatCtrl.add(local); } void sendReaction(String emoji) { @@ -292,10 +422,73 @@ class WatchPartyService with WidgetsBindingObserver { _socket.on('party:reaction', _onReaction); _socket.on('party:closed', _onClosed); _socket.on('party:error', _onErrorEvent); + _socket.on('party:settings', _onSettings); + _socket.on('party:system', _onSystem); + _socket.on('party:buffering', _onBuffering); + _socket.on('party:pong', _onPong); + } + + void _resetSession() { + chatLog.value = const []; + _bufferingSince.clear(); + _reportedBuffering = null; + waitPolicy.release(); + clock.reset(); + } + + void _startPing() { + _pingTimer?.cancel(); + _ping(); + _pingTimer = Timer.periodic(_pingPeriod, (_) => _ping()); + } + + void _stopPing() { + _pingTimer?.cancel(); + _pingTimer = null; + } + + void _ping() { + if (!_socket.connected) return; + _socket.emit('party:ping', { + 't0': DateTime.now().millisecondsSinceEpoch, + }); + } + + void _appendLog(PartyChatMessage msg) { + final next = [...chatLog.value, msg]; + chatLog.value = next.length > _chatLogMax + ? next.sublist(next.length - _chatLogMax) + : next; + } + + /// [pb] re-based from "when it arrived" to "when the server meant it". + PartyPlayback _aligned(PartyPlayback pb, Map json) => + PartyPlayback( + positionSec: pb.positionSec, + isPlaying: pb.isPlaying, + rate: pb.rate, + receivedAt: clock.align( + serverTime: (json['serverTime'] as num?)?.toInt(), + receivedAt: pb.receivedAt, + ), + ); + + void _syncBuffering(List members) { + final now = DateTime.now(); + final buffering = { + for (final m in members) + if (m.online && m.buffering) m.userId, + }; + _bufferingSince.removeWhere((id, _) => !buffering.contains(id)); + for (final id in buffering) { + _bufferingSince.putIfAbsent(id, () => now); + } } void _handleConnect() { _handshakeRetried = false; + _reportedBuffering = null; + _startPing(); state.value = state.value.copyWith(connection: PartyConnection.connected); // Room membership is bound to the socket — (re)join after every connect. final code = _currentCode; @@ -325,6 +518,7 @@ class WatchPartyService with WidgetsBindingObserver { void _handleDisconnect() { if (_disposed) return; + _stopPing(); if (_currentCode != null && state.value.phase != PartyPhase.closed) { // socket.io will auto-reconnect; reflect that in the UI. state.value = @@ -339,7 +533,18 @@ class WatchPartyService with WidgetsBindingObserver { void _onState(dynamic d) { final j = _asMap(d); if (j == null) return; - final room = PartyRoom.fromSnapshot(j); + var room = PartyRoom.fromSnapshot(j); + final pbJson = _asMap(j['playback']); + if (pbJson != null) { + room = room.copyWith(playback: _aligned(room.playback, pbJson)); + } + if (j.containsKey('chat')) { + final history = PartyChatMessage.listFrom(j['chat']); + chatLog.value = history.length > _chatLogMax + ? history.sublist(history.length - _chatLogMax) + : history; + } + _syncBuffering(room.members); state.value = state.value.copyWith( room: room, phase: PartyPhase.inRoom, @@ -354,7 +559,10 @@ class WatchPartyService with WidgetsBindingObserver { if (j == null) return; final pbJson = _asMap(j['playback']); if (pbJson == null) return; - final pb = PartyPlayback.fromJson(pbJson, receivedAt: DateTime.now()); + final pb = _aligned( + PartyPlayback.fromJson(pbJson, receivedAt: DateTime.now()), + pbJson, + ); final room = state.value.room; if (room != null) { state.value = state.value.copyWith(room: room.copyWith(playback: pb)); @@ -370,7 +578,10 @@ class WatchPartyService with WidgetsBindingObserver { contentJson != null ? PartyContent.fromJson(contentJson) : null; final pbJson = _asMap(j['playback']); final pb = pbJson != null - ? PartyPlayback.fromJson(pbJson, receivedAt: DateTime.now()) + ? _aligned( + PartyPlayback.fromJson(pbJson, receivedAt: DateTime.now()), + pbJson, + ) : null; final room = state.value.room; if (room != null) { @@ -387,6 +598,7 @@ class WatchPartyService with WidgetsBindingObserver { if (j == null) return; final members = PartyMember.listFrom(j['members']); final hostUserId = j['hostUserId'] as String?; + _syncBuffering(members); final room = state.value.room; if (room != null) { state.value = state.value.copyWith( @@ -410,9 +622,60 @@ class WatchPartyService with WidgetsBindingObserver { // Legacy fallback for a server that doesn't echo the clientId. return; } + _appendLog(msg); if (!_chatCtrl.isClosed) _chatCtrl.add(msg); } + void _onSystem(dynamic d) { + final j = _asMap(d); + if (j == null) return; + final msg = PartyChatMessage.tryParse(j); + if (msg == null || !msg.isSystem) return; + _appendLog(msg); + } + + void _onSettings(dynamic d) { + final j = _asMap(d); + final room = state.value.room; + if (j == null || room == null) return; + final settings = _asMap(j['settings']) ?? const {}; + state.value = state.value.copyWith( + room: room.copyWith( + onlyHostControls: settings['onlyHostControls'] as bool?, + locked: settings['locked'] as bool?, + waitForAll: settings['waitForAll'] as bool?, + maxMembers: (j['maxMembers'] as num?)?.toInt(), + maxMembersLimit: (j['maxMembersLimit'] as num?)?.toInt(), + ), + ); + } + + void _onBuffering(dynamic d) { + final j = _asMap(d); + final room = state.value.room; + final userId = j?['userId'] as String?; + if (j == null || room == null || userId == null) return; + final buffering = j['buffering'] == true; + final members = [ + for (final m in room.members) + m.userId == userId ? m.copyWith(buffering: buffering) : m, + ]; + _syncBuffering(members); + state.value = state.value.copyWith(room: room.copyWith(members: members)); + } + + void _onPong(dynamic d) { + final j = _asMap(d); + final t0 = (j?['t0'] as num?)?.toInt(); + final serverTime = (j?['serverTime'] as num?)?.toInt(); + if (t0 == null || serverTime == null) return; + clock.add( + sentAt: t0, + serverTime: serverTime, + receivedAt: DateTime.now().millisecondsSinceEpoch, + ); + } + void _onReaction(dynamic d) { final j = _asMap(d); if (j == null) return; @@ -435,6 +698,7 @@ class WatchPartyService with WidgetsBindingObserver { closedReason: reason, ); _currentCode = null; + _stopPing(); _socket.disconnect(); } @@ -494,9 +758,11 @@ class WatchPartyService with WidgetsBindingObserver { void dispose() { _disposed = true; + _stopPing(); WidgetsBinding.instance.removeObserver(this); _socket.dispose(); state.dispose(); + chatLog.dispose(); _chatCtrl.close(); _reactionCtrl.close(); _contentCtrl.close(); diff --git a/lib/features/watch_party/domain/entities/party_chat_message.dart b/lib/features/watch_party/domain/entities/party_chat_message.dart index e52c6706..a191819c 100644 --- a/lib/features/watch_party/domain/entities/party_chat_message.dart +++ b/lib/features/watch_party/domain/entities/party_chat_message.dart @@ -1,11 +1,16 @@ +/// What a system line in the chat says happened. +enum PartySystemKind { joined, left, host, removed } + /// A chat line broadcast in a watch party. Server payload nests the author -/// under `user: {id, username, photoURL}`. +/// under `user: {id, username, photoURL}`. A system line (`type: 'system'`) +/// has a [system] kind and no text; [userId] is who it is about. class PartyChatMessage { final String userId; final String? username; final String? photoURL; final String text; final int ts; + final PartySystemKind? system; const PartyChatMessage({ required this.userId, @@ -13,8 +18,36 @@ class PartyChatMessage { this.photoURL, required this.text, required this.ts, + this.system, }); + bool get isSystem => system != null; + + static PartySystemKind? _kind(dynamic raw) => switch (raw) { + 'joined' => PartySystemKind.joined, + 'left' => PartySystemKind.left, + 'host' => PartySystemKind.host, + 'removed' => PartySystemKind.removed, + _ => null, + }; + + /// Null for a system line of a kind this app does not know, so a newer + /// server cannot put a blank row in an older chat. + static PartyChatMessage? tryParse(Map j) => + j['type'] == 'system' && _kind(j['kind']) == null + ? null + : PartyChatMessage.fromJson(j); + + /// A `party:state` `chat` array, oldest first. + static List listFrom(dynamic raw) { + if (raw is! List) return const []; + return raw + .whereType() + .map((m) => tryParse(Map.from(m))) + .whereType() + .toList(); + } + factory PartyChatMessage.fromJson(Map j) { final rawUser = j['user']; final user = rawUser is Map @@ -26,6 +59,7 @@ class PartyChatMessage { photoURL: user['photoURL'] as String?, text: j['text'] as String? ?? '', ts: (j['ts'] as num?)?.toInt() ?? 0, + system: j['type'] == 'system' ? _kind(j['kind']) : null, ); } } diff --git a/lib/features/watch_party/domain/entities/party_member.dart b/lib/features/watch_party/domain/entities/party_member.dart index c6f4ea39..89fa5fcc 100644 --- a/lib/features/watch_party/domain/entities/party_member.dart +++ b/lib/features/watch_party/domain/entities/party_member.dart @@ -5,6 +5,7 @@ class PartyMember { final String? photoURL; final String role; // 'host' | 'member' final bool online; + final bool buffering; const PartyMember({ required this.userId, @@ -12,8 +13,18 @@ class PartyMember { this.photoURL, this.role = 'member', this.online = false, + this.buffering = false, }); + PartyMember copyWith({bool? buffering}) => PartyMember( + userId: userId, + username: username, + photoURL: photoURL, + role: role, + online: online, + buffering: buffering ?? this.buffering, + ); + bool get isHost => role == 'host'; factory PartyMember.fromJson(Map j) => PartyMember( @@ -22,6 +33,7 @@ class PartyMember { photoURL: j['photoURL'] as String?, role: j['role'] as String? ?? 'member', online: j['online'] as bool? ?? false, + buffering: j['buffering'] as bool? ?? false, ); /// Defensively parse a `members` array from a socket/REST payload. diff --git a/lib/features/watch_party/domain/entities/party_room.dart b/lib/features/watch_party/domain/entities/party_room.dart index dd3bbd09..fc66bd68 100644 --- a/lib/features/watch_party/domain/entities/party_room.dart +++ b/lib/features/watch_party/domain/entities/party_room.dart @@ -8,7 +8,12 @@ class PartyRoom { final String code; final String? hostUserId; final bool onlyHostControls; + final bool locked; + final bool waitForAll; final int maxMembers; + + /// The largest size the host may pick, from the server's hard cap. + final int maxMembersLimit; final List members; final PartyContent? content; final PartyPlayback playback; @@ -17,7 +22,10 @@ class PartyRoom { required this.code, this.hostUserId, this.onlyHostControls = true, + this.locked = false, + this.waitForAll = false, this.maxMembers = 2, + this.maxMembersLimit = 10, this.members = const [], this.content, required this.playback, @@ -32,6 +40,11 @@ class PartyRoom { return true; } + static bool _flag(dynamic settings, String key) => + settings is Map && settings[key] == true; + + static int _limit(dynamic raw) => (raw as num?)?.toInt() ?? 10; + static PartyContent? _content(dynamic raw) => raw is Map ? PartyContent.fromJson(Map.from(raw)) : null; @@ -47,7 +60,10 @@ class PartyRoom { code: j['code'] as String? ?? '', hostUserId: j['hostUserId'] as String?, onlyHostControls: _onlyHostControls(j['settings']), + locked: _flag(j['settings'], 'locked'), + waitForAll: _flag(j['settings'], 'waitForAll'), maxMembers: (j['maxMembers'] as num?)?.toInt() ?? 2, + maxMembersLimit: _limit(j['maxMembersLimit']), members: PartyMember.listFrom(j['members']), content: _content(j['content']), playback: _playback(j['playback']), @@ -62,7 +78,10 @@ class PartyRoom { code: j['code'] as String? ?? '', hostUserId: (j['hostUserId'] as String?) ?? host?['userId'] as String?, onlyHostControls: _onlyHostControls(j['settings']), + locked: _flag(j['settings'], 'locked') || j['locked'] == true, + waitForAll: _flag(j['settings'], 'waitForAll'), maxMembers: (j['maxMembers'] as num?)?.toInt() ?? 2, + maxMembersLimit: _limit(j['maxMembersLimit']), members: PartyMember.listFrom(j['members']), content: _content(j['content']), playback: _playback(j['playback']), @@ -73,7 +92,10 @@ class PartyRoom { String? code, String? hostUserId, bool? onlyHostControls, + bool? locked, + bool? waitForAll, int? maxMembers, + int? maxMembersLimit, List? members, PartyContent? content, PartyPlayback? playback, @@ -82,7 +104,10 @@ class PartyRoom { code: code ?? this.code, hostUserId: hostUserId ?? this.hostUserId, onlyHostControls: onlyHostControls ?? this.onlyHostControls, + locked: locked ?? this.locked, + waitForAll: waitForAll ?? this.waitForAll, maxMembers: maxMembers ?? this.maxMembers, + maxMembersLimit: maxMembersLimit ?? this.maxMembersLimit, members: members ?? this.members, content: content ?? this.content, playback: playback ?? this.playback, diff --git a/lib/features/watch_party/domain/invite_cooldowns.dart b/lib/features/watch_party/domain/invite_cooldowns.dart new file mode 100644 index 00000000..340d0dfe --- /dev/null +++ b/lib/features/watch_party/domain/invite_cooldowns.dart @@ -0,0 +1,50 @@ +/// When each friend can be invited again. The server keeps the real cooldown +/// and reports it in seconds; this turns that into deadlines on this device's +/// clock so a picker can count down without asking again. +class InviteCooldowns { + InviteCooldowns({DateTime Function()? clock}) : _clock = clock ?? DateTime.now; + + final DateTime Function() _clock; + final Map _until = {}; + + void set(String userId, int retryAfterSec) { + if (retryAfterSec <= 0) { + _until.remove(userId); + return; + } + _until[userId] = _clock().add(Duration(seconds: retryAfterSec)); + } + + /// From `GET /watch-party/:code/invites` → `items: [{userId, retryAfterSec}]`. + void applyServer(dynamic items) { + if (items is! List) return; + for (final raw in items.whereType()) { + final id = raw['userId']; + final sec = raw['retryAfterSec']; + if (id is String && sec is num) set(id, sec.ceil()); + } + } + + Duration remaining(String userId) { + final until = _until[userId]; + if (until == null) return Duration.zero; + final left = until.difference(_clock()); + if (left <= Duration.zero) { + _until.remove(userId); + return Duration.zero; + } + return left; + } + + bool coolingDown(String userId) => remaining(userId) > Duration.zero; + + bool get any => _until.keys.toList().any(coolingDown); + + /// `9:05`, rounded up so it never reads `0:00` while still blocked. + static String format(Duration d) { + final total = (d.inMilliseconds / 1000).ceil(); + final m = total ~/ 60; + final s = total % 60; + return '$m:${s.toString().padLeft(2, '0')}'; + } +} diff --git a/lib/features/watch_party/domain/party_clock.dart b/lib/features/watch_party/domain/party_clock.dart new file mode 100644 index 00000000..5a3c3d10 --- /dev/null +++ b/lib/features/watch_party/domain/party_clock.dart @@ -0,0 +1,69 @@ +/// Server clock offset from `party:ping` / `party:pong` round trips. +/// +/// A sync's `positionSec` is where the server put the room at `serverTime`. +/// Reading it as "where the room is at the moment it arrived" is late by the +/// one-way trip, so a guest on a slow link always trails the host by that +/// much. With an offset, the local instant the server meant can be recovered +/// and the trip taken out. +/// +/// Pure: millisecond timestamps in, numbers out. +class PartyClock { + PartyClock({this.window = 8}); + + /// How many recent round trips are kept. The one with the shortest trip + /// wins: queueing only ever adds delay, so the fastest sample is the least + /// skewed. + final int window; + + /// Round trips slower than this say more about the network than the clock. + static const int maxRttMs = 5000; + + /// The most an alignment may move a receipt back. Beyond this the offset is + /// stale or wrong and the receipt time is the safer guess. + static const int maxShiftMs = 3000; + + final List<_Sample> _samples = <_Sample>[]; + + /// Records one round trip: [sentAt] and [receivedAt] on this device's clock, + /// [serverTime] on the server's. Returns false when the sample is unusable. + bool add({required int sentAt, required int serverTime, required int receivedAt}) { + final rtt = receivedAt - sentAt; + if (rtt < 0 || rtt > maxRttMs) return false; + _samples.add(_Sample(rtt, serverTime - (sentAt + rtt / 2).round())); + if (_samples.length > window) _samples.removeAt(0); + return true; + } + + void reset() => _samples.clear(); + + _Sample? get _best { + _Sample? best; + for (final s in _samples) { + if (best == null || s.rtt < best.rtt) best = s; + } + return best; + } + + /// Server clock minus local clock, or null before the first usable sample. + int? get offsetMs => _best?.offset; + + int? get rttMs => _best?.rtt; + + /// The local instant the server meant by [serverTime], for a message that + /// arrived at [receivedAt]. Falls back to [receivedAt] without an offset, or + /// when the answer would be in the future or implausibly far back. + DateTime align({required int? serverTime, required DateTime receivedAt}) { + final offset = offsetMs; + if (serverTime == null || offset == null) return receivedAt; + final local = serverTime - offset; + final shift = receivedAt.millisecondsSinceEpoch - local; + if (shift < 0 || shift > maxShiftMs) return receivedAt; + return DateTime.fromMillisecondsSinceEpoch(local); + } +} + +class _Sample { + const _Sample(this.rtt, this.offset); + final int rtt; + final int offset; +} diff --git a/lib/features/watch_party/domain/party_wait_policy.dart b/lib/features/watch_party/domain/party_wait_policy.dart new file mode 100644 index 00000000..b86eede4 --- /dev/null +++ b/lib/features/watch_party/domain/party_wait_policy.dart @@ -0,0 +1,70 @@ +/// "Wait for everyone": the host's player holds for a guest who is buffering. +/// +/// A short stall is not worth a room-wide pause, so a guest has to buffer for +/// [threshold] first. The hold is also capped at [maxHold]: a guest on a link +/// that never recovers must not freeze the room for good. +/// +/// After giving up on a guest it ignores that same stall, otherwise the room +/// would pause again on the next tick and every [maxHold] after that. +enum PartyWaitAction { none, pause, resume } + +class PartyWaitPolicy { + PartyWaitPolicy({ + this.threshold = const Duration(seconds: 3), + this.maxHold = const Duration(seconds: 15), + }); + + final Duration threshold; + final Duration maxHold; + + DateTime? _heldSince; + DateTime? _ignoreBefore; + + bool get holding => _heldSince != null; + + /// [bufferingSince] is when each other member was first seen buffering. + PartyWaitAction tick({ + required bool enabled, + required bool isHost, + required bool localPlaying, + required Iterable bufferingSince, + required DateTime now, + }) { + final held = _heldSince; + if (!enabled || !isHost) { + _heldSince = null; + return held != null && !localPlaying + ? PartyWaitAction.resume + : PartyWaitAction.none; + } + + if (held != null) { + if (localPlaying) { + // Someone pressed play while we held; the hold is over. + _heldSince = null; + return PartyWaitAction.none; + } + final stillWaiting = bufferingSince.isNotEmpty; + if (!stillWaiting || now.difference(held) >= maxHold) { + _heldSince = null; + if (stillWaiting) _ignoreBefore = now; + return PartyWaitAction.resume; + } + return PartyWaitAction.none; + } + + if (!localPlaying) return PartyWaitAction.none; + final ignore = _ignoreBefore; + final stalled = bufferingSince.any( + (t) => + (ignore == null || t.isAfter(ignore)) && + now.difference(t) >= threshold, + ); + if (!stalled) return PartyWaitAction.none; + _heldSince = now; + return PartyWaitAction.pause; + } + + /// The host took over playback themselves; the hold is theirs now. + void release() => _heldSince = null; +} diff --git a/lib/features/watch_party/domain/pending_party_code.dart b/lib/features/watch_party/domain/pending_party_code.dart new file mode 100644 index 00000000..0f680ed9 --- /dev/null +++ b/lib/features/watch_party/domain/pending_party_code.dart @@ -0,0 +1,58 @@ +/// A party invite that arrived before the user could join it: signed out, or +/// still in first-run setup. Kept through sign-in or sign-up, then opened once. +/// +/// Stored as `CODE|savedAtMs` through [read]/[write] so it survives the app +/// being killed while the user goes to fetch a password. Old invites expire: +/// opening a room someone shared yesterday, right after an unrelated sign-in, +/// would be a surprise. +class PendingPartyCode { + PendingPartyCode({ + required this.read, + required this.write, + DateTime Function()? clock, + }) : _clock = clock ?? DateTime.now; + + final String? Function() read; + final Future Function(String? value) write; + final DateTime Function() _clock; + + static const Duration ttl = Duration(hours: 1); + + static final RegExp _shape = RegExp(r'^[A-Z0-9]{4,12}$'); + + /// Upper-cased, trimmed, or null when it cannot be a party code. + static String? normalize(String? raw) { + final code = raw?.trim().toUpperCase() ?? ''; + return _shape.hasMatch(code) ? code : null; + } + + Future remember(String? code) async { + final c = normalize(code); + if (c == null) return; + await write('$c|${_clock().millisecondsSinceEpoch}'); + } + + /// The pending code if there is a fresh one, without clearing it. + String? peek() { + final raw = read(); + if (raw == null || raw.isEmpty) return null; + final bar = raw.indexOf('|'); + final code = normalize(bar < 0 ? raw : raw.substring(0, bar)); + final savedAt = bar < 0 ? null : int.tryParse(raw.substring(bar + 1)); + if (code == null || savedAt == null) return null; + final age = _clock().difference( + DateTime.fromMillisecondsSinceEpoch(savedAt), + ); + if (age.isNegative || age > ttl) return null; + return code; + } + + /// The pending code, cleared so it opens once. + Future take() async { + final code = peek(); + if (read() != null) await write(null); + return code; + } + + Future clear() => write(null); +} diff --git a/lib/features/watch_party/presentation/pages/watch_party_page.dart b/lib/features/watch_party/presentation/pages/watch_party_page.dart index 3ebb82ed..afc21c63 100644 --- a/lib/features/watch_party/presentation/pages/watch_party_page.dart +++ b/lib/features/watch_party/presentation/pages/watch_party_page.dart @@ -12,13 +12,18 @@ import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/detail/domain/entities/player_args.dart'; import 'package:soplay/features/detail/domain/usecases/resolve_media_usecase.dart'; import 'package:soplay/features/watch_party/data/watch_party_service.dart'; +import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/features/watch_party/domain/entities/party_content.dart'; +import 'package:soplay/features/watch_party/domain/entities/party_member.dart'; import 'package:soplay/features/watch_party/domain/entities/party_room.dart'; import 'package:soplay/features/watch_party/domain/entities/party_state.dart'; import 'package:soplay/features/watch_party/domain/party_resolve_gate.dart'; +import 'package:soplay/features/watch_party/domain/pending_party_code.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_chat_panel.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_code_sheet.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_error_views.dart'; +import 'package:soplay/features/watch_party/presentation/widgets/party_host_settings.dart'; +import 'package:soplay/features/watch_party/presentation/widgets/party_invite_sheet.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_member_bar.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_plugin_required_view.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_reactions_bar.dart'; @@ -123,6 +128,23 @@ class _WatchPartyPageState extends State { final current = _service.state.value; if (current.inParty && current.code == code) return; + // Signed out (often a fresh install opened from an invite link): keep the + // code through sign-in or sign-up. A successful sign-in re-opens the room + // from the pending code; this page is replaced by then. + if (!getIt().isLoggedIn) { + final pending = getIt(); + await pending.remember(code); + if (!mounted) return; + await context.push('/login'); + if (!mounted) return; + if (!getIt().isLoggedIn) { + await pending.clear(); + if (mounted) setState(() => _joinErrorCode = 'unauthorized'); + return; + } + await pending.clear(); + } + setState(() { _joining = true; _joinErrorCode = null; @@ -149,6 +171,42 @@ class _WatchPartyPageState extends State { ); } + Future _onMemberAction( + PartyMember member, + PartyMemberAction action, + ) async { + switch (action) { + case PartyMemberAction.makeHost: + _service.transferHost(member.userId); + case PartyMemberAction.remove: + final name = (member.username ?? '').trim().isNotEmpty + ? member.username!.trim() + : '—'; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: AppColors.surface, + content: Text('watch_party.remove_confirm'.tr(args: [name])), + actions: [ + TextButton( + autofocus: true, + onPressed: () => Navigator.pop(ctx, false), + child: Text('general.cancel'.tr()), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text( + 'general.remove'.tr(), + style: const TextStyle(color: AppColors.error), + ), + ), + ], + ), + ); + if (ok == true) _service.kick(member.userId); + } + } + Future _copyCode(String code) async { await Clipboard.setData(ClipboardData(text: code)); if (!mounted) return; @@ -311,7 +369,7 @@ class _WatchPartyPageState extends State { return PartyErrorView( code: code, actionLabel: 'general.try_again'.tr(), - onAction: _retryConnect, + onAction: code == 'unauthorized' ? _bootstrap : _retryConnect, ); } // A failed connection, or a join that never lands, must NOT sit forever @@ -352,8 +410,27 @@ class _WatchPartyPageState extends State { _ContentCard(content: content), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: PartyMemberBar(room: room, myUserId: s.myUserId), + child: PartyMemberBar( + room: room, + myUserId: s.myUserId, + isHost: s.isHost, + onAction: _onMemberAction, + ), ), + if (s.isHost) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => showPartyInviteSheet(context), + icon: const Icon(Icons.person_add_alt_1_rounded), + label: Text('watch_party.invite_friends'.tr()), + ), + ), + ), + PartyHostSettings(room: room, service: _service), + ], const SizedBox(height: 8), _BottomActions( isHost: s.isHost, diff --git a/lib/features/watch_party/presentation/party_entry.dart b/lib/features/watch_party/presentation/party_entry.dart index bb115d75..be623dba 100644 --- a/lib/features/watch_party/presentation/party_entry.dart +++ b/lib/features/watch_party/presentation/party_entry.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -6,6 +8,7 @@ import 'package:soplay/core/storage/hive_service.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/watch_party/domain/entities/party_content.dart'; +import 'package:soplay/features/watch_party/domain/pending_party_code.dart'; import 'package:soplay/features/watch_party/presentation/widgets/party_code_sheet.dart'; /// Watch Party is auth-gated on the server (REST + socket). Returns true when a @@ -16,6 +19,22 @@ Future _ensurePartyLogin(BuildContext context) async { return getIt().isLoggedIn; } +/// Opens the party invite kept through sign-in or first-run setup, once. +/// Called wherever a signed-in user lands on Home; concurrent calls (sign-in +/// and Home's first frame) share one navigation. +Future openPendingParty(GoRouter router) => _pendingOpen ??= + _openPendingParty(router).whenComplete(() => _pendingOpen = null); + +Future? _pendingOpen; + +Future _openPendingParty(GoRouter router) async { + if (!getIt.isRegistered()) return; + if (!getIt().isLoggedIn) return; + final code = await getIt().take(); + if (code == null) return; + unawaited(router.push('/watch-party?code=${Uri.encodeComponent(code)}')); +} + /// Navigation payload for `/watch-party` (passed via `GoRouter` `state.extra`). class WatchPartyArgs { const WatchPartyArgs({this.code, this.content}); diff --git a/lib/features/watch_party/presentation/widgets/party_chat_panel.dart b/lib/features/watch_party/presentation/widgets/party_chat_panel.dart index dd6dd73f..fc1b9aff 100644 --- a/lib/features/watch_party/presentation/widgets/party_chat_panel.dart +++ b/lib/features/watch_party/presentation/widgets/party_chat_panel.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -9,7 +7,8 @@ import 'package:soplay/features/watch_party/data/watch_party_service.dart'; import 'package:soplay/features/watch_party/domain/entities/party_chat_message.dart'; /// Live chat for a watch party: a scrolling message list fed by -/// `service.chat`, plus a composer wired to `service.sendChat`. +/// `service.chatLog` (history first, then live lines), plus a composer wired +/// to `service.sendChat`. class PartyChatPanel extends StatefulWidget { const PartyChatPanel({super.key, required this.service, this.myUserId}); @@ -21,40 +20,36 @@ class PartyChatPanel extends StatefulWidget { } class _PartyChatPanelState extends State { - static const int _maxMessages = 200; - - final List _messages = []; final TextEditingController _controller = TextEditingController(); final FocusNode _focus = FocusNode(); final ScrollController _scroll = ScrollController(); - StreamSubscription? _sub; @override void initState() { super.initState(); - _sub = widget.service.chat.listen(_onMessage); + widget.service.chatLog.addListener(_onLog); + WidgetsBinding.instance.addPostFrameCallback((_) => _jumpToEnd()); } @override void dispose() { - _sub?.cancel(); + widget.service.chatLog.removeListener(_onLog); _controller.dispose(); _focus.dispose(); _scroll.dispose(); super.dispose(); } - void _onMessage(PartyChatMessage msg) { + void _onLog() { if (!mounted) return; - setState(() { - _messages.add(msg); - if (_messages.length > _maxMessages) { - _messages.removeRange(0, _messages.length - _maxMessages); - } - }); + setState(() {}); WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToEnd()); } + void _jumpToEnd() { + if (_scroll.hasClients) _scroll.jumpTo(_scroll.position.maxScrollExtent); + } + void _scrollToEnd() { if (!_scroll.hasClients) return; _scroll.animateTo( @@ -73,17 +68,25 @@ class _PartyChatPanelState extends State { @override Widget build(BuildContext context) { + final messages = widget.service.chatLog.value; return Column( children: [ Expanded( - child: _messages.isEmpty + child: messages.isEmpty ? _EmptyChat() : ListView.builder( controller: _scroll, padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _messages.length, + itemCount: messages.length, itemBuilder: (_, i) { - final m = _messages[i]; + final m = messages[i]; + if (m.isSystem) { + return _SystemRow( + message: m, + isMe: widget.myUserId != null && + m.userId == widget.myUserId, + ); + } return _ChatRow( message: m, isMe: widget.myUserId != null && @@ -126,6 +129,41 @@ class _EmptyChat extends StatelessWidget { } } +class _SystemRow extends StatelessWidget { + const _SystemRow({required this.message, required this.isMe}); + + final PartyChatMessage message; + final bool isMe; + + @override + Widget build(BuildContext context) { + final name = isMe + ? 'watch_party.you'.tr() + : (message.username ?? '').trim().isNotEmpty + ? message.username!.trim() + : '—'; + final text = switch (message.system) { + PartySystemKind.joined => 'watch_party.sys_joined'.tr(args: [name]), + PartySystemKind.left => 'watch_party.sys_left'.tr(args: [name]), + PartySystemKind.host => 'watch_party.sys_host'.tr(args: [name]), + PartySystemKind.removed => 'watch_party.sys_removed'.tr(args: [name]), + null => '', + }; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + fontStyle: FontStyle.italic, + ), + ), + ); + } +} + class _ChatRow extends StatelessWidget { const _ChatRow({required this.message, required this.isMe}); diff --git a/lib/features/watch_party/presentation/widgets/party_error_views.dart b/lib/features/watch_party/presentation/widgets/party_error_views.dart index 5ec005cb..8903e36d 100644 --- a/lib/features/watch_party/presentation/widgets/party_error_views.dart +++ b/lib/features/watch_party/presentation/widgets/party_error_views.dart @@ -150,6 +150,8 @@ class PartyErrorView extends StatelessWidget { 'watch_party.error_not_found', ), 'full' => (Icons.group_off_rounded, 'watch_party.error_full'), + 'locked' => (Icons.lock_rounded, 'watch_party.error_locked'), + 'banned' => (Icons.block_rounded, 'watch_party.error_banned'), 'forbidden' => (Icons.lock_outline_rounded, 'watch_party.error_forbidden'), 'rate' => (Icons.timer_outlined, 'watch_party.error_rate'), 'unauthorized' => ( @@ -185,9 +187,11 @@ class PartyClosedView extends StatelessWidget { return PartyStateView( icon: Icons.meeting_room_outlined, title: 'watch_party.room_closed'.tr(), - message: reason == 'empty' - ? 'watch_party.room_closed_empty'.tr() - : null, + message: switch (reason) { + 'empty' => 'watch_party.room_closed_empty'.tr(), + 'kicked' => 'watch_party.room_closed_kicked'.tr(), + _ => null, + }, actionLabel: actionLabel, onAction: onAction, ); diff --git a/lib/features/watch_party/presentation/widgets/party_host_settings.dart b/lib/features/watch_party/presentation/widgets/party_host_settings.dart new file mode 100644 index 00000000..83b00e31 --- /dev/null +++ b/lib/features/watch_party/presentation/widgets/party_host_settings.dart @@ -0,0 +1,142 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/watch_party/data/watch_party_service.dart'; +import 'package:soplay/features/watch_party/domain/entities/party_room.dart'; + +/// The host's room controls in the lobby: size, who drives playback, the lock +/// and "wait for everyone". Each change goes out as `party:settings` and comes +/// back to every member, this one included, so the room is the only state. +class PartyHostSettings extends StatelessWidget { + const PartyHostSettings({ + super.key, + required this.room, + required this.service, + }); + + final PartyRoom room; + final WatchPartyService service; + + static const int minMembers = 2; + + @override + Widget build(BuildContext context) { + final limit = room.maxMembersLimit < minMembers + ? minMembers + : room.maxMembersLimit; + final size = room.maxMembers.clamp(minMembers, limit); + return Container( + margin: const EdgeInsets.fromLTRB(16, 4, 16, 4), + padding: const EdgeInsets.fromLTRB(14, 10, 6, 6), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.06)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'watch_party.settings_title'.tr(), + style: const TextStyle( + color: AppColors.textHint, + fontSize: 11.5, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Expanded( + child: Text( + 'watch_party.room_size'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 14, + ), + ), + ), + IconButton( + icon: const Icon(Icons.remove_rounded), + onPressed: size > minMembers + ? () => service.updateSettings(maxMembers: size - 1) + : null, + ), + SizedBox( + width: 28, + child: Text( + '$size', + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + icon: const Icon(Icons.add_rounded), + onPressed: size < limit + ? () => service.updateSettings(maxMembers: size + 1) + : null, + ), + ], + ), + _Toggle( + title: 'watch_party.only_host_setting'.tr(), + value: room.onlyHostControls, + onChanged: (v) => service.updateSettings(onlyHostControls: v), + ), + _Toggle( + title: 'watch_party.lock_room'.tr(), + subtitle: 'watch_party.lock_room_hint'.tr(), + value: room.locked, + onChanged: (v) => service.updateSettings(locked: v), + ), + _Toggle( + title: 'watch_party.wait_for_all'.tr(), + subtitle: 'watch_party.wait_for_all_hint'.tr(), + value: room.waitForAll, + onChanged: (v) => service.updateSettings(waitForAll: v), + ), + ], + ), + ); + } +} + +class _Toggle extends StatelessWidget { + const _Toggle({ + required this.title, + required this.value, + required this.onChanged, + this.subtitle, + }); + + final String title; + final String? subtitle; + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SwitchListTile( + dense: true, + contentPadding: const EdgeInsets.only(right: 4), + value: value, + onChanged: onChanged, + activeThumbColor: AppColors.primary, + title: Text( + title, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + ), + subtitle: subtitle == null + ? null + : Text( + subtitle!, + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ), + ); + } +} diff --git a/lib/features/watch_party/presentation/widgets/party_invite_sheet.dart b/lib/features/watch_party/presentation/widgets/party_invite_sheet.dart new file mode 100644 index 00000000..bd46d1f3 --- /dev/null +++ b/lib/features/watch_party/presentation/widgets/party_invite_sheet.dart @@ -0,0 +1,277 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:soplay/core/di/injection.dart'; +import 'package:soplay/core/system/responsive.dart'; +import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/features/comments/domain/entities/comment_author.dart'; +import 'package:soplay/features/comments/presentation/widgets/comment_avatar.dart'; +import 'package:soplay/features/social/data/social_remote_data_source.dart'; +import 'package:soplay/features/social/domain/social_models.dart'; +import 'package:soplay/features/watch_party/data/watch_party_remote_data_source.dart'; +import 'package:soplay/features/watch_party/data/watch_party_service.dart'; +import 'package:soplay/features/watch_party/domain/invite_cooldowns.dart'; + +/// Host-only: pick friends to send a push invite to the current room. +Future showPartyInviteSheet(BuildContext context) { + return showAdaptiveModal( + context: context, + backgroundColor: AppColors.surface, + isScrollControlled: true, + showDragHandle: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => const PartyInviteSheet(), + ); +} + +class PartyInviteSheet extends StatefulWidget { + const PartyInviteSheet({super.key}); + + @override + State createState() => _PartyInviteSheetState(); +} + +class _PartyInviteSheetState extends State { + final WatchPartyService _service = getIt(); + final InviteCooldowns _cooldowns = InviteCooldowns(); + final Set _sending = {}; + + static const int _maxFriends = 200; + + List? _friends; + bool _failed = false; + Timer? _ticker; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _ticker?.cancel(); + super.dispose(); + } + + Future _load() async { + setState(() { + _failed = false; + _friends = null; + }); + try { + final social = getIt(); + final out = []; + String? cursor; + do { + final page = await social.friends(cursor: cursor, limit: 50); + out.addAll(page.items.map((f) => f.user)); + cursor = page.nextCursor; + } while (cursor != null && out.length < _maxFriends); + try { + _cooldowns.applyServer(await _service.inviteCooldowns()); + } catch (_) { + // An older server has no status endpoint; cooldowns then show up as + // they are hit. + } + if (!mounted) return; + setState(() => _friends = out); + _syncTicker(); + } catch (_) { + if (mounted) setState(() => _failed = true); + } + } + + void _syncTicker() { + if (_cooldowns.any) { + _ticker ??= Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() {}); + if (!_cooldowns.any) { + _ticker?.cancel(); + _ticker = null; + } + }); + } + } + + Future _invite(SocialUser user) async { + if (_sending.contains(user.id) || _cooldowns.coolingDown(user.id)) return; + setState(() => _sending.add(user.id)); + try { + final left = await _service.invite(user.id); + _cooldowns.set(user.id, left > 0 ? left : 600); + _toast('watch_party.invite_sent'.tr()); + } on PartyInviteCooldown catch (e) { + _cooldowns.set(user.id, e.retryAfterSec); + } catch (_) { + _toast('watch_party.invite_failed'.tr()); + } finally { + if (mounted) { + setState(() => _sending.remove(user.id)); + _syncTicker(); + } + } + } + + void _toast(String text) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(text), + behavior: SnackBarBehavior.floating, + backgroundColor: AppColors.surfaceVariant, + duration: const Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + final friends = _friends; + final Widget body; + if (_failed) { + body = Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + children: [ + Text( + 'watch_party.error_generic'.tr(), + style: const TextStyle(color: AppColors.textSecondary), + ), + const SizedBox(height: 8), + TextButton( + autofocus: true, + onPressed: _load, + child: Text('general.try_again'.tr()), + ), + ], + ), + ); + } else if (friends == null) { + body = const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: CircularProgressIndicator(strokeWidth: 2.4)), + ); + } else if (friends.isEmpty) { + body = Padding( + padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 12), + child: Text( + 'watch_party.invite_no_friends'.tr(), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.textSecondary, fontSize: 13), + ), + ); + } else { + body = ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.6, + ), + child: ListView.builder( + shrinkWrap: true, + itemCount: friends.length, + itemBuilder: (_, i) => _FriendRow( + user: friends[i], + autofocus: i == 0, + sending: _sending.contains(friends[i].id), + cooldown: _cooldowns.remaining(friends[i].id), + onInvite: () => _invite(friends[i]), + ), + ), + ); + } + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 10), + child: Text( + 'watch_party.invite_friends'.tr(), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 17, + fontWeight: FontWeight.w800, + ), + ), + ), + body, + ], + ), + ), + ); + } +} + +class _FriendRow extends StatelessWidget { + const _FriendRow({ + required this.user, + required this.autofocus, + required this.sending, + required this.cooldown, + required this.onInvite, + }); + + final SocialUser user; + final bool autofocus; + final bool sending; + final Duration cooldown; + final VoidCallback onInvite; + + @override + Widget build(BuildContext context) { + final cooling = cooldown > Duration.zero; + final Widget trailing; + if (sending) { + trailing = const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ); + } else if (cooling) { + trailing = Text( + 'watch_party.invite_again_in'.tr( + args: [InviteCooldowns.format(cooldown)], + ), + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ); + } else { + trailing = const Icon(Icons.send_rounded, size: 20); + } + return ListTile( + autofocus: autofocus, + enabled: !cooling && !sending, + onTap: onInvite, + leading: CommentAvatar( + author: CommentAuthor( + id: user.id, + username: user.username, + photoURL: user.photoURL, + ), + size: 38, + ), + title: Text( + user.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), + ), + subtitle: user.displayName != null + ? Text( + '@${user.username}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: AppColors.textHint, fontSize: 12), + ) + : null, + trailing: trailing, + ); + } +} diff --git a/lib/features/watch_party/presentation/widgets/party_member_bar.dart b/lib/features/watch_party/presentation/widgets/party_member_bar.dart index 81b19bbb..3401d331 100644 --- a/lib/features/watch_party/presentation/widgets/party_member_bar.dart +++ b/lib/features/watch_party/presentation/widgets/party_member_bar.dart @@ -1,18 +1,94 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/comments/domain/entities/comment_author.dart'; import 'package:soplay/features/comments/presentation/widgets/comment_avatar.dart'; import 'package:soplay/features/watch_party/domain/entities/party_member.dart'; import 'package:soplay/features/watch_party/domain/entities/party_room.dart'; -/// Horizontal strip of party members: avatar (with host crown + online dot), -/// name, and an `online/max` counter chip. +/// What the host picked from a member's menu. +enum PartyMemberAction { makeHost, remove } + +/// Horizontal strip of party members: avatar (with host crown, online dot and +/// a buffering badge), name, and an `online/max` counter chip. +/// +/// For the host, every other member is a button (tap, long-press, or D-pad +/// select on TV) that opens [onAction]'s menu. class PartyMemberBar extends StatelessWidget { - const PartyMemberBar({super.key, required this.room, this.myUserId}); + const PartyMemberBar({ + super.key, + required this.room, + this.myUserId, + this.isHost = false, + this.onAction, + }); final PartyRoom room; final String? myUserId; + final bool isHost; + final void Function(PartyMember member, PartyMemberAction action)? onAction; + + Future _openMenu(BuildContext context, PartyMember member) async { + final onAction = this.onAction; + if (onAction == null) return; + final name = (member.username ?? '').trim().isNotEmpty + ? member.username!.trim() + : '—'; + final picked = await showAdaptiveModal( + context: context, + backgroundColor: AppColors.surface, + showDragHandle: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 8), + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ), + if (member.online) + ListTile( + autofocus: true, + leading: const Icon( + Icons.workspace_premium_rounded, + color: AppColors.rating, + ), + title: Text('watch_party.make_host'.tr()), + onTap: () => + Navigator.of(ctx).pop(PartyMemberAction.makeHost), + ), + ListTile( + autofocus: !member.online, + leading: const Icon( + Icons.person_remove_rounded, + color: AppColors.error, + ), + title: Text( + 'watch_party.remove_member'.tr(), + style: const TextStyle(color: AppColors.error), + ), + onTap: () => Navigator.of(ctx).pop(PartyMemberAction.remove), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + if (picked != null) onAction(member, picked); + } @override Widget build(BuildContext context) { @@ -56,10 +132,20 @@ class PartyMemberBar extends StatelessWidget { scrollDirection: Axis.horizontal, itemCount: members.length, separatorBuilder: (_, _) => const SizedBox(width: 14), - itemBuilder: (_, i) => _MemberTile( - member: members[i], - isMe: myUserId != null && members[i].userId == myUserId, - ), + itemBuilder: (context, i) { + final m = members[i]; + final isMe = myUserId != null && m.userId == myUserId; + final actionable = + isHost && onAction != null && !isMe && !m.isHost; + final tile = _MemberTile(member: m, isMe: isMe); + if (!actionable) return tile; + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _openMenu(context, m), + onLongPress: () => _openMenu(context, m), + child: tile, + ); + }, ), ), ], @@ -115,6 +201,27 @@ class _MemberTile extends StatelessWidget { ), ), ), + if (member.buffering) + Positioned( + top: -4, + right: -4, + child: Tooltip( + message: 'watch_party.buffering'.tr(), + child: Container( + width: 18, + height: 18, + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.background, + shape: BoxShape.circle, + ), + child: const CircularProgressIndicator( + strokeWidth: 1.8, + color: AppColors.rating, + ), + ), + ), + ), Positioned( right: 0, bottom: 0, diff --git a/test/features/watch_party/party_sync_test.dart b/test/features/watch_party/party_sync_test.dart new file mode 100644 index 00000000..a9cb8330 --- /dev/null +++ b/test/features/watch_party/party_sync_test.dart @@ -0,0 +1,220 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/watch_party/domain/entities/party_chat_message.dart'; +import 'package:soplay/features/watch_party/domain/entities/party_playback.dart'; +import 'package:soplay/features/watch_party/domain/invite_cooldowns.dart'; +import 'package:soplay/features/watch_party/domain/party_clock.dart'; +import 'package:soplay/features/watch_party/domain/party_wait_policy.dart'; + +void main() { + group('PartyClock', () { + test('has no offset before a usable sample', () { + final clock = PartyClock(); + expect(clock.offsetMs, isNull); + expect(clock.add(sentAt: 100, serverTime: 0, receivedAt: 50), isFalse); + expect(clock.add(sentAt: 0, serverTime: 0, receivedAt: 9000), isFalse); + expect(clock.offsetMs, isNull); + }); + + test('offset is server time minus the local midpoint of the trip', () { + final clock = PartyClock(); + // Sent at 1000, answered at 1200 local: midpoint 1100. The server said + // 5100, so the server clock runs 4000 ahead. + clock.add(sentAt: 1000, serverTime: 5100, receivedAt: 1200); + expect(clock.offsetMs, 4000); + expect(clock.rttMs, 200); + }); + + test('the fastest round trip wins', () { + final clock = PartyClock(); + clock.add(sentAt: 0, serverTime: 4400, receivedAt: 800); + clock.add(sentAt: 2000, serverTime: 6020, receivedAt: 2040); + clock.add(sentAt: 3000, serverTime: 7300, receivedAt: 3600); + expect(clock.rttMs, 40); + expect(clock.offsetMs, 4000); + }); + + test('keeps only the last window of samples', () { + final clock = PartyClock(window: 2); + clock.add(sentAt: 0, serverTime: 10, receivedAt: 20); + clock.add(sentAt: 100, serverTime: 4250, receivedAt: 400); + clock.add(sentAt: 500, serverTime: 4700, receivedAt: 900); + expect(clock.rttMs, 300); + }); + + test('align takes the one-way trip out of a sync', () { + final clock = PartyClock() + ..add(sentAt: 1000, serverTime: 5100, receivedAt: 1200); + final arrived = DateTime.fromMillisecondsSinceEpoch(10150); + final meant = clock.align(serverTime: 14000, receivedAt: arrived); + expect(meant.millisecondsSinceEpoch, 10000); + + final pb = PartyPlayback( + positionSec: 60, + isPlaying: true, + rate: 1, + receivedAt: meant, + ); + // 150 ms of trip plus 850 ms since arrival: a full second ahead. + expect( + pb.expectedPositionAt(DateTime.fromMillisecondsSinceEpoch(11000)), + closeTo(61, 1e-9), + ); + }); + + test('align falls back to the receipt time when it cannot be trusted', () { + final arrived = DateTime.fromMillisecondsSinceEpoch(10000); + expect( + PartyClock().align(serverTime: 9000, receivedAt: arrived), + arrived, + ); + final clock = PartyClock() + ..add(sentAt: 0, serverTime: 100, receivedAt: 200); + expect(clock.align(serverTime: null, receivedAt: arrived), arrived); + // In the future of the receipt: noise, not latency. + expect(clock.align(serverTime: 10500, receivedAt: arrived), arrived); + // Ten seconds back is a stale offset, not a slow network. + expect(clock.align(serverTime: 0, receivedAt: arrived), arrived); + }); + }); + + group('PartyWaitPolicy', () { + final t0 = DateTime(2026, 1, 1, 12); + DateTime at(int sec) => t0.add(Duration(seconds: sec)); + + PartyWaitAction tick( + PartyWaitPolicy p, { + required int now, + List buffering = const [], + bool playing = true, + bool enabled = true, + bool isHost = true, + }) => p.tick( + enabled: enabled, + isHost: isHost, + localPlaying: playing, + bufferingSince: buffering.map(at), + now: at(now), + ); + + test('a short stall does not pause the room', () { + final p = PartyWaitPolicy(); + expect(tick(p, now: 2, buffering: [0]), PartyWaitAction.none); + }); + + test('pauses after three seconds and resumes when the guest is back', () { + final p = PartyWaitPolicy(); + expect(tick(p, now: 3, buffering: [0]), PartyWaitAction.pause); + expect(p.holding, isTrue); + expect( + tick(p, now: 5, buffering: [0], playing: false), + PartyWaitAction.none, + ); + expect(tick(p, now: 7, playing: false), PartyWaitAction.resume); + expect(p.holding, isFalse); + }); + + test('gives up after the cap and does not hold for the same stall', () { + final p = PartyWaitPolicy(); + expect(tick(p, now: 4, buffering: [0]), PartyWaitAction.pause); + expect( + tick(p, now: 19, buffering: [0], playing: false), + PartyWaitAction.resume, + ); + expect(tick(p, now: 25, buffering: [0]), PartyWaitAction.none); + expect(tick(p, now: 33, buffering: [0, 30]), PartyWaitAction.pause); + }); + + test('a host who presses play during the hold ends it', () { + final p = PartyWaitPolicy(); + tick(p, now: 4, buffering: [0]); + expect(tick(p, now: 6, buffering: [0]), PartyWaitAction.none); + expect(p.holding, isFalse); + }); + + test('never acts for a guest, or when the setting is off', () { + expect( + tick(PartyWaitPolicy(), now: 9, buffering: [0], isHost: false), + PartyWaitAction.none, + ); + expect( + tick(PartyWaitPolicy(), now: 9, buffering: [0], enabled: false), + PartyWaitAction.none, + ); + }); + + test('turning the setting off mid-hold lets the room go', () { + final p = PartyWaitPolicy(); + tick(p, now: 4, buffering: [0]); + expect( + tick(p, now: 5, buffering: [0], playing: false, enabled: false), + PartyWaitAction.resume, + ); + }); + + test('a paused host is never paused again', () { + expect( + tick(PartyWaitPolicy(), now: 9, buffering: [0], playing: false), + PartyWaitAction.none, + ); + }); + }); + + group('chat history', () { + test('parses chat and system lines and drops unknown kinds', () { + final list = PartyChatMessage.listFrom([ + { + 'type': 'chat', + 'user': {'id': 'a', 'username': 'ann'}, + 'text': 'hi', + 'ts': 1, + }, + { + 'type': 'system', + 'kind': 'host', + 'user': {'id': 'b', 'username': 'bob'}, + 'ts': 2, + }, + { + 'type': 'system', + 'kind': 'dance', + 'user': {'id': 'c'}, + 'ts': 3, + }, + 'garbage', + ]); + expect(list, hasLength(2)); + expect(list[0].isSystem, isFalse); + expect(list[0].text, 'hi'); + expect(list[1].system, PartySystemKind.host); + expect(list[1].username, 'bob'); + }); + + test('a line from an older server without a type is chat', () { + final m = PartyChatMessage.tryParse({ + 'user': {'id': 'a'}, + 'text': 'x', + 'ts': 1, + }); + expect(m?.isSystem, isFalse); + }); + }); + + group('InviteCooldowns', () { + test('counts down from the server seconds and expires', () { + var now = DateTime(2026, 1, 1); + final c = InviteCooldowns(clock: () => now); + c.applyServer([ + {'userId': 'a', 'retryAfterSec': 90}, + {'userId': 'b', 'retryAfterSec': 'nope'}, + ]); + expect(c.coolingDown('a'), isTrue); + expect(c.coolingDown('b'), isFalse); + expect(InviteCooldowns.format(c.remaining('a')), '1:30'); + now = now.add(const Duration(seconds: 89, milliseconds: 500)); + expect(InviteCooldowns.format(c.remaining('a')), '0:01'); + now = now.add(const Duration(seconds: 1)); + expect(c.coolingDown('a'), isFalse); + expect(c.any, isFalse); + }); + }); +} diff --git a/test/features/watch_party/pending_party_code_test.dart b/test/features/watch_party/pending_party_code_test.dart new file mode 100644 index 00000000..16ebf0a0 --- /dev/null +++ b/test/features/watch_party/pending_party_code_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:soplay/features/watch_party/domain/pending_party_code.dart'; + +void main() { + late String? stored; + late DateTime now; + late PendingPartyCode pending; + + setUp(() { + stored = null; + now = DateTime(2026, 11, 7, 12); + pending = PendingPartyCode( + read: () => stored, + write: (v) async => stored = v, + clock: () => now, + ); + }); + + test('normalizes codes and rejects what cannot be one', () { + expect(PendingPartyCode.normalize(' ab2c9z '), 'AB2C9Z'); + expect(PendingPartyCode.normalize('abc'), isNull); + expect(PendingPartyCode.normalize('ab-12'), isNull); + expect(PendingPartyCode.normalize(null), isNull); + }); + + test('a code kept through sign-in opens exactly once', () async { + await pending.remember('k7m2qp'); + expect(pending.peek(), 'K7M2QP'); + // Sign-in took a while. + now = now.add(const Duration(minutes: 20)); + expect(await pending.take(), 'K7M2QP'); + expect(await pending.take(), isNull); + expect(stored, isNull); + }); + + test('an invite left too long is dropped', () async { + await pending.remember('K7M2QP'); + now = now.add(PendingPartyCode.ttl + const Duration(minutes: 1)); + expect(await pending.take(), isNull); + expect(stored, isNull); + }); + + test('a bad code is never stored, and a newer invite replaces an older', () async { + await pending.remember('x'); + expect(stored, isNull); + await pending.remember('AAAA22'); + await pending.remember('BBBB33'); + expect(await pending.take(), 'BBBB33'); + }); + + test('garbage in storage reads as nothing', () async { + stored = 'not a code|abc'; + expect(pending.peek(), isNull); + expect(await pending.take(), isNull); + expect(stored, isNull); + }); + + test('cancelling sign-in clears it', () async { + await pending.remember('K7M2QP'); + await pending.clear(); + expect(pending.peek(), isNull); + }); +} From 3cdb1f149e4765cd475f3f54c4c23b523cb1c67d Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:52:08 +0500 Subject: [PATCH 13/16] Match the episode repository with a pattern An is-check does not promote CommentsRepository to an unrelated interface, so the scoped calls did not resolve. --- .../blocs/comments_bloc/comments_bloc.dart | 26 ++++++++++++------- .../presentation/pages/episodes_page.dart | 7 +++-- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/lib/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart b/lib/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart index e648cc6b..046f73f7 100644 --- a/lib/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart +++ b/lib/features/comments/presentation/blocs/comments_bloc/comments_bloc.dart @@ -260,14 +260,20 @@ class CommentsBloc extends Bloc { } } + /// The repository's episode and spoiler support, when it has any. + EpisodeCommentsRepository? get _scoped => switch (repository) { + final EpisodeCommentsRepository r => r, + _ => null, + }; + Future> _getComments({ required String provider, required String contentUrl, required int page, }) { - final repo = repository; - if (_episodeKey != null && repo is EpisodeCommentsRepository) { - return repo.getScopedComments( + final scoped = _scoped; + if (_episodeKey != null && scoped != null) { + return scoped.getScopedComments( provider: provider, contentUrl: contentUrl, episodeKey: _episodeKey, @@ -282,11 +288,11 @@ class CommentsBloc extends Bloc { } Future> _create(CommentsCreate event) { - final repo = repository; - final scoped = + final scoped = _scoped; + final needsScope = _episodeKey != null || event.spoiler || event.atSec != null; - if (scoped && repo is EpisodeCommentsRepository) { - return repo.createScoped( + if (needsScope && scoped != null) { + return scoped.createScoped( provider: _provider, contentUrl: _contentUrl, text: event.text, @@ -305,9 +311,9 @@ class CommentsBloc extends Bloc { } Future> _edit(CommentsEdit event) { - final repo = repository; - if (event.spoiler != null && repo is EpisodeCommentsRepository) { - return repo.editScoped( + final scoped = _scoped; + if (event.spoiler != null && scoped != null) { + return scoped.editScoped( id: event.id, text: event.text, spoiler: event.spoiler, diff --git a/lib/features/detail/presentation/pages/episodes_page.dart b/lib/features/detail/presentation/pages/episodes_page.dart index c8a46e15..601e9784 100644 --- a/lib/features/detail/presentation/pages/episodes_page.dart +++ b/lib/features/detail/presentation/pages/episodes_page.dart @@ -116,8 +116,11 @@ class _EpisodesPageState extends State { return; } if (!getIt.isRegistered()) return; - final repo = getIt(); - if (repo is! EpisodeCommentsRepository) return; + final repo = switch (getIt()) { + final EpisodeCommentsRepository r => r, + _ => null, + }; + if (repo == null) return; final result = await repo.episodeCounts( provider: widget.args.provider, contentUrl: widget.args.contentUrl, From 8832adc1ad8b7746832a9f230f38c4e74d923559 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:00:21 +0500 Subject: [PATCH 14/16] Play through the window in the scheduler test Jumping from 10 s to 13 s in one tick is a seek, which correctly restarts the window. --- test/features/comments/reaction_scheduler_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/features/comments/reaction_scheduler_test.dart b/test/features/comments/reaction_scheduler_test.dart index 7257163c..f518ed37 100644 --- a/test/features/comments/reaction_scheduler_test.dart +++ b/test/features/comments/reaction_scheduler_test.dart @@ -27,7 +27,7 @@ void main() { ..load(ReactionTimeline([_b(10, [('🔥', 1)])])); expect(_play(s, 0, 9), isEmpty); expect(s.at(_s(10)).single.value, '🔥'); - expect(s.at(_s(13)), hasLength(1)); + expect(_play(s, 11, 13), hasLength(1)); expect(s.at(_s(14)), isEmpty); }); From 9249bda3390ac3510ade92c31558c7741e9c3853 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:35:40 +0500 Subject: [PATCH 15/16] Support: never leave a request spinning; player: no raw mpv address as the error A support request that failed with anything but a server refusal (a file that went away, a reply the app could not read) left Send spinning, so a viewer could not tell whether it went (ticket 6aee1aca). Every failure now ends with a message, said inline and in a snackbar, and a dropped connection says so. In the player, mpv's "Failed to open " is shown as the blocked source message instead of the raw line. --- assets/translations/ar.json | 4 ++- assets/translations/ckb.json | 4 ++- assets/translations/de.json | 4 ++- assets/translations/en.json | 4 ++- assets/translations/es.json | 4 ++- assets/translations/fr.json | 4 ++- assets/translations/hi.json | 4 ++- assets/translations/id.json | 4 ++- assets/translations/ku.json | 4 ++- assets/translations/nl.json | 4 ++- assets/translations/pt.json | 4 ++- assets/translations/ru.json | 4 ++- assets/translations/tr.json | 4 ++- assets/translations/uz.json | 4 ++- assets/translations/vi.json | 4 ++- assets/translations/yue.json | 4 ++- .../domain/playback/playback_fault.dart | 4 +++ .../support/data/support_repository.dart | 28 +++++++++++++++++-- .../presentation/pages/support_new_page.dart | 23 +++++++++------ test/features/detail/playback_fault_test.dart | 8 ++++++ 20 files changed, 101 insertions(+), 26 deletions(-) diff --git a/assets/translations/ar.json b/assets/translations/ar.json index f36b9e28..89ca2401 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -3138,7 +3138,9 @@ "error_attachment_type": "صور jpg أو png أو webp فقط.", "error_attachment_size": "يجب ألا يتجاوز حجم كل صورة 5 ميغابايت.", "error_attachment_count": "5 لقطات شاشة كحد أقصى في الرسالة.", - "suggest": "تتكرر المشكلة؟ راسل الدعم" + "suggest": "تتكرر المشكلة؟ راسل الدعم", + "error_offline": "أنت غير متصل أو انقطع الاتصال. تحقق من الإنترنت ثم حاول مرة أخرى.", + "not_sent": "لم يُرسل: {}" }, "zangetsu": { "title": "مصادر Zangetsu", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index a7a4ae52..3bb9188f 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -3134,7 +3134,9 @@ "error_attachment_type": "تەنها وێنەی jpg یان png یان webp.", "error_attachment_size": "هەر وێنەیەک دەبێت 5 MB یان کەمتر بێت.", "error_attachment_count": "لە هەر نامەیەکدا زۆرترین 5 وێنەی شاشە.", - "suggest": "دووبارە دەبێتەوە؟ بۆ پشتگیری بنووسە" + "suggest": "دووبارە دەبێتەوە؟ بۆ پشتگیری بنووسە", + "error_offline": "تۆ دەرهێڵیت یان پەیوەندی پچڕا. ئینتەرنێتەکەت بپشکنە و دووبارە هەوڵ بدەرەوە.", + "not_sent": "نەنێردرا: {}" }, "zangetsu": { "title": "سەرچاوەکانی Zangetsu", diff --git a/assets/translations/de.json b/assets/translations/de.json index 328337ec..cb4b169c 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -3134,7 +3134,9 @@ "error_attachment_type": "Nur jpg-, png- oder webp-Bilder.", "error_attachment_size": "Jedes Bild darf höchstens 5 MB groß sein.", "error_attachment_count": "Höchstens 5 Screenshots pro Nachricht.", - "suggest": "Passiert das öfter? Schreib dem Support" + "suggest": "Passiert das öfter? Schreib dem Support", + "error_offline": "Du bist offline oder die Verbindung ist abgebrochen. Prüfe dein Internet und versuche es erneut.", + "not_sent": "Nicht gesendet: {}" }, "zangetsu": { "title": "Zangetsu-Quellen", diff --git a/assets/translations/en.json b/assets/translations/en.json index 73e00279..76b041c1 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -3134,7 +3134,9 @@ "error_attachment_type": "Only jpg, png or webp images.", "error_attachment_size": "Each image must be 5 MB or less.", "error_attachment_count": "At most 5 screenshots per message.", - "suggest": "Keeps happening? Write to support" + "suggest": "Keeps happening? Write to support", + "error_offline": "You're offline or the connection dropped. Check your internet and try again.", + "not_sent": "Not sent: {}" }, "zangetsu": { "title": "Zangetsu Sources", diff --git a/assets/translations/es.json b/assets/translations/es.json index 715ac2ba..898c3a11 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -3134,7 +3134,9 @@ "error_attachment_type": "Solo imágenes jpg, png o webp.", "error_attachment_size": "Cada imagen debe pesar 5 MB o menos.", "error_attachment_count": "Como máximo 5 capturas por mensaje.", - "suggest": "¿Sigue pasando? Escribe a soporte" + "suggest": "¿Sigue pasando? Escribe a soporte", + "error_offline": "No tienes conexión o se cortó. Revisa tu internet e inténtalo de nuevo.", + "not_sent": "No se envió: {}" }, "zangetsu": { "title": "Fuentes de Zangetsu", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 615f3a17..5602bb11 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -3134,7 +3134,9 @@ "error_attachment_type": "Images jpg, png ou webp uniquement.", "error_attachment_size": "Chaque image doit faire 5 Mo maximum.", "error_attachment_count": "5 captures maximum par message.", - "suggest": "Ça se reproduit ? Écris au support" + "suggest": "Ça se reproduit ? Écris au support", + "error_offline": "Vous êtes hors ligne ou la connexion a été coupée. Vérifiez votre connexion et réessayez.", + "not_sent": "Non envoyé : {}" }, "zangetsu": { "title": "Sources Zangetsu", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index edbf007f..967ff96b 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -3134,7 +3134,9 @@ "error_attachment_type": "सिर्फ़ jpg, png या webp इमेज।", "error_attachment_size": "हर इमेज 5 MB या उससे छोटी होनी चाहिए।", "error_attachment_count": "एक मैसेज में ज़्यादा से ज़्यादा 5 स्क्रीनशॉट।", - "suggest": "बार-बार हो रहा है? सपोर्ट को लिखें" + "suggest": "बार-बार हो रहा है? सपोर्ट को लिखें", + "error_offline": "आप ऑफ़लाइन हैं या कनेक्शन टूट गया। इंटरनेट जाँचें और फिर से कोशिश करें।", + "not_sent": "नहीं भेजा गया: {}" }, "zangetsu": { "title": "Zangetsu स्रोत", diff --git a/assets/translations/id.json b/assets/translations/id.json index 6715e9c1..70e0881a 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -3134,7 +3134,9 @@ "error_attachment_type": "Hanya gambar jpg, png, atau webp.", "error_attachment_size": "Setiap gambar maksimal 5 MB.", "error_attachment_count": "Maksimal 5 tangkapan layar per pesan.", - "suggest": "Terus terjadi? Hubungi dukungan" + "suggest": "Terus terjadi? Hubungi dukungan", + "error_offline": "Kamu sedang offline atau koneksi terputus. Periksa internet lalu coba lagi.", + "not_sent": "Tidak terkirim: {}" }, "zangetsu": { "title": "Sumber Zangetsu", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index e6fe21a6..87bfddef 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -3134,7 +3134,9 @@ "error_attachment_type": "Tenê wêneyên jpg, png an webp.", "error_attachment_size": "Divê her wêne herî zêde 5 MB be.", "error_attachment_count": "Di peyamekê de herî zêde 5 dîmenên ekranê.", - "suggest": "Dubare dibe? Ji piştgiriyê re binivîse" + "suggest": "Dubare dibe? Ji piştgiriyê re binivîse", + "error_offline": "Tu offline î an girêdan qut bû. Înternetê kontrol bike û dîsa biceribîne.", + "not_sent": "Nehat şandin: {}" }, "zangetsu": { "title": "Çavkaniyên Zangetsu", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index 661e14af..d267bce9 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -3134,7 +3134,9 @@ "error_attachment_type": "Alleen jpg-, png- of webp-afbeeldingen.", "error_attachment_size": "Elke afbeelding mag maximaal 5 MB zijn.", "error_attachment_count": "Maximaal 5 screenshots per bericht.", - "suggest": "Blijft het gebeuren? Schrijf support" + "suggest": "Blijft het gebeuren? Schrijf support", + "error_offline": "Je bent offline of de verbinding is verbroken. Controleer je internet en probeer het opnieuw.", + "not_sent": "Niet verzonden: {}" }, "zangetsu": { "title": "Zangetsu-bronnen", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index 96ef5db5..05df7d0f 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -3134,7 +3134,9 @@ "error_attachment_type": "Só imagens jpg, png ou webp.", "error_attachment_size": "Cada imagem pode ter no máximo 5 MB.", "error_attachment_count": "No máximo 5 capturas por mensagem.", - "suggest": "Continua acontecendo? Fale com o suporte" + "suggest": "Continua acontecendo? Fale com o suporte", + "error_offline": "Você está offline ou a conexão caiu. Verifique a internet e tente novamente.", + "not_sent": "Não enviado: {}" }, "zangetsu": { "title": "Fontes Zangetsu", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index 221657e1..c8c032fe 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -3135,7 +3135,9 @@ "error_attachment_type": "Только изображения jpg, png или webp.", "error_attachment_size": "Каждое изображение — не больше 5 МБ.", "error_attachment_count": "Не больше 5 скриншотов в сообщении.", - "suggest": "Повторяется? Напишите в поддержку" + "suggest": "Повторяется? Напишите в поддержку", + "error_offline": "Нет интернета или соединение прервалось. Проверьте подключение и попробуйте снова.", + "not_sent": "Не отправлено: {}" }, "zangetsu": { "title": "Источники Zangetsu", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index 45cdb2d6..c6e7d2c3 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -3134,7 +3134,9 @@ "error_attachment_type": "Yalnızca jpg, png veya webp görseller.", "error_attachment_size": "Her görsel en fazla 5 MB olmalı.", "error_attachment_count": "Mesaj başına en fazla 5 ekran görüntüsü.", - "suggest": "Tekrarlıyor mu? Desteğe yaz" + "suggest": "Tekrarlıyor mu? Desteğe yaz", + "error_offline": "Çevrimdışısın veya bağlantı koptu. İnternetini kontrol edip tekrar dene.", + "not_sent": "Gönderilmedi: {}" }, "zangetsu": { "title": "Zangetsu Kaynakları", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index 60366c15..f13dc99b 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -3134,7 +3134,9 @@ "error_attachment_type": "Faqat jpg, png yoki webp rasm.", "error_attachment_size": "Har bir rasm 5 MB dan oshmasin.", "error_attachment_count": "Bitta xabarga ko‘pi bilan 5 ta skrinshot.", - "suggest": "Takrorlanyaptimi? Support’ga yozing" + "suggest": "Takrorlanyaptimi? Support’ga yozing", + "error_offline": "Internet yo'q yoki aloqa uzildi. Internetni tekshirib, qayta urinib ko'ring.", + "not_sent": "Yuborilmadi: {}" }, "zangetsu": { "title": "Zangetsu manbalari", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index a0b6a8f4..91e39922 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -3134,7 +3134,9 @@ "error_attachment_type": "Chỉ nhận ảnh jpg, png hoặc webp.", "error_attachment_size": "Mỗi ảnh tối đa 5 MB.", "error_attachment_count": "Tối đa 5 ảnh mỗi tin nhắn.", - "suggest": "Vẫn tiếp diễn? Hãy viết cho bộ phận hỗ trợ" + "suggest": "Vẫn tiếp diễn? Hãy viết cho bộ phận hỗ trợ", + "error_offline": "Bạn đang ngoại tuyến hoặc kết nối bị ngắt. Hãy kiểm tra internet rồi thử lại.", + "not_sent": "Chưa gửi được: {}" }, "zangetsu": { "title": "Nguồn Zangetsu", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 62765563..276b3405 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -3134,7 +3134,9 @@ "error_attachment_type": "只接受 jpg、png 或 webp 圖片。", "error_attachment_size": "每張圖唔可以大過 5 MB。", "error_attachment_count": "每條訊息最多 5 張截圖。", - "suggest": "成日都係咁?搵支援" + "suggest": "成日都係咁?搵支援", + "error_offline": "你而家離線,或者連線斷咗。請檢查網絡再試。", + "not_sent": "未送出:{}" }, "zangetsu": { "title": "Zangetsu 來源", diff --git a/lib/features/detail/domain/playback/playback_fault.dart b/lib/features/detail/domain/playback/playback_fault.dart index bad2e5fe..cf13bfbc 100644 --- a/lib/features/detail/domain/playback/playback_fault.dart +++ b/lib/features/detail/domain/playback/playback_fault.dart @@ -95,7 +95,11 @@ class PlaybackFault { lower.contains('renderer')) { return PlaybackFault(PlaybackFaultKind.decoder, raw: raw); } + // libmpv's "Failed to open ": the host answered with a refusal + // (often a 403 without the Referer it expects) or not at all. The raw + // line, with its long address, read as the whole message on screen. if (lower.contains('source error') || + lower.contains('failed to open http') || lower.contains('failed to recognize file format') || lower.contains('unrecognizedinputformat') || lower.contains('nodeclaredbrand')) { diff --git a/lib/features/support/data/support_repository.dart b/lib/features/support/data/support_repository.dart index 40e4a5cd..6d0ae039 100644 --- a/lib/features/support/data/support_repository.dart +++ b/lib/features/support/data/support_repository.dart @@ -57,7 +57,12 @@ class SupportRepository { if (type == null) { throw SupportException('support.error_attachment_type'.tr()); } - final size = await file.length(); + final int size; + try { + size = await file.length(); + } on FileSystemException { + throw SupportException('support.error_attachment'.tr()); + } if (size > maxAttachmentBytes) { throw SupportException('support.error_attachment_size'.tr()); } @@ -86,7 +91,11 @@ class SupportRepository { }, ), ); - } on DioException { + } on DioException catch (e) { + throw SupportException( + _offline(e) ? 'support.error_offline'.tr() : 'support.error_attachment'.tr(), + ); + } on FileSystemException { throw SupportException('support.error_attachment'.tr()); } return key; @@ -192,6 +201,7 @@ class SupportRepository { final body = e.response?.data; final code = body is Map ? body['code']?.toString() : null; final key = switch ((e.response?.statusCode, code)) { + _ when _offline(e) => 'support.error_offline', (429, _) => 'support.error_too_many', (_, 'SUPPORT_MESSAGE') => 'support.error_short', (_, 'SUPPORT_THREAD_FULL') => 'support.error_thread_full', @@ -203,6 +213,20 @@ class SupportRepository { _ => 'support.error_generic', }; throw SupportException(key.tr(), code: code); + } on SupportException { + rethrow; + } catch (_) { + // Anything else (a reply this app cannot read, a file that went + // away) still has to end the spinner with a message. + throw SupportException('support.error_generic'.tr()); } } + + static bool _offline(DioException e) => switch (e.type) { + DioExceptionType.connectionError || + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout => true, + _ => false, + }; } diff --git a/lib/features/support/presentation/pages/support_new_page.dart b/lib/features/support/presentation/pages/support_new_page.dart index f92365ca..8921972b 100644 --- a/lib/features/support/presentation/pages/support_new_page.dart +++ b/lib/features/support/presentation/pages/support_new_page.dart @@ -122,14 +122,21 @@ class _SupportNewPageState extends State { context, ).showSnackBar(SnackBar(content: Text('support.sent'.tr()))); context.pushReplacement('/support/${ticket.id}'); - } on SupportException catch (e) { - if (mounted) { - setState(() { - _sending = false; - _progress = null; - _error = e.message; - }); - } + } catch (e) { + if (!mounted) return; + final message = e is SupportException + ? e.message + : 'support.error_generic'.tr(); + setState(() { + _sending = false; + _progress = null; + _error = message; + }); + // The inline line can sit below the keyboard or the fold; say it + // where it is seen too, so nobody wonders whether it went. + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text('support.not_sent'.tr(args: [message])))); } } diff --git a/test/features/detail/playback_fault_test.dart b/test/features/detail/playback_fault_test.dart index b922e136..b5b3d002 100644 --- a/test/features/detail/playback_fault_test.dart +++ b/test/features/detail/playback_fault_test.dart @@ -213,6 +213,14 @@ void main() { }); }); + test("mpv's Failed to open is a blocked source, not the raw address", () { + final fault = PlaybackFault.classify( + 'Bad state: Failed to open https://fetch.example/anime/a/b/master.m3u8.', + ); + expect(fault.kind, PlaybackFaultKind.sourceBlocked); + expect(fault.raw, contains('master.m3u8')); + }); + test('the domain layer stays free of Flutter', () { // If this file ever imports material.dart the fault can no longer be // classified in a plain Dart test, which is most of why it exists. From 09cae344c268682bfdb3f1394a406fce5dc3d688 Mon Sep 17 00:00:00 2001 From: professorDeveloper <108933534+professorDeveloper@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:57:12 +0500 Subject: [PATCH 16/16] 3.2.1: UI polish for the new features - Progress: retry on the unavailable state, directional padding, locker items focusable on TV and marked selected, tokens instead of raw greys. - Level ring scales its label down at large text sizes, reads as one label and skips the sweep with reduced motion. - Level-up dialog: localised barrier label, scrolls on short screens, spaced buttons, the primary action focused on TV. - Framed avatars draw their frame outside the avatar, so framed and plain avatars line up in lists, comments and headers. - Profile header: the level link is a HoverTap, so it gets the focus ring. - Contributors: a retry beside the load error; one semantics node per person instead of two buttons. - Star prompt: the rating gold token. - Episode comments side panel slides from, and pads for, the correct edge in RTL. - Reaction picker: 48 dp buttons, a label on Send. - Watch party: directional paddings, tooltips on the room size stepper, switches styled like Settings, member tiles focusable on TV, the invite button is the shared secondary button, a label on chat Send. - Mangayomi and Zangetsu pages: theme tokens for error, success and greys; Zangetsu filters use the shared chip rows; the picker's select buttons shrink instead of overflowing; filter chips report their selection. - Manga translation preview: the action bar wraps on narrow phones, Apply focused on TV. - Auto Resolve: Stop and Try again take focus on TV when the row stops being focusable. - Voice search: dialog radius matches the app's dialogs; Cancel focused on TV while listening. - Player settings: the spoiler toggle no longer shares the incognito icon. --- assets/translations/ar.json | 2 + assets/translations/ckb.json | 2 + assets/translations/de.json | 2 + assets/translations/en.json | 2 + assets/translations/es.json | 2 + assets/translations/fr.json | 2 + assets/translations/hi.json | 2 + assets/translations/id.json | 2 + assets/translations/ku.json | 2 + assets/translations/nl.json | 2 + assets/translations/pt.json | 2 + assets/translations/ru.json | 2 + assets/translations/tr.json | 2 + assets/translations/uz.json | 2 + assets/translations/vi.json | 2 + assets/translations/yue.json | 2 + lib/core/widgets/star_prompt_banner.dart | 2 +- .../widgets/episode_comments_sheet.dart | 9 +- .../presentation/widgets/reaction_picker.dart | 3 +- .../widgets/alternate_source_sheet.dart | 5 + .../pages/mangayomi_source_picker_page.dart | 26 ++- .../pages/mangayomi_sources_page.dart | 44 ++-- .../pages/zangetsu_sources_page.dart | 65 +++--- .../widgets/source_filter_chips.dart | 82 ++++---- .../widgets/page_translation_overlay.dart | 60 ++++-- .../pages/player_settings_page.dart | 2 +- .../pages/profile_page.header.dart | 7 +- .../widgets/contributors_section.dart | 9 +- .../presentation/dialogs/level_up_dialog.dart | 178 ++++++++-------- .../presentation/pages/progress_page.dart | 196 +++++++++++------- .../presentation/widgets/level_ring.dart | 85 ++++---- .../presentation/widgets/user_badge_line.dart | 38 +++- .../widgets/voice_search_dialog.dart | 5 +- .../presentation/pages/watch_party_page.dart | 12 +- .../widgets/party_chat_panel.dart | 29 +-- .../widgets/party_host_settings.dart | 10 +- .../widgets/party_member_bar.dart | 4 +- 37 files changed, 536 insertions(+), 367 deletions(-) diff --git a/assets/translations/ar.json b/assets/translations/ar.json index 89ca2401..2469a828 100644 --- a/assets/translations/ar.json +++ b/assets/translations/ar.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "تحقّق من اتصالك بالإنترنت وحاول مرة أخرى.", "settings_title": "إعدادات الغرفة", "room_size": "حجم الغرفة", + "room_size_less": "عدد أقل من الأشخاص", + "room_size_more": "عدد أكبر من الأشخاص", "only_host_setting": "المضيف وحده يتحكم في التشغيل", "lock_room": "قفل الغرفة", "lock_room_hint": "لا يمكن لأحد جديد الانضمام", diff --git a/assets/translations/ckb.json b/assets/translations/ckb.json index 3bb9188f..0bd7bf68 100644 --- a/assets/translations/ckb.json +++ b/assets/translations/ckb.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "هێڵی ئینتەرنێتەکەت بپشکنە و دووبارە هەوڵبدەرەوە.", "settings_title": "ڕێکخستنەکانی ژوور", "room_size": "قەبارەی ژوور", + "room_size_less": "کەسی کەمتر", + "room_size_more": "کەسی زیاتر", "only_host_setting": "تەنها خانەخوێ لێدان کۆنترۆڵ دەکات", "lock_room": "قفڵکردنی ژوور", "lock_room_hint": "کەسی نوێ ناتوانێت بەشداربێت", diff --git a/assets/translations/de.json b/assets/translations/de.json index cb4b169c..266f00e4 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Prüfe deine Internetverbindung und versuche es erneut.", "settings_title": "Raumeinstellungen", "room_size": "Raumgröße", + "room_size_less": "Weniger Personen", + "room_size_more": "Mehr Personen", "only_host_setting": "Nur der Host steuert die Wiedergabe", "lock_room": "Raum sperren", "lock_room_hint": "Niemand Neues kann beitreten", diff --git a/assets/translations/en.json b/assets/translations/en.json index 76b041c1..32dda706 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Check your internet connection and try again.", "settings_title": "Room settings", "room_size": "Room size", + "room_size_less": "Fewer people", + "room_size_more": "More people", "only_host_setting": "Only host controls playback", "lock_room": "Lock room", "lock_room_hint": "No one new can join", diff --git a/assets/translations/es.json b/assets/translations/es.json index 898c3a11..c464b7de 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Comprueba tu conexión a internet e inténtalo de nuevo.", "settings_title": "Ajustes de la sala", "room_size": "Tamaño de la sala", + "room_size_less": "Menos personas", + "room_size_more": "Más personas", "only_host_setting": "Solo el anfitrión controla la reproducción", "lock_room": "Bloquear sala", "lock_room_hint": "Nadie nuevo puede unirse", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 5602bb11..e1a47c74 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Vérifie ta connexion internet et réessaie.", "settings_title": "Paramètres du salon", "room_size": "Taille du salon", + "room_size_less": "Moins de personnes", + "room_size_more": "Plus de personnes", "only_host_setting": "Seul l'hôte contrôle la lecture", "lock_room": "Verrouiller le salon", "lock_room_hint": "Personne de nouveau ne peut rejoindre", diff --git a/assets/translations/hi.json b/assets/translations/hi.json index 967ff96b..83f534df 100644 --- a/assets/translations/hi.json +++ b/assets/translations/hi.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "अपना इंटरनेट कनेक्शन जाँचें और फिर से कोशिश करें।", "settings_title": "रूम सेटिंग्स", "room_size": "रूम का आकार", + "room_size_less": "कम लोग", + "room_size_more": "ज़्यादा लोग", "only_host_setting": "सिर्फ़ होस्ट प्लेबैक नियंत्रित करता है", "lock_room": "रूम लॉक करें", "lock_room_hint": "कोई नया शामिल नहीं हो सकता", diff --git a/assets/translations/id.json b/assets/translations/id.json index 70e0881a..89ecd4ce 100644 --- a/assets/translations/id.json +++ b/assets/translations/id.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Periksa koneksi internetmu lalu coba lagi.", "settings_title": "Pengaturan ruang", "room_size": "Ukuran ruang", + "room_size_less": "Lebih sedikit orang", + "room_size_more": "Lebih banyak orang", "only_host_setting": "Hanya host yang mengontrol pemutaran", "lock_room": "Kunci ruang", "lock_room_hint": "Tidak ada orang baru yang bisa bergabung", diff --git a/assets/translations/ku.json b/assets/translations/ku.json index 87bfddef..56a7ff83 100644 --- a/assets/translations/ku.json +++ b/assets/translations/ku.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Girêdana înternetê kontrol bike û dîsa biceribîne.", "settings_title": "Mîhengên odeyê", "room_size": "Mezinahiya odeyê", + "room_size_less": "Kesên kêmtir", + "room_size_more": "Kesên zêdetir", "only_host_setting": "Tenê mazûvan lêdanê kontrol dike", "lock_room": "Odeyê kilît bike", "lock_room_hint": "Kesekî nû nikare tevlî bibe", diff --git a/assets/translations/nl.json b/assets/translations/nl.json index d267bce9..b86ec879 100644 --- a/assets/translations/nl.json +++ b/assets/translations/nl.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Controleer je internetverbinding en probeer het opnieuw.", "settings_title": "Kamerinstellingen", "room_size": "Kamergrootte", + "room_size_less": "Minder mensen", + "room_size_more": "Meer mensen", "only_host_setting": "Alleen de host bedient het afspelen", "lock_room": "Kamer vergrendelen", "lock_room_hint": "Niemand nieuw kan meedoen", diff --git a/assets/translations/pt.json b/assets/translations/pt.json index 05df7d0f..01e96d87 100644 --- a/assets/translations/pt.json +++ b/assets/translations/pt.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Verifique sua conexão com a internet e tente de novo.", "settings_title": "Configurações da sala", "room_size": "Tamanho da sala", + "room_size_less": "Menos pessoas", + "room_size_more": "Mais pessoas", "only_host_setting": "Só o anfitrião controla a reprodução", "lock_room": "Trancar sala", "lock_room_hint": "Ninguém novo pode entrar", diff --git a/assets/translations/ru.json b/assets/translations/ru.json index c8c032fe..9bc900dd 100644 --- a/assets/translations/ru.json +++ b/assets/translations/ru.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Проверьте подключение к интернету и повторите попытку.", "settings_title": "Настройки комнаты", "room_size": "Размер комнаты", + "room_size_less": "Меньше участников", + "room_size_more": "Больше участников", "only_host_setting": "Воспроизведением управляет только хост", "lock_room": "Закрыть комнату", "lock_room_hint": "Новые участники не смогут войти", diff --git a/assets/translations/tr.json b/assets/translations/tr.json index c6e7d2c3..701b1e45 100644 --- a/assets/translations/tr.json +++ b/assets/translations/tr.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "İnternet bağlantını kontrol edip tekrar dene.", "settings_title": "Oda ayarları", "room_size": "Oda boyutu", + "room_size_less": "Daha az kişi", + "room_size_more": "Daha fazla kişi", "only_host_setting": "Oynatmayı yalnızca host kontrol eder", "lock_room": "Odayı kilitle", "lock_room_hint": "Yeni kimse katılamaz", diff --git a/assets/translations/uz.json b/assets/translations/uz.json index f13dc99b..dddc90c9 100644 --- a/assets/translations/uz.json +++ b/assets/translations/uz.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Internet aloqangizni tekshirib, qayta urinib ko'ring.", "settings_title": "Xona sozlamalari", "room_size": "Xona hajmi", + "room_size_less": "Kamroq odam", + "room_size_more": "Ko‘proq odam", "only_host_setting": "Ijroni faqat host boshqaradi", "lock_room": "Xonani qulflash", "lock_room_hint": "Yangi hech kim qo'shila olmaydi", diff --git a/assets/translations/vi.json b/assets/translations/vi.json index 91e39922..54733568 100644 --- a/assets/translations/vi.json +++ b/assets/translations/vi.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "Hãy kiểm tra kết nối mạng và thử lại.", "settings_title": "Cài đặt phòng", "room_size": "Kích thước phòng", + "room_size_less": "Ít người hơn", + "room_size_more": "Nhiều người hơn", "only_host_setting": "Chỉ chủ phòng điều khiển phát", "lock_room": "Khóa phòng", "lock_room_hint": "Không ai mới có thể tham gia", diff --git a/assets/translations/yue.json b/assets/translations/yue.json index 276b3405..531b9e88 100644 --- a/assets/translations/yue.json +++ b/assets/translations/yue.json @@ -1215,6 +1215,8 @@ "connect_failed_msg": "檢查下你嘅網絡連線再試一次。", "settings_title": "房間設定", "room_size": "房間人數", + "room_size_less": "少啲人", + "room_size_more": "多啲人", "only_host_setting": "淨係房主可以控制播放", "lock_room": "鎖住房間", "lock_room_hint": "新人唔可以加入", diff --git a/lib/core/widgets/star_prompt_banner.dart b/lib/core/widgets/star_prompt_banner.dart index 46ab792e..a2d4e835 100644 --- a/lib/core/widgets/star_prompt_banner.dart +++ b/lib/core/widgets/star_prompt_banner.dart @@ -130,7 +130,7 @@ class _StarPromptBannerState extends State { children: [ const Icon( Icons.star_rounded, - color: Color(0xFFFFC83D), + color: AppColors.rating, size: 22, ), const SizedBox(width: 10), diff --git a/lib/features/comments/presentation/widgets/episode_comments_sheet.dart b/lib/features/comments/presentation/widgets/episode_comments_sheet.dart index f2975416..8bc919bb 100644 --- a/lib/features/comments/presentation/widgets/episode_comments_sheet.dart +++ b/lib/features/comments/presentation/widgets/episode_comments_sheet.dart @@ -1,4 +1,4 @@ -import 'package:easy_localization/easy_localization.dart'; +import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flutter/material.dart'; import 'package:soplay/core/system/platform_utils.dart'; import 'package:soplay/core/theme/app_colors.dart'; @@ -38,6 +38,9 @@ Future showEpisodeComments( builder: (_) => SizedBox(height: size.height * 0.75, child: panel), ); } + // The panel sits on the end edge: it slides in from there, and the inset on + // its inner edge is not its concern. + final rtl = Directionality.of(context) == TextDirection.rtl; return showGeneralDialog( context: context, barrierDismissible: true, @@ -51,12 +54,12 @@ Future showEpisodeComments( height: double.infinity, child: Material( color: AppColors.background, - child: SafeArea(left: false, child: panel), + child: SafeArea(left: rtl, right: !rtl, child: panel), ), ), ), transitionBuilder: (context, animation, _, child) => SlideTransition( - position: Tween(begin: const Offset(1, 0), end: Offset.zero).animate( + position: Tween(begin: Offset(rtl ? -1 : 1, 0), end: Offset.zero).animate( CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), ), child: child, diff --git a/lib/features/comments/presentation/widgets/reaction_picker.dart b/lib/features/comments/presentation/widgets/reaction_picker.dart index 29268701..8cb39228 100644 --- a/lib/features/comments/presentation/widgets/reaction_picker.dart +++ b/lib/features/comments/presentation/widgets/reaction_picker.dart @@ -123,6 +123,7 @@ class _ReactionPickerState extends State<_ReactionPicker> { ), ), _RoundButton( + tooltip: 'support.send'.tr(), onTap: _sendText, child: Icon(Icons.send_rounded, color: AppColors.primary, size: 20), ), @@ -152,7 +153,7 @@ class _RoundButton extends StatelessWidget { autofocus: autofocus, customBorder: const CircleBorder(), focusColor: Colors.white24, - child: SizedBox(width: 44, height: 44, child: Center(child: child)), + child: SizedBox(width: 48, height: 48, child: Center(child: child)), ); return tooltip == null ? button : Tooltip(message: tooltip!, child: button); } diff --git a/lib/features/detail/presentation/widgets/alternate_source_sheet.dart b/lib/features/detail/presentation/widgets/alternate_source_sheet.dart index b8b28383..29b3a134 100644 --- a/lib/features/detail/presentation/widgets/alternate_source_sheet.dart +++ b/lib/features/detail/presentation/widgets/alternate_source_sheet.dart @@ -15,6 +15,7 @@ import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/matching/title_match.dart'; import 'package:soplay/core/network/image_headers.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/system/platform_utils.dart'; import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/features/detail/data/source_choice_store.dart'; import 'package:soplay/features/detail/domain/entities/player_args.dart'; @@ -554,6 +555,9 @@ class _AlternateSourceSheetState extends State { ), const SizedBox(width: 4), TextButton( + // The row it replaces stops being focusable once Auto runs, so a + // remote would otherwise be left with nothing focused. + autofocus: isTvPlatform, onPressed: _stopAuto, style: TextButton.styleFrom(foregroundColor: Colors.white), child: Text('player.alt_auto_stop'.tr()), @@ -562,6 +566,7 @@ class _AlternateSourceSheetState extends State { ); } else if (ended) { trailing = TextButton.icon( + autofocus: isTvPlatform, onPressed: _preparing != null ? null : _runAuto, style: TextButton.styleFrom(foregroundColor: AppColors.primary), icon: const Icon(Icons.refresh_rounded, size: 18), diff --git a/lib/features/extensions/presentation/pages/mangayomi_source_picker_page.dart b/lib/features/extensions/presentation/pages/mangayomi_source_picker_page.dart index 3a1e943f..cf7500b1 100644 --- a/lib/features/extensions/presentation/pages/mangayomi_source_picker_page.dart +++ b/lib/features/extensions/presentation/pages/mangayomi_source_picker_page.dart @@ -153,17 +153,29 @@ class _MangayomiSourcePickerPageState extends State { padding: const EdgeInsets.symmetric(horizontal: 8), child: Row( children: [ - TextButton( - onPressed: () => _setAll(visible, true), - child: Text('ext.select_all'.tr()), + Flexible( + child: TextButton( + onPressed: () => _setAll(visible, true), + child: Text( + 'ext.select_all'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), ), - TextButton( - onPressed: () => _setAll(visible, false), - child: Text('ext.select_none'.tr()), + Flexible( + child: TextButton( + onPressed: () => _setAll(visible, false), + child: Text( + 'ext.select_none'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), ), const Spacer(), Padding( - padding: const EdgeInsets.only(right: 8), + padding: const EdgeInsetsDirectional.only(end: 8), child: Text( '${visible.length}', style: const TextStyle(color: AppColors.textHint), diff --git a/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart b/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart index 0f772734..be5ecb21 100644 --- a/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart +++ b/lib/features/extensions/presentation/pages/mangayomi_sources_page.dart @@ -171,7 +171,7 @@ class _MangayomiSourcesPageState extends State { onPressed: () => Navigator.of(ctx).pop(true), child: Text( 'ext.remove'.tr(), - style: const TextStyle(color: Colors.redAccent), + style: const TextStyle(color: AppColors.error), ), ), ], @@ -284,9 +284,21 @@ class _MangayomiSourcesPageState extends State { Widget build(BuildContext context) { if (!MangayomiRuntime.isSupported) { return Scaffold( - appBar: AppBar(title: Text('ext.mangayomi_title'.tr())), + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.background, + surfaceTintColor: Colors.transparent, + title: Text('ext.mangayomi_title'.tr()), + ), body: Center( - child: Text('ext.needs_webview'.tr()), + child: Padding( + padding: const EdgeInsets.all(32), + child: Text( + 'ext.needs_webview'.tr(), + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.textSecondary), + ), + ), ), ); } @@ -418,9 +430,9 @@ class _MangayomiSourcesPageState extends State { errorBuilder: (_, _, _) => Container( width: 44, height: 44, - color: Colors.white10, + color: AppColors.surfaceVariant, child: const Icon(Icons.javascript_outlined, - color: Colors.white54), + color: AppColors.textHint), )), ), const SizedBox(width: 12), @@ -493,7 +505,7 @@ class _MangayomiSourcesPageState extends State { ); Widget _statusBanner() { - final color = _statusError ? Colors.redAccent : Colors.green; + final color = _statusError ? AppColors.error : AppColors.success; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( @@ -567,13 +579,13 @@ class _MangayomiSourcesPageState extends State { IconButton( tooltip: 'ext.add_sources'.tr(), icon: const Icon(Icons.playlist_add_rounded, - color: Colors.white70), + color: AppColors.textSecondary), onPressed: _busy ? null : () => _install([url]), ), IconButton( tooltip: 'ext.remove'.tr(), icon: const Icon(Icons.delete_outline, - color: Colors.redAccent), + color: AppColors.error), onPressed: _busy ? null : () => _remove(url), ), ], @@ -583,7 +595,7 @@ class _MangayomiSourcesPageState extends State { ); Widget _updatesBanner(int count) => Container( - padding: const EdgeInsets.fromLTRB(12, 6, 6, 6), + padding: const EdgeInsetsDirectional.fromSTEB(12, 6, 6, 6), decoration: BoxDecoration( color: _accent.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), @@ -640,9 +652,9 @@ class _MangayomiSourcesPageState extends State { errorBuilder: (_, _, _) => Container( width: 26, height: 26, - color: Colors.white10, + color: AppColors.surfaceVariant, child: const Icon(Icons.public, - size: 14, color: Colors.white54), + size: 14, color: AppColors.textHint), ), ), ), @@ -666,12 +678,12 @@ class _MangayomiSourcesPageState extends State { padding: const EdgeInsets.symmetric( horizontal: 5, vertical: 1), decoration: BoxDecoration( - color: Colors.redAccent.withValues(alpha: 0.15), + color: AppColors.error.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), ), child: const Text('18+', style: TextStyle( - color: Colors.redAccent, + color: AppColors.error, fontSize: 9, fontWeight: FontWeight.w700)), ), @@ -714,13 +726,13 @@ class _MangayomiSourcesPageState extends State { tooltip: 'ext.settings'.tr(), visualDensity: VisualDensity.compact, icon: const Icon(Icons.tune_rounded, - size: 19, color: Colors.white70), + size: 19, color: AppColors.textSecondary), onPressed: () => SourceSettingsSheet.show(context, s), ), PopupMenuButton<_SourceAction>( enabled: !_busy, icon: const Icon(Icons.more_vert_rounded, - size: 19, color: Colors.white70), + size: 19, color: AppColors.textSecondary), color: AppColors.surface, onSelected: (a) => switch (a) { _SourceAction.toggle => _setEnabled(s, !enabled), @@ -736,7 +748,7 @@ class _MangayomiSourcesPageState extends State { PopupMenuItem( value: _SourceAction.remove, child: Text('ext.remove_source'.tr(), - style: const TextStyle(color: Colors.redAccent)), + style: const TextStyle(color: AppColors.error)), ), ], ), diff --git a/lib/features/extensions/presentation/pages/zangetsu_sources_page.dart b/lib/features/extensions/presentation/pages/zangetsu_sources_page.dart index 1f029a04..568fd2c7 100644 --- a/lib/features/extensions/presentation/pages/zangetsu_sources_page.dart +++ b/lib/features/extensions/presentation/pages/zangetsu_sources_page.dart @@ -9,6 +9,7 @@ import 'package:soplay/features/extensions/data/zangetsu_repo_store.dart'; import 'package:soplay/features/extensions/domain/entities/extension_repo_entity.dart'; import 'package:soplay/features/extensions/domain/entities/zangetsu_source.dart'; import 'package:soplay/features/extensions/presentation/widgets/recommended_repos_section.dart'; +import 'package:soplay/features/extensions/presentation/widgets/source_filter_chips.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_bloc.dart'; import 'package:soplay/features/profile/presentation/bloc/provider_event.dart'; import 'package:soplay/features/sources/domain/source_scope.dart'; @@ -369,7 +370,7 @@ class _ZangetsuSourcesPageState extends State { ); Widget _statusBanner() { - final color = _statusError ? Colors.redAccent : Colors.green; + final color = _statusError ? AppColors.error : AppColors.success; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( @@ -452,7 +453,7 @@ class _ZangetsuSourcesPageState extends State { ), trailing: IconButton( tooltip: 'general.remove'.tr(), - icon: const Icon(Icons.delete_outline, color: Colors.redAccent), + icon: const Icon(Icons.delete_outline, color: AppColors.error), onPressed: _busy ? null : () => _removeRepo(url), ), ), @@ -472,36 +473,37 @@ class _ZangetsuSourcesPageState extends State { prefix: const Icon(Icons.search, color: AppColors.textHint), ), ), - const SizedBox(height: 8), - Wrap( - spacing: 6, - runSpacing: 6, - children: [ + const SizedBox(height: 4), + // The same chip rows as the Mangayomi page and the catalog. + SourceFilterChipRow( + padding: EdgeInsets.zero, + options: [ for (final (value, label) in [ ('all', 'zangetsu.all_types'.tr()), ('anime', 'ext.type_anime'.tr()), ('movies', 'zangetsu.type_movies'.tr()), ]) - ChoiceChip( - label: Text(label), - selected: _type == value, - selectedColor: _accent.withValues(alpha: 0.25), - onSelected: (_) => setState(() => _type = value), + SourceFilterOption( + label: label, + active: _type == value, + onTap: () => setState(() => _type = value), ), - if (langs.length > 1) - for (final lang in ['all', ...langs]) - ChoiceChip( - label: Text( - lang == 'all' - ? 'zangetsu.all_languages'.tr() - : lang.toUpperCase(), - ), - selected: _lang == lang, - selectedColor: _accent.withValues(alpha: 0.25), - onSelected: (_) => setState(() => _lang = lang), - ), ], ), + if (langs.length > 1) + SourceFilterChipRow( + padding: EdgeInsets.zero, + options: [ + for (final lang in ['all', ...langs]) + SourceFilterOption( + label: lang == 'all' + ? 'zangetsu.all_languages'.tr() + : lang.toUpperCase(), + active: _lang == lang, + onTap: () => setState(() => _lang = lang), + ), + ], + ), ], ); @@ -539,7 +541,7 @@ class _ZangetsuSourcesPageState extends State { ), if (s.nsfw) ...[ const SizedBox(width: 6), - const _Badge(text: '18+', color: Colors.redAccent), + const _Badge(text: '18+', color: AppColors.error), ], ], ), @@ -547,6 +549,8 @@ class _ZangetsuSourcesPageState extends State { '${s.lang.toUpperCase()} · ' '${s.isAnime ? 'ext.type_anime'.tr() : 'zangetsu.type_movies'.tr()}' ' · v${shown.version}', + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.textHint, fontSize: 10.5, @@ -568,7 +572,7 @@ class _ZangetsuSourcesPageState extends State { ZangetsuSource? update, ) { if (s.builtIn) { - return [_Badge(text: 'zangetsu.built_in'.tr(), color: Colors.green)]; + return [_Badge(text: 'zangetsu.built_in'.tr(), color: AppColors.success)]; } if (installed == null) { return [ @@ -588,13 +592,14 @@ class _ZangetsuSourcesPageState extends State { ), Switch( value: installed.enabled, - activeThumbColor: _accent, + activeThumbColor: Colors.white, + activeTrackColor: _accent, onChanged: _busy ? null : (v) => _toggle(installed, v), ), IconButton( tooltip: 'general.remove'.tr(), visualDensity: VisualDensity.compact, - icon: const Icon(Icons.delete_outline, color: Colors.redAccent), + icon: const Icon(Icons.delete_outline, color: AppColors.error), onPressed: _busy ? null : () => _remove(installed), ), ]; @@ -604,8 +609,8 @@ class _ZangetsuSourcesPageState extends State { final fallback = Container( width: 26, height: 26, - color: Colors.white10, - child: const Icon(Icons.bolt_rounded, size: 14, color: Colors.white54), + color: AppColors.surfaceVariant, + child: const Icon(Icons.bolt_rounded, size: 14, color: AppColors.textHint), ); return ClipRRect( borderRadius: BorderRadius.circular(7), diff --git a/lib/features/extensions/presentation/widgets/source_filter_chips.dart b/lib/features/extensions/presentation/widgets/source_filter_chips.dart index a58224dc..4f74cf6a 100644 --- a/lib/features/extensions/presentation/widgets/source_filter_chips.dart +++ b/lib/features/extensions/presentation/widgets/source_filter_chips.dart @@ -48,52 +48,56 @@ class SourceFilterChipRow extends StatelessWidget { } Widget _chip(BuildContext context, SourceFilterOption o) { - return TvFocusable( - onPressed: o.onTap, - borderRadius: 17, - ensureVisibleAlignment: 0.5, - child: AnimatedContainer( - duration: Duration( - milliseconds: MediaQuery.disableAnimationsOf(context) ? 0 : 120, - ), - curve: Curves.easeOutCubic, - height: 34, - padding: const EdgeInsets.symmetric(horizontal: 12), - alignment: Alignment.center, - decoration: BoxDecoration( - color: o.active ? AppColors.primary : AppColors.surface, - borderRadius: BorderRadius.circular(17), - border: Border.all( - color: o.active - ? AppColors.primary.withValues(alpha: 0.55) - : Colors.white.withValues(alpha: 0.07), + return Semantics( + button: true, + selected: o.active, + child: TvFocusable( + onPressed: o.onTap, + borderRadius: 17, + ensureVisibleAlignment: 0.5, + child: AnimatedContainer( + duration: Duration( + milliseconds: MediaQuery.disableAnimationsOf(context) ? 0 : 120, ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - o.label, - style: TextStyle( - color: o.active ? Colors.white : AppColors.textSecondary, - fontSize: 12.5, - fontWeight: o.active ? FontWeight.w700 : FontWeight.w600, - ), + curve: Curves.easeOutCubic, + height: 34, + padding: const EdgeInsets.symmetric(horizontal: 12), + alignment: Alignment.center, + decoration: BoxDecoration( + color: o.active ? AppColors.primary : AppColors.surface, + borderRadius: BorderRadius.circular(17), + border: Border.all( + color: o.active + ? AppColors.primary.withValues(alpha: 0.55) + : Colors.white.withValues(alpha: 0.07), ), - if (o.count != null) ...[ - const SizedBox(width: 5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ Text( - '${o.count}', + o.label, style: TextStyle( - color: o.active - ? AppColors.primaryLight.withValues(alpha: 0.75) - : AppColors.textHint, - fontSize: 11, - fontWeight: FontWeight.w600, + color: o.active ? Colors.white : AppColors.textSecondary, + fontSize: 12.5, + fontWeight: o.active ? FontWeight.w700 : FontWeight.w600, ), ), + if (o.count != null) ...[ + const SizedBox(width: 5), + Text( + '${o.count}', + style: TextStyle( + color: o.active + ? AppColors.primaryLight.withValues(alpha: 0.75) + : AppColors.textHint, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], ], - ], + ), ), ), ); diff --git a/lib/features/manga/presentation/widgets/page_translation_overlay.dart b/lib/features/manga/presentation/widgets/page_translation_overlay.dart index a524c356..5e11f619 100644 --- a/lib/features/manga/presentation/widgets/page_translation_overlay.dart +++ b/lib/features/manga/presentation/widgets/page_translation_overlay.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:flutter/material.dart'; +import 'package:soplay/core/system/platform_utils.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/manga/domain/translate/page_translation.dart'; @@ -196,31 +197,44 @@ class _PageTranslatePreviewState extends State { top: false, child: Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 8), - child: Row( - children: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text( - 'general.cancel'.tr(), - style: const TextStyle(color: Colors.white70), + // Wraps rather than overflows when the labels are long and the + // phone is narrow. + child: SizedBox( + width: double.infinity, + child: Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 4, + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + 'general.cancel'.tr(), + style: const TextStyle(color: AppColors.textSecondary), + ), ), - ), - const Spacer(), - FilterChip( - selected: _original, - onSelected: (v) => setState(() => _original = v), - label: Text('manga.translate_show_original'.tr()), - selectedColor: accent.withValues(alpha: 0.3), - ), - const SizedBox(width: 10), - FilledButton( - style: FilledButton.styleFrom(backgroundColor: accent), - onPressed: () => Navigator.of(context).pop( - PageTranslatePreviewResult(showOriginal: _original), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + FilterChip( + selected: _original, + onSelected: (v) => setState(() => _original = v), + label: Text('manga.translate_show_original'.tr()), + selectedColor: accent.withValues(alpha: 0.3), + ), + const SizedBox(width: 10), + FilledButton( + autofocus: isTvPlatform, + style: FilledButton.styleFrom(backgroundColor: accent), + onPressed: () => Navigator.of(context).pop( + PageTranslatePreviewResult(showOriginal: _original), + ), + child: Text('manga.translate_apply'.tr()), + ), + ], ), - child: Text('manga.translate_apply'.tr()), - ), - ], + ], + ), ), ), ), diff --git a/lib/features/profile/presentation/pages/player_settings_page.dart b/lib/features/profile/presentation/pages/player_settings_page.dart index 2a0f07da..30076261 100644 --- a/lib/features/profile/presentation/pages/player_settings_page.dart +++ b/lib/features/profile/presentation/pages/player_settings_page.dart @@ -310,7 +310,7 @@ class _PlayerSettingsPageState extends State { ), const SettingsDivider(), SettingsSwitchTile( - icon: Icons.visibility_off_rounded, + icon: Icons.speaker_notes_off_outlined, title: 'profile.hide_spoilers'.tr(), subtitle: 'profile.hide_spoilers_desc'.tr(), value: _hideSpoilers, diff --git a/lib/features/profile/presentation/pages/profile_page.header.dart b/lib/features/profile/presentation/pages/profile_page.header.dart index fde32c19..227a2244 100644 --- a/lib/features/profile/presentation/pages/profile_page.header.dart +++ b/lib/features/profile/presentation/pages/profile_page.header.dart @@ -271,11 +271,12 @@ class _LevelLink extends StatelessWidget { Widget build(BuildContext context) { final p = view.progress; final title = Cosmetics.titleText(view.title); - return InkWell( + return HoverTap( onTap: () => context.push('/progress'), - borderRadius: BorderRadius.circular(8), + borderRadius: 8, + scale: 1.02, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 2), + padding: const EdgeInsets.symmetric(vertical: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/features/profile/presentation/widgets/contributors_section.dart b/lib/features/profile/presentation/widgets/contributors_section.dart index 86875e57..9dc67952 100644 --- a/lib/features/profile/presentation/widgets/contributors_section.dart +++ b/lib/features/profile/presentation/widgets/contributors_section.dart @@ -58,7 +58,7 @@ class _ContributorsSectionState extends State { : ValueListenableBuilder( valueListenable: _service.failed, builder: (_, failed, _) => failed - ? const _QuietError() + ? _QuietError(onRetry: _service.refresh) : const _Skeleton(), ), ), @@ -99,8 +99,8 @@ class _Grid extends StatelessWidget { borderRadius: 12, onTap: () => onOpen(c.htmlUrl), child: Semantics( - button: true, label: c.login, + excludeSemantics: true, child: SizedBox( width: _cell, child: Column( @@ -202,7 +202,9 @@ class _Skeleton extends StatelessWidget { } class _QuietError extends StatelessWidget { - const _QuietError(); + const _QuietError({required this.onRetry}); + + final VoidCallback onRetry; @override Widget build(BuildContext context) { @@ -216,6 +218,7 @@ class _QuietError extends StatelessWidget { style: const TextStyle(color: AppColors.textHint, fontSize: 12), ), ), + TextButton(onPressed: onRetry, child: Text('general.retry'.tr())), ], ); } diff --git a/lib/features/progress/presentation/dialogs/level_up_dialog.dart b/lib/features/progress/presentation/dialogs/level_up_dialog.dart index b99ec033..a48026e1 100644 --- a/lib/features/progress/presentation/dialogs/level_up_dialog.dart +++ b/lib/features/progress/presentation/dialogs/level_up_dialog.dart @@ -1,6 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:soplay/core/system/platform_utils.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/progress/data/progress_service.dart'; import 'package:soplay/features/progress/presentation/widgets/level_ring.dart'; @@ -21,7 +22,7 @@ class LevelUpDialog extends StatelessWidget { return showGeneralDialog( context: context, barrierDismissible: true, - barrierLabel: 'Level up', + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, barrierColor: Colors.black.withValues(alpha: 0.8), transitionDuration: const Duration(milliseconds: 320), pageBuilder: (_, _, _) => LevelUpDialog(levelUp: levelUp, onOpen: onOpen), @@ -44,104 +45,109 @@ class LevelUpDialog extends StatelessWidget { return Material( type: MaterialType.transparency, child: Center( - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 28), - constraints: const BoxConstraints(maxWidth: 360), - padding: const EdgeInsets.fromLTRB(24, 28, 24, 18), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.surface, AppColors.background], - ), - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: AppColors.primary.withValues(alpha: 0.3), - width: 0.8, - ), - boxShadow: [ - BoxShadow( - color: AppColors.primary.withValues(alpha: 0.18), - blurRadius: 34, - spreadRadius: 2, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 28), + constraints: const BoxConstraints(maxWidth: 360), + padding: const EdgeInsets.fromLTRB(24, 28, 24, 18), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [AppColors.surface, AppColors.background], ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'progress.level_up'.tr().toUpperCase(), - style: TextStyle( - color: AppColors.primaryLight, - fontSize: 12, - fontWeight: FontWeight.w800, - letterSpacing: 1.8, - ), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.3), + width: 0.8, ), - const SizedBox(height: 18), - LevelRing(level: levelUp.to, fraction: 1, size: 132), - const SizedBox(height: 16), - Text( - 'progress.level_reached'.tr(args: ['${levelUp.to}']), - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 20, - fontWeight: FontWeight.w800, + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.18), + blurRadius: 34, + spreadRadius: 2, ), - ), - if (unlocked.isNotEmpty) ...[ - const SizedBox(height: 14), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ Text( - 'progress.unlocked_new'.tr(), + 'progress.level_up'.tr().toUpperCase(), + style: TextStyle( + color: AppColors.primaryLight, + fontSize: 12, + fontWeight: FontWeight.w800, + letterSpacing: 1.8, + ), + ), + const SizedBox(height: 18), + LevelRing(level: levelUp.to, fraction: 1, size: 132), + const SizedBox(height: 16), + Text( + 'progress.level_reached'.tr(args: ['${levelUp.to}']), + textAlign: TextAlign.center, style: const TextStyle( - color: AppColors.textSecondary, - fontSize: 12.5, + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w800, ), ), - const SizedBox(height: 8), - Wrap( - alignment: WrapAlignment.center, - spacing: 6, - runSpacing: 6, + if (unlocked.isNotEmpty) ...[ + const SizedBox(height: 14), + Text( + 'progress.unlocked_new'.tr(), + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 12.5, + ), + ), + const SizedBox(height: 8), + Wrap( + alignment: WrapAlignment.center, + spacing: 6, + runSpacing: 6, + children: [ + for (final c in unlocked) + Chip( + label: Text(Cosmetics.label(c.slot, c.id)), + visualDensity: VisualDensity.compact, + backgroundColor: Colors.white.withValues(alpha: 0.06), + side: BorderSide.none, + labelStyle: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + ), + ), + ], + ), + ], + const SizedBox(height: 18), + Row( children: [ - for (final c in unlocked) - Chip( - label: Text(Cosmetics.label(c.slot, c.id)), - visualDensity: VisualDensity.compact, - backgroundColor: Colors.white.withValues(alpha: 0.06), - side: BorderSide.none, - labelStyle: const TextStyle( - color: AppColors.textPrimary, - fontSize: 12, + if (onOpen != null) + Expanded( + child: TextButton( + onPressed: () { + Navigator.of(context).pop(); + onOpen!(); + }, + child: Text('progress.open_locker'.tr()), ), ), - ], - ), - ], - const SizedBox(height: 18), - Row( - children: [ - if (onOpen != null) + if (onOpen != null) const SizedBox(width: 8), Expanded( - child: TextButton( - onPressed: () { - Navigator.of(context).pop(); - onOpen!(); - }, - child: Text('progress.open_locker'.tr()), + child: FilledButton( + autofocus: isTvPlatform, + onPressed: () => Navigator.of(context).pop(), + child: Text('achievements.great'.tr()), ), ), - Expanded( - child: FilledButton( - onPressed: () => Navigator.of(context).pop(), - child: Text('achievements.great'.tr()), - ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ), diff --git a/lib/features/progress/presentation/pages/progress_page.dart b/lib/features/progress/presentation/pages/progress_page.dart index d2a57b18..996eff86 100644 --- a/lib/features/progress/presentation/pages/progress_page.dart +++ b/lib/features/progress/presentation/pages/progress_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/storage/hive_service.dart'; +import 'package:soplay/core/system/responsive.dart'; import 'package:soplay/core/theme/app_colors.dart'; import 'package:soplay/features/achievements/domain/achievements.dart'; import 'package:soplay/features/progress/data/progress_service.dart'; @@ -33,6 +34,12 @@ class _ProgressPageState extends State { }); } + Future _retry() async { + setState(() => _loading = true); + await _service.refresh(); + if (mounted) setState(() => _loading = false); + } + void _onChange() { if (mounted) setState(() {}); } @@ -78,10 +85,31 @@ class _ProgressPageState extends State { ? const CircularProgressIndicator() : Padding( padding: const EdgeInsets.all(32), - child: Text( - 'progress.unavailable'.tr(), - textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.textSecondary), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.cloud_off_rounded, + size: 40, + color: AppColors.textHint, + ), + const SizedBox(height: 12), + Text( + 'progress.unavailable'.tr(), + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 14, + height: 1.4, + ), + ), + const SizedBox(height: 16), + TextButton( + autofocus: true, + onPressed: _retry, + child: Text('general.retry'.tr()), + ), + ], ), ), ) @@ -336,7 +364,7 @@ class _Recent extends StatelessWidget { ), if (g.at != null) Padding( - padding: const EdgeInsets.only(right: 10), + padding: const EdgeInsetsDirectional.only(end: 10), child: Text( DateFormat.MMMd( context.locale.toString(), @@ -465,10 +493,14 @@ class _LockerItem extends StatelessWidget { return FramedAvatar( size: 34, frame: item.id, - child: const CircleAvatar( + child: CircleAvatar( radius: 17, - backgroundColor: Color(0xFF2A2A2A), - child: Icon(Icons.person_rounded, size: 20, color: Colors.white54), + backgroundColor: AppColors.surfaceVariant, + child: const Icon( + Icons.person_rounded, + size: 20, + color: AppColors.textHint, + ), ), ); case CosmeticSlot.nameColor: @@ -495,85 +527,89 @@ class _LockerItem extends StatelessWidget { final label = Cosmetics.label(item.slot, item.id); return Tooltip( message: locked ? unlockHint(item) : label, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Container( - width: 100, - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), - decoration: BoxDecoration( - color: equipped - ? AppColors.primary.withValues(alpha: 0.14) - : Colors.white.withValues(alpha: 0.04), - borderRadius: BorderRadius.circular(12), - border: Border.all( + child: Semantics( + selected: equipped, + child: HoverTap( + onTap: onTap, + borderRadius: 12, + scale: 1.03, + child: Container( + width: 100, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), + decoration: BoxDecoration( color: equipped - ? AppColors.primary.withValues(alpha: 0.6) - : Colors.white.withValues(alpha: 0.06), + ? AppColors.primary.withValues(alpha: 0.14) + : Colors.white.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: equipped + ? AppColors.primary.withValues(alpha: 0.6) + : Colors.white.withValues(alpha: 0.06), + ), ), - ), - child: Opacity( - opacity: locked ? 0.5 : 1, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 44, - child: Center( - child: busy - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : _preview(), + child: Opacity( + opacity: locked ? 0.5 : 1, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 44, + child: Center( + child: busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : _preview(), + ), ), - ), - const SizedBox(height: 6), - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: TextStyle( - color: item.slot == CosmeticSlot.nameColor - ? Cosmetics.nameColor(item.id) - : AppColors.textPrimary, - fontSize: 12, - fontWeight: FontWeight.w700, + const SizedBox(height: 6), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: item.slot == CosmeticSlot.nameColor + ? Cosmetics.nameColor(item.id) + : AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), ), - ), - const SizedBox(height: 2), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (locked) ...[ - const Icon( - Icons.lock_outline_rounded, - size: 11, - color: AppColors.textHint, - ), - const SizedBox(width: 3), - ], - Flexible( - child: Text( - locked - ? unlockHint(item) - : equipped - ? 'progress.equipped'.tr() - : 'progress.equip'.tr(), - maxLines: 2, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: const TextStyle( + const SizedBox(height: 2), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (locked) ...[ + const Icon( + Icons.lock_outline_rounded, + size: 11, color: AppColors.textHint, - fontSize: 10.5, + ), + const SizedBox(width: 3), + ], + Flexible( + child: Text( + locked + ? unlockHint(item) + : equipped + ? 'progress.equipped'.tr() + : 'progress.equip'.tr(), + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textHint, + fontSize: 10.5, + ), ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), ), ), diff --git a/lib/features/progress/presentation/widgets/level_ring.dart b/lib/features/progress/presentation/widgets/level_ring.dart index 98509181..8f01f191 100644 --- a/lib/features/progress/presentation/widgets/level_ring.dart +++ b/lib/features/progress/presentation/widgets/level_ring.dart @@ -20,45 +20,56 @@ class LevelRing extends StatelessWidget { @override Widget build(BuildContext context) { - return SizedBox( - width: size, - height: size, - child: TweenAnimationBuilder( - tween: Tween(begin: 0, end: fraction), - duration: const Duration(milliseconds: 700), - curve: Curves.easeOutCubic, - builder: (_, t, child) => CustomPaint( - painter: _RingPainter( - fraction: t, - color: AppColors.primary, - track: Colors.white.withValues(alpha: 0.08), - stroke: size * 0.075, + final still = MediaQuery.disableAnimationsOf(context); + return Semantics( + label: 'progress.level_short'.tr(args: ['$level']), + excludeSemantics: true, + child: SizedBox( + width: size, + height: size, + child: TweenAnimationBuilder( + tween: Tween(begin: still ? fraction : 0, end: fraction), + duration: still ? Duration.zero : const Duration(milliseconds: 700), + curve: Curves.easeOutCubic, + builder: (_, t, child) => CustomPaint( + painter: _RingPainter( + fraction: t, + color: AppColors.primary, + track: Colors.white.withValues(alpha: 0.08), + stroke: size * 0.075, + ), + child: child, ), - child: child, - ), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'progress.level'.tr().toUpperCase(), - style: TextStyle( - color: AppColors.textSecondary, - fontSize: size * 0.09, - fontWeight: FontWeight.w700, - letterSpacing: 1.2, - ), - ), - Text( - '$level', - style: TextStyle( - color: AppColors.textPrimary, - fontSize: size * 0.32, - fontWeight: FontWeight.w900, - height: 1.05, - ), + // Scales down rather than spilling out of the ring at large text + // sizes. + child: Padding( + padding: EdgeInsets.all(size * 0.14), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'progress.level'.tr().toUpperCase(), + style: TextStyle( + color: AppColors.textSecondary, + fontSize: size * 0.09, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + Text( + '$level', + style: TextStyle( + color: AppColors.textPrimary, + fontSize: size * 0.32, + fontWeight: FontWeight.w900, + height: 1.05, + ), + ), + ], ), - ], + ), ), ), ), diff --git a/lib/features/progress/presentation/widgets/user_badge_line.dart b/lib/features/progress/presentation/widgets/user_badge_line.dart index 59399cc2..511eaa0b 100644 --- a/lib/features/progress/presentation/widgets/user_badge_line.dart +++ b/lib/features/progress/presentation/widgets/user_badge_line.dart @@ -68,6 +68,10 @@ class Cosmetics { /// An avatar inside the frame its owner equipped; the avatar alone without /// one. +/// +/// The frame is painted outside the avatar's box rather than added to it, so a +/// framed avatar takes the same space as a plain one and a list of names stays +/// aligned whoever has a frame. class FramedAvatar extends StatelessWidget { const FramedAvatar({ super.key, @@ -96,20 +100,32 @@ class FramedAvatar extends StatelessWidget { final shape = radius == null ? const BoxDecoration(shape: BoxShape.circle) : BoxDecoration(borderRadius: BorderRadius.circular(radius! + ring)); - return Container( - width: size + ring * 2, - height: size + ring * 2, - padding: EdgeInsets.all(ring), - decoration: shape.copyWith( - gradient: SweepGradient(colors: [...colors, colors.first]), - boxShadow: [ - BoxShadow( - color: colors.last.withValues(alpha: 0.35), - blurRadius: ring * 2.5, + return SizedBox( + width: size, + height: size, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: -ring, + top: -ring, + right: -ring, + bottom: -ring, + child: DecoratedBox( + decoration: shape.copyWith( + gradient: SweepGradient(colors: [...colors, colors.first]), + boxShadow: [ + BoxShadow( + color: colors.last.withValues(alpha: 0.35), + blurRadius: ring * 2.5, + ), + ], + ), + ), ), + Positioned.fill(child: child), ], ), - child: child, ); } } diff --git a/lib/features/search/presentation/widgets/voice_search_dialog.dart b/lib/features/search/presentation/widgets/voice_search_dialog.dart index 330494c1..35ee0d43 100644 --- a/lib/features/search/presentation/widgets/voice_search_dialog.dart +++ b/lib/features/search/presentation/widgets/voice_search_dialog.dart @@ -320,7 +320,7 @@ class _VoiceSearchDialogState extends State return Dialog( backgroundColor: AppColors.surface, insetPadding: const EdgeInsets.symmetric(horizontal: 32), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Padding( @@ -389,6 +389,9 @@ class _VoiceSearchDialogState extends State spacing: 4, children: [ TextButton( + // While listening there is nothing else to focus: Done + // stays disabled until a word is heard. + autofocus: isTvPlatform && active, onPressed: _cancel, child: Text('general.cancel'.tr()), ), diff --git a/lib/features/watch_party/presentation/pages/watch_party_page.dart b/lib/features/watch_party/presentation/pages/watch_party_page.dart index afc21c63..bcc0ac5c 100644 --- a/lib/features/watch_party/presentation/pages/watch_party_page.dart +++ b/lib/features/watch_party/presentation/pages/watch_party_page.dart @@ -9,6 +9,7 @@ import 'package:share_plus/share_plus.dart'; import 'package:soplay/core/di/injection.dart'; import 'package:soplay/core/error/result.dart'; import 'package:soplay/core/theme/app_colors.dart'; +import 'package:soplay/core/widgets/app_buttons.dart'; import 'package:soplay/features/detail/domain/entities/player_args.dart'; import 'package:soplay/features/detail/domain/usecases/resolve_media_usecase.dart'; import 'package:soplay/features/watch_party/data/watch_party_service.dart'; @@ -420,13 +421,10 @@ class _WatchPartyPageState extends State { if (s.isHost) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), - child: SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => showPartyInviteSheet(context), - icon: const Icon(Icons.person_add_alt_1_rounded), - label: Text('watch_party.invite_friends'.tr()), - ), + child: AppSecondaryButton( + onPressed: () => showPartyInviteSheet(context), + icon: Icons.person_add_alt_1_rounded, + label: 'watch_party.invite_friends'.tr(), ), ), PartyHostSettings(room: room, service: _service), diff --git a/lib/features/watch_party/presentation/widgets/party_chat_panel.dart b/lib/features/watch_party/presentation/widgets/party_chat_panel.dart index fc1b9aff..364bcffc 100644 --- a/lib/features/watch_party/presentation/widgets/party_chat_panel.dart +++ b/lib/features/watch_party/presentation/widgets/party_chat_panel.dart @@ -285,19 +285,22 @@ class _Composer extends StatelessWidget { ), ), const SizedBox(width: 4), - Material( - color: AppColors.primary, - shape: const CircleBorder(), - child: InkWell( - onTap: onSend, - customBorder: const CircleBorder(), - child: const SizedBox( - width: 36, - height: 36, - child: Icon( - Icons.send_rounded, - color: Colors.white, - size: 16, + Tooltip( + message: 'support.send'.tr(), + child: Material( + color: AppColors.primary, + shape: const CircleBorder(), + child: InkWell( + onTap: onSend, + customBorder: const CircleBorder(), + child: const SizedBox( + width: 36, + height: 36, + child: Icon( + Icons.send_rounded, + color: Colors.white, + size: 16, + ), ), ), ), diff --git a/lib/features/watch_party/presentation/widgets/party_host_settings.dart b/lib/features/watch_party/presentation/widgets/party_host_settings.dart index 83b00e31..89a619a8 100644 --- a/lib/features/watch_party/presentation/widgets/party_host_settings.dart +++ b/lib/features/watch_party/presentation/widgets/party_host_settings.dart @@ -27,7 +27,7 @@ class PartyHostSettings extends StatelessWidget { final size = room.maxMembers.clamp(minMembers, limit); return Container( margin: const EdgeInsets.fromLTRB(16, 4, 16, 4), - padding: const EdgeInsets.fromLTRB(14, 10, 6, 6), + padding: const EdgeInsetsDirectional.fromSTEB(14, 10, 6, 6), decoration: BoxDecoration( color: AppColors.card, borderRadius: BorderRadius.circular(14), @@ -58,6 +58,7 @@ class PartyHostSettings extends StatelessWidget { ), ), IconButton( + tooltip: 'watch_party.room_size_less'.tr(), icon: const Icon(Icons.remove_rounded), onPressed: size > minMembers ? () => service.updateSettings(maxMembers: size - 1) @@ -76,6 +77,7 @@ class PartyHostSettings extends StatelessWidget { ), ), IconButton( + tooltip: 'watch_party.room_size_more'.tr(), icon: const Icon(Icons.add_rounded), onPressed: size < limit ? () => service.updateSettings(maxMembers: size + 1) @@ -123,10 +125,12 @@ class _Toggle extends StatelessWidget { Widget build(BuildContext context) { return SwitchListTile( dense: true, - contentPadding: const EdgeInsets.only(right: 4), + contentPadding: const EdgeInsetsDirectional.only(end: 4), value: value, onChanged: onChanged, - activeThumbColor: AppColors.primary, + // Same as the settings switches: a white thumb on an accent track. + activeThumbColor: Colors.white, + activeTrackColor: AppColors.primary, title: Text( title, style: const TextStyle(color: AppColors.textPrimary, fontSize: 14), diff --git a/lib/features/watch_party/presentation/widgets/party_member_bar.dart b/lib/features/watch_party/presentation/widgets/party_member_bar.dart index 3401d331..bc2153ff 100644 --- a/lib/features/watch_party/presentation/widgets/party_member_bar.dart +++ b/lib/features/watch_party/presentation/widgets/party_member_bar.dart @@ -139,8 +139,8 @@ class PartyMemberBar extends StatelessWidget { isHost && onAction != null && !isMe && !m.isHost; final tile = _MemberTile(member: m, isMe: isMe); if (!actionable) return tile; - return InkWell( - borderRadius: BorderRadius.circular(12), + return HoverTap( + borderRadius: 12, onTap: () => _openMenu(context, m), onLongPress: () => _openMenu(context, m), child: tile,