Second screen: cover and RetroAchievements in the default layout

Shipping them as chips nobody would find was the wrong call -- a tile that
has to be discovered in a settings screen may as well not exist. Cover,
Achievements, RA points and rich presence are now part of the default panel.

A saved layout still wins, because that is what saving it is for. But a
layout that is byte-for-byte the OLD default is not a choice anyone made,
it is just what they were handed, so those upgrade once to the new default.
Anything actually arranged is left alone.

The cover tile also gets a visible fallback. With no game, or before the art
downloads, it was an empty box -- and an empty box is indistinguishable from
a broken tile. It now shows "Cover" with no game and the game's title once
there is one, with the picture drawn over the top when it arrives. A custom
cover file that cannot be decoded falls through to the fetched URL instead of
leaving the tile blank.
This commit is contained in:
jpolo1224
2026-08-24 12:35:01 -04:00
parent 42eb718fbd
commit c109f45ab0
2 changed files with 72 additions and 20 deletions
@@ -559,10 +559,27 @@ object SecondScreen {
* 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 {
private var coverImage: android.widget.ImageView? = null
private var coverLabel: TextView? = null
private fun buildCoverTile(): View {
val image = android.widget.ImageView(context).apply {
scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
adjustViewBounds = true
}
// A label UNDER the image rather than instead of it. With no game, or before the
// fetch lands, an empty box is indistinguishable from a broken tile -- and this tile
// is blank far more often than the text ones, because it depends on a download.
val label = TextView(context).apply {
gravity = Gravity.CENTER
setTextColor(TEXT_DIM)
textSize = 13f
maxLines = 3
text = I18n.get("secondScreen.tile.cover")
}
coverImage = image
coverLabel = label
return android.widget.FrameLayout(context).apply {
background = android.graphics.drawable.GradientDrawable().apply {
cornerRadius = dp * 14f
setColor(TILE_STAT)
@@ -574,35 +591,44 @@ object SecondScreen {
o.setRoundRect(0, 0, v.width, v.height, dp * 14f)
}
}
addView(label, android.widget.FrameLayout.LayoutParams(-1, -1))
addView(image, android.widget.FrameLayout.LayoutParams(-1, -1))
}
}
/** 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) {
private fun updateCover() {
val view = coverImage ?: return
val game = MainActivityRuntime.currentGame.value
val key = game?.serial ?: game?.title
if (key == coverKey) return
coverKey = key
// The label says what the tile IS while there is no picture, and what the GAME is
// once there is a game but no art yet.
coverLabel?.text = game?.title ?: I18n.get("secondScreen.tile.cover")
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 bmp = runCatching {
android.graphics.BitmapFactory.decodeFile(custom.absolutePath)
}.getOrNull()
if (bmp != null) { view.setImageBitmap(bmp); return }
// Fall through to the URL when a custom cover is set but unreadable.
}
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)
coil.ImageLoader(context).enqueue(
coil.request.ImageRequest.Builder(context)
.data(url)
.target(
onSuccess = { d -> view.setImageDrawable(d) },
// Leave the label showing rather than a blank box.
onError = { _ -> view.setImageDrawable(null) },
)
.build(),
)
}
}
@@ -865,10 +891,7 @@ object SecondScreen {
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.COVER -> { updateCover(); null }
SecondScreenTile.RA_POINTS -> raPoints()
SecondScreenTile.RA_RECENT ->
I18n.get(tile.labelKey) + "\n" + (lastUnlock ?: "")
@@ -88,6 +88,30 @@ object SecondScreenLayout {
/** What the panel showed before it was customisable, so an existing user's panel is unchanged
* by the update and a new one starts somewhere sensible. */
private val DEFAULT = listOf(
SecondScreenTile.COVER,
SecondScreenTile.TITLE,
SecondScreenTile.FPS,
SecondScreenTile.BATTERY,
SecondScreenTile.CLOCK,
SecondScreenTile.ACHIEVEMENTS,
SecondScreenTile.RA_POINTS,
SecondScreenTile.RICH_PRESENCE,
SecondScreenTile.SAVE,
SecondScreenTile.LOAD,
SecondScreenTile.FAST_FORWARD,
SecondScreenTile.PAUSE,
SecondScreenTile.SCREENSHOT,
)
/**
* The default as it stood before cover art and the RetroAchievements read-outs existed.
*
* Kept so the new defaults can reach people who never customised their panel. A saved layout
* normally wins over DEFAULT -- that is the whole point of saving it -- but a layout that is
* byte-for-byte the OLD default is not a choice anyone made, it is just what they were given.
* Those get upgraded once; anything the user actually arranged is left alone.
*/
private val DEFAULT_V1 = listOf(
SecondScreenTile.TITLE,
SecondScreenTile.FPS,
SecondScreenTile.BATTERY,
@@ -131,6 +155,11 @@ object SecondScreenLayout {
runCatching {
val raw = MainActivityRuntime.prefs.getString(PREF_TILES, null)
tiles = if (raw == null) DEFAULT else parse(raw)
// One-time upgrade for panels still sitting on the untouched old default.
if (tiles == DEFAULT_V1) {
tiles = DEFAULT
persist()
}
columnCount = MainActivityRuntime.prefs.getInt(PREF_COLUMNS, 3).coerceIn(1, 6)
tileHeightDp = MainActivityRuntime.prefs.getInt(PREF_TILE_HEIGHT, 0).coerceIn(0, 200)
}