mirror of
https://github.com/izzy2lost/xemu.git
synced 2026-07-06 00:20:22 -07:00
add game cover grid option and better controller support
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user