diff --git a/android/app/src/main/java/com/armsx2/GameInfo.kt b/android/app/src/main/java/com/armsx2/GameInfo.kt index 3172ed7..4e43de3 100644 --- a/android/app/src/main/java/com/armsx2/GameInfo.kt +++ b/android/app/src/main/java/com/armsx2/GameInfo.kt @@ -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 /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. diff --git a/android/app/src/main/java/com/armsx2/core/Ps1Covers.kt b/android/app/src/main/java/com/armsx2/core/Ps1Covers.kt index 0f68573..07cdbc1 100644 --- a/android/app/src/main/java/com/armsx2/core/Ps1Covers.kt +++ b/android/app/src/main/java/com/armsx2/core/Ps1Covers.kt @@ -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 `/covers/.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. + --------------------------------------------------------------------------------------- */ + + /** `/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, 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 + } } diff --git a/android/app/src/main/java/com/armsx2/i18n/I18n.kt b/android/app/src/main/java/com/armsx2/i18n/I18n.kt index 05807a0..da0d03f 100644 --- a/android/app/src/main/java/com/armsx2/i18n/I18n.kt +++ b/android/app/src/main/java/com/armsx2/i18n/I18n.kt @@ -309,6 +309,11 @@ val EN: Map = 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?", diff --git a/android/app/src/main/java/com/armsx2/ui/settings/AppTab.kt b/android/app/src/main/java/com/armsx2/ui/settings/AppTab.kt index 76170bf..d49e4b4 100644 --- a/android/app/src/main/java/com/armsx2/ui/settings/AppTab.kt +++ b/android/app/src/main/java/com/armsx2/ui/settings/AppTab.kt @@ -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 /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.