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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ steps and the checksums for each version.

## [Unreleased]

### Fixed

- **The mouse works in the in-stream menu:** the overlay menu now shows its own cursor
driven by the captured mouse instead of releasing pointer capture, so a Bluetooth or
USB mouse can point at and click the menu's tiles. Releasing and re-acquiring capture
left some devices (Samsung's Android 10 stack in particular) with no mouse motion at
all. Samsung's captured-motion source (a trackball-classed variant of
`SOURCE_MOUSE_RELATIVE`) is recognised too, and the stream view keeps view focus while
captured so the menu's selection highlight can't cut off its events. A mouse
connecting while the cursor was freed deliberately no longer steals capture back.

## [0.2.1] - 2026-10-23

The repo changes that followed u/rambo3349's code review went live on GitHub before this release.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ private static class RecordingKeys implements OverlayMenuView.MenuActionListener
@Override public void onShowKeyboard() {}
@Override public void onSendGuideButton() {}
@Override public void onCustomCommand(CustomCommand command) {}
@Override public void onMenuOpened() {}
@Override public void onMenuClosed() {}
@Override public void onRotateScreen() {}
@Override public void onToggleOnscreenController() {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ private void flip(int toggle) {
@Override public void onShowKeyboard() { events.add("keyboard"); }
@Override public void onSendGuideButton() { events.add("guide"); }
@Override public void onCustomCommand(CustomCommand command) { events.add("custom"); }
@Override public void onMenuOpened() { events.add("opened"); }
@Override public void onMenuClosed() { events.add("closed"); }
@Override public void onSendKeys(short[] keys) { events.add("keys"); lastKeys = keys; }
@Override public void onRotateScreen() { events.add("rotate"); }
Expand Down
195 changes: 193 additions & 2 deletions app/src/main/java/com/limelight/Game.java
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.view.inputmethod.InputMethodManager;
import android.widget.SeekBar;
import android.widget.TextView;
Expand Down Expand Up @@ -191,6 +192,14 @@ public class Game extends Activity implements SurfaceHolder.Callback,
private TextView performanceOverlayView;
private BrightnessSliderView brightnessSliderView;
private OverlayMenuView overlayMenuView;
// While the overlay menu is up under pointer capture, a drawn cursor tracks the
// captured mouse's deltas and its button presses become touches on the sheet
private boolean menuSoftwareCursor;
private boolean menuCursorPressOutside;
private float menuCursorX;
private float menuCursorY;
private int menuCursorButtonState;
private ImageView menuCursorView;

// Floating menu button (from Artemide): tap opens the overlay menu, drag moves it, and
// FloatingButton remembers where it was dragged
Expand Down Expand Up @@ -459,6 +468,17 @@ public boolean onCapturedPointer(View view, MotionEvent motionEvent) {
return handleMotionEvent(view, motionEvent);
}
});

// Samsung's stack only feeds captured-pointer events to the view holding both
// capture and focus; anything else taking focus stops the events entirely. The
// menu's selection moves focus for its highlight, so put it back on the stream
// view whenever it strays while the menu is up (or is lost completely).
streamView.getViewTreeObserver().addOnGlobalFocusChangeListener((oldFocus, newFocus) -> {
if (streamView.hasPointerCapture() && newFocus != streamView &&
(menuSoftwareCursor || newFocus == null)) {
streamView.post(streamView::requestFocus);
}
});
}

// Warn the user if they're on a metered connection
Expand Down Expand Up @@ -857,6 +877,18 @@ static boolean opensMenuBeforeTouchHandling(boolean panZoomMode, int mouseMode,
actionMasked == MotionEvent.ACTION_POINTER_DOWN && pointerCount == 3;
}

// While the overlay menu is up and the pointer is captured, the menu drives a software
// cursor from the captured deltas instead of releasing capture: on Samsung's Android 10
// input stack, asking for capture back after a release leaves the window registered as
// the capture owner but no motion events ever reach it again.
// The system cursor serves the menu when nothing is captured: input ungrabbed, the
// cursor freed with Ctrl+Alt+Shift+C, or no compatible mouse to capture.
// Checked by GameRulesTest.
static boolean menuShouldUseSoftwareCursor(boolean grabbedInput, boolean cursorVisible,
boolean pointerCaptureHeld) {
return grabbedInput && !cursorVisible && pointerCaptureHeld;
}


