From 740f2a8975172226f3edbe40f31c8c039a919343 Mon Sep 17 00:00:00 2001 From: jpolo1224 Date: Mon, 24 Aug 2026 12:18:25 -0400 Subject: [PATCH] Second screen: cover art and more of what RetroAchievements knows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover art tile. The panel had the game as a line of text, and on a screen sitting beside you the cover is what makes it read as "this game" at a glance. It resolves through the same two sources the library uses -- a user-set custom cover first, then the fetched URL -- so the panel shows what the library shows, including a cover picked by hand. The only tile that is a picture rather than text, and it re-resolves on the tick keyed on the game, so switching game changes it without rebuilding the panel. Three more RetroAchievements read-outs: points earned against the total, which is the figure RA itself leads with; the latest unlock on its own; and the rich presence line, which is the one thing here that says something a number cannot. Two things fixed while adding them. The unlock tracking lived inside the Achievements tile's own text builder, so the new Latest-unlock tile would have read "—" forever unless the Achievements tile happened to be placed as well -- it is now a per-tick step that runs regardless of which tiles exist. And the achievements JSON was parsed per tile, so placing all three would have parsed the same string three times a tick for identical results; it is parsed once now and shared. --- .../src/main/java/com/armsx2/SecondScreen.kt | 122 ++++++++++++++++-- .../main/java/com/armsx2/SecondScreenTiles.kt | 8 ++ .../app/src/main/java/com/armsx2/i18n/I18n.kt | 4 + 3 files changed, 123 insertions(+), 11 deletions(-) diff --git a/platforms/android/app/src/main/java/com/armsx2/SecondScreen.kt b/platforms/android/app/src/main/java/com/armsx2/SecondScreen.kt index 08fbde22b0..e45232502f 100644 --- a/platforms/android/app/src/main/java/com/armsx2/SecondScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/SecondScreen.kt @@ -526,6 +526,7 @@ object SecondScreen { ) return null return macroAction(id).styleAsTile(action = true) } + if (tile == SecondScreenTile.COVER) return buildCoverTile() if (tile.stat) { return TextView(context).styleAsTile(action = false).also { (it as TextView).text = I18n.get(tile.labelKey) @@ -546,6 +547,65 @@ object SecondScreen { else -> null } + /** + * The cover art tile: the only tile that is a picture rather than text. + * + * Loading goes through the same two sources the library uses -- a user-set custom cover + * file first, then the fetched cover URL -- so the panel shows whatever the library + * shows, including a per-game cover the user chose by hand. The URL path uses Coil's + * ImageLoader directly because a Presentation is plain Views; the file path decodes + * inline, since it is local and already on disk. + * + * Re-resolved on the panel tick rather than once at build, so it follows a game change + * without the panel being rebuilt. + */ + private fun buildCoverTile(): View = + android.widget.ImageView(context).apply { + scaleType = android.widget.ImageView.ScaleType.CENTER_CROP + adjustViewBounds = true + background = android.graphics.drawable.GradientDrawable().apply { + cornerRadius = dp * 14f + setColor(TILE_STAT) + setStroke((dp * 1f).toInt(), BORDER) + } + clipToOutline = true + outlineProvider = object : android.view.ViewOutlineProvider() { + override fun getOutline(v: View, o: android.graphics.Outline) { + o.setRoundRect(0, 0, v.width, v.height, dp * 14f) + } + } + } + + /** The cover currently shown, so the tick only reloads when the game actually changes. */ + private var coverKey: String? = null + + private fun updateCover(view: android.widget.ImageView) { + val game = MainActivityRuntime.currentGame.value + val key = game?.serial ?: game?.title + if (key == coverKey) return + coverKey = key + if (game == null) { view.setImageDrawable(null); return } + val custom = runCatching { com.armsx2.CustomCovers.fileFor(context, game) }.getOrNull() + if (custom != null) { + runCatching { + view.setImageBitmap(android.graphics.BitmapFactory.decodeFile(custom.absolutePath)) + } + return + } + val url = game.coverUrl ?: run { view.setImageDrawable(null); return } + runCatching { + val loader = coil.ImageLoader(context) + val req = coil.request.ImageRequest.Builder(context) + .data(url) + .target( + onSuccess = { d -> view.setImageDrawable(d) }, + onError = { _ -> view.setImageDrawable(null) }, + ) + .build() + loader.enqueue(req) + } + } + /** * A tile's face: the glyph on its own line, larger and in the accent, over the label. * @@ -732,6 +792,7 @@ object SecondScreen { // panel tick — that interval IS the mitigation Cotcho asked about. Cheap to call: // Thermals.poll returns immediately until the interval is up. runCatching { Thermals.poll(context, tempIntervalMs()) } + runCatching { trackAchievements() } // Read charge straight from BatteryManager rather than plumbing state over from the // main-display status cluster — this panel ticks on its own and the call is cheap. @@ -792,6 +853,19 @@ object SecondScreen { SecondScreenTile.GPU_TEMP -> "GPU\n" + (Thermals.format(Thermals.gpu) ?: "—") SecondScreenTile.BATTERY_TEMP -> "BATT\n" + (Thermals.format(Thermals.battery) ?: "—") SecondScreenTile.ACHIEVEMENTS -> achievementSummary() + // The picture tile updates itself; the when only produces text. + SecondScreenTile.COVER -> { + (view as? android.widget.ImageView)?.let { updateCover(it) } + null + } + SecondScreenTile.RA_POINTS -> raPoints() + SecondScreenTile.RA_RECENT -> + I18n.get(tile.labelKey) + "\n" + (lastUnlock ?: "—") + // RetroAchievements' own description of where you are in the game. It is the + // one line that says something a number cannot. + SecondScreenTile.RICH_PRESENCE -> + runCatching { NativeApp.getRichPresence() }.getOrDefault("").ifBlank { null } + ?: (I18n.get(tile.labelKey) + "\n—") // Action tiles that carry state show it, so the panel reads as a status // display and not just a remote control. // State is carried by the GLYPH, not by an extra line. Appending one was @@ -831,20 +905,46 @@ object SecondScreen { * recently THIS SESSION. RetroAchievements' own snapshot carries no unlock timestamp, so * "recent" is tracked by watching the locked→unlocked edge on the panel's own tick rather * than invented from list order. */ - private fun achievementSummary(): String { + /** + * This tick's achievements, parsed ONCE. + * + * Three tiles read this now, and each used to parse the JSON for itself -- so placing all + * three meant three parses of the same string every tick, for identical results. + */ + private var raItems: List = emptyList() + + /** + * Refresh [raItems] and note any new unlock. + * + * Called once per tick regardless of which tiles are placed. It used to live inside the + * Achievements tile's own text builder, which meant the Latest-unlock tile read "—" + * forever unless the Achievements tile happened to be on the panel as well. + */ + private fun trackAchievements() { val json = runCatching { NativeApp.getAchievementsJSON() }.getOrDefault("") - val items = runCatching { com.armsx2.ui.achievements.parseAchievementItems(json) } + raItems = runCatching { com.armsx2.ui.achievements.parseAchievementItems(json) } .getOrDefault(emptyList()) - if (items.isEmpty()) return I18n.get("secondScreen.tile.achievements") + "\n—" - val unlocked = items.filter { it.unlocked } - unlocked.map { it.id }.toSet().let { ids -> - val fresh = ids - seenUnlocked - if (seenUnlocked.isNotEmpty() && fresh.isNotEmpty()) - lastUnlock = unlocked.firstOrNull { it.id in fresh }?.title - seenUnlocked = ids - } + if (raItems.isEmpty()) return + val ids = raItems.filter { it.unlocked }.map { it.id }.toSet() + val fresh = ids - seenUnlocked + // The first poll of a session seeds the set without announcing: everything already + // unlocked is not news. + if (seenUnlocked.isNotEmpty() && fresh.isNotEmpty()) + lastUnlock = raItems.firstOrNull { it.id in fresh }?.title + seenUnlocked = ids + } + + /** Earned / total points, which is the figure RA itself leads with. */ + private fun raPoints(): String { + if (raItems.isEmpty()) return I18n.get("secondScreen.tile.raPoints") + "\n—" + val earned = raItems.filter { it.unlocked }.sumOf { it.points } + return "RA\n$earned/${raItems.sumOf { it.points }}" + } + + private fun achievementSummary(): String { + if (raItems.isEmpty()) return I18n.get("secondScreen.tile.achievements") + "\n—" return buildString { - append(unlocked.size).append('/').append(items.size) + append(raItems.count { it.unlocked }).append('/').append(raItems.size) lastUnlock?.let { append('\n').append(it) } } } diff --git a/platforms/android/app/src/main/java/com/armsx2/SecondScreenTiles.kt b/platforms/android/app/src/main/java/com/armsx2/SecondScreenTiles.kt index fe486ff6d8..d9ec24594a 100644 --- a/platforms/android/app/src/main/java/com/armsx2/SecondScreenTiles.kt +++ b/platforms/android/app/src/main/java/com/armsx2/SecondScreenTiles.kt @@ -39,6 +39,14 @@ enum class SecondScreenTile(val id: String, val labelKey: String, val stat: Bool // Thermals (Cotcho, Mike22). Stat tiles like the rest -- a device with no readable zone // simply shows a dash rather than the tile being hidden, so the grid does not reflow // depending on what the kernel happens to expose. + // The game's cover, and more of what RetroAchievements already knows. The panel had the + // title as text and a bare unlocked-count; on a screen sitting beside you, the cover is what + // makes it read as "this game" at a glance. + COVER("cover", "secondScreen.tile.cover", stat = true), + RA_POINTS("rapoints", "secondScreen.tile.raPoints", stat = true), + RA_RECENT("rarecent", "secondScreen.tile.raRecent", stat = true), + RICH_PRESENCE("presence", "secondScreen.tile.presence", stat = true), + // The rest of what the in-game OSD shows (Mike22). Backed by new JNI getters -- until those // existed, FPS was the only figure the panel could reach. VPS("vps", "secondScreen.tile.vps", stat = true), diff --git a/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt b/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt index cbc00fecec..47656f6324 100644 --- a/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt +++ b/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt @@ -1703,6 +1703,10 @@ private val BASE_EN: Map = mapOf( "touch.editor.scopeGlobal" to "Editing Global Default touch layout", "touch.editor.show" to "Show", "touch.editor.tapHoldOff" to "Tap-Hold Off", + "secondScreen.tile.cover" to "Cover", + "secondScreen.tile.raPoints" to "RA points", + "secondScreen.tile.raRecent" to "Latest unlock", + "secondScreen.tile.presence" to "Now playing", "secondScreen.tile.vps" to "VPS", "secondScreen.tile.cpuLoad" to "EE load", "secondScreen.tile.gsLoad" to "GS load",