Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 1.0-test255 (2026-10-22)
- UI review on the Odin (test254, 20 screens): Home, All games (every tab), Add apps, the game menus, Consoles and every Settings group. Measured: cold start 327 ms; janky frames 0.6% moving along Home, 0.4% scrolling All games, 0.5% moving down the Settings groups (5-12 ms frames).
- Fixed: lists ran on under the hint bar (Consoles' "Find my Xbox" drew over the controller name; a sliver of All games' next row showed below it). A view is clipped by its parent and the content area doesn't clip, so the All games grid, the list pages and the Settings list now sit in a frame that clips them.

## 1.0-test254 (2026-10-22)
- UI review, round 2:
1. **Phones** (no built-in controller): tabs, chips, Back and the settings groups are at least 48 dp tall (they were 23-35 dp on a 1080p phone), and text follows Android's Font size (up to 1.3x) on the usual layout. Handhelds with controls (the Odin) look the same.
2. **One row style** for Consoles, Settings and dialogs: the same icon square (72, corner 18), title weight, subtitle spacing and a value on the right in the quiet colour.
3. **Quick menu**: Quit game and other dangerous items are red on the handheld too, the hints read A then B on both apps, the upright sheet's list scrolls so the hints never fall off, and TV rows grow for a long label instead of being cut.
4. **All games**: no shadow per grid tile (smoother scrolling with many PC games), and a redraw in the background keeps your place in All games and in Home's row instead of jumping back to the start.
5. **TV**: lays itself out again when the display changes (HDMI mode, resolution) instead of keeping the old scale until a restart, and covers without box art use the handheld's colours (`Covers`), so a game has the same colour on both.

## 1.0-test253 (2026-10-22)
- UI review, first round (handheld): faster and lighter, same look.
- Settings: moving down the groups rebuilt the whole screen at every step; it now waits until focus rests on a group (160 ms).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ class HandheldHomeActivity : Activity() {
private fun buildShell() {
portrait = isPortrait()
sw = Sw(this, prefs.getString("hh_theme", "dark") == "light", portrait, Sw.pick(this, portrait, prefs.getString("hh_screen_size", "auto")))
sw.touchFirst = !hasController()
sw.bars = systemBarSizes()
root = FrameLayout(this).apply { setBackgroundColor(sw.bg) }
wash = Wash(this)
Expand Down Expand Up @@ -683,7 +684,7 @@ class HandheldHomeActivity : Activity() {
} else {
// a back button you can tap: a touch screen has no B (the same as B / the system back)
topBar.addView(roundButton("top:back", "back", sw.ink, 84, sw.panel) { onBackPressed() }.apply { isFocusable = false; contentDescription = "Back" },
sw.lp(84, 84).apply { rightMargin = sw.px(24) })
LinearLayout.LayoutParams(sw.tap(84), sw.tap(84)).apply { rightMargin = sw.px(24) })
topBar.addView(sw.text(page.title, 44, sw.ink, 800))
}
topBar.addView(sw.grow())
Expand Down Expand Up @@ -790,7 +791,10 @@ class HandheldHomeActivity : Activity() {
} else {
val row = sw.row().apply { clipChildren = false; setPadding(sw.px(e), 0, sw.px(e), 0) }
tiles.forEachIndexed { i, t -> row.addView(t, sw.lp(ts, ts).apply { if (i > 0) leftMargin = sw.px(gap) }) }
val keepX = homeScrollX
val scroll = RowScroll(this, sw.px(e)).apply {
if (keepX > 0) post { scrollTo(keepX, 0) }
if (Build.VERSION.SDK_INT >= 23) setOnScrollChangeListener { _, x, _, _, _ -> homeScrollX = x }
isHorizontalScrollBarEnabled = false; isFocusable = false; clipChildren = false; clipToPadding = false
isHorizontalFadingEdgeEnabled = true; setFadingEdgeLength(sw.px(90))
overScrollMode = View.OVER_SCROLL_NEVER
Expand Down Expand Up @@ -881,7 +885,9 @@ class HandheldHomeActivity : Activity() {
f.setTag(R.id.hh_tint, if (appColor != 0) appColor else if (g != null) sw.tint(g.seed) else 0)
f.clipToOutline = true
f.outlineProvider = rounded(sw.tileCorner)
f.elevation = sw.pxf(8) // a soft shadow lifts the tiles off the background
// a soft shadow lifts the tiles off the background on Home; All games' grid goes without (one shadow per tile
// to draw for every cover while scrolling)
if (page != Page.GAMES) f.elevation = sw.pxf(8)
sw.pressable(f)
if (g == null) {
f.background = if (tag == "g:all") sw.allArt() else sw.art("add") // g:add and g:devadd
Expand Down Expand Up @@ -1015,9 +1021,9 @@ class HandheldHomeActivity : Activity() {
background = sw.round(if (on) sw.ink else sw.panel, 32)
sw.focusable(this)
setOnClickListener { if (gamesTab != k) { gamesTab = k; FeelSounds.play(this@HandheldHomeActivity, FeelSounds.Cue.TAB); render(force = true) } }
}, sw.lp(-2, 64).apply { if (i > 0) leftMargin = sw.px(14) })
}, LinearLayout.LayoutParams(-2, sw.tap(64)).apply { if (i > 0) leftMargin = sw.px(14) })
}
col.addView(part(tabs), sw.lp(-2, 64).apply { topMargin = sw.px(20) })
col.addView(part(tabs), LinearLayout.LayoutParams(-2, sw.tap(64)).apply { topMargin = sw.px(20) })

val recent = Recents.list(this)
var list = allGames().filter { g -> when (gamesTab) { "pc" -> g.pc != null; "ps" -> g.ps != null; "xb" -> g.xb != null; "dev" -> g.dev != null; else -> true } }
Expand Down Expand Up @@ -1049,11 +1055,16 @@ class HandheldHomeActivity : Activity() {
})
}
primaryView = grid.getChildAt(0)
col.addView(part(ColScroll(this, sw.px(40)).apply {
// a redraw in the background (a poll, a game starting on the PC) kept you where you were, not back at the top
val keepY = if (gamesScrollTab == gamesTab) gamesScrollY else 0
gamesScrollTab = gamesTab
col.addView(part(clipped(ColScroll(this, sw.px(40)).apply {
isVerticalScrollBarEnabled = false; isFocusable = false; clipToPadding = false; clipChildren = false
setPadding(0, sw.px(26), 0, sw.px(26))
addView(grid)
}), LinearLayout.LayoutParams(-1, 0, 1f).apply { topMargin = sw.px(20) })
if (keepY > 0) post { scrollTo(0, keepY) }
if (Build.VERSION.SDK_INT >= 23) setOnScrollChangeListener { _, _, y, _, _ -> gamesScrollY = y }
})), LinearLayout.LayoutParams(-1, 0, 1f).apply { topMargin = sw.px(20) })
return col
}

