add game folder for game list

This commit is contained in:
izzy2lost
2026-02-07 21:54:55 -05:00
parent a9403b1446
commit 1e7b978852
11 changed files with 602 additions and 59 deletions
+1
View File
@@ -104,6 +104,7 @@ dependencies {
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.documentfile:documentfile:1.0.1")
implementation("com.google.android.material:material:1.14.0-alpha07")
}
+5
View File
@@ -35,5 +35,10 @@
android:screenOrientation="fullSensor"
android:exported="false" />
<activity
android:name=".GameLibraryActivity"
android:screenOrientation="fullSensor"
android:exported="false" />
</application>
</manifest>
+59 -3
View File
@@ -9,6 +9,8 @@
#include <jni.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <string>
#include <vector>
@@ -57,6 +59,42 @@ static bool FileExists(const std::string& path) {
return stat(path.c_str(), &st) == 0;
}
static bool IsTcgTuningEnabled() {
const char* value = SDL_getenv("XEMU_ANDROID_TCG_TUNING");
return !(value && value[0] == '0');
}
static const char* GetTcgThreadFromEnv() {
const char* value = SDL_getenv("XEMU_ANDROID_TCG_THREAD");
if (value && strcmp(value, "single") == 0) {
return "single";
}
return "multi";
}
static int GetTcgTbSizeFromEnv() {
constexpr int kDefaultTbSize = 128;
constexpr int kMinTbSize = 32;
constexpr int kMaxTbSize = 512;
const char* value = SDL_getenv("XEMU_ANDROID_TCG_TB_SIZE");
if (!value || value[0] == '\0') {
return kDefaultTbSize;
}
char* end = nullptr;
long parsed = strtol(value, &end, 10);
if (end == value || (end && *end != '\0')) {
return kDefaultTbSize;
}
if (parsed < kMinTbSize) {
parsed = kMinTbSize;
} else if (parsed > kMaxTbSize) {
parsed = kMaxTbSize;
}
return static_cast<int>(parsed);
}
static JNIEnv* GetEnv() {
return static_cast<JNIEnv*>(SDL_AndroidGetJNIEnv());
}
@@ -221,6 +259,15 @@ static bool WriteConfigToml(const std::string& config_path,
if (!android->contains("force_cpu_blit")) {
android->insert_or_assign("force_cpu_blit", false);
}
if (!android->contains("tcg_tuning")) {
android->insert_or_assign("tcg_tuning", true);
}
if (!android->contains("tcg_thread")) {
android->insert_or_assign("tcg_thread", "multi");
}
if (!android->contains("tcg_tb_size")) {
android->insert_or_assign("tcg_tb_size", 128);
}
files->insert_or_assign("bootrom_path", mcpx);
files->insert_or_assign("flashrom_path", flash);
@@ -454,9 +501,18 @@ extern "C" int SDL_main(int argc, char* argv[]) {
std::vector<std::string> arg_storage;
arg_storage.emplace_back("xemu");
arg_storage.emplace_back("-accel");
arg_storage.emplace_back("tcg,thread=multi,tb-size=256");
LogInfo("SDL_main: forcing TCG accel thread=multi tb-size=256");
if (IsTcgTuningEnabled()) {
const char* tcg_thread = GetTcgThreadFromEnv();
int tcg_tb_size = GetTcgTbSizeFromEnv();
char accel_opts[64];
snprintf(accel_opts, sizeof(accel_opts), "tcg,thread=%s,tb-size=%d",
tcg_thread, tcg_tb_size);
arg_storage.emplace_back("-accel");
arg_storage.emplace_back(accel_opts);
LogInfoFmt("SDL_main: using accel %s", accel_opts);
} else {
LogInfo("SDL_main: TCG tuning disabled");
}
std::vector<char*> xemu_argv;
xemu_argv.reserve(arg_storage.size() + 1);
@@ -198,6 +198,9 @@ bool xemu_settings_load(void)
xemu_settings_apply_defaults();
error_msg.clear();
setenv("XEMU_ANDROID_FORCE_CPU_BLIT", "0", 1);
setenv("XEMU_ANDROID_TCG_TUNING", "1", 1);
setenv("XEMU_ANDROID_TCG_THREAD", "multi", 1);
setenv("XEMU_ANDROID_TCG_TB_SIZE", "128", 1);
const char *path = xemu_settings_get_path();
if (!path || *path == '\0') {
@@ -256,11 +259,37 @@ bool xemu_settings_load(void)
}
// Android-specific settings
if (auto force_cpu_blit = android_cfg["force_cpu_blit"].value<bool>()) {
setenv("XEMU_ANDROID_FORCE_CPU_BLIT", *force_cpu_blit ? "1" : "0", 1);
}
if (auto egl_offscreen = android_cfg["egl_offscreen"].value<bool>()) {
if (!*egl_offscreen) {
setenv("XEMU_ANDROID_EGL_OFFSCREEN", "0", 1);
}
}
if (auto tcg_tuning = android_cfg["tcg_tuning"].value<bool>()) {
setenv("XEMU_ANDROID_TCG_TUNING", *tcg_tuning ? "1" : "0", 1);
}
if (auto tcg_thread = android_cfg["tcg_thread"].value<std::string>()) {
if (*tcg_thread == "single" || *tcg_thread == "multi") {
setenv("XEMU_ANDROID_TCG_THREAD", tcg_thread->c_str(), 1);
} else {
__android_log_print(ANDROID_LOG_WARN, "xemu-android",
"Ignoring android.tcg_thread=%s (expected single|multi)",
tcg_thread->c_str());
}
}
if (auto tcg_tb_size = android_cfg["tcg_tb_size"].value<int64_t>()) {
int tb_size = (int)*tcg_tb_size;
if (tb_size < 32) {
tb_size = 32;
} else if (tb_size > 512) {
tb_size = 512;
}
char tb_size_str[16];
snprintf(tb_size_str, sizeof(tb_size_str), "%d", tb_size);
setenv("XEMU_ANDROID_TCG_TB_SIZE", tb_size_str, 1);
}
// System file paths
if (auto bootrom = sys_files["bootrom_path"].value<std::string>()) {
@@ -0,0 +1,240 @@
package com.izzy2lost.x1box
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.documentfile.provider.DocumentFile
import com.google.android.material.button.MaterialButton
import java.util.ArrayDeque
import java.util.Locale
class GameLibraryActivity : AppCompatActivity() {
private data class GameEntry(
val title: String,
val uri: Uri,
val relativePath: String,
val sizeBytes: Long
)
private val prefs by lazy { getSharedPreferences("x1box_prefs", MODE_PRIVATE) }
private val gameExts = setOf("iso", "xiso", "cso", "cci")
private lateinit var folderText: TextView
private lateinit var loadingSpinner: ProgressBar
private lateinit var loadingText: TextView
private lateinit var emptyText: TextView
private lateinit var gamesContainer: LinearLayout
private lateinit var btnChangeFolder: MaterialButton
private var gamesFolderUri: Uri? = null
private var scanGeneration = 0
private val pickGamesFolder =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
persistUriPermission(uri)
gamesFolderUri = uri
prefs.edit().putString("gamesFolderUri", uri.toString()).apply()
loadGames()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_game_library)
folderText = findViewById(R.id.library_folder_text)
loadingSpinner = findViewById(R.id.library_loading)
loadingText = findViewById(R.id.library_loading_text)
emptyText = findViewById(R.id.library_empty_text)
gamesContainer = findViewById(R.id.library_games_container)
btnChangeFolder = findViewById(R.id.btn_change_games_folder)
gamesFolderUri = prefs.getString("gamesFolderUri", null)?.let(Uri::parse)
btnChangeFolder.setOnClickListener {
pickGamesFolder.launch(gamesFolderUri)
}
if (!isFolderReady(gamesFolderUri)) {
folderText.text = getString(R.string.library_no_folder)
Toast.makeText(this, getString(R.string.setup_pick_disc), Toast.LENGTH_SHORT).show()
pickGamesFolder.launch(gamesFolderUri)
return
}
loadGames()
}
private fun loadGames() {
val folderUri = gamesFolderUri
if (!isFolderReady(folderUri)) {
setLoading(false)
setGames(emptyList())
folderText.text = getString(R.string.library_no_folder)
return
}
folderText.text = getString(R.string.library_folder_value, formatTreeLabel(folderUri!!))
setLoading(true)
val generation = ++scanGeneration
Thread {
val games = scanFolderForGames(folderUri)
runOnUiThread {
if (generation != scanGeneration) {
return@runOnUiThread
}
setLoading(false)
setGames(games)
}
}.start()
}
private fun setLoading(loading: Boolean) {
loadingSpinner.visibility = if (loading) View.VISIBLE else View.GONE
loadingText.visibility = if (loading) View.VISIBLE else View.GONE
}
private fun setGames(games: List<GameEntry>) {
gamesContainer.removeAllViews()
emptyText.visibility = if (games.isEmpty()) View.VISIBLE else View.GONE
if (games.isEmpty()) {
return
}
val inflater = LayoutInflater.from(this)
for (game in games) {
val item = inflater.inflate(R.layout.item_game_entry, gamesContainer, false)
val nameText = item.findViewById<TextView>(R.id.game_name_text)
val sizeText = item.findViewById<TextView>(R.id.game_size_text)
val pathText = item.findViewById<TextView>(R.id.game_path_text)
nameText.text = game.title
sizeText.text = getString(R.string.library_game_size, formatSize(game.sizeBytes))
pathText.text = getString(R.string.library_game_path, game.relativePath)
item.setOnClickListener { launchGame(game) }
gamesContainer.addView(item)
}
}
private fun launchGame(game: GameEntry) {
persistUriPermission(game.uri)
prefs.edit()
.putString("dvdUri", game.uri.toString())
.remove("dvdPath")
.putBoolean("skip_game_picker", false)
.apply()
startActivity(Intent(this, MainActivity::class.java))
finish()
}
private fun scanFolderForGames(folderUri: Uri): List<GameEntry> {
val root = DocumentFile.fromTreeUri(this, folderUri) ?: return emptyList()
val stack = ArrayDeque<Pair<DocumentFile, String>>()
stack.add(root to "")
val games = ArrayList<GameEntry>()
while (stack.isNotEmpty()) {
val (node, prefix) = stack.removeLast()
val files = try {
node.listFiles()
} catch (_: Exception) {
emptyArray()
}
for (child in files) {
val name = child.name ?: continue
if (child.isDirectory) {
stack.add(child to (prefix + name + "/"))
continue
}
if (!child.isFile || !isSupportedGame(name)) {
continue
}
games.add(
GameEntry(
title = toGameTitle(name),
uri = child.uri,
relativePath = prefix + name,
sizeBytes = child.length()
)
)
}
}
games.sortBy { it.title.lowercase(Locale.ROOT) }
return games
}
private fun isSupportedGame(name: String): Boolean {
val lower = name.lowercase(Locale.ROOT)
if (lower.endsWith(".xiso.iso")) {
return true
}
val ext = lower.substringAfterLast('.', "")
return ext.isNotEmpty() && gameExts.contains(ext)
}
private fun toGameTitle(fileName: String): String {
val lower = fileName.lowercase(Locale.ROOT)
return when {
lower.endsWith(".xiso.iso") -> fileName.dropLast(".xiso.iso".length)
fileName.contains('.') -> fileName.substringBeforeLast('.')
else -> fileName
}
}
private fun formatSize(bytes: Long): String {
if (bytes <= 0L) {
return "Unknown"
}
val units = arrayOf("B", "KB", "MB", "GB", "TB")
var value = bytes.toDouble()
var unitIndex = 0
while (value >= 1024.0 && unitIndex < units.lastIndex) {
value /= 1024.0
unitIndex++
}
return String.format(Locale.US, "%.1f %s", value, units[unitIndex])
}
private fun isFolderReady(uri: Uri?): Boolean {
if (uri == null || !hasPersistedReadPermission(uri)) {
return false
}
val root = DocumentFile.fromTreeUri(this, uri) ?: return false
return root.exists() && root.isDirectory
}
private fun persistUriPermission(uri: Uri) {
val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
try {
contentResolver.takePersistableUriPermission(uri, flags)
} catch (_: SecurityException) {
}
}
private fun hasPersistedReadPermission(uri: Uri): Boolean {
return contentResolver.persistedUriPermissions.any { perm ->
perm.uri == uri && perm.isReadPermission
}
}
private fun formatTreeLabel(uri: Uri): String {
val name = DocumentFile.fromTreeUri(this, uri)?.name
if (!name.isNullOrBlank()) {
return name
}
return uri.toString()
}
}
@@ -16,21 +16,23 @@ class LauncherActivity : Activity() {
val flashUriStr = prefs.getString("flashUri", null)
val hddUriStr = prefs.getString("hddUri", null)
val dvdUriStr = prefs.getString("dvdUri", null)
val gamesFolderUriStr = prefs.getString("gamesFolderUri", null)
val mcpxPath = prefs.getString("mcpxPath", null)
val flashPath = prefs.getString("flashPath", null)
val hddPath = prefs.getString("hddPath", null)
val dvdPath = prefs.getString("dvdPath", null)
val skipGamePicker = prefs.getBoolean("skip_game_picker", false)
val mcpxUri = mcpxUriStr?.let(Uri::parse)
val flashUri = flashUriStr?.let(Uri::parse)
val hddUri = hddUriStr?.let(Uri::parse)
val dvdUri = dvdUriStr?.let(Uri::parse)
val gamesFolderUri = gamesFolderUriStr?.let(Uri::parse)
val hasMcpx = hasLocalFile(mcpxPath) || (mcpxUri != null && hasPersistedReadPermission(mcpxUri))
val hasFlash = hasLocalFile(flashPath) || (flashUri != null && hasPersistedReadPermission(flashUri))
val hasHdd = hasLocalFile(hddPath) || (hddUri != null && hasPersistedReadPermission(hddUri))
val hasDvd = hasLocalFile(dvdPath) || (dvdUri != null && hasPersistedReadPermission(dvdUri))
val hasGamesFolder = gamesFolderUri != null && hasPersistedReadPermission(gamesFolderUri)
val editor = prefs.edit()
var clearedCore = false
@@ -67,6 +69,10 @@ class LauncherActivity : Activity() {
editor.remove("dvdPath")
clearedOptional = true
}
if (!hasGamesFolder && gamesFolderUriStr != null) {
editor.remove("gamesFolderUri")
clearedCore = true
}
if (clearedCore) {
setupComplete = false
editor.putBoolean("setup_complete", false)
@@ -76,14 +82,8 @@ class LauncherActivity : Activity() {
editor.apply()
}
val needsSetup = !setupComplete || !hasMcpx || !hasFlash || !hasHdd
val needsGamePicker = !skipGamePicker && !hasDvd
val next =
if (needsSetup || needsGamePicker) {
SetupWizardActivity::class.java
} else {
MainActivity::class.java
}
val needsSetup = !setupComplete || !hasMcpx || !hasFlash || !hasHdd || !hasGamesFolder
val next = if (needsSetup) SetupWizardActivity::class.java else GameLibraryActivity::class.java
startActivity(Intent(this, next))
finish()
@@ -10,6 +10,7 @@ import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.documentfile.provider.DocumentFile
import com.google.android.material.button.MaterialButton
import java.io.File
import java.io.FileOutputStream
@@ -38,24 +39,16 @@ class SetupWizardActivity : AppCompatActivity() {
private var mcpxUri: Uri? = null
private var flashUri: Uri? = null
private var hddUri: Uri? = null
private var dvdUri: Uri? = null
private var gamesFolderUri: Uri? = null
private var mcpxPath: String? = null
private var flashPath: String? = null
private var hddPath: String? = null
private var dvdPath: String? = null
private var currentStep = 0
private var isCopying = false
private val mcpxExts = setOf("bin", "rom", "img")
private val flashExts = setOf("bin", "rom", "img")
private val hddExts = setOf("qcow2", "img")
private val discExts = setOf("iso", "xiso", "cso", "cci")
private val discMimes = setOf(
"application/x-iso9660-image",
"application/x-cd-image",
"application/x-iso9660",
"application/vnd.iso-image"
)
private val pickMcpx =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
@@ -126,26 +119,14 @@ class SetupWizardActivity : AppCompatActivity() {
}
}
private val pickDisc =
registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
private val pickGamesFolder =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
if (!isAllowedFile(uri, discExts, discMimes)) {
showExtensionError(discExts)
return@registerForActivityResult
}
persistUriPermission(uri)
dvdUri = uri
prefs.edit().putString("dvdUri", uri.toString()).apply()
copyUriAsync(uri, "dvd.iso") { path ->
if (path != null) {
dvdPath = path
prefs.edit().putString("dvdPath", path).apply()
} else {
Toast.makeText(this, "Failed to copy game disc image", Toast.LENGTH_LONG).show()
}
updateDiscSelection()
updateButtons()
}
gamesFolderUri = uri
prefs.edit().putString("gamesFolderUri", uri.toString()).apply()
updateDiscSelection()
updateButtons()
}
}
@@ -155,16 +136,15 @@ class SetupWizardActivity : AppCompatActivity() {
mcpxPath = loadLocalPath("mcpxPath")
flashPath = loadLocalPath("flashPath")
hddPath = loadLocalPath("hddPath")
dvdPath = loadLocalPath("dvdPath")
mcpxUri = prefs.getString("mcpxUri", null)?.let(Uri::parse)
flashUri = prefs.getString("flashUri", null)?.let(Uri::parse)
hddUri = prefs.getString("hddUri", null)?.let(Uri::parse)
dvdUri = prefs.getString("dvdUri", null)?.let(Uri::parse)
gamesFolderUri = prefs.getString("gamesFolderUri", null)?.let(Uri::parse)
val coreReady = isFileReady(mcpxPath) && isFileReady(flashPath) && isFileReady(hddPath)
val discReady = isFileReady(dvdPath)
if (prefs.getBoolean("setup_complete", false) && coreReady && discReady) {
goToMain()
val gamesFolderReady = hasGamesFolderReady()
if (prefs.getBoolean("setup_complete", false) && coreReady && gamesFolderReady) {
goToLibrary()
return
}
@@ -204,7 +184,7 @@ class SetupWizardActivity : AppCompatActivity() {
btnPickMcpx.setOnClickListener { pickMcpx.launch(arrayOf("application/octet-stream")) }
btnPickFlash.setOnClickListener { pickFlash.launch(arrayOf("application/octet-stream")) }
btnPickHdd.setOnClickListener { pickHdd.launch(arrayOf("application/x-qcow2", "application/octet-stream")) }
btnPickDisc.setOnClickListener { pickDisc.launch(arrayOf("*/*")) }
btnPickDisc.setOnClickListener { pickGamesFolder.launch(gamesFolderUri) }
btnBack.setOnClickListener { showStep(currentStep - 1) }
btnNext.setOnClickListener {
@@ -233,7 +213,7 @@ class SetupWizardActivity : AppCompatActivity() {
updateHddSelection()
updateDiscSelection()
val startStep = if (coreReady && !discReady) pages.size - 1 else 0
val startStep = if (coreReady && !gamesFolderReady) pages.size - 1 else 0
showStep(startStep)
}
@@ -271,7 +251,7 @@ class SetupWizardActivity : AppCompatActivity() {
0 -> isFileReady(mcpxPath)
1 -> isFileReady(flashPath)
2 -> isFileReady(hddPath)
else -> true
else -> hasGamesFolderReady()
}
}
@@ -291,22 +271,21 @@ class SetupWizardActivity : AppCompatActivity() {
}
private fun updateDiscSelection() {
val value = dvdPath ?: dvdUri?.toString() ?: getString(R.string.setup_not_set)
val value = gamesFolderUri?.let { formatTreeLabel(it) } ?: getString(R.string.setup_not_set)
discPathText.text = getString(R.string.setup_disc_value, value)
}
private fun finishSetup() {
val skipGamePicker = !isFileReady(dvdPath)
prefs.edit()
.putBoolean("setup_complete", true)
.putBoolean("skip_game_picker", skipGamePicker)
.putBoolean("skip_game_picker", false)
.apply()
goToMain()
goToLibrary()
}
private fun goToMain() {
startActivity(Intent(this, MainActivity::class.java))
private fun goToLibrary() {
startActivity(Intent(this, GameLibraryActivity::class.java))
finish()
}
@@ -331,6 +310,29 @@ class SetupWizardActivity : AppCompatActivity() {
return path != null && File(path).isFile
}
private fun hasGamesFolderReady(): Boolean {
val uri = gamesFolderUri ?: return false
if (!hasPersistedReadPermission(uri)) {
return false
}
val root = DocumentFile.fromTreeUri(this, uri) ?: return false
return root.exists() && root.isDirectory
}
private fun hasPersistedReadPermission(uri: Uri): Boolean {
return contentResolver.persistedUriPermissions.any { perm ->
perm.uri == uri && perm.isReadPermission
}
}
private fun formatTreeLabel(uri: Uri): String {
val name = DocumentFile.fromTreeUri(this, uri)?.name
if (!name.isNullOrBlank()) {
return name
}
return uri.toString()
}
private fun copyUriAsync(uri: Uri, destName: String, onDone: (String?) -> Unit) {
if (isCopying) return
isCopying = true
@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/setup_wizard_background"
android:fitsSystemWindows="true">
<androidx.core.widget.NestedScrollView
android:id="@+id/library_scroll"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipToPadding="false"
android:fillViewport="true"
android:padding="24dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardBackgroundColor="@color/xemu_surface"
app:cardCornerRadius="28dp"
app:cardElevation="8dp"
app:strokeColor="@color/xemu_outline_variant"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/library_title"
android:textAppearance="@style/TextAppearance.Material3.HeadlineMedium" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/library_subtitle"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="@color/xemu_text_muted" />
<TextView
android:id="@+id/library_folder_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/setup_wizard_path_background"
android:ellipsize="end"
android:maxLines="2"
android:text="@string/library_no_folder"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@color/xemu_text_muted" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_change_games_folder"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/library_change_folder"
app:icon="@android:drawable/ic_menu_set_as"
app:iconGravity="textStart"
app:iconPadding="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<ProgressBar
android:id="@+id/library_loading"
style="?android:attr/progressBarStyleSmall"
android:layout_width="20dp"
android:layout_height="20dp"
android:indeterminateTint="@color/xemu_green"
android:visibility="gone" />
<TextView
android:id="@+id/library_loading_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:text="@string/library_loading_games"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="@color/xemu_text_muted"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/library_empty_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:background="@drawable/setup_wizard_path_background"
android:text="@string/library_empty_games"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
android:textColor="@color/xemu_text_muted"
android:visibility="gone" />
<LinearLayout
android:id="@+id/library_games_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -242,7 +242,7 @@
android:layout_height="40dp"
android:layout_gravity="center_horizontal"
android:contentDescription="@string/setup_disc_title"
android:src="@android:drawable/ic_menu_slideshow"
android:src="@android:drawable/ic_menu_agenda"
android:tint="@color/xemu_green" />
<TextView
@@ -268,7 +268,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/setup_pick_disc"
app:icon="@android:drawable/ic_menu_slideshow"
app:icon="@android:drawable/ic_menu_agenda"
app:iconGravity="textStart"
app:iconPadding="8dp" />
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="@color/xemu_surface_variant"
app:cardCornerRadius="22dp"
app:cardElevation="0dp"
app:rippleColor="@color/xemu_green"
app:strokeColor="@color/xemu_outline"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="16dp">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="@string/library_open_game"
android:src="@android:drawable/ic_menu_slideshow"
android:tint="@color/xemu_green" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/game_name_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium" />
<TextView
android:id="@+id/game_size_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@color/xemu_text_muted" />
<TextView
android:id="@+id/game_path_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:ellipsize="middle"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:textColor="@color/xemu_text_muted" />
</LinearLayout>
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:contentDescription="@string/library_open_game"
android:src="@android:drawable/ic_media_play"
android:tint="@color/xemu_green" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
+15 -4
View File
@@ -22,8 +22,19 @@
<string name="setup_pick_hdd">Choose Hard Disk</string>
<string name="setup_hdd_value">Hard Disk: %1$s</string>
<string name="setup_disc_title">Game Disc (Optional)</string>
<string name="setup_disc_body">Choose an Xbox game disc image to boot.</string>
<string name="setup_pick_disc">Choose Game Disc</string>
<string name="setup_disc_value">Game Disc: %1$s</string>
<string name="setup_disc_title">Games Folder</string>
<string name="setup_disc_body">Pick your games folder using SAF. X1 BOX will scan it for playable images.</string>
<string name="setup_pick_disc">Choose Games Folder</string>
<string name="setup_disc_value">Games Folder: %1$s</string>
<string name="library_title">Game Library</string>
<string name="library_subtitle">Pick a game to launch in xemu.</string>
<string name="library_change_folder">Change Folder</string>
<string name="library_folder_value">Folder: %1$s</string>
<string name="library_no_folder">No folder selected</string>
<string name="library_loading_games">Scanning games...</string>
<string name="library_empty_games">No supported games found in this folder.</string>
<string name="library_game_size">Size: %1$s</string>
<string name="library_game_path">Path: %1$s</string>
<string name="library_open_game">Open</string>
</resources>