diff --git a/app/src/main/java/com/limelight/binding/input/ControllerContext.kt b/app/src/main/java/com/limelight/binding/input/ControllerContext.kt index c7eac47415..3db9c3e203 100644 --- a/app/src/main/java/com/limelight/binding/input/ControllerContext.kt +++ b/app/src/main/java/com/limelight/binding/input/ControllerContext.kt @@ -461,6 +461,9 @@ class UsbDeviceContext(handler: ControllerHandler) : GenericControllerContext(ha internal var lastReportedBatteryState: Byte? = null internal var lastReportedBatteryPercentage: Byte? = null internal val menuKeyDownTimes = ConcurrentHashMap() + // Use a map instead of ConcurrentHashMap.newKeySet(), which requires API 24. + internal val forwardedTouchPointerIds = ConcurrentHashMap() + internal var touchCaptureActive: Boolean = false internal val shortcutState = UsbControllerShortcutStateMachine() internal val shortcutLongPressRunnable = Runnable { handler.onUsbShortcutLongPress(this) @@ -468,6 +471,8 @@ class UsbDeviceContext(handler: ControllerHandler) : GenericControllerContext(ha override fun destroy() { menuKeyDownTimes.clear() + forwardedTouchPointerIds.clear() + touchCaptureActive = false handler.releaseUsbShortcutState(this) super.destroy() } @@ -475,6 +480,9 @@ class UsbDeviceContext(handler: ControllerHandler) : GenericControllerContext(ha override fun onGameMenuDismissed() { menuKeyDownTimes.clear() shortcutState.onGameMenuUnavailable() + if (!shortcutState.isLocalInputCaptureActive()) { + handler.onUsbLocalCaptureEnded(this) + } } override fun sendControllerArrival(): Int { 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 9a7f766f03..cfeb790d49 100644 --- a/app/src/main/java/com/limelight/binding/input/ControllerHandler.kt +++ b/app/src/main/java/com/limelight/binding/input/ControllerHandler.kt @@ -2407,6 +2407,13 @@ class ControllerHandler( context: UsbDeviceContext, update: UsbControllerShortcutStateMachine.Update ) { + val captureStarted = update.consumeAllInput && !context.touchCaptureActive + val captureEnded = !update.consumeAllInput && context.touchCaptureActive + context.touchCaptureActive = update.consumeAllInput + if (captureStarted || captureEnded) { + cancelForwardedUsbTouches(context) + context.device?.resetTouchState() + } for (action in update.actions) { when (action) { UsbControllerShortcutStateMachine.Action.SCHEDULE_LONG_PRESS -> { @@ -2485,6 +2492,13 @@ class ControllerHandler( } } + internal fun onUsbLocalCaptureEnded(context: UsbDeviceContext) { + if (!context.touchCaptureActive) return + context.touchCaptureActive = false + cancelForwardedUsbTouches(context) + context.device?.resetTouchState() + } + fun onExternalGameMenuOpened() { synchronized(usbDeviceContextsLifecycleLock) { if (stopped) return @@ -2724,6 +2738,58 @@ class ControllerHandler( } } + override fun reportControllerTouch( + controllerId: Int, + eventType: Byte, + pointerId: Int, + x: Float, + y: Float + ) { + val context = usbDeviceContexts[controllerId] ?: return + if (prefConfig.multiController && !context.assignedControllerNumber) return + if (context.shortcutState.isLocalInputCaptureActive()) { + cancelForwardedUsbTouches(context) + return + } + + // The Linux DS5 backend distinguishes contact/release via pressure. + val pressure = when (eventType) { + MoonBridge.LI_TOUCH_EVENT_DOWN, MoonBridge.LI_TOUCH_EVENT_MOVE -> 1f + else -> 0f + } + val result = conn.sendControllerTouchEvent( + context.controllerNumber.toByte(), eventType, pointerId, x, y, pressure + ) + if (result == MoonBridge.LI_ERR_UNSUPPORTED) return + + when (eventType) { + MoonBridge.LI_TOUCH_EVENT_DOWN, MoonBridge.LI_TOUCH_EVENT_MOVE -> + context.forwardedTouchPointerIds[pointerId] = true + MoonBridge.LI_TOUCH_EVENT_UP, MoonBridge.LI_TOUCH_EVENT_CANCEL -> + context.forwardedTouchPointerIds.remove(pointerId) + MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL -> context.forwardedTouchPointerIds.clear() + } + } + + private fun cancelForwardedUsbTouches(context: UsbDeviceContext) { + if (context.forwardedTouchPointerIds.isNotEmpty()) { + context.forwardedTouchPointerIds.keys.toList().forEach { pointerId -> + conn.sendControllerTouchEvent( + context.controllerNumber.toByte(), MoonBridge.LI_TOUCH_EVENT_CANCEL, + pointerId, 0f, 0f, 0f + ) + } + } + context.forwardedTouchPointerIds.clear() + context.device?.resetTouchState() + } + + override fun isUsbControllerReady(controllerId: Int): Boolean { + val context = usbDeviceContexts[controllerId] ?: return false + if (prefConfig.multiController && !context.assignedControllerNumber) return false + return context.controllerArrival.isReported + } + // ========== Sensor Management ========== fun handleSetMotionEventState(controllerNumber: Short, motionType: Byte, reportRateHz: Short) { diff --git a/app/src/main/java/com/limelight/binding/input/driver/AbstractController.kt b/app/src/main/java/com/limelight/binding/input/driver/AbstractController.kt index b05c2c58b9..a84274e60d 100644 --- a/app/src/main/java/com/limelight/binding/input/driver/AbstractController.kt +++ b/app/src/main/java/com/limelight/binding/input/driver/AbstractController.kt @@ -75,4 +75,13 @@ abstract class AbstractController( protected fun notifyBatteryState(batteryState: Byte, batteryPercentage: Byte) { listener.reportControllerBattery(deviceId, batteryState, batteryPercentage) } + + protected fun isControllerReady(): Boolean = listener.isUsbControllerReady(deviceId) + + protected fun notifyControllerTouch(eventType: Byte, pointerId: Int, x: Float, y: Float) { + listener.reportControllerTouch(deviceId, eventType, pointerId, x, y) + } + + /** Reset driver-side touch state after the host has received a cancellation. */ + open fun resetTouchState() {} } diff --git a/app/src/main/java/com/limelight/binding/input/driver/DualSenseController.kt b/app/src/main/java/com/limelight/binding/input/driver/DualSenseController.kt index c323f57e80..7b4d44dd38 100644 --- a/app/src/main/java/com/limelight/binding/input/driver/DualSenseController.kt +++ b/app/src/main/java/com/limelight/binding/input/driver/DualSenseController.kt @@ -18,10 +18,19 @@ class DualSenseController( override val supportsAdaptiveTriggers: Boolean = true + private class TouchSlot { + var down = false + var lastX = -1f + var lastY = -1f + } + + private val touchSlots = Array(2) { TouchSlot() } + init { capabilities = (capabilities.toInt() or MoonBridge.LI_CCAP_BATTERY_STATE.toInt() or - MoonBridge.LI_CCAP_RGB_LED.toInt()).toShort() + MoonBridge.LI_CCAP_RGB_LED.toInt() or + MoonBridge.LI_CCAP_TOUCHPAD.toInt()).toShort() } private fun normalizeThumbStickAxis(value: Int): Float { @@ -128,10 +137,63 @@ class DualSenseController( } reportBattery(buffer.get(BATTERY_OFFSET)) + reportTouch(buffer, 0, TOUCH1_COUNTER_OFFSET, TOUCH1_DATA_OFFSET) + reportTouch(buffer, 1, TOUCH2_COUNTER_OFFSET, TOUCH2_DATA_OFFSET) return true } + private fun reportTouch(buffer: ByteBuffer, slotIndex: Int, counterOffset: Int, dataOffset: Int) { + val slot = touchSlots[slotIndex] + + // Don't consume touch state until the controller has reported arrival and + // (in multi-controller mode) been assigned a number; otherwise the first + // DOWN would be dropped by the handler while the stationary finger never + // re-triggers an event. + if (!isControllerReady()) { + return + } + + val down = (buffer.get(counterOffset).toInt() and 0x80) == 0 + + if (!down) { + if (slot.down) { + notifyControllerTouch(MoonBridge.LI_TOUCH_EVENT_UP, slotIndex, slot.lastX, slot.lastY) + } + slot.down = false + return + } + + // Contact position: 12-bit X and Y, normalized by the touchpad panel size. + val d0 = buffer.get(dataOffset).toInt() and 0xFF + val d1 = buffer.get(dataOffset + 1).toInt() and 0xFF + val d2 = buffer.get(dataOffset + 2).toInt() and 0xFF + val x = (d0 or ((d1 and 0x0F) shl 8)) / TOUCHPAD_WIDTH + val y = ((d1 shr 4) or (d2 shl 4)) / TOUCHPAD_HEIGHT + + if (!slot.down) { + notifyControllerTouch(MoonBridge.LI_TOUCH_EVENT_DOWN, slotIndex, x, y) + } else if (x != slot.lastX || y != slot.lastY) { + notifyControllerTouch(MoonBridge.LI_TOUCH_EVENT_MOVE, slotIndex, x, y) + } + slot.down = true + slot.lastX = x + slot.lastY = y + } + + private fun releaseTouchContacts() { + touchSlots.forEachIndexed { index, slot -> + if (slot.down) { + notifyControllerTouch(MoonBridge.LI_TOUCH_EVENT_UP, index, slot.lastX, slot.lastY) + slot.down = false + } + } + } + + override fun resetTouchState() { + touchSlots.forEach { it.down = false } + } + private fun reportBattery(batteryByte: Byte) { val status = (batteryByte.toInt() shr 4) and 0x0F val percentage = ((batteryByte.toInt() and 0x0F) * 10 + 5).coerceAtMost(100).toByte() @@ -149,6 +211,12 @@ class DualSenseController( } } + override fun stop() { + // Release held touchpad contacts so the host doesn't keep a stuck finger. + releaseTouchContacts() + super.stop() + } + override fun doInit(): Boolean { Log.d("DualSenseController", "doInit") sendCommand(getDualSenseInit()) @@ -231,6 +299,14 @@ class DualSenseController( companion object { private const val BATTERY_OFFSET = 53 + // Offsets in the full 64-byte input report (report ID at index 0). SDL's + // PS5StatePacket_t comments exclude the report ID, so wire = struct + 1. + private const val TOUCH1_COUNTER_OFFSET = 33 + private const val TOUCH1_DATA_OFFSET = 34 + private const val TOUCH2_COUNTER_OFFSET = 37 + private const val TOUCH2_DATA_OFFSET = 38 + private const val TOUCHPAD_WIDTH = 1920f + private const val TOUCHPAD_HEIGHT = 1070f private val SUPPORTED_VENDORS = intArrayOf(0x054C, 0x1532) private val SUPPORTED_PRODUCTS = intArrayOf(0x0CE6, 0x0DF2, 0x100b, 0x100c) diff --git a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.kt b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.kt index ddc621de8a..553f24e3e2 100644 --- a/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.kt +++ b/app/src/main/java/com/limelight/binding/input/driver/UsbDriverListener.kt @@ -16,4 +16,18 @@ interface UsbDriverListener { // Report battery state sourced from the USB controller itself fun reportControllerBattery(controllerId: Int, batteryState: Byte, batteryPercentage: Byte) {} + + // Report touchpad touch events sourced from the USB controller itself + fun reportControllerTouch( + controllerId: Int, + eventType: Byte, + pointerId: Int, + x: Float, + y: Float + ) {} + + // Whether the controller has reported arrival and received its controller + // number. Stateful consumers (e.g. touch tracking) must not consume state + // transitions until this is true. + fun isUsbControllerReady(controllerId: Int): Boolean = true }