Expand Down Expand Up @@ -1094,12 +1105,19 @@ class HandheldHomeActivity : Activity() {
return scrollPane(list)
}

private fun scrollPane(list: LinearLayout): View = ColScroll(this, sw.px(40)).apply {
private fun scrollPane(list: LinearLayout): View = clipped(ColScroll(this, sw.px(40)).apply {
isVerticalScrollBarEnabled = false; isFocusable = false; clipToPadding = false; clipChildren = false
val side = sw.edge
setPadding(sw.px(side), sw.px(30), sw.px(side), sw.px(30))
addView(part(list))
}
})

/**
* A scrolling list inside a frame that clips it at its edges. A view is clipped by its parent, and the content area
* doesn't clip (tiles and the focus ring may spill over), so rows scrolled below the bottom drew over the hint bar
* (Consoles' Find my Xbox, a sliver of All games' next row). The focus ring draws on its own layer.
*/
private fun clipped(v: View): FrameLayout = FrameLayout(this).apply { clipChildren = true; addView(v, FrameLayout.LayoutParams(-1, -1)) }

private fun groupHeader(list: LinearLayout, title: String) =
list.addView(sw.text(title, 26, sw.sub, 800), sw.lp(-2, -2).apply { topMargin = sw.px(40); bottomMargin = sw.px(14); leftMargin = sw.px(26) })
Expand All @@ -1111,13 +1129,13 @@ class HandheldHomeActivity : Activity() {
setPadding(sw.px(26), sw.px(if (sw.compact) 24 else 18), sw.px(26), sw.px(if (sw.compact) 24 else 18))
minimumHeight = sw.px(if (sw.compact) 128 else 116)
// a symbol fills its square; a short word (PC, PS) stays the size of a badge
addView(sw.text(icon, if (icon.length == 1) 34 else 22, 0xFFFFFFFF.toInt(), 800).apply { gravity = Gravity.CENTER; background = sw.round(iconColor, 16) },
sw.lp(68, 68).apply { rightMargin = sw.px(26) })
addView(sw.text(icon, if (icon.length == 1) 36 else 23, 0xFFFFFFFF.toInt(), 800).apply { gravity = Gravity.CENTER; background = sw.round(iconColor, ROW_ICON_R) },
sw.lp(ROW_ICON, ROW_ICON).apply { rightMargin = sw.px(26) })
addView(sw.col().apply {
addView(sw.text(title, 30, sw.ink, 700))
if (sub != null) addView(sw.text(sub, 23, sw.sub, 600, 2), sw.lp(-2, -2).apply { topMargin = sw.px(sw.lineGap) })
}, LinearLayout.LayoutParams(0, -2, 1f))
if (right != null) addView(sw.text(right, 28, sw.ring, 700), sw.lp(-2, -2).apply { leftMargin = sw.px(24) })
if (right != null) addView(sw.text(right, ROW_VALUE, sw.sub, 700), sw.lp(-2, -2).apply { leftMargin = sw.px(24) })
sw.focusable(this)
setOnClickListener { onClick() }
}, sw.lp(-1, -2))
Expand Down Expand Up @@ -1268,6 +1286,10 @@ class HandheldHomeActivity : Activity() {
}), listOf("Close" to {}))
}

