diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4f6fe81..3cee06b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -80,6 +80,9 @@ dependencies {
// DataStore
implementation(libs.datastore.prefs)
+ // Image loading
+ implementation(libs.coil.compose)
+
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a465706..0f9f745 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -11,6 +11,7 @@
android:maxSdkVersion="32" />
+
+ 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
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt b/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt
index a177b4f..fe759af 100644
--- a/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt
+++ b/app/src/main/java/com/lazy/emulate/data/repository/GameRepository.kt
@@ -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>(emptyList())
val games: StateFlow> = _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
+ }
+ }
+ }
}
}
diff --git a/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt b/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt
index a11d454..49c6f1e 100644
--- a/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt
+++ b/app/src/main/java/com/lazy/emulate/emulation/ConsoleType.kt
@@ -5,44 +5,51 @@ enum class ConsoleType(
val manufacturer: String,
val fileExtensions: List,
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 {
diff --git a/app/src/main/java/com/lazy/emulate/ui/components/GameCard.kt b/app/src/main/java/com/lazy/emulate/ui/components/GameCard.kt
index 534739f..8f3010e 100644
--- a/app/src/main/java/com/lazy/emulate/ui/components/GameCard.kt
+++ b/app/src/main/java/com/lazy/emulate/ui/components/GameCard.kt
@@ -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(
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 4a32fc4..e11cf96 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -14,6 +14,7 @@ lifecycle = "2.9.0"
coroutines = "1.10.2"
windowSizeClass = "1.3.2"
datastorePrefs = "1.1.4"
+coil = "2.7.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -47,6 +48,9 @@ coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutin
# DataStore
datastore-prefs = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePrefs" }
+# Coil (image loading)
+coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
+
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }