diff --git a/CHANGELOG.md b/CHANGELOG.md index 06ea92a0d4..f9668f8d30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 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). + - Home and All games work out the games list once per redraw instead of 3 times, and "On this device" keeps its list, app names and icons in memory (refreshed when MoonCast comes back to the front, for installs and updates meanwhile). + - Tile pictures: which tiles have one is read from disk once, not checked per tile per redraw; the picture cache counts bytes (about 24 MB), and each picture's colour is worked out once. + - Add apps loads the app list and icons off the main thread; switching Games / All apps reuses them. + - The focus ring's breathing stops while MoonCast isn't in front (it redrew the ring 60 times a second in the background). + - Focus and scroll listeners were added again on every rotation or fold; now once. + - Stats corner and Quick menu: the numbers are only set when they changed (a new text lays the player out again). + - L1/R1 on All games skipped the This tab. + - The focus ring fits the Add apps chips and app cells. + - TalkBack: tiles, the round buttons and Back say what they are. + ## 1.0-test252 (2026-10-22) - Adding more apps was hard to find (only All games, This , Add apps). Now also: an **Add apps** tile on Home, always there before All games, and **Settings, Handheld, Apps on this ** (shows how many are added). All three open the same picker. diff --git a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt index 5d98266a53..2b910800eb 100644 --- a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt +++ b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt @@ -101,6 +101,7 @@ class HandheldHomeActivity : Activity() { private lateinit var foot: LinearLayout private lateinit var overlay: FrameLayout private lateinit var glide: FocusGlide + private var treeHooked = false private var padName: TextView? = null private var portrait = false private val ui = Handler(Looper.getMainLooper()) @@ -216,6 +217,8 @@ class HandheldHomeActivity : Activity() { super.onResume() resumed = true resumedAt = android.os.SystemClock.elapsedRealtime() + if (::glide.isInitialized) glide.resumeBreath() + com.limelight.home.DeviceApps.refresh() // apps may have been installed, updated or removed meanwhile // anonymous usage check-in: once a day if the user said yes; the question once, a moment after home is up com.limelight.home.Usage.checkIn(this) if (com.limelight.home.Usage.shouldAsk(this)) ui.postDelayed({ @@ -244,6 +247,8 @@ class HandheldHomeActivity : Activity() { holdMulticast(false) stopPolling() ui.removeCallbacks(tick) + ui.removeCallbacks(groupRender) + if (::glide.isInitialized) glide.pauseBreath() super.onPause() } @@ -456,6 +461,7 @@ class HandheldHomeActivity : Activity() { /** Every game: each PS5 and Xbox (one tile per console) and every PC game that isn't hidden. */ private fun allGames(): List { + if (memoOn) gamesMemo?.let { return it } val out = ArrayList() consoles.forEach { c -> val st = psStatus[c.host] @@ -469,6 +475,7 @@ class HandheldHomeActivity : Activity() { out += Game("pc:${pc.uuid}:${a.appId}", a.appName, "PC", a.appName, a.appId == pc.runningGameId, pc = pc, app = a) } } + if (memoOn) gamesMemo = out return out } @@ -549,7 +556,8 @@ class HandheldHomeActivity : Activity() { val t = v.tag as? String when { v.getTag(R.id.hh_pill) == true -> 9999f - t != null && (t.startsWith("rb:") || t.startsWith("pill:") || t.startsWith("tab:") || t.startsWith("dock:")) -> 9999f + t != null && (t.startsWith("rb:") || t.startsWith("pill:") || t.startsWith("tab:") || t.startsWith("dock:") || t.startsWith("chip:")) -> 9999f + t != null && t.startsWith("app:") -> sw.pxf(13 + 22) t != null && t.startsWith("g:") -> sw.pxf(13 + sw.tileCorner) else -> null } @@ -557,8 +565,12 @@ class HandheldHomeActivity : Activity() { accept = { v -> v !is EditText } breathe = true }.attach() - root.viewTreeObserver.addOnGlobalFocusChangeListener { _, n -> onFocus(n) } - root.viewTreeObserver.addOnScrollChangedListener { placeName() } + // once per window: buildShell runs again on every rotation or fold, and each run used to add another pair + if (!treeHooked) { + treeHooked = true + window.decorView.viewTreeObserver.addOnGlobalFocusChangeListener { _, n -> onFocus(n) } + window.decorView.viewTreeObserver.addOnScrollChangedListener { placeName() } + } snack = null FeelSounds.warm(this) lastPage = null @@ -585,6 +597,15 @@ class HandheldHomeActivity : Activity() { Profile.syncPcSettings(this) val sig = signature() if (!force && sig == lastSig) return + gamesMemo = null; memoOn = true + try { renderPage(sig) } finally { memoOn = false; gamesMemo = null } + } + + /** allGames() is asked for several times per render (Home's row, the wash, the selected game): work it out once. */ + private var gamesMemo: List? = null + private var memoOn = false + + private fun renderPage(sig: String) { lastSig = sig val entering = lastPage != page lastPage = page @@ -661,7 +682,7 @@ class HandheldHomeActivity : Activity() { }, sw.lp(-2, 84)) } 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 }, + 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) }) topBar.addView(sw.text(page.title, 44, sw.ink, 800)) } @@ -790,6 +811,7 @@ class HandheldHomeActivity : Activity() { listOf(Triple("Consoles", "consoles", Sw.C_CYAN), Triple(if (labelsShown) "Play on TV" else "Play on a TV", "tv", Sw.C_AMBER), Triple("Controllers", "pad", Sw.C_BLUE), Triple(if (labelsShown) "Network" else "Network test", "net", Sw.C_GREEN), Triple("Settings", "gear", sw.sub), Triple("Exit", "power", Sw.C_RED)).forEachIndexed { i, (label, icon, color) -> val btn = roundButton("rb:$icon", icon, color, 112, sw.panel) { roundAction(icon) } + btn.contentDescription = label val lab = sw.text(label, 27, if (labelsShown) sw.label else sw.ring, 700).apply { gravity = Gravity.CENTER_HORIZONTAL; visibility = if (labelsShown) View.VISIBLE else View.INVISIBLE } rbLabels += btn to lab if (portrait) { @@ -852,8 +874,9 @@ class HandheldHomeActivity : Activity() { val f = FrameLayout(this) f.tag = tag f.setTag(R.id.hh_title, label) + f.contentDescription = label val custom = g?.let { com.limelight.home.CustomArt.bitmap(this, it.key) } - val appColor = if (custom != null) com.limelight.home.CustomArt.color(custom) + val appColor = if (custom != null) com.limelight.home.CustomArt.color(g!!.key, custom) else g?.dev?.let { com.limelight.home.DeviceApps.color(this, it.pkg) } ?: 0 f.setTag(R.id.hh_tint, if (appColor != 0) appColor else if (g != null) sw.tint(g.seed) else 0) f.clipToOutline = true @@ -1252,7 +1275,15 @@ class HandheldHomeActivity : Activity() { * "On this device": a grid of the installed apps with their icons, games first. A tap adds or removes an app at * once (a ✓ and a green ring mark what's added) and the grid stays as it is, so you can pick several. */ + /** Opens Add apps; the first time, the app list and icons load off the main thread (hundreds of apps took a moment). */ private fun pickDeviceApps() { + Thread { + com.limelight.home.DeviceApps.installed(this).forEach { com.limelight.home.DeviceApps.icon(this, it.pkg) } + runOnUiThread { if (!isFinishing && resumed) showPicker() } + }.start() + } + + private fun showPicker() { val all = com.limelight.home.DeviceApps.installed(this) if (all.isEmpty()) { toast("No apps found on this ${deviceShort()}"); return } val games = all.filter { it.isGame } @@ -1276,7 +1307,7 @@ class HandheldHomeActivity : Activity() { background = sw.round(if (on) sw.ink else sw.bg, 30) sw.focusable(this) setOnClickListener { - if (pickerFilter != k) { pickerFilter = k; FeelSounds.play(this@HandheldHomeActivity, FeelSounds.Cue.TAB); pickDeviceApps() + 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) }) @@ -1335,6 +1366,8 @@ class HandheldHomeActivity : Activity() { showOverlay("picker", card, wide = true) } + private val groupRender = Runnable { if (resumed && page == Page.SETTINGS) render(force = true) } + // ---------------------------------------------------------------- Settings private val groups = listOf("handheld" to "Handheld", "picture" to "Picture", "sound" to "Sound", "pads" to "Controllers", @@ -1351,7 +1384,9 @@ class HandheldHomeActivity : Activity() { if (on) addView(View(this@HandheldHomeActivity).apply { background = sw.round(sw.ring, 3) }, sw.flp(6, -1).apply { topMargin = sw.px(18); bottomMargin = sw.px(18) }) addView(sw.text(label, 30, if (on) sw.ring else sw.sub, 700).apply { gravity = Gravity.CENTER_VERTICAL; setPadding(sw.px(28), 0, 0, 0) }, FrameLayout.LayoutParams(-1, -1)) sw.focusable(this) - setOnFocusChangeListener { _, has -> if (has && settingsGroup != k) { settingsGroup = k; licencesOpen = false; ui.post { render(force = true) } } } + // moving down the sidebar rebuilt the whole screen at every step: wait until focus rests on a group + setOnFocusChangeListener { _, has -> if (has && settingsGroup != k) { settingsGroup = k; licencesOpen = false + ui.removeCallbacks(groupRender); ui.postDelayed(groupRender, 160) } } setOnClickListener { settingsGroup = k; licencesOpen = false render(force = true) @@ -2346,7 +2381,7 @@ class HandheldHomeActivity : Activity() { val step = if (e.keyCode == KeyEvent.KEYCODE_BUTTON_L1) -1 else 1 when (page) { Page.GAMES -> { - val tabs = listOf("all", "pc", "ps", "xb") + val tabs = listOf("all", "pc", "ps", "xb", "dev") gamesTab = tabs[(tabs.indexOf(gamesTab) + step + tabs.size) % tabs.size] FeelSounds.play(this, FeelSounds.Cue.TAB, 0.3f * step); render(force = true); return true } diff --git a/app/src/main/java/com/limelight/home/CustomArt.kt b/app/src/main/java/com/limelight/home/CustomArt.kt index a92b8141ce..4e90bb579f 100644 --- a/app/src/main/java/com/limelight/home/CustomArt.kt +++ b/app/src/main/java/com/limelight/home/CustomArt.kt @@ -15,20 +15,30 @@ import java.security.MessageDigest */ object CustomArt { private const val MAX = 900 // the longest side kept: sharp on the biggest tiles, small on disk - private val cache = LruCache(24) + // pictures by bytes (about 24 MB), not by count: 24 pictures of 900 px could take 50 MB + private val cache = object : LruCache(24 * 1024 * 1024) { + override fun sizeOf(key: String, value: Bitmap) = value.byteCount + } + // which keys have a picture, read from disk once: asking the disk for every tile on every redraw cost 100 file checks + private var known: MutableSet? = null + private val colors = HashMap() - private fun file(c: Context, key: String): File { - val name = MessageDigest.getInstance("SHA-1").digest(key.toByteArray()).joinToString("") { "%02x".format(it) } - return File(File(c.filesDir, "tile-art").apply { mkdirs() }, "$name.jpg") + private fun knownKeys(c: Context): MutableSet = known ?: HashSet().also { set -> + File(c.filesDir, "tile-art").list()?.forEach { set += it.removeSuffix(".jpg") } + known = set } - @JvmStatic fun has(c: Context, key: String) = file(c, key).exists() + private fun hash(key: String) = MessageDigest.getInstance("SHA-1").digest(key.toByteArray()).joinToString("") { "%02x".format(it) } + + private fun file(c: Context, key: String) = File(File(c.filesDir, "tile-art").apply { mkdirs() }, "${hash(key)}.jpg") + + @JvmStatic fun has(c: Context, key: String) = hash(key) in knownKeys(c) /** The picture for [key], or null when there's none. */ @JvmStatic fun bitmap(c: Context, key: String): Bitmap? { cache.get(key)?.let { return it } + if (!has(c, key)) return null val f = file(c, key) - if (!f.exists()) return null return BitmapFactory.decodeFile(f.path)?.also { cache.put(key, it) } } @@ -49,14 +59,14 @@ object CustomArt { ?: throw IllegalStateException("unreadable") } file(c, key).outputStream().use { bmp.compress(Bitmap.CompressFormat.JPEG, 88, it) } - cache.put(key, bmp) + cache.put(key, bmp); knownKeys(c) += hash(key); colors.remove(key) true } catch (e: Exception) { android.util.Log.w("MoonCast", "custom picture: $e"); false } - @JvmStatic fun clear(c: Context, key: String) { file(c, key).delete(); cache.remove(key) } + @JvmStatic fun clear(c: Context, key: String) { file(c, key).delete(); cache.remove(key); knownKeys(c) -= hash(key); colors.remove(key) } - /** The picture's average colour, for the background wash. */ - @JvmStatic fun color(b: Bitmap): Int = Bitmap.createScaledBitmap(b, 1, 1, true).getPixel(0, 0) + /** The picture's average colour, for the background wash (worked out once per picture). */ + @JvmStatic fun color(key: String, b: Bitmap): Int = colors.getOrPut(key) { Bitmap.createScaledBitmap(b, 1, 1, true).getPixel(0, 0) } } diff --git a/app/src/main/java/com/limelight/home/DeviceApps.kt b/app/src/main/java/com/limelight/home/DeviceApps.kt index 29a5a23ee2..04e33f9a56 100644 --- a/app/src/main/java/com/limelight/home/DeviceApps.kt +++ b/app/src/main/java/com/limelight/home/DeviceApps.kt @@ -20,12 +20,23 @@ object DeviceApps { private fun store(c: Context) = c.getSharedPreferences("mc_tv", Context.MODE_PRIVATE) + // Kept in memory: the home screen asks on every redraw, and each answer was a JSON parse plus a PackageManager + // call per app. refresh() (on resume) drops what may have changed outside MoonCast: installs, updates, removals. + private var pinnedCache: List? = null + private val infoCache = java.util.Collections.synchronizedMap(HashMap()) + private var installedCache: List? = null + private val iconCache = android.util.LruCache(64) + + @JvmStatic fun refresh() { + infoCache.clear(); installedCache = null; iconCache.evictAll(); colors.clear() + } + /** The picked apps that are still installed, in the order they were added. */ @JvmStatic fun pinned(c: Context): List = pinnedPkgs(c).mapNotNull { info(c, it) } - private fun pinnedPkgs(c: Context): List = try { + private fun pinnedPkgs(c: Context): List = pinnedCache ?: try { val a = JSONArray(store(c).getString(PREF, "[]")); (0 until a.length()).map { a.getString(it) } - } catch (e: Exception) { emptyList() } + } catch (e: Exception) { emptyList() }.also { pinnedCache = it } @JvmStatic fun isPinned(c: Context, pkg: String) = pkg in pinnedPkgs(c) @@ -35,10 +46,12 @@ object DeviceApps { @JvmStatic fun remove(c: Context, pkg: String) = save(c, pinnedPkgs(c) - pkg) - private fun save(c: Context, l: List) = store(c).edit().putString(PREF, JSONArray(l).toString()).apply() + private fun save(c: Context, l: List) { pinnedCache = l; store(c).edit().putString(PREF, JSONArray(l).toString()).apply() } /** Every app with a launcher entry (phone or TV launcher), not MoonCast (any of its copies): games first, then A to Z. */ - @JvmStatic fun installed(c: Context): List { + @JvmStatic fun installed(c: Context): List = installedCache ?: listInstalled(c).also { installedCache = it } + + private fun listInstalled(c: Context): List { val pm = c.packageManager val pkgs = LinkedHashSet() for (cat in listOf(Intent.CATEGORY_LAUNCHER, Intent.CATEGORY_LEANBACK_LAUNCHER)) { @@ -49,22 +62,28 @@ object DeviceApps { return pkgs.mapNotNull { info(c, it) }.sortedWith(compareByDescending { it.isGame }.thenBy { it.label.lowercase() }) } - @JvmStatic fun info(c: Context, pkg: String): App? = try { - @Suppress("DEPRECATION") val ai = c.packageManager.getApplicationInfo(pkg, 0) - App(pkg, c.packageManager.getApplicationLabel(ai).toString(), isGame(ai)) - } catch (e: PackageManager.NameNotFoundException) { null } + @JvmStatic fun info(c: Context, pkg: String): App? = infoCache.getOrPut(pkg) { + try { + @Suppress("DEPRECATION") val ai = c.packageManager.getApplicationInfo(pkg, 0) + App(pkg, c.packageManager.getApplicationLabel(ai).toString(), isGame(ai)) + } catch (e: PackageManager.NameNotFoundException) { null } + } @Suppress("DEPRECATION") private fun isGame(ai: ApplicationInfo) = (Build.VERSION.SDK_INT >= 26 && ai.category == ApplicationInfo.CATEGORY_GAME) || (ai.flags and ApplicationInfo.FLAG_IS_GAME) != 0 /** The app's icon; on a TV its wide banner when it has one. */ - @JvmStatic fun icon(c: Context, pkg: String, tvBanner: Boolean = false): Drawable? = try { - val pm = c.packageManager - (if (tvBanner) pm.getApplicationBanner(pkg) else null) ?: pm.getApplicationIcon(pkg) - } catch (e: Exception) { null } + @JvmStatic fun icon(c: Context, pkg: String, tvBanner: Boolean = false): Drawable? { + val k = if (tvBanner) "$pkg#banner" else pkg + iconCache.get(k)?.let { return it.constantState?.newDrawable() ?: it } + return try { + val pm = c.packageManager + ((if (tvBanner) pm.getApplicationBanner(pkg) else null) ?: pm.getApplicationIcon(pkg)).also { iconCache.put(k, it) } + } catch (e: Exception) { null } + } - private val colors = HashMap() + private val colors = java.util.Collections.synchronizedMap(HashMap()) /** * The app's own colour, from its icon: the strongest hue among its vivid pixels, toned down so white text reads diff --git a/app/src/main/java/com/limelight/home/Feel.kt b/app/src/main/java/com/limelight/home/Feel.kt index 5c2f55af59..3f4b709dd4 100644 --- a/app/src/main/java/com/limelight/home/Feel.kt +++ b/app/src/main/java/com/limelight/home/Feel.kt @@ -200,6 +200,10 @@ class FocusGlide(private val host: FrameLayout) : View(host.context) { (parent as? ViewGroup)?.removeView(this) } + /** Stop the breathing while the screen isn't in front: it redrew the ring 60 times a second in the background. */ + fun pauseBreath() { breath?.pause() } + fun resumeBreath() { breath?.resume() } + private fun startBreath() { breath?.cancel(); breath = null if (reduced()) return diff --git a/app/src/main/java/com/limelight/home/QuickMenu.kt b/app/src/main/java/com/limelight/home/QuickMenu.kt index 2948ccb29a..67fe9a2f6b 100644 --- a/app/src/main/java/com/limelight/home/QuickMenu.kt +++ b/app/src/main/java/com/limelight/home/QuickMenu.kt @@ -267,7 +267,7 @@ class QuickMenu(private val act: Activity) { if (switchStyle) { val sv = short(s[i]).first if (v.text.toString() != sv) { v.text = sv; v.setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, st.pxf(statSize(sv))) } - } else v.text = s[i].first + } else if (v.text.toString() != s[i].first) v.text = s[i].first } ui.postDelayed(this, 1000) } diff --git a/app/src/main/java/com/limelight/home/StatusCorner.kt b/app/src/main/java/com/limelight/home/StatusCorner.kt index 7d7a28074f..c3ea6f6764 100644 --- a/app/src/main/java/com/limelight/home/StatusCorner.kt +++ b/app/src/main/java/com/limelight/home/StatusCorner.kt @@ -21,8 +21,9 @@ class StatusCorner(private val act: Activity, private val stats: () -> List