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" }