Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 89 additions & 15 deletions app/src/main/java/com/limelight/TouchInputHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

// ---- 公共入口 ----

Expand Down Expand Up @@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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()
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1328,8 +1340,14 @@ 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_ANALOG_TRIGGERS.toInt() or
MoonBridge.LI_CCAP_RUMBLE.toInt() or
MoonBridge.LI_CCAP_TOUCHPAD.toInt() or
MoonBridge.LI_CCAP_PREFER_DS5.toInt()).toShort(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
4 changes: 2 additions & 2 deletions app/src/main/res/values-zh-rCN/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -855,7 +855,7 @@
<string name="game_menu_touch_mode_native_mouse_summary">适合蓝牙、USB 鼠标和键盘触摸板</string>
<string name="game_menu_touch_mode_ds5">DS5 触控板</string>
<string name="game_menu_touch_mode_ds5_short">DS5</string>
<string name="game_menu_touch_mode_ds5_summary">屏幕作为 DualSense 手柄的触控板,用力按压即可点击</string>
<string name="game_menu_touch_mode_ds5_summary">屏幕作为 DualSense 手柄的触控板,轻点或用力按压即可点击</string>
<string name="toast_ds5_touchpad_unsupported">主机不支持 DS5 触控板,触摸将按原触控方式处理</string>
<string name="game_menu_current_selection">当前:%1$s</string>
<string name="game_menu_trackpad_double_click_drag">双击并按住拖动</string>
Expand Down Expand Up @@ -1421,7 +1421,7 @@
<string name="toast_crown_not_enabled">王冠未启用</string>
<string name="game_menu_toggle_touch">触控输入</string>
<string name="ds5_touchpad_active_title">DS5 触控板已激活</string>
<string name="ds5_touchpad_active_subtitle">触摸移动 · 用力按压即可点击</string>
<string name="ds5_touchpad_active_subtitle">触摸移动 · 轻点即可点击</string>
<string name="toast_touch_enabled">触控已开启</string>
<string name="toast_touch_disabled">触控已关闭</string>
<string name="toast_cannot_access_controller">无法访问控制器</string>
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1059,7 +1059,7 @@
<string name="game_menu_touch_mode_native_mouse_summary">For Bluetooth, USB mice, and keyboard touchpads</string>
<string name="game_menu_touch_mode_ds5">DS5 touchpad</string>
<string name="game_menu_touch_mode_ds5_short">DS5</string>
<string name="game_menu_touch_mode_ds5_summary">Screen becomes a DualSense touchpad; firm press to click</string>
<string name="game_menu_touch_mode_ds5_summary">Screen becomes a DualSense touchpad; tap or firm press to click</string>
<string name="toast_ds5_touchpad_unsupported">Host doesn\'t support DS5 touchpad; touches fall back to the previous mode</string>
<string name="game_menu_current_selection">Current: %1$s</string>
<string name="game_menu_trackpad_double_click_drag">Double-tap and hold to drag</string>
Expand Down Expand Up @@ -1688,7 +1688,7 @@ Tap again to replace it.</string>
<string name="toast_crown_not_enabled">Crown is off</string>
<string name="game_menu_toggle_touch">Touch Input</string>
<string name="ds5_touchpad_active_title">DS5 touchpad active</string>
<string name="ds5_touchpad_active_subtitle">Touch to move · Firm press to click</string>
<string name="ds5_touchpad_active_subtitle">Touch to move · Tap to click</string>
<string name="toast_touch_enabled">Touch enabled</string>
<string name="toast_touch_disabled">Touch disabled</string>
<string name="toast_cannot_access_controller">Cannot access controller</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading