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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 1.0-test245 (2026-10-22)
- **"On battery, stream at" is removed** (Settings, Handheld, and `home/BatteryCap.kt`, added for #65 in test237). It saved little: the screen uses most of the battery and doesn't change, and Native resolution already avoids streaming more pixels than the screen shows. With it goes test244's Auto-bitrate fix. A saved setting is simply ignored.

## 1.0-test244 (2026-10-22)
- **Quit prompts pick Cancel.** "Quit X and start Y?", "Quit game?", Unpair and Remove now have Cancel selected, on the handheld and the TV, so pressing A (or OK on a remote) by habit doesn't do them. On 2026-10-21 an A on the pre-selected "Quit and start" quit Ryujinx. Wake it stays selected, since it's harmless.
- Handheld: while a dialog is open the hints say "B Cancel, A OK" instead of the page's "A Start", and a tapped A hint presses the dialog's selected button, not the game behind it.
- Handheld: a PC game moves to the front of Recent only once it really starts, not when the prompt opens, so Cancel no longer reshuffles the tiles.
- **On battery with bitrate Auto:** the bitrate was still the one for the uncapped size (80 Mbps for 4K on a 720p stream). It's now worked out for the capped size (10 Mbps for 720p60, Moonlight's usual Auto).

## 1.0-test243 (2026-10-22)
- Touch screen (Quick menu, PC), found by Saleh on test242:
- The PC's pointer stayed in the picture with touch Off. With Off, MoonCast now moves it to the middle of the right edge, where it's off the screen, when the stream starts and when you switch to Off. (Not a corner: Windows' bottom-right corner is Show desktop.)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,7 @@ 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 = when (page) {
val h = 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")
Expand Down Expand Up @@ -1205,8 +1205,6 @@ class HandheldHomeActivity : Activity() {
// 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")
choice(list, prefs, com.limelight.home.BatteryCap.PREF, "On battery, stream at", "Unplugged, PC and PS5 streams go no higher than this. Plugged in, the Picture settings apply",
listOf("Same" to "same", "1080p" to "1080", "720p" to "720", "540p" to "540"), "same")
appToggle(list, "hh_dim_on_tv", "Dim the screen on a TV", "While this ${deviceShort()} is the controller for a TV", true)
toggle(list, "checkbox_ultra_low_latency", "Ultra low latency", "Experimental. This device: ${chipName()}", false)
}
Expand Down Expand Up @@ -1545,11 +1543,10 @@ class HandheldHomeActivity : Activity() {
when {
g.pc != null && g.app != null -> {
if (g.pc.state == ComputerDetails.State.OFFLINE) { // only when a poll says so (UNKNOWN = not asked yet)
confirm("${g.pc.name} is offline", "Wake it, then start ${g.app.appName} once it's online.", "Wake it") { wake(g.pc) }
confirm("${g.pc.name} is offline", "Wake it, then start ${g.app.appName} once it's online.", "Wake it", yesPicked = true) { wake(g.pc) }
return
}
Recents.touch(this, g.key)
start(g.pc, g.app)
start(g.pc, g.app) { Recents.touch(this, g.key) }
}
g.ps != null -> { Recents.touch(this, g.key); playPs(g.ps) }
g.xb != null -> { Recents.touch(this, g.key); playXbox(g.xb) }
Expand Down Expand Up @@ -1634,7 +1631,7 @@ class HandheldHomeActivity : Activity() {
val t = when {
g.pc != null && g.app != null -> {
if (!online(g.pc)) {
confirm("${g.pc.name} is offline", "Wake it, then send ${g.app.appName} to ${tv.name} once it's online.", "Wake it") { wake(g.pc) }
confirm("${g.pc.name} is offline", "Wake it, then send ${g.app.appName} to ${tv.name} once it's online.", "Wake it", yesPicked = true) { wake(g.pc) }
return
}
MoveToTv.Target("pc", g.app.appName, g.pc.uuid, g.pc.name, g.app.appId)
Expand All @@ -1659,16 +1656,19 @@ class HandheldHomeActivity : Activity() {
Opt("⚙", Sw.PC, "Controller settings") { settingsGroup = "pads"; go(Page.SETTINGS) }), listOf("Close" to {}))
}

private fun start(pc: ComputerDetails, app: NvApp) {
/** Starts [app], asking first when another game runs there. [started] runs once it really starts (not on Cancel). */
private fun start(pc: ComputerDetails, app: NvApp, started: () -> Unit = {}) {
val b = binder ?: return toast(getString(R.string.error_manager_not_running))
val other = runningApp(pc)
if (other != null && other.appId != app.appId) {
confirm("Quit ${other.appName}?", "${pc.name} is running ${other.appName}. Quit it and start ${app.appName}?", "Quit and start") {
started()
FeelSounds.play(this, FeelSounds.Cue.LAUNCH)
ServerHelper.doQuit(this, pc, other, b) { runOnUiThread { ServerHelper.doStart(this, app, pc, b) } }
}
return
}
started()
FeelSounds.play(this, FeelSounds.Cue.LAUNCH)
ServerHelper.doStart(this, app, pc, b)
}
Expand Down Expand Up @@ -1950,13 +1950,15 @@ class HandheldHomeActivity : Activity() {
}

/** A dialog's buttons along its bottom: rounded buttons side by side; the last is where focus starts. */
private fun buttons(card: LinearLayout, list: List<Pair<String, () -> Unit>>, primary: Boolean = true, keepOpenLast: Boolean = false) {
/** A row of buttons; with [primary], the one at [primaryAt] (default the last) is the one A and the first focus pick. */
private fun buttons(card: LinearLayout, list: List<Pair<String, () -> Unit>>, primary: Boolean = true, keepOpenLast: Boolean = false,
primaryAt: Int = list.size - 1) {
val row = sw.row()
list.forEachIndexed { i, (label, fn) ->
val keep = keepOpenLast && i == list.size - 1
row.addView(sw.text(label, 30, sw.ring, 800).apply {
gravity = Gravity.CENTER
tag = if (primary && i == list.size - 1) "primary" else "btn:$i"
tag = if (primary && i == primaryAt) "primary" else "btn:$i"
setTag(R.id.hh_pill, true)
background = sw.round(sw.bg, 48)
sw.focusable(this)
Expand All @@ -1972,7 +1974,7 @@ class HandheldHomeActivity : Activity() {
card.addView(row, sw.lp(-1, 96).apply { leftMargin = sw.px(40); rightMargin = sw.px(40); topMargin = sw.px(28); bottomMargin = sw.px(40) })
}

private fun dialog(title: String, body: String?, opts: List<Opt>, btns: List<Pair<String, () -> Unit>>) {
private fun dialog(title: String, body: String?, opts: List<Opt>, btns: List<Pair<String, () -> Unit>>, primaryAt: Int = btns.size - 1) {
val card = card()
card.addView(sw.text(title, 40, sw.ink, 800, 2), sw.lp(-1, -2).apply { leftMargin = sw.px(56); rightMargin = sw.px(56); topMargin = sw.px(48); bottomMargin = sw.px(10) })
if (body != null) card.addView(sw.text(body, 26, sw.sub, 600, 8), sw.lp(-1, -2).apply { leftMargin = sw.px(56); rightMargin = sw.px(56); bottomMargin = sw.px(26) })
Expand All @@ -1984,12 +1986,16 @@ class HandheldHomeActivity : Activity() {
val fit = ((sw.designHeight - 620) / 132f).toInt().coerceIn(3, 8)
if (opts.size > fit) card.addView(ColScroll(this, sw.px(30)).apply { isVerticalScrollBarEnabled = false; addView(list) }, sw.lp(-1, fit * 132 - 66))
else card.addView(list, sw.lp(-1, -2))
buttons(card, btns, primary = firstOn < 0)
buttons(card, btns, primary = firstOn < 0, primaryAt = primaryAt)
showOverlay("dialog", card)
}

private fun confirm(title: String, body: String, yes: String, onYes: () -> Unit) =
dialog(title, body, emptyList(), listOf("Cancel" to {}, yes to onYes))
/**
* A yes/Cancel question. Cancel is picked (A presses it) unless [yesPicked]: a habitual A must not quit a game,
* unpair or remove anything (it quit Ryujinx on 2026-10-21). Only a harmless yes, like Wake it, is picked.
*/
private fun confirm(title: String, body: String, yes: String, yesPicked: Boolean = false, onYes: () -> Unit) =
dialog(title, body, emptyList(), listOf("Cancel" to {}, yes to onYes), primaryAt = if (yesPicked) 1 else 0)

/** A one-line entry; [onSave] returns an error to show, or null when the value is good. */
private fun entry(title: String, body: String, hint: String, v: String, numbers: Boolean, onSave: (String) -> String?) =
Expand Down Expand Up @@ -2047,6 +2053,7 @@ class HandheldHomeActivity : Activity() {
overlay.isFocusable = false; card.isFocusable = false
main.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
(overlay.findViewWithTag<View>("primary") ?: firstFocusable(overlay))?.requestFocus()
buildFoot() // the hints say what A and B do in the dialog, not on the page under it
}

private fun hideOverlay(cue: Boolean = true) {
Expand Down Expand Up @@ -2085,7 +2092,9 @@ class HandheldHomeActivity : Activity() {
"Y" -> KeyEvent.KEYCODE_BUTTON_Y; "L R" -> KeyEvent.KEYCODE_BUTTON_R1; else -> return
}
// A starts what's selected; after a tap nothing has focus, so use what the page starts on
if (code == KeyEvent.KEYCODE_BUTTON_A && (currentFocus == null || currentFocus?.isInTouchMode == true)) { primaryView?.performClick(); return }
if (code == KeyEvent.KEYCODE_BUTTON_A && (currentFocus == null || currentFocus?.isInTouchMode == true)) {
(if (overlayKind != null) overlay.findViewWithTag<View>("primary") else primaryView)?.performClick(); return
}
dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, code)); dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, code))
}

Expand Down
9 changes: 1 addition & 8 deletions app/src/main/java/com/limelight/Game.java
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,6 @@ protected void onCreate(Bundle savedInstanceState) {

// Read the stream preferences
prefConfig = PreferenceConfiguration.readPreferences(this);
// #65: on battery, Settings, Handheld, "On battery, stream at" caps the size. The fold checks compare the
// screen with the size before the cap, so a capped stream isn't taken for a wrong-size one.
uncappedSize = prefConfig.width + "x" + prefConfig.height;
com.limelight.home.BatteryCap.apply(this, prefConfig);
trace(foldLabel(getResources().getConfiguration()) + ": stream " + prefConfig.width + "x" + prefConfig.height
+ ", Native " + PreferenceConfiguration.nativeWholeScreen(this));
tombstonePrefs = Game.this.getSharedPreferences("DecoderTombstone", 0);
Expand Down Expand Up @@ -3030,12 +3026,9 @@ private void reconnectHere(String why) {
/** With Native on, the size this screen streams at now differs from this stream's. */
private boolean sizeChanged() {
return prefConfig != null && PreferenceConfiguration.nativeWholeScreen(this)
&& !PreferenceConfiguration.nativeResolution(this).equals(uncappedSize != null ? uncappedSize : prefConfig.width + "x" + prefConfig.height);
&& !PreferenceConfiguration.nativeResolution(this).equals(prefConfig.width + "x" + prefConfig.height);
}

/** The stream's size before the battery cap (#65). */
private String uncappedSize;

@Override
public void mouseMove(int deltaX, int deltaY) {
conn.sendMouseMove((short) deltaX, (short) deltaY);
Expand Down
9 changes: 0 additions & 9 deletions app/src/main/java/com/limelight/Ps5StreamActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -151,15 +151,6 @@ class Ps5StreamActivity : Activity(), SurfaceHolder.Callback, com.limelight.home
away && awayQuality == "1080" -> VideoResolutionPreset.RES_1080P
weak || (!away && prefs.getString("ps5_resolution", "1080") == "720") -> VideoResolutionPreset.RES_720P
else -> VideoResolutionPreset.RES_1080P
}.let { chosen ->
// #65: on battery, Settings, Handheld, "On battery, stream at" caps it
val cap = com.limelight.home.BatteryCap.height(this)
val h = when (chosen) { VideoResolutionPreset.RES_540P -> 540; VideoResolutionPreset.RES_720P -> 720; else -> 1080 }
when {
cap in 1 until h && cap <= 540 -> VideoResolutionPreset.RES_540P
cap in 1 until h -> VideoResolutionPreset.RES_720P
else -> chosen
}.also { if (it != chosen) Log.i(TAG, "on battery: $chosen capped to $it") }
}
val h264 = !ps5 || (if (away) prefs.getString("away_codec", "h265") else prefs.getString("ps5_codec", "h265")) == "h264"
// Frame rate: the PS5 streams at 30 or 60 fps only; any other rate broke the picture (#39). A Custom value
Expand Down
36 changes: 0 additions & 36 deletions app/src/main/java/com/limelight/home/BatteryCap.kt

This file was deleted.

11 changes: 6 additions & 5 deletions app/src/tv/java/com/limelight/home/HomeActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1342,7 +1342,7 @@ class HomeActivity : Activity(), PhoneLink.Listener {
val other = runningApp(pc)
if (other != null && other.appId != app.appId) {
confirm("Quit ${other.appName}?", "${pc.name} is running ${other.appName}. Quit it and start ${app.appName}?",
"Quit and start") {
"Quit and start", danger = true) {
FeelSounds.play(this, FeelSounds.Cue.LAUNCH)
ServerHelper.doQuit(this, pc, other, b) { runOnUiThread { ServerHelper.doStart(this, app, pc, b) } }
}
Expand All @@ -1354,7 +1354,7 @@ class HomeActivity : Activity(), PhoneLink.Listener {

private fun quit(pc: ComputerDetails, app: NvApp) {
val b = binder ?: return
confirm("Quit ${app.appName}?", "The game closes on ${pc.name}. Anything you haven't saved is lost.", "Quit game") {
confirm("Quit ${app.appName}?", "The game closes on ${pc.name}. Anything you haven't saved is lost.", "Quit game", danger = true) {
ServerHelper.doQuit(this, pc, app, b) { runOnUiThread { b.invalidateStateForComputer(pc.uuid) } }
}
}
Expand Down Expand Up @@ -1879,15 +1879,16 @@ class HomeActivity : Activity(), PhoneLink.Listener {
val card = dialogCard()
card.addView(tv.text(title, 40, tv.p.moon, tv.display(700), 2))
card.addView(tv.text(body, 22, tv.p.dust, tv.ui(500), 4), tv.lp(-1, -2).apply { topMargin = tv.px(12); bottomMargin = tv.px(28) })
// danger (quit, unpair, remove): Cancel is picked, so a habitual OK on the remote doesn't do it
card.addView(tv.row().apply {
addView(tv.pill(yes, "A", !danger) {
addView(tv.pill(yes, if (danger) null else "A", !danger) {
// one sound per action: what the answer starts (a launch) speaks for it, else the dialog's close
val n = FeelSounds.serial
hideOverlay(cue = false); onYes()
if (n == FeelSounds.serial) FeelSounds.play(this@HomeActivity, FeelSounds.Cue.CLOSE)
}.apply {
tag = "primary"; if (danger) (getChildAt(childCount - 1) as TextView).setTextColor(Tv.DANGER) })
addView(tv.pill("Cancel", "B", false) { hideOverlay() })
tag = if (danger) "yes" else "primary"; if (danger) (getChildAt(childCount - 1) as TextView).setTextColor(Tv.DANGER) })
addView(tv.pill("Cancel", "B", false) { hideOverlay() }.apply { if (danger) tag = "primary" })
})
showOverlay("confirm", FrameLayout(this).apply { addView(card, tv.flp(900, -2, Gravity.CENTER)) })
}
Expand Down
Loading