Add automatic cover art fetching from LibRetro thumbnails
Downloads box art from libretro-thumbnails GitHub repos for all supported consoles (PS1, NES, SNES, N64, Genesis, PS2). Art is fetched in the background on first scan and cached locally. Also fixes multi-track .bin files showing as separate game entries.
This commit is contained in:
@@ -80,6 +80,9 @@ dependencies {
|
||||
// DataStore
|
||||
implementation(libs.datastore.prefs)
|
||||
|
||||
// Image loading
|
||||
implementation(libs.coil.compose)
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
105
app/src/main/java/com/lazy/emulate/data/CoverArtManager.kt
Normal file
105
app/src/main/java/com/lazy/emulate/data/CoverArtManager.kt
Normal file
@@ -0,0 +1,105 @@
|
||||
package com.lazy.emulate.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.lazy.emulate.emulation.ConsoleType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.net.URLEncoder
|
||||
|
||||
class CoverArtManager(private val context: Context) {
|
||||
|
||||
private val cacheDir = File(context.filesDir, "cover_art")
|
||||
|
||||
init {
|
||||
cacheDir.mkdirs()
|
||||
}
|
||||
|
||||
fun getCachedPath(gameTitle: String, consoleType: ConsoleType): String? {
|
||||
val file = cacheFile(gameTitle, consoleType)
|
||||
return if (file.exists()) file.absolutePath else null
|
||||
}
|
||||
|
||||
suspend fun fetchCoverArt(gameTitle: String, consoleType: ConsoleType): String? {
|
||||
val cached = getCachedPath(gameTitle, consoleType)
|
||||
if (cached != null) return cached
|
||||
|
||||
if (consoleType.libretroThumbnailRepo.isEmpty()) return null
|
||||
|
||||
val dest = cacheFile(gameTitle, consoleType)
|
||||
|
||||
// Try the title as-is first, then cleaned up
|
||||
val namesToTry = buildList {
|
||||
add(gameTitle)
|
||||
val cleaned = cleanTitle(gameTitle)
|
||||
if (cleaned != gameTitle) add(cleaned)
|
||||
}
|
||||
|
||||
for (name in namesToTry) {
|
||||
val encoded = URLEncoder.encode(name, "UTF-8")
|
||||
.replace("+", "%20")
|
||||
val url = "$BASE_URL/${consoleType.libretroThumbnailRepo}/master/Named_Boxarts/$encoded.png"
|
||||
if (downloadFile(url, dest)) {
|
||||
return dest.absolutePath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun cacheFile(gameTitle: String, consoleType: ConsoleType): File {
|
||||
val safeTitle = gameTitle.replace(Regex("[^a-zA-Z0-9._\\- ]"), "_")
|
||||
val dir = File(cacheDir, consoleType.name)
|
||||
dir.mkdirs()
|
||||
return File(dir, "$safeTitle.png")
|
||||
}
|
||||
|
||||
private suspend fun downloadFile(urlStr: String, dest: File): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val connection = URL(urlStr).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 10_000
|
||||
connection.readTimeout = 10_000
|
||||
connection.instanceFollowRedirects = true
|
||||
|
||||
if (connection.responseCode != 200) {
|
||||
connection.disconnect()
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
connection.inputStream.use { input ->
|
||||
dest.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
connection.disconnect()
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Failed to download cover art: ${e.message}")
|
||||
dest.delete()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CoverArtManager"
|
||||
private const val BASE_URL = "https://raw.githubusercontent.com/libretro-thumbnails"
|
||||
|
||||
fun cleanTitle(title: String): String {
|
||||
return title
|
||||
// Remove region tags: (USA), (Europe), (Japan), etc.
|
||||
.replace(Regex("\\s*\\([^)]*\\)"), "")
|
||||
// Remove flags: [!], [b], etc.
|
||||
.replace(Regex("\\s*\\[[^]]*]"), "")
|
||||
// Remove leading "The " for matching (LibRetro uses "Game, The" format)
|
||||
.trim()
|
||||
.let { cleaned ->
|
||||
if (cleaned.startsWith("The ", ignoreCase = true)) {
|
||||
cleaned.removePrefix("The ").removePrefix("the ") + ", The"
|
||||
} else cleaned
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,24 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.provider.DocumentsContract
|
||||
import com.lazy.emulate.data.CoverArtManager
|
||||
import com.lazy.emulate.data.model.Game
|
||||
import com.lazy.emulate.emulation.ConsoleType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
class GameRepository(private val context: Context) {
|
||||
|
||||
private val coverArtManager = CoverArtManager(context)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val _games = MutableStateFlow<List<Game>>(emptyList())
|
||||
val games: StateFlow<List<Game>> = _games.asStateFlow()
|
||||
|
||||
@@ -51,17 +59,23 @@ class GameRepository(private val context: Context) {
|
||||
files.forEach { file ->
|
||||
if (file.absolutePath in existingPaths) return@forEach
|
||||
val ext = file.extension.lowercase()
|
||||
// Skip .bin files that have a matching .cue
|
||||
if (ext == "bin" && file.nameWithoutExtension.lowercase() in cueBasenames) {
|
||||
return@forEach
|
||||
// Skip .bin files that belong to a .cue (exact match or multi-track)
|
||||
if (ext == "bin") {
|
||||
val binName = file.nameWithoutExtension.lowercase()
|
||||
if (cueBasenames.any { binName == it || binName.startsWith("$it ") }) {
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
if (ext in consoleType.fileExtensions || ext == "zip" || ext == "7z") {
|
||||
val title = file.nameWithoutExtension
|
||||
val cachedArt = coverArtManager.getCachedPath(title, consoleType)
|
||||
scanned.add(
|
||||
Game(
|
||||
id = UUID.randomUUID().toString(),
|
||||
title = file.nameWithoutExtension,
|
||||
title = title,
|
||||
romPath = file.absolutePath,
|
||||
consoleType = consoleType
|
||||
consoleType = consoleType,
|
||||
coverArtPath = cachedArt
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -70,6 +84,23 @@ class GameRepository(private val context: Context) {
|
||||
|
||||
if (scanned.isNotEmpty()) {
|
||||
_games.value = _games.value + scanned
|
||||
fetchMissingCoverArt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetchMissingCoverArt() {
|
||||
val gamesNeedingArt = _games.value.filter { it.coverArtPath == null }
|
||||
if (gamesNeedingArt.isEmpty()) return
|
||||
|
||||
scope.launch {
|
||||
for (game in gamesNeedingArt) {
|
||||
val path = coverArtManager.fetchCoverArt(game.title, game.consoleType)
|
||||
if (path != null) {
|
||||
_games.value = _games.value.map {
|
||||
if (it.id == game.id) it.copy(coverArtPath = path) else it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,44 +5,51 @@ enum class ConsoleType(
|
||||
val manufacturer: String,
|
||||
val fileExtensions: List<String>,
|
||||
val isImplemented: Boolean = false,
|
||||
val fallbackAspectRatio: Float = 4f / 3f
|
||||
val fallbackAspectRatio: Float = 4f / 3f,
|
||||
val libretroThumbnailRepo: String = ""
|
||||
) {
|
||||
PS1(
|
||||
displayName = "PlayStation",
|
||||
manufacturer = "Sony",
|
||||
fileExtensions = listOf("bin", "cue", "iso", "img", "pbp", "chd"),
|
||||
isImplemented = true,
|
||||
fallbackAspectRatio = 4f / 3f
|
||||
fallbackAspectRatio = 4f / 3f,
|
||||
libretroThumbnailRepo = "Sony_-_PlayStation"
|
||||
),
|
||||
NES(
|
||||
displayName = "Nintendo Entertainment System",
|
||||
manufacturer = "Nintendo",
|
||||
fileExtensions = listOf("nes", "unf", "unif"),
|
||||
fallbackAspectRatio = 256f / 240f
|
||||
fallbackAspectRatio = 256f / 240f,
|
||||
libretroThumbnailRepo = "Nintendo_-_Nintendo_Entertainment_System"
|
||||
),
|
||||
SNES(
|
||||
displayName = "Super Nintendo",
|
||||
manufacturer = "Nintendo",
|
||||
fileExtensions = listOf("sfc", "smc", "fig", "swc"),
|
||||
fallbackAspectRatio = 4f / 3f
|
||||
fallbackAspectRatio = 4f / 3f,
|
||||
libretroThumbnailRepo = "Nintendo_-_Super_Nintendo_Entertainment_System"
|
||||
),
|
||||
N64(
|
||||
displayName = "Nintendo 64",
|
||||
manufacturer = "Nintendo",
|
||||
fileExtensions = listOf("n64", "z64", "v64", "ndd"),
|
||||
fallbackAspectRatio = 4f / 3f
|
||||
fallbackAspectRatio = 4f / 3f,
|
||||
libretroThumbnailRepo = "Nintendo_-_Nintendo_64"
|
||||
),
|
||||
GENESIS(
|
||||
displayName = "Sega Genesis",
|
||||
manufacturer = "Sega",
|
||||
fileExtensions = listOf("gen", "md", "smd", "bin"),
|
||||
fallbackAspectRatio = 4f / 3f
|
||||
fallbackAspectRatio = 4f / 3f,
|
||||
libretroThumbnailRepo = "Sega_-_Mega_Drive_-_Genesis"
|
||||
),
|
||||
PS2(
|
||||
displayName = "PlayStation 2",
|
||||
manufacturer = "Sony",
|
||||
fileExtensions = listOf("iso", "bin", "chd", "cso"),
|
||||
fallbackAspectRatio = 4f / 3f
|
||||
fallbackAspectRatio = 4f / 3f,
|
||||
libretroThumbnailRepo = "Sony_-_PlayStation_2"
|
||||
);
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -22,9 +23,12 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.lazy.emulate.data.model.Game
|
||||
import java.io.File
|
||||
|
||||
@Composable
|
||||
fun GameCard(
|
||||
@@ -41,7 +45,7 @@ fun GameCard(
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
||||
) {
|
||||
Column {
|
||||
// Cover art placeholder
|
||||
// Cover art
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -50,12 +54,21 @@ fun GameCard(
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SportsEsports,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||
modifier = Modifier.padding(32.dp)
|
||||
)
|
||||
if (game.coverArtPath != null) {
|
||||
AsyncImage(
|
||||
model = File(game.coverArtPath),
|
||||
contentDescription = "${game.title} cover art",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SportsEsports,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||
modifier = Modifier.padding(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Console badge
|
||||
Text(
|
||||
|
||||
Reference in New Issue
Block a user