@Override
public void onConfigurationChanged(Configuration newConfig) {
Expand Down Expand Up @@ -2209,6 +2241,39 @@ private boolean handleMotionEvent(View view, MotionEvent event) {
return false;
}

// While the overlay menu is up it gets the pointer and the touch, not the game.
// A touch off the sheet closes it, like a dialog's outside area.
if (overlayMenuView != null && overlayMenuView.getVisibility() == View.VISIBLE) {
int action = event.getActionMasked();
if (menuSoftwareCursor) {
if (inputCaptureProvider.eventHasRelativeMouseAxes(event)) {
return handleMenuCursorEvent(event);
}
if (!inputCaptureProvider.isCapturingActive() &&
event.getSource() != InputDevice.SOURCE_TOUCHSCREEN) {
// Capture was released underneath us (the cursor freed, or the mouse
// removed), so the real pointer serves the menu from here on
exitMenuCursor();
}
}
if (action == MotionEvent.ACTION_DOWN) {
// The gesture's other events were consumed here, so the touch contexts never
// saw its down; cancel them before they report a click that never happened
for (TouchContext aTouchContext : touchContextMap) {
aTouchContext.cancelTouch();
}
overlayMenuView.closeMenu();
}
else if (action == MotionEvent.ACTION_HOVER_ENTER ||
action == MotionEvent.ACTION_HOVER_MOVE ||
action == MotionEvent.ACTION_HOVER_EXIT) {
// Hover carries no input for the host; let it reach the view's default
// handling so the system can keep tracking the pointer.
return false;
}
return true;
}

int eventSource = event.getSource();
int deviceSources = event.getDevice() != null ? event.getDevice().getSources() : 0;
if ((eventSource & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) {
Expand All @@ -2221,7 +2286,8 @@ else if ((deviceSources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0 && controllerH
}
else if ((eventSource & InputDevice.SOURCE_CLASS_POINTER) != 0 ||
(eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0 ||
eventSource == InputDevice.SOURCE_MOUSE_RELATIVE)
eventSource == InputDevice.SOURCE_MOUSE_RELATIVE ||
inputCaptureProvider.eventHasRelativeMouseAxes(event))
{
// This case is for mice and non-finger touch devices
if (eventSource == InputDevice.SOURCE_MOUSE ||
Expand Down Expand Up @@ -3495,9 +3561,14 @@ public void onCustomCommand(CustomCommand command) {
}
}

@Override
public void onMenuOpened() {
enterMenuCursor();
}

@Override
public void onMenuClosed() {
// Menu closed, no additional action needed
exitMenuCursor();
}

@Override
Expand Down Expand Up @@ -4140,6 +4211,126 @@ private void toggleOverlayMenu() {
}
}

// The mouse keeps its pointer capture while the overlay menu is up: releasing it and
// asking for it back breaks the input pipeline on Samsung's Android 10 stack. Instead
// the menu shows a software cursor that the captured deltas move, and turns button
// presses into touches where it points.
private void enterMenuCursor() {
boolean takeOver = menuShouldUseSoftwareCursor(grabbedInput, cursorVisible,
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && streamView.hasPointerCapture());
if (!takeOver) {
return;
}
if (menuCursorView == null) {
FrameLayout container = (FrameLayout) streamView.getParent();
menuCursorView = new ImageView(this);
menuCursorView.setImageResource(R.drawable.ic_menu_cursor);
// The sheet draws at 12dp elevation; without its own elevation the cursor
// renders underneath it
menuCursorView.setElevation(10000 * getResources().getDisplayMetrics().density);
menuCursorView.setVisibility(View.GONE);
int cursorSize = (int) (24 * getResources().getDisplayMetrics().density);
container.addView(menuCursorView, new FrameLayout.LayoutParams(cursorSize, cursorSize));
}
menuCursorButtonState = 0;
menuSoftwareCursor = true;
if (!streamView.hasFocus()) {
streamView.requestFocus();
}
// The sheet may not have been laid out yet, so place the cursor at its centre
// once positions are known rather than guessing
menuCursorView.post(() -> {
int[] sheetPos = new int[2];
int[] containerPos = new int[2];
overlayMenuView.getLocationOnScreen(sheetPos);
((View) menuCursorView.getParent()).getLocationOnScreen(containerPos);
menuCursorX = sheetPos[0] - containerPos[0] + overlayMenuView.getWidth() / 2f;
menuCursorY = sheetPos[1] - containerPos[1] + overlayMenuView.getHeight() / 2f;
moveMenuCursorView();
menuCursorView.setVisibility(View.VISIBLE);
});
}

private void exitMenuCursor() {
if (menuSoftwareCursor) {
menuSoftwareCursor = false;
menuCursorView.setVisibility(View.GONE);
}
}

// The captured mouse's deltas move the software cursor; its button presses become
// touches where the cursor points, so the sheet clicks like under a real pointer.
private boolean handleMenuCursorEvent(MotionEvent event) {
menuCursorX += inputCaptureProvider.getRelativeAxisX(event);
menuCursorY += inputCaptureProvider.getRelativeAxisY(event);
moveMenuCursorView();

if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) {
// Point the wheel event at the cursor so the sheet's scroll views take it
MotionEvent scroll = MotionEvent.obtain(event);
scroll.offsetLocation(menuCursorX - event.getX(), menuCursorY - event.getY());
scroll.setSource(InputDevice.SOURCE_MOUSE);
((View) menuCursorView.getParent()).dispatchGenericMotionEvent(scroll);
scroll.recycle();
return true;
}

int buttons = event.getButtonState();
int changed = buttons ^ menuCursorButtonState;
menuCursorButtonState = buttons;
lastButtonState = buttons;
if ((changed & MotionEvent.BUTTON_PRIMARY) != 0) {
boolean pressed = (buttons & MotionEvent.BUTTON_PRIMARY) != 0;
if (pressed) {
menuCursorPressOutside = !isMenuCursorOverSheet();
if (menuCursorPressOutside) {
// Like a dialog, a press off the sheet dismisses it
overlayMenuView.closeMenu();
return true;
}
}
else if (menuCursorPressOutside) {
menuCursorPressOutside = false;
return true;
}
dispatchMenuTouch(pressed ? MotionEvent.ACTION_DOWN : MotionEvent.ACTION_UP);
}
else if ((buttons & MotionEvent.BUTTON_PRIMARY) != 0) {
// The button is held, so the sheet's scroll area follows the drag
dispatchMenuTouch(MotionEvent.ACTION_MOVE);
}
return true;
}

private boolean isMenuCursorOverSheet() {
int[] sheetPos = new int[2];
int[] containerPos = new int[2];
overlayMenuView.getLocationOnScreen(sheetPos);
((View) menuCursorView.getParent()).getLocationOnScreen(containerPos);
float sheetX = sheetPos[0] - containerPos[0];
float sheetY = sheetPos[1] - containerPos[1];
return menuCursorX >= sheetX && menuCursorX <= sheetX + overlayMenuView.getWidth() &&
menuCursorY >= sheetY && menuCursorY <= sheetY + overlayMenuView.getHeight();
}

private void moveMenuCursorView() {
View container = (View) menuCursorView.getParent();
menuCursorX = Math.max(0, Math.min(menuCursorX, container.getWidth()));
menuCursorY = Math.max(0, Math.min(menuCursorY, container.getHeight()));
// The triangle sprite points right, so its tip sits near the right edge
menuCursorView.setTranslationX(menuCursorX - menuCursorView.getWidth() * 0.78f);
menuCursorView.setTranslationY(menuCursorY - menuCursorView.getHeight() * 0.38f);
}

private void dispatchMenuTouch(int action) {
FrameLayout container = (FrameLayout) menuCursorView.getParent();
long now = SystemClock.uptimeMillis();
MotionEvent touch = MotionEvent.obtain(now, now, action, menuCursorX, menuCursorY, 0);
touch.setSource(InputDevice.SOURCE_TOUCHSCREEN);
container.dispatchTouchEvent(touch);
touch.recycle();
}

private void setFloatingButtonShown(boolean shown) {
floatingButtonShown = shown;
floatingMenuButton.setVisibility(shown && !isHidingOverlays ? View.VISIBLE : View.GONE);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import android.view.View;



// We extend AndroidPointerIconCaptureProvider because we want to also get the
// pointer icon hiding behavior over our stream view just in case pointer capture
// is unavailable on this system (ex: DeX, ChromeOS)
Expand Down Expand Up @@ -102,26 +103,37 @@ public void onWindowFocusChanged(boolean focusActive) {
h.postDelayed(new Runnable() {
@Override
public void run() {
if (hasCaptureCompatibleInputDevice()) {
// The cursor may have been freed while this was queued (the overlay menu,
// or the show-cursor shortcut); a stale request must not grab it back.
if (isCapturing && !isCursorVisible && hasCaptureCompatibleInputDevice()) {
targetView.requestPointerCapture();
}
}
}, 500);
}

// A captured mouse shows up with SOURCE_MOUSE_RELATIVE's device bits on top of a
// class byte; AOSP pairs them with SOURCE_CLASS_POINTER while Samsung's stack pairs
// them with SOURCE_CLASS_TRACKBALL (0x20004). Match the device bits either way.
// Checked by AndroidNativePointerCaptureProviderTest.
static boolean isRelativeMouseSource(int eventSource) {
return (eventSource & ~InputDevice.SOURCE_CLASS_MASK) ==
(InputDevice.SOURCE_MOUSE_RELATIVE & ~InputDevice.SOURCE_CLASS_MASK);
}

@Override
public boolean eventHasRelativeMouseAxes(MotionEvent event) {
// SOURCE_MOUSE_RELATIVE is how SOURCE_MOUSE appears when our view has pointer capture.
// SOURCE_TOUCHPAD will have relative axes populated iff our view has pointer capture.
// See https://developer.android.com/reference/android/view/View#requestPointerCapture()
int eventSource = event.getSource();
return (eventSource == InputDevice.SOURCE_MOUSE_RELATIVE && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) ||
return (isRelativeMouseSource(eventSource) && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) ||
(eventSource == InputDevice.SOURCE_TOUCHPAD && targetView.hasPointerCapture());
}

@Override
public float getRelativeAxisX(MotionEvent event) {
int axis = (event.getSource() == InputDevice.SOURCE_MOUSE_RELATIVE) ?
int axis = isRelativeMouseSource(event.getSource()) ?
MotionEvent.AXIS_X : MotionEvent.AXIS_RELATIVE_X;
float x = event.getAxisValue(axis);
for (int i = 0; i < event.getHistorySize(); i++) {
Expand All @@ -132,7 +144,7 @@ public float getRelativeAxisX(MotionEvent event) {

@Override
public float getRelativeAxisY(MotionEvent event) {
int axis = (event.getSource() == InputDevice.SOURCE_MOUSE_RELATIVE) ?
int axis = isRelativeMouseSource(event.getSource()) ?
MotionEvent.AXIS_Y : MotionEvent.AXIS_RELATIVE_Y;
float y = event.getAxisValue(axis);
for (int i = 0; i < event.getHistorySize(); i++) {
Expand All @@ -143,8 +155,9 @@ public float getRelativeAxisY(MotionEvent event) {

@Override
public void onInputDeviceAdded(int deviceId) {
// Check if we've added a capture-compatible device
if (!targetView.hasPointerCapture() && hasCaptureCompatibleInputDevice()) {
// Check if we've added a capture-compatible device. It isn't captured if the cursor
// was freed on purpose, like while the overlay menu is up or the cursor was shown.
if (!targetView.hasPointerCapture() && !isCursorVisible && hasCaptureCompatibleInputDevice()) {
targetView.requestPointerCapture();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ private void init(Context context) {
setMinimumHeight(iconSize + 2 * padding);
setClickable(true);
setFocusable(true);
// Keys still focus tiles for gamepad nav, but a touch may not grab focus or
// the captured pointer stream stops reaching the stream view
setFocusableInTouchMode(false);

// Create background drawable
background = new GradientDrawable();
Expand Down
Loading