Second screen: cover art and more of what RetroAchievements knows

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.
This commit is contained in:
jpolo1224
2026-08-24 12:18:25 -04:00
parent 484bd78b51
commit 740f2a8975
3 changed files with 123 additions and 11 deletions
@@ -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<com.armsx2.ui.achievements.AchievementItem> = 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) }
}
}
@@ -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),
@@ -1703,6 +1703,10 @@ private val BASE_EN: Map<String, String> = 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",