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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 <device> 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 <device>, Add apps). Now also: an **Add apps** tile on Home, always there before All games, and **Settings, Handheld, Apps on this <device>** (shows how many are added). All three open the same picker.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -244,6 +247,8 @@ class HandheldHomeActivity : Activity() {
holdMulticast(false)
stopPolling()
ui.removeCallbacks(tick)
ui.removeCallbacks(groupRender)
if (::glide.isInitialized) glide.pauseBreath()
super.onPause()
}

Expand Down Expand Up @@ -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<Game> {
if (memoOn) gamesMemo?.let { return it }
val out = ArrayList<Game>()
consoles.forEach { c ->
val st = psStatus[c.host]
Expand All @@ -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
}

Expand Down Expand Up @@ -549,16 +556,21 @@ 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
}
}
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
Expand All @@ -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<Game>? = null
private var memoOn = false

private fun renderPage(sig: String) {
lastSig = sig
val entering = lastPage != page
lastPage = page
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand All @@ -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<View>("chip:$k")?.requestFocus() }
}
}, sw.lp(-2, 60).apply { if (i > 0) leftMargin = sw.px(14) })
Expand Down Expand Up @@ -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",
Expand All @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
30 changes: 20 additions & 10 deletions app/src/main/java/com/limelight/home/CustomArt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Bitmap>(24)
// pictures by bytes (about 24 MB), not by count: 24 pictures of 900 px could take 50 MB
private val cache = object : LruCache<String, Bitmap>(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<String>? = null
private val colors = HashMap<String, Int>()

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<String> = known ?: HashSet<String>().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) }
}

Expand All @@ -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) }
}
45 changes: 32 additions & 13 deletions app/src/main/java/com/limelight/home/DeviceApps.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>? = null
private val infoCache = java.util.Collections.synchronizedMap(HashMap<String, App?>())
private var installedCache: List<App>? = null
private val iconCache = android.util.LruCache<String, Drawable>(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<App> = pinnedPkgs(c).mapNotNull { info(c, it) }

private fun pinnedPkgs(c: Context): List<String> = try {
private fun pinnedPkgs(c: Context): List<String> = 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<String>() }.also { pinnedCache = it }

@JvmStatic fun isPinned(c: Context, pkg: String) = pkg in pinnedPkgs(c)

Expand All @@ -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<String>) = store(c).edit().putString(PREF, JSONArray(l).toString()).apply()
private fun save(c: Context, l: List<String>) { 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<App> {
@JvmStatic fun installed(c: Context): List<App> = installedCache ?: listInstalled(c).also { installedCache = it }

private fun listInstalled(c: Context): List<App> {
val pm = c.packageManager
val pkgs = LinkedHashSet<String>()
for (cat in listOf(Intent.CATEGORY_LAUNCHER, Intent.CATEGORY_LEANBACK_LAUNCHER)) {
Expand All @@ -49,22 +62,28 @@ object DeviceApps {
return pkgs.mapNotNull { info(c, it) }.sortedWith(compareByDescending<App> { 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<String, Int>()
private val colors = java.util.Collections.synchronizedMap(HashMap<String, Int>())

/**
* The app's own colour, from its icon: the strongest hue among its vivid pixels, toned down so white text reads
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/limelight/home/Feel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/limelight/home/QuickMenu.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading