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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 "<PC> 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 "<PC> 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.
Expand Down
122 changes: 122 additions & 0 deletions app/src/main/java/com/limelight/Game.java
Original file line number Diff line number Diff line change
Expand Up @@ -1602,14 +1602,17 @@ public void openQuickMenu() {
if (quickMenu == null) quickMenu = new com.limelight.home.QuickMenu(Game.this);
java.util.List<com.limelight.home.QuickMenu.Item> 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,
getIntent().getIntExtra(EXTRA_APP_ID, 0), null, null, null, true, null));
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(
Expand All @@ -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<com.limelight.home.QuickMenu.Item> 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<com.limelight.home.QuickMenu.Item> items = new java.util.ArrayList<>();
Expand Down Expand Up @@ -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 + ").");
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/com/limelight/Ps5StreamActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 } }
Expand Down Expand Up @@ -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() },
Expand Down Expand Up @@ -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()
}
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/com/limelight/XboxStreamActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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) { }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() }))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
84 changes: 84 additions & 0 deletions app/src/main/java/com/limelight/home/StatusCorner.kt
Original file line number Diff line number Diff line change
@@ -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<Pair<String, String>>) {
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<FrameLayout>(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)
}
}
Loading