diff --git a/CHANGELOG.md b/CHANGELOG.md index 394a4252ec..6067cc50f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # 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. + +## 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). + - **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..a75c25f74e 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: 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( @@ -1622,6 +1625,119 @@ 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 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); + } + + /** 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"); + // 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"); }, 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) + 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) { + 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; + 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 +2628,12 @@ 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 + endedByUser = true; + runOnUiThread(this::finishHibernated); + 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/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. // 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) + } +}