From 5acdefa936ea7f37eadbc84a1fa0b6037e8f321b Mon Sep 17 00:00:00 2001 From: qiin <414382190@qq.com> Date: Wed, 19 Aug 2026 21:38:06 +0800 Subject: [PATCH 1/3] fix(input): add tap-to-click to screen DS5 touchpad MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tap-to-click is now the primary click trigger, mirroring the verified HarmonyOS implementation: a single-finger lift within 250ms and 24dp of the press point presses the clickpad and auto-releases after 50ms. The firm-press detector (pressure / contact size / deep-press) stays as a supplementary trigger for devices that report real pressure, and a gesture that already firm-pressed never double-fires a tap click. The clickpad button state is now owned by a single press/release pair shared by both triggers, so pending auto-releases cannot stack across gestures. Also advertise a standard gamepad button set on the bare virtual DS5 declaration — previously it carried only the touchpad flag, so hosts created a DualSense with no face buttons. Co-Authored-By: Claude Sonnet 5 --- .../java/com/limelight/TouchInputHandler.kt | 104 +++++++++++++++--- .../binding/input/ControllerHandler.kt | 18 ++- .../touchpad/ScreenDs5TapClickDetector.kt | 64 +++++++++++ app/src/main/res/values-zh-rCN/strings.xml | 4 +- app/src/main/res/values/strings.xml | 4 +- .../touchpad/ScreenDs5TapClickDetectorTest.kt | 59 ++++++++++ 6 files changed, 233 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/limelight/binding/input/touchpad/ScreenDs5TapClickDetector.kt create mode 100644 app/src/test/java/com/limelight/binding/input/touchpad/ScreenDs5TapClickDetectorTest.kt diff --git a/app/src/main/java/com/limelight/TouchInputHandler.kt b/app/src/main/java/com/limelight/TouchInputHandler.kt index 5a3fc69658..141db295ab 100644 --- a/app/src/main/java/com/limelight/TouchInputHandler.kt +++ b/app/src/main/java/com/limelight/TouchInputHandler.kt @@ -3,6 +3,8 @@ package com.limelight import android.graphics.Point import android.os.Build +import android.os.Handler +import android.os.Looper import android.view.HapticFeedbackConstants import android.view.InputDevice import android.view.MotionEvent @@ -15,6 +17,7 @@ import com.limelight.binding.input.touch.RelativeTouchContext import com.limelight.binding.input.touch.TouchContext import com.limelight.binding.input.touchpad.NonRootTouchpadHandler import com.limelight.binding.input.touchpad.ScreenDs5PressureClickDetector +import com.limelight.binding.input.touchpad.ScreenDs5TapClickDetector import com.limelight.binding.input.virtual_controller.VirtualController import com.limelight.nvstream.NvConnection import com.limelight.nvstream.input.MouseButtonPacket @@ -96,6 +99,12 @@ class TouchInputHandler(private val game: Game) { private val penPointerCoords = MotionEvent.PointerCoords() private val screenDs5PressureClickDetector = ScreenDs5PressureClickDetector() private var screenDs5PressurePointerId = MotionEvent.INVALID_POINTER_ID + private val screenDs5TapClickDetector = ScreenDs5TapClickDetector( + movementThresholdPx = SCREEN_DS5_TAP_MOVEMENT_DP * game.resources.displayMetrics.density, + ) + private var screenDs5PressureClickFired = false + private val screenDs5ClickHandler = Handler(Looper.getMainLooper()) + private var screenDs5ClickReleaseCallback: Runnable? = null // ---- 公共入口 ---- @@ -1128,40 +1137,101 @@ class TouchInputHandler(private val game: Game) { } private fun updateScreenDs5TouchpadPress(view: View, event: MotionEvent) { - val transition = when (event.actionMasked) { + when (event.actionMasked) { MotionEvent.ACTION_DOWN -> { - screenDs5PressurePointerId = event.getPointerId(event.actionIndex) + val pointerId = event.getPointerId(event.actionIndex) + screenDs5PressurePointerId = pointerId + screenDs5PressureClickFired = false + screenDs5TapClickDetector.onDown( + pointerId, + event.getX(event.actionIndex), + event.getY(event.actionIndex), + event.eventTime, + ) screenDs5PressureClickDetector.begin( event.getPressure(event.actionIndex), event.getSize(event.actionIndex), event.isDeepPress(), - ) + )?.let { onFirmPressTransition(view, it) } + } + MotionEvent.ACTION_MOVE -> { + val pointerIndex = event.findPointerIndex(screenDs5PressurePointerId) + if (pointerIndex >= 0) { + screenDs5TapClickDetector.onMove( + screenDs5PressurePointerId, + event.getX(pointerIndex), + event.getY(pointerIndex), + ) + } + updateTrackedScreenPressure(event)?.let { onFirmPressTransition(view, it) } + } + MotionEvent.ACTION_POINTER_DOWN -> { + screenDs5TapClickDetector.onPointerDown() + updateTrackedScreenPressure(event)?.let { onFirmPressTransition(view, it) } } - MotionEvent.ACTION_MOVE, - MotionEvent.ACTION_POINTER_DOWN -> updateTrackedScreenPressure(event) MotionEvent.ACTION_POINTER_UP -> { if (event.getPointerId(event.actionIndex) == screenDs5PressurePointerId) { screenDs5PressurePointerId = MotionEvent.INVALID_POINTER_ID - screenDs5PressureClickDetector.end() + screenDs5TapClickDetector.cancel() + screenDs5PressureClickDetector.end()?.let { onFirmPressTransition(view, it) } } else { - updateTrackedScreenPressure(event) + updateTrackedScreenPressure(event)?.let { onFirmPressTransition(view, it) } + } + } + MotionEvent.ACTION_UP -> { + screenDs5PressurePointerId = MotionEvent.INVALID_POINTER_ID + screenDs5PressureClickDetector.end()?.let { onFirmPressTransition(view, it) } + // A firm press already clicked this gesture; don't double-fire on lift. + val tapClicked = !screenDs5PressureClickFired && screenDs5TapClickDetector.onUp( + event.getPointerId(event.actionIndex), + event.getX(event.actionIndex), + event.getY(event.actionIndex), + event.eventTime, + ) + releaseScreenDs5Click() + if (tapClicked) { + pressScreenDs5Click(view, autoRelease = true) } } - MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + screenDs5TapClickDetector.cancel() + releaseScreenDs5Click() screenDs5PressurePointerId = MotionEvent.INVALID_POINTER_ID - screenDs5PressureClickDetector.end() + screenDs5PressureClickDetector.end()?.let { onFirmPressTransition(view, it) } } - else -> null - } ?: return + } + } - game.controllerHandler.setScreenDs5TouchpadPressed(transition) - game.ds5TouchpadFeedbackView?.setTouchpadPressed(transition) - if (transition) { - view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + private fun onFirmPressTransition(view: View, pressed: Boolean) { + if (pressed) { + screenDs5PressureClickFired = true + pressScreenDs5Click(view, autoRelease = false) + } else { + releaseScreenDs5Click() } } + /** Single owner of the clickpad button state shared by tap and firm-press triggers. */ + private fun pressScreenDs5Click(view: View, autoRelease: Boolean) { + releaseScreenDs5Click() + game.controllerHandler.setScreenDs5TouchpadPressed(true) + game.ds5TouchpadFeedbackView?.setTouchpadPressed(true) + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + if (autoRelease) { + screenDs5ClickReleaseCallback = Runnable { + screenDs5ClickReleaseCallback = null + releaseScreenDs5Click() + }.also { screenDs5ClickHandler.postDelayed(it, SCREEN_DS5_CLICK_RELEASE_MS) } + } + } + + private fun releaseScreenDs5Click() { + screenDs5ClickReleaseCallback?.let { screenDs5ClickHandler.removeCallbacks(it) } + screenDs5ClickReleaseCallback = null + game.controllerHandler.setScreenDs5TouchpadPressed(false) + game.ds5TouchpadFeedbackView?.setTouchpadPressed(false) + } + private fun updateTrackedScreenPressure(event: MotionEvent): Boolean? { val pointerIndex = event.findPointerIndex(screenDs5PressurePointerId) if (pointerIndex < 0) return screenDs5PressureClickDetector.end() @@ -1272,6 +1342,10 @@ class TouchInputHandler(private val game: Game) { const val TOUCH_CONTEXT_LENGTH = 2 private const val TWO_FINGER_TAP_THRESHOLD = 100L private const val TWO_FINGER_MOVE_THRESHOLD = 40f + // Matches the verified HarmonyOS DS5 touchpad behavior (24vp tap slop, + // 50ms clickpad hold). + private const val SCREEN_DS5_TAP_MOVEMENT_DP = 24f + private const val SCREEN_DS5_CLICK_RELEASE_MS = 50L private const val STYLUS_DOWN_DEAD_ZONE_DELAY = 100L private const val STYLUS_DOWN_DEAD_ZONE_RADIUS = 20f private const val STYLUS_UP_DEAD_ZONE_DELAY = 150L diff --git a/app/src/main/java/com/limelight/binding/input/ControllerHandler.kt b/app/src/main/java/com/limelight/binding/input/ControllerHandler.kt index 24b821ecd7..e415ba6c38 100644 --- a/app/src/main/java/com/limelight/binding/input/ControllerHandler.kt +++ b/app/src/main/java/com/limelight/binding/input/ControllerHandler.kt @@ -51,6 +51,18 @@ class ControllerHandler( companion object { private const val MAXIMUM_BUMPER_UP_DELAY_MS = 100 + // Mirrors the HarmonyOS client's STANDARD_BUTTON_FLAGS for virtual pads. + private val STANDARD_GAMEPAD_BUTTON_FLAGS = ( + ControllerPacket.A_FLAG or ControllerPacket.B_FLAG or + ControllerPacket.X_FLAG or ControllerPacket.Y_FLAG or + ControllerPacket.UP_FLAG or ControllerPacket.DOWN_FLAG or + ControllerPacket.LEFT_FLAG or ControllerPacket.RIGHT_FLAG or + ControllerPacket.LB_FLAG or ControllerPacket.RB_FLAG or + ControllerPacket.LS_CLK_FLAG or ControllerPacket.RS_CLK_FLAG or + ControllerPacket.PLAY_FLAG or ControllerPacket.BACK_FLAG or + ControllerPacket.SPECIAL_BUTTON_FLAG + ) + const val START_DOWN_TIME_MOUSE_MODE_MS = 750 const val MINIMUM_BUTTON_DOWN_TIME_MS = 25 @@ -1328,7 +1340,11 @@ class ControllerHandler( return if (controllerNumber == 0 && prefConfig.screenDs5Touchpad) { baseMetadata.copy( type = MoonBridge.LI_CTYPE_PS, - supportedButtonFlags = baseMetadata.supportedButtonFlags or ControllerPacket.TOUCHPAD_FLAG, + // A bare virtual declaration carries no buttons of its own; a screen + // DS5 must still advertise a normal gamepad or the host creates a + // DualSense with no face buttons (matches the HarmonyOS client). + supportedButtonFlags = baseMetadata.supportedButtonFlags or + STANDARD_GAMEPAD_BUTTON_FLAGS or ControllerPacket.TOUCHPAD_FLAG, capabilities = (baseMetadata.capabilities.toInt() or MoonBridge.LI_CCAP_TOUCHPAD.toInt() or MoonBridge.LI_CCAP_PREFER_DS5.toInt()).toShort(), diff --git a/app/src/main/java/com/limelight/binding/input/touchpad/ScreenDs5TapClickDetector.kt b/app/src/main/java/com/limelight/binding/input/touchpad/ScreenDs5TapClickDetector.kt new file mode 100644 index 0000000000..61d6025d9d --- /dev/null +++ b/app/src/main/java/com/limelight/binding/input/touchpad/ScreenDs5TapClickDetector.kt @@ -0,0 +1,64 @@ +package com.limelight.binding.input.touchpad + +import kotlin.math.abs + +/** + * Decides whether a finger lift completes a DualSense clickpad tap. + * + * Mirrors the verified HarmonyOS behavior: a tap is a single-finger contact + * that stays short (<= [timeThresholdMs]) and still (<= [movementThresholdPx] + * from the press point). A second finger or drifting movement disqualifies the + * gesture, and eligibility never returns once lost. + */ +internal class ScreenDs5TapClickDetector( + private val movementThresholdPx: Float, + private val timeThresholdMs: Long = DEFAULT_TAP_TIME_MS, +) { + private var pointerId = -1 + private var startX = 0f + private var startY = 0f + private var startTime = 0L + private var tapEligible = false + + fun onDown(pointerId: Int, x: Float, y: Float, eventTime: Long) { + this.pointerId = pointerId + startX = x + startY = y + startTime = eventTime + tapEligible = true + } + + /** A second finger landing disqualifies the whole gesture. */ + fun onPointerDown() { + tapEligible = false + } + + fun onMove(pointerId: Int, x: Float, y: Float) { + if (pointerId != this.pointerId) return + if (exceedsMovement(x, y)) tapEligible = false + } + + /** Returns true when the lift completes a valid tap; consumes the gesture either way. */ + fun onUp(pointerId: Int, x: Float, y: Float, eventTime: Long): Boolean { + val isTap = tapEligible && + pointerId == this.pointerId && + eventTime - startTime <= timeThresholdMs && + !exceedsMovement(x, y) + reset() + return isTap + } + + fun cancel() = reset() + + private fun exceedsMovement(x: Float, y: Float) = + abs(x - startX) > movementThresholdPx || abs(y - startY) > movementThresholdPx + + private fun reset() { + pointerId = -1 + tapEligible = false + } + + private companion object { + const val DEFAULT_TAP_TIME_MS = 250L + } +} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 394392b61f..f0658bb2b0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -855,7 +855,7 @@ 适合蓝牙、USB 鼠标和键盘触摸板 DS5 触控板 DS5 - 屏幕作为 DualSense 手柄的触控板,用力按压即可点击 + 屏幕作为 DualSense 手柄的触控板,轻点或用力按压即可点击 主机不支持 DS5 触控板,触摸将按原触控方式处理 当前:%1$s 双击并按住拖动 @@ -1421,7 +1421,7 @@ 王冠未启用 触控输入 DS5 触控板已激活 - 触摸移动 · 用力按压即可点击 + 触摸移动 · 轻点即可点击 触控已开启 触控已关闭 无法访问控制器 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ec90516558..03dae30be8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1059,7 +1059,7 @@ For Bluetooth, USB mice, and keyboard touchpads DS5 touchpad DS5 - Screen becomes a DualSense touchpad; firm press to click + Screen becomes a DualSense touchpad; tap or firm press to click Host doesn\'t support DS5 touchpad; touches fall back to the previous mode Current: %1$s Double-tap and hold to drag @@ -1688,7 +1688,7 @@ Tap again to replace it. Crown is off Touch Input DS5 touchpad active - Touch to move · Firm press to click + Touch to move · Tap to click Touch enabled Touch disabled Cannot access controller diff --git a/app/src/test/java/com/limelight/binding/input/touchpad/ScreenDs5TapClickDetectorTest.kt b/app/src/test/java/com/limelight/binding/input/touchpad/ScreenDs5TapClickDetectorTest.kt new file mode 100644 index 0000000000..d42f47b173 --- /dev/null +++ b/app/src/test/java/com/limelight/binding/input/touchpad/ScreenDs5TapClickDetectorTest.kt @@ -0,0 +1,59 @@ +package com.limelight.binding.input.touchpad + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ScreenDs5TapClickDetectorTest { + private val detector = ScreenDs5TapClickDetector(movementThresholdPx = 24f) + + @Test + fun quickStillTapClicks() { + detector.onDown(0, 100f, 200f, 1_000L) + detector.onMove(0, 108f, 212f) + assertTrue(detector.onUp(0, 110f, 215f, 1_200L)) + } + + @Test + fun slowLiftDoesNotClick() { + detector.onDown(0, 100f, 200f, 1_000L) + assertFalse(detector.onUp(0, 100f, 200f, 1_300L)) + } + + @Test + fun driftingFingerDoesNotClick() { + detector.onDown(0, 100f, 200f, 1_000L) + detector.onMove(0, 140f, 240f) + assertFalse(detector.onUp(0, 150f, 250f, 1_100L)) + } + + @Test + fun secondFingerDisqualifiesTap() { + detector.onDown(0, 100f, 200f, 1_000L) + detector.onPointerDown() + assertFalse(detector.onUp(0, 100f, 200f, 1_100L)) + } + + @Test + fun eligibilityNeverReturnsAfterMovement() { + detector.onDown(0, 100f, 200f, 1_000L) + detector.onMove(0, 140f, 200f) + detector.onMove(0, 100f, 200f) + assertFalse(detector.onUp(0, 100f, 200f, 1_100L)) + } + + @Test + fun cancelResetsGesture() { + detector.onDown(0, 100f, 200f, 1_000L) + detector.cancel() + assertFalse(detector.onUp(0, 100f, 200f, 1_100L)) + } + + @Test + fun upConsumesGestureForNextFinger() { + detector.onDown(0, 100f, 200f, 1_000L) + assertTrue(detector.onUp(0, 100f, 200f, 1_100L)) + // A stale second-lift event for the consumed gesture must not click. + assertFalse(detector.onUp(0, 100f, 200f, 1_150L)) + } +} From 92c41bd002b9d1c47b5f38ac86826f79597d2cba Mon Sep 17 00:00:00 2001 From: qiin <414382190@qq.com> Date: Wed, 19 Aug 2026 21:52:11 +0800 Subject: [PATCH 2/3] fix(input): advertise analog triggers and rumble on virtual DS5 Aligns the bare screen-DS5 declaration's capabilities with the verified HarmonyOS client (LI_CCAP_ANALOG_TRIGGERS | LI_CCAP_RUMBLE | TOUCHPAD). Sunshine currently gates only on TOUCHPAD/ACCEL/GYRO/RGB_LED, so this is forward-proofing rather than a behavior fix. Co-Authored-By: Claude Sonnet 5 --- .../main/java/com/limelight/binding/input/ControllerHandler.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/limelight/binding/input/ControllerHandler.kt b/app/src/main/java/com/limelight/binding/input/ControllerHandler.kt index e415ba6c38..8ba741cee4 100644 --- a/app/src/main/java/com/limelight/binding/input/ControllerHandler.kt +++ b/app/src/main/java/com/limelight/binding/input/ControllerHandler.kt @@ -1346,6 +1346,8 @@ class ControllerHandler( supportedButtonFlags = baseMetadata.supportedButtonFlags or STANDARD_GAMEPAD_BUTTON_FLAGS or ControllerPacket.TOUCHPAD_FLAG, capabilities = (baseMetadata.capabilities.toInt() or + MoonBridge.LI_CCAP_ANALOG_TRIGGERS.toInt() or + MoonBridge.LI_CCAP_RUMBLE.toInt() or MoonBridge.LI_CCAP_TOUCHPAD.toInt() or MoonBridge.LI_CCAP_PREFER_DS5.toInt()).toShort(), ) From d1882e08bf3cdaed5fbb0d0ee54d697d73259472 Mon Sep 17 00:00:00 2001 From: qiin <414382190@qq.com> Date: Wed, 19 Aug 2026 22:19:20 +0800 Subject: [PATCH 3/3] fix(input): suppress tap-click on system-canceled lifts getLiTouchTypeFromEvent() already maps ACTION_UP carrying MotionEvent.FLAG_CANCELED to a cancel touch event, but the tap-click state machine still evaluated the lift as a tap, so a system-canceled final touch (palm rejection, gesture takeover) could fire a remote click. Mirror the ACTION_CANCEL handling before tap evaluation. Co-Authored-By: Claude Sonnet 5 --- app/src/main/java/com/limelight/TouchInputHandler.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/java/com/limelight/TouchInputHandler.kt b/app/src/main/java/com/limelight/TouchInputHandler.kt index 141db295ab..60ae66d6e6 100644 --- a/app/src/main/java/com/limelight/TouchInputHandler.kt +++ b/app/src/main/java/com/limelight/TouchInputHandler.kt @@ -1179,6 +1179,14 @@ class TouchInputHandler(private val game: Game) { } } MotionEvent.ACTION_UP -> { + if (event.flags and MotionEvent.FLAG_CANCELED != 0) { + // A system-canceled final lift (palm rejection etc.) must not tap-click. + screenDs5TapClickDetector.cancel() + releaseScreenDs5Click() + screenDs5PressurePointerId = MotionEvent.INVALID_POINTER_ID + screenDs5PressureClickDetector.end()?.let { onFirmPressTransition(view, it) } + return + } screenDs5PressurePointerId = MotionEvent.INVALID_POINTER_ID screenDs5PressureClickDetector.end()?.let { onFirmPressTransition(view, it) } // A firm press already clicked this gesture; don't double-fire on lift.