add game cover grid option and better controller support

This commit is contained in:
izzy2lost
2026-02-08 19:06:27 -05:00
parent 1e7b978852
commit e6de9da878
10 changed files with 13639 additions and 8 deletions
+1
View File
@@ -105,6 +105,7 @@ dependencies {
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.documentfile:documentfile:1.0.1")
implementation("io.coil-kt:coil:2.7.0")
implementation("com.google.android.material:material:1.14.0-alpha07")
}
+2
View File
@@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="@string/app_name"
android:icon="@android:drawable/sym_def_app_icon"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+86
View File
@@ -6,8 +6,11 @@
#include <toml++/toml.h>
#include <android/log.h>
#include <android/asset_manager.h>
#include <android/asset_manager_jni.h>
#include <jni.h>
#include <climits>
#include <cstdio>
#include <cstdlib>
#include <cstring>
@@ -23,6 +26,10 @@ namespace {
constexpr const char* kLogTag = "xemu-android";
constexpr const char* kPrefsName = "x1box_prefs";
static JNIEnv* GetEnv();
static jobject GetActivity(JNIEnv* env);
static bool HasException(JNIEnv* env, const char* context);
static void LogInfo(const char* msg) {
__android_log_print(ANDROID_LOG_INFO, kLogTag, "%s", msg);
}
@@ -64,6 +71,83 @@ static bool IsTcgTuningEnabled() {
return !(value && value[0] == '0');
}
static void LoadGameControllerMappingsFromAssets() {
constexpr const char* kDbAssetName = "gamecontrollerdb.txt";
JNIEnv* env = GetEnv();
jobject activity = GetActivity(env);
if (!env || !activity) {
LogInfo("Controller mappings: JNI unavailable");
return;
}
jclass activityClass = env->GetObjectClass(activity);
jmethodID getAssets = env->GetMethodID(
activityClass, "getAssets", "()Landroid/content/res/AssetManager;");
if (!getAssets) {
LogInfo("Controller mappings: Activity.getAssets() not found");
return;
}
jobject assetManagerObj = env->CallObjectMethod(activity, getAssets);
if (HasException(env, "Activity.getAssets") || !assetManagerObj) {
LogInfo("Controller mappings: could not access AssetManager");
return;
}
AAssetManager* assetManager = AAssetManager_fromJava(env, assetManagerObj);
env->DeleteLocalRef(assetManagerObj);
if (!assetManager) {
LogInfo("Controller mappings: AssetManager bridge failed");
return;
}
AAsset* asset = AAssetManager_open(assetManager, kDbAssetName, AASSET_MODE_STREAMING);
if (!asset) {
LogInfo("Controller mappings: no custom gamecontrollerdb.txt in assets");
return;
}
const off_t length = AAsset_getLength(asset);
if (length <= 0 || length > INT_MAX) {
AAsset_close(asset);
LogError("Controller mappings: invalid gamecontrollerdb.txt size");
return;
}
std::vector<char> data(static_cast<size_t>(length));
size_t total = 0;
while (total < data.size()) {
const int read = AAsset_read(asset, data.data() + total,
static_cast<size_t>(data.size() - total));
if (read <= 0) {
break;
}
total += static_cast<size_t>(read);
}
AAsset_close(asset);
if (total == 0) {
LogError("Controller mappings: gamecontrollerdb.txt is empty");
return;
}
data.resize(total);
SDL_RWops* rw = SDL_RWFromConstMem(data.data(), static_cast<int>(data.size()));
if (!rw) {
LogErrorFmt("Controller mappings: SDL_RWFromConstMem failed: %s", SDL_GetError());
return;
}
const int added = SDL_GameControllerAddMappingsFromRW(rw, 1);
if (added < 0) {
LogErrorFmt("Controller mappings: failed to parse gamecontrollerdb.txt: %s", SDL_GetError());
return;
}
LogInfoInt("Controller mappings loaded from assets: %d", added);
}
static const char* GetTcgThreadFromEnv() {
const char* value = SDL_getenv("XEMU_ANDROID_TCG_THREAD");
if (value && strcmp(value, "single") == 0) {
@@ -434,6 +518,8 @@ extern "C" int SDL_main(int argc, char* argv[]) {
__android_log_print(ANDROID_LOG_ERROR, kLogTag, "SDL_Init failed: %s", SDL_GetError());
return 1;
}
SDL_GameControllerEventState(SDL_ENABLE);
LoadGameControllerMappingsFromAssets();
SetupFiles setup = SyncSetupFiles();
@@ -5,16 +5,23 @@ import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.Space
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 coil.load
import com.google.android.material.button.MaterialButton
import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.materialswitch.MaterialSwitch
import java.net.URLEncoder
import java.util.ArrayDeque
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
class GameLibraryActivity : AppCompatActivity() {
private data class GameEntry(
@@ -24,18 +31,39 @@ class GameLibraryActivity : AppCompatActivity() {
val sizeBytes: Long
)
private data class CoverEntry(
val collapsed: String,
val tokens: Set<String>,
val numericTokens: Set<String>,
val url: String
)
private val prefs by lazy { getSharedPreferences("x1box_prefs", MODE_PRIVATE) }
private val gameExts = setOf("iso", "xiso", "cso", "cci")
private val titleStopWords = setOf("the", "a", "an", "and", "of", "for", "in", "on", "to")
private val coverRepoBaseUrl = "https://raw.githubusercontent.com/izzy2lost/X1_Covers/main/"
private val boxArtCache = ConcurrentHashMap<String, String>()
private val boxArtMisses = ConcurrentHashMap.newKeySet<String>()
private val coverIndex = ConcurrentHashMap<String, String>()
private val coverCollapsedIndex = ConcurrentHashMap<String, String>()
private val coverEntries = ArrayList<CoverEntry>()
@Volatile private var coverIndexLoaded = false
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 gamesListContainer: LinearLayout
private lateinit var gamesGridContainer: LinearLayout
private lateinit var btnChangeFolder: MaterialButton
private lateinit var viewModeToggle: MaterialButtonToggleGroup
private lateinit var switchBoxArtLookup: MaterialSwitch
private var gamesFolderUri: Uri? = null
private var scanGeneration = 0
private var currentGames: List<GameEntry> = emptyList()
private var useCoverGrid = false
private var boxArtLookupEnabled = true
private val pickGamesFolder =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
@@ -43,6 +71,8 @@ class GameLibraryActivity : AppCompatActivity() {
persistUriPermission(uri)
gamesFolderUri = uri
prefs.edit().putString("gamesFolderUri", uri.toString()).apply()
boxArtCache.clear()
boxArtMisses.clear()
loadGames()
}
}
@@ -55,14 +85,43 @@ class GameLibraryActivity : AppCompatActivity() {
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)
gamesListContainer = findViewById(R.id.library_games_container)
gamesGridContainer = findViewById(R.id.library_games_grid_container)
btnChangeFolder = findViewById(R.id.btn_change_games_folder)
viewModeToggle = findViewById(R.id.library_view_mode_toggle)
switchBoxArtLookup = findViewById(R.id.switch_box_art_lookup)
gamesFolderUri = prefs.getString("gamesFolderUri", null)?.let(Uri::parse)
useCoverGrid = prefs.getBoolean("library_cover_grid", false)
boxArtLookupEnabled = prefs.getBoolean("library_box_art_lookup", true)
switchBoxArtLookup.isChecked = boxArtLookupEnabled
viewModeToggle.check(if (useCoverGrid) R.id.btn_view_grid else R.id.btn_view_list)
syncDisplayModeUi()
btnChangeFolder.setOnClickListener {
pickGamesFolder.launch(gamesFolderUri)
}
viewModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) {
return@addOnButtonCheckedListener
}
val nextGrid = checkedId == R.id.btn_view_grid
if (nextGrid == useCoverGrid) {
return@addOnButtonCheckedListener
}
useCoverGrid = nextGrid
prefs.edit().putBoolean("library_cover_grid", useCoverGrid).apply()
syncDisplayModeUi()
renderGames()
}
switchBoxArtLookup.setOnCheckedChangeListener { _, checked ->
boxArtLookupEnabled = checked
prefs.edit().putBoolean("library_box_art_lookup", checked).apply()
if (useCoverGrid) {
renderGames()
}
}
if (!isFolderReady(gamesFolderUri)) {
folderText.text = getString(R.string.library_no_folder)
@@ -78,7 +137,8 @@ class GameLibraryActivity : AppCompatActivity() {
val folderUri = gamesFolderUri
if (!isFolderReady(folderUri)) {
setLoading(false)
setGames(emptyList())
currentGames = emptyList()
renderGames()
folderText.text = getString(R.string.library_no_folder)
return
}
@@ -94,26 +154,44 @@ class GameLibraryActivity : AppCompatActivity() {
return@runOnUiThread
}
setLoading(false)
setGames(games)
currentGames = games
renderGames()
}
}.start()
}
private fun syncDisplayModeUi() {
switchBoxArtLookup.visibility = if (useCoverGrid) View.VISIBLE else View.GONE
gamesListContainer.visibility = if (useCoverGrid) View.GONE else View.VISIBLE
gamesGridContainer.visibility = if (useCoverGrid) View.VISIBLE else View.GONE
}
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()
private fun renderGames() {
val games = currentGames
syncDisplayModeUi()
gamesListContainer.removeAllViews()
gamesGridContainer.removeAllViews()
emptyText.visibility = if (games.isEmpty()) View.VISIBLE else View.GONE
if (games.isEmpty()) {
return
}
if (useCoverGrid) {
renderCoverGrid(games)
} else {
renderList(games)
}
}
private fun renderList(games: List<GameEntry>) {
val inflater = LayoutInflater.from(this)
for (game in games) {
val item = inflater.inflate(R.layout.item_game_entry, gamesContainer, false)
val item = inflater.inflate(R.layout.item_game_entry, gamesListContainer, 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)
@@ -123,10 +201,295 @@ class GameLibraryActivity : AppCompatActivity() {
pathText.text = getString(R.string.library_game_path, game.relativePath)
item.setOnClickListener { launchGame(game) }
gamesContainer.addView(item)
gamesListContainer.addView(item)
}
}
private fun renderCoverGrid(games: List<GameEntry>) {
val inflater = LayoutInflater.from(this)
var row: LinearLayout? = null
val spacingPx = dp(8)
for ((index, game) in games.withIndex()) {
if (index % 2 == 0) {
row = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
}
val rowLp = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
if (index > 0) {
rowLp.topMargin = dp(12)
}
gamesGridContainer.addView(row, rowLp)
}
val item = inflater.inflate(R.layout.item_game_cover, row, false)
val itemLp = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
if (index % 2 == 0) {
itemLp.marginEnd = spacingPx
} else {
itemLp.marginStart = spacingPx
}
row!!.addView(item, itemLp)
val nameText = item.findViewById<TextView>(R.id.game_cover_name_text)
val sizeText = item.findViewById<TextView>(R.id.game_cover_size_text)
val coverImage = item.findViewById<ImageView>(R.id.game_cover_image)
nameText.text = game.title
sizeText.text = getString(R.string.library_game_size, formatSize(game.sizeBytes))
item.setOnClickListener { launchGame(game) }
bindCoverArt(coverImage, game)
}
if (games.size % 2 != 0) {
val filler = Space(this)
val fillerLp = LinearLayout.LayoutParams(0, 0, 1f)
fillerLp.marginStart = spacingPx
row?.addView(filler, fillerLp)
}
}
private fun bindCoverArt(coverView: ImageView, game: GameEntry) {
coverView.tag = game.uri.toString()
coverView.setImageResource(android.R.drawable.ic_menu_report_image)
if (!boxArtLookupEnabled) {
return
}
val key = normalizeCoverKey(game.title)
val cachedUrl = boxArtCache[key]
if (cachedUrl != null) {
applyBoxArtToView(coverView, cachedUrl)
return
}
val url = lookupBoxArtUrl(game.title) ?: return
boxArtCache[key] = url
if (coverView.tag == game.uri.toString()) {
applyBoxArtToView(coverView, url)
}
}
private fun applyBoxArtToView(coverView: ImageView, url: String) {
coverView.load(url) {
crossfade(true)
placeholder(android.R.drawable.ic_menu_report_image)
error(android.R.drawable.ic_menu_report_image)
}
}
private fun lookupBoxArtUrl(title: String): String? {
ensureCoverIndexLoaded()
val candidates = linkedSetOf<String>()
val cleanTitle = normalizeLookupTitle(title)
val normalizedTitle = normalizeCoverKey(cleanTitle)
if (normalizedTitle.isBlank()) {
return null
}
if (boxArtMisses.contains(normalizedTitle)) {
return null
}
addCoverLookupCandidates(candidates, title)
addCoverLookupCandidates(candidates, cleanTitle)
addCoverLookupCandidates(candidates, cleanTitle.replace(":", ""))
addCoverLookupCandidates(candidates, cleanTitle.substringBefore(" - ").trim())
addCoverLookupCandidates(candidates, cleanTitle.substringBefore(":").trim())
for (key in candidates) {
val found = coverIndex[key]
if (!found.isNullOrBlank()) {
return found
}
}
for (key in candidates) {
val collapsed = collapseCoverKey(key)
if (collapsed.isBlank()) {
continue
}
val found = coverCollapsedIndex[collapsed]
if (!found.isNullOrBlank()) {
coverIndex.putIfAbsent(key, found)
return found
}
}
val fuzzyMatch = findClosestCoverUrl(candidates)
if (!fuzzyMatch.isNullOrBlank()) {
for (key in candidates) {
coverIndex.putIfAbsent(key, fuzzyMatch)
val collapsed = collapseCoverKey(key)
if (collapsed.isNotBlank()) {
coverCollapsedIndex.putIfAbsent(collapsed, fuzzyMatch)
}
}
return fuzzyMatch
}
boxArtMisses.add(normalizedTitle)
return null
}
private fun addCoverLookupCandidates(out: MutableSet<String>, raw: String) {
if (raw.isBlank()) {
return
}
val normalized = normalizeCoverKey(raw)
if (normalized.isBlank()) {
return
}
out.add(normalized)
out.add(stripTrailingRegion(normalized))
}
private fun ensureCoverIndexLoaded() {
if (coverIndexLoaded) {
return
}
try {
val lines = assets.open("X1_Covers.txt").bufferedReader().use { it.readLines() }
val seenEntries = HashSet<String>()
for (line in lines) {
val fileName = line.trim()
if (fileName.isEmpty() || !fileName.endsWith(".png", ignoreCase = true)) {
continue
}
val gameName = fileName.removeSuffix(".png").trim()
val encoded = URLEncoder.encode(fileName, "UTF-8").replace("+", "%20")
val url = coverRepoBaseUrl + encoded
val exactKey = normalizeCoverKey(gameName)
val strippedKey = stripTrailingRegion(exactKey)
if (exactKey.isNotEmpty()) {
coverIndex.putIfAbsent(exactKey, url)
}
if (strippedKey.isNotEmpty()) {
coverIndex.putIfAbsent(strippedKey, url)
}
val canonical = if (strippedKey.isNotEmpty()) strippedKey else exactKey
val collapsed = collapseCoverKey(canonical)
if (collapsed.isNotEmpty()) {
coverCollapsedIndex.putIfAbsent(collapsed, url)
}
if (canonical.isNotEmpty() && seenEntries.add("$canonical|$url")) {
val tokens = tokenizeCoverKey(canonical)
coverEntries.add(
CoverEntry(
collapsed = collapsed,
tokens = tokens,
numericTokens = tokens.filterTo(HashSet()) { token -> token.any(Char::isDigit) },
url = url
)
)
}
}
} catch (_: Exception) {
// Keep empty index; grid will show placeholders if the asset is unavailable.
}
coverIndexLoaded = true
}
private fun normalizeLookupTitle(input: String): String {
var title = input.trim()
title = title.replace('_', ' ')
title = title.replace(Regex("\\[[^\\]]*\\]"), " ")
title = title.replace(Regex("\\([^\\)]*\\)"), " ")
title = title.replace(Regex("\\s+"), " ").trim()
return title
}
private fun normalizeCoverKey(input: String): String {
var title = input.lowercase(Locale.ROOT).trim()
title = title.replace('_', ' ')
title = title.replace('\u2019', '\'')
title = title.replace("", "'")
title = title.replace(Regex("\\s+"), " ")
return title
}
private fun stripTrailingRegion(input: String): String {
return input.replace(Regex("\\s*\\([^\\)]*\\)\\s*$"), "").trim()
}
private fun collapseCoverKey(input: String): String {
return normalizeCoverKey(input).replace(Regex("[^a-z0-9]+"), "")
}
private fun tokenizeCoverKey(input: String): Set<String> {
return normalizeCoverKey(input)
.replace(Regex("[^a-z0-9]+"), " ")
.split(' ')
.asSequence()
.map { it.trim() }
.filter { it.length >= 2 }
.filter { it !in titleStopWords }
.toSet()
}
private fun findClosestCoverUrl(candidates: Set<String>): String? {
var bestUrl: String? = null
var bestScore = 0
for (candidate in candidates) {
val collapsed = collapseCoverKey(candidate)
val tokens = tokenizeCoverKey(candidate)
if (collapsed.isBlank() || tokens.isEmpty()) {
continue
}
val numericTokens = tokens.filterTo(HashSet()) { token -> token.any(Char::isDigit) }
for (entry in coverEntries) {
val score = scoreCoverMatch(collapsed, tokens, numericTokens, entry)
if (score > bestScore) {
bestScore = score
bestUrl = entry.url
}
}
}
return if (bestScore >= 55) bestUrl else null
}
private fun scoreCoverMatch(
candidateCollapsed: String,
candidateTokens: Set<String>,
candidateNumericTokens: Set<String>,
entry: CoverEntry
): Int {
if (candidateCollapsed == entry.collapsed) {
return 100
}
if (candidateNumericTokens.isNotEmpty() &&
entry.numericTokens.isNotEmpty() &&
candidateNumericTokens != entry.numericTokens
) {
return 0
}
val overlapCount = candidateTokens.count { token -> entry.tokens.contains(token) }
if (overlapCount == 0) {
return 0
}
val maxTokenCount = maxOf(candidateTokens.size, entry.tokens.size)
var score = (overlapCount * 70) / maxTokenCount
if (candidateCollapsed.contains(entry.collapsed) || entry.collapsed.contains(candidateCollapsed)) {
score += 20
}
val lengthDelta = kotlin.math.abs(candidateCollapsed.length - entry.collapsed.length)
if (lengthDelta <= 4) {
score += 10
} else if (lengthDelta <= 10) {
score += 5
}
return score
}
private fun launchGame(game: GameEntry) {
persistUriPermission(game.uri)
prefs.edit()
@@ -237,4 +600,9 @@ class GameLibraryActivity : AppCompatActivity() {
}
return uri.toString()
}
private fun dp(value: Int): Int {
return (value * resources.displayMetrics.density).toInt()
}
}
@@ -76,6 +76,45 @@
app:iconGravity="textStart"
app:iconPadding="8dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/library_view_mode_toggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:selectionRequired="true"
app:singleSelection="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_view_list"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/library_view_list"
app:icon="@android:drawable/ic_menu_agenda"
app:iconGravity="textStart"
app:iconPadding="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_view_grid"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/library_view_cover_grid"
app:icon="@android:drawable/ic_dialog_dialer"
app:iconGravity="textStart"
app:iconPadding="8dp" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switch_box_art_lookup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/library_box_art_lookup"
android:textColor="@color/xemu_text_muted" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -119,6 +158,14 @@
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical" />
<LinearLayout
android:id="@+id/library_games_grid_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
@@ -0,0 +1,49 @@
<?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:clickable="true"
android:focusable="true"
app:cardBackgroundColor="@color/xemu_surface_variant"
app:cardCornerRadius="20dp"
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:orientation="vertical"
android:padding="10dp">
<ImageView
android:id="@+id/game_cover_image"
android:layout_width="match_parent"
android:layout_height="190dp"
android:background="@drawable/setup_wizard_path_background"
android:contentDescription="@string/library_open_game"
android:scaleType="centerCrop"
android:src="@android:drawable/ic_menu_report_image" />
<TextView
android:id="@+id/game_cover_name_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:maxLines="2"
android:textAppearance="@style/TextAppearance.Material3.TitleSmall" />
<TextView
android:id="@+id/game_cover_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" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
@@ -30,6 +30,9 @@
<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_view_list">List</string>
<string name="library_view_cover_grid">Cover Grid</string>
<string name="library_box_art_lookup">Look up box art online</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>