private var gamesScrollY = 0
private var gamesScrollTab: String? = null
private var homeScrollX = 0

/** The picker's filter: "games" (what Android marks as games, the usual pick) or "all". */
private var pickerFilter = "games"

Expand Down Expand Up @@ -1310,9 +1332,9 @@ class HandheldHomeActivity : Activity() {
if (pickerFilter != k) { pickerFilter = k; FeelSounds.play(this@HandheldHomeActivity, FeelSounds.Cue.TAB); showPicker()
this@HandheldHomeActivity.overlay.findViewWithTag<View>("chip:$k")?.requestFocus() }
}
}, sw.lp(-2, 60).apply { if (i > 0) leftMargin = sw.px(14) })
}, LinearLayout.LayoutParams(-2, sw.tap(60)).apply { if (i > 0) leftMargin = sw.px(14) })
}
card.addView(chips, sw.lp(-2, 60).apply { leftMargin = sw.px(56); topMargin = sw.px(22) })
card.addView(chips, LinearLayout.LayoutParams(-2, sw.tap(60)).apply { leftMargin = sw.px(56); topMargin = sw.px(22) })

// the grid: as many columns as fit cells about 210 wide in the card
val cardW = minOf(1560f, sw.designWidth - 80)
Expand Down Expand Up @@ -1392,16 +1414,16 @@ class HandheldHomeActivity : Activity() {
render(force = true)
content.findViewWithTag<View>("settings:list")?.let { firstFocusable(it)?.requestFocus() }
}
}, sw.lp(-1, 92))
}, LinearLayout.LayoutParams(-1, sw.tap(92)))
}
row.addView(part(side), sw.lp(if (sw.compact) 330 else 600, -2))
val list = sw.col().apply { tag = "settings:list" }
settingsRows(list, settingsGroup)
row.addView(part(ColScroll(this, sw.px(40)).apply {
row.addView(part(clipped(ColScroll(this, sw.px(40)).apply {
isVerticalScrollBarEnabled = false; isFocusable = false; clipToPadding = false; clipChildren = false
setPadding(sw.px(if (sw.compact) 20 else 40), sw.px(20), sw.px(sw.edge), sw.px(30))
addView(list)
}), LinearLayout.LayoutParams(0, -1, 1f))
})), LinearLayout.LayoutParams(0, -1, 1f))
primaryView = side.findViewWithTag<View>("group:$settingsGroup")
return row
}
Expand Down Expand Up @@ -2163,13 +2185,13 @@ class HandheldHomeActivity : Activity() {
minimumHeight = sw.px(116)
if (!o.enabled) alpha = 0.45f
// a symbol fills its square; a short word (PC, PS, TV) stays the size of a badge
if (o.icon != null) addView(sw.text(o.icon, if (o.icon.length == 1) 38 else 24, 0xFFFFFFFF.toInt(), 800).apply { gravity = Gravity.CENTER; background = sw.round(o.iconColor, 18) },
sw.lp(76, 76).apply { rightMargin = sw.px(26) })
if (o.icon != null) addView(sw.text(o.icon, if (o.icon.length == 1) 36 else 23, 0xFFFFFFFF.toInt(), 800).apply { gravity = Gravity.CENTER; background = sw.round(o.iconColor, ROW_ICON_R) },
sw.lp(ROW_ICON, ROW_ICON).apply { rightMargin = sw.px(26) })
addView(sw.col().apply {
addView(sw.text(o.title, 30, sw.ink, 800))
if (o.sub != null) addView(sw.text(o.sub, 23, sw.sub, 600, 2), sw.lp(-2, -2).apply { topMargin = sw.px(6) })
addView(sw.text(o.title, 30, sw.ink, 700))
if (o.sub != null) addView(sw.text(o.sub, 23, sw.sub, 600, 2), sw.lp(-2, -2).apply { topMargin = sw.px(sw.lineGap) })
}, LinearLayout.LayoutParams(0, -2, 1f))
if (o.right != null) addView(sw.text(o.right, 22, sw.sub, 700), sw.lp(-2, -2).apply { leftMargin = sw.px(20) })
if (o.right != null) addView(sw.text(o.right, ROW_VALUE, sw.sub, 700), sw.lp(-2, -2).apply { leftMargin = sw.px(24) })
if (o.enabled) {
sw.focusable(this)
setOnClickListener { val n = FeelSounds.serial; hideOverlay(false); o.onClick(); if (n == FeelSounds.serial) FeelSounds.play(this@HandheldHomeActivity, FeelSounds.Cue.SELECT) }
Expand Down Expand Up @@ -2486,5 +2508,9 @@ class HandheldHomeActivity : Activity() {
private const val REQ_PSN_REGISTER = 41
private const val REQ_PSN = 42
private const val REQ_PICTURE = 43
// one look for every list row (Consoles, Settings, dialogs): the icon square, its corner, a value on the right
private const val ROW_ICON = 72
private const val ROW_ICON_R = 18
private const val ROW_VALUE = 24
}
}
31 changes: 15 additions & 16 deletions app/src/handheld/java/com/limelight/handheld/Sw.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ class Sw(ctx: Context, val light: Boolean, val portrait: Boolean, val size: Size
Size.COMPACT -> if (portrait) Tv(ctx, false, upright, 0) else Tv(ctx, false, 1240, 1080)
Size.LARGE -> if (portrait) Tv(ctx, false, upright * 940 / 1080, 0) else Tv(ctx, false, 1080, 940)
}
/**
* A phone with no game controller: tabs, chips, Back and the settings groups get at least Android's 48 dp touch
* size (on a 1080p phone the canvas made them 23-35 dp). Handhelds with built-in controls (the Odin) keep their look.
*/
var touchFirst = false
private val dp48 = (48 * ctx.resources.displayMetrics.density).toInt()
/** A height in design px, at least 48 dp on a touch-first phone. */
fun tap(h: Number): Int = if (touchFirst) maxOf(px(h), dp48) else px(h)
/** Android's Font size on the usual layout (compact layouts already step up with it), up to 1.3x. */
private val fontScale = if (size == Size.NORMAL) ctx.resources.configuration.fontScale.coerceIn(1f, 1.3f) else 1f

/** The status bar, navigation and cutout, in px: the menus are laid out inside them (set by the activity). */
var bars = Rect()
/** The design canvas height this screen really has for the app, inside the system bars. */
Expand Down Expand Up @@ -111,23 +122,11 @@ class Sw(ctx: Context, val light: Boolean, val portrait: Boolean, val size: Size
const val C_GREEN = 0xFF2DBE5A.toInt()
const val C_RED = 0xFFE9605A.toInt()

/** Cover colours when there's no box art (a1..a8): highlight, body, shadow. */
val ART = arrayOf(
intArrayOf(0xFFF05A45.toInt(), 0xFFA01E16.toInt(), 0xFF4A0B08.toInt()),
intArrayOf(0xFF3D86FF.toInt(), 0xFF1035A0.toInt(), 0xFF071644.toInt()),
intArrayOf(0xFF3CCB63.toInt(), 0xFF0E7A2C.toInt(), 0xFF06300F.toInt()),
intArrayOf(0xFFF4C04E.toInt(), 0xFFB87414.toInt(), 0xFF4C2C06.toInt()),
intArrayOf(0xFFA06BF0.toInt(), 0xFF5223A8.toInt(), 0xFF1F0B47.toInt()),
intArrayOf(0xFF4FD6E3.toInt(), 0xFF127C8A.toInt(), 0xFF06323A.toInt()),
intArrayOf(0xFFF06BA8.toInt(), 0xFFA3205E.toInt(), 0xFF420A25.toInt()),
intArrayOf(0xFFB9C0CC.toInt(), 0xFF5B6372.toInt(), 0xFF22262E.toInt()))
/** Cover colours when there's no box art: shared with the TV (Covers), so a game looks the same on both. */
val ART = com.limelight.home.Covers.ART

/** PlayStation is always blue, Xbox always green, anything else picks by name. */
fun artIndex(seed: String): Int = when (seed) {
"ps" -> 1
"xb" -> 2
else -> Math.abs(seed.hashCode() % ART.size).let { if (it == 1 || it == 2) it + 4 else it }
}
fun artIndex(seed: String): Int = com.limelight.home.Covers.index(seed)
}

fun px(v: Number) = tv.px(v)
Expand All @@ -137,7 +136,7 @@ class Sw(ctx: Context, val light: Boolean, val portrait: Boolean, val size: Size

fun ui(w: Int = 700) = tv.ui(w)
fun text(s: CharSequence, size: Number, color: Int = ink, weight: Int = 700, lines: Int = 1): TextView =
tv.text(s, size, color, tv.ui(weight), lines)
tv.text(s, size.toFloat() * fontScale, color, tv.ui(weight), lines)

fun round(fill: Int, radius: Number, stroke: Int = 0, strokeW: Number = 2) = tv.round(fill, radius, stroke, strokeW)
fun row(g: Int = Gravity.CENTER_VERTICAL) = tv.row(g)
Expand Down
11 changes: 7 additions & 4 deletions app/src/main/java/com/limelight/home/QuickMenu.kt
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,9 @@ class QuickMenu(private val act: Activity) {
addView(tv.text(it.label, 24, if (it.danger) Tv.DANGER else tv.p.moon, tv.ui(700)), LinearLayout.LayoutParams(0, -2, 1f))
if (it.detail != null) addView(tv.text(it.detail, 19, tv.p.dust, tv.ui(600)))
setOnClickListener { _ -> choose(i) } // touch screens (Odin)
list.addView(this, tv.lp(-1, 80))
minimumHeight = tv.px(80) // at least 80; a long label with its detail grows instead of being cut
setPadding(tv.px(20), tv.px(10), tv.px(20), tv.px(10))
list.addView(this, tv.lp(-1, -2))
}
}
// the list takes the room between the stats and the hints, and scrolls when the items don't fit
Expand Down Expand Up @@ -173,18 +175,19 @@ class QuickMenu(private val act: Activity) {
gravity = Gravity.CENTER; background = t.round(if (it.danger) 0xFFE9605A.toInt() else 0xFF5B6170.toInt(), 12)
}, t.lp(52, 52).apply { rightMargin = t.px(22) })
addView(t.col().apply {
addView(txt(it.label, 28, sInk, 800))
addView(txt(it.label, 28, if (it.danger) 0xFFE9605A.toInt() else sInk, 800)) // red like the TV's
if (it.detail != null) addView(txt(it.detail, 21, sSub, 600), t.lp(-2, -2).apply { topMargin = t.px(4) })
}, LinearLayout.LayoutParams(0, -2, 1f))
setOnClickListener { _ -> choose(i) }
list.addView(this, t.lp(-1, -2).apply { topMargin = t.px(10) })
}
}
val scroll = android.widget.ScrollView(act).apply { isVerticalScrollBarEnabled = false; addView(list) }
if (upright) panel.addView(scroll, t.lp(-1, -2)) else panel.addView(scroll, LinearLayout.LayoutParams(-1, 0, 1f))
// the list takes the room left, so the hints stay on the sheet however many items there are
panel.addView(scroll, LinearLayout.LayoutParams(-1, 0, 1f))
panel.addView(t.row().apply {
addView(View(act), LinearLayout.LayoutParams(0, 1, 1f))
listOf("B" to "Back to the game", "A" to "OK").forEach { (k, l) ->
listOf("A" to "OK", "B" to "Back to the game").forEach { (k, l) -> // A first, as on the TV
addView(txt(k, 24, sBg, 800).apply { gravity = Gravity.CENTER; background = t.round(sInk, 23) }, t.lp(46, 46).apply { leftMargin = t.px(32); rightMargin = t.px(12) })
addView(txt(l, 24, sInk, 700))
}
Expand Down
Loading
Loading