diff --git a/platforms/android/README.md b/platforms/android/README.md index f0213c376c..7952520e9f 100644 --- a/platforms/android/README.md +++ b/platforms/android/README.md @@ -1,6 +1,20 @@ # ARMSX2 PlayStation 2 Emulator for Android based on the work of [PCSX2](https://github.com/PCSX2/pcsx2) +## App version + +The Android app version is configured in `app/build.gradle.kts` under +`android.defaultConfig`: + +```kotlin +versionCode = providers.gradleProperty("armsx2.versionCode").orNull?.toInt() ?: 1088 +versionName = providers.gradleProperty("armsx2.versionName").orNull ?: "2.5.8" +``` + +Change `versionName` to the user-facing release version and increment the integer +`versionCode` for every published APK or AAB. Release scripts can still override +these defaults with `-Parmsx2.versionName=...` and `-Parmsx2.versionCode=...`. + ## Android APK builds Release/test APKs should be built with the universal page-size builder: diff --git a/platforms/android/app/build.gradle.kts b/platforms/android/app/build.gradle.kts index 1387ed050d..9e79af7ccb 100644 --- a/platforms/android/app/build.gradle.kts +++ b/platforms/android/app/build.gradle.kts @@ -12,8 +12,6 @@ val armsx2Pgo = providers.gradleProperty("armsx2.pgo").orElse("none") // none | val armsx2PgoProfile = providers.gradleProperty("armsx2.pgoProfile").orElse("") // abs path to merged .profdata (optimize) val armsx2HostPageSize = providers.gradleProperty("armsx2.hostPageSize").orElse("0x1000") val armsx2ApplicationId = providers.gradleProperty("armsx2.applicationId").orElse("com.armsx2") -val armsx2VersionCode = providers.gradleProperty("armsx2.versionCode").map { it.toInt() }.orElse(1088) -val armsx2VersionName = providers.gradleProperty("armsx2.versionName").orElse("2.5.8") val armsx2SigningPropertiesFile = rootProject.file("armsx2_keystore.properties") val armsx2SigningProperties = Properties().apply { if (armsx2SigningPropertiesFile.isFile) { @@ -36,8 +34,8 @@ android { applicationId = armsx2ApplicationId.get() minSdk = 26 targetSdk = 37 - versionCode = armsx2VersionCode.get() - versionName = armsx2VersionName.get() + versionCode = providers.gradleProperty("armsx2.versionCode").orNull?.toInt() ?: 1088 + versionName = providers.gradleProperty("armsx2.versionName").orNull ?: "2.5.9" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" ndk { @@ -151,7 +149,7 @@ android { externalNativeBuild { cmake { path = file("src/main/cpp/CMakeLists.txt") - version = "3.30.5" + version = "3.22.1" } } buildFeatures { diff --git a/platforms/android/app/src/main/assets/i18n/ar.json b/platforms/android/app/src/main/assets/i18n/ar.json index 08af7c1c9d..48ffc3e2be 100644 --- a/platforms/android/app/src/main/assets/i18n/ar.json +++ b/platforms/android/app/src/main/assets/i18n/ar.json @@ -14,6 +14,11 @@ "tab.skins": "الأشكال", "app.language": "اللغة", "app.language.desc": "اختر لغة التطبيق. تُطبَّق فوراً.", + "app.language.system": "لغة النظام", + "app.library.search": "البحث في المكتبة", + "app.library.search.desc": "إظهار حقل البحث عن الألعاب على الشاشة الرئيسية للمكتبة.", + "app.library.recents": "الألعاب التي تم لعبها مؤخراً", + "app.library.recents.desc": "إظهار قسم الألعاب التي تم لعبها مؤخراً على الشاشة الرئيسية للمكتبة.", "app.language.machineNote": "الترجمات ما عدا الإنجليزية مترجمة آلياً. لاحظت ترجمة سيئة؟ أخبرنا وسنصلحها.", "app.theme": "المظهر", "app.theme.system": "النظام", @@ -233,7 +238,12 @@ "games.info.perGameSettings.title": "إعدادات لكل لعبة", "games.info.tapToClose": "انقر في أي مكان أو اضغط B للإغلاق", "games.info.title": "مساعدة المكتبة", + "games.library.totalGames": "إجمالي الألعاب", "games.nav.library": "المكتبة", + "games.overflow.coverStyle": "نمط الغلاف", + "games.overflow.openNavigation": "فتح التنقل", + "games.overflow.sortRecent": "الترتيب حسب آخر تشغيل", + "games.overflow.sortTitle": "الترتيب حسب العنوان", "games.noCover": "بلا غلاف", "games.noSerial": "بلا رقم تسلسلي", "games.scanningRoms": "جارٍ فحص ROMs…", @@ -671,6 +681,8 @@ "savestate.autosave.screenshotDesc": "لقطة الحفظ التلقائي", "savestate.autosave.title": "حفظ تلقائي", "savestate.backup": "نسخة احتياطية", + "savestate.empty.description": "أنشئ حالة حفظ أثناء تشغيل لعبة، ثم يمكنك إدارتها أو نسخها احتياطياً هنا.", + "savestate.empty.title": "لا توجد حالات حفظ بعد", "savestate.noBackupFound": "لم يُعثر على نسخة احتياطية", "savestate.noSavesToBackUp": "لا حفظات للنسخ الاحتياطي", "savestate.restore": "استعادة", @@ -817,5 +829,16 @@ "about.device": "الجهاز", "about.androidVersion": "إصدار Android", "about.repository.title": "مستودع GitHub", + "about.build.title": "معلومات الإصدار", + "about.hardware.title": "معلومات الجهاز", + "about.soc": "مجموعة الشرائح", + "about.gpu": "وحدة معالجة الرسومات", + "about.cpuCores": "أنوية المعالج", + "about.memory": "الذاكرة", + "about.display": "الشاشة", + "about.architecture": "البنية", + "about.pageSize": "صفحة الذاكرة", + "about.pcsx2.title": "مشروع PCSX2", + "about.pcsx2.description": "يعتمد ARMSX2 على محاكي PCSX2 مفتوح المصدر.", "about.repository.description": "افتح الشفرة المصدرية والإصدارات ومتتبع المشكلات." } diff --git a/platforms/android/app/src/main/assets/i18n/de.json b/platforms/android/app/src/main/assets/i18n/de.json index a455fef093..ed7cad3a3a 100644 --- a/platforms/android/app/src/main/assets/i18n/de.json +++ b/platforms/android/app/src/main/assets/i18n/de.json @@ -14,6 +14,11 @@ "tab.skins": "Skins", "app.language": "Sprache", "app.language.desc": "App-Sprache wählen. Wird sofort angewendet.", + "app.language.system": "Systemsprache", + "app.library.search": "Bibliothekssuche", + "app.library.search.desc": "Zeigt das Suchfeld für Spiele auf der Startseite der Bibliothek an.", + "app.library.recents": "Zuletzt gespielte Spiele", + "app.library.recents.desc": "Zeigt den Bereich „Zuletzt gespielt“ auf der Startseite der Bibliothek an.", "app.language.machineNote": "Übersetzungen außer Englisch sind maschinell erstellt. Fehler entdeckt? Sag uns Bescheid und wir korrigieren es.", "app.theme": "Design", "app.theme.system": "System", @@ -233,7 +238,12 @@ "games.info.perGameSettings.title": "Spielspezifische Einstellungen", "games.info.tapToClose": "Beliebige Stelle tippen oder B drücken zum Schließen", "games.info.title": "Bibliothek-Hilfe", + "games.library.totalGames": "Spiele insgesamt", "games.nav.library": "BIBLIOTHEK", + "games.overflow.coverStyle": "Cover-Stil", + "games.overflow.openNavigation": "Navigation öffnen", + "games.overflow.sortRecent": "Nach zuletzt gespielt sortieren", + "games.overflow.sortTitle": "Nach Titel sortieren", "games.noCover": "Kein Cover", "games.noSerial": "Keine Seriennummer", "games.scanningRoms": "Scanne ROMs…", @@ -671,6 +681,8 @@ "savestate.autosave.screenshotDesc": "Autosave-Screenshot", "savestate.autosave.title": "Autosave", "savestate.backup": "Backup", + "savestate.empty.description": "Erstelle während eines laufenden Spiels einen Spielstand. Anschließend kannst du ihn hier verwalten oder sichern.", + "savestate.empty.title": "Noch keine Spielstände", "savestate.noBackupFound": "Kein Backup gefunden", "savestate.noSavesToBackUp": "Keine Speicherungen zum Sichern", "savestate.restore": "Wiederherstellen", @@ -817,5 +829,16 @@ "about.device": "Gerät", "about.androidVersion": "Android-Version", "about.repository.title": "GitHub-Repository", + "about.build.title": "Build-Informationen", + "about.hardware.title": "Geräteinformationen", + "about.soc": "Chipsatz", + "about.gpu": "GPU", + "about.cpuCores": "CPU-Kerne", + "about.memory": "Arbeitsspeicher", + "about.display": "Display", + "about.architecture": "Architektur", + "about.pageSize": "Speicherseite", + "about.pcsx2.title": "PCSX2-Projekt", + "about.pcsx2.description": "ARMSX2 basiert auf dem quelloffenen PCSX2-Emulator.", "about.repository.description": "Quellcode, Releases und Fehlerverwaltung öffnen." } diff --git a/platforms/android/app/src/main/assets/i18n/es.json b/platforms/android/app/src/main/assets/i18n/es.json index b192b30f73..2c4e127420 100644 --- a/platforms/android/app/src/main/assets/i18n/es.json +++ b/platforms/android/app/src/main/assets/i18n/es.json @@ -14,6 +14,11 @@ "tab.skins": "Skins", "app.language": "Idioma", "app.language.desc": "Elige el idioma de la app. Se aplica al instante.", + "app.language.system": "Idioma del sistema", + "app.library.search": "Búsqueda en la biblioteca", + "app.library.search.desc": "Muestra el campo de búsqueda de juegos en la pantalla principal de la biblioteca.", + "app.library.recents": "Juegos recientes", + "app.library.recents.desc": "Muestra la sección de juegos recientes en la pantalla principal de la biblioteca.", "app.language.machineNote": "Las traducciones, salvo el inglés, son automáticas. ¿Ves alguna mal? Avísanos y la corregimos.", "app.theme": "Tema", "app.theme.system": "Sistema", @@ -233,7 +238,12 @@ "games.info.perGameSettings.title": "Ajustes por juego", "games.info.tapToClose": "Toca en cualquier sitio o pulsa B para cerrar", "games.info.title": "Ayuda de la biblioteca", + "games.library.totalGames": "Total de juegos", "games.nav.library": "BIBLIOTECA", + "games.overflow.coverStyle": "Estilo de carátula", + "games.overflow.openNavigation": "Abrir navegación", + "games.overflow.sortRecent": "Ordenar por jugados recientemente", + "games.overflow.sortTitle": "Ordenar por título", "games.noCover": "Sin carátula", "games.noSerial": "Sin serial", "games.scanningRoms": "Escaneando ROMs…", @@ -671,6 +681,8 @@ "savestate.autosave.screenshotDesc": "Captura de autoguardado", "savestate.autosave.title": "Autoguardado", "savestate.backup": "Copia de seguridad", + "savestate.empty.description": "Crea un estado guardado mientras juegas y después podrás administrarlo o respaldarlo aquí.", + "savestate.empty.title": "Aún no hay estados guardados", "savestate.noBackupFound": "No se encontró copia de seguridad", "savestate.noSavesToBackUp": "No hay guardados que respaldar", "savestate.restore": "Restaurar", @@ -810,12 +822,23 @@ "touch.settingsButton.description": "Abrir ajustes en el juego", "touch.stateAction.load": "CARGAR", "touch.stateAction.save": "GUARDAR", - "about.title": "Acerca de", + "about.title": "Acerca de la aplicación", "about.tagline": "Emulación de PlayStation 2 rápida y moderna para Android.", "about.appVersion": "Versión de la aplicación", "about.coreVersion": "Versión del emulador", "about.device": "Dispositivo", "about.androidVersion": "Versión de Android", "about.repository.title": "Repositorio de GitHub", + "about.build.title": "Información de compilación", + "about.hardware.title": "Información del dispositivo", + "about.soc": "Chipset", + "about.gpu": "GPU", + "about.cpuCores": "Núcleos de CPU", + "about.memory": "Memoria", + "about.display": "Pantalla", + "about.architecture": "Arquitectura", + "about.pageSize": "Página de memoria", + "about.pcsx2.title": "Proyecto PCSX2", + "about.pcsx2.description": "ARMSX2 está basado en el emulador PCSX2 de código abierto.", "about.repository.description": "Abre el código fuente, las versiones y el seguimiento de errores." } diff --git a/platforms/android/app/src/main/assets/i18n/fa.json b/platforms/android/app/src/main/assets/i18n/fa.json index 0174f6b2fe..bc46c6180c 100644 --- a/platforms/android/app/src/main/assets/i18n/fa.json +++ b/platforms/android/app/src/main/assets/i18n/fa.json @@ -1,12 +1,23 @@ { "_note": "Machine-translated (fa); report bad strings to fix.", - "about.title": "درباره", + "about.title": "درباره برنامه", "about.tagline": "شبیه سازی سریع و مدرن پلی استیشن 2 برای اندروید.", "about.appVersion": "نسخه برنامه", "about.coreVersion": "نسخه شبیه ساز", "about.device": "دستگاه", "about.androidVersion": "نسخه اندروید", "about.repository.title": "مخزن GitHub", + "about.build.title": "اطلاعات ساخت", + "about.hardware.title": "اطلاعات دستگاه", + "about.soc": "چیپست", + "about.gpu": "پردازنده گرافیکی", + "about.cpuCores": "هسته‌های پردازنده", + "about.memory": "حافظه", + "about.display": "نمایشگر", + "about.architecture": "معماری", + "about.pageSize": "صفحه حافظه", + "about.pcsx2.title": "پروژه PCSX2", + "about.pcsx2.description": "ARMSX2 بر پایه شبیه‌ساز متن‌باز PCSX2 ساخته شده است.", "about.repository.description": "کد منبع، نسخه‌ها و ردیاب مشکل را باز کنید.", "tab.app": "برنامه", "tab.info": "اطلاعات", @@ -39,6 +50,11 @@ "tab.skins": "پوسته ها", "app.language": "زبان", "app.language.desc": "زبان برنامه را انتخاب کنید. فورا اعمال می شود.", + "app.language.system": "زبان سیستم", + "app.library.search": "جستجوی کتابخانه", + "app.library.search.desc": "نمایش کادر جستجوی بازی در صفحه اصلی کتابخانه.", + "app.library.recents": "بازی‌های اخیراً اجراشده", + "app.library.recents.desc": "نمایش بخش بازی‌های اخیراً اجراشده در صفحه اصلی کتابخانه.", "app.language.machineNote": "ترجمه ها به جز انگلیسی به صورت ماشینی ترجمه می شوند. یک بد را می بینید؟ به ما اطلاع دهید و ما آن را درست می کنیم.", "app.theme": "تم", "app.theme.system": "سیستم", @@ -274,7 +290,12 @@ "games.info.perGameSettings.title": "تنظیمات هر بازی", "games.info.tapToClose": "روی هر جایی ضربه بزنید یا B را فشار دهید تا بسته شود", "games.info.title": "راهنمای کتابخانه", + "games.library.totalGames": "مجموع بازی‌ها", "games.nav.library": "کتابخانه", + "games.overflow.coverStyle": "سبک جلد", + "games.overflow.openNavigation": "باز کردن پیمایش", + "games.overflow.sortRecent": "مرتب‌سازی بر اساس آخرین اجرا", + "games.overflow.sortTitle": "مرتب‌سازی بر اساس عنوان", "games.noCover": "بدون پوشش", "games.noSerial": "بدون سریال", "games.scanningRoms": "اسکن رام …", @@ -743,6 +764,8 @@ "savestate.autosave.screenshotDesc": "ذخیره خودکار اسکرین شات", "savestate.autosave.title": "ذخیره خودکار", "savestate.backup": "پشتیبان گیری", + "savestate.empty.description": "هنگام اجرای بازی یک حالت ذخیره بسازید، سپس آن را اینجا مدیریت یا پشتیبان‌گیری کنید.", + "savestate.empty.title": "هنوز حالت ذخیره‌ای وجود ندارد", "savestate.noBackupFound": "پشتیبان پیدا نشد", "savestate.noSavesToBackUp": "هیچ ذخیره ای برای پشتیبان گیری وجود ندارد", "savestate.restore": "بازیابی کنید", diff --git a/platforms/android/app/src/main/assets/i18n/fr.json b/platforms/android/app/src/main/assets/i18n/fr.json index 75999c76ac..c10a828e63 100644 --- a/platforms/android/app/src/main/assets/i18n/fr.json +++ b/platforms/android/app/src/main/assets/i18n/fr.json @@ -14,6 +14,11 @@ "tab.skins": "Skins", "app.language": "Langue", "app.language.desc": "Choisissez la langue de l'appli. Appliquée instantanément.", + "app.language.system": "Langue du système", + "app.library.search": "Recherche dans la bibliothèque", + "app.library.search.desc": "Affiche le champ de recherche de jeux sur l’écran d’accueil de la bibliothèque.", + "app.library.recents": "Jeux récents", + "app.library.recents.desc": "Affiche la section des jeux récents sur l’écran d’accueil de la bibliothèque.", "app.language.machineNote": "Les traductions autres que l'anglais sont automatiques. Vous en repérez une mauvaise ? Signalez-la et nous la corrigerons.", "app.theme": "Thème", "app.theme.system": "Système", @@ -233,7 +238,12 @@ "games.info.perGameSettings.title": "Paramètres par jeu", "games.info.tapToClose": "Touchez n'importe où ou appuyez sur B pour fermer", "games.info.title": "Aide de la bibliothèque", + "games.library.totalGames": "Nombre total de jeux", "games.nav.library": "BIBLIOTHÈQUE", + "games.overflow.coverStyle": "Style des jaquettes", + "games.overflow.openNavigation": "Ouvrir la navigation", + "games.overflow.sortRecent": "Trier par dernière utilisation", + "games.overflow.sortTitle": "Trier par titre", "games.noCover": "Pas de jaquette", "games.noSerial": "Pas de série", "games.scanningRoms": "Analyse des ROMs…", @@ -671,6 +681,8 @@ "savestate.autosave.screenshotDesc": "Capture de la sauvegarde auto", "savestate.autosave.title": "Sauvegarde auto", "savestate.backup": "Sauvegarde", + "savestate.empty.description": "Créez un état de sauvegarde pendant une partie, puis gérez-le ou sauvegardez-le ici.", + "savestate.empty.title": "Aucun état de sauvegarde", "savestate.noBackupFound": "Aucune sauvegarde trouvée", "savestate.noSavesToBackUp": "Aucune sauvegarde à sauvegarder", "savestate.restore": "Restaurer", @@ -810,12 +822,23 @@ "touch.settingsButton.description": "Ouvrir les paramètres en jeu", "touch.stateAction.load": "CHARGER", "touch.stateAction.save": "SAUVEGARDER", - "about.title": "À propos", + "about.title": "À propos de l’application", "about.tagline": "Émulation PlayStation 2 rapide et moderne pour Android.", "about.appVersion": "Version de l’application", "about.coreVersion": "Version de l’émulateur", "about.device": "Appareil", "about.androidVersion": "Version d’Android", - "about.repository.title": "Dépôt GitHub", + "about.repository.title": "Dépôt GitHub", + "about.build.title": "Informations de compilation", + "about.hardware.title": "Informations sur l’appareil", + "about.soc": "Puce", + "about.gpu": "GPU", + "about.cpuCores": "Cœurs du processeur", + "about.memory": "Mémoire", + "about.display": "Écran", + "about.architecture": "Architecture", + "about.pageSize": "Page mémoire", + "about.pcsx2.title": "Projet PCSX2", + "about.pcsx2.description": "ARMSX2 repose sur l’émulateur PCSX2 open source.", "about.repository.description": "Ouvrir le code source, les versions et le suivi des problèmes." } diff --git a/platforms/android/app/src/main/assets/i18n/id.json b/platforms/android/app/src/main/assets/i18n/id.json index c700a0ca01..db0987c44d 100644 --- a/platforms/android/app/src/main/assets/i18n/id.json +++ b/platforms/android/app/src/main/assets/i18n/id.json @@ -14,6 +14,11 @@ "tab.skins": "Skin", "app.language": "Bahasa", "app.language.desc": "Pilih bahasa aplikasi. Berlaku seketika.", + "app.language.system": "Bahasa sistem", + "app.library.search": "Pencarian pustaka", + "app.library.search.desc": "Tampilkan kolom pencarian game di layar utama pustaka.", + "app.library.recents": "Game yang baru dimainkan", + "app.library.recents.desc": "Tampilkan bagian game yang baru dimainkan di layar utama pustaka.", "app.language.machineNote": "Terjemahan selain bahasa Inggris diterjemahkan mesin. Menemukan yang salah? Beri tahu kami dan akan kami perbaiki.", "app.theme": "Tema", "app.theme.system": "Sistem", @@ -233,7 +238,12 @@ "games.info.perGameSettings.title": "Pengaturan per-game", "games.info.tapToClose": "Ketuk di mana saja atau tekan B untuk menutup", "games.info.title": "Bantuan Pustaka", + "games.library.totalGames": "Total game", "games.nav.library": "PUSTAKA", + "games.overflow.coverStyle": "Gaya sampul", + "games.overflow.openNavigation": "Buka navigasi", + "games.overflow.sortRecent": "Urutkan berdasarkan terakhir dimainkan", + "games.overflow.sortTitle": "Urutkan berdasarkan judul", "games.noCover": "Tanpa sampul", "games.noSerial": "Tanpa serial", "games.scanningRoms": "Memindai ROM…", @@ -671,6 +681,8 @@ "savestate.autosave.screenshotDesc": "Screenshot autosave", "savestate.autosave.title": "Autosave", "savestate.backup": "Cadangkan", + "savestate.empty.description": "Buat save state saat game berjalan, lalu kelola atau cadangkan di sini.", + "savestate.empty.title": "Belum ada save state", "savestate.noBackupFound": "Tidak ada cadangan ditemukan", "savestate.noSavesToBackUp": "Tidak ada save untuk dicadangkan", "savestate.restore": "Pulihkan", @@ -810,12 +822,23 @@ "touch.settingsButton.description": "Buka pengaturan dalam game", "touch.stateAction.load": "MUAT", "touch.stateAction.save": "SIMPAN", - "about.title": "Tentang", + "about.title": "Tentang aplikasi", "about.tagline": "Emulasi PlayStation 2 yang cepat dan modern untuk Android.", "about.appVersion": "Versi aplikasi", "about.coreVersion": "Versi emulator", "about.device": "Perangkat", "about.androidVersion": "Versi Android", - "about.repository.title": "Repositori GitHub", + "about.repository.title": "Repositori GitHub", + "about.build.title": "Informasi build", + "about.hardware.title": "Informasi perangkat", + "about.soc": "Chipset", + "about.gpu": "GPU", + "about.cpuCores": "Inti CPU", + "about.memory": "Memori", + "about.display": "Layar", + "about.architecture": "Arsitektur", + "about.pageSize": "Halaman memori", + "about.pcsx2.title": "Proyek PCSX2", + "about.pcsx2.description": "ARMSX2 dibangun di atas emulator PCSX2 sumber terbuka.", "about.repository.description": "Buka kode sumber, rilis, dan pelacak masalah." } diff --git a/platforms/android/app/src/main/assets/i18n/it.json b/platforms/android/app/src/main/assets/i18n/it.json index 28bb05b4e9..71501df1e7 100644 --- a/platforms/android/app/src/main/assets/i18n/it.json +++ b/platforms/android/app/src/main/assets/i18n/it.json @@ -14,6 +14,11 @@ "tab.skins": "Skin", "app.language": "Lingua", "app.language.desc": "Scegli la lingua dell'app. Si applica all'istante.", + "app.language.system": "Lingua di sistema", + "app.library.search": "Ricerca nella libreria", + "app.library.search.desc": "Mostra il campo di ricerca dei giochi nella schermata principale della libreria.", + "app.library.recents": "Giochi recenti", + "app.library.recents.desc": "Mostra la sezione dei giochi recenti nella schermata principale della libreria.", "app.language.machineNote": "Le traduzioni diverse dall'inglese sono automatiche. Ne noti una sbagliata? Faccelo sapere e la correggeremo.", "app.theme": "Tema", "app.theme.system": "Sistema", @@ -233,7 +238,12 @@ "games.info.perGameSettings.title": "Impostazioni per gioco", "games.info.tapToClose": "Tocca ovunque o premi B per chiudere", "games.info.title": "Aiuto libreria", + "games.library.totalGames": "Giochi totali", "games.nav.library": "LIBRERIA", + "games.overflow.coverStyle": "Stile copertina", + "games.overflow.openNavigation": "Apri navigazione", + "games.overflow.sortRecent": "Ordina per utilizzo recente", + "games.overflow.sortTitle": "Ordina per titolo", "games.noCover": "Nessuna copertina", "games.noSerial": "Nessun seriale", "games.scanningRoms": "Scansione ROM…", @@ -671,6 +681,8 @@ "savestate.autosave.screenshotDesc": "Screenshot salvataggio automatico", "savestate.autosave.title": "Salvataggio automatico", "savestate.backup": "Backup", + "savestate.empty.description": "Crea uno stato di salvataggio durante una partita, poi gestiscilo o eseguine il backup qui.", + "savestate.empty.title": "Nessuno stato di salvataggio", "savestate.noBackupFound": "Nessun backup trovato", "savestate.noSavesToBackUp": "Nessun salvataggio da fare backup", "savestate.restore": "Ripristina", @@ -810,12 +822,23 @@ "touch.settingsButton.description": "Apri impostazioni in gioco", "touch.stateAction.load": "CARICA", "touch.stateAction.save": "SALVA", - "about.title": "Informazioni", + "about.title": "Informazioni sull’app", "about.tagline": "Emulazione PlayStation 2 veloce e moderna per Android.", "about.appVersion": "Versione dell’app", "about.coreVersion": "Versione dell’emulatore", "about.device": "Dispositivo", "about.androidVersion": "Versione Android", "about.repository.title": "Repository GitHub", + "about.build.title": "Informazioni build", + "about.hardware.title": "Informazioni dispositivo", + "about.soc": "Chipset", + "about.gpu": "GPU", + "about.cpuCores": "Core CPU", + "about.memory": "Memoria", + "about.display": "Schermo", + "about.architecture": "Architettura", + "about.pageSize": "Pagina di memoria", + "about.pcsx2.title": "Progetto PCSX2", + "about.pcsx2.description": "ARMSX2 è basato sull’emulatore open source PCSX2.", "about.repository.description": "Apri il codice sorgente, le versioni e il registro dei problemi." } diff --git a/platforms/android/app/src/main/assets/i18n/ja.json b/platforms/android/app/src/main/assets/i18n/ja.json index dda8dc9fe9..aceeea8fd2 100644 --- a/platforms/android/app/src/main/assets/i18n/ja.json +++ b/platforms/android/app/src/main/assets/i18n/ja.json @@ -14,6 +14,11 @@ "tab.skins": "スキン", "app.language": "言語", "app.language.desc": "アプリの言語を選択します。即座に適用されます。", + "app.language.system": "システムの言語", + "app.library.search": "ライブラリ検索", + "app.library.search.desc": "ライブラリのホーム画面にゲーム検索欄を表示します。", + "app.library.recents": "最近プレイしたゲーム", + "app.library.recents.desc": "ライブラリのホーム画面に最近プレイしたゲームを表示します。", "app.language.machineNote": "英語以外の翻訳は機械翻訳です。おかしな箇所を見つけたらお知らせください。修正します。", "app.theme": "テーマ", "app.theme.system": "システム", @@ -233,7 +238,12 @@ "games.info.perGameSettings.title": "ゲームごとの設定", "games.info.tapToClose": "どこかをタップまたはBで閉じる", "games.info.title": "ライブラリのヘルプ", + "games.library.totalGames": "ゲーム総数", "games.nav.library": "ライブラリ", + "games.overflow.coverStyle": "カバースタイル", + "games.overflow.openNavigation": "ナビゲーションを開く", + "games.overflow.sortRecent": "最近プレイした順", + "games.overflow.sortTitle": "タイトル順", "games.noCover": "カバーなし", "games.noSerial": "シリアルなし", "games.scanningRoms": "ROMをスキャン中…", @@ -671,6 +681,8 @@ "savestate.autosave.screenshotDesc": "オートセーブのスクリーンショット", "savestate.autosave.title": "オートセーブ", "savestate.backup": "バックアップ", + "savestate.empty.description": "ゲームの実行中にセーブステートを作成すると、ここで管理やバックアップができます。", + "savestate.empty.title": "セーブステートはまだありません", "savestate.noBackupFound": "バックアップが見つかりません", "savestate.noSavesToBackUp": "バックアップするセーブがありません", "savestate.restore": "復元", @@ -816,6 +828,17 @@ "about.coreVersion": "エミュレーターのバージョン", "about.device": "デバイス", "about.androidVersion": "Androidバージョン", - "about.repository.title": "GitHubリポジトリ", + "about.repository.title": "GitHubリポジトリ", + "about.build.title": "ビルド情報", + "about.hardware.title": "デバイス情報", + "about.soc": "チップセット", + "about.gpu": "GPU", + "about.cpuCores": "CPUコア", + "about.memory": "メモリ", + "about.display": "ディスプレイ", + "about.architecture": "アーキテクチャ", + "about.pageSize": "メモリページ", + "about.pcsx2.title": "PCSX2プロジェクト", + "about.pcsx2.description": "ARMSX2はオープンソースのPCSX2エミュレーターを基にしています。", "about.repository.description": "ソースコード、リリース、問題追跡を開きます。" } diff --git a/platforms/android/app/src/main/assets/i18n/ko.json b/platforms/android/app/src/main/assets/i18n/ko.json index e50d2e2012..5e1658de0d 100644 --- a/platforms/android/app/src/main/assets/i18n/ko.json +++ b/platforms/android/app/src/main/assets/i18n/ko.json @@ -14,6 +14,11 @@ "tab.skins": "스킨", "app.language": "언어", "app.language.desc": "앱 언어를 선택합니다. 즉시 적용됩니다.", + "app.language.system": "시스템 언어", + "app.library.search": "라이브러리 검색", + "app.library.search.desc": "라이브러리 홈 화면에 게임 검색창을 표시합니다.", + "app.library.recents": "최근 플레이한 게임", + "app.library.recents.desc": "라이브러리 홈 화면에 최근 플레이한 게임 섹션을 표시합니다.", "app.language.machineNote": "영어를 제외한 번역은 기계 번역입니다. 잘못된 부분을 발견하셨나요? 알려주시면 수정하겠습니다.", "app.theme": "테마", "app.theme.system": "시스템", @@ -233,7 +238,12 @@ "games.info.perGameSettings.title": "게임별 설정", "games.info.tapToClose": "아무 곳이나 탭하거나 B를 눌러 닫기", "games.info.title": "라이브러리 도움말", + "games.library.totalGames": "전체 게임", "games.nav.library": "라이브러리", + "games.overflow.coverStyle": "커버 스타일", + "games.overflow.openNavigation": "탐색 메뉴 열기", + "games.overflow.sortRecent": "최근 플레이순 정렬", + "games.overflow.sortTitle": "제목순 정렬", "games.noCover": "커버 없음", "games.noSerial": "시리얼 없음", "games.scanningRoms": "ROM 스캔 중…", @@ -671,6 +681,8 @@ "savestate.autosave.screenshotDesc": "자동 저장 스크린샷", "savestate.autosave.title": "자동 저장", "savestate.backup": "백업", + "savestate.empty.description": "게임 실행 중 저장 상태를 만든 다음 여기에서 관리하거나 백업할 수 있습니다.", + "savestate.empty.title": "아직 저장 상태가 없습니다", "savestate.noBackupFound": "백업을 찾을 수 없습니다", "savestate.noSavesToBackUp": "백업할 저장이 없습니다", "savestate.restore": "복원", @@ -817,5 +829,16 @@ "about.device": "기기", "about.androidVersion": "Android 버전", "about.repository.title": "GitHub 저장소", + "about.build.title": "빌드 정보", + "about.hardware.title": "기기 정보", + "about.soc": "칩셋", + "about.gpu": "GPU", + "about.cpuCores": "CPU 코어", + "about.memory": "메모리", + "about.display": "디스플레이", + "about.architecture": "아키텍처", + "about.pageSize": "메모리 페이지", + "about.pcsx2.title": "PCSX2 프로젝트", + "about.pcsx2.description": "ARMSX2는 오픈 소스 PCSX2 에뮬레이터를 기반으로 합니다.", "about.repository.description": "소스 코드, 릴리스 및 이슈 추적기를 엽니다." } diff --git a/platforms/android/app/src/main/assets/i18n/ku.json b/platforms/android/app/src/main/assets/i18n/ku.json index f809787264..f459d12306 100644 --- a/platforms/android/app/src/main/assets/i18n/ku.json +++ b/platforms/android/app/src/main/assets/i18n/ku.json @@ -1,11 +1,22 @@ { - "about.title": "Ji dor", + "about.title": "Der barê sepanê de", "about.tagline": "Emûlasyona bilez, nûjen PlayStation 2 ji bo Android.", "about.appVersion": "Guhertoya app", "about.coreVersion": "Guhertoya Emulatorê", "about.device": "Sazî", "about.androidVersion": "Guhertoya Android", "about.repository.title": "depoya GitHub", + "about.build.title": "Agahiyên avakirinê", + "about.hardware.title": "Agahiyên cîhazê", + "about.soc": "Çîpset", + "about.gpu": "GPU", + "about.cpuCores": "Navokên CPU", + "about.memory": "Bîr", + "about.display": "Dîmender", + "about.architecture": "Mîmarî", + "about.pageSize": "Rûpela bîrê", + "about.pcsx2.title": "Projeya PCSX2", + "about.pcsx2.description": "ARMSX2 li ser emûlatora çavkaniya vekirî PCSX2 hatiye avakirin.", "about.repository.description": "Koda çavkaniyê, serbestberdan, û şopînerê pirsgirêkê vekin.", "tab.app": "App", "tab.info": "Info", @@ -38,6 +49,11 @@ "tab.skins": "Skins", "app.language": "Ziman", "app.language.desc": "Zimanê sepanê hilbijêrin. Yekser tê sepandin.", + "app.language.system": "Zimanê pergalê", + "app.library.search": "Lêgerîna pirtûkxaneyê", + "app.library.search.desc": "Qada lêgerîna lîstikan li ser rûpela sereke ya pirtûkxaneyê nîşan bide.", + "app.library.recents": "Lîstikên vê dawiyê hatine lîstin", + "app.library.recents.desc": "Beşa lîstikên vê dawiyê hatine lîstin li ser rûpela sereke ya pirtûkxaneyê nîşan bide.", "app.language.machineNote": "Werger ji bilî Îngilîzî bi makîneyê têne wergerandin. Yekî xirab dibînî? Ji me re agahdar bikin û em ê çareser bikin.", "app.theme": "Mijad", "app.theme.system": "Sîstem", @@ -261,7 +277,12 @@ "games.info.perGameSettings.title": "Mîhengên per-game", "games.info.tapToClose": "Li her deverê bikirtînin an jî B bikirtînin da ku bigire", "games.info.title": "Alîkariya Pirtûkxaneyê", + "games.library.totalGames": "Tevahiya lîstikan", "games.nav.library": "PIRTÛKXANE", + "games.overflow.coverStyle": "Şêwaza bergê", + "games.overflow.openNavigation": "Navîgasyonê veke", + "games.overflow.sortRecent": "Li gorî lîstina dawî rêz bike", + "games.overflow.sortTitle": "Li gorî sernavê rêz bike", "games.noCover": "No cover", "games.noSerial": "No serial", "games.scanningRoms": "Skankirina ROM-ê…", @@ -729,6 +750,8 @@ "savestate.autosave.screenshotDesc": "Wêneya wêneyê bixweber tomar bike", "savestate.autosave.title": "Xwe tomar bike", "savestate.backup": "Backup", + "savestate.empty.description": "Dema lîstik dimeşe rewşek tomar bike, paşê li vir birêve bibe an jî paşvedanê çêbike.", + "savestate.empty.title": "Hêj rewşa tomarkirinê tune ye", "savestate.noBackupFound": "Piştgir nehat dîtin", "savestate.noSavesToBackUp": "Ji bo paşvekişandinê xilas nabe", "savestate.restore": "Nûvdekirin", @@ -871,4 +894,4 @@ "touch.settingsButton.description": "Mîhengên lîstikê vekin", "touch.stateAction.load": "DAWETKIRIN", "touch.stateAction.save": "RIZGARKIRIN" -} \ No newline at end of file +} diff --git a/platforms/android/app/src/main/assets/i18n/pl.json b/platforms/android/app/src/main/assets/i18n/pl.json index aca80edaee..a8007306ad 100644 --- a/platforms/android/app/src/main/assets/i18n/pl.json +++ b/platforms/android/app/src/main/assets/i18n/pl.json @@ -14,6 +14,11 @@ "tab.skins": "Skórki", "app.language": "Język", "app.language.desc": "Wybierz język aplikacji. Zastosowuje się natychmiast.", + "app.language.system": "Język systemu", + "app.library.search": "Wyszukiwanie w bibliotece", + "app.library.search.desc": "Pokazuje pole wyszukiwania gier na ekranie głównym biblioteki.", + "app.library.recents": "Ostatnio uruchamiane gry", + "app.library.recents.desc": "Pokazuje sekcję ostatnio uruchamianych gier na ekranie głównym biblioteki.", "app.language.machineNote": "Tłumaczenia inne niż angielskie są maszynowe. Widzisz błąd? Daj znać, a poprawimy.", "app.theme": "Motyw", "app.theme.system": "Systemowy", @@ -233,7 +238,12 @@ "games.info.perGameSettings.title": "Ustawienia dla gry", "games.info.tapToClose": "Dotknij gdziekolwiek lub naciśnij B, aby zamknąć", "games.info.title": "Pomoc biblioteki", + "games.library.totalGames": "Łącznie gier", "games.nav.library": "BIBLIOTEKA", + "games.overflow.coverStyle": "Styl okładki", + "games.overflow.openNavigation": "Otwórz nawigację", + "games.overflow.sortRecent": "Sortuj według ostatnio granych", + "games.overflow.sortTitle": "Sortuj według tytułu", "games.noCover": "Brak okładki", "games.noSerial": "Brak numeru seryjnego", "games.scanningRoms": "Skanowanie ROM-ów…", @@ -671,6 +681,8 @@ "savestate.autosave.screenshotDesc": "Zrzut ekranu auto-zapisu", "savestate.autosave.title": "Auto-zapis", "savestate.backup": "Kopia zapasowa", + "savestate.empty.description": "Utwórz stan zapisu podczas gry, a następnie zarządzaj nim lub wykonaj kopię zapasową tutaj.", + "savestate.empty.title": "Brak stanów zapisu", "savestate.noBackupFound": "Nie znaleziono kopii zapasowej", "savestate.noSavesToBackUp": "Brak zapisów do skopiowania", "savestate.restore": "Przywróć", @@ -817,5 +829,16 @@ "about.device": "Urządzenie", "about.androidVersion": "Wersja Androida", "about.repository.title": "Repozytorium GitHub", + "about.build.title": "Informacje o kompilacji", + "about.hardware.title": "Informacje o urządzeniu", + "about.soc": "Chipset", + "about.gpu": "GPU", + "about.cpuCores": "Rdzenie CPU", + "about.memory": "Pamięć", + "about.display": "Wyświetlacz", + "about.architecture": "Architektura", + "about.pageSize": "Strona pamięci", + "about.pcsx2.title": "Projekt PCSX2", + "about.pcsx2.description": "ARMSX2 bazuje na otwartoźródłowym emulatorze PCSX2.", "about.repository.description": "Otwórz kod źródłowy, wydania i listę problemów." } diff --git a/platforms/android/app/src/main/assets/i18n/pt-BR.json b/platforms/android/app/src/main/assets/i18n/pt-BR.json index df38994158..49007a5c70 100644 --- a/platforms/android/app/src/main/assets/i18n/pt-BR.json +++ b/platforms/android/app/src/main/assets/i18n/pt-BR.json @@ -14,6 +14,11 @@ "tab.skins": "Skins", "app.language": "Idioma", "app.language.desc": "Escolha o idioma do app. Aplica na hora.", + "app.language.system": "Idioma do sistema", + "app.library.search": "Pesquisa na biblioteca", + "app.library.search.desc": "Mostra o campo de pesquisa de jogos na tela inicial da biblioteca.", + "app.library.recents": "Jogos recentes", + "app.library.recents.desc": "Mostra a seção de jogos recentes na tela inicial da biblioteca.", "app.language.machineNote": "As traduções, exceto o inglês, são feitas por máquina. Encontrou algum erro? Avise e vamos corrigir.", "app.theme": "Tema", "app.theme.system": "Sistema", @@ -233,7 +238,12 @@ "games.info.perGameSettings.title": "Configurações por jogo", "games.info.tapToClose": "Toque em qualquer lugar ou pressione B para fechar", "games.info.title": "Ajuda da Biblioteca", + "games.library.totalGames": "Total de jogos", "games.nav.library": "BIBLIOTECA", + "games.overflow.coverStyle": "Estilo da capa", + "games.overflow.openNavigation": "Abrir navegação", + "games.overflow.sortRecent": "Ordenar por jogados recentemente", + "games.overflow.sortTitle": "Ordenar por título", "games.noCover": "Sem capa", "games.noSerial": "Sem serial", "games.scanningRoms": "Escaneando ROMs…", @@ -671,6 +681,8 @@ "savestate.autosave.screenshotDesc": "Captura do autosave", "savestate.autosave.title": "Autosave", "savestate.backup": "Backup", + "savestate.empty.description": "Crie um estado de salvamento durante o jogo e depois gerencie-o ou faça backup aqui.", + "savestate.empty.title": "Ainda não há estados salvos", "savestate.noBackupFound": "Nenhum backup encontrado", "savestate.noSavesToBackUp": "Nenhum save para fazer backup", "savestate.restore": "Restaurar", @@ -810,12 +822,23 @@ "touch.settingsButton.description": "Abrir configurações no jogo", "touch.stateAction.load": "CARREGAR", "touch.stateAction.save": "SALVAR", - "about.title": "Sobre", + "about.title": "Sobre o aplicativo", "about.tagline": "Emulação rápida e moderna de PlayStation 2 para Android.", "about.appVersion": "Versão do aplicativo", "about.coreVersion": "Versão do emulador", "about.device": "Dispositivo", "about.androidVersion": "Versão do Android", "about.repository.title": "Repositório no GitHub", + "about.build.title": "Informações da compilação", + "about.hardware.title": "Informações do dispositivo", + "about.soc": "Chipset", + "about.gpu": "GPU", + "about.cpuCores": "Núcleos da CPU", + "about.memory": "Memória", + "about.display": "Tela", + "about.architecture": "Arquitetura", + "about.pageSize": "Página de memória", + "about.pcsx2.title": "Projeto PCSX2", + "about.pcsx2.description": "O ARMSX2 é baseado no emulador PCSX2 de código aberto.", "about.repository.description": "Abra o código-fonte, as versões e o rastreador de problemas." } diff --git a/platforms/android/app/src/main/assets/i18n/ru.json b/platforms/android/app/src/main/assets/i18n/ru.json index 5285dfcc62..f242305989 100644 --- a/platforms/android/app/src/main/assets/i18n/ru.json +++ b/platforms/android/app/src/main/assets/i18n/ru.json @@ -1,11 +1,22 @@ { - "about.title": "О", + "about.title": "О приложении", "about.tagline": "Быстрая и современная эмуляция PlayStation 2 для Android.", "about.appVersion": "Версия приложения", "about.coreVersion": "Версия эмулятора", "about.device": "Устройство", "about.androidVersion": "Android-версия", "about.repository.title": "Репозиторий GitHub", + "about.build.title": "Информация о сборке", + "about.hardware.title": "Информация об устройстве", + "about.soc": "Чипсет", + "about.gpu": "GPU", + "about.cpuCores": "Ядра CPU", + "about.memory": "Память", + "about.display": "Экран", + "about.architecture": "Архитектура", + "about.pageSize": "Страница памяти", + "about.pcsx2.title": "Проект PCSX2", + "about.pcsx2.description": "ARMSX2 создан на основе эмулятора PCSX2 с открытым исходным кодом.", "about.repository.description": "Откройте исходный код, выпуски и систему отслеживания проблем.", "tab.app": "Приложение", "tab.info": "Информация", @@ -38,6 +49,11 @@ "tab.skins": "Скины", "app.language": "Язык", "app.language.desc": "Выберите язык приложения. Применяется мгновенно.", + "app.language.system": "Язык системы", + "app.library.search": "Поиск в библиотеке", + "app.library.search.desc": "Показывать поле поиска игр на главном экране библиотеки.", + "app.library.recents": "Недавно запущенные игры", + "app.library.recents.desc": "Показывать раздел недавно запущенных игр на главном экране библиотеки.", "app.language.machineNote": "Переводы, за исключением английского, выполняются машинным переводом. Заметили плохой? Дайте нам знать, и мы это исправим.", "app.theme": "Тема", "app.theme.system": "Система", @@ -261,7 +277,12 @@ "games.info.perGameSettings.title": "Настройки для каждой игры", "games.info.tapToClose": "Нажмите в любом месте или нажмите B, чтобы закрыть", "games.info.title": "Помощь библиотеки", + "games.library.totalGames": "Всего игр", "games.nav.library": "БИБЛИОТЕКА", + "games.overflow.coverStyle": "Стиль обложек", + "games.overflow.openNavigation": "Открыть навигацию", + "games.overflow.sortRecent": "По последнему запуску", + "games.overflow.sortTitle": "Сортировать по названию", "games.noCover": "Нет покрытия", "games.noSerial": "Нет серийного номера", "games.scanningRoms": "Сканирование ПЗУ…", @@ -729,6 +750,8 @@ "savestate.autosave.screenshotDesc": "Автосохранение скриншота", "savestate.autosave.title": "Автосохранение", "savestate.backup": "Резервное копирование", + "savestate.empty.description": "Создайте сохранение во время игры, а затем управляйте им или сделайте резервную копию здесь.", + "savestate.empty.title": "Сохранений пока нет", "savestate.noBackupFound": "Резервная копия не найдена", "savestate.noSavesToBackUp": "Нет сохранений для резервного копирования", "savestate.restore": "Восстановить", @@ -871,4 +894,4 @@ "touch.settingsButton.description": "Открыть внутриигровые настройки", "touch.stateAction.load": "ЗАГРУЗИТЬ", "touch.stateAction.save": "СОХРАНИТЬ" -} \ No newline at end of file +} diff --git a/platforms/android/app/src/main/assets/i18n/th.json b/platforms/android/app/src/main/assets/i18n/th.json index 3c0431f797..7b6985bc92 100644 --- a/platforms/android/app/src/main/assets/i18n/th.json +++ b/platforms/android/app/src/main/assets/i18n/th.json @@ -14,6 +14,11 @@ "tab.skins": "สกิน", "app.language": "ภาษา", "app.language.desc": "เลือกภาษาของแอป มีผลทันที", + "app.language.system": "ภาษาของระบบ", + "app.library.search": "ค้นหาในคลังเกม", + "app.library.search.desc": "แสดงช่องค้นหาเกมบนหน้าหลักของคลังเกม", + "app.library.recents": "เกมที่เล่นล่าสุด", + "app.library.recents.desc": "แสดงส่วนเกมที่เล่นล่าสุดบนหน้าหลักของคลังเกม", "app.language.machineNote": "ทุกภาษายกเว้นอังกฤษเป็นการแปลด้วยเครื่อง เจอที่แปลผิดไหม? แจ้งเราแล้วเราจะแก้ให้", "app.theme": "ธีม", "app.theme.system": "ระบบ", @@ -233,7 +238,12 @@ "games.info.perGameSettings.title": "การตั้งค่าต่อเกม", "games.info.tapToClose": "แตะที่ใดก็ได้หรือกด B เพื่อปิด", "games.info.title": "ความช่วยเหลือคลังเกม", + "games.library.totalGames": "เกมทั้งหมด", "games.nav.library": "คลังเกม", + "games.overflow.coverStyle": "รูปแบบปก", + "games.overflow.openNavigation": "เปิดเมนูนำทาง", + "games.overflow.sortRecent": "เรียงตามการเล่นล่าสุด", + "games.overflow.sortTitle": "เรียงตามชื่อ", "games.noCover": "ไม่มีปก", "games.noSerial": "ไม่มี serial", "games.scanningRoms": "กำลังสแกน ROMs…", @@ -671,6 +681,8 @@ "savestate.autosave.screenshotDesc": "ภาพหน้าจอ autosave", "savestate.autosave.title": "Autosave", "savestate.backup": "สำรอง", + "savestate.empty.description": "สร้างสถานะบันทึกขณะเล่นเกม แล้วจัดการหรือสำรองข้อมูลได้ที่นี่", + "savestate.empty.title": "ยังไม่มีสถานะบันทึก", "savestate.noBackupFound": "ไม่พบข้อมูลสำรอง", "savestate.noSavesToBackUp": "ไม่มีเซฟให้สำรอง", "savestate.restore": "กู้คืน", @@ -810,12 +822,23 @@ "touch.settingsButton.description": "เปิดการตั้งค่าในเกม", "touch.stateAction.load": "โหลด", "touch.stateAction.save": "บันทึก", - "about.title": "เกี่ยวกับ", + "about.title": "เกี่ยวกับแอป", "about.tagline": "การจำลอง PlayStation 2 ที่รวดเร็วและทันสมัยสำหรับ Android", "about.appVersion": "เวอร์ชันแอป", "about.coreVersion": "เวอร์ชันอีมูเลเตอร์", "about.device": "อุปกรณ์", "about.androidVersion": "เวอร์ชัน Android", "about.repository.title": "ที่เก็บ GitHub", + "about.build.title": "ข้อมูลบิลด์", + "about.hardware.title": "ข้อมูลอุปกรณ์", + "about.soc": "ชิปเซ็ต", + "about.gpu": "GPU", + "about.cpuCores": "คอร์ CPU", + "about.memory": "หน่วยความจำ", + "about.display": "หน้าจอ", + "about.architecture": "สถาปัตยกรรม", + "about.pageSize": "หน้าหน่วยความจำ", + "about.pcsx2.title": "โครงการ PCSX2", + "about.pcsx2.description": "ARMSX2 สร้างขึ้นจากโปรแกรมจำลอง PCSX2 แบบโอเพนซอร์ส", "about.repository.description": "เปิดซอร์สโค้ด รุ่นเผยแพร่ และตัวติดตามปัญหา" } diff --git a/platforms/android/app/src/main/assets/i18n/tr.json b/platforms/android/app/src/main/assets/i18n/tr.json index f7e807d1df..ed9a530ea1 100644 --- a/platforms/android/app/src/main/assets/i18n/tr.json +++ b/platforms/android/app/src/main/assets/i18n/tr.json @@ -14,6 +14,11 @@ "tab.skins": "Görünümler", "app.language": "Dil", "app.language.desc": "Uygulama dilini seçin. Anında uygulanır.", + "app.language.system": "Sistem dili", + "app.library.search": "Kitaplık araması", + "app.library.search.desc": "Kitaplık ana ekranında oyun arama alanını gösterir.", + "app.library.recents": "Son oynanan oyunlar", + "app.library.recents.desc": "Kitaplık ana ekranında son oynanan oyunlar bölümünü gösterir.", "app.language.machineNote": "İngilizce dışındaki çeviriler makine çevirisidir. Hatalı bir çeviri mi gördünüz? Bize bildirin, düzeltelim.", "app.theme": "Tema", "app.theme.system": "Sistem", @@ -233,7 +238,12 @@ "games.info.perGameSettings.title": "Oyuna özel ayarlar", "games.info.tapToClose": "Kapatmak için herhangi bir yere dokunun veya B'ye basın", "games.info.title": "Kitaplık Yardımı", + "games.library.totalGames": "Toplam oyun", "games.nav.library": "KİTAPLIK", + "games.overflow.coverStyle": "Kapak stili", + "games.overflow.openNavigation": "Gezinmeyi aç", + "games.overflow.sortRecent": "Son oynanana göre sırala", + "games.overflow.sortTitle": "Başlığa göre sırala", "games.noCover": "Kapak yok", "games.noSerial": "Seri no yok", "games.scanningRoms": "ROM'lar taranıyor…", @@ -671,6 +681,8 @@ "savestate.autosave.screenshotDesc": "Otomatik kayıt ekran görüntüsü", "savestate.autosave.title": "Otomatik Kayıt", "savestate.backup": "Yedek", + "savestate.empty.description": "Oyun çalışırken bir kayıt durumu oluşturun, ardından burada yönetin veya yedekleyin.", + "savestate.empty.title": "Henüz kayıt durumu yok", "savestate.noBackupFound": "Yedek bulunamadı", "savestate.noSavesToBackUp": "Yedeklenecek kayıt yok", "savestate.restore": "Geri Yükle", @@ -810,12 +822,23 @@ "touch.settingsButton.description": "Oyun içi ayarları aç", "touch.stateAction.load": "YÜKLE", "touch.stateAction.save": "KAYDET", - "about.title": "Hakkında", + "about.title": "Uygulama hakkında", "about.tagline": "Android için hızlı ve modern PlayStation 2 emülasyonu.", "about.appVersion": "Uygulama sürümü", "about.coreVersion": "Emülatör sürümü", "about.device": "Cihaz", "about.androidVersion": "Android sürümü", "about.repository.title": "GitHub deposu", + "about.build.title": "Derleme bilgileri", + "about.hardware.title": "Cihaz bilgileri", + "about.soc": "Yonga seti", + "about.gpu": "GPU", + "about.cpuCores": "CPU çekirdekleri", + "about.memory": "Bellek", + "about.display": "Ekran", + "about.architecture": "Mimari", + "about.pageSize": "Bellek sayfası", + "about.pcsx2.title": "PCSX2 projesi", + "about.pcsx2.description": "ARMSX2, açık kaynaklı PCSX2 emülatörü üzerine kuruludur.", "about.repository.description": "Kaynak kodunu, sürümleri ve sorun takipçisini açın." } diff --git a/platforms/android/app/src/main/assets/i18n/uk.json b/platforms/android/app/src/main/assets/i18n/uk.json index 0f19def71a..5d54c22c08 100644 --- a/platforms/android/app/src/main/assets/i18n/uk.json +++ b/platforms/android/app/src/main/assets/i18n/uk.json @@ -1,11 +1,22 @@ { - "about.title": "про", + "about.title": "Про додаток", "about.tagline": "Швидка сучасна емуляція PlayStation 2 для Android.", "about.appVersion": "Версія програми", "about.coreVersion": "Версія емулятора", "about.device": "пристрій", "about.androidVersion": "версія Android", "about.repository.title": "Репозиторій GitHub", + "about.build.title": "Інформація про збірку", + "about.hardware.title": "Інформація про пристрій", + "about.soc": "Чипсет", + "about.gpu": "GPU", + "about.cpuCores": "Ядра CPU", + "about.memory": "Пам’ять", + "about.display": "Дисплей", + "about.architecture": "Архітектура", + "about.pageSize": "Сторінка пам’яті", + "about.pcsx2.title": "Проєкт PCSX2", + "about.pcsx2.description": "ARMSX2 створено на основі емулятора PCSX2 з відкритим кодом.", "about.repository.description": "Відкрийте вихідний код, випуски та засіб відстеження проблем.", "tab.app": "додаток", "tab.info": "Інформація", @@ -38,6 +49,11 @@ "tab.skins": "Скіни", "app.language": "Мова", "app.language.desc": "Виберіть мову програми. Застосовується миттєво.", + "app.language.system": "Мова системи", + "app.library.search": "Пошук у бібліотеці", + "app.library.search.desc": "Показувати поле пошуку ігор на головному екрані бібліотеки.", + "app.library.recents": "Нещодавно запущені ігри", + "app.library.recents.desc": "Показувати розділ нещодавно запущених ігор на головному екрані бібліотеки.", "app.language.machineNote": "Переклади, крім англійської, виконуються машинним перекладом. Помітили поганий? Дайте нам знати, і ми це виправимо.", "app.theme": "Тема", "app.theme.system": "система", @@ -261,7 +277,12 @@ "games.info.perGameSettings.title": "Налаштування для кожної гри", "games.info.tapToClose": "Торкніться будь-де або натисніть B, щоб закрити", "games.info.title": "Довідка бібліотеки", + "games.library.totalGames": "Всього ігор", "games.nav.library": "БІБЛІОТЕКА", + "games.overflow.coverStyle": "Стиль обкладинок", + "games.overflow.openNavigation": "Відкрити навігацію", + "games.overflow.sortRecent": "За останнім запуском", + "games.overflow.sortTitle": "Сортувати за назвою", "games.noCover": "Без покриття", "games.noSerial": "Без серіалу", "games.scanningRoms": "Сканування ПЗУ…", @@ -729,6 +750,8 @@ "savestate.autosave.screenshotDesc": "Автозбереження знімка екрана", "savestate.autosave.title": "Автозбереження", "savestate.backup": "Резервне копіювання", + "savestate.empty.description": "Створіть збереження під час гри, а потім керуйте ним або зробіть резервну копію тут.", + "savestate.empty.title": "Збережень поки немає", "savestate.noBackupFound": "Резервна копія не знайдена", "savestate.noSavesToBackUp": "Немає збережень для резервного копіювання", "savestate.restore": "Відновити", @@ -871,4 +894,4 @@ "touch.settingsButton.description": "Відкрийте налаштування гри", "touch.stateAction.load": "НАВАНТАЖЕННЯ", "touch.stateAction.save": "ЗБЕРЕГТИ" -} \ No newline at end of file +} diff --git a/platforms/android/app/src/main/assets/i18n/vi.json b/platforms/android/app/src/main/assets/i18n/vi.json index a752aded81..67b07fbc60 100644 --- a/platforms/android/app/src/main/assets/i18n/vi.json +++ b/platforms/android/app/src/main/assets/i18n/vi.json @@ -14,6 +14,11 @@ "tab.skins": "Skin", "app.language": "Ngôn ngữ", "app.language.desc": "Chọn ngôn ngữ ứng dụng. Áp dụng ngay lập tức.", + "app.language.system": "Ngôn ngữ hệ thống", + "app.library.search": "Tìm kiếm thư viện", + "app.library.search.desc": "Hiển thị ô tìm kiếm trò chơi trên màn hình chính của thư viện.", + "app.library.recents": "Trò chơi gần đây", + "app.library.recents.desc": "Hiển thị mục trò chơi gần đây trên màn hình chính của thư viện.", "app.language.machineNote": "Các bản dịch ngoài tiếng Anh đều do máy dịch. Thấy bản dịch dở? Hãy báo cho chúng tôi và chúng tôi sẽ sửa.", "app.theme": "Giao diện", "app.theme.system": "Hệ thống", @@ -233,7 +238,12 @@ "games.info.perGameSettings.title": "Cài đặt theo từng game", "games.info.tapToClose": "Chạm bất kỳ đâu hoặc nhấn B để đóng", "games.info.title": "Trợ giúp Thư viện", + "games.library.totalGames": "Tổng số trò chơi", "games.nav.library": "THƯ VIỆN", + "games.overflow.coverStyle": "Kiểu bìa", + "games.overflow.openNavigation": "Mở điều hướng", + "games.overflow.sortRecent": "Sắp xếp theo lần chơi gần nhất", + "games.overflow.sortTitle": "Sắp xếp theo tiêu đề", "games.noCover": "Không có bìa", "games.noSerial": "Không có serial", "games.scanningRoms": "Đang quét ROM…", @@ -671,6 +681,8 @@ "savestate.autosave.screenshotDesc": "Ảnh chụp tự lưu", "savestate.autosave.title": "Tự lưu", "savestate.backup": "Sao lưu", + "savestate.empty.description": "Tạo trạng thái lưu khi đang chơi, sau đó quản lý hoặc sao lưu tại đây.", + "savestate.empty.title": "Chưa có trạng thái lưu", "savestate.noBackupFound": "Không tìm thấy bản sao lưu", "savestate.noSavesToBackUp": "Không có bản lưu để sao lưu", "savestate.restore": "Khôi phục", @@ -810,12 +822,23 @@ "touch.settingsButton.description": "Mở cài đặt trong game", "touch.stateAction.load": "TẢI", "touch.stateAction.save": "LƯU", - "about.title": "Giới thiệu", + "about.title": "Giới thiệu ứng dụng", "about.tagline": "Giả lập PlayStation 2 nhanh và hiện đại cho Android.", "about.appVersion": "Phiên bản ứng dụng", "about.coreVersion": "Phiên bản trình giả lập", "about.device": "Thiết bị", "about.androidVersion": "Phiên bản Android", - "about.repository.title": "Kho GitHub", + "about.repository.title": "Kho GitHub", + "about.build.title": "Thông tin bản dựng", + "about.hardware.title": "Thông tin thiết bị", + "about.soc": "Chipset", + "about.gpu": "GPU", + "about.cpuCores": "Lõi CPU", + "about.memory": "Bộ nhớ", + "about.display": "Màn hình", + "about.architecture": "Kiến trúc", + "about.pageSize": "Trang bộ nhớ", + "about.pcsx2.title": "Dự án PCSX2", + "about.pcsx2.description": "ARMSX2 được xây dựng trên trình giả lập PCSX2 mã nguồn mở.", "about.repository.description": "Mở mã nguồn, bản phát hành và trình theo dõi lỗi." } diff --git a/platforms/android/app/src/main/assets/i18n/zh-CN.json b/platforms/android/app/src/main/assets/i18n/zh-CN.json index 680a28e37b..5f0470c207 100644 --- a/platforms/android/app/src/main/assets/i18n/zh-CN.json +++ b/platforms/android/app/src/main/assets/i18n/zh-CN.json @@ -14,6 +14,11 @@ "tab.skins": "皮肤", "app.language": "语言", "app.language.desc": "选择应用语言,立即生效。", + "app.language.system": "系统语言", + "app.library.search": "游戏库搜索", + "app.library.search.desc": "在游戏库主屏幕上显示游戏搜索框。", + "app.library.recents": "最近游玩的游戏", + "app.library.recents.desc": "在游戏库主屏幕上显示最近游玩的游戏。", "app.language.machineNote": "除英语外的翻译均为机器翻译。发现错误?告诉我们,我们会修正。", "app.theme": "主题", "app.theme.system": "跟随系统", @@ -233,7 +238,12 @@ "games.info.perGameSettings.title": "单游戏设置", "games.info.tapToClose": "点击任意处或按 B 关闭", "games.info.title": "游戏库帮助", + "games.library.totalGames": "游戏总数", "games.nav.library": "游戏库", + "games.overflow.coverStyle": "封面样式", + "games.overflow.openNavigation": "打开导航", + "games.overflow.sortRecent": "按最近游玩排序", + "games.overflow.sortTitle": "按标题排序", "games.noCover": "无封面", "games.noSerial": "无序列号", "games.scanningRoms": "扫描 ROMs…", @@ -671,6 +681,8 @@ "savestate.autosave.screenshotDesc": "自动保存截图", "savestate.autosave.title": "自动保存", "savestate.backup": "备份", + "savestate.empty.description": "在游戏运行时创建即时存档,然后可在此管理或备份。", + "savestate.empty.title": "暂无即时存档", "savestate.noBackupFound": "未找到备份", "savestate.noSavesToBackUp": "无可备份的存档", "savestate.restore": "恢复", @@ -810,12 +822,23 @@ "touch.settingsButton.description": "打开游戏内设置", "touch.stateAction.load": "载入", "touch.stateAction.save": "保存", - "about.title": "关于", + "about.title": "关于应用", "about.tagline": "适用于 Android 的快速现代 PlayStation 2 模拟器。", "about.appVersion": "应用版本", "about.coreVersion": "模拟器版本", "about.device": "设备", "about.androidVersion": "Android 版本", "about.repository.title": "GitHub 仓库", + "about.build.title": "构建信息", + "about.hardware.title": "设备信息", + "about.soc": "芯片组", + "about.gpu": "GPU", + "about.cpuCores": "CPU 核心", + "about.memory": "内存", + "about.display": "显示屏", + "about.architecture": "架构", + "about.pageSize": "内存页", + "about.pcsx2.title": "PCSX2 项目", + "about.pcsx2.description": "ARMSX2 基于开源 PCSX2 模拟器构建。", "about.repository.description": "打开源代码、发行版本和问题跟踪。" } diff --git a/platforms/android/app/src/main/assets/i18n/zh-TW.json b/platforms/android/app/src/main/assets/i18n/zh-TW.json index f86045cd9c..88030152d9 100644 --- a/platforms/android/app/src/main/assets/i18n/zh-TW.json +++ b/platforms/android/app/src/main/assets/i18n/zh-TW.json @@ -14,6 +14,11 @@ "tab.skins": "外觀", "app.language": "語言", "app.language.desc": "選擇應用程式語言。立即套用。", + "app.language.system": "系統語言", + "app.library.search": "遊戲庫搜尋", + "app.library.search.desc": "在遊戲庫主畫面顯示遊戲搜尋欄。", + "app.library.recents": "最近遊玩的遊戲", + "app.library.recents.desc": "在遊戲庫主畫面顯示最近遊玩的遊戲區段。", "app.language.machineNote": "英文以外的翻譯皆為機器翻譯。發現不妥之處?告訴我們,我們會修正。", "app.theme": "主題", "app.theme.system": "跟隨系統", @@ -233,7 +238,12 @@ "games.info.perGameSettings.title": "單一遊戲設定", "games.info.tapToClose": "點任意處或按 B 關閉", "games.info.title": "遊戲庫說明", + "games.library.totalGames": "遊戲總數", "games.nav.library": "遊戲庫", + "games.overflow.coverStyle": "封面樣式", + "games.overflow.openNavigation": "開啟導覽", + "games.overflow.sortRecent": "依最近遊玩排序", + "games.overflow.sortTitle": "依標題排序", "games.noCover": "無封面", "games.noSerial": "無序號", "games.scanningRoms": "掃描 ROMs 中…", @@ -671,6 +681,8 @@ "savestate.autosave.screenshotDesc": "自動儲存截圖", "savestate.autosave.title": "自動儲存", "savestate.backup": "備份", + "savestate.empty.description": "在遊戲執行時建立即時存檔,之後可在此管理或備份。", + "savestate.empty.title": "尚無即時存檔", "savestate.noBackupFound": "找不到備份", "savestate.noSavesToBackUp": "沒有可備份的存檔", "savestate.restore": "還原", @@ -810,12 +822,23 @@ "touch.settingsButton.description": "開啟遊戲內設定", "touch.stateAction.load": "載入", "touch.stateAction.save": "儲存", - "about.title": "關於", + "about.title": "關於應用程式", "about.tagline": "適用於 Android 的快速現代 PlayStation 2 模擬器。", "about.appVersion": "應用程式版本", "about.coreVersion": "模擬器版本", "about.device": "裝置", "about.androidVersion": "Android 版本", "about.repository.title": "GitHub 儲存庫", + "about.build.title": "建置資訊", + "about.hardware.title": "裝置資訊", + "about.soc": "晶片組", + "about.gpu": "GPU", + "about.cpuCores": "CPU 核心", + "about.memory": "記憶體", + "about.display": "顯示器", + "about.architecture": "架構", + "about.pageSize": "記憶體頁面", + "about.pcsx2.title": "PCSX2 專案", + "about.pcsx2.description": "ARMSX2 建構於開源 PCSX2 模擬器之上。", "about.repository.description": "開啟原始碼、發行版本與問題追蹤。" } diff --git a/platforms/android/app/src/main/java/com/armsx2/Pasx2Application.kt b/platforms/android/app/src/main/java/com/armsx2/Pasx2Application.kt index e76c35e0b9..e0ad1bf9bb 100644 --- a/platforms/android/app/src/main/java/com/armsx2/Pasx2Application.kt +++ b/platforms/android/app/src/main/java/com/armsx2/Pasx2Application.kt @@ -34,6 +34,11 @@ class Pasx2Application : Application(), ImageLoaderFactory { installCrashLogging() } + override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { + super.onConfigurationChanged(newConfig) + com.armsx2.i18n.I18n.refreshSystemLanguage(this) + } + /** * Diagnostics for the no-ADB case: some games (e.g. GoW2, LEGO Batman) drop * back to the library on boot on some setups. We can't read logcat on the diff --git a/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt b/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt index a653ad8c8d..4acf33fb33 100644 --- a/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt +++ b/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt @@ -34,8 +34,11 @@ data class AppLanguage( ) object I18n { - /** English first (source of truth), then the machine-translated set. */ + const val SYSTEM_CODE = "system" + + /** System default first, then English (source of truth) and translations. */ val languages: List = listOf( + AppLanguage(SYSTEM_CODE, "System", "System"), AppLanguage("en", "English", "English"), AppLanguage("es", "Spanish", "Español"), AppLanguage("pt-BR", "Portuguese (Brazil)", "Português (Brasil)"), @@ -64,24 +67,56 @@ object I18n { var current by mutableStateOf("en") private set + /** Picker selection. `system` resolves to an actual [current] language. */ + var selected by mutableStateOf(SYSTEM_CODE) + private set + /** code → (key → translated text). English is never stored here (it lives in [EN]). */ private val tables = HashMap>() /** Call once at startup (after MainActivityRuntime.prefs is ready). Restores the saved language. */ fun init(context: Context) { val saved = runCatching { MainActivityRuntime.prefs.getString(PREF_KEY, null) }.getOrNull() - val code = if (saved != null && languages.any { it.code == saved }) saved else "en" - if (code != "en") ensureLoaded(context, code) - current = code + val selection = if (saved != null && languages.any { it.code == saved }) saved else SYSTEM_CODE + applySelection(context, selection) } /** Switch language live + persist. Loads the JSON if not already cached. */ fun setLanguage(context: Context, code: String) { - if (code != "en") ensureLoaded(context, code) - current = code + if (languages.none { it.code == code }) return + applySelection(context, code) runCatching { MainActivityRuntime.prefs.edit { putString(PREF_KEY, code) } } } + /** Re-resolve only when the picker is following the device language. */ + fun refreshSystemLanguage(context: Context) { + if (selected == SYSTEM_CODE) applySelection(context, SYSTEM_CODE) + } + + private fun applySelection(context: Context, selection: String) { + val code = if (selection == SYSTEM_CODE) resolveSystemLanguage(context) else selection + if (code != "en") ensureLoaded(context, code) + selected = selection + current = code + } + + private fun resolveSystemLanguage(context: Context): String { + val locale = context.resources.configuration.locales[0] ?: return "en" + val supported = languages.filterNot { it.code == SYSTEM_CODE } + supported.firstOrNull { it.code.equals(locale.toLanguageTag(), ignoreCase = true) }?.let { return it.code } + + if (locale.language.equals("zh", ignoreCase = true)) { + val traditional = locale.script.equals("Hant", ignoreCase = true) || + locale.country.uppercase() in setOf("TW", "HK", "MO") + return if (traditional) "zh-TW" else "zh-CN" + } + if (locale.language.equals("pt", ignoreCase = true)) return "pt-BR" + + return supported.firstOrNull { + it.code.substringBefore('-').equals(locale.language, ignoreCase = true) + }?.code ?: "en" + } + private fun ensureLoaded(context: Context, code: String) { if (code == "en" || tables.containsKey(code)) return val map = runCatching { @@ -125,7 +160,7 @@ fun str(key: String): String { * renaming a key orphans it in every translation JSON. */ val EN: Map = mapOf( - "about.title" to "About", + "about.title" to "About app", "about.tagline" to "Fast, modern PlayStation 2 emulation for Android.", "about.appVersion" to "App version", "about.coreVersion" to "Emulator version", @@ -133,6 +168,17 @@ val EN: Map = mapOf( "about.androidVersion" to "Android version", "about.repository.title" to "GitHub repository", "about.repository.description" to "Open the source code, releases, and issue tracker.", + "about.build.title" to "Build information", + "about.hardware.title" to "Device information", + "about.soc" to "Chipset", + "about.gpu" to "GPU", + "about.cpuCores" to "CPU cores", + "about.memory" to "Memory", + "about.display" to "Display", + "about.architecture" to "Architecture", + "about.pageSize" to "Memory page", + "about.pcsx2.title" to "PCSX2 project", + "about.pcsx2.description" to "ARMSX2 is built on the open-source PCSX2 emulator.", // --- settings tabs --- "tab.app" to "App", "tab.info" to "Info", @@ -166,7 +212,12 @@ val EN: Map = mapOf( // --- App tab / language --- "app.language" to "Language", "app.language.desc" to "Choose the app language. Applies instantly.", + "app.language.system" to "System language", "app.language.machineNote" to "Translations except English are machine-translated. Spot a bad one? Let us know and we'll fix it.", + "app.library.search" to "Library search", + "app.library.search.desc" to "Show the game search field on the library home screen.", + "app.library.recents" to "Recently played games", + "app.library.recents.desc" to "Show the Recently Played section on the library home screen.", "app.theme" to "Theme", "app.theme.system" to "System", "app.theme.light" to "Light", @@ -406,7 +457,12 @@ val EN: Map = mapOf( "games.info.perGameSettings.title" to "Per-game settings", "games.info.tapToClose" to "Tap anywhere or press B to close", "games.info.title" to "Library Help", + "games.library.totalGames" to "Total games", "games.nav.library" to "LIBRARY", + "games.overflow.coverStyle" to "Cover style", + "games.overflow.openNavigation" to "Open navigation", + "games.overflow.sortRecent" to "Sort by recently played", + "games.overflow.sortTitle" to "Sort by title", "games.noCover" to "No cover", "games.noSerial" to "No serial", "games.scanningRoms" to "Scanning ROMs…", @@ -875,6 +931,8 @@ val EN: Map = mapOf( "savestate.autosave.screenshotDesc" to "Autosave screenshot", "savestate.autosave.title" to "Autosave", "savestate.backup" to "Backup", + "savestate.empty.description" to "Create a save state while a game is running, then manage or back it up here.", + "savestate.empty.title" to "No save states yet", "savestate.noBackupFound" to "No backup found", "savestate.noSavesToBackUp" to "No saves to back up", "savestate.restore" to "Restore", diff --git a/platforms/android/app/src/main/java/com/armsx2/navigation/AppNavigation.kt b/platforms/android/app/src/main/java/com/armsx2/navigation/AppNavigation.kt index a5076d7f4f..05429b0c9e 100644 --- a/platforms/android/app/src/main/java/com/armsx2/navigation/AppNavigation.kt +++ b/platforms/android/app/src/main/java/com/armsx2/navigation/AppNavigation.kt @@ -57,7 +57,8 @@ fun AppNavigation() { ), ) val isReturning = targetState is AppRoute.Home || - (initialState is AppRoute.Language && targetState is AppRoute.Settings) + ((initialState is AppRoute.Language || initialState is AppRoute.About) && + targetState is AppRoute.Settings) val exit = if (isReturning) { fadeOut( animationSpec = tween( @@ -87,6 +88,7 @@ fun AppNavigation() { initialCategory = destination.category, game = destination.game, onBack = UiNavigator::home, + onOpenAbout = { UiNavigator.navigate(AppRoute.About) }, ) AppRoute.BiosManager -> BiosManagerScreen(onBack = UiNavigator::home) AppRoute.MemoryCardManager -> MemoryCardScreen(onBack = UiNavigator::home) @@ -98,7 +100,9 @@ fun AppNavigation() { AppRoute.Language -> LanguageScreen( onBack = { UiNavigator.navigate(AppRoute.Settings(SettingsCategory.General)) }, ) - AppRoute.About -> AboutScreen(onBack = UiNavigator::home) + AppRoute.About -> AboutScreen( + onBack = { UiNavigator.navigate(AppRoute.Settings(SettingsCategory.General)) }, + ) } } diff --git a/platforms/android/app/src/main/java/com/armsx2/navigation/AppRoute.kt b/platforms/android/app/src/main/java/com/armsx2/navigation/AppRoute.kt index 90e4ab977d..cefe3ee7cc 100644 --- a/platforms/android/app/src/main/java/com/armsx2/navigation/AppRoute.kt +++ b/platforms/android/app/src/main/java/com/armsx2/navigation/AppRoute.kt @@ -33,6 +33,7 @@ enum class SettingsCategory { Skins, Advanced, Recompiler, + About, } object UiNavigator { @@ -56,6 +57,10 @@ object UiNavigator { route.value = AppRoute.Settings(SettingsCategory.General) return true } + AppRoute.About -> { + route.value = AppRoute.Settings(SettingsCategory.General) + return true + } AppRoute.Home -> Unit else -> { route.value = AppRoute.Home diff --git a/platforms/android/app/src/main/java/com/armsx2/navigation/NavigationDrawer.kt b/platforms/android/app/src/main/java/com/armsx2/navigation/NavigationDrawer.kt index be1cea96c2..eb37018eed 100644 --- a/platforms/android/app/src/main/java/com/armsx2/navigation/NavigationDrawer.kt +++ b/platforms/android/app/src/main/java/com/armsx2/navigation/NavigationDrawer.kt @@ -155,10 +155,6 @@ private fun DrawerContent(selected: AppRoute, onNavigate: (AppRoute) -> Unit) { HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.45f)) Spacer(Modifier.height(14.dp)) DrawerSection(str("ra.options.header"), managers, selected, onNavigate) - Spacer(Modifier.height(14.dp)) - HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.45f)) - Spacer(Modifier.height(14.dp)) - DrawerRow("drawer.About", "ARMSX2", "ⓘ", selected = selected is AppRoute.About, onClick = { onNavigate(AppRoute.About) }) } } diff --git a/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt b/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt index a508108039..ef155e664d 100644 --- a/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt +++ b/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt @@ -80,6 +80,9 @@ import kotlin.math.min import androidx.core.net.toUri import androidx.core.content.edit +private const val LIGHT_NAVIGATION_BAR_SCRIM = 0x04000000 +private const val DARK_NAVIGATION_BAR_SCRIM = 0x0A000000 + private const val STICK_DEAD = 0.15f // Trigger (L2/R2) dead-low: much smaller than the stick deadzone — triggers want fine // control and full range. Just enough to swallow resting-axis noise on cheaper / non-Xbox @@ -1304,17 +1307,36 @@ open class MainActivityRuntime : ComponentActivity() { } } - override fun onCreate(savedInstanceState: Bundle?) { + private fun applyEdgeToEdge() { enableEdgeToEdge( statusBarStyle = SystemBarStyle.auto( android.graphics.Color.TRANSPARENT, android.graphics.Color.TRANSPARENT, ), navigationBarStyle = SystemBarStyle.auto( - android.graphics.Color.TRANSPARENT, - android.graphics.Color.TRANSPARENT, + LIGHT_NAVIGATION_BAR_SCRIM, + DARK_NAVIGATION_BAR_SCRIM, ), ) + } + + private fun applySystemBarTheme(darkTheme: Boolean, showSystemBars: Boolean) { + // Re-apply edge-to-edge along with icon appearance. Some Android versions + // reset one half of this state when the app switches theme at runtime. + applyEdgeToEdge() + WindowCompat.setDecorFitsSystemWindows(window, false) + WindowInsetsControllerCompat(window, window.decorView).apply { + val useDarkIcons = !darkTheme + isAppearanceLightStatusBars = useDarkIcons + isAppearanceLightNavigationBars = useDarkIcons + if (showSystemBars) show(WindowInsetsCompat.Type.systemBars()) + else hide(WindowInsetsCompat.Type.systemBars()) + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + applyEdgeToEdge() super.onCreate(savedInstanceState) com.armsx2.navigation.UiNavigator.drawerOpen.value = false if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { @@ -1347,6 +1369,7 @@ open class MainActivityRuntime : ComponentActivity() { com.armsx2.ui.theme.ThemePreferences.load() com.armsx2.ui.theme.BootLogoPreferences.load() com.armsx2.ui.theme.ToolbarPositionPreferences.load() + com.armsx2.ui.theme.LibraryChromePreferences.load() com.armsx2.ControllerSkinStore.load(applicationContext) // Restore the saved rumble master toggle into the native gate (NativeApp.onPadRumble). NativeApp.sRumbleEnabled = ControllerMappings.rumbleEnabled() @@ -1466,23 +1489,13 @@ open class MainActivityRuntime : ComponentActivity() { eState.value == EmuState.STOPPED || eState.value == EmuState.RENDER_UNSUPPORTED || eState.value == EmuState.EMULATOR_UNSUPPORTED - val lightSystemBars = when (com.armsx2.ui.theme.ThemePreferences.mode.value) { - com.armsx2.ui.theme.ThemeMode.System -> !androidx.compose.foundation.isSystemInDarkTheme() - com.armsx2.ui.theme.ThemeMode.Light -> true - com.armsx2.ui.theme.ThemeMode.Dark -> false + val darkTheme = when (com.armsx2.ui.theme.ThemePreferences.mode.value) { + com.armsx2.ui.theme.ThemeMode.System -> androidx.compose.foundation.isSystemInDarkTheme() + com.armsx2.ui.theme.ThemeMode.Light -> false + com.armsx2.ui.theme.ThemeMode.Dark -> true } - androidx.compose.runtime.LaunchedEffect(showSystemBars, lightSystemBars) { - WindowInsetsControllerCompat(window, window.decorView).apply { - if (showSystemBars) { - show(WindowInsetsCompat.Type.systemBars()) - isAppearanceLightStatusBars = lightSystemBars - isAppearanceLightNavigationBars = lightSystemBars - } else { - hide(WindowInsetsCompat.Type.systemBars()) - } - systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - } + androidx.compose.runtime.SideEffect { + applySystemBarTheme(darkTheme = darkTheme, showSystemBars = showSystemBars) } // First-time setup deferral: when the wizard finishes and // setupComplete flips to true, kick off the heavy emucore diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/about/AboutScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/about/AboutScreen.kt index 6c4ab72fb9..0df56655ae 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/about/AboutScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/about/AboutScreen.kt @@ -36,6 +36,7 @@ import com.armsx2.ui.common.GlassPanel import com.armsx2.ui.common.RoundAction private const val RepositoryUrl = "https://github.com/ARMSX2/ARMSX2" +private const val Pcsx2RepositoryUrl = "https://github.com/PCSX2/pcsx2" @Composable fun AboutScreen(onBack: () -> Unit, viewModel: AboutViewModel = viewModel()) { @@ -47,7 +48,7 @@ fun AboutScreen(onBack: () -> Unit, viewModel: AboutViewModel = viewModel()) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { ArmsTopBar( title = str("about.title"), - leading = { RoundAction("‹", str("action.back"), onBack) }, + leading = { RoundAction("←", str("action.back"), onBack) }, ) BoxWithConstraints(Modifier.fillMaxWidth()) { val compact = maxWidth < 760.dp @@ -66,11 +67,26 @@ fun AboutScreen(onBack: () -> Unit, viewModel: AboutViewModel = viewModel()) { } val information: @Composable (Modifier) -> Unit = { panelModifier -> GlassPanel(panelModifier) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + PanelTitle(str("about.build.title")) InfoRow(str("about.appVersion"), state.appVersion) InfoRow(str("about.coreVersion"), state.coreVersion) + } + } + } + val hardware: @Composable (Modifier) -> Unit = { panelModifier -> + GlassPanel(panelModifier) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + PanelTitle(str("about.hardware.title")) InfoRow(str("about.device"), state.device) InfoRow(str("about.androidVersion"), state.androidVersion) + InfoRow(str("about.soc"), state.soc) + InfoRow(str("about.gpu"), state.gpu) + InfoRow(str("about.cpuCores"), state.cpu) + InfoRow(str("about.memory"), state.memory) + InfoRow(str("about.display"), state.display) + InfoRow(str("about.architecture"), state.architecture) + InfoRow(str("about.pageSize"), state.pageSize) } } } @@ -81,13 +97,51 @@ fun AboutScreen(onBack: () -> Unit, viewModel: AboutViewModel = viewModel()) { if (compact) { overview(Modifier.fillMaxWidth()) information(Modifier.fillMaxWidth()) + hardware(Modifier.fillMaxWidth()) } else { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(14.dp)) { overview(Modifier.weight(1f)) information(Modifier.weight(1f)) } + hardware(Modifier.fillMaxWidth()) + } + if (compact) { + ProjectCard( + title = str("about.repository.title"), + repository = "ARMSX2/ARMSX2", + description = str("about.repository.description"), + glyph = "⌘", + onOpen = { uriHandler.openUri(RepositoryUrl) }, + ) + ProjectCard( + title = str("about.pcsx2.title"), + repository = "PCSX2/pcsx2", + description = str("about.pcsx2.description"), + glyph = "PS2", + secondary = true, + onOpen = { uriHandler.openUri(Pcsx2RepositoryUrl) }, + ) + } else { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + ProjectCard( + title = str("about.repository.title"), + repository = "ARMSX2/ARMSX2", + description = str("about.repository.description"), + glyph = "⌘", + modifier = Modifier.weight(1f), + onOpen = { uriHandler.openUri(RepositoryUrl) }, + ) + ProjectCard( + title = str("about.pcsx2.title"), + repository = "PCSX2/pcsx2", + description = str("about.pcsx2.description"), + glyph = "PS2", + modifier = Modifier.weight(1f), + secondary = true, + onOpen = { uriHandler.openUri(Pcsx2RepositoryUrl) }, + ) + } } - RepositoryCard { uriHandler.openUri(RepositoryUrl) } } } } @@ -95,13 +149,22 @@ fun AboutScreen(onBack: () -> Unit, viewModel: AboutViewModel = viewModel()) { } @Composable -private fun RepositoryCard(onOpen: () -> Unit) { +private fun ProjectCard( + title: String, + repository: String, + description: String, + glyph: String, + modifier: Modifier = Modifier, + secondary: Boolean = false, + onOpen: () -> Unit, +) { + val accent = if (secondary) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.primary Surface( onClick = onOpen, - modifier = Modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.58f), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.48f)), + color = accent.copy(alpha = 0.10f), + border = BorderStroke(1.dp, accent.copy(alpha = 0.46f)), ) { Row( modifier = Modifier.padding(horizontal = 15.dp, vertical = 14.dp), @@ -110,33 +173,43 @@ private fun RepositoryCard(onOpen: () -> Unit) { Surface( modifier = Modifier.size(48.dp), shape = RoundedCornerShape(14.dp), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), + color = accent.copy(alpha = 0.14f), ) { Box(contentAlignment = Alignment.Center) { - Text("⌘", color = MaterialTheme.colorScheme.primary, fontSize = 22.sp, fontWeight = FontWeight.Bold) + Text(glyph, color = accent, fontSize = if (glyph.length > 2) 13.sp else 22.sp, fontWeight = FontWeight.Bold) } } Spacer(Modifier.size(12.dp)) Column(Modifier.weight(1f)) { - Text(str("about.repository.title"), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text( - "ARMSX2/ARMSX2", + repository, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, + color = accent, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( - str("about.repository.description"), + description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Text("↗", color = MaterialTheme.colorScheme.primary, fontSize = 22.sp) + Text("↗", color = accent, fontSize = 22.sp) } } } +@Composable +private fun PanelTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) +} + @Composable private fun InfoRow(label: String, value: String) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { @@ -147,11 +220,12 @@ private fun InfoRow(label: String, value: String) { modifier = Modifier.weight(1f), ) Text( - value, + value.ifBlank { "…" }, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, - maxLines = 1, + maxLines = 2, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1.15f), ) } } diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/about/AboutViewModel.kt b/platforms/android/app/src/main/java/com/armsx2/ui/about/AboutViewModel.kt index 7a0fa72bd7..45e2803a65 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/about/AboutViewModel.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/about/AboutViewModel.kt @@ -1,15 +1,35 @@ package com.armsx2.ui.about import android.app.Application +import android.app.ActivityManager +import android.content.Context +import android.opengl.EGL14 +import android.opengl.EGLConfig +import android.opengl.GLES20 +import android.os.Build +import android.system.Os +import android.system.OsConstants import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope import com.armsx2.BuildConfig import kr.co.iefriends.pcsx2.NativeApp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Locale data class AboutUiState( val appVersion: String = BuildConfig.VERSION_NAME, val coreVersion: String = "", - val device: String = "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}".trim(), - val androidVersion: String = "Android ${android.os.Build.VERSION.RELEASE}", + val device: String = "${Build.MANUFACTURER} ${Build.MODEL}".trim(), + val androidVersion: String = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})", + val soc: String = "", + val gpu: String = "", + val cpu: String = "", + val memory: String = "", + val display: String = "", + val architecture: String = "", + val pageSize: String = "", ) class AboutViewModel(application: Application) : AndroidViewModel(application) { @@ -17,7 +37,94 @@ class AboutViewModel(application: Application) : AndroidViewModel(application) { private set fun load() { - state.value = state.value.copy(coreVersion = runCatching { NativeApp.getBuildVersion() }.getOrDefault("Unknown")) + viewModelScope.launch { + val details = withContext(Dispatchers.Default) { collectDeviceDetails(getApplication()) } + state.value = state.value.copy( + coreVersion = runCatching { NativeApp.getBuildVersion() }.getOrDefault("—"), + soc = details.soc, + gpu = details.gpu, + cpu = details.cpu, + memory = details.memory, + display = details.display, + architecture = details.architecture, + pageSize = details.pageSize, + ) + } + } + + private data class DeviceDetails( + val soc: String, + val gpu: String, + val cpu: String, + val memory: String, + val display: String, + val architecture: String, + val pageSize: String, + ) + + private fun collectDeviceDetails(context: Context): DeviceDetails { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memoryInfo = ActivityManager.MemoryInfo().also(activityManager::getMemoryInfo) + val metrics = context.resources.displayMetrics + val pageBytes = runCatching { Os.sysconf(OsConstants._SC_PAGESIZE) }.getOrDefault(4096L) + val soc = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + listOf(Build.SOC_MANUFACTURER, Build.SOC_MODEL).filter(String::isNotBlank).joinToString(" ") + } else { + Build.HARDWARE + } + return DeviceDetails( + soc = soc.ifBlank { "—" }, + gpu = queryGpuRenderer().ifBlank { "—" }, + cpu = Runtime.getRuntime().availableProcessors().toString(), + memory = String.format(Locale.US, "%.1f GB", memoryInfo.totalMem / 1_073_741_824.0), + display = "${metrics.widthPixels} × ${metrics.heightPixels} · ${metrics.densityDpi} dpi", + architecture = Build.SUPPORTED_ABIS.joinToString(", ").ifBlank { "—" }, + pageSize = if (pageBytes >= 16_384L) "${pageBytes / 1024L} KB (16K)" else "${pageBytes / 1024L} KB", + ) + } + + private fun queryGpuRenderer(): String { + val display = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY) + if (display == EGL14.EGL_NO_DISPLAY) return "" + val version = IntArray(2) + if (!EGL14.eglInitialize(display, version, 0, version, 1)) return "" + var surface = EGL14.EGL_NO_SURFACE + var context = EGL14.EGL_NO_CONTEXT + return try { + val attributes = intArrayOf( + EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, + EGL14.EGL_SURFACE_TYPE, EGL14.EGL_PBUFFER_BIT, + EGL14.EGL_RED_SIZE, 8, + EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, + EGL14.EGL_NONE, + ) + val configs = arrayOfNulls(1) + val count = IntArray(1) + if (!EGL14.eglChooseConfig(display, attributes, 0, configs, 0, 1, count, 0) || count[0] == 0) return "" + val config = configs[0] ?: return "" + context = EGL14.eglCreateContext( + display, + config, + EGL14.EGL_NO_CONTEXT, + intArrayOf(EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE), + 0, + ) + surface = EGL14.eglCreatePbufferSurface( + display, + config, + intArrayOf(EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE), + 0, + ) + if (context == EGL14.EGL_NO_CONTEXT || surface == EGL14.EGL_NO_SURFACE || + !EGL14.eglMakeCurrent(display, surface, surface, context) + ) return "" + GLES20.glGetString(GLES20.GL_RENDERER).orEmpty() + } finally { + EGL14.eglMakeCurrent(display, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_SURFACE, EGL14.EGL_NO_CONTEXT) + if (surface != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(display, surface) + if (context != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(display, context) + EGL14.eglTerminate(display) + } } } - diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/achievements/AchievementsScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/achievements/AchievementsScreen.kt index 264139cd38..3440511269 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/achievements/AchievementsScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/achievements/AchievementsScreen.kt @@ -71,8 +71,7 @@ fun AchievementsScreen(onBack: () -> Unit, viewModel: AchievementsViewModel = vi Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { ArmsTopBar( title = "RetroAchievements", - subtitle = state.richPresence.ifBlank { str("ra.status.notSignedIn.body") }, - leading = { RoundAction("‹", str("action.back"), onBack) }, + leading = { RoundAction("←", str("action.back"), onBack) }, actions = { if (state.loggedIn) { StatusChip("${state.score} pts", Success) diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/bios/BiosManagerScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/bios/BiosManagerScreen.kt index e16b94dbb8..dc19450d5e 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/bios/BiosManagerScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/bios/BiosManagerScreen.kt @@ -4,6 +4,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -14,11 +15,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface @@ -32,6 +36,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -49,6 +54,7 @@ import com.armsx2.ui.theme.Success fun BiosManagerScreen(onBack: () -> Unit, viewModel: BiosManagerViewModel = viewModel()) { val state = viewModel.state.value var deleteTarget by remember { mutableStateOf(null) } + var actionsMenuExpanded by remember { mutableStateOf(false) } val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> uri?.let(viewModel::import) } @@ -68,11 +74,23 @@ fun BiosManagerScreen(onBack: () -> Unit, viewModel: BiosManagerViewModel = view item { ArmsTopBar( title = str("setup.page.bios.title"), - leading = { RoundAction("‹", str("action.back"), onBack) }, + leading = { RoundAction("←", str("action.back"), onBack) }, actions = { - RoundAction("+", str("action.import"), { picker.launch(arrayOf("application/octet-stream", "*/*")) }) - RoundAction("📁", str("action.importFolder"), { folderPicker.launch(null) }) - RoundAction("↻", str("games.card.refresh"), viewModel::refresh) + Box { + RoundAction( + glyph = "⋮", + description = str("games.toolbar.more"), + onClick = { actionsMenuExpanded = true }, + selected = actionsMenuExpanded, + ) + BiosActionsMenu( + expanded = actionsMenuExpanded, + onDismiss = { actionsMenuExpanded = false }, + onImportFile = { picker.launch(arrayOf("application/octet-stream", "*/*")) }, + onImportFolder = { folderPicker.launch(null) }, + onRefresh = viewModel::refresh, + ) + } }, horizontalPadding = 0.dp, ) @@ -129,6 +147,75 @@ fun BiosManagerScreen(onBack: () -> Unit, viewModel: BiosManagerViewModel = view } } +@Composable +private fun BiosActionsMenu( + expanded: Boolean, + onDismiss: () -> Unit, + onImportFile: () -> Unit, + onImportFolder: () -> Unit, + onRefresh: () -> Unit, +) { + fun closeThen(action: () -> Unit) { + onDismiss() + action() + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + modifier = Modifier.widthIn(min = 280.dp, max = 340.dp), + shape = RoundedCornerShape(22.dp), + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + shadowElevation = 14.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.42f)), + ) { + Text( + text = str("setup.page.bios.title"), + modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + BiosActionMenuItem("+", str("action.import")) { closeThen(onImportFile) } + BiosActionMenuItem("▣", str("action.importFolder")) { closeThen(onImportFolder) } + BiosActionMenuItem("↻", str("games.card.refresh")) { closeThen(onRefresh) } + } +} + +@Composable +private fun BiosActionMenuItem(glyph: String, label: String, onClick: () -> Unit) { + DropdownMenuItem( + text = { + Text( + text = label, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + }, + onClick = onClick, + leadingIcon = { + Surface( + modifier = Modifier.size(36.dp), + shape = RoundedCornerShape(11.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = glyph, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + }, + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp), + ) +} + @Composable private fun BiosRow(item: InstalledBios, onSelect: () -> Unit, onDelete: () -> Unit) { Surface( diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/common/ArmsComponents.kt b/platforms/android/app/src/main/java/com/armsx2/ui/common/ArmsComponents.kt index 6fa987b347..49e1f02be3 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/common/ArmsComponents.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/common/ArmsComponents.kt @@ -36,7 +36,6 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface @@ -53,6 +52,9 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalConfiguration @@ -173,6 +175,15 @@ fun ArmsTopBar( maxLines = 1, overflow = TextOverflow.Ellipsis, ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } Row(horizontalArrangement = Arrangement.spacedBy(8.dp), content = actions) } @@ -210,35 +221,53 @@ fun RoundAction( description: String, onClick: () -> Unit, selected: Boolean = false, + framed: Boolean = true, + buttonSize: Dp = 42.dp, + buttonShape: Shape = CircleShape, + subtleFrame: Boolean = false, ) { val interaction = remember { MutableInteractionSource() } val focused by interaction.collectIsFocusedAsState() - IconButton( + val actionBorder = if (framed || focused || selected) { + BorderStroke( + width = if (focused) 2.dp else 1.dp, + color = when { + focused -> MaterialTheme.colorScheme.primary + selected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.76f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = if (subtleFrame) 0.30f else 0.58f) + }, + ) + } else { + null + } + val actionColor = when { + selected -> MaterialTheme.colorScheme.primaryContainer + framed -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (subtleFrame) 0.58f else 1f) + else -> Color.Transparent + } + Surface( onClick = onClick, interactionSource = interaction, modifier = Modifier - .size(44.dp) - .border( - width = if (focused) 2.dp else 1.dp, - color = when { - focused -> MaterialTheme.colorScheme.primary - selected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.76f) - else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.58f) - }, - shape = CircleShape, - ) - .background( - if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, - CircleShape, - ) + .size(buttonSize) + .semantics { contentDescription = description } .focusable(interactionSource = interaction), + shape = buttonShape, + color = actionColor, + border = actionBorder, ) { - Text( - text = glyph, - color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - ) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = glyph, + color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, + fontSize = when { + glyph.length >= 3 -> 13.sp + glyph.length == 2 -> 18.sp + else -> 22.sp + }, + fontWeight = FontWeight.Bold, + ) + } } } diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/common/GameCoverArt.kt b/platforms/android/app/src/main/java/com/armsx2/ui/common/GameCoverArt.kt index f1d36a28f5..e1ed854de0 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/common/GameCoverArt.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/common/GameCoverArt.kt @@ -2,7 +2,9 @@ package com.armsx2.ui.common import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -15,6 +17,8 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest @@ -35,13 +39,13 @@ fun GameCoverArt(game: GameInfo, modifier: Modifier = Modifier) { contentDescription = game.title, modifier = modifier.clip(RoundedCornerShape(14.dp)), contentScale = ContentScale.Crop, - loading = { GameCoverPlaceholder(game.title) }, - error = { GameCoverPlaceholder(game.title) }, + loading = { GameCoverPlaceholder(game.title, game.serial) }, + error = { GameCoverPlaceholder(game.title, game.serial) }, ) } @Composable -fun GameCoverPlaceholder(title: String, modifier: Modifier = Modifier) { +fun GameCoverPlaceholder(title: String, serial: String? = null, modifier: Modifier = Modifier) { Box( modifier .fillMaxSize() @@ -55,11 +59,26 @@ fun GameCoverPlaceholder(title: String, modifier: Modifier = Modifier) { ), contentAlignment = Alignment.Center, ) { - Text( - title.take(2).uppercase(), - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Black, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f), - ) + Column(Modifier.padding(10.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + textAlign = TextAlign.Center, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + if (!serial.isNullOrBlank()) { + Text( + text = serial, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.68f), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } } diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/controls/ControllerManagerScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/controls/ControllerManagerScreen.kt index 1002dfd39a..ae3c002d4d 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/controls/ControllerManagerScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/controls/ControllerManagerScreen.kt @@ -64,7 +64,7 @@ fun ControllerManagerScreen(onBack: () -> Unit, viewModel: ControllerManagerView ArmsTopBar( title = str("tab.controls"), subtitle = str("pad.editing.description"), - leading = { RoundAction("‹", str("action.back"), onBack) }, + leading = { RoundAction("←", str("action.back"), onBack) }, actions = { RoundAction("↺", str("action.reset"), viewModel::resetPlayer) }, ) BoxWithConstraints(Modifier.fillMaxWidth()) { diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/emulation/EmulationMenuScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/emulation/EmulationMenuScreen.kt index 99880826dc..2659c548b9 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/emulation/EmulationMenuScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/emulation/EmulationMenuScreen.kt @@ -61,6 +61,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.layout +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -71,7 +73,6 @@ import com.armsx2.i18n.str import com.armsx2.runtime.MainActivityRuntime import com.armsx2.ui.InGameOverlay import com.armsx2.ui.achievements.AchievementItem -import com.armsx2.ui.common.ArmsLogo import com.armsx2.ui.common.GameCoverArt import com.armsx2.ui.settings.controllerFocusable import com.armsx2.ui.theme.Danger @@ -144,31 +145,52 @@ fun EmulationMenuScreen(viewModel: EmulationMenuViewModel = viewModel()) { exit = slideOutHorizontally(tween(220, easing = EaseIn)) { it }, modifier = Modifier.align(Alignment.CenterEnd), ) { - Surface( - modifier = Modifier - .fillMaxHeight() - .fillMaxWidth(if (compact) 0.96f else 0.76f) - .widthIn(max = 1020.dp) - .windowInsetsPadding( - WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical), - ), - shape = RoundedCornerShape(topStart = 28.dp, bottomStart = 28.dp), - color = MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)), - shadowElevation = 22.dp, - ) { - if (compact) { + if (compact) { + Surface( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(0.96f) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)), + shape = RoundedCornerShape(topStart = 28.dp, bottomStart = 28.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)), + shadowElevation = 22.dp, + ) { MenuPage(state, viewModel, compact = true, modifier = Modifier.fillMaxSize()) - } else { - Row(Modifier.fillMaxSize()) { - MenuRail(state.tab, viewModel::selectTab) + } + } else { + Row( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(0.64f) + .widthIn(max = 900.dp) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .padding(top = 14.dp, end = 12.dp, bottom = 14.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Surface( + modifier = Modifier.weight(1f).fillMaxHeight(), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)), + shadowElevation = 22.dp, + ) { MenuPage( state = state, viewModel = viewModel, compact = false, - modifier = Modifier.weight(1f).fillMaxHeight(), + modifier = Modifier.fillMaxSize(), ) } + Surface( + modifier = Modifier.width(76.dp).fillMaxHeight(), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.94f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)), + shadowElevation = 18.dp, + ) { + MenuRail(state.tab, viewModel::selectTab) + } } } } @@ -246,15 +268,39 @@ private fun MenuRail(selected: EmulationMenuTab, onSelect: (EmulationMenuTab) -> Column( Modifier .fillMaxHeight() - .width(184.dp) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.34f)) .verticalScroll(rememberScrollState()) - .padding(horizontal = 10.dp, vertical = 16.dp), + .padding(horizontal = 8.dp, vertical = 10.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - ArmsLogo(showWordmark = false, iconSize = 72.dp, modifier = Modifier.align(Alignment.CenterHorizontally)) - Spacer(Modifier.height(18.dp)) EmulationMenuTab.entries.forEach { tab -> - MenuTab(tab, tab == selected, onSelect) + MenuRailTab(tab, tab == selected, onSelect) + } + } +} + +@Composable +private fun MenuRailTab(tab: EmulationMenuTab, active: Boolean, onSelect: (EmulationMenuTab) -> Unit) { + val bring = remember { BringIntoViewRequester() } + val label = str(tab.titleKey) + LaunchedEffect(active) { if (active) runCatching { bring.bringIntoView() } } + Surface( + onClick = { onSelect(tab) }, + modifier = Modifier.size(56.dp).bringIntoViewRequester(bring).semantics { contentDescription = label }, + shape = RoundedCornerShape(18.dp), + color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface.copy(alpha = 0.5f), + border = BorderStroke( + if (active) 2.dp else 1.dp, + if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.34f), + ), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = tabGlyph(tab), + fontSize = 23.sp, + fontWeight = FontWeight.Bold, + color = if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } @@ -270,22 +316,61 @@ private fun MenuTab(tab: EmulationMenuTab, active: Boolean, onSelect: (Emulation LaunchedEffect(active) { if (active) runCatching { bring.bringIntoView() } } Surface( onClick = { onSelect(tab) }, - modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp).bringIntoViewRequester(bring), + modifier = Modifier + .widthIn(min = 132.dp, max = 210.dp) + .padding(vertical = 3.dp) + .bringIntoViewRequester(bring), shape = RoundedCornerShape(14.dp), - color = if (active) MaterialTheme.colorScheme.primary.copy(alpha = 0.17f) else Color.Transparent, - border = if (active) BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.62f)) else null, + color = if (active) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.56f), + border = BorderStroke( + if (active) 1.5.dp else 1.dp, + if (active) MaterialTheme.colorScheme.primary.copy(alpha = 0.72f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.32f), + ), ) { - Text( - str(tab.titleKey), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), - color = if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.labelLarge, - fontWeight = if (active) FontWeight.Bold else FontWeight.Medium, - maxLines = 2, - ) + Row( + modifier = Modifier.padding(horizontal = 11.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Surface( + modifier = Modifier.size(30.dp), + shape = RoundedCornerShape(9.dp), + color = if (active) MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + else MaterialTheme.colorScheme.surface.copy(alpha = 0.72f), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = tabGlyph(tab), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Text( + text = str(tab.titleKey), + color = if (active) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (active) FontWeight.Bold else FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } } } +private fun tabGlyph(tab: EmulationMenuTab): String = when (tab) { + EmulationMenuTab.Session -> "☰" + EmulationMenuTab.Graphics -> "✣" + EmulationMenuTab.Fixes -> "⚙" + EmulationMenuTab.Performance -> "↯" + EmulationMenuTab.Controls -> "⌁" + EmulationMenuTab.Options -> "▣" + EmulationMenuTab.Achievements -> "★" +} + @Composable private fun MenuHeader(compact: Boolean, hardcore: Boolean) { val game = MainActivityRuntime.currentGame.value diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/home/HomeScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/home/HomeScreen.kt index bcc92194e5..635c1c8071 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/home/HomeScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/home/HomeScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed @@ -50,6 +51,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface @@ -80,6 +82,7 @@ import androidx.compose.ui.res.painterResource import com.armsx2.CoverArtStyle import com.armsx2.R import com.armsx2.ui.theme.ToolbarPositionPreferences +import com.armsx2.ui.theme.LibraryChromePreferences import androidx.compose.ui.layout.layout import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp @@ -94,6 +97,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage +import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest import coil.size.Precision import com.armsx2.CustomCovers @@ -125,11 +129,10 @@ fun HomeScreen( val context = LocalContext.current val coverVersion = CustomCovers.version.value val customCoverMap = remember(coverVersion) { CustomCovers.loadAll(context) } - var sortMenu by remember { mutableStateOf(false) } + var overflowMenu by remember { mutableStateOf(false) } var menuGame by remember { mutableStateOf(null) } // #9 custom library background — inert until the user picks an image. LaunchedEffect(Unit) { LibraryBackground.ensureLoaded(); CoverArtStyle.load() } - var bgMenu by remember { mutableStateOf(false) } val backgroundPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { picked -> picked?.let { LibraryBackground.set(context, it) } } @@ -138,8 +141,11 @@ fun HomeScreen( // the field only exists then.) val searchFocus = remember { FocusRequester() } val keyboard = LocalSoftwareKeyboardController.current - LaunchedEffect(state.initialized) { - HomeInputController.setSearchAction(state.initialized) { + val showSearch = LibraryChromePreferences.showSearch.value + val showRecents = LibraryChromePreferences.showRecents.value + LaunchedEffect(state.initialized, showSearch) { + if (!showSearch && viewModel.state.value.query.isNotEmpty()) viewModel.setQuery("") + HomeInputController.setSearchAction(state.initialized && showSearch) { // Controller path: open our own D-pad-navigable keyboard (the system IME // can't be driven by a D-pad). Touch still uses the system keyboard by // tapping the field directly. @@ -230,7 +236,7 @@ fun HomeScreen( } // The Recently-Played games shown above the grid (empty while searching); // register them so the controller's Recents zone can launch them. - val shownRecents = if (state.recentGames.isNotEmpty() && state.query.isBlank()) { + val shownRecents = if (showRecents && state.recentGames.isNotEmpty() && state.query.isBlank()) { state.recentGames.take(10) } else { emptyList() @@ -247,7 +253,7 @@ fun HomeScreen( // (grid, list). Only scroll when it's actually off-screen so paging through a // visible screenful doesn't jitter. (Previously this only ran while searching, // so normal browsing never followed the selector.) - val allGamesHeaderShown = state.recentGames.isNotEmpty() && state.query.isBlank() && + val allGamesHeaderShown = shownRecents.isNotEmpty() && state.initialized && state.visibleGames.isNotEmpty() LaunchedEffect(state.selectedIndex, state.visibleGames.size, state.layout, HomeInputController.zone.value) { if (HomeInputController.zone.value != HomeZone.Grid) return@LaunchedEffect @@ -258,7 +264,7 @@ fun HomeScreen( val sel = state.selectedIndex if (sel < 0 || state.visibleGames.isEmpty()) return@LaunchedEffect val leading = 1 + // toolbar - (if (state.initialized) 1 else 0) + // search field + (if (state.initialized && showSearch) 1 else 0) + // search field (if (shownRecents.isNotEmpty()) 1 else 0) + // recents section (if (allGamesHeaderShown) 1 else 0) // All Games header val cols = estimatedColumns.coerceAtLeast(1) @@ -298,24 +304,39 @@ fun HomeScreen( listOf( onOpenMenu, { viewModel.refresh() }, - { sortMenu = true }, { viewModel.toggleLayout() }, - { CoverArtStyle.set(!CoverArtStyle.use3d.value) }, - { bgMenu = true }, + { overflowMenu = true }, ), ) val tb = HomeInputController.zone.value == HomeZone.Toolbar val tbi = HomeInputController.toolbarIndex.intValue ArmsTopBar( title = str("games.section.library"), - subtitle = if (state.scanning) str("games.scanningRoms") else state.allGames.size.toString(), - leading = { RoundAction("☰", str("games.nav.library"), onOpenMenu, selected = tb && tbi == 0) }, + subtitle = if (state.scanning) { + str("games.scanningRoms") + } else { + "${str("games.library.totalGames")}: ${state.allGames.size}" + }, + leading = { + RoundAction( + "☰", + str("games.overflow.openNavigation"), + onOpenMenu, + selected = tb && tbi == 0, + framed = true, + buttonSize = 44.dp, + buttonShape = RoundedCornerShape(14.dp), + subtleFrame = true, + ) + }, actions = { - RoundAction("↻", str("games.card.refresh"), viewModel::refresh, selected = tb && tbi == 1) - Box { - RoundAction("⇅", str("games.toolbar.recent"), { sortMenu = true }, selected = tb && tbi == 2) - SortMenu(sortMenu, state.sort, viewModel::setSort) { sortMenu = false } - } + RoundAction( + "↻", + str("games.card.refresh"), + viewModel::refresh, + selected = tb && tbi == 1, + framed = false, + ) RoundAction( when (state.layout) { LibraryLayout.Grid -> "☷" @@ -324,30 +345,29 @@ fun HomeScreen( }, str("games.toolbar.rows"), viewModel::toggleLayout, - selected = tb && tbi == 3, - ) - // 3D box-art covers vs flat 2D scans (xlenore covers/3d). - // Shows the current mode (like the layout toggle), tap to flip. - RoundAction( - if (CoverArtStyle.use3d.value) "3D" else "2D", - str("games.toolbar.covers3d"), - { CoverArtStyle.set(!CoverArtStyle.use3d.value) }, - selected = tb && tbi == 4, + selected = tb && tbi == 2, + framed = false, ) Box { - RoundAction("▧", str("games.toolbar.background"), { bgMenu = true }, selected = tb && tbi == 5) - DropdownMenu(expanded = bgMenu, onDismissRequest = { bgMenu = false }) { - DropdownMenuItem( - text = { Text(str("games.background.choose")) }, - onClick = { bgMenu = false; backgroundPicker.launch(arrayOf("image/*", "video/*")) }, - ) - if (LibraryBackground.uri.value != null) { - DropdownMenuItem( - text = { Text(str("games.background.clear")) }, - onClick = { bgMenu = false; LibraryBackground.clear() }, - ) - } - } + RoundAction( + "⋮", + str("games.toolbar.more"), + { overflowMenu = true }, + selected = overflowMenu || tb && tbi == 3, + framed = false, + ) + LibraryOverflowMenu( + expanded = overflowMenu, + selectedSort = state.sort, + use3dCovers = CoverArtStyle.use3d.value, + hasCustomBackground = LibraryBackground.uri.value != null, + onDismiss = { overflowMenu = false }, + onOpenNavigation = onOpenMenu, + onSort = viewModel::setSort, + onToggleCoverStyle = { CoverArtStyle.set(!CoverArtStyle.use3d.value) }, + onChooseBackground = { backgroundPicker.launch(arrayOf("image/*", "video/*")) }, + onClearBackground = LibraryBackground::clear, + ) } }, horizontalPadding = 0.dp, @@ -370,7 +390,7 @@ fun HomeScreen( if (!toolbarBottom) { item(span = { GridItemSpan(maxLineSpan) }) { libraryToolbar(false) } } - if (state.initialized) { + if (state.initialized && showSearch) { item(span = { GridItemSpan(maxLineSpan) }) { SearchField( value = state.query, @@ -458,18 +478,12 @@ fun HomeScreen( // Separate the Recently Played shelf from the full library with an // "All Games" header so the two rows don't run together. - if (state.recentGames.isNotEmpty() && state.query.isBlank() && state.initialized && state.visibleGames.isNotEmpty()) { + if (shownRecents.isNotEmpty() && state.initialized && state.visibleGames.isNotEmpty()) { item(span = { GridItemSpan(maxLineSpan) }) { - Column { - Spacer(Modifier.height(20.dp)) - SectionTitle( - str("games.section.allGames"), - modifier = Modifier.padding( - start = if (state.layout == LibraryLayout.Shelf) 4.dp else 0.dp, - ), - ) - Spacer(Modifier.height(9.dp)) - } + HorizontalDivider( + modifier = Modifier.padding(vertical = 8.dp), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.34f), + ) } } @@ -613,20 +627,120 @@ private fun GameMenuAction(glyph: String, label: String, onClick: () -> Unit) { } @Composable -private fun SortMenu(expanded: Boolean, selected: HomeSort, onSort: (HomeSort) -> Unit, dismiss: () -> Unit) { - DropdownMenu(expanded = expanded, onDismissRequest = dismiss) { - listOf( - HomeSort.Title to str("games.section.library"), - HomeSort.RecentlyPlayed to str("games.section.recentlyPlayed"), - ).forEach { (sort, label) -> - DropdownMenuItem( - text = { Text(label, fontWeight = if (sort == selected) FontWeight.Bold else FontWeight.Normal) }, - onClick = { onSort(sort); dismiss() }, - ) +private fun LibraryOverflowMenu( + expanded: Boolean, + selectedSort: HomeSort, + use3dCovers: Boolean, + hasCustomBackground: Boolean, + onDismiss: () -> Unit, + onOpenNavigation: () -> Unit, + onSort: (HomeSort) -> Unit, + onToggleCoverStyle: () -> Unit, + onChooseBackground: () -> Unit, + onClearBackground: () -> Unit, +) { + fun closeThen(action: () -> Unit) { + onDismiss() + action() + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + modifier = Modifier.widthIn(min = 320.dp, max = 380.dp), + shape = RoundedCornerShape(22.dp), + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + shadowElevation = 14.dp, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.42f)), + ) { + Text( + text = str("games.section.library"), + modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + LibraryOverflowItem("☰", str("games.overflow.openNavigation")) { + closeThen(onOpenNavigation) + } + LibraryOverflowItem( + glyph = "A–Z", + label = str("games.overflow.sortTitle"), + selected = selectedSort == HomeSort.Title, + ) { + closeThen { onSort(HomeSort.Title) } + } + LibraryOverflowItem( + glyph = "⇅", + label = str("games.overflow.sortRecent"), + selected = selectedSort == HomeSort.RecentlyPlayed, + ) { + closeThen { onSort(HomeSort.RecentlyPlayed) } + } + LibraryOverflowItem( + glyph = if (use3dCovers) "3D" else "2D", + label = str("games.overflow.coverStyle"), + trailing = if (use3dCovers) "3D" else "2D", + ) { + closeThen(onToggleCoverStyle) + } + LibraryOverflowItem("▧", str("games.background.choose")) { + closeThen(onChooseBackground) + } + if (hasCustomBackground) { + LibraryOverflowItem("×", str("games.background.clear")) { + closeThen(onClearBackground) + } } } } +@Composable +private fun LibraryOverflowItem( + glyph: String, + label: String, + selected: Boolean = false, + trailing: String? = null, + onClick: () -> Unit, +) { + DropdownMenuItem( + text = { + Text( + text = label, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, + maxLines = 2, + lineHeight = 20.sp, + overflow = TextOverflow.Ellipsis, + ) + }, + onClick = onClick, + leadingIcon = { + Surface( + modifier = Modifier.size(34.dp), + shape = RoundedCornerShape(11.dp), + color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = glyph, + fontSize = if (glyph.length > 2) 11.sp else 17.sp, + fontWeight = FontWeight.Bold, + color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, + ) + } + } + }, + trailingIcon = { + when { + selected -> Text("✓", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold) + trailing != null -> Text(trailing, color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Bold) + } + }, + modifier = Modifier.padding(horizontal = 6.dp), + ) +} + private fun LazyGridScope.emptyLibrary(noFolders: Boolean) { item(span = { GridItemSpan(maxLineSpan) }) { EmptyState( @@ -734,6 +848,7 @@ private fun GameCover( // Fit (not Crop) everywhere so the whole box art shows — Crop trims the top of // covers whose source art is a touch taller than the 0.7 slot. contentScale: ContentScale = ContentScale.Fit, + placeholderText: Boolean = true, ) { val context = LocalContext.current // Read the 3D-cover flag explicitly (not just via game.coverUrl, which is @@ -754,23 +869,25 @@ private fun GameCover( } Box(modifier.clip(RoundedCornerShape(cornerRadius))) { if (model == null) { - CoverPlaceholder(game.title) + CoverPlaceholder(game.title, game.serial, showText = placeholderText) } else { // No fill behind the art: 3D box-art PNGs are transparent around the // angled case, and any backing shows as a dark/coloured "notch" at the // top. Keeping it transparent lets the case sit directly on the shelf. - AsyncImage( + SubcomposeAsyncImage( model = request, contentDescription = game.title, modifier = Modifier.fillMaxSize(), contentScale = contentScale, + loading = { CoverPlaceholder(game.title, game.serial, showText = placeholderText) }, + error = { CoverPlaceholder(game.title, game.serial, showText = placeholderText) }, ) } } } @Composable -private fun CoverPlaceholder(title: String) { +private fun CoverPlaceholder(title: String, serial: String?, showText: Boolean) { Box( Modifier.fillMaxSize().background( Brush.linearGradient( @@ -779,12 +896,29 @@ private fun CoverPlaceholder(title: String) { ), contentAlignment = Alignment.Center, ) { - Text( - title.take(2).uppercase(), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Black, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.78f), - ) + if (showText) { + Column(Modifier.padding(8.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + textAlign = TextAlign.Center, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + if (!serial.isNullOrBlank()) { + Text( + text = serial, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.66f), + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } } } @@ -1105,6 +1239,7 @@ private fun ShelfGameCard(game: GameInfo, width: Dp, reflectionHeight: Dp, selec .graphicsLayer { scaleY = -1f; alpha = 0.18f }, cornerRadius = 0.dp, contentScale = ContentScale.Fit, + placeholderText = false, ) // Fade the reflection out toward the front of the shelf. Box(Modifier.matchParentSize().background(Brush.verticalGradient(listOf(Color.Transparent, Color(0x55000000))))) diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/language/LanguageScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/language/LanguageScreen.kt index 2513ef3632..19427a289e 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/language/LanguageScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/language/LanguageScreen.kt @@ -52,10 +52,10 @@ fun LanguageScreen( ArmsTopBar( title = str("app.language"), subtitle = str("app.language.desc"), - leading = { RoundAction("‹", str("action.back"), onBack) }, + leading = { RoundAction("←", str("action.back"), onBack) }, ) I18n.languages.forEach { language -> - val selected = language.code == I18n.current + val selected = language.code == I18n.selected Surface( onClick = { viewModel.select(language.code) }, modifier = Modifier @@ -79,11 +79,11 @@ fun LanguageScreen( ) { Column(Modifier.weight(1f)) { Text( - language.nativeName, + if (language.code == I18n.SYSTEM_CODE) str("app.language.system") else language.nativeName, style = MaterialTheme.typography.titleMedium, color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface, ) - if (language.nativeName != language.englishName) { + if (language.code != I18n.SYSTEM_CODE && language.nativeName != language.englishName) { Text( language.englishName, style = MaterialTheme.typography.bodySmall, diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/memorycards/MemoryCardScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/memorycards/MemoryCardScreen.kt index 0a62561f1a..b0ddd4975f 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/memorycards/MemoryCardScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/memorycards/MemoryCardScreen.kt @@ -66,7 +66,7 @@ fun MemoryCardScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: Memo item { ArmsTopBar( title = str("memcard.title"), - leading = { RoundAction("‹", str("action.back"), onBack) }, + leading = { RoundAction("←", str("action.back"), onBack) }, actions = { RoundAction("+", str("memcard.newCard"), { createDialog = true }) RoundAction("⇩", str("action.import"), { importer.launch(arrayOf("application/octet-stream", "*/*")) }) diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/onboarding/OnboardingScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/onboarding/OnboardingScreen.kt index d5e95ebb3e..e13e9b9f64 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/onboarding/OnboardingScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/onboarding/OnboardingScreen.kt @@ -182,7 +182,7 @@ fun OnboardingScreen(viewModel: OnboardingViewModel = viewModel()) { label = "onboarding-landscape-page", ) { page -> PageViewport(compact = false) { - WizardPage(page, state, viewModel, showWelcomeLogo = false, biosPicker = { + WizardPage(page, state, viewModel, biosPicker = { biosPicker.launch(null) }, folderPicker = { folderPicker.launch(null) }, onCustomStorage = onCustomStorage) } @@ -222,7 +222,7 @@ fun OnboardingScreen(viewModel: OnboardingViewModel = viewModel()) { label = "onboarding-portrait-page", ) { page -> PageViewport(compact = true) { - WizardPage(page, state, viewModel, showWelcomeLogo = true, biosPicker = { + WizardPage(page, state, viewModel, biosPicker = { biosPicker.launch(null) }, folderPicker = { folderPicker.launch(null) }, onCustomStorage = onCustomStorage) } @@ -255,13 +255,12 @@ private fun WizardPage( page: Int, state: OnboardingUiState, viewModel: OnboardingViewModel, - showWelcomeLogo: Boolean, biosPicker: () -> Unit, folderPicker: () -> Unit, onCustomStorage: () -> Unit, ) { when (page) { - 0 -> WelcomePage(compact = true, showLogo = showWelcomeLogo) + 0 -> WelcomePage(compact = true) 1 -> StoragePage(state, compact = true, viewModel::selectStorage, onCustomStorage) 2 -> BiosPage(state, compact = true, onPick = biosPicker, onSelectBios = viewModel::selectBiosCandidate) 3 -> GamesPage(state, folderPicker, viewModel::removeGameFolder) @@ -326,22 +325,11 @@ private fun PageViewport(compact: Boolean, content: @Composable () -> Unit) { } @Composable -private fun WelcomePage(compact: Boolean, showLogo: Boolean) { +private fun WelcomePage(compact: Boolean) { Column( Modifier.fillMaxWidth(), horizontalAlignment = if (compact) Alignment.Start else Alignment.CenterHorizontally, ) { - if (showLogo) { - Surface( - modifier = Modifier.size(if (compact) 72.dp else 82.dp), - shape = RoundedCornerShape(if (compact) 18.dp else 22.dp), - color = MaterialTheme.colorScheme.primaryContainer, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.55f)), - ) { - Box(contentAlignment = Alignment.Center) { ArmsLogo(showWordmark = false) } - } - Spacer(Modifier.height(if (compact) 18.dp else 24.dp)) - } Text( str("setup.welcome.heading"), style = MaterialTheme.typography.displayLarge, diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/patches/PatchManagerScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/patches/PatchManagerScreen.kt index fa2618f3dc..b199ef17ec 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/patches/PatchManagerScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/patches/PatchManagerScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -43,7 +44,6 @@ import com.armsx2.PatchRepo import com.armsx2.i18n.str import com.armsx2.ui.common.ArmsBackdrop import com.armsx2.ui.common.ArmsTopBar -import com.armsx2.ui.common.EmptyState import com.armsx2.ui.common.GlassPanel import com.armsx2.ui.common.RoundAction import com.armsx2.ui.common.SectionTitle @@ -62,8 +62,7 @@ fun PatchManagerScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: Pa Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { ArmsTopBar( title = str("patches.dialog.patchesAndCheats"), - subtitle = str("patches.applyAtBoot"), - leading = { RoundAction("‹", str("action.back"), onBack) }, + leading = { RoundAction("←", str("action.back"), onBack) }, actions = { RoundAction("+", str("action.import"), { picker.launch(arrayOf("text/plain", "application/octet-stream", "*/*")) }) RoundAction("↻", str("games.card.refresh"), viewModel::refresh) @@ -235,7 +234,7 @@ private fun PatchFiles(state: PatchManagerUiState, viewModel: PatchManagerViewMo Column(modifier) { SectionTitle(str("patches.installedHeader"), state.files.size.toString()) if (state.files.isEmpty()) { - EmptyState(str("patches.noFilesInstalled"), str("patches.pasteImportHint"), modifier = Modifier.fillMaxWidth().height(220.dp)) + PatchFilesEmptyState() } else { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { state.files.forEach { file -> @@ -254,6 +253,52 @@ private fun PatchFiles(state: PatchManagerUiState, viewModel: PatchManagerViewMo } } +@Composable +private fun PatchFilesEmptyState() { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.42f)), + tonalElevation = 1.dp, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 18.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + BoxWithConstraints( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "✦", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + Column(Modifier.weight(1f)) { + Text( + text = str("patches.noFilesInstalled"), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = str("patches.pasteImportHint"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable private fun PatchFileRow( file: File, diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/saves/SaveManagerScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/saves/SaveManagerScreen.kt index fe490d67db..9a09ebb395 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/saves/SaveManagerScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/saves/SaveManagerScreen.kt @@ -41,7 +41,6 @@ import com.armsx2.i18n.str import com.armsx2.ui.common.ArmsBackdrop import com.armsx2.ui.common.ArmsLogo import com.armsx2.ui.common.ArmsTopBar -import com.armsx2.ui.common.EmptyState import com.armsx2.ui.common.RoundAction import com.armsx2.ui.common.SectionTitle import com.armsx2.ui.settings.controllerFocusable @@ -60,7 +59,7 @@ fun SaveManagerScreen(onBack: () -> Unit, viewModel: SaveManagerViewModel = view item { ArmsTopBar( title = str("savestate.title.loadManage"), - leading = { RoundAction("‹", str("action.back"), onBack) }, + leading = { RoundAction("←", str("action.back"), onBack) }, actions = { if (state.saves.isNotEmpty()) { RoundAction("▣", str("savestate.backup"), viewModel::backupAll) @@ -83,11 +82,7 @@ fun SaveManagerScreen(onBack: () -> Unit, viewModel: SaveManagerViewModel = view } } else if (state.saves.isEmpty()) { item { - EmptyState( - title = str("savestate.noSavesToBackUp"), - message = str("savestate.title.save"), - modifier = Modifier.fillMaxWidth().height(190.dp), - ) + SaveManagerEmptyState() } } else { items(state.saves, key = { it.file.absolutePath }) { save -> @@ -115,6 +110,52 @@ fun SaveManagerScreen(onBack: () -> Unit, viewModel: SaveManagerViewModel = view } } +@Composable +private fun SaveManagerEmptyState() { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.42f)), + tonalElevation = 1.dp, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 18.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Box( + modifier = Modifier.width(48.dp).height(48.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "▣", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + Column(Modifier.weight(1f)) { + Text( + text = str("savestate.empty.title"), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = str("savestate.empty.description"), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable private fun SaveStateCard( save: SaveStateItem, diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/saves/SaveStatePicker.kt b/platforms/android/app/src/main/java/com/armsx2/ui/saves/SaveStatePicker.kt index 6b9e50adb5..64efdb15c2 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/saves/SaveStatePicker.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/saves/SaveStatePicker.kt @@ -96,7 +96,7 @@ fun SaveStatePickerScreen(mode: SaveMode, onBack: () -> Unit) { ArmsTopBar( title = if (mode == SaveMode.Save) str("savestate.title.save") else str("savestate.title.loadManage"), - leading = { RoundAction("‹", str("action.back"), onBack) }, + leading = { RoundAction("←", str("action.back"), onBack) }, ) if (mode == SaveMode.Load) { AutoOptions(Modifier.fillMaxWidth().padding(horizontal = 8.dp)) diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/settings/AppTab.kt b/platforms/android/app/src/main/java/com/armsx2/ui/settings/AppTab.kt index 5061621a07..a70e93a6c9 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/settings/AppTab.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/settings/AppTab.kt @@ -27,10 +27,11 @@ import com.armsx2.ui.theme.BootLogoPreferences import com.armsx2.ui.theme.ThemeMode import com.armsx2.ui.theme.ThemePreferences import com.armsx2.ui.theme.ToolbarPositionPreferences +import com.armsx2.ui.theme.LibraryChromePreferences @Composable fun AppTab() { - val currentLanguage = I18n.languages.firstOrNull { it.code == I18n.current } ?: I18n.languages.first() + val currentLanguage = I18n.languages.firstOrNull { it.code == I18n.current } Column( modifier = Modifier.fillMaxWidth(), @@ -61,7 +62,7 @@ fun AppTab() { Column(Modifier.weight(1f)) { Text(str("app.language"), style = MaterialTheme.typography.titleMedium) Text( - currentLanguage.nativeName, + if (I18n.selected == I18n.SYSTEM_CODE) str("app.language.system") else currentLanguage?.nativeName ?: "English", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -102,5 +103,19 @@ fun AppTab() { selectedIndex = if (ToolbarPositionPreferences.atBottom.value) 1 else 0, onChange = { ToolbarPositionPreferences.set(it == 1) }, ) + + ToggleRow( + label = str("app.library.search"), + value = LibraryChromePreferences.showSearch.value, + description = str("app.library.search.desc"), + onChange = LibraryChromePreferences::setShowSearch, + ) + + ToggleRow( + label = str("app.library.recents"), + value = LibraryChromePreferences.showRecents.value, + description = str("app.library.recents.desc"), + onChange = LibraryChromePreferences::setShowRecents, + ) } } diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsScreen.kt index d4c67ee9f0..0dca10194b 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/settingshub/SettingsScreen.kt @@ -59,7 +59,6 @@ import com.armsx2.ui.settings.RecompilerTab import com.armsx2.ui.settings.RendererTab import com.armsx2.ui.settings.SkinsTab import com.armsx2.ui.settings.LocalSettingsScrollState -import com.armsx2.ui.settings.verticalScrollbar private data class SettingsSection(val category: SettingsCategory, val titleKey: String, val glyph: String) @@ -68,6 +67,7 @@ fun SettingsScreen( initialCategory: SettingsCategory, game: GameInfo?, onBack: () -> Unit, + onOpenAbout: () -> Unit = {}, viewModel: SettingsViewModel = viewModel(), ) { var showReset by remember { mutableStateOf(false) } @@ -96,17 +96,22 @@ fun SettingsScreen( Modifier .fillMaxSize() .verticalScroll(screenScroll) - .verticalScrollbar(screenScroll) .padding(bottom = 8.dp), ) { ArmsTopBar( title = game?.title ?: str("action.settings"), subtitle = if (game == null) str("scope.global") else str("scope.game"), - leading = { RoundAction("‹", str("action.back"), onBack) }, + leading = { RoundAction("←", str("action.back"), onBack) }, actions = { RoundAction("↺", str("action.reset"), { showReset = true }) }, ) if (contentReady) { - SettingsCategoryBar(displayedCategory, gameSpecific = game != null, viewModel::selectCategory) + SettingsCategoryBar( + selected = displayedCategory, + gameSpecific = game != null, + onSelect = { category -> + if (category == SettingsCategory.About) onOpenAbout() else viewModel.selectCategory(category) + }, + ) Spacer(Modifier.height(10.dp)) SettingsPanel(displayedCategory, viewModel, Modifier.fillMaxWidth()) Spacer(Modifier.height(16.dp)) @@ -158,49 +163,56 @@ private fun SettingsCategoryBar( // tab (Skins / Fixes / Recompiler, past On-Screen) unregistered and unreachable, so // the controller got stuck at the last visible tab. A plain Row composes them all; // each selected chip's bringIntoView then scrolls it into view as the selector moves. - Row( - modifier = Modifier.fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(vertical = 6.dp, horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - val sections = settingsSections().filterNot { - // General is redundant per-game (redirects to Performance); Info only makes - // sense for a specific game, so hide it in the global settings. - (gameSpecific && it.category == SettingsCategory.General) || - (!gameSpecific && it.category == SettingsCategory.Info) - } - sections.forEach { section -> - val active = section.category == selected - FilterChip( - modifier = Modifier.height(44.dp) - .controllerFocusable( - "settings.chip.${section.category.name}", - RoundedCornerShape(13.dp), - onConfirm = { onSelect(section.category) }, - ), - selected = active, - onClick = { onSelect(section.category) }, - label = { Text(str(section.titleKey), maxLines = 1) }, - leadingIcon = { - Box(Modifier.size(20.dp), contentAlignment = Alignment.Center) { - Text( - section.glyph, - fontSize = 15.sp, - color = if (active) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - }, - shape = RoundedCornerShape(13.dp), - colors = FilterChipDefaults.filterChipColors( - containerColor = Color.Transparent, - labelColor = MaterialTheme.colorScheme.onSurfaceVariant, - iconColor = MaterialTheme.colorScheme.primary, - selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, - selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, - selectedLeadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - ) + val sections = settingsSections().filterNot { + // General is redundant per-game (redirects to Performance); Info only makes + // sense for a specific game, so hide it in the global settings. + (gameSpecific && it.category == SettingsCategory.General) || + (!gameSpecific && it.category == SettingsCategory.Info) || + (gameSpecific && it.category == SettingsCategory.About) + } + Box(Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()).padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Edge spacing belongs to the scrollable CONTENT, not its viewport. This + // lets the final chip scroll fully into view before the trailing inset. + Spacer(Modifier.size(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + sections.forEach { section -> + val active = section.category == selected + FilterChip( + modifier = Modifier.height(36.dp) + .controllerFocusable( + "settings.chip.${section.category.name}", + RoundedCornerShape(11.dp), + onConfirm = { onSelect(section.category) }, + ), + selected = active, + onClick = { onSelect(section.category) }, + label = { Text(str(section.titleKey), maxLines = 1, style = MaterialTheme.typography.labelLarge) }, + leadingIcon = { + Box(Modifier.size(17.dp), contentAlignment = Alignment.Center) { + Text( + section.glyph, + fontSize = 13.sp, + color = if (active) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + shape = RoundedCornerShape(11.dp), + colors = FilterChipDefaults.filterChipColors( + containerColor = Color.Transparent, + labelColor = MaterialTheme.colorScheme.onSurfaceVariant, + iconColor = MaterialTheme.colorScheme.primary, + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + selectedLeadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } + } + Spacer(Modifier.size(8.dp)) } } } @@ -218,6 +230,7 @@ private fun settingsSections() = listOf( SettingsSection(SettingsCategory.Skins, "tab.skins", "◈"), SettingsSection(SettingsCategory.Advanced, "tab.fixes", "⌘"), SettingsSection(SettingsCategory.Recompiler, "tab.recompiler", "⚙"), + SettingsSection(SettingsCategory.About, "about.title", "ⓘ"), ) @Composable @@ -235,6 +248,7 @@ private fun CategoryContent(category: SettingsCategory, viewModel: SettingsViewM SettingsCategory.Skins -> SkinsTab(viewModel.settings) SettingsCategory.Advanced -> FixesTab(viewModel.settings) SettingsCategory.Recompiler -> RecompilerTab(viewModel.settings) + SettingsCategory.About -> Unit } } @@ -252,4 +266,5 @@ private fun categoryTitle(category: SettingsCategory): String = when (category) SettingsCategory.Skins -> str("tab.skins") SettingsCategory.Advanced -> str("tab.fixes") SettingsCategory.Recompiler -> str("tab.recompiler") + SettingsCategory.About -> str("about.title") } diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/textures/TextureManagerScreen.kt b/platforms/android/app/src/main/java/com/armsx2/ui/textures/TextureManagerScreen.kt index d31d3a7ab0..c9bc9b7471 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/textures/TextureManagerScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/textures/TextureManagerScreen.kt @@ -53,7 +53,7 @@ fun TextureManagerScreen(onBack: () -> Unit, viewModel: TextureManagerViewModel ArmsTopBar( title = str("renderer.section.texturePacks"), subtitle = state.activeSerial ?: str("games.info.perGameSettings.body"), - leading = { RoundAction("‹", str("action.back"), onBack) }, + leading = { RoundAction("←", str("action.back"), onBack) }, actions = { RoundAction("+", str("action.import"), { folderPicker.launch(null) }) RoundAction("↻", str("games.card.refresh"), viewModel::refresh) diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/theme/Theme.kt b/platforms/android/app/src/main/java/com/armsx2/ui/theme/Theme.kt index 677cb9feac..57218319c3 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/theme/Theme.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/theme/Theme.kt @@ -68,6 +68,31 @@ object ToolbarPositionPreferences { } } +/** Visibility of optional library-home sections. Both default to visible so + * existing users keep the current layout until they explicitly opt out. */ +object LibraryChromePreferences { + private const val SearchKey = "ui.library.showSearch" + private const val RecentsKey = "ui.library.showRecents" + + val showSearch = mutableStateOf(false) + val showRecents = mutableStateOf(true) + + fun load() { + showSearch.value = MainActivityRuntime.prefs.getBoolean(SearchKey, false) + showRecents.value = MainActivityRuntime.prefs.getBoolean(RecentsKey, true) + } + + fun setShowSearch(value: Boolean) { + showSearch.value = value + MainActivityRuntime.prefs.edit { putBoolean(SearchKey, value) } + } + + fun setShowRecents(value: Boolean) { + showRecents.value = value + MainActivityRuntime.prefs.edit { putBoolean(RecentsKey, value) } + } +} + private val NightScheme = darkColorScheme( primary = ArmsBlueBright, onPrimary = Color(0xFF07101F),