From f7af5091cf4daf58912f12b8f6ce9ebc0d64fd0a Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 24 Sep 2026 04:29:57 -0400 Subject: [PATCH 1/7] ARMSX2 like RPCS3: front-end launches, first-time setup, PCSX2 settings, 16K pages; on-screen controls everywhere - From the rail ARMSX2 starts the game in its full-screen, controller-driven UI and exits with it (-batch -bigpicture -fullscreen). - bannerlator-pad-defaults: a PCSX2 player's settings and memory cards are copied into ARMSX2 once; a new player starts on ARMSX2's first-time setup with the BIOS folder in the ROMs folder (ps2/bios, created, or ps2/firmware), the ps2 folder as the game list and the controller on Player 1; the guide button opens the pause menu (how a rail game is left) instead of toggling analog. Users bring their own BIOS and games. - The catalog can name a file per memory page size ("pages"); the device's own size picks it (ARMSX2 has 4K and 16K builds). - On-screen controls: Auto keeps them off the desktop (the screen is a touchpad there) and shows them for rail games and Steam when no controller is connected; the drawer offers the setting in every session. --- .../com/droiddeck/launcher/SessionActivity.kt | 5 +- .../droiddeck/launcher/frontend/Library.kt | 3 + .../launcher/runtime/DesktopCatalog.kt | 10 ++- .../droiddeck/launcher/ui/SessionOverlay.kt | 6 +- .../usr/local/bin/bannerlator-pad-defaults | 84 +++++++++++++++++-- 5 files changed, 96 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index 6c83cddba..e66c43f49 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -885,7 +885,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/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt index c56971aae..76f8c29dd 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -170,6 +170,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/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index 0aaf1fc7d..e5685f031 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -318,9 +318,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..f402ac4dd 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,79 @@ 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" + "[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 +524,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: From b5c9d6bc50eb3876ab9d4a38b64bf763455d841d Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 24 Sep 2026 04:32:58 -0400 Subject: [PATCH 2/7] ARMSX2: a new player starts on the Vulkan renderer (GSRendererType::VK = 14), not Auto's pick --- tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults index f402ac4dd..45459a0e2 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-pad-defaults @@ -338,6 +338,8 @@ def armsx2(): 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 From e938127c7a0865d596c16a2ac15af60e42705b86 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 24 Sep 2026 04:43:24 -0400 Subject: [PATCH 3/7] Drawer: a game from the rail is headed by its emulator (ARMSX2, RPCS3), not "Desktop" --- app/src/main/java/com/droiddeck/launcher/SessionActivity.kt | 2 ++ app/src/main/java/com/droiddeck/launcher/frontend/Library.kt | 3 +++ app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt | 4 +++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index e66c43f49..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, 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 76f8c29dd..4bf6b414d 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -76,6 +76,9 @@ object Library { "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] 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 e5685f031..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() } From 7165907ba9cc26693068b4c2da5617df08f0db08 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 24 Sep 2026 04:55:01 -0400 Subject: [PATCH 4/7] Front end: Back steps out of a game or emulator page; PS2 and other games found deeper in their folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Back / B on an emulator page went back to Android (the app closed); it now returns to Desktop, a game page to its emulator, a Steam game to Steam - the same as each page's '‹' link. - Games are looked for up to three folders inside the system folder (ROMs/ps2/games//.iso showed ARMSX2 with 0 games), and the loose-file pass over the ROMs root no longer walks into other systems' folders. --- .../com/droiddeck/launcher/frontend/Library.kt | 17 ++++++++++------- .../com/droiddeck/launcher/ui/FrontEndScreen.kt | 12 ++++++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) 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 4bf6b414d..56334a3f4 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -71,6 +71,8 @@ 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()), ) + /** 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", @@ -88,15 +90,16 @@ 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) { 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..bc5c9ca45 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 } From 0436953451e44cf4d627319cb5111bc9877f3ef7 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 24 Sep 2026 05:11:38 -0400 Subject: [PATCH 5/7] Front end: box art for emulator games, found automatically and kept CoverArt looks a game up by its file name in RetroArch's libretro-thumbnails collection for the system (exactly, then loosely: tags dropped, same region preferred, against the collection's cached list of names), then by the disc's own ID - PS2 serials from SYSTEM.CNF against xlenore/ps2-covers, GameCube/Wii IDs against GameTDB. Covers are downloaded once into files/covers after the list is shown, and the list is refreshed when some arrive; a game with none is retried after three days; nothing is fetched offline. PS3 disc dumps use their own PS3_GAME/ICON0.PNG. --- .../com/droiddeck/launcher/MainActivity.kt | 7 + .../droiddeck/launcher/frontend/CoverArt.kt | 229 ++++++++++++++++++ .../droiddeck/launcher/frontend/Library.kt | 8 +- 3 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/frontend/CoverArt.kt 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/frontend/CoverArt.kt b/app/src/main/java/com/droiddeck/launcher/frontend/CoverArt.kt new file mode 100644 index 000000000..6e437c10a --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/frontend/CoverArt.kt @@ -0,0 +1,229 @@ +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 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 url = try { findUrl(context, rom) } catch (e: Exception) { Log.w(TAG, "${rom.name}: $e"); null } + val target = coverFile(context, rom) + 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 ------------------------------------------------------------------------------ + + /** "SLUS-21065" from a PS2 .iso's SYSTEM.CNF (BOOT2 = cdrom0:\SLUS_210.65;1), or null. */ + private fun ps2Serial(file: File): String? { + if (!file.name.endsWith(".iso", ignoreCase = true)) return null + RandomAccessFile(file, "r").use { f -> + fun sector(lba: Long, length: Int): ByteArray = ByteArray(length).also { f.seek(lba * 2048); f.readFully(it) } + val pvd = sector(16, 2048) + if (String(pvd, 1, 5, Charsets.US_ASCII) != "CD001") return null + 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 rootLba = le32(pvd, 156 + 2) + val rootSize = le32(pvd, 156 + 10).toInt().coerceAtMost(64 * 1024) + val dir = sector(rootLba, rootSize) + var at = 0 + while (at < dir.size) { + val len = dir[at].toInt() and 0xff + if (len == 0) { at = (at / 2048 + 1) * 2048; continue } + val nameLen = dir[at + 32].toInt() and 0xff + val name = String(dir, at + 33, nameLen, Charsets.US_ASCII) + if (name.startsWith("SYSTEM.CNF", ignoreCase = true)) { + val cnf = String(sector(le32(dir, at + 2), le32(dir, at + 10).toInt().coerceAtMost(4096)), 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]}" + } + at += len + } + } + return null + } + + /** 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 56334a3f4..d4062dc3a 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt @@ -104,7 +104,9 @@ object Library { // 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(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 -> @@ -118,7 +120,9 @@ object Library { } } 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) } } From f4302e4fa91a3cbdf527376b0b7ece9d91be16e6 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 24 Sep 2026 05:17:20 -0400 Subject: [PATCH 6/7] Cover art: a PS3 disc image gives its own ICON0.PNG The libretro PS3 collection holds 67 covers (Tomb Raider found none). A PS3 disc image carries PS3_GAME/ICON0.PNG, unencrypted even on protected discs; it is read out of the ISO 9660 image, the way an installed game's icon already is. The PS2 serial lookup uses the same reader. --- .../droiddeck/launcher/frontend/CoverArt.kt | 73 +++++++++++++------ 1 file changed, 52 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/CoverArt.kt b/app/src/main/java/com/droiddeck/launcher/frontend/CoverArt.kt index 6e437c10a..cf7f151aa 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/CoverArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/CoverArt.kt @@ -20,7 +20,8 @@ import java.net.URLEncoder * (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 cover is downloaded once into files/covers; a game none of them has is not + * 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 { @@ -69,8 +70,15 @@ object CoverArt { for (rom in roms) { if (!wanted(context, rom)) continue if (++tries > MAX_PER_PASS) break - val url = try { findUrl(context, rom) } catch (e: Exception) { Log.w(TAG, "${rom.name}: $e"); null } 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() @@ -155,35 +163,58 @@ object CoverArt { // ---- disc IDs ------------------------------------------------------------------------------ - /** "SLUS-21065" from a PS2 .iso's SYSTEM.CNF (BOOT2 = cdrom0:\SLUS_210.65;1), or null. */ - private fun ps2Serial(file: File): String? { + /** + * 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 sector(lba: Long, length: Int): ByteArray = ByteArray(length).also { f.seek(lba * 2048); f.readFully(it) } - val pvd = sector(16, 2048) - if (String(pvd, 1, 5, Charsets.US_ASCII) != "CD001") return null + 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 rootLba = le32(pvd, 156 + 2) - val rootSize = le32(pvd, 156 + 10).toInt().coerceAtMost(64 * 1024) - val dir = sector(rootLba, rootSize) - var at = 0 - while (at < dir.size) { - val len = dir[at].toInt() and 0xff - if (len == 0) { at = (at / 2048 + 1) * 2048; continue } - val nameLen = dir[at + 32].toInt() and 0xff - val name = String(dir, at + 33, nameLen, Charsets.US_ASCII) - if (name.startsWith("SYSTEM.CNF", ignoreCase = true)) { - val cnf = String(sector(le32(dir, at + 2), le32(dir, at + 10).toInt().coerceAtMost(4096)), 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]}" + 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 } - 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 } From dce87061d1a1e8da687a9de40b0fa526c5bfe8b3 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Thu, 24 Sep 2026 05:31:00 -0400 Subject: [PATCH 7/7] Front end: wide cover art shown whole, titles without dump tags - A portrait tile or poster with wide art (a PS3 disc's ICON0, 320x176) shows it whole over a blurred, darkened copy of itself instead of cropping its sides ('Tomb Raider' read 'B RAI'); box art still fills the tile. - A game's title drops the dump's tags - (USA), (En,Fr,Es,Pt), (v1.01), [!] - which stay in the file name and in the cover lookup. --- .../droiddeck/launcher/frontend/Library.kt | 12 +++++-- .../droiddeck/launcher/ui/FrontEndScreen.kt | 32 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) 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 d4062dc3a..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,6 +71,14 @@ 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( @@ -104,7 +112,7 @@ object Library { // 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 @@ -114,7 +122,7 @@ 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)) } } } 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 bc5c9ca45..d37e68963 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt @@ -861,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) } } @@ -933,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 -> {