From f8fe234c644393d32ac8ee5e553028b38b6d1151 Mon Sep 17 00:00:00 2001 From: MoonCast Date: Thu, 22 Oct 2026 16:11:48 +0000 Subject: [PATCH 1/6] feat(handheld): On this device, pick Android apps to show next to PC, PS5 and Xbox (test247) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++ .../handheld/HandheldHomeActivity.kt | 65 +++++++++++++--- app/src/main/AndroidManifest.xml | 13 ++++ .../java/com/limelight/home/DeviceApps.kt | 74 +++++++++++++++++++ 4 files changed, 150 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/limelight/home/DeviceApps.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f5cb7fc94b..73b959adc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 1.0-test247 (2026-10-22) +- **On this device** (handheld): Android apps you pick show in MoonCast next to your PC, PS5 and Xbox games, like ES-DE's Android apps. Nothing is added by itself. + - All games has a new tab, **This ** (This Odin), starting with **Add apps**: the installed apps, games first, each ticked or not. A pick adds or removes it straight away and the list stays open. + - The apps are tiles with their own icon and an "Android" badge, on Home and in All games, and open the app. Long press (or X on Home): **Open** or **Remove from MoonCast** (the app stays installed). + - `home/DeviceApps.kt`; the manifest's `` lets MoonCast see launcher apps only (no QUERY_ALL_PACKAGES). + - Play on TV doesn't apply to these yet: they open on the device (#99). + ## 1.0-test246 (2026-10-22) - **Anonymous usage check-in** (opt-in), so the project can count daily and monthly active users (`home/Usage.kt`, TelemetryDeck's ingest API v2, no SDK): - Official builds ask once, a moment after the home screen opens: "Help improve MoonCast?" with **No thanks** selected. Settings, About has the switch afterwards. diff --git a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt index 46fdf34388..313de44a90 100644 --- a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt +++ b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt @@ -83,10 +83,11 @@ import java.util.concurrent.Executors class HandheldHomeActivity : Activity() { private enum class Page(val title: String) { HOME("Home"), GAMES("All games"), CONSOLES("Consoles"), SETTINGS("Settings") } - /** One thing you can play: a PC game, a PS5 or an Xbox. */ + /** One thing you can play: a PC game, a PS5, an Xbox, or an Android app picked for "On this device". */ private class Game(val key: String, val title: String, val src: String, val seed: String, val running: Boolean, val pc: ComputerDetails? = null, val app: NvApp? = null, - val ps: Ps5Store.Console? = null, val xb: Xbox.Console? = null) + val ps: Ps5Store.Console? = null, val xb: Xbox.Console? = null, + val dev: com.limelight.home.DeviceApps.App? = null) private class Opt(val icon: String?, val iconColor: Int, val title: String, val sub: String? = null, val right: String? = null, val enabled: Boolean = true, val onClick: () -> Unit = {}) @@ -461,6 +462,7 @@ class HandheldHomeActivity : Activity() { out += Game("ps:${c.host}", st?.runningApp ?: c.name, "PS5", "ps", st?.power == Ps5Store.Power.ON && st.runningApp != null, ps = c) } xboxes.forEach { c -> out += Game("xb:${c.id}", c.name, "Xbox", "xb", false, xb = c) } + com.limelight.home.DeviceApps.pinned(this).forEach { a -> out += Game("dev:${a.pkg}", a.label, "Android", a.pkg, false, dev = a) } pcs.values.filter { paired(it) }.forEach { pc -> val hidden = hiddenIds(pc) (apps[pc.uuid] ?: emptyList()).filter { it.appId !in hidden }.forEach { a -> @@ -479,6 +481,7 @@ class HandheldHomeActivity : Activity() { all.filter { it.running }.forEach { ordered[it.key] = it } recent.forEach { ordered[it.key] = it } all.filter { it.ps != null || it.xb != null }.forEach { ordered[it.key] = it } + all.filter { it.dev != null }.forEach { ordered[it.key] = it } all.filter { it.pc != null }.sortedBy { it.title.lowercase() }.forEach { ordered[it.key] = it } return ordered.values.take(10) } @@ -846,7 +849,7 @@ class HandheldHomeActivity : Activity() { f.elevation = sw.pxf(8) // a soft shadow lifts the tiles off the background sw.pressable(f) if (g == null) { - f.background = if (tag == "g:all") sw.allArt() else sw.art("add") + f.background = if (tag == "g:all") sw.allArt() else sw.art("add") // g:add and g:devadd if (tag != "g:all") f.addView(sw.text("+", size * 0.3f, 0xFFFFFFFF.toInt(), 600).apply { gravity = Gravity.CENTER }, FrameLayout.LayoutParams(-1, -1)) } else { f.background = sw.art(g.seed) @@ -861,12 +864,18 @@ class HandheldHomeActivity : Activity() { f.addView(img, FrameLayout.LayoutParams(-1, -1)) f.addView(title, FrameLayout.LayoutParams(-1, -1)) loader(g.pc).populateImageView(g.app, img, title) + } else if (g.dev != null) { + // an Android app: its own icon in the middle of its colour, the name under it + val icon = com.limelight.home.DeviceApps.icon(this, g.dev.pkg) + if (icon != null) f.addView(ImageView(this).apply { setImageDrawable(icon); scaleType = ImageView.ScaleType.FIT_CENTER }, + FrameLayout.LayoutParams(sw.px(size * 0.42f), sw.px(size * 0.42f), Gravity.CENTER).apply { bottomMargin = sw.px(size * 0.1f) }) + f.addView(title, FrameLayout.LayoutParams(-1, -1)) } else f.addView(title, FrameLayout.LayoutParams(-1, -1)) val bs = if (size >= 500) 24 else if (size >= 360) 20 else 17 val m = if (size >= 360) 18 else 12 f.addView(sw.badge(g.src, bs), sw.flp(-2, bs * 2).apply { leftMargin = sw.px(m); topMargin = sw.px(m) }) if (g.running) f.addView(sw.live(bs), sw.flp(-2, bs * 2, Gravity.END).apply { rightMargin = sw.px(m); topMargin = sw.px(m) }) - if (g.pc != null && g.app != null) { + if ((g.pc != null && g.app != null) || g.dev != null) { f.setOnLongClickListener { gameMenu(g); true } f.setOnKeyListener { _, code, e -> if (code == KeyEvent.KEYCODE_BUTTON_X && page == Page.HOME) { if (e.action == KeyEvent.ACTION_UP) gameMenu(g); true } else false @@ -942,7 +951,7 @@ class HandheldHomeActivity : Activity() { private fun gamesPage(): View { val col = sw.col().apply { setPadding(sw.px(sw.edge), 0, sw.px(sw.edge), 0); clipChildren = false } val tabs = sw.row() - listOf("all" to "All", "pc" to "PC", "ps" to "PlayStation", "xb" to "Xbox").forEachIndexed { i, (k, label) -> + listOf("all" to "All", "pc" to "PC", "ps" to "PlayStation", "xb" to "Xbox", "dev" to "This ${deviceShort()}").forEachIndexed { i, (k, label) -> val on = k == gamesTab tabs.addView(sw.text(label, 26, if (on) sw.bg else sw.sub, 700).apply { tag = "tab:$k" @@ -956,11 +965,11 @@ class HandheldHomeActivity : Activity() { col.addView(part(tabs), sw.lp(-2, 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; else -> true } } + 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 } } list = if (sortAz) list.sortedBy { it.title.lowercase() } else list.sortedWith(compareByDescending { it.running }.thenBy { recent.indexOf(it.key).let { i -> if (i < 0) Int.MAX_VALUE else i } } .thenBy { if (it.pc != null) 1 else 0 }.thenBy { it.title.lowercase() }) - if (list.isEmpty()) { + if (list.isEmpty() && gamesTab != "dev") { val what = when (gamesTab) { "pc" -> "No PC games yet"; "ps" -> "No PlayStation yet"; "xb" -> "No Xbox yet"; else -> "No games yet" } col.addView(part(sw.text(what, 40, sw.ink, 800)), sw.lp(-2, -2).apply { topMargin = sw.px(80) }) col.addView(part(sw.text(if (pcs.isEmpty() && consoles.isEmpty() && xboxes.isEmpty()) "Pair a PC, register a PS5 or sign in to Xbox in Consoles." @@ -973,8 +982,12 @@ class HandheldHomeActivity : Activity() { val gap = if (portrait) 22 else 24 val size = ((sw.designWidth - 2 * sw.edge - (cols - 1) * gap) / cols).toInt() val grid = GridLayout(this).apply { columnCount = cols; clipChildren = false } - list.forEachIndexed { i, g -> - grid.addView(tileView(g, size, if (portrait) 28 else 26, "g:${g.key}", g.title) { play(g) }, GridLayout.LayoutParams().apply { + val cells = ArrayList() + // This device's tab starts with Add apps: you pick which apps show (and remove them from a tile's menu) + if (gamesTab == "dev") cells += tileView(null, size, if (portrait) 28 else 26, "g:devadd", "Add apps") { pickDeviceApps() } + list.forEach { g -> cells += tileView(g, size, if (portrait) 28 else 26, "g:${g.key}", g.title) { play(g) } } + cells.forEachIndexed { i, t -> + grid.addView(t, GridLayout.LayoutParams().apply { width = sw.px(size); height = sw.px(size) if (i % cols != 0) leftMargin = sw.px(gap) bottomMargin = sw.px(gap) @@ -1154,6 +1167,15 @@ class HandheldHomeActivity : Activity() { } private fun gameMenu(g: Game) { + g.dev?.let { a -> + dialog(a.label, "An app on this ${deviceShort()}.", listOf( + Opt("▶", Sw.LIVE, "Open") { playHere(g) }, + Opt("–", Sw.DANGER, "Remove from MoonCast", "It stays installed; add it again from All games, This ${deviceShort()}") { + com.limelight.home.DeviceApps.remove(this, a.pkg); Recents.forget(this, g.key) + render(force = true); toast("${a.label} removed from MoonCast") + }), listOf("Close" to {})) + return + } val pc = g.pc ?: return; val app = g.app ?: return val running = app.appId == pc.runningGameId dialog(app.appName, "On ${pc.name}" + (if (running) ". Running now." else "."), listOfNotNull( @@ -1167,6 +1189,26 @@ class HandheldHomeActivity : Activity() { }), listOf("Close" to {})) } + /** + * "On this device": the installed apps, games first, each one ticked or not. A pick adds or removes it at once and + * the list stays open where you were, so you can pick several. + */ + private fun pickDeviceApps(at: Int = 0) { + val apps = com.limelight.home.DeviceApps.installed(this) + if (apps.isEmpty()) { toast("No apps found on this ${deviceShort()}"); return } + val opts = apps.mapIndexed { i, a -> + val on = com.limelight.home.DeviceApps.isPinned(this, a.pkg) + Opt(if (on) "✓" else "+", if (on) Sw.LIVE else sw.panel, a.label, if (a.isGame) "Game" else null, if (on) "Added" else null) { + if (on) com.limelight.home.DeviceApps.remove(this, a.pkg) else com.limelight.home.DeviceApps.add(this, a.pkg) + render(force = true) + pickDeviceApps(i) + } + } + dialog("Add apps", "Pick the apps to show in MoonCast. Pick again to remove one.", opts, listOf("Done" to {})) + // back on the row just picked + (overlay.findViewWithTag(if (at == 0) "primary" else "opt:$at"))?.requestFocus() + } + // ---------------------------------------------------------------- Settings private val groups = listOf("handheld" to "Handheld", "picture" to "Picture", "sound" to "Sound", "pads" to "Controllers", @@ -1546,6 +1588,7 @@ class HandheldHomeActivity : Activity() { /** A: plays where games go now, this device or the TV picked in "Play on" (the top chip says which). */ private fun play(g: Game) { + if (g.dev != null) { playHere(g); return } // an app on this device opens here (Play on TV for apps: #99) val tv = TvFinder.target(this) if (tv != null) sendToTv(g, tv) else playHere(g) } @@ -1561,6 +1604,10 @@ class HandheldHomeActivity : Activity() { } g.ps != null -> { Recents.touch(this, g.key); playPs(g.ps) } g.xb != null -> { Recents.touch(this, g.key); playXbox(g.xb) } + g.dev != null -> { + if (com.limelight.home.DeviceApps.launch(this, g.dev.pkg)) { Recents.touch(this, g.key); FeelSounds.play(this, FeelSounds.Cue.LAUNCH) } + else toast("${g.dev.label} can't be opened. Is it still installed?") + } } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 34adbff7cc..e3f8538db5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -40,6 +40,19 @@ android:name="android.hardware.type.pc" android:required="false"/> + + + + + + + + + + + + , not QUERY_ALL_PACKAGES). + */ +object DeviceApps { + class App(val pkg: String, val label: String, val isGame: Boolean) + + private const val PREF = "mc_device_apps" // in mc_tv: a JSON array of package names, in the order added + + private fun store(c: Context) = c.getSharedPreferences("mc_tv", Context.MODE_PRIVATE) + + /** 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 { + val a = JSONArray(store(c).getString(PREF, "[]")); (0 until a.length()).map { a.getString(it) } + } catch (e: Exception) { emptyList() } + + @JvmStatic fun isPinned(c: Context, pkg: String) = pkg in pinnedPkgs(c) + + @JvmStatic fun add(c: Context, pkg: String) { + val l = pinnedPkgs(c); if (pkg !in l) save(c, l + pkg) + } + + @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() + + /** Every app with a launcher entry (phone or TV launcher), not MoonCast itself: games first, then A to Z. */ + @JvmStatic fun installed(c: Context): List { + val pm = c.packageManager + val pkgs = LinkedHashSet() + for (cat in listOf(Intent.CATEGORY_LAUNCHER, Intent.CATEGORY_LEANBACK_LAUNCHER)) { + @Suppress("DEPRECATION") + pm.queryIntentActivities(Intent(Intent.ACTION_MAIN).addCategory(cat), 0).forEach { pkgs += it.activityInfo.packageName } + } + pkgs -= c.packageName + 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 } + + @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 } + + /** Opens the app; false when it can't be opened (uninstalled, or no launcher entry any more). */ + @JvmStatic fun launch(c: Context, pkg: String): Boolean { + val pm = c.packageManager + val i = (if (com.limelight.BuildConfig.TV_APP) pm.getLeanbackLaunchIntentForPackage(pkg) else null) + ?: pm.getLaunchIntentForPackage(pkg) ?: pm.getLeanbackLaunchIntentForPackage(pkg) ?: return false + return try { c.startActivity(i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); true } catch (e: Exception) { false } + } +} From 401db58cc12fd910d01a57708c987b2a40a3a3b1 Mon Sep 17 00:00:00 2001 From: MoonCast Date: Thu, 22 Oct 2026 16:22:07 +0000 Subject: [PATCH 2/6] fix(handheld): the Add apps tile shows its name (test248) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ .../java/com/limelight/handheld/HandheldHomeActivity.kt | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73b959adc4..27a306f795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 1.0-test248 (2026-10-22) +- On this device, tested on the Odin (test247): the tab, the picker (emulators listed first as games), adding, opening (Back returns to MoonCast) and Remove from MoonCast (the app stays installed) all work. The Add apps tile showed only "+": it now says Add apps. + ## 1.0-test247 (2026-10-22) - **On this device** (handheld): Android apps you pick show in MoonCast next to your PC, PS5 and Xbox games, like ES-DE's Android apps. Nothing is added by itself. - All games has a new tab, **This ** (This Odin), starting with **Add apps**: the installed apps, games first, each ticked or not. A pick adds or removes it straight away and the list stays open. diff --git a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt index 313de44a90..3c4a6633c4 100644 --- a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt +++ b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt @@ -851,6 +851,10 @@ class HandheldHomeActivity : Activity() { if (g == null) { f.background = if (tag == "g:all") sw.allArt() else sw.art("add") // g:add and g:devadd if (tag != "g:all") f.addView(sw.text("+", size * 0.3f, 0xFFFFFFFF.toInt(), 600).apply { gravity = Gravity.CENTER }, FrameLayout.LayoutParams(-1, -1)) + // All games doesn't show the name above the grid, so Add apps carries its own + if (tag == "g:devadd") f.addView(sw.text(label, titleSize, 0xFFFFFFFF.toInt(), 800).apply { + gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL; setPadding(0, 0, 0, sw.px(size * 0.08f)) + }, FrameLayout.LayoutParams(-1, -1)) } else { f.background = sw.art(g.seed) val k = size / 380f From 6c2012269cb8af8b529bea4e9e5e24c0a6224779 Mon Sep 17 00:00:00 2001 From: MoonCast Date: Thu, 22 Oct 2026 16:29:26 +0000 Subject: [PATCH 3/6] feat(handheld): app tiles in the icon's colour, Add apps as an icon grid (test249) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + .../handheld/HandheldHomeActivity.kt | 119 ++++++++++++++---- .../java/com/limelight/handheld/Sw.kt | 10 ++ .../java/com/limelight/home/DeviceApps.kt | 36 +++++- 4 files changed, 147 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27a306f795..ddee052851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.0-test249 (2026-10-22) +- On this device, a better look: + - **Tiles** take their colour from the app's own icon (its strongest hue, toned down so the name stays readable), with a big faded copy of the icon leaning in from the corner and the icon itself larger, above the name. The background wash follows the same colour. Grey or white icons keep the usual covers. + - **Add apps** is a wide page with a grid of the apps' real icons and names instead of a text list: Games / All apps chips with counts, "N added" at the top, and a ✓ and green ring on what's added. A tap adds or removes at once without the grid jumping back to the top. MoonCast's own copies (a beta next to a test build) aren't listed. + ## 1.0-test248 (2026-10-22) - On this device, tested on the Odin (test247): the tab, the picker (emulators listed first as games), adding, opening (Back returns to MoonCast) and Remove from MoonCast (the app stays installed) all work. The Add apps tile showed only "+": it now says Add apps. diff --git a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt index 3c4a6633c4..c2586fa7a7 100644 --- a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt +++ b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt @@ -843,7 +843,8 @@ class HandheldHomeActivity : Activity() { val f = FrameLayout(this) f.tag = tag f.setTag(R.id.hh_title, label) - f.setTag(R.id.hh_tint, if (g != null) sw.tint(g.seed) else 0) + val appColor = 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 f.outlineProvider = rounded(sw.tileCorner) f.elevation = sw.pxf(8) // a soft shadow lifts the tiles off the background @@ -856,7 +857,7 @@ class HandheldHomeActivity : Activity() { gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL; setPadding(0, 0, 0, sw.px(size * 0.08f)) }, FrameLayout.LayoutParams(-1, -1)) } else { - f.background = sw.art(g.seed) + f.background = if (appColor != 0) sw.artFrom(appColor) else sw.art(g.seed) val k = size / 380f val title = sw.text(g.title, titleSize, 0xFFFFFFFF.toInt(), 800, 3).apply { gravity = Gravity.BOTTOM @@ -869,10 +870,18 @@ class HandheldHomeActivity : Activity() { f.addView(title, FrameLayout.LayoutParams(-1, -1)) loader(g.pc).populateImageView(g.app, img, title) } else if (g.dev != null) { - // an Android app: its own icon in the middle of its colour, the name under it + // an Android app, on a cover in its own colour: a big faded copy of its icon leaning in from the + // corner, the icon itself above the middle, the name under it val icon = com.limelight.home.DeviceApps.icon(this, g.dev.pkg) - if (icon != null) f.addView(ImageView(this).apply { setImageDrawable(icon); scaleType = ImageView.ScaleType.FIT_CENTER }, - FrameLayout.LayoutParams(sw.px(size * 0.42f), sw.px(size * 0.42f), Gravity.CENTER).apply { bottomMargin = sw.px(size * 0.1f) }) + if (icon != null) { + f.addView(ImageView(this).apply { + setImageDrawable(icon.constantState?.newDrawable()?.mutate() ?: icon) + scaleType = ImageView.ScaleType.FIT_CENTER; alpha = 0.16f; rotation = -14f + }, FrameLayout.LayoutParams(sw.px(size * 0.95f), sw.px(size * 0.95f), Gravity.END or Gravity.BOTTOM).apply { + rightMargin = -sw.px(size * 0.22f); bottomMargin = -sw.px(size * 0.18f) }) + f.addView(ImageView(this).apply { setImageDrawable(icon); scaleType = ImageView.ScaleType.FIT_CENTER }, + FrameLayout.LayoutParams(sw.px(size * 0.5f), sw.px(size * 0.5f), Gravity.CENTER_HORIZONTAL).apply { topMargin = sw.px(size * 0.15f) }) + } f.addView(title, FrameLayout.LayoutParams(-1, -1)) } else f.addView(title, FrameLayout.LayoutParams(-1, -1)) val bs = if (size >= 500) 24 else if (size >= 360) 20 else 17 @@ -1193,24 +1202,92 @@ class HandheldHomeActivity : Activity() { }), listOf("Close" to {})) } + /** The picker's filter: "games" (what Android marks as games, the usual pick) or "all". */ + private var pickerFilter = "games" + /** - * "On this device": the installed apps, games first, each one ticked or not. A pick adds or removes it at once and - * the list stays open where you were, so you can pick several. + * "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. */ - private fun pickDeviceApps(at: Int = 0) { - val apps = com.limelight.home.DeviceApps.installed(this) - if (apps.isEmpty()) { toast("No apps found on this ${deviceShort()}"); return } - val opts = apps.mapIndexed { i, a -> - val on = com.limelight.home.DeviceApps.isPinned(this, a.pkg) - Opt(if (on) "✓" else "+", if (on) Sw.LIVE else sw.panel, a.label, if (a.isGame) "Game" else null, if (on) "Added" else null) { - if (on) com.limelight.home.DeviceApps.remove(this, a.pkg) else com.limelight.home.DeviceApps.add(this, a.pkg) - render(force = true) - pickDeviceApps(i) + private fun pickDeviceApps() { + 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 } + if (games.isEmpty()) pickerFilter = "all" + val shown = if (pickerFilter == "games") games else all + val card = card() + val head = sw.row(Gravity.CENTER_VERTICAL) + head.addView(sw.text("Add apps", 40, sw.ink, 800), LinearLayout.LayoutParams(0, -2, 1f)) + val count = sw.text("", 26, Sw.LIVE, 800) + fun recount() { val n = com.limelight.home.DeviceApps.pinned(this).size; count.text = if (n == 0) "None added yet" else "$n added" } + recount() + head.addView(count) + card.addView(head, sw.lp(-1, -2).apply { leftMargin = sw.px(56); rightMargin = sw.px(56); topMargin = sw.px(44) }) + card.addView(sw.text("Tap an app to show it in MoonCast. Tap it again to take it out. Your apps stay installed.", 24, sw.sub, 600, 2), + sw.lp(-1, -2).apply { leftMargin = sw.px(56); rightMargin = sw.px(56); topMargin = sw.px(8) }) + val chips = sw.row() + listOf("games" to "Games (${games.size})", "all" to "All apps (${all.size})").forEachIndexed { i, (k, label) -> + val on = k == pickerFilter + chips.addView(sw.text(label, 24, if (on) sw.bg else sw.sub, 700).apply { + tag = "chip:$k"; gravity = Gravity.CENTER; setPadding(sw.px(28), 0, sw.px(28), 0) + 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() + this@HandheldHomeActivity.overlay.findViewWithTag("chip:$k")?.requestFocus() } + } + }, sw.lp(-2, 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) }) + + // the grid: as many columns as fit cells about 210 wide in the card + val cardW = minOf(1560f, sw.designWidth - 80) + val gap = 18 + val cols = ((cardW - 2 * 48 + gap) / (210 + gap)).toInt().coerceIn(3, 7) + val cell = ((cardW - 2 * 48 - (cols - 1) * gap) / cols).toInt() + val grid = GridLayout(this).apply { columnCount = cols; clipChildren = false } + shown.forEachIndexed { i, a -> + val v = FrameLayout(this).apply { + tag = if (i == 0) "primary" else "app:$i" + clipChildren = false + sw.focusable(this); sw.pressable(this) + } + val check = sw.text("✓", 26, 0xFFFFFFFF.toInt(), 800).apply { gravity = Gravity.CENTER; background = sw.round(Sw.LIVE, 22) } + fun paint() { + val on = com.limelight.home.DeviceApps.isPinned(this, a.pkg) + v.background = sw.round(sw.bg, 22, if (on) Sw.LIVE else 0, if (on) 4 else 0) + check.visibility = if (on) View.VISIBLE else View.GONE + } + val iconSize = (cell * 0.5f).toInt() + com.limelight.home.DeviceApps.icon(this, a.pkg)?.let { d -> + v.addView(ImageView(this).apply { setImageDrawable(d); scaleType = ImageView.ScaleType.FIT_CENTER }, + FrameLayout.LayoutParams(sw.px(iconSize), sw.px(iconSize), Gravity.CENTER_HORIZONTAL).apply { topMargin = sw.px(cell * 0.12f) }) + } + v.addView(sw.text(a.label, 21, sw.ink, 700, 2).apply { + gravity = Gravity.CENTER_HORIZONTAL; setPadding(sw.px(10), 0, sw.px(10), 0) + }, FrameLayout.LayoutParams(-1, -2, Gravity.BOTTOM).apply { bottomMargin = sw.px(cell * 0.08f) }) + v.addView(check, sw.flp(44, 44, Gravity.END).apply { topMargin = sw.px(10); rightMargin = sw.px(10) }) + paint() + v.setOnClickListener { + if (com.limelight.home.DeviceApps.isPinned(this, a.pkg)) com.limelight.home.DeviceApps.remove(this, a.pkg) + else com.limelight.home.DeviceApps.add(this, a.pkg) + FeelSounds.play(this@HandheldHomeActivity, FeelSounds.Cue.TOGGLE) + paint(); recount() } + grid.addView(v, GridLayout.LayoutParams().apply { + width = sw.px(cell); height = sw.px(cell * 1.08f) + if (i % cols != 0) leftMargin = sw.px(gap) + bottomMargin = sw.px(gap) + }) } - dialog("Add apps", "Pick the apps to show in MoonCast. Pick again to remove one.", opts, listOf("Done" to {})) - // back on the row just picked - (overlay.findViewWithTag(if (at == 0) "primary" else "opt:$at"))?.requestFocus() + val gridH = (sw.designHeight - 470).coerceAtLeast(300f) + card.addView(ColScroll(this, sw.px(30)).apply { + isVerticalScrollBarEnabled = false; clipToPadding = false + setPadding(0, sw.px(8), 0, sw.px(8)) + addView(grid) + }, sw.lp(-1, gridH).apply { leftMargin = sw.px(48); rightMargin = sw.px(48); topMargin = sw.px(22) }) + buttons(card, listOf("Done" to {}), primary = false) + showOverlay("picker", card, wide = true) } // ---------------------------------------------------------------- Settings @@ -2101,13 +2178,13 @@ class HandheldHomeActivity : Activity() { private var returnTag: String? = null - private fun showOverlay(kind: String, card: View) { + private fun showOverlay(kind: String, card: View, wide: Boolean = false) { FeelSounds.play(this, FeelSounds.Cue.OPEN) if (overlayKind == null) returnTag = currentFocus?.tag as? String // where focus goes back to when it closes overlayKind = kind overlay.removeAllViews() overlay.setBackgroundColor(0x9E000000.toInt()) - overlay.addView(card, FrameLayout.LayoutParams(sw.px(minOf(960f, sw.designWidth - 60)), -2, Gravity.CENTER)) + overlay.addView(card, FrameLayout.LayoutParams(sw.px(if (wide) minOf(1560f, sw.designWidth - 80) else minOf(960f, sw.designWidth - 60)), -2, Gravity.CENTER)) overlay.visibility = View.VISIBLE overlay.setOnClickListener { hideOverlay() } // a tap outside the card closes it card.isClickable = true diff --git a/app/src/handheld/java/com/limelight/handheld/Sw.kt b/app/src/handheld/java/com/limelight/handheld/Sw.kt index 9ae2c6d11a..0a1214ef4f 100644 --- a/app/src/handheld/java/com/limelight/handheld/Sw.kt +++ b/app/src/handheld/java/com/limelight/handheld/Sw.kt @@ -215,6 +215,16 @@ class Sw(ctx: Context, val light: Boolean, val portrait: Boolean, val size: Size /** Cover art for things without box art: the mockup's radial gradients. */ fun art(seed: String, radius: Number = tileCorner): Drawable = Art(ART[artIndex(seed)], pxf(radius)) + /** A cover in one colour (an app's own, from its icon): lighter top, the colour, darker bottom. */ + fun artFrom(color: Int, radius: Number = tileCorner): Drawable { + fun shade(f: Float): Int { + val hsv = FloatArray(3); android.graphics.Color.colorToHSV(color, hsv) + hsv[2] = (hsv[2] * f).coerceIn(0f, 1f); if (f > 1f) hsv[1] = (hsv[1] * 0.85f) + return android.graphics.Color.HSVToColor(hsv) + } + return Art(intArrayOf(shade(1.25f), color, shade(0.45f)), pxf(radius)) + } + /** The tint colour a cover sets for the background wash. */ fun tint(seed: String): Int = ART[artIndex(seed)][1] diff --git a/app/src/main/java/com/limelight/home/DeviceApps.kt b/app/src/main/java/com/limelight/home/DeviceApps.kt index 8c1f6a140e..29a5a23ee2 100644 --- a/app/src/main/java/com/limelight/home/DeviceApps.kt +++ b/app/src/main/java/com/limelight/home/DeviceApps.kt @@ -37,7 +37,7 @@ object DeviceApps { private fun save(c: Context, l: List) = store(c).edit().putString(PREF, JSONArray(l).toString()).apply() - /** Every app with a launcher entry (phone or TV launcher), not MoonCast itself: games first, then A to Z. */ + /** 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 { val pm = c.packageManager val pkgs = LinkedHashSet() @@ -45,7 +45,7 @@ object DeviceApps { @Suppress("DEPRECATION") pm.queryIntentActivities(Intent(Intent.ACTION_MAIN).addCategory(cat), 0).forEach { pkgs += it.activityInfo.packageName } } - pkgs -= c.packageName + pkgs.removeAll { it == c.packageName || it.startsWith("me.mooncast.") } // MoonCast itself, and its beta or test copy return pkgs.mapNotNull { info(c, it) }.sortedWith(compareByDescending { it.isGame }.thenBy { it.label.lowercase() }) } @@ -64,6 +64,38 @@ object DeviceApps { (if (tvBanner) pm.getApplicationBanner(pkg) else null) ?: pm.getApplicationIcon(pkg) } catch (e: Exception) { null } + private val colors = HashMap() + + /** + * The app's own colour, from its icon: the strongest hue among its vivid pixels, toned down so white text reads + * on it. 0 for a grey or white icon (the caller uses its usual cover then). + */ + @JvmStatic fun color(c: Context, pkg: String): Int = colors.getOrPut(pkg) { + val d = icon(c, pkg) ?: return@getOrPut 0 + val n = 32 + val bmp = android.graphics.Bitmap.createBitmap(n, n, android.graphics.Bitmap.Config.ARGB_8888) + d.setBounds(0, 0, n, n); d.draw(android.graphics.Canvas(bmp)) + val weight = FloatArray(12); val sum = Array(12) { FloatArray(3) } + val hsv = FloatArray(3) + for (y in 0 until n) for (x in 0 until n) { + val px = bmp.getPixel(x, y) + if (android.graphics.Color.alpha(px) < 200) continue + android.graphics.Color.colorToHSV(px, hsv) + if (hsv[1] < 0.25f || hsv[2] < 0.2f) continue // greys, whites and near-blacks don't say much + val bin = ((hsv[0] / 30f).toInt()).coerceIn(0, 11) + val w = hsv[1] * hsv[2] + weight[bin] += w + sum[bin][0] += android.graphics.Color.red(px) * w; sum[bin][1] += android.graphics.Color.green(px) * w; sum[bin][2] += android.graphics.Color.blue(px) * w + } + bmp.recycle() + val best = weight.indices.maxByOrNull { weight[it] } ?: return@getOrPut 0 + if (weight[best] < 8f) return@getOrPut 0 + val avg = android.graphics.Color.rgb((sum[best][0] / weight[best]).toInt(), (sum[best][1] / weight[best]).toInt(), (sum[best][2] / weight[best]).toInt()) + android.graphics.Color.colorToHSV(avg, hsv) + hsv[1] = hsv[1].coerceIn(0.45f, 0.85f); hsv[2] = hsv[2].coerceIn(0.45f, 0.72f) + android.graphics.Color.HSVToColor(hsv) + } + /** Opens the app; false when it can't be opened (uninstalled, or no launcher entry any more). */ @JvmStatic fun launch(c: Context, pkg: String): Boolean { val pm = c.packageManager From 6966144a3fac493aecd5a1ceb994406116b01434 Mon Sep 17 00:00:00 2001 From: MoonCast Date: Thu, 22 Oct 2026 17:42:07 +0000 Subject: [PATCH 4/6] feat(handheld): Change picture for any game tile (test250) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ .../handheld/HandheldHomeActivity.kt | 50 +++++++++++++-- .../main/java/com/limelight/home/CustomArt.kt | 62 +++++++++++++++++++ 3 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/limelight/home/CustomArt.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ddee052851..eace77ca2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.0-test250 (2026-10-22) +- **Change picture** for any game tile on the handheld: a PC game, a PS5, an Xbox or an Android app. Long press the tile (or X on Home), Change picture, and pick an image with Android's photo picker. MoonCast keeps a resized copy (longest side 900 px) in its own storage and shows it filling the tile, darkened at the bottom so the name stays readable; the background wash takes its colour. **Reset picture** goes back to MoonCast's own look. + - `home/CustomArt.kt`. PS5 and Xbox tiles now have a menu too (Play, Change picture). + ## 1.0-test249 (2026-10-22) - On this device, a better look: - **Tiles** take their colour from the app's own icon (its strongest hue, toned down so the name stays readable), with a big faded copy of the icon leaning in from the corner and the icon itself larger, above the name. The background wash follows the same colour. Grey or white icons keep the usual covers. diff --git a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt index c2586fa7a7..da4403ab34 100644 --- a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt +++ b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt @@ -843,7 +843,9 @@ class HandheldHomeActivity : Activity() { val f = FrameLayout(this) f.tag = tag f.setTag(R.id.hh_title, label) - val appColor = g?.dev?.let { com.limelight.home.DeviceApps.color(this, it.pkg) } ?: 0 + val custom = g?.let { com.limelight.home.CustomArt.bitmap(this, it.key) } + val appColor = if (custom != null) com.limelight.home.CustomArt.color(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 f.outlineProvider = rounded(sw.tileCorner) @@ -864,7 +866,15 @@ class HandheldHomeActivity : Activity() { setPadding(sw.px(26 * k.coerceAtMost(1.5f)), 0, sw.px(26 * k.coerceAtMost(1.5f)), sw.px(24 * k.coerceAtMost(1.5f))) setShadowLayer(sw.pxf(10), 0f, sw.pxf(2), 0x59000000) } - if (g.pc != null && g.app != null) { + if (custom != null) { + // your own picture fills the tile, darkened at the bottom so the name stays readable + f.addView(ImageView(this).apply { setImageBitmap(custom); scaleType = ImageView.ScaleType.CENTER_CROP }, FrameLayout.LayoutParams(-1, -1)) + f.addView(View(this).apply { + background = android.graphics.drawable.GradientDrawable(android.graphics.drawable.GradientDrawable.Orientation.TOP_BOTTOM, + intArrayOf(0x00000000, 0x00000000, 0xB0000000.toInt())) + }, FrameLayout.LayoutParams(-1, -1)) + f.addView(title, FrameLayout.LayoutParams(-1, -1)) + } else if (g.pc != null && g.app != null) { val img = ImageView(this).apply { scaleType = ImageView.ScaleType.CENTER_CROP } f.addView(img, FrameLayout.LayoutParams(-1, -1)) f.addView(title, FrameLayout.LayoutParams(-1, -1)) @@ -888,7 +898,7 @@ class HandheldHomeActivity : Activity() { val m = if (size >= 360) 18 else 12 f.addView(sw.badge(g.src, bs), sw.flp(-2, bs * 2).apply { leftMargin = sw.px(m); topMargin = sw.px(m) }) if (g.running) f.addView(sw.live(bs), sw.flp(-2, bs * 2, Gravity.END).apply { rightMargin = sw.px(m); topMargin = sw.px(m) }) - if ((g.pc != null && g.app != null) || g.dev != null) { + run { f.setOnLongClickListener { gameMenu(g); true } f.setOnKeyListener { _, code, e -> if (code == KeyEvent.KEYCODE_BUTTON_X && page == Page.HOME) { if (e.action == KeyEvent.ACTION_UP) gameMenu(g); true } else false @@ -1179,10 +1189,33 @@ class HandheldHomeActivity : Activity() { listOf("Show all" to { show(hidden); toast("All hidden games are back") }, "Close" to {})) } + /** Change picture / Reset picture, for any tile's menu. */ + private fun pictureOpts(g: Game): List = listOfNotNull( + Opt("▣", Sw.C_BLUE, "Change picture", "Pick any image from this ${deviceShort()} for this tile") { pickPicture(g.key) }, + if (com.limelight.home.CustomArt.has(this, g.key)) Opt("↺", sw.panel, "Reset picture", "Back to MoonCast's own look") { + com.limelight.home.CustomArt.clear(this, g.key); render(force = true); toast("Picture reset") + } else null) + + /** The key of the tile whose picture is being picked (the photo picker answers in onActivityResult). */ + private var pictureFor: String? = null + + private fun pickPicture(key: String) { + pictureFor = key + val i = if (Build.VERSION.SDK_INT >= 33) Intent(android.provider.MediaStore.ACTION_PICK_IMAGES) + else Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("image/*") + try { startActivityForResult(i, REQ_PICTURE) } + catch (e: Exception) { toast("No photo picker on this ${deviceShort()}") } + } + private fun gameMenu(g: Game) { + if (g.ps != null || g.xb != null) { + dialog(g.title, if (g.ps != null) "A PlayStation." else "An Xbox.", listOf( + Opt("▶", Sw.LIVE, "Play") { play(g) }) + pictureOpts(g), listOf("Close" to {})) + return + } g.dev?.let { a -> dialog(a.label, "An app on this ${deviceShort()}.", listOf( - Opt("▶", Sw.LIVE, "Open") { playHere(g) }, + Opt("▶", Sw.LIVE, "Open") { playHere(g) }) + pictureOpts(g) + listOf( Opt("–", Sw.DANGER, "Remove from MoonCast", "It stays installed; add it again from All games, This ${deviceShort()}") { com.limelight.home.DeviceApps.remove(this, a.pkg); Recents.forget(this, g.key) render(force = true); toast("${a.label} removed from MoonCast") @@ -1195,6 +1228,7 @@ class HandheldHomeActivity : Activity() { Opt("▶", Sw.LIVE, if (running) "Resume here" else "Play here") { playHere(g) }, Opt("TV", sw.ring, "Play on a TV") { playWhere(g) }, if (running) Opt("✕", Sw.DANGER, "Quit game", "Closes it on the PC") { quit(pc, app) } else null, + *pictureOpts(g).toTypedArray(), Opt("–", Sw.PC, "Hide from All games", "Show it again from the PC's menu in Consoles, Hidden games") { val p = getSharedPreferences(AppView.HIDDEN_APPS_PREF_FILENAME, MODE_PRIVATE) val set = HashSet(p.getStringSet(pc.uuid, emptySet())!!); set.add(app.appId.toString()) @@ -1992,6 +2026,13 @@ class HandheldHomeActivity : Activity() { when (requestCode) { REQ_PSN_REGISTER -> { toast("Signed in to PlayStation Network"); ui.post { registerPs() } } REQ_PSN -> { toast("Signed in to PlayStation Network"); render(force = true) } + REQ_PICTURE -> { + val key = pictureFor ?: return; val uri = data?.data ?: return + Thread { + val ok = com.limelight.home.CustomArt.set(this, key, uri) + runOnUiThread { if (ok) { render(force = true); toast("Picture changed") } else toast("That image couldn't be read") } + }.start() + } } } @@ -2396,5 +2437,6 @@ class HandheldHomeActivity : Activity() { const val EXTRA_PLAY_HERE = "play_here" private const val REQ_PSN_REGISTER = 41 private const val REQ_PSN = 42 + private const val REQ_PICTURE = 43 } } diff --git a/app/src/main/java/com/limelight/home/CustomArt.kt b/app/src/main/java/com/limelight/home/CustomArt.kt new file mode 100644 index 0000000000..a92b8141ce --- /dev/null +++ b/app/src/main/java/com/limelight/home/CustomArt.kt @@ -0,0 +1,62 @@ +package com.limelight.home + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.os.Build +import android.util.LruCache +import java.io.File +import java.security.MessageDigest + +/** + * Your own picture for a game tile (a PC game, a PS5, an Xbox or an Android app): picked from the device's photos, + * kept as a resized copy in the app's own storage (never shared), keyed by the tile's game key. + */ +object CustomArt { + private const val MAX = 900 // the longest side kept: sharp on the biggest tiles, small on disk + private val cache = LruCache(24) + + 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") + } + + @JvmStatic fun has(c: Context, key: String) = file(c, key).exists() + + /** The picture for [key], or null when there's none. */ + @JvmStatic fun bitmap(c: Context, key: String): Bitmap? { + cache.get(key)?.let { return it } + val f = file(c, key) + if (!f.exists()) return null + return BitmapFactory.decodeFile(f.path)?.also { cache.put(key, it) } + } + + /** Copies the picked image in, resized; false when it can't be read. Call it off the main thread. */ + @JvmStatic fun set(c: Context, key: String, uri: Uri): Boolean = try { + val bmp: Bitmap = if (Build.VERSION.SDK_INT >= 28) { + android.graphics.ImageDecoder.decodeBitmap(android.graphics.ImageDecoder.createSource(c.contentResolver, uri)) { d, info, _ -> + val s = maxOf(info.size.width, info.size.height) + if (s > MAX) d.setTargetSize(info.size.width * MAX / s, info.size.height * MAX / s) + d.allocator = android.graphics.ImageDecoder.ALLOCATOR_SOFTWARE + } + } else { + val o = BitmapFactory.Options().apply { inJustDecodeBounds = true } + c.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, o) } + var sample = 1 + while (maxOf(o.outWidth, o.outHeight) / (sample * 2) >= MAX) sample *= 2 + c.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample }) } + ?: throw IllegalStateException("unreadable") + } + file(c, key).outputStream().use { bmp.compress(Bitmap.CompressFormat.JPEG, 88, it) } + cache.put(key, bmp) + 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) } + + /** The picture's average colour, for the background wash. */ + @JvmStatic fun color(b: Bitmap): Int = Bitmap.createScaledBitmap(b, 1, 1, true).getPixel(0, 0) +} From 386951a4b8557724d1c5ef17804dc586a592b811 Mon Sep 17 00:00:00 2001 From: MoonCast Date: Thu, 22 Oct 2026 18:17:53 +0000 Subject: [PATCH 5/6] fix(handheld): added apps and consoles keep their place on Home; Add apps starts on Done and says what each tap did (test251) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++++ .../handheld/HandheldHomeActivity.kt | 25 +++++++++++++------ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eace77ca2a..0c2592913f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 1.0-test251 (2026-10-22) +- Added Android apps "disappeared after a while" (Saleh on the Odin): + - **Home** kept the last-played games first and cut the row at ten, so after about ten games played your added apps (and the PS5 and Xbox) fell off Home. Now the row is: what's running, the last five played, then every console and every added app (they always keep a place), then PC games up to ten. + - **Add apps** started with the first app selected and the hints said "A OK", so an A meant to close the page took that app out (Azahar, first in the list). Focus now starts on Done, the hints say "B Done, A Add or remove", and each tap says " added" or " removed. Tap it again to add it back". + ## 1.0-test250 (2026-10-22) - **Change picture** for any game tile on the handheld: a PC game, a PS5, an Xbox or an Android app. Long press the tile (or X on Home), Change picture, and pick an image with Android's photo picker. MoonCast keeps a resized copy (longest side 900 px) in its own storage and shows it filling the tile, darkened at the bottom so the name stays readable; the background wash takes its colour. **Reset picture** goes back to MoonCast's own look. - `home/CustomArt.kt`. PS5 and Xbox tiles now have a menu too (Play, Change picture). diff --git a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt index da4403ab34..52a1730860 100644 --- a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt +++ b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt @@ -472,18 +472,24 @@ class HandheldHomeActivity : Activity() { return out } - /** Home's row: what's running, then what you played last, then the rest; at most ten before "All games". */ + /** + * Home's row: what's running, the last few played, then every console and every app you added (they always keep + * their place: with only the last-played first, ten PC games played pushed them off Home), then PC games to fill + * up to ten, before "All games". + */ private fun homeGames(): List { val all = allGames() val byKey = all.associateBy { it.key } val recent = Recents.list(this).mapNotNull { byKey[it] } val ordered = LinkedHashMap() all.filter { it.running }.forEach { ordered[it.key] = it } - recent.forEach { ordered[it.key] = it } + recent.take(5).forEach { ordered[it.key] = it } all.filter { it.ps != null || it.xb != null }.forEach { ordered[it.key] = it } all.filter { it.dev != null }.forEach { ordered[it.key] = it } + val kept = ordered.size + recent.forEach { ordered[it.key] = it } all.filter { it.pc != null }.sortedBy { it.title.lowercase() }.forEach { ordered[it.key] = it } - return ordered.values.take(10) + return ordered.values.take(maxOf(10, kept)) } private fun gameFor(key: String?): Game? = key?.let { k -> allGames().firstOrNull { it.key == k } } @@ -681,7 +687,8 @@ class HandheldHomeActivity : Activity() { foot.addView(sw.grow()) // button hints mean nothing without buttons: touch only (no controller) shows none if (!hasController()) { part(foot); return } - val h = if (overlayKind != null) listOf("B" to "Cancel", "A" to "OK") else when (page) { + val h = if (overlayKind == "picker") listOf("B" to "Done", "A" to "Add or remove") + else if (overlayKind != null) listOf("B" to "Cancel", "A" to "OK") else when (page) { Page.HOME -> listOf("Y" to (if (TvFinder.target(this) != null) "Play here" else "Play on TV"), "A" to "Start") Page.GAMES -> listOf("L R" to "Tabs", "X" to (if (sortAz) "Recent first" else "A to Z"), "B" to "Back", "A" to "Start") Page.CONSOLES -> listOf("B" to "Back", "A" to "Open") @@ -1282,7 +1289,7 @@ class HandheldHomeActivity : Activity() { val grid = GridLayout(this).apply { columnCount = cols; clipChildren = false } shown.forEachIndexed { i, a -> val v = FrameLayout(this).apply { - tag = if (i == 0) "primary" else "app:$i" + tag = "app:$i" clipChildren = false sw.focusable(this); sw.pressable(this) } @@ -1303,9 +1310,10 @@ class HandheldHomeActivity : Activity() { v.addView(check, sw.flp(44, 44, Gravity.END).apply { topMargin = sw.px(10); rightMargin = sw.px(10) }) paint() v.setOnClickListener { - if (com.limelight.home.DeviceApps.isPinned(this, a.pkg)) com.limelight.home.DeviceApps.remove(this, a.pkg) - else com.limelight.home.DeviceApps.add(this, a.pkg) + val was = com.limelight.home.DeviceApps.isPinned(this, a.pkg) + if (was) com.limelight.home.DeviceApps.remove(this, a.pkg) else com.limelight.home.DeviceApps.add(this, a.pkg) FeelSounds.play(this@HandheldHomeActivity, FeelSounds.Cue.TOGGLE) + toast(if (was) "${a.label} removed. Tap it again to add it back" else "${a.label} added") paint(); recount() } grid.addView(v, GridLayout.LayoutParams().apply { @@ -1320,7 +1328,8 @@ class HandheldHomeActivity : Activity() { setPadding(0, sw.px(8), 0, sw.px(8)) addView(grid) }, sw.lp(-1, gridH).apply { leftMargin = sw.px(48); rightMargin = sw.px(48); topMargin = sw.px(22) }) - buttons(card, listOf("Done" to {}), primary = false) + // focus starts on Done: a habitual A closes the page instead of taking out the first app + buttons(card, listOf("Done" to {})) showOverlay("picker", card, wide = true) } From fc7695faa55da507a643e3eef03000ba203e352c Mon Sep 17 00:00:00 2001 From: MoonCast Date: Thu, 22 Oct 2026 18:21:16 +0000 Subject: [PATCH 6/6] feat(handheld): Add apps tile on Home and Apps on this device in Settings (test252) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ .../java/com/limelight/handheld/HandheldHomeActivity.kt | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c2592913f..06ea92a0d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 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. + ## 1.0-test251 (2026-10-22) - Added Android apps "disappeared after a while" (Saleh on the Odin): - **Home** kept the last-played games first and cut the row at ten, so after about ten games played your added apps (and the PS5 and Xbox) fell off Home. Now the row is: what's running, the last five played, then every console and every added app (they always keep a place), then PC games up to ten. diff --git a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt index 52a1730860..5d98266a53 100644 --- a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt +++ b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt @@ -750,9 +750,11 @@ class HandheldHomeActivity : Activity() { col.addView(part(box), sw.lp(-1, 48).apply { topMargin = sw.px(headTop); leftMargin = sw.px(left) }) val tiles = ArrayList() - val shown = if (grid) games.take(cols * rows - 1) else games // the last cell is All games + val shown = if (grid) games.take(cols * rows - 2) else games // the last two cells: Add apps, All games if (games.isEmpty()) tiles += tileView(null, ts, 36, "g:add", "Add a console") { go(Page.CONSOLES) } shown.forEach { g -> tiles += tileView(g, ts, 36, "g:${g.key}", g.title) { play(g) } } + // always on Home: the way to add this device's apps (before, it was only in All games, This ) + tiles += tileView(null, ts, 36, "g:devadd", "Add apps") { pickDeviceApps() } tiles += tileView(null, ts, 36, "g:all", "All games") { gamesTab = "all"; go(Page.GAMES) } if (grid) { val gl = GridLayout(this).apply { columnCount = cols; clipChildren = false } @@ -1376,6 +1378,8 @@ class HandheldHomeActivity : Activity() { choice(list, prefs, "hh_screen_size", "Screen size", "Auto fits this screen. Compact and Large make everything bigger", listOf("Auto" to "auto", "Compact" to "compact", "Normal" to "normal", "Large" to "large"), "auto") { recreate() } appToggle(list, "hh_rotate", "Screen turns with the device", "Portrait when you hold it upright", true) { applyRotation() } + action(list, "set:devapps", "Apps on this ${deviceShort()}", "Pick Android apps to show next to your games (emulators, Steam Link…)", + com.limelight.home.DeviceApps.pinned(this).size.let { if (it == 0) "None" else "$it" }) { pickDeviceApps() } // PC games: what a finger on the picture does when a game starts (the Quick menu changes it for that game) choice(list, stream, com.limelight.Game.TOUCH_DEFAULT_PREF, "Touch screen", "In PC games. Mouse is a trackpad, Touch clicks where you tap. The Quick menu changes it while you play", listOf("Off" to "off", "Mouse" to "mouse", "Touch" to "touch"), "off")