From ade35029b0ecb37d16b9f8e6619772353926ac24 Mon Sep 17 00:00:00 2001 From: MoonCast Date: Wed, 21 Oct 2026 20:40:17 +0000 Subject: [PATCH 1/4] feat(quickmenu): Streaming status toggle, Task Manager and Hibernate PC (test238) Streaming status flips the stats corner on PC, PS5 and Xbox (new StatusCorner for PS5 and Xbox) and saves it as Stats while playing. PC only: Task Manager sends Ctrl+Shift+Esc; Hibernate PC asks first (Cancel picked), then sends Win+X, U, H, and the stream ending within a minute after that is not reported as an error. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++ app/src/main/java/com/limelight/Game.java | 85 +++++++++++++++++++ .../java/com/limelight/Ps5StreamActivity.kt | 6 ++ .../java/com/limelight/XboxStreamActivity.kt | 5 ++ .../java/com/limelight/home/StatusCorner.kt | 84 ++++++++++++++++++ 5 files changed, 186 insertions(+) create mode 100644 app/src/main/java/com/limelight/home/StatusCorner.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 394a4252ec..dbaca06155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.0-test238 (2026-10-22) +- In-game Quick menu, new items: + - **Streaming status** (PC, PS5 and Xbox): turns the stats corner on or off right away, and saves it as Settings' "Stats while playing". The PC shows Moonlight's stats; PS5 and Xbox get a new one-line corner (frame rate, network delay, bitrate, lost packets, and decode time on PS5). + - **Task Manager** (PC): presses Ctrl + Shift + Esc on the PC, which opens Task Manager on Windows. It does nothing on Linux or macOS PCs. + - **Hibernate PC** (PC): asks first, with Cancel picked, then presses Win + X, U, H: the Windows power menu's Hibernate. Windows only lists Hibernate when it's turned on in Power Options; without it, the power menu stays open on the PC. When the PC goes down within a minute, the stream ends with " is hibernating" instead of an error. Wake the PC from MoonCast's home. + ## 1.0-test237 (2026-10-21) - Handheld, "On battery, stream at" (Settings, Handheld) now works (#65). It was saved but nothing read it. Unplugged, PC and PS5 streams go no higher than the chosen height (1080p, 720p or 540p); plugged in, the Picture settings apply as before. - PC: the size keeps the stream's shape (Native 2448 × 1696 at 720p becomes 1040 × 720). The fold check compares the screen with the size before the cap, so a capped stream doesn't keep reconnecting at the screen's size. diff --git a/app/src/main/java/com/limelight/Game.java b/app/src/main/java/com/limelight/Game.java index cd0aee4793..93ec350c09 100644 --- a/app/src/main/java/com/limelight/Game.java +++ b/app/src/main/java/com/limelight/Game.java @@ -1602,7 +1602,9 @@ public void openQuickMenu() { if (quickMenu == null) quickMenu = new com.limelight.home.QuickMenu(Game.this); java.util.List items = new java.util.ArrayList<>(); items.add(new com.limelight.home.QuickMenu.Item("⌨", "Keyboard", "On-screen", false, this::toggleKeyboard).asToggle()); + items.add(new com.limelight.home.QuickMenu.Item("ⓘ", "Streaming status", prefConfig.enablePerfOverlay ? "On" : "Off", false, this::toggleStatus).asToggle()); items.add(new com.limelight.home.QuickMenu.Item("✚", "Controllers", com.limelight.home.QuickMenu.pads().size() + " connected", false, this::showControllers)); + items.add(new com.limelight.home.QuickMenu.Item("▤", "Task Manager", "Windows: Ctrl + Shift + Esc", false, this::openTaskManager)); // handheld only: send the game to the last MoonCast TV (it ends this stream, the game keeps running) com.limelight.home.QuickMenu.Item move = com.limelight.home.MoveToTv.item(Game.this, new com.limelight.home.MoveToTv.Target( "pc", appName != null ? appName : "Your game", getIntent().getStringExtra(EXTRA_PC_UUID), pcName, @@ -1610,6 +1612,7 @@ public void openQuickMenu() { if (move != null) items.add(move); items.add(new com.limelight.home.QuickMenu.Item("←", "Disconnect", "The game keeps running", false, this::finish)); items.add(new com.limelight.home.QuickMenu.Item("✕", "Quit game", null, true, this::quitOnHostAndFinish)); + items.add(new com.limelight.home.QuickMenu.Item("Zz", "Hibernate PC", "Windows: its power menu's Hibernate", true, this::confirmHibernate)); quickMenu.show(appName != null ? appName : "Your game", (pcName != null ? "On " + pcName + ". " : "") + com.limelight.home.QuickMenu.COMBO_LABEL + " opens this menu", () -> java.util.Arrays.asList( @@ -1622,6 +1625,75 @@ public void openQuickMenu() { }); } + // Quick menu, Streaming status: Moonlight's stats corner on or off right away, saved like Settings' "Stats while playing" + private void toggleStatus() { + prefConfig.enablePerfOverlay = !prefConfig.enablePerfOverlay; + android.preference.PreferenceManager.getDefaultSharedPreferences(this).edit() + .putBoolean("checkbox_enable_perf_overlay", prefConfig.enablePerfOverlay).apply(); + performanceOverlayView.setVisibility(prefConfig.enablePerfOverlay && !isHidingOverlays ? View.VISIBLE : View.GONE); + } + + // Quick menu, Task Manager: the keys Windows opens it with (stock Sunshine has no other way in) + private void openTaskManager() { + sendKeys(new short[] {VK_LCONTROL, VK_LSHIFT, VK_ESCAPE}); + } + + // Quick menu, Hibernate PC: asks first, with Cancel picked, since the game and the stream both stop + private void confirmHibernate() { + String pc = pcName != null ? pcName : "the PC"; + java.util.List items = new java.util.ArrayList<>(); + items.add(new com.limelight.home.QuickMenu.Item("↩", "Cancel", "Keep playing", false, () -> { })); + items.add(new com.limelight.home.QuickMenu.Item("Zz", "Hibernate", "Unsaved progress may be lost", true, this::hibernate)); + quickMenu.show("Hibernate " + pc + "?", + "MoonCast opens the Windows power menu (Win + X, U) and picks Hibernate (H). Windows only shows it when it's " + + "turned on in Power Options. Wake the PC again from MoonCast's home.", + java.util.Collections::emptyList, items); + } + + /** When Hibernate PC was sent: the PC going away soon after is the end the user asked for, not an error. */ + private volatile long hibernateAt; + + private void hibernate() { + hibernateAt = android.os.SystemClock.uptimeMillis(); + LimeLog.info("Quick menu: Hibernate PC"); + sendKeys(new short[] {VK_LWIN, VK_X}); + Handler h = new Handler(getMainLooper()); + h.postDelayed(() -> sendKeys(new short[] {VK_U}), 900); + h.postDelayed(() -> sendKeys(new short[] {VK_H}), 1800); + } + + // Windows virtual-key codes, with Moonlight's 0x80 prefix (KeyboardTranslator) + private static final short VK_LCONTROL = (short) 0x80A2, VK_LSHIFT = (short) 0x80A0, VK_LWIN = (short) 0x805B, + VK_ESCAPE = (short) 0x801B, VK_X = (short) 0x8058, VK_U = (short) 0x8055, VK_H = (short) 0x8048; + + /** Press [keys] in order, holding each, then let them go in reverse, like a person pressing a shortcut. */ + private void sendKeys(short[] keys) { + if (conn == null) return; + byte mod = 0; + for (short k : keys) { + conn.sendKeyboardInput(k, KeyboardPacket.KEY_DOWN, mod, (byte) 0); + mod |= keyModifier(k); + } + final byte held = mod; + new Handler(getMainLooper()).postDelayed(() -> { + if (conn == null) return; + byte m = held; + for (int i = keys.length - 1; i >= 0; i--) { + m &= (byte) ~keyModifier(keys[i]); + conn.sendKeyboardInput(keys[i], KeyboardPacket.KEY_UP, m, (byte) 0); + } + }, 100); + } + + private static byte keyModifier(short k) { + switch (k) { + case VK_LCONTROL: return KeyboardPacket.MODIFIER_CTRL; + case VK_LSHIFT: return KeyboardPacket.MODIFIER_SHIFT; + case VK_LWIN: return KeyboardPacket.MODIFIER_META; + default: return 0; + } + } + // Quick menu, Controllers: on a PC every pad is its own player (in the order they were first used). private void showControllers() { java.util.List items = new java.util.ArrayList<>(); @@ -2512,6 +2584,19 @@ public void run() { @Override public void connectionTerminated(final int errorCode) { PhoneGamepad.stopFor(conn); // the stream is over: stop pulling the sender's controller + if (hibernateAt != 0 && android.os.SystemClock.uptimeMillis() - hibernateAt < 60_000) { + // the PC went down after Hibernate PC: that's what the user asked for, so no error + LimeLog.info("Stream ended after Hibernate PC"); + endedByUser = true; + runOnUiThread(() -> { + if (displayedFailureDialog) return; + displayedFailureDialog = true; + stopConnection(); + android.widget.Toast.makeText(Game.this, (pcName != null ? pcName : "The PC") + " is hibernating", android.widget.Toast.LENGTH_LONG).show(); + finish(); + }); + return; + } // A stream the user ended (Quit game makes the PC close it, error -1) isn't a failure (#41) if (errorCode != MoonBridge.ML_ERROR_GRACEFUL_TERMINATION && !endedByUser && !isFinishing()) com.limelight.home.StreamReport.failed("The stream from the PC stopped (error " + errorCode + ")."); diff --git a/app/src/main/java/com/limelight/Ps5StreamActivity.kt b/app/src/main/java/com/limelight/Ps5StreamActivity.kt index fcb0a5273d..f65771129a 100644 --- a/app/src/main/java/com/limelight/Ps5StreamActivity.kt +++ b/app/src/main/java/com/limelight/Ps5StreamActivity.kt @@ -104,8 +104,12 @@ class Ps5StreamActivity : Activity(), SurfaceHolder.Callback, com.limelight.home addView(steps, android.widget.FrameLayout.LayoutParams(dp(1100), -2, android.view.Gravity.CENTER_HORIZONTAL or android.view.Gravity.BOTTOM).apply { bottomMargin = dp(220) }) }) sv.holder.addCallback(this) + corner.apply() } + // Streaming status: the stats line in the corner while playing (Settings' "Stats while playing", or the Quick menu) + private val corner by lazy { com.limelight.home.StatusCorner(this) { liveStats() } } + private var status: android.widget.TextView? = null private fun showStatus(t: String?) = runOnUiThread { status?.let { if (t == null) it.visibility = android.view.View.GONE else { it.text = t; it.visibility = android.view.View.VISIBLE } } @@ -831,6 +835,7 @@ class Ps5StreamActivity : Activity(), SurfaceHolder.Callback, com.limelight.home listOfNotNull( com.limelight.home.QuickMenu.Item("PS", "PS button", "L2 + Start", false) { tap(ControllerState.BUTTON_PS) }, com.limelight.home.QuickMenu.Item("▢", "Touchpad", "Select + Start", false) { tap(ControllerState.BUTTON_TOUCHPAD) }, + com.limelight.home.QuickMenu.Item("ⓘ", "Streaming status", if (com.limelight.home.StatusCorner.enabled(this)) "On" else "Off", false) { corner.toggle() }.asToggle(), com.limelight.home.QuickMenu.Item("✚", "Controllers", "${com.limelight.home.QuickMenu.pads().size} connected", false) { controllers() }, move, com.limelight.home.QuickMenu.Item("←", "Disconnect", disconnectHint(), false) { leave() }, @@ -996,6 +1001,7 @@ class Ps5StreamActivity : Activity(), SurfaceHolder.Callback, com.limelight.home } override fun onDestroy() { + corner.stop() if (!closed || session != null) teardown(null) super.onDestroy() } diff --git a/app/src/main/java/com/limelight/XboxStreamActivity.kt b/app/src/main/java/com/limelight/XboxStreamActivity.kt index a26c67f429..9454950bf7 100644 --- a/app/src/main/java/com/limelight/XboxStreamActivity.kt +++ b/app/src/main/java/com/limelight/XboxStreamActivity.kt @@ -93,6 +93,7 @@ class XboxStreamActivity : Activity(), com.limelight.home.MoveToTv.LocalSession addView(r, FrameLayout.LayoutParams(-1, -1, Gravity.CENTER)) addView(status, FrameLayout.LayoutParams(-1, -1)) }) + corner.apply() main.postDelayed(pictureWatchdog, 60_000) Thread { connect() }.start() } @@ -249,6 +250,7 @@ class XboxStreamActivity : Activity(), com.limelight.home.MoveToTv.LocalSession } override fun onDestroy() { + corner.stop() closed = true if (remoteOn) try { remotePad?.stop() } catch (e: Throwable) { } try { rtc?.stop() } catch (e: Throwable) { } @@ -457,6 +459,8 @@ class XboxStreamActivity : Activity(), com.limelight.home.MoveToTv.LocalSession // ---- Quick menu ---- private val menu by lazy { QuickMenu(this) } + // Streaming status: the stats line in the corner while playing (Settings' "Stats while playing", or the Quick menu) + private val corner by lazy { com.limelight.home.StatusCorner(this) { liveStats() } } // Live numbers from WebRTC's own stats, refreshed every second (and logged every 5 s for diagnosis) @Volatile private var last: XboxRtc.Stats? = null @@ -498,6 +502,7 @@ class XboxStreamActivity : Activity(), com.limelight.home.MoveToTv.LocalSession val move = com.limelight.home.MoveToTv.item(this, com.limelight.home.MoveToTv.Target("xb", consoleName, xboxId = consoleId)) menu.show(consoleName, "Xbox Remote Play. " + QuickMenu.COMBO_LABEL + " opens this menu", { liveStats() }, listOfNotNull( QuickMenu.Item("⊗", "Xbox button", "Opens the guide", false) { tapGuide() }, + QuickMenu.Item("ⓘ", "Streaming status", if (com.limelight.home.StatusCorner.enabled(this)) "On" else "Off", false) { corner.toggle() }.asToggle(), move, QuickMenu.Item("←", "Disconnect", "The game keeps running", false) { finish() }, QuickMenu.Item("⏻", "Turn off the Xbox", "Ends the game", true) { turnOff() })) diff --git a/app/src/main/java/com/limelight/home/StatusCorner.kt b/app/src/main/java/com/limelight/home/StatusCorner.kt new file mode 100644 index 0000000000..7d7a28074f --- /dev/null +++ b/app/src/main/java/com/limelight/home/StatusCorner.kt @@ -0,0 +1,84 @@ +package com.limelight.home + +import android.app.Activity +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.TypedValue +import android.view.Gravity +import android.widget.FrameLayout +import android.widget.TextView + +/** + * Streaming status for the PS5 and Xbox players: one line of live numbers in the top-left corner while playing + * (the PC player has Moonlight's own). It follows Settings' "Stats while playing", and the Quick menu flips it. + * [stats] gives the Quick menu's (value, label) tiles; the line shows them once the stream has its first numbers. + */ +class StatusCorner(private val act: Activity, private val stats: () -> List>) { + private val ui = Handler(Looper.getMainLooper()) + private var view: TextView? = null + private val tick = object : Runnable { + override fun run() { + val v = view ?: return + val line = line() + v.text = line + v.visibility = if (line.isEmpty()) android.view.View.GONE else android.view.View.VISIBLE + ui.postDelayed(this, 1000) + } + } + + /** Show or hide it as Settings say (call once the player's screen is up). */ + fun apply() = set(enabled(act)) + + /** The Quick menu's toggle: flips it now and saves the choice. */ + fun toggle() { + val on = !enabled(act) + android.preference.PreferenceManager.getDefaultSharedPreferences(act).edit().putBoolean(PREF, on).apply() + set(on) + } + + fun stop() { ui.removeCallbacks(tick) } + + private fun set(on: Boolean) { + if (on == (view != null)) return + if (!on) { + ui.removeCallbacks(tick) + (view?.parent as? FrameLayout)?.removeView(view) + view = null + return + } + val root = act.findViewById(android.R.id.content) ?: return + val d = act.resources.displayMetrics.density + view = TextView(act).apply { + setTextColor(0xFFE8ECFF.toInt()) + setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f) + setBackgroundColor(0x99000000.toInt()) + setPadding((10 * d).toInt(), (5 * d).toInt(), (10 * d).toInt(), (5 * d).toInt()) + visibility = android.view.View.GONE + } + root.addView(view, FrameLayout.LayoutParams(-2, -2, Gravity.TOP or Gravity.START).apply { + leftMargin = (12 * d).toInt(); topMargin = (12 * d).toInt() + }) + ui.post(tick) + } + + private fun line(): String { + val s = stats() + if (s.firstOrNull()?.second != "Frames per second") return "" // still connecting + return s.filter { it.first != "…" }.joinToString(" ") { (v, label) -> + when (label) { + "Frames per second" -> "$v fps" + "Network delay" -> "$v delay" + "Lost packets" -> "$v lost" + "Decode time" -> "$v decode" + else -> v + } + } + } + + companion object { + /** Moonlight's own "Stats while playing" key, so one switch covers PC, PS5 and Xbox. */ + const val PREF = "checkbox_enable_perf_overlay" + fun enabled(c: Context) = android.preference.PreferenceManager.getDefaultSharedPreferences(c).getBoolean(PREF, false) + } +} From 582d98dd4bf6b62af9d843adba735ff47a2dfe40 Mon Sep 17 00:00:00 2001 From: MoonCast Date: Wed, 21 Oct 2026 21:01:10 +0000 Subject: [PATCH 2/4] fix(quickmenu): Hibernate PC types shutdown /h into Run, layout-independent (test239) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ app/src/main/java/com/limelight/Game.java | 17 ++++++++++------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbaca06155..6b0506e388 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 1.0-test239 (2026-10-22) +- Hibernate PC didn't work on Ahmed's PC (Task Manager and Streaming status did). The power menu's U and H only match on an English layout and English Windows. It now opens Run (Win + R), types `shutdown /h` as text (Sunshine types text the same on any keyboard layout) and presses Enter. That also works without Hibernate ticked in Power Options; it needs hibernation itself on, which most PCs have (`powercfg /h on` turns it on). + ## 1.0-test238 (2026-10-22) - In-game Quick menu, new items: - **Streaming status** (PC, PS5 and Xbox): turns the stats corner on or off right away, and saves it as Settings' "Stats while playing". The PC shows Moonlight's stats; PS5 and Xbox get a new one-line corner (frame rate, network delay, bitrate, lost packets, and decode time on PS5). diff --git a/app/src/main/java/com/limelight/Game.java b/app/src/main/java/com/limelight/Game.java index 93ec350c09..bc0b06d4ad 100644 --- a/app/src/main/java/com/limelight/Game.java +++ b/app/src/main/java/com/limelight/Game.java @@ -1612,7 +1612,7 @@ public void openQuickMenu() { if (move != null) items.add(move); items.add(new com.limelight.home.QuickMenu.Item("←", "Disconnect", "The game keeps running", false, this::finish)); items.add(new com.limelight.home.QuickMenu.Item("✕", "Quit game", null, true, this::quitOnHostAndFinish)); - items.add(new com.limelight.home.QuickMenu.Item("Zz", "Hibernate PC", "Windows: its power menu's Hibernate", true, this::confirmHibernate)); + items.add(new com.limelight.home.QuickMenu.Item("Zz", "Hibernate PC", "Windows: runs shutdown /h", true, this::confirmHibernate)); quickMenu.show(appName != null ? appName : "Your game", (pcName != null ? "On " + pcName + ". " : "") + com.limelight.home.QuickMenu.COMBO_LABEL + " opens this menu", () -> java.util.Arrays.asList( @@ -1645,8 +1645,8 @@ private void confirmHibernate() { items.add(new com.limelight.home.QuickMenu.Item("↩", "Cancel", "Keep playing", false, () -> { })); items.add(new com.limelight.home.QuickMenu.Item("Zz", "Hibernate", "Unsaved progress may be lost", true, this::hibernate)); quickMenu.show("Hibernate " + pc + "?", - "MoonCast opens the Windows power menu (Win + X, U) and picks Hibernate (H). Windows only shows it when it's " + - "turned on in Power Options. Wake the PC again from MoonCast's home.", + "MoonCast opens Run on the PC (Win + R) and types shutdown /h, which works on Windows with hibernation " + + "on (most PCs). Wake the PC again from MoonCast's home.", java.util.Collections::emptyList, items); } @@ -1656,15 +1656,18 @@ private void confirmHibernate() { private void hibernate() { hibernateAt = android.os.SystemClock.uptimeMillis(); LimeLog.info("Quick menu: Hibernate PC"); - sendKeys(new short[] {VK_LWIN, VK_X}); + // Run, not the Win + X power menu: its U and H don't match on other keyboard layouts or Windows languages + // (Arabic, for one), and it only lists Hibernate when that's ticked. The command goes as text, which Sunshine + // types the same on any layout. + sendKeys(new short[] {VK_LWIN, VK_R}); Handler h = new Handler(getMainLooper()); - h.postDelayed(() -> sendKeys(new short[] {VK_U}), 900); - h.postDelayed(() -> sendKeys(new short[] {VK_H}), 1800); + h.postDelayed(() -> { if (conn != null) conn.sendUtf8Text("shutdown /h"); }, 1000); + h.postDelayed(() -> sendKeys(new short[] {VK_RETURN}), 1600); } // Windows virtual-key codes, with Moonlight's 0x80 prefix (KeyboardTranslator) private static final short VK_LCONTROL = (short) 0x80A2, VK_LSHIFT = (short) 0x80A0, VK_LWIN = (short) 0x805B, - VK_ESCAPE = (short) 0x801B, VK_X = (short) 0x8058, VK_U = (short) 0x8055, VK_H = (short) 0x8048; + VK_ESCAPE = (short) 0x801B, VK_R = (short) 0x8052, VK_RETURN = (short) 0x800D; /** Press [keys] in order, holding each, then let them go in reverse, like a person pressing a shortcut. */ private void sendKeys(short[] keys) { From 102349931df9087bf6fba9f83d06a223a967706c Mon Sep 17 00:00:00 2001 From: MoonCast Date: Wed, 21 Oct 2026 21:11:45 +0000 Subject: [PATCH 3/4] fix(quickmenu): Hibernate PC opens Start with Ctrl+Esc, no Windows key (test240) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ app/src/main/java/com/limelight/Game.java | 22 +++++++++++----------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b0506e388..6440cceba0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 1.0-test240 (2026-10-22) +- Hibernate PC still didn't work on Ahmed's PC: the Run box never opened, so Win + R didn't arrive (games often turn the Windows key off while they play). It now opens Start with Ctrl + Esc, which doesn't use the Windows key, types `shutdown /h` into Start's search and presses Enter 2 seconds later. + ## 1.0-test239 (2026-10-22) - Hibernate PC didn't work on Ahmed's PC (Task Manager and Streaming status did). The power menu's U and H only match on an English layout and English Windows. It now opens Run (Win + R), types `shutdown /h` as text (Sunshine types text the same on any keyboard layout) and presses Enter. That also works without Hibernate ticked in Power Options; it needs hibernation itself on, which most PCs have (`powercfg /h on` turns it on). diff --git a/app/src/main/java/com/limelight/Game.java b/app/src/main/java/com/limelight/Game.java index bc0b06d4ad..ff8948f42c 100644 --- a/app/src/main/java/com/limelight/Game.java +++ b/app/src/main/java/com/limelight/Game.java @@ -1645,8 +1645,8 @@ private void confirmHibernate() { items.add(new com.limelight.home.QuickMenu.Item("↩", "Cancel", "Keep playing", false, () -> { })); items.add(new com.limelight.home.QuickMenu.Item("Zz", "Hibernate", "Unsaved progress may be lost", true, this::hibernate)); quickMenu.show("Hibernate " + pc + "?", - "MoonCast opens Run on the PC (Win + R) and types shutdown /h, which works on Windows with hibernation " + - "on (most PCs). Wake the PC again from MoonCast's home.", + "MoonCast opens Start on the PC (Ctrl + Esc), types shutdown /h and presses Enter. It works on Windows " + + "with hibernation on (most PCs). Wake the PC again from MoonCast's home.", java.util.Collections::emptyList, items); } @@ -1656,18 +1656,19 @@ private void confirmHibernate() { private void hibernate() { hibernateAt = android.os.SystemClock.uptimeMillis(); LimeLog.info("Quick menu: Hibernate PC"); - // Run, not the Win + X power menu: its U and H don't match on other keyboard layouts or Windows languages - // (Arabic, for one), and it only lists Hibernate when that's ticked. The command goes as text, which Sunshine - // types the same on any layout. - sendKeys(new short[] {VK_LWIN, VK_R}); + // Start's search runs the command. Not Win + R: games turn the Windows key off while they play (test239), and + // Ctrl + Esc opens Start without it. Not the Win + X power menu either: its U and H don't match on other + // keyboard layouts or Windows languages (test238). The command goes as text, which Sunshine types the same + // on any layout; search needs a moment to find it before Enter. + sendKeys(new short[] {VK_LCONTROL, VK_ESCAPE}); Handler h = new Handler(getMainLooper()); - h.postDelayed(() -> { if (conn != null) conn.sendUtf8Text("shutdown /h"); }, 1000); - h.postDelayed(() -> sendKeys(new short[] {VK_RETURN}), 1600); + h.postDelayed(() -> { if (conn != null) conn.sendUtf8Text("shutdown /h"); }, 1500); + h.postDelayed(() -> sendKeys(new short[] {VK_RETURN}), 3500); } // Windows virtual-key codes, with Moonlight's 0x80 prefix (KeyboardTranslator) - private static final short VK_LCONTROL = (short) 0x80A2, VK_LSHIFT = (short) 0x80A0, VK_LWIN = (short) 0x805B, - VK_ESCAPE = (short) 0x801B, VK_R = (short) 0x8052, VK_RETURN = (short) 0x800D; + private static final short VK_LCONTROL = (short) 0x80A2, VK_LSHIFT = (short) 0x80A0, + VK_ESCAPE = (short) 0x801B, VK_RETURN = (short) 0x800D; /** Press [keys] in order, holding each, then let them go in reverse, like a person pressing a shortcut. */ private void sendKeys(short[] keys) { @@ -1692,7 +1693,6 @@ private static byte keyModifier(short k) { switch (k) { case VK_LCONTROL: return KeyboardPacket.MODIFIER_CTRL; case VK_LSHIFT: return KeyboardPacket.MODIFIER_SHIFT; - case VK_LWIN: return KeyboardPacket.MODIFIER_META; default: return 0; } } From 2e3980271b888b0a5f03d107c3989545956470dd Mon Sep 17 00:00:00 2001 From: MoonCast Date: Wed, 21 Oct 2026 21:24:01 +0000 Subject: [PATCH 4/4] fix(quickmenu): leave the stream once a hibernating PC stops sending video (test242) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++ app/src/main/java/com/limelight/Game.java | 50 ++++++++++++++++--- .../video/MediaCodecDecoderRenderer.java | 5 ++ 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6440cceba0..6067cc50f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 1.0-test242 (2026-10-22) +- Hibernate PC worked on Ahmed's PC (test240/241), but MoonCast stayed on the last picture, frozen: a hibernating PC just stops sending video, and Moonlight only notices after a long timeout. After Hibernate PC, MoonCast now watches the video: after 3 seconds without a new frame it checks whether the PC still answers (its http port), and if not it leaves the stream with " is hibernating". The game isn't quit; it stays as it was in the hibernated PC. + ## 1.0-test240 (2026-10-22) - Hibernate PC still didn't work on Ahmed's PC: the Run box never opened, so Win + R didn't arrive (games often turn the Windows key off while they play). It now opens Start with Ctrl + Esc, which doesn't use the Windows key, types `shutdown /h` into Start's search and presses Enter 2 seconds later. diff --git a/app/src/main/java/com/limelight/Game.java b/app/src/main/java/com/limelight/Game.java index ff8948f42c..a75c25f74e 100644 --- a/app/src/main/java/com/limelight/Game.java +++ b/app/src/main/java/com/limelight/Game.java @@ -1664,6 +1664,47 @@ private void hibernate() { Handler h = new Handler(getMainLooper()); h.postDelayed(() -> { if (conn != null) conn.sendUtf8Text("shutdown /h"); }, 1500); h.postDelayed(() -> sendKeys(new short[] {VK_RETURN}), 3500); + // A PC going into hibernation just stops sending video: Moonlight would only notice after a long timeout, + // with the last picture frozen (test241). Watch for it and end the stream ourselves. + h.postDelayed(() -> watchHibernate(60, -1, 0), 4500); + } + + /** Once a second for up to [checksLeft] s: after 3 s without a new frame, end the stream if the PC stopped answering. */ + private void watchHibernate(final int checksLeft, final int lastFrame, final int still) { + if (isFinishing() || decoderRenderer == null || displayedFailureDialog) return; + final int frame = decoderRenderer.getLastFrameNumber(); + final int quiet = frame == lastFrame ? still + 1 : 0; + if (quiet < 3) { + if (checksLeft > 0) new Handler(getMainLooper()).postDelayed(() -> watchHibernate(checksLeft - 1, frame, quiet), 1000); + return; + } + // no video for 3 s: a still screen does that too, so ask whether the PC is still there (its http port, 47989) + new Thread(() -> { + boolean up; + try (java.net.Socket sock = new java.net.Socket()) { + sock.connect(new java.net.InetSocketAddress(getIntent().getStringExtra(EXTRA_HOST), + getIntent().getIntExtra(EXTRA_PORT, NvHTTP.DEFAULT_HTTP_PORT)), 1500); + up = true; + } catch (Exception e) { + up = false; + } + final boolean answers = up; + runOnUiThread(() -> { + if (!answers) finishHibernated(); + else if (checksLeft > 0) new Handler(getMainLooper()).postDelayed(() -> watchHibernate(checksLeft - 1, frame, 0), 1000); + }); + }, "mc-hibernate-watch").start(); + } + + /** The PC went down after Hibernate PC: leave the stream without an error (and without quitting the game). */ + private void finishHibernated() { + if (displayedFailureDialog) return; + displayedFailureDialog = true; + endedByUser = true; + LimeLog.info("Stream ended after Hibernate PC"); + stopConnection(); + android.widget.Toast.makeText(this, (pcName != null ? pcName : "The PC") + " is hibernating", android.widget.Toast.LENGTH_LONG).show(); + finish(); } // Windows virtual-key codes, with Moonlight's 0x80 prefix (KeyboardTranslator) @@ -2589,15 +2630,8 @@ public void connectionTerminated(final int errorCode) { PhoneGamepad.stopFor(conn); // the stream is over: stop pulling the sender's controller if (hibernateAt != 0 && android.os.SystemClock.uptimeMillis() - hibernateAt < 60_000) { // the PC went down after Hibernate PC: that's what the user asked for, so no error - LimeLog.info("Stream ended after Hibernate PC"); endedByUser = true; - runOnUiThread(() -> { - if (displayedFailureDialog) return; - displayedFailureDialog = true; - stopConnection(); - android.widget.Toast.makeText(Game.this, (pcName != null ? pcName : "The PC") + " is hibernating", android.widget.Toast.LENGTH_LONG).show(); - finish(); - }); + runOnUiThread(this::finishHibernated); return; } // A stream the user ended (Quit game makes the PC close it, error -1) isn't a failure (#41) diff --git a/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java b/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java index 6400927809..42969f4e78 100644 --- a/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java +++ b/app/src/main/java/com/limelight/binding/video/MediaCodecDecoderRenderer.java @@ -412,6 +412,11 @@ public boolean isAv1Main10Supported() { return false; } + /** The number of the last frame that arrived from the PC (0 before the first): unchanged means no video coming in. */ + public int getLastFrameNumber() { + return lastFrameNumber; + } + public int getPreferredColorSpace() { // Default to Rec 709 which is probably better supported on modern devices. //