Android: 2D fallback background, portrait status layout, Clear Shader Cache placement, memcard delete wording

This commit is contained in:
jpolo1224
2026-07-30 18:20:12 -04:00
parent 1a45319a6a
commit 12a7e37682
9 changed files with 292 additions and 36 deletions
@@ -340,7 +340,9 @@ val EN: Map<String, String> = mapOf(
"app.theme.custom.g" to "Green",
"app.theme.custom.b" to "Blue",
"app.bgColor" to "Background Color",
"app.bgColor.desc" to "Recolor the animated library background. The white waves ride over your color; the theme accent above stays separate. Updates live as you drag. Has no effect on devices that fall back to the simpler wave background \u2014 some GPUs can't run the animated one, and the fallback isn't recolorable.",
"app.bgColor.desc" to "Recolor the animated library background. The white waves ride over your color; the theme accent above stays separate. Updates live as you drag. Applies to both the 3D GL wave and the simpler 2D wave below.",
"app.bg.simple" to "Simple animated background",
"app.bg.simple.desc" to "Use the lightweight 2D animated wave background instead of the 3D GL one \u2014 the same backdrop devices that can't run the 3D wave already use. Lighter on the GPU, and recolorable with the options above. No effect if you've set a custom background image.",
"app.bgColor.rgb" to "RGB Cycle",
"app.bgColor.rgb.desc" to "Continuously drift the background through the colour spectrum, like RGB peripherals. Overrides the fixed colour below. Same limitation as above: no effect where the fallback background is in use.",
"app.theme.light" to "Light",
@@ -699,6 +701,7 @@ val EN: Map<String, String> = mapOf(
"memcard.cardName.label" to "Card name (A to type)",
"memcard.create" to "Create",
"memcard.delete.confirm" to "Sure?",
"memcard.delete.body" to "Remove \"%s\" from ARMSX2?\n\nThis deletes the card that ARMSX2 keeps in its own memory-card folder. If you imported it from a file on your device, that original file is not touched.",
"memcard.empty" to "No memory-card files found yet.",
"memcard.globalDefault" to "Global default",
"memcard.help" to "Import File adds a memory card (then tap Slot 1 or Slot 2 to use it). Import Folder imports .ps2/.mcr cards found in a folder, or the folder itself as a folder memory card. Export saves a card out to Downloads / Drive / anywhere for backup or moving to another device. Delete removes a card and clears its slot.",
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
@@ -148,13 +149,19 @@ fun ArmsTopBar(
) {
val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
// Also clear a top/centre display cutout (punch-hole camera). In portrait on a tall panel the
// status bar can be hidden or shorter than the camera, so statusBars alone leaves the pill riding
// up under the lens; take the larger of the two so the top bar always sits clear of it.
// (#Isshin — S24 Ultra portrait.) Landscape/no-cutout devices see no change (cutout top is 0).
val cutoutTop = WindowInsets.displayCutout.asPaddingValues().calculateTopPadding()
val topInset = maxOf(statusBarPadding, cutoutTop)
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(
start = horizontalPadding,
end = horizontalPadding,
top = if (bottomEdge) 4.dp else statusBarPadding + 8.dp,
top = if (bottomEdge) 4.dp else topInset + 8.dp,
bottom = if (bottomEdge) navBarPadding + 8.dp else 4.dp,
),
shape = RoundedCornerShape(26.dp),
@@ -218,16 +218,34 @@ fun ClockIndicator(modifier: Modifier = Modifier) {
)
}
/** Clock above battery, for the library toolbar's action row. */
/**
* Clock + battery for the library toolbar's action row.
*
* Landscape stacks them (clock above battery) the wide bar has the height for it. Portrait lays
* them side by side in a single compact row instead: the narrow bar crammed the two-line stack
* between the title and the buttons (#Isshin, S24 Ultra portrait), and one short line reads cleaner
* and leaves the buttons room.
*/
@Composable
fun LibraryStatusCluster(modifier: Modifier = Modifier) {
Column(
modifier,
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.Center,
) {
ClockIndicator()
Spacer(Modifier.height(1.dp))
BatteryIndicator()
fun LibraryStatusCluster(modifier: Modifier = Modifier, compact: Boolean = false) {
if (compact) {
Row(
modifier,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ClockIndicator()
BatteryIndicator()
}
} else {
Column(
modifier,
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.Center,
) {
ClockIndicator()
Spacer(Modifier.height(1.dp))
BatteryIndicator()
}
}
}
@@ -93,6 +93,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.LocalDensity
@@ -173,30 +174,33 @@ fun HomeScreen(
if (libraryBg == null) {
// Default: the live PS3-XMB wave (XmbGlView — a GLES3 port of linkev's
// grid-displacement mesh, matching iOS). When GL can't init — older Mali without
// float-texture filtering, or any EGL failure — we fall back to a looping GIF
// instead of a frozen still. The bundled still is the cheap floor shown during GL
// startup (and, once the wave is up, sits hidden behind it), so capable devices
// never decode the heavy GIF. Custom backgrounds below override all of this.
var xmbGlState by remember { mutableStateOf<Boolean?>(null) } // null=starting, true=up, false=failed
if (xmbGlState == false) {
AsyncImage(
model = ImageRequest.Builder(context).data(R.raw.library_fallback).build(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// float-texture filtering, or any EGL failure — we fall back to LibraryWaveBackground,
// a procedural PPSSPP-style animated background drawn on the hardware 2D Canvas (no
// GLES3, runs anywhere) that reads the SAME colour prefs as the GL wave, so Mali users
// finally get an animated, recolourable backdrop instead of the old fixed GIF. The
// bundled still is the cheap floor shown during GL startup (and, once the wave is up,
// sits hidden behind it). Custom backgrounds below override all of this.
if (LibraryBackground.animated2D.value) {
// User opted into the lightweight 2D animated wave everywhere (#Luminz) — the same
// backdrop GL-fail devices get; skip the GLES3 XmbGlView entirely.
LibraryWaveBackground(Modifier.fillMaxSize())
} else {
Image(
painter = painterResource(R.drawable.library_bg_xmb),
contentDescription = null,
var xmbGlState by remember { mutableStateOf<Boolean?>(null) } // null=starting, true=up, false=failed
if (xmbGlState == false) {
LibraryWaveBackground(Modifier.fillMaxSize())
} else {
Image(
painter = painterResource(R.drawable.library_bg_xmb),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
AndroidView(
factory = { XmbGlView(it).apply { onGlStatus = { ok -> xmbGlState = ok } } },
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
AndroidView(
factory = { XmbGlView(it).apply { onGlStatus = { ok -> xmbGlState = ok } } },
modifier = Modifier.fillMaxSize(),
)
} else {
// User-picked still image / GIF (Coil handles both).
AsyncImage(
@@ -372,6 +376,9 @@ fun HomeScreen(
// align(): the title block makes the bar taller than this two-line
// cluster, so without it the pair sits high relative to the buttons.
Modifier.align(Alignment.CenterVertically).padding(end = 6.dp),
// Portrait: single compact row so the narrow bar doesn't cram it.
compact = LocalConfiguration.current.orientation ==
android.content.res.Configuration.ORIENTATION_PORTRAIT,
)
RoundAction(
"",
@@ -16,13 +16,29 @@ import com.armsx2.runtime.MainActivityRuntime
*/
object LibraryBackground {
private const val PREF = "library.background.uri"
private const val PREF_ANIM = "library.background.animated2d"
val uri = mutableStateOf<String?>(null)
/**
* Force the lightweight 2D animated background ([LibraryWaveBackground]) even on devices where
* the GLES3 XMB wave ([XmbGlView]) would run i.e. let capable devices opt into the same
* backdrop Mali / GL-fail devices already get. A user preference (#Luminz). Off = the GL wave.
* No effect when a custom background image is set (that overrides everything), and no effect for
* the devices that already fall back to the 2D wave.
*/
val animated2D = mutableStateOf(false)
private var loaded = false
fun ensureLoaded() {
if (loaded) return
loaded = true
uri.value = runCatching { MainActivityRuntime.prefs.getString(PREF, null) }.getOrNull()
animated2D.value = runCatching { MainActivityRuntime.prefs.getBoolean(PREF_ANIM, false) }.getOrDefault(false)
}
fun setAnimated2D(on: Boolean) {
animated2D.value = on
runCatching { MainActivityRuntime.prefs.edit().putBoolean(PREF_ANIM, on).apply() }
}
fun set(context: Context, value: Uri) {
@@ -0,0 +1,194 @@
package com.armsx2.ui.home
import androidx.compose.animation.core.withInfiniteAnimationFrameNanos
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import com.armsx2.ui.theme.LibraryBackgroundColorPreferences
import kotlin.math.PI
import kotlin.math.sin
/**
* Animated library background for devices where the GLES3 [XmbGlView] can't run older Mali
* without float-texture filtering, or any EGL failure. It used to fall back to a fixed looping GIF
* (R.raw.library_fallback) that ignored the colour picker entirely, so Mali users had a background
* they couldn't recolour. This is a PPSSPP-style procedural background instead: soft flowing waves
* plus a few drifting PlayStation glyphs, drawn with the hardware 2D Canvas (Skia) rather than GLES3
* so it runs on ANY GPU, and it reads the SAME [LibraryBackgroundColorPreferences] the GL wave
* does, so the colour swatches and the RGB hue-cycle finally do something on a Mali device.
*
* Deliberately cheap: one vertical gradient, four stroked sine paths, and ten small glyphs per
* frame a few hundred segments, nothing a weak tiler struggles with. No textures, no blur, no
* offscreen passes. The readability scrim is applied by HomeScreen on top, same as for the GL wave.
*/
@Composable
fun LibraryWaveBackground(modifier: Modifier = Modifier) {
// Colour picker + RGB toggle, read as Compose state so a live change recolours immediately.
val colorArgb by LibraryBackgroundColorPreferences.color
val rgbCycle by LibraryBackgroundColorPreferences.rgbCycle
// Elapsed seconds, ticked once per frame. Driven off the animation clock (not a recomposition
// loop) so only the Canvas draw re-runs each frame, not the whole tree.
val timeSec = remember { mutableFloatStateOf(0f) }
LaunchedEffect(Unit) {
var start = 0L
withInfiniteAnimationFrameNanos { start = it }
while (true) {
withInfiniteAnimationFrameNanos { now ->
timeSec.floatValue = (now - start) / 1_000_000_000f
}
}
}
Canvas(modifier) {
val t = timeSec.floatValue
// Base colour: the RGB cycle sweeps the hue wheel (~28s/turn, matching the GL peripheral
// vibe); otherwise the picked colour, or the built-in royal blue when unset.
val base: Color = when {
rgbCycle -> Color.hsv(((t / 28f) * 360f) % 360f, 0.72f, 0.96f)
colorArgb == 0 -> DEFAULT_WAVE
else -> Color(colorArgb)
}
drawWaveScene(t, base)
}
}
/** The built-in wave colour — matches XmbGlView.BG_BOT / LibraryBackgroundColorPreferences default. */
private val DEFAULT_WAVE = Color(0xFF2E75F5)
private fun DrawScope.drawWaveScene(t: Float, base: Color) {
val w = size.width
val h = size.height
// 1) Vertical gradient — near-black anchor at the top (where the content/grid sits), deepening
// to the chosen colour at the bottom. Same 0.20 top/bottom ratio the GL path uses.
drawRect(
Brush.verticalGradient(
0.0f to base.scaleRgb(0.10f),
0.55f to base.scaleRgb(0.35f),
1.0f to base.scaleRgb(0.85f),
),
)
// 2) Soft flowing wave BANDS — filled translucent sheets that glow just under the crest and
// fade downward, layered back-to-front. Reads as flowing light, not thin squiggly lines.
val samples = 64
val step = w / samples
val twoPi = 2f * PI.toFloat()
for (layer in 0 until WAVE_LAYERS) {
val f = layer.toFloat() / (WAVE_LAYERS - 1)
val baseY = h * (0.40f + 0.16f * f) // deeper layers sit lower
val amp = h * (0.05f + 0.028f * (1f - f)) // gentle undulation
val len = 1.05f + 0.5f * f
val speed = 0.26f + 0.14f * f
val phase = t * speed + layer * 2.2f
fun waveY(nx: Float): Float =
baseY + amp * sin(nx * len * twoPi + phase) +
amp * 0.34f * sin(nx * len * 2.1f * twoPi - phase * 1.35f + layer)
// Filled sheet from the crest curve down past the bottom edge.
val body = Path().apply {
moveTo(0f, h + 2f)
var i = 0
while (i <= samples) { lineTo(i * step, waveY(i.toFloat() / samples)); i++ }
lineTo(w, h + 2f)
close()
}
val tint = base.lighten(0.30f + 0.22f * f)
drawPath(
path = body,
brush = Brush.verticalGradient(
0.00f to tint.copy(alpha = 0f),
0.05f to tint.copy(alpha = 0.10f + 0.05f * f), // soft glow under the crest
0.55f to base.scaleRgb(1.06f).copy(alpha = 0.04f + 0.03f * f),
1.00f to base.scaleRgb(0.75f).copy(alpha = 0f),
startY = baseY - amp * 1.6f,
endY = h,
),
)
// Faint, wide, soft crest — enough to define the wave without reading as a hard line.
val crest = Path().apply {
var i = 0
while (i <= samples) {
val x = i * step; val y = waveY(i.toFloat() / samples)
if (i == 0) moveTo(x, y) else lineTo(x, y); i++
}
}
drawPath(
path = crest,
color = base.lighten(0.62f).copy(alpha = 0.07f + 0.07f * f),
style = Stroke(width = size.minDimension * (0.008f + 0.004f * f), cap = StrokeCap.Round),
)
}
// 3) Drifting PlayStation glyphs — a faint, slow parallax layer, the PPSSPP "floating symbols"
// flavour. Fixed pseudo-random spots (deterministic, no RNG per frame) rising and looping.
val glyphColor = base.lighten(0.6f).copy(alpha = 0.06f)
for (i in GLYPH_SPOTS.indices) {
val (sx, sy, kind, scale) = GLYPH_SPOTS[i]
val drift = (t * (0.012f + 0.006f * (i % 3))) + sy
val y = h * (1.1f - (drift % 1.2f)) // rise from below, loop past the top
val x = w * ((sx + 0.02f * sin(t * 0.2f + i)) % 1f)
val r = size.minDimension * 0.04f * scale
drawGlyph(kind, Offset(x, y), r, glyphColor, t + i)
}
}
private const val WAVE_LAYERS = 4
/** (xFrac, yPhase, kind 0..3 = △○✕□, scale). Deterministic so nothing allocates per frame. */
private val GLYPH_SPOTS: List<Glyph> = listOf(
Glyph(0.10f, 0.05f, 0, 1.1f), Glyph(0.24f, 0.55f, 1, 0.8f), Glyph(0.38f, 0.30f, 2, 1.0f),
Glyph(0.52f, 0.80f, 3, 0.9f), Glyph(0.63f, 0.15f, 1, 1.2f), Glyph(0.71f, 0.62f, 0, 0.75f),
Glyph(0.82f, 0.40f, 3, 1.05f), Glyph(0.90f, 0.90f, 2, 0.85f), Glyph(0.46f, 0.05f, 0, 0.7f),
Glyph(0.16f, 0.72f, 3, 0.95f),
)
private data class Glyph(val x: Float, val y: Float, val kind: Int, val scale: Float)
private fun DrawScope.drawGlyph(kind: Int, c: Offset, r: Float, color: Color, spin: Float) {
val stroke = Stroke(width = r * 0.14f, cap = StrokeCap.Round)
when (kind) {
1 -> drawCircle(color, radius = r * 0.82f, center = c, style = stroke) // ○
3 -> rotate(spin * 6f, pivot = c) { // □
val s = r * 1.35f
drawRect(color, topLeft = Offset(c.x - s / 2, c.y - s / 2),
size = androidx.compose.ui.geometry.Size(s, s), style = stroke)
}
2 -> { // ✕
val s = r * 0.7f
drawLine(color, Offset(c.x - s, c.y - s), Offset(c.x + s, c.y + s), strokeWidth = r * 0.16f, cap = StrokeCap.Round)
drawLine(color, Offset(c.x - s, c.y + s), Offset(c.x + s, c.y - s), strokeWidth = r * 0.16f, cap = StrokeCap.Round)
}
else -> { // △
val p = Path().apply {
moveTo(c.x, c.y - r)
lineTo(c.x + r * 0.87f, c.y + r * 0.5f)
lineTo(c.x - r * 0.87f, c.y + r * 0.5f)
close()
}
drawPath(p, color, style = stroke)
}
}
}
// ---- small colour helpers -------------------------------------------------
/** Scale RGB by [v] (toward black for v<1, a touch brighter for v>1), clamped to valid range. */
private fun Color.scaleRgb(v: Float) = Color(
(red * v).coerceIn(0f, 1f), (green * v).coerceIn(0f, 1f), (blue * v).coerceIn(0f, 1f), alpha,
)
/** Blend toward white by [amount] — the ribbon/glyph highlight tint. */
private fun Color.lighten(amount: Float) = Color(
red + (1f - red) * amount, green + (1f - green) * amount, blue + (1f - blue) * amount, alpha,
)
@@ -125,7 +125,7 @@ fun MemoryCardScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: Memo
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text(str("memcard.delete.confirm")) },
text = { Text(item.file.name) },
text = { Text(str("memcard.delete.body").format(item.file.name)) },
confirmButton = { TextButton(onClick = { viewModel.delete(item); deleteTarget = null }, modifier = Modifier.controllerFocusable("memcard.delete.confirm", onConfirm = { viewModel.delete(item); deleteTarget = null })) { Text(str("action.delete"), color = MaterialTheme.colorScheme.error) } },
dismissButton = { TextButton(onClick = { deleteTarget = null }, modifier = Modifier.controllerFocusable("memcard.delete.cancel", onConfirm = { deleteTarget = null })) { Text(str("action.cancel")) } },
)
@@ -207,6 +207,15 @@ fun AppTab() {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Opt into the lightweight 2D animated backdrop everywhere (the same one older Mali /
// GL-fail devices already get) instead of the GLES3 XMB wave. The colour options below
// apply to it too. No effect if a custom background image is set.
ToggleRow(
label = str("app.bg.simple"),
value = com.armsx2.ui.home.LibraryBackground.animated2D.value,
description = str("app.bg.simple.desc"),
onChange = { com.armsx2.ui.home.LibraryBackground.setAnimated2D(it) },
)
// Continuous RGB hue-cycle — same idea as the theme's RGB mode. While on, the fixed
// color (presets + sliders) doesn't apply, so it's hidden.
ToggleRow(
@@ -107,6 +107,11 @@ fun RendererTab(state: MutableState<Settings>) {
// from the removed first-run setup renderer page into settings.
RendererBackendSection(state)
SettingsDivider()
// Clear Shader Cache — directly under the GPU driver picker. Switching the Vulkan driver
// is exactly when you want to wipe the on-disk shader cache so it recompiles on the new
// one, so the control lives with the driver rather than buried lower in the tab.
ClearShaderCacheRow()
SettingsDivider()
// GS Multi-threading (GV7 front/back split), placed right under the
// renderer/driver picker. Off = today's single-threaded path (the
// default — opt-in); On = the GS runs on a dedicated back thread
@@ -554,9 +559,6 @@ fun RendererTab(state: MutableState<Settings>) {
apply(s.copy(gpuProfile = it))
},
)
SettingsDivider()
ClearShaderCacheRow()
}
}
}