diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt index 092682004..e587aac8c 100644 --- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt @@ -47,6 +47,7 @@ import com.droiddeck.launcher.ui.CreditsDialog import com.droiddeck.launcher.ui.FrontEndScreen import com.droiddeck.launcher.ui.FrontEndState import com.droiddeck.launcher.ui.FrontEndActions +import com.droiddeck.launcher.frontend.CoverArt import com.droiddeck.launcher.frontend.Library import com.droiddeck.launcher.ui.DroidDeckTheme import com.droiddeck.launcher.ui.RomsDialog @@ -885,6 +886,12 @@ class MainActivity : ComponentActivity() { } else emptyList() val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null } ui.post { steamGames = games; emulatorList = emus } + // Box art for the games that have none, fetched after the list is up; the list is + // rebuilt once if any was found. + if (!OfflineMode.enabled(this) && CoverArt.fetchMissing(this, emus.flatMap { it.games })) { + val refreshed = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null } + ui.post { emulatorList = refreshed } + } }, "library").start() } diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 6c83cddba..a2d31962e 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -192,6 +192,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { ) SessionDrawer(drawerOpen, DrawerActions( steam = SessionState.mode == SessionService.MODE_STEAM, + title = if (SessionState.mode == SessionService.MODE_RUN) + com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "Game" else null, isHomeApp = isHomeApp, androidApps = androidApps, hudOn = hudOn, @@ -885,7 +887,10 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { SessionPrefs.OSC_ALWAYS -> true SessionPrefs.OSC_STEAM_QAM -> true SessionPrefs.OSC_NEVER -> false - else -> !PadBridge.anyControllerConnected() + // Auto: the touch pad when there is no controller - except on the desktop, where the + // screen is a touchpad for the pointer and a pad over it would be in the way. A game + // started from the rail, or Steam, gets it; the drawer turns it on anywhere. + else -> !PadBridge.anyControllerConnected() && SessionState.mode != SessionService.MODE_DESKTOP } if (show == (controls.visibility == View.VISIBLE)) return if (!show) controls.releaseAll() diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/CoverArt.kt b/app/src/main/java/com/droiddeck/launcher/frontend/CoverArt.kt new file mode 100644 index 000000000..cf7f151aa --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/frontend/CoverArt.kt @@ -0,0 +1,260 @@ +package com.droiddeck.launcher.frontend + +import android.content.Context +import android.graphics.BitmapFactory +import android.util.Log +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.RandomAccessFile +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder + +/** + * Box art for the emulators' games, found and kept on the device: the front end shows a disc image + * with nothing but its emulator's logo otherwise. + * + * In order: the name of the file against RetroArch's thumbnail collection for the system + * (libretro-thumbnails, named after the standard disc-dump names - "Need for Speed - Underground 2 + * (USA)"), first exactly and then loosely (tags dropped, the same region preferred) against the + * collection's list of names; then the disc's own ID, read from the image - PS2 serials against + * xlenore/ps2-covers (what PCSX2 and ARMSX2 download from), GameCube/Wii IDs against GameTDB (what + * Dolphin uses). A PS3 disc image gives its own PS3_GAME/ICON0.PNG first: the libretro PS3 + * collection is small. A cover is downloaded once into files/covers; a game none of them has is not + * asked about again for a few days. + */ +object CoverArt { + private const val TAG = "CoverArt" + private const val MISS_RETRY_MS = 3L * 24 * 3600 * 1000 + private const val INDEX_TTL_MS = 14L * 24 * 3600 * 1000 + /** Downloads in one pass, so a first visit to a big library does not hold the network for long. */ + private const val MAX_PER_PASS = 40 + + /** The emulator's systems in libretro-thumbnails, in the order they are tried. */ + private val collections = mapOf( + "rpcs3" to listOf("Sony_-_PlayStation_3"), + "armsx2" to listOf("Sony_-_PlayStation_2"), + "duckstation" to listOf("Sony_-_PlayStation"), + "ppsspp" to listOf("Sony_-_PlayStation_Portable"), + "dolphin" to listOf("Nintendo_-_GameCube", "Nintendo_-_Wii"), + "melonds" to listOf("Nintendo_-_Nintendo_DS"), + "cemu" to listOf("Nintendo_-_Wii_U"), + ) + + private fun root(context: Context) = File(context.filesDir, "covers") + + /** The file name, as the cover is kept under: a game keeps its art when its folder moves. */ + private fun keyOf(rom: Library.Rom): String = + rom.hostPath.name.replace(Regex("[^A-Za-z0-9._() -]"), "_").take(150) + + private fun coverFile(context: Context, rom: Library.Rom) = File(root(context), "${rom.emulatorId}/${keyOf(rom)}.img") + private fun missFile(context: Context, rom: Library.Rom) = File(root(context), "${rom.emulatorId}/${keyOf(rom)}.miss") + + /** A cover already on the device for this game, or null. */ + fun cached(context: Context, rom: Library.Rom): File? = coverFile(context, rom).takeIf { it.isFile && it.length() > 0 } + + private fun wanted(context: Context, rom: Library.Rom): Boolean { + if (rom.art != null || cached(context, rom) != null || rom.emulatorId !in collections) return false + val miss = missFile(context, rom) + return !(miss.isFile && System.currentTimeMillis() - miss.lastModified() < MISS_RETRY_MS) + } + + /** + * Looks for covers for the games that have none, on the calling (background) thread. True when + * at least one was found, so the caller rebuilds the list to show it. + */ + fun fetchMissing(context: Context, roms: List): Boolean { + var found = false + var tries = 0 + for (rom in roms) { + if (!wanted(context, rom)) continue + if (++tries > MAX_PER_PASS) break + val target = coverFile(context, rom) + // A PS3 disc carries its own cover (PS3_GAME/ICON0.PNG, not encrypted): no download. + if (rom.emulatorId == "rpcs3" && try { copyPs3Icon(rom.hostPath, target) } catch (e: Exception) { false }) { + Log.i(TAG, "${rom.name}: ICON0.PNG from the disc") + missFile(context, rom).delete() + found = true + continue + } + val url = try { findUrl(context, rom) } catch (e: Exception) { Log.w(TAG, "${rom.name}: $e"); null } + if (url != null && download(url, target)) { + Log.i(TAG, "${rom.name}: $url") + missFile(context, rom).delete() + found = true + } else { + missFile(context, rom).apply { parentFile?.mkdirs(); writeText(url ?: "") } + } + } + return found + } + + private fun findUrl(context: Context, rom: Library.Rom): String? { + val name = rom.hostPath.nameWithoutExtension.removeSuffix(".dec") + for (collection in collections.getValue(rom.emulatorId)) { + // Exactly the dump name first: no listing needed. + val exact = thumbnailUrl(collection, name) + if (exists(exact)) return exact + // Then loosely, against the collection's names. + nearest(name, names(context, collection))?.let { return thumbnailUrl(collection, it) } + } + // The disc's own ID. + return when (rom.emulatorId) { + "armsx2" -> ps2Serial(rom.hostPath)?.let { "https://raw.githubusercontent.com/xlenore/ps2-covers/main/covers/default/$it.jpg" } + "dolphin" -> nintendoDiscId(rom.hostPath)?.let { id -> + val region = when (id.getOrNull(3)) { 'P' -> "EN"; 'J' -> "JA"; 'K' -> "KO"; else -> "US" } + "https://art.gametdb.com/wii/cover/$region/$id.png" + } + else -> null + }?.takeIf { exists(it) } + } + + /** libretro-thumbnails replaces these in a title's file name. */ + private fun thumbName(title: String) = title.replace(Regex("[&*/:`<>?\\\\|\"]"), "_") + + private fun thumbnailUrl(collection: String, title: String) = + "https://raw.githubusercontent.com/libretro-thumbnails/$collection/master/Named_Boxarts/" + + URLEncoder.encode("${thumbName(title)}.png", "UTF-8").replace("+", "%20") + + // ---- loose matching ------------------------------------------------------------------------ + + private fun tags(name: String): List = Regex("[(\\[]([^)\\]]*)[)\\]]").findAll(name).map { it.groupValues[1].lowercase() }.toList() + + /** A title without its tags, articles and punctuation: "Legend of Zelda, The (USA)" = "legend of zelda". */ + private fun base(name: String): String = name + .replace(Regex("\\s*[(\\[][^)\\]]*[)\\]]"), " ") + .lowercase() + .replace(Regex("[^a-z0-9]+"), " ") + .split(' ').filter { it.isNotEmpty() && it != "the" && it != "a" } + .joinToString(" ") + + private fun nearest(name: String, candidates: List): String? { + if (candidates.isEmpty()) return null + val want = base(name) + if (want.isEmpty()) return null + val same = candidates.filter { base(it) == want } + if (same.isEmpty()) return null + val region = tags(name).firstOrNull { it in setOf("usa", "europe", "japan", "world", "korea") } ?: "usa" + return same.firstOrNull { region in tags(it) } ?: same.firstOrNull { "usa" in tags(it) } ?: same.first() + } + + /** The titles in a collection's Named_Boxarts (no extension), kept for a fortnight. */ + private fun names(context: Context, collection: String): List { + val cache = File(root(context), "index-$collection.txt") + if (cache.isFile && System.currentTimeMillis() - cache.lastModified() < INDEX_TTL_MS) return cache.readLines() + val list = try { + val top = JSONArray(get("https://api.github.com/repos/libretro-thumbnails/$collection/contents/") ?: return cachedOr(cache)) + val sha = (0 until top.length()).map { top.getJSONObject(it) }.firstOrNull { it.optString("name") == "Named_Boxarts" } + ?.optString("sha") ?: return cachedOr(cache) + val tree = JSONObject(get("https://api.github.com/repos/libretro-thumbnails/$collection/git/trees/$sha") ?: return cachedOr(cache)) + .getJSONArray("tree") + (0 until tree.length()).map { tree.getJSONObject(it).optString("path") } + .filter { it.endsWith(".png") }.map { it.removeSuffix(".png") } + } catch (e: Exception) { + Log.w(TAG, "index $collection: $e"); return cachedOr(cache) + } + cache.parentFile?.mkdirs() + cache.writeText(list.joinToString("\n")) + return list + } + + private fun cachedOr(cache: File): List = if (cache.isFile) cache.readLines() else emptyList() + + // ---- disc IDs ------------------------------------------------------------------------------ + + /** + * A file's bytes from an ISO 9660 image by path ("PS3_GAME/ICON0.PNG"), or null. Primary volume + * names only (upper case, ";1" suffix), which is what PS2 and PS3 discs use. + */ + private fun isoFile(file: File, path: String, maxSize: Int): ByteArray? { + if (!file.name.endsWith(".iso", ignoreCase = true)) return null + RandomAccessFile(file, "r").use { f -> + fun read(lba: Long, length: Int): ByteArray = ByteArray(length).also { f.seek(lba * 2048); f.readFully(it) } + fun le32(b: ByteArray, at: Int) = (b[at].toLong() and 0xff) or ((b[at + 1].toLong() and 0xff) shl 8) or + ((b[at + 2].toLong() and 0xff) shl 16) or ((b[at + 3].toLong() and 0xff) shl 24) + val pvd = read(16, 2048) + if (String(pvd, 1, 5, Charsets.US_ASCII) != "CD001") return null + var lba = le32(pvd, 156 + 2) + var size = le32(pvd, 156 + 10) + val parts = path.split('/') + for ((i, part) in parts.withIndex()) { + val dir = read(lba, size.toInt().coerceAtMost(256 * 1024)) + var at = 0 + var hit = false + while (at < dir.size) { + val len = dir[at].toInt() and 0xff + if (len == 0) { at = (at / 2048 + 1) * 2048; continue } + val name = String(dir, at + 33, dir[at + 32].toInt() and 0xff, Charsets.US_ASCII).substringBefore(';') + if (name.equals(part, ignoreCase = true)) { + lba = le32(dir, at + 2); size = le32(dir, at + 10); hit = true; break + } + at += len + } + if (!hit) return null + if (i == parts.lastIndex) return if (size in 1..maxSize) read(lba, size.toInt()) else null + } + } + return null + } + + private fun copyPs3Icon(file: File, target: File): Boolean { + val png = isoFile(file, "PS3_GAME/ICON0.PNG", 4 * 1024 * 1024) ?: return false + target.parentFile?.mkdirs() + val tmp = File(target.path + ".part") + tmp.writeBytes(png) + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(tmp.path, bounds) + return if (bounds.outWidth > 0 && tmp.renameTo(target)) true else { tmp.delete(); false } + } + + /** "SLUS-21065" from a PS2 .iso's SYSTEM.CNF (BOOT2 = cdrom0:\SLUS_210.65;1), or null. */ + private fun ps2Serial(file: File): String? { + val cnf = String(isoFile(file, "SYSTEM.CNF", 4096) ?: return null, Charsets.US_ASCII) + val m = Regex("BOOT2\\s*=\\s*cdrom0:\\\\?([A-Z]{4})_(\\d{3})\\.(\\d{2})").find(cnf) ?: return null + return "${m.groupValues[1]}-${m.groupValues[2]}${m.groupValues[3]}" + } + + /** The six-character ID of a GameCube/Wii disc image (.iso/.gcm, or .wbfs), or null. */ + private fun nintendoDiscId(file: File): String? { + val offset = when (file.extension.lowercase()) { "iso", "gcm" -> 0L; "wbfs" -> 0x200L; else -> return null } + RandomAccessFile(file, "r").use { f -> + val b = ByteArray(6); f.seek(offset); f.readFully(b) + val id = String(b, Charsets.US_ASCII) + return id.takeIf { it.all { c -> c.isLetterOrDigit() } } + } + } + + // ---- network ------------------------------------------------------------------------------- + + private fun open(url: String, method: String = "GET"): HttpURLConnection = + (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = method + connectTimeout = 10_000 + readTimeout = 20_000 + instanceFollowRedirects = true + setRequestProperty("User-Agent", "DroidDeck") + } + + private fun exists(url: String): Boolean = try { + val c = open(url, "HEAD"); val ok = c.responseCode == 200; c.disconnect(); ok + } catch (e: Exception) { false } + + private fun get(url: String): String? = try { + val c = open(url) + if (c.responseCode != 200) { c.disconnect(); null } else c.inputStream.bufferedReader().use { it.readText() } + } catch (e: Exception) { Log.w(TAG, "$url: $e"); null } + + /** Saves the image at [url] to [target] if it really is an image. */ + private fun download(url: String, target: File): Boolean = try { + val c = open(url) + if (c.responseCode != 200) { c.disconnect(); false } else { + target.parentFile?.mkdirs() + val tmp = File(target.path + ".part") + c.inputStream.use { input -> tmp.outputStream().use { input.copyTo(it) } } + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(tmp.path, bounds) + if (bounds.outWidth > 0 && tmp.renameTo(target)) true else { tmp.delete(); false } + } + } catch (e: Exception) { Log.w(TAG, "$url: $e"); false } +} diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index c56971aae..0337e9d94 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -71,11 +71,24 @@ object Library { Spec("ppsspp", "PPSSPP", "PSP", "/usr/bin/PPSSPPSDL", listOf("psp"), setOf("iso", "cso", "pbp", "chd")), Spec("retroarch", "RetroArch", "many systems", "/usr/bin/retroarch", emptyList(), emptySet()), ) + /** + * A dump's file name as a title: its tags - (USA), (En,Fr,Es,Pt), [!], (v1.01) - identify the + * file and are dropped for reading ("Tomb Raider (USA) (En,Fr,Es,Pt)" -> "Tomb Raider"). The + * file keeps them, and the cover lookup still uses them. + */ + private fun displayTitle(name: String): String = + name.replace(Regex("\\s*[(\\[][^)\\]]*[)\\]]"), "").trim().ifEmpty { name } + + /** Every system folder name the specs claim, so a loose-file scan of the root skips them. */ + private val systemFolders: Set by lazy { specs.flatMap { it.folders }.toSet() } private val installedIds = mapOf( "rpcs3" to "rpcs3", "armsx2" to "armsx2", "dolphin" to "dolphin", "duckstation" to "duckstation", "melonds" to "melonds", "cemu" to "cemu", "ppsspp" to "emulators", "retroarch" to "emulators", ) + /** The emulator's name for a program path from the rail ("ARMSX2"), or null. */ + fun nameForProgram(program: String?): String? = specs.firstOrNull { it.program == program }?.name + /** Desktop catalog package that supplies this emulator. */ fun packageId(emulatorId: String): String? = installedIds[emulatorId] @@ -85,20 +98,23 @@ object Library { return specs.map { spec -> val games = ArrayList() if (romsRoot != null && spec.exts.isNotEmpty()) { - // The system's folder(s), matched without regard to case, then the root itself for - // a file left loose there - and one folder deeper, since a dump usually comes as a - // folder named for the game with the image inside it. + // The system's folder(s), matched without regard to case, and up to three folders + // inside them - a dump usually comes as a folder named for the game with the image + // inside it, and people sort those into folders of their own (ps2/games//). + // The root itself only one deep, for a file left loose there. val systemDirs = romsRoot.listFiles { f -> f.isDirectory && f.name.lowercase() in spec.folders }.orEmpty().toList() val dirs = LinkedHashSet() - for (top in systemDirs + romsRoot) { - dirs.add(top) - top.listFiles { f -> f.isDirectory }?.forEach { dirs.add(it) } - } + for (top in systemDirs) top.walkTopDown().maxDepth(3).filter { it.isDirectory }.forEach { dirs.add(it) } + dirs.add(romsRoot) + // ...and not into another system's folder: a PS3 .iso in ps3/ is not a PS2 game. + romsRoot.listFiles { f -> f.isDirectory && f.name.lowercase() !in systemFolders }?.forEach { dirs.add(it) } for (dir in dirs) { // A PS3 disc dump is a folder with PS3_GAME in it; RPCS3 boots the folder. if (spec.id == "rpcs3" && File(dir, "PS3_GAME").isDirectory) { val rel = dir.relativeTo(romsRoot).path - games.add(Rom(dir.name, dir, "/root/ROMs/$rel", spec.id)) + games.add(Rom(displayTitle(dir.name), dir, "/root/ROMs/$rel", spec.id, + // The dump carries its own art, as an installed package does. + art = File(dir, "PS3_GAME/ICON0.PNG").takeIf { it.isFile })) continue } dir.listFiles()?.sortedBy { it.name.lowercase() }?.forEach { f -> @@ -106,13 +122,15 @@ object Library { // A .bin beside a .cue is a track, not a game. if (f.isFile && ext in spec.exts && !(ext == "bin" && File(dir, f.nameWithoutExtension + ".cue").isFile)) { val rel = f.relativeTo(romsRoot).path - games.add(Rom(f.nameWithoutExtension.removeSuffix(".dec"), f, "/root/ROMs/$rel", spec.id)) + games.add(Rom(displayTitle(f.nameWithoutExtension.removeSuffix(".dec")), f, "/root/ROMs/$rel", spec.id)) } } } } if (spec.id == "rpcs3") games.addAll(rpcs3Installed(context)) - Emulator(spec.id, spec.name, spec.system, spec.program, installedPackage(installedIds.getValue(spec.id)), games) + // A cover found for the game before (CoverArt) where it has no art of its own. + val withArt = games.map { g -> if (g.art != null) g else CoverArt.cached(context, g)?.let { Rom(g.name, g.hostPath, g.guestPath, g.emulatorId, it) } ?: g } + Emulator(spec.id, spec.name, spec.system, spec.program, installedPackage(installedIds.getValue(spec.id)), withArt) } } @@ -170,6 +188,9 @@ object Library { /** How the emulator is told which game to boot, on its command line. */ fun launchArgs(emulatorId: String, guestPath: String): List = when (emulatorId) { "rpcs3" -> listOf("--no-gui", guestPath) + // Straight into the game in its controller-driven full-screen UI (first-time setup there + // too), and gone when the game is quit from its pause menu (guide button): -batch. + "armsx2" -> listOf("-batch", "-bigpicture", "-fullscreen", "--", guestPath) "dolphin" -> listOf("-e", guestPath) "cemu" -> listOf("-g", guestPath) else -> listOf(guestPath) diff --git a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt index 7654917e9..2de78f99c 100644 --- a/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt +++ b/app/src/main/java/com/droiddeck/launcher/runtime/DesktopCatalog.kt @@ -31,12 +31,18 @@ object DesktopCatalog { return try { val json = JSONObject(body) val arr = json.getJSONArray("packages") + // A package built per memory page size (ARMSX2: 4K and 16K kernels) names the others + // under "pages"; the device's own size picks one, and the top-level file is the 4K one. + val pageSize = try { + android.system.Os.sysconf(android.system.OsConstants._SC_PAGESIZE).toString() + } catch (e: Exception) { "4096" } (0 until arr.length()).map { i -> val o = arr.getJSONObject(i) + val file = o.optJSONObject("pages")?.optJSONObject(pageSize) ?: o Entry( o.getString("id"), o.getString("name"), o.optInt("tier", 1), o.optString("version", ""), - o.optString("kind", "tar"), o.getString("url"), o.optString("sha256", ""), - o.optLong("size", 0L), o.optString("notes", ""), + o.optString("kind", "tar"), file.getString("url"), file.optString("sha256", ""), + file.optLong("size", 0L), o.optString("notes", ""), o.optString("icon", "applications-games"), o.optString("category", "Game"), ) } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt index 052630cc7..d37e68963 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -233,6 +233,18 @@ fun FrontEndScreen(s: FrontEndState, a: FrontEndActions, page: (@Composable () - val colors = MaterialTheme.colorScheme val ctx = LocalContext.current BackHandler(enabled = s.pageKey != null && page != null) { a.onPageBack() } + // Back (and B) from a game or an emulator steps out one level, as its "‹" link does, instead + // of leaving the app: a game -> its emulator (or Steam), an emulator -> Desktop. + BackHandler( + enabled = (s.pageKey == null || page == null) && + (selected.startsWith("app:") || selected.startsWith("emu:") || selected.startsWith("rom:")), + ) { + selected = when { + selected.startsWith("app:") -> "steam" + selected.startsWith("emu:") -> "desktop" + else -> "emu:" + selected.removePrefix("rom:").substringBefore(':') + } + } LaunchedEffect(s.isHomeApp) { if (!s.isHomeApp && selected == "android-apps") selected = "steam" } remember { Motion.scale = Settings.Global.getFloat(ctx.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f); true } @@ -849,7 +861,7 @@ private fun Poster(art: File?, name: String, modifier: Modifier) { .graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 16.dp.toPx(); rotationZ = (1f - t.value) * 2f; scaleX = 0.94f + 0.06f * t.value; scaleY = scaleX; shadowElevation = 22.dp.toPx(); shape = Shape12; clip = false } .clip(Shape12).background(artBrush(hueOf(name))), ) { - if (art != null) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + if (art != null) CoverImage(art, Modifier.fillMaxSize()) else Text(name, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.align(Alignment.BottomStart).padding(8.dp), maxLines = 3, overflow = TextOverflow.Ellipsis) } } @@ -921,13 +933,41 @@ private fun GameTile(t: Tile, wide: Boolean, square: Boolean, src: MutableIntera } } +/** + * Art for a portrait (2:3) tile. Box art fills it; wide art - a PS3 disc's ICON0, a game's header - + * is shown whole over a blurred, darkened copy of itself instead of losing its sides to the crop. + */ +@Composable +private fun CoverImage(art: File, modifier: Modifier) { + var wideArt by remember(art) { mutableStateOf(false) } + Box(modifier) { + if (wideArt) { + AsyncImage( + model = art, contentDescription = null, contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().graphicsLayer { scaleX = 1.2f; scaleY = 1.2f }.blur(14.dp), + ) + Spacer(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f))) + } + AsyncImage( + model = art, contentDescription = null, + contentScale = if (wideArt) ContentScale.Fit else ContentScale.Crop, + onSuccess = { state -> + val size = state.painter.intrinsicSize + if (size.width > size.height * 1.1f) wideArt = true + }, + modifier = Modifier.fillMaxSize(), + ) + } +} + @Composable private fun Art(art: File?, iconRes: Int?, label: String, modifier: Modifier, wide: Boolean = false, iconBitmap: Bitmap? = null) { val colors = MaterialTheme.colorScheme val ratio = if (art == null && (iconRes != null || iconBitmap != null)) 1f else if (wide) 16f / 9f else 2f / 3f Box(modifier = modifier.aspectRatio(ratio).background(if (art == null && iconRes == null && iconBitmap == null) artBrush(hueOf(label)) else Brush.linearGradient(listOf(colors.surfaceVariant, colors.surface)))) { when { - art != null -> AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + art != null -> if (wide) AsyncImage(model = art, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + else CoverImage(art, Modifier.fillMaxSize()) iconRes != null -> Image(painterResource(iconRes), null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(if (wide) 10.dp else 8.dp)) iconBitmap != null -> Image(bitmap = iconBitmap.asImageBitmap(), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize().padding(8.dp)) else -> { diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 0aaf1fc7d..400caaeb8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -142,6 +142,8 @@ fun SessionPausedOverlay(onResume: () -> Unit) { /** Everything the drawer shows and does. */ class DrawerActions( val steam: Boolean, + /** The drawer's heading: the emulator for a program from the rail, else Steam or Desktop. */ + val title: String? = null, val isHomeApp: Boolean, val androidApps: List, val hudOn: Boolean, @@ -215,7 +217,7 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 4.dp, bottom = 6.dp)) { Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Brush.linearGradient(listOf(colors.primary, pal.primary2)))) Spacer(Modifier.width(10.dp)) - Text(if (a.steam) "Steam" else "Desktop", fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) + Text(a.title ?: if (a.steam) "Steam" else "Desktop", fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) } if (a.onSteamMenu != null && a.onQam != null) { val qamInteraction = remember { MutableInteractionSource() } @@ -318,9 +320,11 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) { listOf(SessionPrefs.TOUCH_AUTO to "Auto (${a.touchAuto})", SessionPrefs.TOUCH_PAD to "Touchpad", SessionPrefs.TOUCH_DIRECT to "Direct"), a.touchMode, onPick = a.onTouch, ) - if (a.steam) ChoiceRow( + ChoiceRow( host, "osc", "On-screen controls", null, - listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_STEAM_QAM to "Steam + QAM", SessionPrefs.OSC_NEVER to "Never"), a.oscMode, + if (a.steam) listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_STEAM_QAM to "Steam + QAM", SessionPrefs.OSC_NEVER to "Never") + else listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_NEVER to "Never"), + a.oscMode, onPick = a.onOsc, ) SettingsRow("Keyboard", null) { diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults index 01b9f593f..45459a0e2 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults @@ -116,7 +116,7 @@ def ini_section(lines, name): return (start, len(lines)) if start is not None else None -def patch_ini_section(path, section, body, what, needle="SDL-", require=None): +def patch_ini_section(path, section, body, what, needle="SDL-", require=None, done="Player 1 on the controller"): """Add body's lines to [section] of an ini the emulator wrote itself, unless the section already binds a controller (needle). Missing file: nothing - the emulator writes its own defaults on first run and this runs again.""" @@ -144,7 +144,7 @@ def patch_ini_section(path, section, body, what, needle="SDL-", require=None): with open(path + ".tmp", "w", encoding="utf-8") as f: f.write("\n".join(lines) + "\n") os.replace(path + ".tmp", path) - print(f"bannerlator-pad-defaults: {what}: Player 1 on the controller ({path})") + print(f"bannerlator-pad-defaults: {what}: {done} ({path})") # DuckStation: SDL-/, axes signed (src/util/sdl_input_source.cpp); Pad1 keys from @@ -221,10 +221,7 @@ SmallMotor = SDL-0/SmallMotor def pcsx2(): patch_ini_section(os.path.join(CONFIG, "PCSX2/inis/PCSX2.ini"), "Pad1", PCSX2_PAD1, "PCSX2", require="SettingsVersion") - # ARMSX2 (PCSX2 with ARM64 recompilers): the same ini and binding names under its own folder - # (pcsx2/Pcsx2Config.cpp: $XDG_CONFIG_HOME/ARMSX2/inis/PCSX2.ini). - patch_ini_section(os.path.join(CONFIG, "ARMSX2/inis/PCSX2.ini"), "Pad1", PCSX2_PAD1, "ARMSX2", - require="SettingsVersion") + # Dolphin: SDL//; control names from InputCommon/.../SDL/SDLGamepad.h (Left Y+ @@ -287,6 +284,81 @@ Nunchuk/Stick/Right = `Left X+` """ +# ARMSX2 - the PS2 emulator (PCSX2 with ARM64 recompilers; upstream PCSX2 interprets on ARM64). Its +# files are PCSX2's under $XDG_CONFIG_HOME/ARMSX2 (pcsx2/Pcsx2Config.cpp). The user supplies the BIOS +# and the games; they live in the ROMs folder (/root/ROMs). The guide button opens the pause menu +# (Hotkeys OpenPauseMenu) - the way out of a game started from the front end, which runs ARMSX2 +# without its window (-batch -bigpicture) - so it is not also the analog toggle here. +ARMSX2_PAD1 = "\n".join(l for l in PCSX2_PAD1.splitlines() if not l.startswith("Analog ")) + "\n" +ROMS = "/root/ROMs" + + +def ps2_folder(): + for name in ("ps2", "PS2"): + d = os.path.join(ROMS, name) + if os.path.isdir(d): + return d + return os.path.join(ROMS, "ps2") + + +def ps2_bios_folder(): + base = ps2_folder() + for name in ("bios", "BIOS", "firmware"): + d = os.path.join(base, name) + if os.path.isdir(d): + return d + d = os.path.join(base, "bios") + if os.path.isdir(ROMS): + try: + os.makedirs(d, exist_ok=True) # where the user is told to put their BIOS + except OSError: + pass + return d + + +def armsx2(): + root = os.path.join(CONFIG, "ARMSX2") + ini = os.path.join(root, "inis/PCSX2.ini") + old = os.path.join(CONFIG, "PCSX2") + if not os.path.isfile(ini) and os.path.isfile(os.path.join(old, "inis/PCSX2.ini")): + # Someone who played on PCSX2 here: the same settings, BIOS choice and memory cards (the + # saves), copied once. PCSX2's own folder is left as it was. + import shutil + for sub in ("inis", "memcards", "bios", "gamesettings", "cheats", "patches", "covers", + "inputprofiles", "textures"): + src = os.path.join(old, sub) + if os.path.isdir(src): + shutil.copytree(src, os.path.join(root, sub), dirs_exist_ok=True) + print(f"bannerlator-pad-defaults: ARMSX2: settings and memory cards copied from PCSX2 ({root})") + elif not os.path.isfile(ini): + # A new player: ARMSX2's own first-time setup still runs (SetupWizardIncomplete) to pick the + # BIOS, but looking in the ROMs folder, with the ps2 folder as its game list and the + # controller already on Player 1. + bios, games = ps2_bios_folder(), ps2_folder() + seed(ini, "[UI]\nSettingsVersion = 1\nSetupWizardIncomplete = true\n\n" + f"[Folders]\nBios = {bios}\n\n" + f"[GameList]\nRecursivePaths = {games}\n\n" + # Vulkan outright rather than Auto's pick: the renderer every other path here uses. + "[EmuCore/GS]\nRenderer = 14\n\n" + "[Hotkeys]\nOpenPauseMenu = Keyboard/Escape\nOpenPauseMenu = SDL-0/Guide\n\n" + "[Pad1]\n" + ARMSX2_PAD1, "ARMSX2", "first-time setup, BIOS from " + bios) + return + patch_ini_section(ini, "Pad1", ARMSX2_PAD1, "ARMSX2", require="SettingsVersion") + # The guide button is the pause menu here; PCSX2's profile (ours, copied over) had it as the + # analog toggle as well. + with open(ini, encoding="utf-8", errors="replace") as f: + lines = f.read().splitlines() + kept = [l for l in lines if l.replace(" ", "") != "Analog=SDL-0/Guide"] + if len(kept) != len(lines): + with open(ini + ".tmp", "w", encoding="utf-8") as f: + f.write("\n".join(kept) + "\n") + os.replace(ini + ".tmp", ini) + patch_ini_section(ini, "Hotkeys", "OpenPauseMenu = SDL-0/Guide\n", "ARMSX2", needle="SDL-0/Guide", + require="SettingsVersion", done="the guide button opens the pause menu") + patch_ini_section(ini, "GameList", f"RecursivePaths = {ps2_folder()}\n", "ARMSX2", needle=ROMS, + require="SettingsVersion", done="games listed from the ROMs folder") + + def dolphin(): d = os.path.join(CONFIG, "dolphin-emu") seed(os.path.join(d, "GCPadNew.ini"), DOLPHIN_GC, "Dolphin (GameCube)") @@ -454,7 +526,7 @@ def retroarch(): print(f"bannerlator-pad-defaults: RetroArch: joypad driver udev -> sdl2 ({cfg})") -for step in (rpcs3, duckstation, pcsx2, dolphin, cemu, melonds, ppsspp, retroarch): +for step in (rpcs3, duckstation, pcsx2, armsx2, dolphin, cemu, melonds, ppsspp, retroarch): try: step() except Exception as e: