mirror of
https://github.com/ARMSX2/ARMSX1.git
synced 2026-08-24 16:53:35 -07:00
Pre-fetch cover art to <DataRoot>/covers with a Download cover art action
This commit is contained in:
@@ -289,6 +289,12 @@ data class GameInfo(
|
||||
val coverUrl: String? get() {
|
||||
localCoverPath()?.let { return Uri.fromFile(File(it)).toString() }
|
||||
val s = coverSerial ?: return null
|
||||
// A cover pre-fetched into <DataRoot>/covers beats the network: it renders instantly,
|
||||
// works offline, and survives a cache clear. Only 2D art is stored this way, so the 3D
|
||||
// style still goes to the network for its own URL.
|
||||
if (!CoverArtStyle.use3d.value) {
|
||||
com.armsx2.core.Ps1Covers.downloadedCover(s)?.let { return Uri.fromFile(it).toString() }
|
||||
}
|
||||
// 3D cases live under covers/3d/*.png; flat 2D scans under
|
||||
// covers/default/*.jpg. Coil decodes by content, so the extension
|
||||
// mismatch on the cached file is fine.
|
||||
|
||||
@@ -119,4 +119,81 @@ object Ps1Covers {
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------------------
|
||||
Persistent, pre-fetched covers.
|
||||
|
||||
Coil already fetches a cover the moment a tile scrolls into view, and caches it — but that
|
||||
is LAZY: nothing exists until the tile has been on screen, art never appears offline, and a
|
||||
cache clear silently loses the lot. Downloading to `<DataRoot>/covers/<SERIAL>.jpg` makes
|
||||
covers real files the user owns: visible in the data folder, survive a cache wipe, work
|
||||
with no network, and can be hand-replaced.
|
||||
|
||||
`covers` is already in Ps1Library.BLOCKED_DIRS, so these never get scanned back in as games.
|
||||
--------------------------------------------------------------------------------------- */
|
||||
|
||||
/** `<DataRoot>/covers`, created on demand. Null when no data root is configured yet. */
|
||||
fun coversDir(): File? {
|
||||
val root = com.armsx2.runtime.MainActivityRuntime.systemDirPosix() ?: return null
|
||||
return File(root, "covers").apply { runCatching { mkdirs() } }
|
||||
}
|
||||
|
||||
/** The downloaded cover for [serial], or null if it has not been fetched. */
|
||||
fun downloadedCover(serial: String): File? =
|
||||
coversDir()?.let { File(it, "${serial.uppercase(Locale.US)}.jpg") }?.takeIf { it.isFile && it.length() > 0 }
|
||||
|
||||
/**
|
||||
* Fetch one cover into [coversDir]. Returns true if the file is present afterwards.
|
||||
* Already-downloaded covers are a no-op, so this is safe to call repeatedly.
|
||||
*
|
||||
* **Blocking.** Call from an IO dispatcher.
|
||||
*/
|
||||
fun downloadCover(serial: String): Boolean {
|
||||
if (serial.isBlank()) return false
|
||||
downloadedCover(serial)?.let { return true }
|
||||
val dir = coversDir() ?: return false
|
||||
val target = File(dir, "${serial.uppercase(Locale.US)}.jpg")
|
||||
// Write to a temp name first: a half-written file that already has the final name would
|
||||
// be treated as a valid cover forever after.
|
||||
val temp = File(dir, ".${target.name}.part")
|
||||
return runCatching {
|
||||
val connection = (java.net.URL(coverUrl(serial)).openConnection() as java.net.HttpURLConnection).apply {
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 15_000
|
||||
instanceFollowRedirects = true
|
||||
}
|
||||
try {
|
||||
if (connection.responseCode != 200) return false
|
||||
connection.inputStream.use { input -> temp.outputStream().use(input::copyTo) }
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
if (temp.length() <= 0) {
|
||||
temp.delete()
|
||||
false
|
||||
} else {
|
||||
temp.renameTo(target) || run { temp.delete(); false }
|
||||
}
|
||||
}.getOrElse {
|
||||
temp.delete()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download every cover that is missing, one at a time. [serials] is deduplicated and blanks
|
||||
* are dropped. [onProgress] fires after each attempt with (done, total) so a UI can show
|
||||
* where it is. Returns how many covers were newly fetched.
|
||||
*
|
||||
* **Blocking.** Call from an IO dispatcher.
|
||||
*/
|
||||
fun downloadMissing(serials: Collection<String>, onProgress: (Int, Int) -> Unit = { _, _ -> }): Int {
|
||||
val wanted = serials.mapNotNull { it.takeIf(String::isNotBlank)?.uppercase(Locale.US) }.distinct()
|
||||
var fetched = 0
|
||||
wanted.forEachIndexed { index, serial ->
|
||||
if (downloadedCover(serial) == null && downloadCover(serial)) fetched++
|
||||
onProgress(index + 1, wanted.size)
|
||||
}
|
||||
return fetched
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,6 +309,11 @@ val EN: Map<String, String> = mapOf(
|
||||
"app.backup.export.desc" to "Save states, memory cards, artwork, per-game settings, controller profiles, patches and all settings into one .zip. Games and BIOS are not included.",
|
||||
"app.backup.import" to "Restore app data",
|
||||
"app.backup.import.desc" to "Load a backup .zip. Files with the same name are replaced, and the app restarts.",
|
||||
"app.covers" to "Download cover art",
|
||||
"app.covers.desc" to "Fetch box art for every game in your library and keep it on disk, so covers show up straight away and work offline.",
|
||||
"app.covers.done" to "Downloaded %d covers",
|
||||
"app.covers.upToDate" to "All covers already downloaded",
|
||||
"app.covers.none" to "No games found to fetch covers for",
|
||||
"app.reset" to "Reset app",
|
||||
"app.reset.desc" to "Restore every setting to its default. Your games and saves are kept.",
|
||||
"app.reset.title" to "Reset the whole app?",
|
||||
|
||||
@@ -701,6 +701,44 @@ private fun BackupRestoreRows() {
|
||||
BackupActionRow("💾", "app.backup.export", "app.backup.export.desc", status, busy, doExport)
|
||||
BackupActionRow("📥", "app.backup.import", "app.backup.import.desc", "", busy, doImport)
|
||||
|
||||
/*
|
||||
Fetch every missing cover up front, into <DataRoot>/covers.
|
||||
|
||||
Coil already pulls a cover when a tile scrolls into view, but that is lazy and lives in a
|
||||
cache: art does not exist until you have looked at the game, never appears offline, and
|
||||
vanishes on a cache clear. This walks the whole library once and writes real files, so the
|
||||
grid is populated before it is scrolled and stays populated afterwards.
|
||||
*/
|
||||
var coverStatus by remember { mutableStateOf("") }
|
||||
val doCovers = {
|
||||
if (!busy) {
|
||||
busy = true
|
||||
coverStatus = ""
|
||||
scope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
val games = runCatching { com.armsx2.core.Ps1Library.scan(context) }.getOrDefault(emptyList())
|
||||
val serials = games.mapNotNull { g ->
|
||||
runCatching { com.armsx2.core.Ps1Covers.serialForPath(g.path) }.getOrNull()
|
||||
}
|
||||
if (serials.isEmpty()) return@withContext -1
|
||||
com.armsx2.core.Ps1Covers.downloadMissing(serials) { done, total ->
|
||||
coverStatus = "$done / $total"
|
||||
}
|
||||
}
|
||||
coverStatus = when {
|
||||
// No serial anywhere usually means the library itself is empty — which on
|
||||
// Android 11+ is the all-files-access case, not a cover problem.
|
||||
result < 0 -> I18n.get("app.covers.none")
|
||||
result == 0 -> I18n.get("app.covers.upToDate")
|
||||
else -> I18n.get("app.covers.done").replace("%d", result.toString())
|
||||
}
|
||||
busy = false
|
||||
com.armsx2.core.Ps1Library.rescan.intValue++
|
||||
}
|
||||
}
|
||||
}
|
||||
BackupActionRow("🖼️", "app.covers", "app.covers.desc", coverStatus, busy, doCovers)
|
||||
|
||||
// Factory reset. Sits with Backup/Restore because Export is the thing to do first — the
|
||||
// prompt says so. Routed through GlobalConfirm rather than a local overlay: this row is
|
||||
// inside a scrolling tab, so a scrim drawn here would clip to the row's bounds.
|
||||
|
||||
Reference in New Issue
Block a user