From 3032f549ccd9931695d243a24c1a497ee7f42a88 Mon Sep 17 00:00:00 2001 From: MoonCast Date: Thu, 22 Oct 2026 18:38:40 +0000 Subject: [PATCH 1/2] ui: review round 2: phone touch sizes and font size, one row style, Quick menu, All games scroll, TV re-fit and shared covers (test254) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +++ .../handheld/HandheldHomeActivity.kt | 49 +++++++++++++------ .../java/com/limelight/handheld/Sw.kt | 31 ++++++------ .../main/java/com/limelight/home/QuickMenu.kt | 11 +++-- app/src/main/java/com/limelight/home/Tv.kt | 32 +++++++++--- .../java/com/limelight/home/HomeActivity.kt | 12 +++++ 6 files changed, 102 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9668f8d30..659d5fc34c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 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). diff --git a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt index 2b910800eb..56e9a239e2 100644 --- a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt +++ b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt @@ -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) @@ -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()) @@ -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 @@ -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 @@ -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 } } @@ -1049,10 +1055,15 @@ class HandheldHomeActivity : Activity() { }) } primaryView = grid.getChildAt(0) + // 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(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) + 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 } @@ -1111,13 +1122,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)) @@ -1268,6 +1279,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" @@ -1310,9 +1325,9 @@ class HandheldHomeActivity : Activity() { if (pickerFilter != k) { pickerFilter = k; FeelSounds.play(this@HandheldHomeActivity, FeelSounds.Cue.TAB); showPicker() this@HandheldHomeActivity.overlay.findViewWithTag("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) @@ -1392,7 +1407,7 @@ class HandheldHomeActivity : Activity() { render(force = true) content.findViewWithTag("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" } @@ -2163,13 +2178,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) } @@ -2486,5 +2501,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 } } diff --git a/app/src/handheld/java/com/limelight/handheld/Sw.kt b/app/src/handheld/java/com/limelight/handheld/Sw.kt index 0a1214ef4f..626d191dc0 100644 --- a/app/src/handheld/java/com/limelight/handheld/Sw.kt +++ b/app/src/handheld/java/com/limelight/handheld/Sw.kt @@ -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. */ @@ -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) @@ -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) diff --git a/app/src/main/java/com/limelight/home/QuickMenu.kt b/app/src/main/java/com/limelight/home/QuickMenu.kt index 67fe9a2f6b..978a156bdf 100644 --- a/app/src/main/java/com/limelight/home/QuickMenu.kt +++ b/app/src/main/java/com/limelight/home/QuickMenu.kt @@ -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 @@ -173,7 +175,7 @@ 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) } @@ -181,10 +183,11 @@ class QuickMenu(private val act: Activity) { } } 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)) } diff --git a/app/src/main/java/com/limelight/home/Tv.kt b/app/src/main/java/com/limelight/home/Tv.kt index af7b25c780..a1b86537c9 100644 --- a/app/src/main/java/com/limelight/home/Tv.kt +++ b/app/src/main/java/com/limelight/home/Tv.kt @@ -193,13 +193,13 @@ class Tv @JvmOverloads constructor(val ctx: Context, val oled: Boolean, designW: fun flp(w: Number, h: Number, gravity: Int = Gravity.NO_GRAVITY) = FrameLayout.LayoutParams(dim(w), dim(h), gravity) private fun dim(v: Number) = when (v.toInt()) { -1 -> ViewGroup.LayoutParams.MATCH_PARENT; -2 -> ViewGroup.LayoutParams.WRAP_CONTENT; else -> px(v) } - /** Two-tone cover art for things with no box art (PS5, unknown apps), as in the mockup. */ + /** + * Two-tone cover art for things with no box art (PS5, unknown apps), as in the mockup. The colours are the + * handheld's (Covers): the same game has the same colour on the TV and the Odin. + */ fun twoTone(seed: String): Drawable { - val pairs = intArrayOf(0xFFE5484D.toInt(), 0xFFB0232B.toInt(), 0xFF2F5DF0.toInt(), 0xFF1B37A8.toInt(), - 0xFF12A594.toInt(), 0xFF0B6F65.toInt(), 0xFFF5A623.toInt(), 0xFFB86A12.toInt(), - 0xFF8E5CF0.toInt(), 0xFF5A31B5.toInt(), 0xFF1E8FD6.toInt(), 0xFF12609A.toInt()) - val i = if (seed == "ps") 2 else (Math.abs(seed.hashCode()) % (pairs.size / 2)) * 2 // PlayStation is always blue - return TwoTone(pairs[i], pairs[i + 1], pxf(16), if (oled) 0.9f else 1f) + val c = Covers.ART[Covers.index(seed)] + return TwoTone(c[0], c[1], pxf(16), if (oled) 0.9f else 1f) } private class TwoTone(val a: Int, val b: Int, val r: Float, val bright: Float) : Drawable() { @@ -235,3 +235,23 @@ class Crescent(ctx: Context, private val color: Int) : View(ctx) { c.restore() } } + +/** Cover colours for games with no box art, shared by the TV and the handheld (a1..a8): highlight, body, shadow. */ +object Covers { + @JvmField 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())) + + /** PlayStation is always blue, Xbox always green, anything else picks by name. */ + @JvmStatic fun index(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 } + } +} diff --git a/app/src/tv/java/com/limelight/home/HomeActivity.kt b/app/src/tv/java/com/limelight/home/HomeActivity.kt index 89fcf9d8a2..dc0f8ea4c8 100644 --- a/app/src/tv/java/com/limelight/home/HomeActivity.kt +++ b/app/src/tv/java/com/limelight/home/HomeActivity.kt @@ -105,8 +105,20 @@ class HomeActivity : Activity(), PhoneLink.Listener { // ---------------------------------------------------------------- lifecycle + // The size the screen was laid out for. The manifest keeps the activity across display changes (HDMI mode, + // resolution, density), so without this the TV scale stayed the old one until MoonCast was restarted. + private var laidOut: String? = null + + override fun onConfigurationChanged(c: android.content.res.Configuration) { + super.onConfigurationChanged(c) + val now = "${c.screenWidthDp}x${c.screenHeightDp}@${c.densityDpi}" + if (laidOut != null && laidOut != now) recreate() + laidOut = now + } + override fun onCreate(b: Bundle?) { super.onCreate(b) + resources.configuration.let { laidOut = "${it.screenWidthDp}x${it.screenHeightDp}@${it.densityDpi}" } Profile.syncPcSettings(this) com.limelight.PhoneLink.sessionListener = com.limelight.PhoneLink.SessionListener { uuid -> runOnUiThread { pcs.remove(uuid); apps.remove(uuid); rawLists.remove(uuid); render(force = true) } From 1b79d6f32021ca06627407f3103ee6b8dd4577e0 Mon Sep 17 00:00:00 2001 From: MoonCast Date: Thu, 22 Oct 2026 18:49:36 +0000 Subject: [PATCH 2/2] fix(handheld): list panes clip at their edge, Consoles ran under the hint bar (test255) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ .../handheld/HandheldHomeActivity.kt | 19 +++++++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 659d5fc34c..0b167ac10f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 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. diff --git a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt index 56e9a239e2..1b124af637 100644 --- a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt +++ b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt @@ -1058,13 +1058,13 @@ class HandheldHomeActivity : Activity() { // 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(ColScroll(this, sw.px(40)).apply { + 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) 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) }) + })), LinearLayout.LayoutParams(-1, 0, 1f).apply { topMargin = sw.px(20) }) return col } @@ -1105,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) }) @@ -1412,11 +1419,11 @@ class HandheldHomeActivity : Activity() { 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("group:$settingsGroup") return row }