diff --git a/app/src/main/java/com/limelight/Game.java b/app/src/main/java/com/limelight/Game.java index 992fc00a71..9d11405611 100755 --- a/app/src/main/java/com/limelight/Game.java +++ b/app/src/main/java/com/limelight/Game.java @@ -47,6 +47,7 @@ import com.limelight.utils.ExternalDisplayControlActivity; import com.limelight.utils.MouseModeOption; import com.limelight.utils.PanZoomHandler; +import com.limelight.utils.TouchpadPresentation; import com.limelight.utils.PerformanceDataTracker; import com.limelight.utils.ServerHelper; import com.limelight.utils.ShortcutHelper; @@ -55,6 +56,7 @@ import android.annotation.SuppressLint; import android.annotation.TargetApi; +import androidx.annotation.RequiresApi; import android.app.AlertDialog; import android.app.PictureInPictureParams; import android.app.Service; @@ -142,6 +144,7 @@ public class Game extends AppCompatActivity implements SurfaceHolder.Callback, ExternalControllerView.InputCallbacks, PerfOverlayListener, UsbDriverService.UsbDriverStateListener, View.OnKeyListener { public static Game instance; + private TouchpadPresentation touchpadPresentation; private int lastButtonState = 0; @@ -170,6 +173,10 @@ public class Game extends AppCompatActivity implements SurfaceHolder.Callback, private ControllerHandler controllerHandler; private KeyboardTranslator keyboardTranslator; + + public KeyboardTranslator getKeyboardTranslator() { + return keyboardTranslator; + } private VirtualController virtualController; private KeyBoardController keyBoardController; @@ -396,6 +403,11 @@ protected void onCreate(Bundle savedInstanceState) { onExternelDisplay = currentDisplay.getDisplayId() != Display.DEFAULT_DISPLAY; + // Initialize touchpad presentation if in touchpad mode + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && prefConfig.secondaryScreenTouchpad) { + initTouchpadPresentation(); + } + boolean shouldInvertDecoderResolution = false; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M @@ -1698,6 +1710,72 @@ public void onMultiWindowModeChanged(boolean isInMultiWindowMode) { hideSystemUi(50); } + @RequiresApi(api = Build.VERSION_CODES.R) + private void initTouchpadPresentation() { + try { + DisplayManager displayManager = (DisplayManager) getSystemService(Context.DISPLAY_SERVICE); + + // Get the touchpad display using DISPLAY_CATEGORY_PRESENTATION + Display[] presentations = displayManager.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION); + Display touchpadDisplay = null; + + LimeLog.info("Game - Found " + presentations.length + " presentation displays"); + + // Find the display matching our touchpad display ID + for (Display display : presentations) { + LimeLog.info("Game - Checking presentation display: " + display.getDisplayId() + " (" + display.getName() + ")"); + if (display.getDisplayId() == prefConfig.touchpadDisplayId) { + touchpadDisplay = display; + break; + } + } + + if (touchpadDisplay != null) { + LimeLog.info("Game - Creating TouchpadPresentation on display: " + touchpadDisplay.getDisplayId()); + touchpadPresentation = new TouchpadPresentation(this, touchpadDisplay, prefConfig); + touchpadPresentation.show(); + LimeLog.info("Game - TouchpadPresentation shown successfully"); + } else { + LimeLog.warning("Game - Touchpad display " + prefConfig.touchpadDisplayId + " not found in presentation displays"); + } + } catch (Exception e) { + LimeLog.severe("Game - Error initializing touchpad presentation: " + e.getMessage()); + e.printStackTrace(); + } + } + + private void dismissTouchpadPresentation() { + if (touchpadPresentation != null) { + LimeLog.info("Game - Dismissing touchpad presentation"); + touchpadPresentation.dismiss(); + touchpadPresentation = null; + } + } + + public void onTouchpadPresentationDismissed() { + LimeLog.info("Game - Touchpad presentation was dismissed"); + touchpadPresentation = null; + } + + @RequiresApi(api = Build.VERSION_CODES.R) + public void toggleTouchpadPresentation() { + if (touchpadPresentation != null) { + // Hide touchpad + dismissTouchpadPresentation(); + Toast.makeText(this, R.string.toast_touchpad_hidden, Toast.LENGTH_SHORT).show(); + } else if (prefConfig.secondaryScreenTouchpad) { + // Show touchpad + initTouchpadPresentation(); + if (touchpadPresentation != null) { + Toast.makeText(this, R.string.toast_touchpad_shown, Toast.LENGTH_SHORT).show(); + } + } + } + + public boolean isTouchpadModeEnabled() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && prefConfig.secondaryScreenTouchpad; + } + @Override protected void onDestroy() { super.onDestroy(); @@ -1705,6 +1783,9 @@ protected void onDestroy() { instance = null; timerHandler.removeCallbacksAndMessages(null); + // Dismiss touchpad presentation if active + dismissTouchpadPresentation(); + if (prefConfig.enableFullExDisplay) handleDisplayRemoved(); if (controllerHandler != null) { @@ -1757,6 +1838,20 @@ protected void onPause() { super.onPause(); } + @Override + protected void onStart() { + super.onStart(); + + // Recreate touchpad presentation if it was dismissed when app went to background + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && + prefConfig.secondaryScreenTouchpad && + touchpadPresentation == null && + conn != null) { + LimeLog.info("Game - onStart: Recreating touchpad presentation"); + initTouchpadPresentation(); + } + } + @Override protected void onStop() { super.onStop(); @@ -2402,6 +2497,10 @@ private TouchContext getTouchContext(int actionIndex, TouchContext[] inputContex public void toggleKeyboard() { if (isOnExternalDisplay()) { ExternalDisplayControlActivity.toggleKeyboard(); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && prefConfig.secondaryScreenTouchpad && touchpadPresentation != null) { + // In touchpad mode, keyboard is shown on touchpad display via the keyboard button + // Don't toggle it on the game display + LimeLog.info("Touchpad mode active - keyboard should be toggled from touchpad display"); } else { LimeLog.info("Toggling keyboard overlay"); InputMethodManager inputManager = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); @@ -3289,11 +3388,14 @@ private boolean handleMultiTouchGesture(MotionEvent event, int eventAction, int if (eventAction == MotionEvent.ACTION_POINTER_DOWN) { if (pointerCount == 3) { + LimeLog.info("Game - 3 fingers down detected"); threeFingerDownTime = event.getEventTime(); } else if (pointerCount == 4) { + LimeLog.info("Game - 4 fingers down detected"); threeFingerDownTime = 0; fourFingerDownTime = event.getEventTime(); } else if (pointerCount == 5) { + LimeLog.info("Game - 5 fingers down detected"); threeFingerDownTime = 0; fourFingerDownTime = 0; fiveFingerDownTime = event.getEventTime(); @@ -3315,9 +3417,12 @@ private boolean handleMultiTouchGesture(MotionEvent event, int eventAction, int fourFingerDownTime = 0; break; } else if (pointerCount == 3 && threeFingerDownTime > 0 && currentEventTime - threeFingerDownTime < THREE_FINGER_TAP_THRESHOLD) { + LimeLog.info("Game - 3 finger tap detected, toggling keyboard"); toggleKeyboard(); threeFingerDownTime = 0; break; + } else if (pointerCount == 3) { + LimeLog.info("Game - 3 fingers up but not a tap (time: " + (currentEventTime - threeFingerDownTime) + "ms, threshold: " + THREE_FINGER_TAP_THRESHOLD + "ms)"); } threeFingerDownTime = 0; fourFingerDownTime = 0; diff --git a/app/src/main/java/com/limelight/GameMenu.java b/app/src/main/java/com/limelight/GameMenu.java index 63f3532206..64859702ca 100755 --- a/app/src/main/java/com/limelight/GameMenu.java +++ b/app/src/main/java/com/limelight/GameMenu.java @@ -247,7 +247,7 @@ private void showAdvancedMenu(GameInputDevice device) { if (game.allowChangeMouseMode) { options.add(new MenuOption(getString(R.string.game_menu_select_mouse_mode), true, () -> game.selectMouseMode(dialogScreenContext))); } - + options.add(new MenuOption(getString(R.string.game_menu_toggle_hud), true, game::toggleHUD)); options.add(new MenuOption(getString(R.string.game_menu_toggle_floating_button), true, game::toggleFloatingButtonVisibility)); options.add(new MenuOption(getString(R.string.game_menu_toggle_keyboard_model), true, game::toggleKeyboardController)); @@ -317,6 +317,10 @@ public void showMenu(GameInputDevice device) { options.add(new MenuOption(getString(R.string.game_menu_toggle_keyboard), true, game::toggleKeyboard)); + if (game.isTouchpadModeEnabled()) { + options.add(new MenuOption(getString(R.string.game_menu_toggle_touchpad), true, game::toggleTouchpadPresentation)); + } + options.add(new MenuOption(getString(game.isZoomModeEnabled() ? R.string.game_menu_disable_zoom_mode : R.string.game_menu_enable_zoom_mode), true, game::toggleZoomMode)); diff --git a/app/src/main/java/com/limelight/preferences/DisplaySelectionPreference.java b/app/src/main/java/com/limelight/preferences/DisplaySelectionPreference.java new file mode 100644 index 0000000000..65b765116c --- /dev/null +++ b/app/src/main/java/com/limelight/preferences/DisplaySelectionPreference.java @@ -0,0 +1,131 @@ +package com.limelight.preferences; + +import android.content.Context; +import android.hardware.display.DisplayManager; +import android.os.Build; +import android.util.AttributeSet; +import android.view.Display; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.preference.ListPreference; + +import java.util.ArrayList; +import java.util.List; + +/** + * A custom ListPreference that dynamically populates with available displays. + * Shows display ID, name, and resolution to help users identify screens. + */ +public class DisplaySelectionPreference extends ListPreference { + + public DisplaySelectionPreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + populateDisplays(); + } + + public DisplaySelectionPreference(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + populateDisplays(); + } + + public DisplaySelectionPreference(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + populateDisplays(); + } + + public DisplaySelectionPreference(@NonNull Context context) { + super(context); + populateDisplays(); + } + + /** + * Populates the preference with available displays from DisplayManager. + * Each entry shows: "Display [ID]: [Name] ([Width]x[Height])" + */ + private void populateDisplays() { + DisplayManager displayManager = (DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE); + if (displayManager == null) { + // Fallback to simple primary/secondary if DisplayManager unavailable + setEntries(new CharSequence[]{"Primary display", "Secondary display"}); + setEntryValues(new CharSequence[]{"0", "1"}); + return; + } + + Display[] displays = displayManager.getDisplays(); + List entries = new ArrayList<>(); + List entryValues = new ArrayList<>(); + + for (Display display : displays) { + int displayId = display.getDisplayId(); + String displayName = getDisplayName(display); + String resolution = getDisplayResolution(display); + + // Format: "Display 0: Built-in Screen (2560x1600)" + String entry = String.format("Display %d: %s (%s)", displayId, displayName, resolution); + + entries.add(entry); + entryValues.add(String.valueOf(displayId)); + } + + // If no displays found, add a default entry + if (entries.isEmpty()) { + entries.add("Primary display"); + entryValues.add("0"); + } + + setEntries(entries.toArray(new CharSequence[0])); + setEntryValues(entryValues.toArray(new CharSequence[0])); + } + + /** + * Gets a friendly name for the display. + */ + private String getDisplayName(Display display) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Android 11+ has a proper display name API + return getDisplayNameR(display); + } else { + // Fallback for older Android versions + int displayId = display.getDisplayId(); + if (displayId == Display.DEFAULT_DISPLAY) { + return "Built-in Screen"; + } else { + return "External Display"; + } + } + } + + @RequiresApi(api = Build.VERSION_CODES.R) + private String getDisplayNameR(Display display) { + String name = display.getName(); + int displayId = display.getDisplayId(); + + if (name != null && !name.isEmpty()) { + return name; + } else if (displayId == Display.DEFAULT_DISPLAY) { + return "Built-in Screen"; + } else { + return "External Display"; + } + } + + /** + * Gets the display resolution as "WIDTHxHEIGHT" + */ + private String getDisplayResolution(Display display) { + Display.Mode mode = display.getMode(); + return mode.getPhysicalWidth() + "x" + mode.getPhysicalHeight(); + } + + /** + * Refresh the display list when the preference is shown. + * This ensures we have the latest display information. + */ + @Override + protected void onClick() { + populateDisplays(); + super.onClick(); + } +} diff --git a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java index 96249d87ac..e51fea73fc 100755 --- a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java +++ b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.java @@ -117,6 +117,10 @@ public enum AnalogStickForScrolling { private static final String CHECKBOX_SHOW_OVERLAY_ZOOM_TOGGLE_BUTTON = "checkbox_show_overlay_zoom_toggle_button"; + //Use secondary screen as touchpad + private static final String CHECKBOX_SECONDARY_SCREEN_TOUCHPAD = "checkbox_secondary_screen_touchpad"; + private static final String LIST_TOUCHPAD_DISPLAY = "list_touchpad_display"; + //竖屏模式 private static final String CHECKBOX_AUTO_ORIENTATION = "checkbox_auto_orientation"; //屏幕特殊按键 @@ -208,6 +212,8 @@ public enum AnalogStickForScrolling { private static final boolean DEFAULT_ENABLE_COMMIT_TEXT = false; private static final String DEFAULT_ONSCREEN_KEYBOARD_ALIGN_MODE = "center"; private static final boolean DEFAULT_SHOW_OVERLAY_TOGGLE_BUTTON = false; + private static final boolean DEFAULT_SECONDARY_SCREEN_TOUCHPAD = false; + private static final int DEFAULT_TOUCHPAD_DISPLAY = 1; // Display ID 1 (usually secondary) private static final boolean DEFAULT_REMEMBER_ZOOM_PAN = false; private static final float DEFAULT_ZOOM_SCALE = 1.0f; @@ -282,6 +288,8 @@ public enum AnalogStickForScrolling { public boolean enableBackMenu; public boolean enableFloatingButton; public boolean showOverlayZoomToggleButton; + public boolean secondaryScreenTouchpad; + public int touchpadDisplayId; // Display ID for touchpad mode //Invert video width/height public boolean autoInvertVideoResolution; @@ -938,6 +946,16 @@ else if (audioConfig.equals("51")) { config.enableBackMenu = prefs.getBoolean(CHECKBOX_ENABLE_QUIT_DIALOG,true); config.enableFloatingButton = prefs.getBoolean(CHECKBOX_ENABLE_FLOATING_BUTTON,DEFAULT_ENABLE_FLOATING_BUTTON); config.showOverlayZoomToggleButton = prefs.getBoolean(CHECKBOX_SHOW_OVERLAY_ZOOM_TOGGLE_BUTTON, DEFAULT_SHOW_OVERLAY_TOGGLE_BUTTON); + config.secondaryScreenTouchpad = prefs.getBoolean(CHECKBOX_SECONDARY_SCREEN_TOUCHPAD, DEFAULT_SECONDARY_SCREEN_TOUCHPAD); + // Try to read as int first (new format), fall back to string for backwards compatibility + try { + String displayIdStr = prefs.getString(LIST_TOUCHPAD_DISPLAY, String.valueOf(DEFAULT_TOUCHPAD_DISPLAY)); + config.touchpadDisplayId = Integer.parseInt(displayIdStr); + } catch (NumberFormatException e) { + // Handle legacy string values ("primary"/"secondary") + String legacyValue = prefs.getString(LIST_TOUCHPAD_DISPLAY, "secondary"); + config.touchpadDisplayId = legacyValue.equals("primary") ? Display.DEFAULT_DISPLAY : 1; + } config.autoOrientation = prefs.getBoolean(CHECKBOX_AUTO_ORIENTATION,false); config.autoInvertVideoResolution = prefs.getBoolean(AUTO_INVERT_VIDEO_RESOLUTION_PREF_STRING, DEFAULT_AUTO_INVERT_VIDEO_RESOLUTION); config.resolutionScaleFactor = prefs.getInt(RESOLUTION_SCALE_FACTOR_PREF_STRING, DEFAULT_RESOLUTION_SCALE_FACTOR); diff --git a/app/src/main/java/com/limelight/ui/StreamContainer.java b/app/src/main/java/com/limelight/ui/StreamContainer.java index 33ae5de10c..0c85a08354 100644 --- a/app/src/main/java/com/limelight/ui/StreamContainer.java +++ b/app/src/main/java/com/limelight/ui/StreamContainer.java @@ -15,6 +15,7 @@ import com.limelight.Game; import com.limelight.LimeLog; +import com.limelight.nvstream.input.KeyboardPacket; import com.limelight.preferences.PreferenceConfiguration; import com.limelight.utils.Stereo3DRenderer; @@ -159,6 +160,11 @@ public void setCommitTextEnabled(boolean enabled) { @Override public boolean onKeyPreIme(int keyCode, KeyEvent event) { + // Allow back button to dismiss keyboard + if (keyCode == KeyEvent.KEYCODE_BACK) { + return false; + } + if (mInputCallbacks != null) { if (event.getAction() == KeyEvent.ACTION_DOWN) { if (mInputCallbacks.handleKeyDown(event)) return true; @@ -179,24 +185,61 @@ public void onWindowFocusChanged(boolean hasWindowFocus) { @Override public boolean onCheckIsTextEditor() { - return commitTextEnabled || super.onCheckIsTextEditor(); + // Always return true so soft keyboard can attach to this view + return true; } @Override public InputConnection onCreateInputConnection(EditorInfo outAttrs) { - if (!commitTextEnabled) { - return super.onCreateInputConnection(outAttrs); - } - outAttrs.inputType = android.text.InputType.TYPE_CLASS_TEXT; - outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI; + // Configure the IME for immediate character input (no buffering) + // Use password type to disable predictions and autocomplete + outAttrs.inputType = android.text.InputType.TYPE_CLASS_TEXT | + android.text.InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | + android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; + outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI | + EditorInfo.IME_FLAG_NO_FULLSCREEN; + return new BaseInputConnection(this, false) { + @Override + public boolean setComposingText(CharSequence text, int newCursorPosition) { + // Convert composing text to immediate commit for live typing + return commitText(text, newCursorPosition); + } + @Override public boolean commitText(CharSequence text, int newCursorPosition) { - return mInputCallbacks != null && mInputCallbacks.handleCommitText(text) || super.commitText(text, newCursorPosition); + // Send text directly to the streaming connection + if (Game.instance != null && Game.instance.conn != null) { + try { + for (int i = 0; i < text.length(); i++) { + Game.instance.conn.sendUtf8Text(String.valueOf(text.charAt(i))); + } + return true; + } catch (Exception e) { + LimeLog.severe("StreamContainer - Error sending text: " + e.getMessage()); + } + } + return false; } + @Override public boolean deleteSurroundingText(int beforeLength, int afterLength) { - return mInputCallbacks != null && mInputCallbacks.handleDeleteSurroundingText(beforeLength, afterLength) || super.deleteSurroundingText(beforeLength, afterLength); + // Send backspace events for deleted characters + if (Game.instance != null && Game.instance.getKeyboardTranslator() != null && Game.instance.conn != null) { + try { + short backspaceCode = Game.instance.getKeyboardTranslator().translate(KeyEvent.KEYCODE_DEL, 0, -1); + for (int i = 0; i < beforeLength; i++) { + Game.instance.conn.sendKeyboardInput(backspaceCode, + com.limelight.nvstream.input.KeyboardPacket.KEY_DOWN, (byte)0, (byte)0); + Game.instance.conn.sendKeyboardInput(backspaceCode, + com.limelight.nvstream.input.KeyboardPacket.KEY_UP, (byte)0, (byte)0); + } + return true; + } catch (Exception e) { + LimeLog.severe("StreamContainer - Error sending backspace: " + e.getMessage()); + } + } + return false; } }; } diff --git a/app/src/main/java/com/limelight/utils/ExternalDisplayControlActivity.java b/app/src/main/java/com/limelight/utils/ExternalDisplayControlActivity.java index 5bbb0a30b0..6e5222f18f 100644 --- a/app/src/main/java/com/limelight/utils/ExternalDisplayControlActivity.java +++ b/app/src/main/java/com/limelight/utils/ExternalDisplayControlActivity.java @@ -15,6 +15,7 @@ import android.content.pm.PackageManager; import android.content.res.Configuration; import android.graphics.Color; +import android.hardware.display.DisplayManager; import android.os.Build; import android.os.Bundle; import android.os.Handler; @@ -57,11 +58,15 @@ public class ExternalDisplayControlActivity extends AppCompatActivity implements View.OnKeyListener, KeyBoardLayoutController.ViewCallbacks { public static String EXTRA_LAUNCH_INTENT = "launchIntent"; + public static String EXTRA_TOUCHPAD_DISPLAY_ID = "TOUCHPAD_DISPLAY_ID"; + public static String EXTRA_IS_TOUCHPAD_MODE = "IS_TOUCHPAD_MODE"; @SuppressLint("StaticFieldLeak") public static ExternalDisplayControlActivity instance; private PreferenceConfiguration prefConfig; + private boolean isTouchpadMode = false; + private int touchpadDisplayId = -1; private ExternalControllerView rootLayout; private ImageButton zoomButton; @@ -117,27 +122,79 @@ protected void onCreate(Bundle savedInstanceState) { instance = this; prefConfig = PreferenceConfiguration.readPreferences(this); + // Check if we're in touchpad mode + isTouchpadMode = getIntent().getBooleanExtra(EXTRA_IS_TOUCHPAD_MODE, false); + touchpadDisplayId = getIntent().getIntExtra(EXTRA_TOUCHPAD_DISPLAY_ID, -1); + + LimeLog.info("ExternalDisplayControlActivity onCreate - isTouchpadMode: " + isTouchpadMode + ", touchpadDisplayId: " + touchpadDisplayId); + if (!isGameInstanceAvailable()) { Intent gameIntent = getIntent().getParcelableExtra(EXTRA_LAUNCH_INTENT); + LimeLog.info("ExternalDisplayControlActivity - gameIntent null: " + (gameIntent == null) + ", Game.instance null: " + (Game.instance == null)); if (gameIntent == null) { + LimeLog.warning("ExternalDisplayControlActivity - No game intent, finishing"); finish(); } else { - 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()); + if (isTouchpadMode && touchpadDisplayId != -1) { + LimeLog.info("ExternalDisplayControlActivity - Touchpad mode active, touchpadDisplayId: " + touchpadDisplayId); + // In touchpad mode, launch game on the specified display + DisplayManager displayManager = (DisplayManager) getSystemService(Context.DISPLAY_SERVICE); + Display targetDisplay = displayManager.getDisplay(touchpadDisplayId); + LimeLog.info("ExternalDisplayControlActivity - targetDisplay: " + (targetDisplay != null ? targetDisplay.getDisplayId() : "null")); + + if (targetDisplay != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Get the streaming display to show info + Display streamDisplay = null; + for (Display display : displayManager.getDisplays()) { + if (display.getDisplayId() != touchpadDisplayId) { + streamDisplay = display; + break; + } + } + + LimeLog.info("ExternalDisplayControlActivity - Found stream display: " + (streamDisplay != null ? streamDisplay.getDisplayId() : "null")); + + if (streamDisplay != null) { + // Launch Game on the stream display explicitly + ActivityOptions options = ActivityOptions.makeBasic(); + options.setLaunchDisplayId(streamDisplay.getDisplayId()); + LimeLog.info("ExternalDisplayControlActivity - Launching Game on display: " + streamDisplay.getDisplayId()); + + Toast.makeText(this, + "Touchpad mode: Stream on Display " + streamDisplay.getDisplayId() + + " (" + streamDisplay.getMode().getPhysicalWidth() + "x" + + streamDisplay.getMode().getPhysicalHeight() + ")", + Toast.LENGTH_LONG).show(); + + startActivity(gameIntent, options.toBundle()); + } else { + LimeLog.warning("ExternalDisplayControlActivity - No stream display found, launching on default"); + startActivity(gameIntent); + } + } else { + LimeLog.warning(getString(R.string.no_external_display)); + startActivity(gameIntent); + finish(); + } } else { - LimeLog.warning(getString(R.string.no_external_display)); - startActivity(gameIntent); - finish(); + // Original behavior for full external display mode + 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(); + } } } } @@ -146,41 +203,66 @@ protected void onCreate(Bundle savedInstanceState) { } private void initViews() { + LimeLog.info("ExternalDisplayControlActivity - initViews called, failCount: " + failCount + ", Game.instance: " + (Game.instance != null)); if (Game.instance == null) { if (failCount > 10) { + LimeLog.warning("ExternalDisplayControlActivity - Failed to find Game.instance after 10 attempts, finishing"); Toast.makeText(this, getString(R.string.no_game_instance), Toast.LENGTH_LONG).show(); finish(); } // Wait for the intent to get started + LimeLog.info("ExternalDisplayControlActivity - Waiting for Game.instance, retrying in 500ms"); handler.postDelayed(this::initViews, 500); failCount++; return; } - WindowInsetsControllerCompat windowInsetsController = WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView()); + LimeLog.info("ExternalDisplayControlActivity - Game.instance found, creating UI"); - windowInsetsController.setSystemBarsBehavior( - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - ); + try { + LimeLog.info("ExternalDisplayControlActivity - Setting up window insets"); + WindowInsetsControllerCompat windowInsetsController = WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView()); - windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()); - windowInsetsController.hide(WindowInsetsCompat.Type.navigationBars()); + windowInsetsController.setSystemBarsBehavior( + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + ); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - WindowCompat.setDecorFitsSystemWindows(getWindow(), false); - androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener(getWindow().getDecorView(), (v, insets) -> { - boolean imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()); - updateKeyboardVisibility(imeVisible || (keyBoardLayoutController != null && keyBoardLayoutController.isKeyboardVisible())); - return androidx.core.view.ViewCompat.onApplyWindowInsets(v, insets); - }); - } + windowInsetsController.hide(WindowInsetsCompat.Type.systemBars()); + windowInsetsController.hide(WindowInsetsCompat.Type.navigationBars()); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + WindowCompat.setDecorFitsSystemWindows(getWindow(), false); + androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener(getWindow().getDecorView(), (v, insets) -> { + boolean imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()); + updateKeyboardVisibility(imeVisible || (keyBoardLayoutController != null && keyBoardLayoutController.isKeyboardVisible())); + return androidx.core.view.ViewCompat.onApplyWindowInsets(v, insets); + }); + } + + LimeLog.info("ExternalDisplayControlActivity - Initializing components"); + initializeComponents(); + + LimeLog.info("ExternalDisplayControlActivity - Creating programmatic UI"); + createProgrammaticUI(); + + LimeLog.info("ExternalDisplayControlActivity - Checking notification permission"); + checkNotificationPermission(); - initializeComponents(); - createProgrammaticUI(); - checkNotificationPermission(); - initTouchEventHandling(); - setupInactivityTimeoutForBrightness(); - requestFocusToGameActivity(false); + LimeLog.info("ExternalDisplayControlActivity - Initializing touch event handling"); + initTouchEventHandling(); + + LimeLog.info("ExternalDisplayControlActivity - Setting up inactivity timeout"); + setupInactivityTimeoutForBrightness(); + + LimeLog.info("ExternalDisplayControlActivity - Requesting focus to game"); + requestFocusToGameActivity(false); + + LimeLog.info("ExternalDisplayControlActivity - UI initialization complete!"); + } catch (Exception e) { + LimeLog.severe("ExternalDisplayControlActivity - Error during UI creation: " + e.getMessage()); + e.printStackTrace(); + finish(); + } } @SuppressLint("ClickableViewAccessibility") @@ -198,7 +280,10 @@ private void initTouchEventHandling() { @Override protected void onResume() { super.onResume(); - if (!isGameInstanceAvailable() && gameMenu != null) { + LimeLog.info("ExternalDisplayControlActivity - onResume, isTouchpadMode: " + isTouchpadMode + ", gameMenu: " + (gameMenu != null) + ", Game.instance: " + (Game.instance != null)); + // In touchpad mode, we should stay open even if Game isn't available + if (!isTouchpadMode && !isGameInstanceAvailable() && gameMenu != null) { + LimeLog.info("ExternalDisplayControlActivity - Finishing due to no Game instance (not touchpad mode)"); finish(); } } @@ -206,7 +291,10 @@ protected void onResume() { @Override protected void onPause() { super.onPause(); - if (!isGameInstanceAvailable()) { + LimeLog.info("ExternalDisplayControlActivity - onPause, isTouchpadMode: " + isTouchpadMode + ", Game.instance: " + (Game.instance != null)); + // In touchpad mode, we should stay open even if Game pauses + if (!isTouchpadMode && !isGameInstanceAvailable()) { + LimeLog.info("ExternalDisplayControlActivity - Finishing due to no Game instance (not touchpad mode)"); finish(); } } diff --git a/app/src/main/java/com/limelight/utils/ServerHelper.java b/app/src/main/java/com/limelight/utils/ServerHelper.java index 2f474fb7ee..b45603ae79 100755 --- a/app/src/main/java/com/limelight/utils/ServerHelper.java +++ b/app/src/main/java/com/limelight/utils/ServerHelper.java @@ -95,12 +95,20 @@ public static Intent createStartIntent(Activity parent, NvApp app, ComputerDetai boolean withVDisplay) { Intent gameIntent = null; PreferenceConfiguration prefConfig = PreferenceConfiguration.readPreferences(parent); - // 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 + Display secondaryDisplay = getSecondaryDisplay(parent); + + // Handle touchpad mode - just launch Game normally, it will create the Presentation + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && prefConfig.secondaryScreenTouchpad) { + LimeLog.info("ServerHelper - Touchpad mode enabled, Game will create presentation on display: " + prefConfig.touchpadDisplayId); + gameIntent = new Intent(parent, Game.class); + } + // Try to add secondary DisplayContext if supported and connected for full external display mode + else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && prefConfig.enableFullExDisplay && secondaryDisplay != null) { + Context displayContext = parent.createDisplayContext(secondaryDisplay); // use secondary display gameIntent = new Intent(displayContext, Game.class); gameIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); } + if(gameIntent == null) gameIntent = new Intent(parent, Game.class); gameIntent.putExtra(Game.EXTRA_HOST, computer.activeAddress.address); gameIntent.putExtra(Game.EXTRA_PORT, computer.activeAddress.port); @@ -123,15 +131,15 @@ public static Intent createStartIntent(Activity parent, NvApp app, ComputerDetai e.printStackTrace(); } - if (prefConfig.enableFullExDisplay) { - Display secondaryDisplay = getSecondaryDisplay(parent); - if (secondaryDisplay != null) { - int secondaryDisplayId = secondaryDisplay.getDisplayId(); - gameIntent.putExtra(Game.EXTRA_DISPLAY_ID, secondaryDisplayId); - Intent touchpadIntent = new Intent(parent, ExternalDisplayControlActivity.class); - touchpadIntent.putExtra(ExternalDisplayControlActivity.EXTRA_LAUNCH_INTENT, gameIntent); - return touchpadIntent; - } + // Touchpad mode is now handled by Game creating a Presentation internally + + // Handle full external display mode + if (prefConfig.enableFullExDisplay && secondaryDisplay != null) { + int secondaryDisplayId = secondaryDisplay.getDisplayId(); + gameIntent.putExtra(Game.EXTRA_DISPLAY_ID, secondaryDisplayId); + Intent touchpadIntent = new Intent(parent, ExternalDisplayControlActivity.class); + touchpadIntent.putExtra(ExternalDisplayControlActivity.EXTRA_LAUNCH_INTENT, gameIntent); + return touchpadIntent; } return gameIntent; diff --git a/app/src/main/java/com/limelight/utils/TouchpadPresentation.java b/app/src/main/java/com/limelight/utils/TouchpadPresentation.java new file mode 100644 index 0000000000..fbd72c5ec5 --- /dev/null +++ b/app/src/main/java/com/limelight/utils/TouchpadPresentation.java @@ -0,0 +1,319 @@ +package com.limelight.utils; + +import android.annotation.SuppressLint; +import android.app.Presentation; +import android.content.Context; +import android.os.Build; +import android.os.Bundle; +import android.view.Display; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.view.WindowInsetsControllerCompat; + +import com.limelight.Game; +import com.limelight.LimeLog; +import com.limelight.R; +import com.limelight.nvstream.input.KeyboardPacket; +import com.limelight.preferences.PreferenceConfiguration; + +/** + * Presentation that displays a touchpad UI on a secondary display. + * Uses the Android Presentation API which is specifically designed for secondary displays. + */ +@RequiresApi(api = Build.VERSION_CODES.R) +public class TouchpadPresentation extends Presentation { + + private final PreferenceConfiguration prefConfig; + private FrameLayout rootLayout; + + public TouchpadPresentation(Context outerContext, Display display, PreferenceConfiguration config) { + super(outerContext, display); + this.prefConfig = config; + LimeLog.info("TouchpadPresentation - Created for display: " + display.getDisplayId()); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + LimeLog.info("TouchpadPresentation - onCreate called"); + + // Request fullscreen + requestWindowFeature(Window.FEATURE_NO_TITLE); + Window window = getWindow(); + if (window != null) { + window.setFlags( + WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN + ); + + // Hide system bars + WindowInsetsControllerCompat insetsController = WindowCompat.getInsetsController(window, window.getDecorView()); + insetsController.setSystemBarsBehavior(WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + insetsController.hide(WindowInsetsCompat.Type.systemBars()); + insetsController.hide(WindowInsetsCompat.Type.navigationBars()); + + WindowCompat.setDecorFitsSystemWindows(window, false); + } + + createUI(); + + LimeLog.info("TouchpadPresentation - UI created successfully"); + } + + @SuppressLint("ClickableViewAccessibility") + private void createUI() { + // Create root layout with keyboard input support + rootLayout = new KeyboardSupportFrameLayout(getContext()); + rootLayout.setLayoutParams(new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + rootLayout.setBackgroundColor(0xFF1A1A1A); // Dark gray background + rootLayout.setFocusable(true); + rootLayout.setFocusableInTouchMode(true); + rootLayout.requestFocus(); // Request focus so keyboard can attach + + // Set up touch handling to forward events to Game + rootLayout.setOnTouchListener((v, event) -> { + if (Game.instance != null) { + // Forward touch events to Game's handleMotionEvent + return Game.instance.handleMotionEvent(v, event); + } + return false; + }); + + // Add control buttons + addControlButtons(); + + setContentView(rootLayout); + } + + private void addControlButtons() { + int buttonSize = 80; // dp + int margin = 20; // dp + float density = getContext().getResources().getDisplayMetrics().density; + int buttonSizePx = (int) (buttonSize * density); + int marginPx = (int) (margin * density); + + // Top-right: Menu and Close buttons + LinearLayout topRightButtons = createButtonContainer(Gravity.TOP | Gravity.END, marginPx); + ImageButton menuButton = createImageButton(R.drawable.ic_menu_external, buttonSizePx, v -> { + LimeLog.info("TouchpadPresentation - Menu button clicked"); + if (Game.instance != null) { + Game.instance.showGameMenu(null); + } + }); + ImageButton closeButton = createImageButton(R.drawable.ic_close, buttonSizePx, v -> { + LimeLog.info("TouchpadPresentation - Close button clicked"); + if (Game.instance != null) { + Game.instance.finish(); + } + dismiss(); + }); + topRightButtons.addView(menuButton); + topRightButtons.addView(closeButton); + rootLayout.addView(topRightButtons); + + // Bottom-right: Android keyboard toggle + LinearLayout bottomRightButtons = createButtonContainer(Gravity.BOTTOM | Gravity.END, marginPx); + ImageButton keyboardButton = createImageButton(R.drawable.ic_android_keyboard, buttonSizePx, v -> { + LimeLog.info("TouchpadPresentation - Keyboard button clicked"); + // Show the Android soft keyboard, ensuring it's bound to our rootLayout + rootLayout.requestFocus(); + InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.showSoftInput(rootLayout, InputMethodManager.SHOW_FORCED); + } + }); + bottomRightButtons.addView(keyboardButton); + rootLayout.addView(bottomRightButtons); + + LimeLog.info("TouchpadPresentation - Control buttons added"); + } + + private LinearLayout createButtonContainer(int gravity, int margin) { + LinearLayout container = new LinearLayout(getContext()); + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ); + params.gravity = gravity; + params.setMargins(margin, margin, margin, margin); + container.setLayoutParams(params); + container.setOrientation(LinearLayout.HORIZONTAL); + container.setFocusable(false); + return container; + } + + private ImageButton createImageButton(int drawableRes, int sizePx, View.OnClickListener listener) { + ImageButton button = new ImageButton(getContext()); + button.setImageResource(drawableRes); + // Use a simple background for now + button.setBackgroundColor(0x80000000); // Semi-transparent black + button.setScaleType(ImageButton.ScaleType.FIT_CENTER); + button.setOnClickListener(listener); + button.setFocusable(false); + + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(sizePx, sizePx); + params.setMargins(10, 10, 10, 10); + button.setLayoutParams(params); + + return button; + } + + @Override + protected void onStart() { + super.onStart(); + LimeLog.info("TouchpadPresentation - onStart"); + } + + @Override + protected void onStop() { + super.onStop(); + LimeLog.info("TouchpadPresentation - onStop"); + // Don't automatically dismiss - let Game activity manage lifecycle + // The presentation will be recreated in Game.onStart() if needed + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + // Forward hardware keyboard events to Game instance + if (Game.instance != null) { + return Game.instance.onKeyDown(keyCode, event); + } + return super.onKeyDown(keyCode, event); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + // Forward hardware keyboard events to Game instance + if (Game.instance != null) { + return Game.instance.onKeyUp(keyCode, event); + } + return super.onKeyUp(keyCode, event); + } + + @Override + public void dismiss() { + LimeLog.info("TouchpadPresentation - dismiss called"); + super.dismiss(); + // Notify Game that presentation was dismissed + if (Game.instance != null) { + Game.instance.onTouchpadPresentationDismissed(); + } + } + + /** + * Custom FrameLayout that supports keyboard input via InputConnection. + * This allows the soft keyboard to stay open and send key events to the game. + * Based on ExternalControllerView implementation. + */ + private static class KeyboardSupportFrameLayout extends FrameLayout { + public KeyboardSupportFrameLayout(@NonNull Context context) { + super(context); + } + + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + // Allow back button to dismiss keyboard + if (keyCode == KeyEvent.KEYCODE_BACK) { + return false; + } + + // Forward hardware keyboard events to Game + if (Game.instance != null) { + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (Game.instance.handleKeyDown(event)) { + return true; + } + } + else if (event.getAction() == KeyEvent.ACTION_UP) { + if (Game.instance.handleKeyUp(event)) { + return true; + } + } + } + return super.onKeyPreIme(keyCode, event); + } + + @Override + public boolean onCheckIsTextEditor() { + // Tell Android this view can receive text input + return true; + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + // Configure the IME for immediate character input (no buffering) + // Use password type to disable predictions and autocomplete + outAttrs.inputType = android.text.InputType.TYPE_CLASS_TEXT | + android.text.InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | + android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; + outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI | + EditorInfo.IME_FLAG_NO_FULLSCREEN; + + // Return a BaseInputConnection for commit text and delete operations + return new BaseInputConnection(this, false) { + @Override + public boolean setComposingText(CharSequence text, int newCursorPosition) { + // Convert composing text to immediate commit for live typing + return commitText(text, newCursorPosition); + } + + @Override + public boolean commitText(CharSequence text, int newCursorPosition) { + // Send text directly to the streaming connection + if (Game.instance != null && Game.instance.conn != null) { + try { + for (int i = 0; i < text.length(); i++) { + Game.instance.conn.sendUtf8Text(String.valueOf(text.charAt(i))); + } + return true; + } catch (Exception e) { + LimeLog.severe("TouchpadPresentation - Error sending text: " + e.getMessage()); + } + } + return false; + } + + @Override + public boolean deleteSurroundingText(int beforeLength, int afterLength) { + // Send backspace events for deleted characters + if (Game.instance != null && Game.instance.getKeyboardTranslator() != null && Game.instance.conn != null) { + try { + short backspaceCode = Game.instance.getKeyboardTranslator().translate(KeyEvent.KEYCODE_DEL, 0, -1); + for (int i = 0; i < beforeLength; i++) { + Game.instance.conn.sendKeyboardInput(backspaceCode, + KeyboardPacket.KEY_DOWN, (byte)0, (byte)0); + Game.instance.conn.sendKeyboardInput(backspaceCode, + KeyboardPacket.KEY_UP, (byte)0, (byte)0); + } + return true; + } catch (Exception e) { + LimeLog.severe("TouchpadPresentation - Error sending backspace: " + e.getMessage()); + } + } + return false; + } + }; + } + } +} diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 763ed88197..cb7135f060 100755 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -200,4 +200,5 @@ right + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 95f64c92a5..7feaa5eb13 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -535,6 +535,10 @@ Set the duration to hold on the trackpad before activating drag-and-drop on Samsung devices. Swap X and Y axis of the trackpad input On some devices, trackpad input may be rotated in axis. Enabling this option may help. + Use secondary screen as touchpad + When a secondary screen is detected, use one display as a touchpad for controlling the mouse on the game stream + Touchpad display + Select which display acts as the touchpad Force QWERTY layout Force use QWERTY keyboard layout.\nRequired by GFE.\nTurn off if you want to use a different keyboard layout. Back button as Meta @@ -627,6 +631,9 @@ Server Commands Fetch Clipboard Upload Clipboard + Toggle Touchpad Display + Touchpad shown + Touchpad hidden No Server Commands Please make sure you\'re using Apollo as server software and has been granted this client with "Server Command" permission. Virtual display driver isn\'t ready on the server. Please make sure the SudoVDA driver is installed and enabled. diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 5b009c891f..408bd411d6 100755 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -450,6 +450,19 @@ android:summary="@string/summary_trackpad_swap_axis" android:title="@string/title_trackpad_swap_axis" app:iconSpaceReserved="false" /> + +