diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d30e9fa83..fa4de1c719 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.) diff --git a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt index 4282d270b5..fbdfc95138 100644 --- a/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt +++ b/app/src/handheld/java/com/limelight/handheld/HandheldHomeActivity.kt @@ -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") @@ -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) } @@ -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) } @@ -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) @@ -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) } @@ -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 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 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) @@ -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, btns: List Unit>>) { + private fun dialog(title: String, body: String?, opts: List, btns: List 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) }) @@ -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?) = @@ -2047,6 +2053,7 @@ class HandheldHomeActivity : Activity() { overlay.isFocusable = false; card.isFocusable = false main.descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS (overlay.findViewWithTag("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) { @@ -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("primary") else primaryView)?.performClick(); return + } dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, code)); dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, code)) } diff --git a/app/src/main/java/com/limelight/Game.java b/app/src/main/java/com/limelight/Game.java index 3a8c1e7f44..5ce5bc2e5b 100644 --- a/app/src/main/java/com/limelight/Game.java +++ b/app/src/main/java/com/limelight/Game.java @@ -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); @@ -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); diff --git a/app/src/main/java/com/limelight/Ps5StreamActivity.kt b/app/src/main/java/com/limelight/Ps5StreamActivity.kt index f65771129a..a3de616ed7 100644 --- a/app/src/main/java/com/limelight/Ps5StreamActivity.kt +++ b/app/src/main/java/com/limelight/Ps5StreamActivity.kt @@ -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 diff --git a/app/src/main/java/com/limelight/home/BatteryCap.kt b/app/src/main/java/com/limelight/home/BatteryCap.kt deleted file mode 100644 index 64885615d5..0000000000 --- a/app/src/main/java/com/limelight/home/BatteryCap.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.limelight.home - -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.BatteryManager -import android.util.Log -import com.limelight.preferences.PreferenceConfiguration - -/** - * Settings, Handheld, "On battery, stream at" (#65): while the device isn't plugged in, streams are capped at that - * height. Plugged in, the Picture settings apply. Only the handheld app sets it; elsewhere it stays "same". - */ -object BatteryCap { - const val PREF = "hh_battery_res" - - /** The height to cap a stream at now, or 0: no cap (the setting is Same, or the device is plugged in). */ - @JvmStatic - fun height(context: Context): Int { - val cap = context.getSharedPreferences("mc_tv", Context.MODE_PRIVATE).getString(PREF, "same")?.toIntOrNull() ?: return 0 - val status = try { context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) } catch (e: RuntimeException) { null } ?: return 0 - return if (status.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) == 0) cap else 0 - } - - /** Caps a PC stream's size, keeping its shape (width a multiple of 8). True if it was capped. */ - @JvmStatic - fun apply(context: Context, config: PreferenceConfiguration): Boolean { - val cap = height(context) - if (cap <= 0 || config.height <= cap) return false - val w = (Math.round(config.width.toDouble() * cap / config.height / 8.0) * 8).toInt() - Log.i("MoonCast", "on battery: stream ${config.width}x${config.height} capped to ${w}x$cap") - config.width = w - config.height = cap - return true - } -} diff --git a/app/src/tv/java/com/limelight/home/HomeActivity.kt b/app/src/tv/java/com/limelight/home/HomeActivity.kt index 433b2419c8..4d7c1a533b 100644 --- a/app/src/tv/java/com/limelight/home/HomeActivity.kt +++ b/app/src/tv/java/com/limelight/home/HomeActivity.kt @@ -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) } } } @@ -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) } } } } @@ -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)) }) }