diff --git a/app/src/main/java/com/limelight/Game.java b/app/src/main/java/com/limelight/Game.java index 992fc00a71..7078efbab6 100755 --- a/app/src/main/java/com/limelight/Game.java +++ b/app/src/main/java/com/limelight/Game.java @@ -4183,7 +4183,9 @@ private void applyMouseMode(int mode) { } else if (mode == 3) { touchContextMap[i] = new RelativeTouchContext(conn, i, REFERENCE_HORIZ_RES, REFERENCE_VERT_RES, streamContainer, prefConfig); } else { - touchContextMap[i] = new TrackpadContext(conn, i); + // Mode 2: Natural trackpad — pass sensitivity from Virtual Trackpad Settings + touchContextMap[i] = new TrackpadContext(conn, i, false, + prefConfig.touchPadSensitivity, prefConfig.touchPadYSensitity); } } @@ -4209,6 +4211,23 @@ public void toggleHUD() { //切换触控灵敏度开关 public void switchTouchSensitivity(){ prefConfig.enableTouchSensitivity = !prefConfig.enableTouchSensitivity; + String status = prefConfig.enableTouchSensitivity ? "ON" : "OFF"; + Toast.makeText(this, "Touch Sensitivity: " + status, Toast.LENGTH_SHORT).show(); + } + + /** + * Inject a keyboard key event with proper modifier tracking. + * Used by ControllerHandler for X→Ctrl / Y→Esc remapping + * to match the virtual keyboard's modifier handling. + */ + public void injectKey(short keyCode, byte modifierMask, boolean down) { + if (down) { + modifierFlags |= modifierMask; + } else { + modifierFlags &= ~modifierMask; + } + byte direction = down ? KeyboardPacket.KEY_DOWN : KeyboardPacket.KEY_UP; + conn.sendKeyboardInput(keyCode, direction, getModifierState(), (byte)0); } public void disconnect() { diff --git a/app/src/main/java/com/limelight/GameMenu.java b/app/src/main/java/com/limelight/GameMenu.java index 63f3532206..590125d524 100755 --- a/app/src/main/java/com/limelight/GameMenu.java +++ b/app/src/main/java/com/limelight/GameMenu.java @@ -263,7 +263,7 @@ private void showAdvancedMenu(GameInputDevice device) { showSpecialKeysMenu(); })); - options.add(new MenuOption(getString(R.string.game_menu_switch_touch_sensitivity_model), true, game::switchTouchSensitivity)); + options.add(new MenuOption(getString(R.string.game_menu_switch_touch_sensitivity_model), game::switchTouchSensitivity)); if (device != null) { options.addAll(device.getGameMenuOptions()); } diff --git a/app/src/main/java/com/limelight/binding/input/ControllerHandler.java b/app/src/main/java/com/limelight/binding/input/ControllerHandler.java index 7a7ea3146b..cde9786d16 100755 --- a/app/src/main/java/com/limelight/binding/input/ControllerHandler.java +++ b/app/src/main/java/com/limelight/binding/input/ControllerHandler.java @@ -33,6 +33,7 @@ import android.view.Surface; import android.widget.Toast; +import com.limelight.Game; import com.limelight.GameMenu; import com.limelight.LimeLog; import com.limelight.R; @@ -41,6 +42,7 @@ import com.limelight.binding.input.driver.UsbDriverService; import com.limelight.nvstream.NvConnection; import com.limelight.nvstream.input.ControllerPacket; +import com.limelight.nvstream.input.KeyboardPacket; import com.limelight.nvstream.input.MouseButtonPacket; import com.limelight.nvstream.jni.MoonBridge; import com.limelight.preferences.PreferenceConfiguration; @@ -1344,10 +1346,36 @@ private void sendControllerInputPacket(GenericControllerContext originalContext) } } + // Remap X→Ctrl / Y→Esc in mouse emulation mode. + // Route through Game.injectKey() for proper modifier state tracking + // (matches the virtual keyboard's modifier handling exactly). + if (prefConfig.remapXToCtrl) { + boolean xPressed = (inputMap & ControllerPacket.X_FLAG) != 0; + if (xPressed && !remapXActive) { + Game.instance.injectKey((short)0xA2, KeyboardPacket.MODIFIER_CTRL, true); + remapXActive = true; + } else if (!xPressed && remapXActive) { + Game.instance.injectKey((short)0xA2, KeyboardPacket.MODIFIER_CTRL, false); + remapXActive = false; + } + } + if (prefConfig.remapYToEsc) { + boolean yPressed = (inputMap & ControllerPacket.Y_FLAG) != 0; + if (yPressed && !remapYActive) { + Game.instance.injectKey((short)27, (byte)0, true); + remapYActive = true; + } else if (!yPressed && remapYActive) { + Game.instance.injectKey((short)27, (byte)0, false); + remapYActive = false; + } + } + conn.sendControllerInput(controllerNumber, getActiveControllerMask(), (short)0, (byte)0, (byte)0, (short)0, (short)0, (short)0, (short)0); } else { + // X→Ctrl / Y→Esc only active in mouse emulation mode; + // in normal gamepad mode X/Y keep their original function. conn.sendControllerInput(controllerNumber, getActiveControllerMask(), inputMap, leftTrigger, rightTrigger, @@ -1957,36 +1985,75 @@ private Vector2d convertRawStickAxisToPixelMovement(short stickX, short stickY) Vector2d vector = new Vector2d(); vector.initialize(stickX, stickY); vector.scalarMultiply(1 / 32766.0f); - vector.scalarMultiply(4); if (vector.getMagnitude() > 0) { - // Move faster as the stick is pressed further from center - vector.scalarMultiply(Math.pow(vector.getMagnitude(), 2)); + double magnitude = vector.getMagnitude(); + // sqrt curve for fine control at small deflections + // Full deflection: 12 pixels per frame (at 60Hz = 720 px/s) + double speedMultiplier = 12.0 * Math.sqrt(magnitude); + // Apply stick mouse sensitivity (default 100 = 1.0x) + speedMultiplier *= (prefConfig.stickMouseSensitivity / 100.0); + vector.scalarMultiply(speedMultiplier); } return vector; } - private void sendEmulatedMouseMove(short x, short y, boolean mouseEmulationXDown, int mouseEmulationPixelMultiplier) { - Vector2d vector = convertRawStickAxisToPixelMovement(x, y); - if (vector.getMagnitude() >= 1) { + // Sub-pixel accumulation for smooth fractional deltas (per-stick: left & right) + private final double[] emulatedMousePendingX = new double[2]; + private final double[] emulatedMousePendingY = new double[2]; - // Used a fixed amount of mouse movement while the X button is pressed - if(mouseEmulationXDown == true ) - { - // convert the vector number to -1 if negative and +1 if positive and then send the mouse movement in pixels - conn.sendMouseMove((short)(Integer.signum((int)vector.getX()) * mouseEmulationPixelMultiplier) , (short)(Integer.signum((int)-vector.getY()) * mouseEmulationPixelMultiplier) ); - } - else { - // If X button is not pressed, base the movement on how much the stick is moved from the center - conn.sendMouseMove((short) vector.getX(), (short) -vector.getY()); + private void sendEmulatedMouseMove(short x, short y, int stickIndex, + boolean xDown, int pixelMultiplier) { + Vector2d vector = convertRawStickAxisToPixelMovement(x, y); + if (vector.getMagnitude() >= 0.15) { + if (xDown) { + // X-button precision mode: fixed-step movement in stick direction + conn.sendMouseMove( + (short)(Integer.signum((int)vector.getX()) * pixelMultiplier), + (short)(Integer.signum((int)-vector.getY()) * pixelMultiplier)); + } else { + // Accumulate sub-pixel deltas for smooth cursor movement + emulatedMousePendingX[stickIndex] += vector.getX(); + emulatedMousePendingY[stickIndex] += -vector.getY(); + short moveX = (short) emulatedMousePendingX[stickIndex]; + short moveY = (short) emulatedMousePendingY[stickIndex]; + if (moveX != 0 || moveY != 0) { + conn.sendMouseMove(moveX, moveY); + emulatedMousePendingX[stickIndex] -= moveX; + emulatedMousePendingY[stickIndex] -= moveY; + } } + } else { + // Stick at center: clear pending deltas to prevent cursor flickering + emulatedMousePendingX[stickIndex] = 0; + emulatedMousePendingY[stickIndex] = 0; } } + private double emulatedScrollPendingY = 0; + private double emulatedScrollPendingX = 0; + + // State machine for X→Ctrl / Y→Esc remapping + private boolean remapXActive = false; + private boolean remapYActive = false; + private void sendEmulatedMouseScroll(short x, short y) { Vector2d vector = convertRawStickAxisToPixelMovement(x, y); - if (vector.getMagnitude() >= 1) { - conn.sendMouseHighResScroll((short)vector.getY()); - conn.sendMouseHighResHScroll((short)vector.getX()); + if (vector.getMagnitude() >= 0.15) { + emulatedScrollPendingY += vector.getY(); + emulatedScrollPendingX += vector.getX(); + short scrollY = (short) emulatedScrollPendingY; + short scrollX = (short) emulatedScrollPendingX; + if (scrollY != 0) { + conn.sendMouseHighResScroll(scrollY); + emulatedScrollPendingY -= scrollY; + } + if (scrollX != 0) { + conn.sendMouseHighResHScroll(scrollX); + emulatedScrollPendingX -= scrollX; + } + } else { + emulatedScrollPendingY = 0; + emulatedScrollPendingX = 0; } } @@ -3056,7 +3123,9 @@ class GenericControllerContext implements GameInputDevice{ public int mouseEmulationPixelMultiplier = 1; public int mouseEmulationLastInputMap; - public final int mouseEmulationReportPeriod = 50; + public int inputMapLastSent; + // Poll at ~60Hz for smooth cursor movement (was 50ms = 20Hz) + public final int mouseEmulationReportPeriod = 16; public final Runnable mouseEmulationRunnable = new Runnable() { @Override @@ -3067,18 +3136,16 @@ public void run() { // Send mouse events from analog sticks if (prefConfig.analogStickForScrolling == PreferenceConfiguration.AnalogStickForScrolling.RIGHT) { - - // Changed absolute value - sendEmulatedMouseMove(leftStickX, leftStickY, mouseEmulationXDown, mouseEmulationPixelMultiplier); + sendEmulatedMouseMove(leftStickX, leftStickY, 0, mouseEmulationXDown, mouseEmulationPixelMultiplier); sendEmulatedMouseScroll(rightStickX, rightStickY); } else if (prefConfig.analogStickForScrolling == PreferenceConfiguration.AnalogStickForScrolling.LEFT) { - sendEmulatedMouseMove(rightStickX, rightStickY, mouseEmulationXDown, mouseEmulationPixelMultiplier); + sendEmulatedMouseMove(rightStickX, rightStickY, 0, mouseEmulationXDown, mouseEmulationPixelMultiplier); sendEmulatedMouseScroll(leftStickX, leftStickY); } else { - sendEmulatedMouseMove(leftStickX, leftStickY, mouseEmulationXDown, mouseEmulationPixelMultiplier); - sendEmulatedMouseMove(rightStickX, rightStickY, mouseEmulationXDown, mouseEmulationPixelMultiplier); + sendEmulatedMouseMove(leftStickX, leftStickY, 0, mouseEmulationXDown, mouseEmulationPixelMultiplier); + sendEmulatedMouseMove(rightStickX, rightStickY, 1, mouseEmulationXDown, mouseEmulationPixelMultiplier); } // Requeue the callback diff --git a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardLayoutController.java b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardLayoutController.java index e37527a05a..b3afd87ba5 100755 --- a/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardLayoutController.java +++ b/app/src/main/java/com/limelight/binding/input/virtual_controller/keyboard/KeyBoardLayoutController.java @@ -331,7 +331,7 @@ public void refreshLayout() { } else { DisplayMetrics screen = context.getResources().getDisplayMetrics(); width = screen.widthPixels; - height = (int) (screen.heightPixels * 0.5); + height = (int) (screen.heightPixels * (prefConfig.keyboardHeightPercent / 100.0)); } FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(width, height); params.gravity = Gravity.BOTTOM; diff --git a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java index 96249d87ac..be1bdc05e8 100755 --- a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java +++ b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java @@ -319,6 +319,9 @@ public enum AnalogStickForScrolling { public int touchPadYSensitity; + //摇杆鼠标灵敏度 + public int stickMouseSensitivity; + //多点触控模式 public boolean enableMultiTouchScreen; @@ -354,6 +357,9 @@ public enum AnalogStickForScrolling { public boolean bindAllUsb; public boolean mouseEmulation; + public boolean remapXToCtrl; + public boolean remapYToEsc; + public int keyboardHeightPercent; public AnalogStickForScrolling analogStickForScrolling; public boolean mouseNavButtons; public boolean rememberMouseMode; @@ -926,6 +932,9 @@ else if (audioConfig.equals("51")) { config.enablePerfOverlayBottom = prefs.getBoolean("checkbox_enable_perf_overlay_bottom",DEFAULT_PERF_OVERLAY_BOTTOM); config.bindAllUsb = prefs.getBoolean(BIND_ALL_USB_STRING, DEFAULT_BIND_ALL_USB); config.mouseEmulation = prefs.getBoolean(MOUSE_EMULATION_STRING, DEFAULT_MOUSE_EMULATION); + config.remapXToCtrl = prefs.getBoolean("checkbox_remap_x_to_ctrl", false); + config.remapYToEsc = prefs.getBoolean("checkbox_remap_y_to_esc", false); + config.keyboardHeightPercent = prefs.getInt("seekbar_keyboard_height_percent", 80); config.mouseNavButtons = prefs.getBoolean(MOUSE_NAV_BUTTONS_STRING, DEFAULT_MOUSE_NAV_BUTTONS); config.rememberMouseMode = prefs.getBoolean(REMEMBER_MOUSE_MODE_PREF_STRING, DEFAULT_REMEMBER_MOUSE_MODE); config.unlockFps = prefs.getBoolean(UNLOCK_FPS_STRING, DEFAULT_UNLOCK_FPS); @@ -996,6 +1005,8 @@ else if (audioConfig.equals("51")) { config.touchPadYSensitity=prefs.getInt("seekbar_touchpad_sensitivity_y_opacity",100); + config.stickMouseSensitivity = prefs.getInt("seekbar_stick_mouse_sensitivity", 100); + config.trackpadSensitivityX = prefs.getInt(SEEKBAR_TRACKPAD_SENSITIVITY_X, DEFAULT_TRACKPAD_SENSITIVITY_X); config.trackpadSensitivityY = prefs.getInt(SEEKBAR_TRACKPAD_SENSITIVITY_Y, DEFAULT_TRACKPAD_SENSITIVITY_Y); config.trackpadDragDropVibration = prefs.getBoolean(CHECKBOX_TRACKPAD_DRAG_DROP_VIBRATION, DEFAULT_TRACKPAD_DRAG_DROP_VIBRATION); diff --git a/app/src/main/java/com/limelight/utils/ExternalDisplayControlActivity.java b/app/src/main/java/com/limelight/utils/ExternalDisplayControlActivity.java index 5bbb0a30b0..9c4421e889 100644 --- a/app/src/main/java/com/limelight/utils/ExternalDisplayControlActivity.java +++ b/app/src/main/java/com/limelight/utils/ExternalDisplayControlActivity.java @@ -13,6 +13,7 @@ import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; +import android.hardware.display.DisplayManager; import android.content.res.Configuration; import android.graphics.Color; import android.os.Build; @@ -57,6 +58,7 @@ public class ExternalDisplayControlActivity extends AppCompatActivity implements View.OnKeyListener, KeyBoardLayoutController.ViewCallbacks { public static String EXTRA_LAUNCH_INTENT = "launchIntent"; + public static String EXTRA_LAUNCH_DISPLAY_ID = "launchDisplayId"; @SuppressLint("StaticFieldLeak") public static ExternalDisplayControlActivity instance; @@ -74,6 +76,7 @@ public class ExternalDisplayControlActivity extends AppCompatActivity implements private Runnable dimScreenRunnable; private float originalBrightness = -1f; // -1 = use system default private static final int INACTIVITY_TIMEOUT_MS = 10_000; + private static final String PREF_NOTIFICATION_REQUESTED = "notification_permission_requested"; private static final String NOTIFICATION_CHANNEL_ID = "secondary_screen_active_channel_id"; @@ -122,22 +125,44 @@ protected void onCreate(Bundle savedInstanceState) { if (gameIntent == null) { finish(); } else { - Display secondaryDisplay = getSecondaryDisplay(this); - if (secondaryDisplay != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // For dual internal screen devices, Game's EXTRA_DISPLAY_ID is already set + // to the larger display; honor it instead of using getSecondaryDisplay() + int launchDisplayId = getIntent().getIntExtra(EXTRA_LAUNCH_DISPLAY_ID, -1); + boolean isDualInternal = launchDisplayId != -1; + + if (isDualInternal && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Dual internal screen: launch Game on the display specified in EXTRA_DISPLAY_ID + int gameDisplayId = gameIntent.getIntExtra(Game.EXTRA_DISPLAY_ID, -1); + if (gameDisplayId == -1) gameDisplayId = Display.DEFAULT_DISPLAY; ActivityOptions options = ActivityOptions.makeBasic(); - options.setLaunchDisplayId(secondaryDisplay.getDisplayId()); + options.setLaunchDisplayId(gameDisplayId); + DisplayManager dm = (DisplayManager) getSystemService(Context.DISPLAY_SERVICE); + Display targetDisplay = dm.getDisplay(gameDisplayId); Toast.makeText(this, getString(R.string.external_display_info, - secondaryDisplay.getMode().getPhysicalWidth(), - secondaryDisplay.getMode().getPhysicalHeight(), - secondaryDisplay.getMode().getRefreshRate()), + targetDisplay != null ? targetDisplay.getMode().getPhysicalWidth() : 0, + targetDisplay != null ? targetDisplay.getMode().getPhysicalHeight() : 0, + targetDisplay != null ? targetDisplay.getMode().getRefreshRate() : 0), Toast.LENGTH_LONG).show(); - startActivity(gameIntent, options.toBundle()); } else { - LimeLog.warning(getString(R.string.no_external_display)); - startActivity(gameIntent); - finish(); + Display secondaryDisplay = getSecondaryDisplay(this); + if (secondaryDisplay != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + ActivityOptions options = ActivityOptions.makeBasic(); + options.setLaunchDisplayId(secondaryDisplay.getDisplayId()); + Toast.makeText(this, + getString(R.string.external_display_info, + secondaryDisplay.getMode().getPhysicalWidth(), + secondaryDisplay.getMode().getPhysicalHeight(), + secondaryDisplay.getMode().getRefreshRate()), + Toast.LENGTH_LONG).show(); + + startActivity(gameIntent, options.toBundle()); + } else { + LimeLog.warning(getString(R.string.no_external_display)); + startActivity(gameIntent); + finish(); + } } } } @@ -179,7 +204,6 @@ private void initViews() { createProgrammaticUI(); checkNotificationPermission(); initTouchEventHandling(); - setupInactivityTimeoutForBrightness(); requestFocusToGameActivity(false); } @@ -263,9 +287,7 @@ private void restoreBrightnessIfNeeded() { } private void handleUserActivity() { - // Restore brightness if dimmed - restoreBrightnessIfNeeded(); - resetInactivityTimer(); + // Screen dimming disabled - keep screen always bright } private void resetInactivityTimer() { @@ -516,7 +538,13 @@ private ImageButton createImageButton(int imageResourceId, View.OnClickListener private void checkNotificationPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.POST_NOTIFICATIONS}, PERMISSION_REQUEST_CODE); + android.content.SharedPreferences prefs = getPreferences(MODE_PRIVATE); + boolean alreadyRequested = prefs.getBoolean(PREF_NOTIFICATION_REQUESTED, false); + // Only ask once ever. After the first request (granted or denied), never ask again. + if (!alreadyRequested) { + prefs.edit().putBoolean(PREF_NOTIFICATION_REQUESTED, true).apply(); + ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.POST_NOTIFICATIONS}, PERMISSION_REQUEST_CODE); + } return; } } diff --git a/app/src/main/java/com/limelight/utils/ServerHelper.java b/app/src/main/java/com/limelight/utils/ServerHelper.java index 2f474fb7ee..9b037f3a91 100755 --- a/app/src/main/java/com/limelight/utils/ServerHelper.java +++ b/app/src/main/java/com/limelight/utils/ServerHelper.java @@ -61,33 +61,196 @@ public static Intent createAppShortcutIntent(Activity parent, ComputerDetails co i.setAction(Intent.ACTION_DEFAULT); return i; } + /** + * Check if a display's name suggests it's an externally connected display + * (HDMI, DP, AR glasses, USB monitor, etc.). + */ + private static boolean hasExternalDisplayName(Display display) { + String name = display.getName(); + if (name == null) return false; + String lower = name.toLowerCase(); + return lower.contains("hdmi") || lower.contains("displayport") || + lower.contains("dp-") || lower.contains("dp_") || + lower.contains("external") || lower.contains("virtual") || + lower.contains("miracast") || lower.contains("wireless") || + lower.contains("xreal") || lower.contains("rokid") || + lower.contains("viture") || lower.contains("nreal") || + lower.contains("quest") || lower.contains("pico") || + lower.contains("hololens") || lower.contains("magic leap") || + lower.contains("vuzix") || lower.contains("epson") || + lower.contains("mad gaze") || lower.contains("shadow") || + lower.contains("lenovo") || lower.contains("thinkreality"); + } + + /** + * Check if a display is a built-in/internal screen (not an externally connected display). + * Uses multiple heuristics: flags, display name patterns, and comparison with default display. + */ + private static boolean isBuiltInDisplay(Display display) { + int flags = display.getFlags(); + + // FLAG_PRIVATE indicates the display is internal/private to the system + if ((flags & Display.FLAG_PRIVATE) != 0) { + return true; + } + + // Known external display name patterns → definitely external + if (hasExternalDisplayName(display)) { + return false; + } + + // SECURE and PROTECTED flags are typical of built-in displays (DRM-protected panels). + // External displays (HDMI, DP, AR glasses) rarely have these flags. + if ((flags & Display.FLAG_SECURE) != 0 || + (flags & Display.FLAG_SUPPORTS_PROTECTED_BUFFERS) != 0) { + return true; + } + + // Manufacturer name match (internal screens often have device OEM in display name) + String displayName = display.getName(); + String deviceManufacturer = Build.MANUFACTURER; + if (displayName != null && deviceManufacturer != null && + displayName.toLowerCase().contains(deviceManufacturer.toLowerCase())) { + return true; + } + + // Default: treat as external (safer to use external display mode features) + return false; + } + + /** + * Check if the device has two internal screens (dual-screen handheld like AYN Thor). + * Uses combined heuristics: flag patterns across all displays, name checks. + */ + private static boolean isDualInternalScreenDevice(DisplayManager displayManager, Display defaultDisplay) { + Display[] allDisplays = displayManager.getDisplays(); + int internalScreenCount = 0; + int totalScreens = allDisplays.length; + + for (Display d : allDisplays) { + int flags = d.getFlags(); + LimeLog.info("Display " + d.getDisplayId() + ": " + d.getName() + + " " + d.getMode().getPhysicalWidth() + "x" + d.getMode().getPhysicalHeight() + + " flags=" + flags); + if (isBuiltInDisplay(d)) { + internalScreenCount++; + } + } + + // If standard detection found >= 2 internal screens, we're done + if (internalScreenCount >= 2) { + LimeLog.info("Detected " + internalScreenCount + " internal screen(s) — dual internal screen device"); + return true; + } + + // Fallback: if there are exactly 2 displays and neither has clear external + // indicators, AND both share SECURE or PROTECTED flags, treat as dual internal. + // This catches devices like AYN Thor where the secondary screen has PRESENTATION + // flag but is actually a second built-in panel. + if (totalScreens == 2 && internalScreenCount == 1) { + int defaultFlags = defaultDisplay.getFlags(); + boolean defaultIsSecure = (defaultFlags & (Display.FLAG_SECURE | Display.FLAG_SUPPORTS_PROTECTED_BUFFERS)) != 0; + + for (Display d : allDisplays) { + if (d.getDisplayId() == Display.DEFAULT_DISPLAY) continue; + if (isBuiltInDisplay(d)) continue; // already counted above + + int dFlags = d.getFlags(); + boolean dIsSecure = (dFlags & (Display.FLAG_SECURE | Display.FLAG_SUPPORTS_PROTECTED_BUFFERS)) != 0; + + // If non-default display shares SECURE/PROTECTED with default AND + // has no external name, it's likely a secondary internal screen + if (defaultIsSecure && dIsSecure && !hasExternalDisplayName(d)) { + LimeLog.info("Non-default display shares internal flags with default — treating as dual internal screen"); + return true; + } + } + } + + LimeLog.info("Detected " + internalScreenCount + " internal screen(s) — not dual internal"); + return false; + } + + /** + * Return the display with the larger physical area from two candidates. + */ + private static Display getLargerDisplay(Display a, Display b) { + int areaA = a.getMode().getPhysicalWidth() * a.getMode().getPhysicalHeight(); + int areaB = b.getMode().getPhysicalWidth() * b.getMode().getPhysicalHeight(); + LimeLog.info("Comparing displays: " + a.getDisplayId() + " area=" + areaA + + " vs " + b.getDisplayId() + " area=" + areaB); + return (areaA >= areaB) ? a : b; + } + public static Display getActiveDisplay(Context context, PreferenceConfiguration prefs) { + DisplayManager displayManager = (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); + Display defaultDisplay = displayManager.getDisplay(Display.DEFAULT_DISPLAY); Display secondary = getSecondaryDisplay(context); - if (secondary != null && (prefs.enableFullExDisplay)) { + + if (secondary != null && prefs.enableFullExDisplay) { + // Check if both displays are internal (dual-screen device like AYN Thor) + if (isBuiltInDisplay(defaultDisplay) && isBuiltInDisplay(secondary)) { + // Dual internal screens: use the LARGER one for streaming + LimeLog.info("Dual internal screen detected - selecting larger display for streaming"); + return getLargerDisplay(defaultDisplay, secondary); + } + // True external display (AR glasses, USB monitor, etc.): use it return secondary; - } else { - return ((DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE)).getDisplay(Display.DEFAULT_DISPLAY); } + + return defaultDisplay; } public static Display getSecondaryDisplay(Context context) { DisplayManager displayManager = (DisplayManager) context.getSystemService(Context.DISPLAY_SERVICE); - Display display = null; Display[] displays = displayManager.getDisplays(); int mainDisplayId = Display.DEFAULT_DISPLAY; - int secondaryDisplayId = -1; - for (Display displayVariant : displays) { - LimeLog.info(displayVariant.toString()); - if (displayVariant.getDisplayId() != mainDisplayId) { - secondaryDisplayId = displayVariant.getDisplayId(); - break; + Display defaultDisplay = displayManager.getDisplay(mainDisplayId); + + // Collect non-default displays + ArrayList nonDefaultDisplays = new ArrayList<>(); + for (Display d : displays) { + LimeLog.info(d.toString()); + if (d.getDisplayId() != mainDisplayId) { + nonDefaultDisplays.add(d); } } - if (secondaryDisplayId != -1) { - display = displayManager.getDisplay(secondaryDisplayId); + if (nonDefaultDisplays.isEmpty()) { + return null; + } + + // On dual-internal-screen devices, prefer the larger non-default internal display + // as the "secondary" display for dual-screen mode + if (isDualInternalScreenDevice(displayManager, defaultDisplay)) { + // Return the largest non-default internal display + Display best = null; + int maxArea = 0; + for (Display d : nonDefaultDisplays) { + if (isBuiltInDisplay(d)) { + int area = d.getMode().getPhysicalWidth() * d.getMode().getPhysicalHeight(); + if (area > maxArea) { + maxArea = area; + best = d; + } + } + } + if (best != null) { + LimeLog.info("Dual internal screen: selected secondary display " + best.getDisplayId()); + return best; + } } - return display; + + // Prefer truly external displays over secondary internal screens + for (Display d : nonDefaultDisplays) { + if (!isBuiltInDisplay(d)) { + LimeLog.info("External display detected: " + d.getDisplayId()); + return d; + } + } + + // Fallback: return the first non-default display + return nonDefaultDisplays.get(0); } public static Intent createStartIntent(Activity parent, NvApp app, ComputerDetails computer, @@ -95,9 +258,23 @@ public static Intent createStartIntent(Activity parent, NvApp app, ComputerDetai boolean withVDisplay) { Intent gameIntent = null; PreferenceConfiguration prefConfig = PreferenceConfiguration.readPreferences(parent); + DisplayManager displayManager = (DisplayManager) parent.getSystemService(Context.DISPLAY_SERVICE); + Display secondaryDisplay = getSecondaryDisplay(parent); + boolean enableFullEx = prefConfig.enableFullExDisplay && secondaryDisplay != null; + boolean isDualInternal = enableFullEx && isDualInternalScreenDevice(displayManager, + displayManager.getDisplay(Display.DEFAULT_DISPLAY)); + // Try to add secondary DisplayContext if supported and connected - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && prefConfig.enableFullExDisplay && getSecondaryDisplay(parent) != null) { - Context displayContext = parent.createDisplayContext(getSecondaryDisplay(parent)); // use secondary display + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && enableFullEx) { + Context displayContext; + if (isDualInternal) { + // Dual internal screens: use the larger display for Game context + Display defaultDisplay = displayManager.getDisplay(Display.DEFAULT_DISPLAY); + Display largerDisplay = getLargerDisplay(defaultDisplay, secondaryDisplay); + displayContext = parent.createDisplayContext(largerDisplay); + } else { + displayContext = parent.createDisplayContext(secondaryDisplay); + } gameIntent = new Intent(displayContext, Game.class); gameIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); } @@ -123,9 +300,21 @@ public static Intent createStartIntent(Activity parent, NvApp app, ComputerDetai e.printStackTrace(); } - if (prefConfig.enableFullExDisplay) { - Display secondaryDisplay = getSecondaryDisplay(parent); - if (secondaryDisplay != null) { + if (enableFullEx) { + if (isDualInternal) { + // Dual internal screens (e.g. AYN Thor): stream on larger display, controls on smaller + Display defaultDisplay = displayManager.getDisplay(Display.DEFAULT_DISPLAY); + Display largerDisplay = getLargerDisplay(defaultDisplay, secondaryDisplay); + Display smallerDisplay = (largerDisplay == defaultDisplay) ? secondaryDisplay : defaultDisplay; + + gameIntent.putExtra(Game.EXTRA_DISPLAY_ID, largerDisplay.getDisplayId()); + Intent touchpadIntent = new Intent(parent, ExternalDisplayControlActivity.class); + touchpadIntent.putExtra(ExternalDisplayControlActivity.EXTRA_LAUNCH_INTENT, gameIntent); + // Signal to doStart() to launch touchpad on the smaller display + touchpadIntent.putExtra(ExternalDisplayControlActivity.EXTRA_LAUNCH_DISPLAY_ID, smallerDisplay.getDisplayId()); + return touchpadIntent; + } else { + // True external display: original behavior (stream on external, controls on default) int secondaryDisplayId = secondaryDisplay.getDisplayId(); gameIntent.putExtra(Game.EXTRA_DISPLAY_ID, secondaryDisplayId); Intent touchpadIntent = new Intent(parent, ExternalDisplayControlActivity.class); @@ -151,7 +340,16 @@ public static void doStart( } Intent intent = createStartIntent(parent, app, computer, managerBinder, withVDisplay); - parent.startActivity(intent); + + // For dual internal screen devices, launch the touchpad on the smaller display + int launchDisplayId = intent.getIntExtra(ExternalDisplayControlActivity.EXTRA_LAUNCH_DISPLAY_ID, -1); + if (launchDisplayId != -1 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + android.app.ActivityOptions options = android.app.ActivityOptions.makeBasic(); + options.setLaunchDisplayId(launchDisplayId); + parent.startActivity(intent, options.toBundle()); + } else { + parent.startActivity(intent); + } } public static void doNetworkTest(final Activity parent) { diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1f74cb1dcd..16f597736a 100755 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -184,6 +184,14 @@ 强制 Artemis 的USB驱动接管所有受支持的Xbox/Nintendo Pro手柄 通过手柄模拟鼠标 长按开始键将手柄切换为鼠标模式 + 摇杆鼠标灵敏度 + 使用手柄模拟鼠标时,摇杆控制光标的速度 + X 键映射为 Ctrl + 按下手柄 X 键时发送键盘 Ctrl 键 + Y 键映射为 Esc + 按下手柄 Y 键时发送键盘 Esc 键 + 键盘高度 + 虚拟键盘占屏幕高度的百分比 记住鼠标模式 记住在串流中选择的鼠标模式。禁用时,串流中改变的鼠标模式将是临时的。 启用前进后退鼠标键 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 95f64c92a5..145c38b59d 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -236,6 +236,14 @@ Use Artemis\'s USB driver for all supported gamepads, even if native Xbox/Nintendo Pro controller support is present Mouse emulation via gamepad Long-press the Start button to toggle between gamepad and mouse mode + Stick Mouse Sensitivity + Analog stick cursor speed when using gamepad mouse emulation + Remap X to Ctrl + Send keyboard Ctrl key instead of gamepad X button + Remap Y to Esc + Send keyboard Escape key instead of gamepad Y button + Keyboard Height + Virtual keyboard height as percentage of screen Remember mouse mode Remember mouse mode selected during stream. When disabled, mouse mode changed during stream will be temporary. Flip face buttons diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 5b009c891f..f6808294de 100755 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -286,6 +286,31 @@ android:summary="@string/summary_analog_scrolling" android:title="@string/title_analog_scrolling" app:iconSpaceReserved="false" /> + + + + +