diff --git a/app/src/androidTest/java/com/limelight/binding/input/touch/ThreeFingerPanZoomGestureTest.kt b/app/src/androidTest/java/com/limelight/binding/input/touch/ThreeFingerPanZoomGestureTest.kt
new file mode 100644
index 0000000000..29c64ddf68
--- /dev/null
+++ b/app/src/androidTest/java/com/limelight/binding/input/touch/ThreeFingerPanZoomGestureTest.kt
@@ -0,0 +1,250 @@
+package com.limelight.binding.input.touch
+
+import android.content.Context
+import android.os.Build
+import android.os.SystemClock
+import android.view.InputDevice
+import android.view.MotionEvent
+import android.view.View
+import android.widget.FrameLayout
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.limelight.utils.PanZoomHandler
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ThreeFingerPanZoomGestureTest {
+ private data class Contact(val id: Int, val x: Float, val y: Float = 200f)
+ private val contacts = listOf(Contact(5, 200f), Contact(2, 300f), Contact(9, 400f))
+
+ private class Harness(panZoom: (MotionEvent) -> Unit = {}) {
+ val events = mutableListOf()
+ var keyboardCount = 0
+ val gesture = ThreeFingerPanZoomGesture(
+ movementThreshold = 20f,
+ cancelHostTouches = { events += "cancel-host" },
+ panZoom = { events += "pan-${it.actionMasked}"; panZoom(it) },
+ toggleKeyboard = { keyboardCount++ }
+ )
+ }
+
+ @Test
+ fun onlyThirdPointerDownStartsOwnershipAndCancelsHostOnce() {
+ val h = Harness()
+ assertFalse(send(h, MotionEvent.ACTION_DOWN, 0, contacts.take(1)))
+ assertFalse(send(h, MotionEvent.ACTION_POINTER_DOWN, 10, contacts.take(2), actionIndex = 1))
+ assertFalse(send(h, MotionEvent.ACTION_MOVE, 20, contacts))
+ assertTrue(start(h))
+ assertEquals(listOf("cancel-host", "pan-${MotionEvent.ACTION_DOWN}",
+ "pan-${MotionEvent.ACTION_POINTER_DOWN}", "pan-${MotionEvent.ACTION_POINTER_DOWN}"), h.events)
+ send(h, MotionEvent.ACTION_MOVE, 110, contacts)
+ assertEquals(1, h.events.count { it == "cancel-host" })
+ }
+
+ @Test
+ fun detectorDownSequencePreservesPointerIdsWhenNewContactIsNotLast() {
+ val frames = mutableListOf>>()
+ val h = Harness { event ->
+ frames += event.action to (0 until event.pointerCount).map(event::getPointerId)
+ }
+ send(h, MotionEvent.ACTION_POINTER_DOWN, 100, contacts, actionIndex = 1)
+ assertEquals(listOf(5), frames[0].second)
+ assertEquals(MotionEvent.ACTION_DOWN, frames[0].first)
+ assertEquals(listOf(5, 9), frames[1].second)
+ assertEquals(MotionEvent.ACTION_POINTER_DOWN or (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), frames[1].first)
+ assertEquals(listOf(5, 2, 9), frames[2].second)
+ }
+
+ @Test
+ fun excludedModeDoesNotCaptureOrCancelInput() {
+ val h = Harness()
+ assertFalse(start(h, enabled = false))
+ assertTrue(h.events.isEmpty())
+ assertReleased(h)
+ }
+
+ @Test
+ fun settingChangeDoesNotLeakTailAndNextGestureCanBeDisabled() {
+ val h = Harness()
+ start(h)
+ assertTrue(send(h, MotionEvent.ACTION_POINTER_UP, 150, contacts, enabled = false))
+ assertTrue(send(h, MotionEvent.ACTION_MOVE, 200, contacts.drop(1), enabled = false))
+ assertTrue(send(h, MotionEvent.ACTION_UP, 410, contacts.takeLast(1), enabled = false))
+ assertReleased(h)
+ assertFalse(start(h, enabled = false))
+ assertEquals(0, h.keyboardCount)
+ }
+
+ @Test
+ fun quickTapTogglesOnceOnlyWhenKeyboardGestureIsEnabled() {
+ for (fingers in listOf(3, -1, 4)) {
+ val h = Harness()
+ start(h, fingers = fingers)
+ send(h, MotionEvent.ACTION_POINTER_UP, 150, contacts)
+ send(h, MotionEvent.ACTION_POINTER_UP, 170, contacts.drop(1))
+ send(h, MotionEvent.ACTION_UP, 180, contacts.takeLast(1))
+ assertEquals(if (fingers == 3) 1 else 0, h.keyboardCount)
+ assertReleased(h)
+ }
+ }
+
+ @Test
+ fun tailMovementUsesPointerIdsAndLiftCoordinates() {
+ val h = Harness()
+ start(h)
+ send(h, MotionEvent.ACTION_POINTER_UP, 140, contacts, actionIndex = 0)
+ send(h, MotionEvent.ACTION_POINTER_UP, 160, contacts.drop(1).reversed(), actionIndex = 1)
+ send(h, MotionEvent.ACTION_UP, 180, listOf(Contact(9, 450f)))
+ assertEquals(0, h.keyboardCount)
+ }
+
+ @Test
+ fun historicalMovementCannotBeHiddenByReturningToStart() {
+ val h = Harness()
+ start(h)
+ val move = event(MotionEvent.ACTION_MOVE, 150, contacts.map { it.copy(x = it.x + 50) })
+ try {
+ move.addBatch(1_180, coordinates(contacts), 0)
+ h.gesture.handle(move, enabled = true, keyboardFingers = 3)
+ } finally {
+ move.recycle()
+ }
+ send(h, MotionEvent.ACTION_UP, 200, contacts.takeLast(1))
+ assertEquals(0, h.keyboardCount)
+ }
+
+ @Test
+ fun additionalOrReplacementContactCannotToggleKeyboard() {
+ for (extraContacts in listOf(contacts + Contact(10, 500f), contacts.take(2) + Contact(10, 400f))) {
+ val h = Harness()
+ start(h)
+ send(h, MotionEvent.ACTION_POINTER_DOWN, 150, extraContacts, actionIndex = extraContacts.lastIndex)
+ send(h, MotionEvent.ACTION_UP, 200, contacts.takeLast(1))
+ assertEquals(0, h.keyboardCount)
+ }
+ }
+
+ @Test
+ fun systemCancelAndCaptureLossResetWithoutKeyboard() {
+ val h = Harness()
+ start(h)
+ send(h, MotionEvent.ACTION_CANCEL, 150, contacts)
+ assertReleased(h)
+ start(h)
+ h.gesture.cancel()
+ h.gesture.cancel()
+ assertReleased(h)
+ assertEquals(2, h.events.count { it == "pan-${MotionEvent.ACTION_CANCEL}" })
+ assertEquals(0, h.keyboardCount)
+ assertFalse(send(h, MotionEvent.ACTION_DOWN, 300, contacts.take(1)))
+ }
+
+ @Test
+ fun canceledLiftDoesNotToggleKeyboard() {
+ assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
+ val h = Harness()
+ start(h)
+ send(h, MotionEvent.ACTION_POINTER_UP, 150, contacts, flags = MotionEvent.FLAG_CANCELED)
+ send(h, MotionEvent.ACTION_UP, 180, contacts.takeLast(1))
+ assertEquals(0, h.keyboardCount)
+ }
+
+ @Test
+ fun newDownResetsGestureWithMissingTerminalEvent() {
+ val h = Harness()
+ start(h)
+ assertFalse(send(h, MotionEvent.ACTION_DOWN, 200, contacts.take(1)))
+ assertReleased(h)
+ assertEquals("pan-${MotionEvent.ACTION_CANCEL}", h.events.last())
+ }
+
+ @Test
+ fun realAndroidDetectorsScaleAndPanStreamAndCursorTogether() {
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ val context = ApplicationProvider.getApplicationContext()
+ val density = context.resources.displayMetrics.density
+ val width = (800 * density).toInt()
+ val height = (600 * density).toInt()
+ val parent = FrameLayout(context)
+ val stream = View(context)
+ val cursor = View(context)
+ parent.addView(stream, FrameLayout.LayoutParams(width, height))
+ parent.addView(cursor, FrameLayout.LayoutParams(width, height))
+ parent.measure(
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY)
+ )
+ parent.layout(0, 0, width, height)
+ val panZoom = PanZoomHandler(context, stream, cursor) {}
+ panZoom.handleSurfaceChange()
+ val h = Harness(panZoom::handleTouchEvent)
+ val baseTime = SystemClock.uptimeMillis()
+ fun points(left: Float, middle: Float, right: Float) = listOf(
+ Contact(5, left * density, 200 * density),
+ Contact(2, middle * density, 200 * density),
+ Contact(9, right * density, 200 * density)
+ )
+ send(h, MotionEvent.ACTION_POINTER_DOWN, 100, points(200f, 300f, 400f), actionIndex = 2, baseTime = baseTime)
+ send(h, MotionEvent.ACTION_MOVE, 200, points(100f, 300f, 500f), baseTime = baseTime)
+ send(h, MotionEvent.ACTION_MOVE, 250, points(0f, 300f, 600f), baseTime = baseTime)
+ assertTrue("Pinch changes scale", stream.scaleX > 1f)
+ val oldX = stream.x
+ send(h, MotionEvent.ACTION_MOVE, 280, points(-40f, 260f, 560f), baseTime = baseTime)
+ assertTrue("Translation follows the three-finger movement", stream.x < oldX)
+ assertEquals(stream.scaleX, cursor.scaleX, 0f)
+ assertEquals(stream.x, cursor.x, 0f)
+ assertEquals(stream.y, cursor.y, 0f)
+ send(h, MotionEvent.ACTION_CANCEL, 300, contacts, baseTime = baseTime)
+ }
+ }
+
+ private fun assertReleased(h: Harness) {
+ assertFalse(send(h, MotionEvent.ACTION_MOVE, 500, contacts, enabled = false))
+ }
+
+ private fun start(h: Harness, enabled: Boolean = true, fingers: Int = 3, baseTime: Long = 1_000L) =
+ send(h, MotionEvent.ACTION_POINTER_DOWN, 100, contacts, actionIndex = 2,
+ enabled = enabled, fingers = fingers, baseTime = baseTime)
+
+ private fun send(
+ h: Harness,
+ action: Int,
+ time: Long,
+ points: List,
+ actionIndex: Int = 0,
+ enabled: Boolean = true,
+ fingers: Int = 3,
+ flags: Int = 0,
+ baseTime: Long = 1_000L
+ ): Boolean {
+ val event = event(action, time, points, actionIndex, flags, baseTime)
+ return try {
+ h.gesture.handle(event, enabled, fingers)
+ } finally {
+ event.recycle()
+ }
+ }
+
+ private fun coordinates(points: List) = points.map {
+ MotionEvent.PointerCoords().apply { x = it.x; y = it.y; pressure = 1f; size = 0.1f }
+ }.toTypedArray()
+
+ private fun event(
+ action: Int,
+ time: Long,
+ points: List,
+ actionIndex: Int = 0,
+ flags: Int = 0,
+ baseTime: Long = 1_000L
+ ) = MotionEvent.obtain(
+ baseTime, baseTime + time, action or (actionIndex shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), points.size,
+ points.map { MotionEvent.PointerProperties().apply { id = it.id; toolType = MotionEvent.TOOL_TYPE_FINGER } }.toTypedArray(),
+ coordinates(points), 0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_TOUCHSCREEN, flags
+ )
+}
diff --git a/app/src/androidTest/java/com/limelight/gamemenu/ThreeFingerPanZoomMenuTest.kt b/app/src/androidTest/java/com/limelight/gamemenu/ThreeFingerPanZoomMenuTest.kt
new file mode 100644
index 0000000000..9691fdf51c
--- /dev/null
+++ b/app/src/androidTest/java/com/limelight/gamemenu/ThreeFingerPanZoomMenuTest.kt
@@ -0,0 +1,41 @@
+package com.limelight.gamemenu
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.test.assertIsOff
+import androidx.compose.ui.test.assertIsOn
+import androidx.compose.ui.test.isToggleable
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+
+class ThreeFingerPanZoomMenuTest {
+ @get:Rule
+ val composeRule = createComposeRule()
+
+ @Test
+ fun rowAndSwitchEachToggleOnceAndRenderUpdatedState() {
+ var enabled by mutableStateOf(true)
+ var calls = 0
+ val action = Runnable { enabled = !enabled; calls++ }
+ composeRule.setContent {
+ MenuOptionColumn(
+ options = listOf(threeFingerPanZoomOption("Three-finger pan/zoom", enabled, action)),
+ iconForOption = { 0 },
+ onOptionClick = { it.runnable!!.run() },
+ onInlineToggle = { it.toggleAction!!.run() },
+ onSegmentClick = {}
+ )
+ }
+ composeRule.onNode(isToggleable(), useUnmergedTree = true).assertIsOn()
+ composeRule.onNodeWithText("Three-finger pan/zoom").performClick()
+ composeRule.onNode(isToggleable(), useUnmergedTree = true).assertIsOff()
+ composeRule.runOnIdle { assertEquals(1, calls) }
+ composeRule.onNode(isToggleable(), useUnmergedTree = true).performClick().assertIsOn()
+ composeRule.runOnIdle { assertEquals(2, calls) }
+ }
+}
diff --git a/app/src/main/java/com/limelight/Game.kt b/app/src/main/java/com/limelight/Game.kt
index 075930b839..379151cb09 100644
--- a/app/src/main/java/com/limelight/Game.kt
+++ b/app/src/main/java/com/limelight/Game.kt
@@ -373,7 +373,7 @@ class Game : ComponentActivity(), SurfaceHolder.Callback,
}
val cursorOverlayView = findViewById(R.id.cursorOverlay)
- panZoomHandler = PanZoomHandler(this, this, streamView, cursorOverlayView, prefConfig)
+ panZoomHandler = PanZoomHandler(this, streamView, cursorOverlayView, ::updatePipAutoEnter)
val backgroundTouchView = findViewById(R.id.backgroundTouchView)
backgroundTouchView.setOnTouchListener(this)
@@ -1777,6 +1777,7 @@ class Game : ComponentActivity(), SurfaceHolder.Callback,
} else {
if (::touchInputHandler.isInitialized) {
touchInputHandler.cancelNonRootTouchpad()
+ touchInputHandler.cancelThreeFingerPanZoom()
}
inputCaptureProvider.disableCapture()
}
diff --git a/app/src/main/java/com/limelight/TouchInputHandler.kt b/app/src/main/java/com/limelight/TouchInputHandler.kt
index 4a13ba3a67..a6e76c1d33 100644
--- a/app/src/main/java/com/limelight/TouchInputHandler.kt
+++ b/app/src/main/java/com/limelight/TouchInputHandler.kt
@@ -9,6 +9,7 @@ import android.view.HapticFeedbackConstants
import android.view.InputDevice
import android.view.MotionEvent
import android.view.View
+import android.view.ViewConfiguration
import android.widget.Toast
import androidx.annotation.RequiresApi
import com.limelight.binding.input.touch.AbsoluteTouchContext
@@ -16,6 +17,7 @@ import com.limelight.binding.input.touch.EnhancedTouchGestureRouteOwner
import com.limelight.binding.input.touch.NativeTouchContext
import com.limelight.binding.input.touch.RelativeTouchContext
import com.limelight.binding.input.touch.TouchContext
+import com.limelight.binding.input.touch.ThreeFingerPanZoomGesture
import com.limelight.binding.input.touchpad.NonRootTouchpadHandler
import com.limelight.binding.input.touchpad.ScreenDs5PressureClickDetector
import com.limelight.binding.input.touchpad.ScreenDs5TapClickDetector
@@ -79,6 +81,13 @@ class TouchInputHandler(private val game: Game) {
private var twoFingerStartX = 0f
private var twoFingerStartY = 0f
+ private val threeFingerPanZoom = ThreeFingerPanZoomGesture(
+ movementThreshold = ViewConfiguration.get(game).scaledTouchSlop.toFloat(),
+ cancelHostTouches = ::cancelTouchesForPanZoom,
+ panZoom = { game.panZoomHandler.handleTouchEvent(it) },
+ toggleKeyboard = { game.toggleKeyboard() }
+ )
+
private var lastAbsTouchUpTime = 0L
private var lastAbsTouchDownTime = 0L
private var lastAbsTouchUpX = 0f
@@ -456,6 +465,24 @@ class TouchInputHandler(private val game: Game) {
lastButtonState = buttonState
} else {
// This case is for fingers
+ val editingController = game.virtualController?.controllerMode.let {
+ it == VirtualController.ControllerMode.MoveButtons ||
+ it == VirtualController.ControllerMode.ResizeButtons
+ }
+ // Existing owners (DS5/manual pan/zoom/controller editor) keep their input.
+ // Once claimed, a three-finger gesture consumes its entire tail even if settings change.
+ if (threeFingerPanZoom.handle(
+ event,
+ enabled = game.prefConfig.enableThreeFingerPanZoom &&
+ !game.prefConfig.screenDs5Touchpad &&
+ !game.getisTouchOverrideEnabled() && !editingController,
+ keyboardFingers = if (enhancedTouchRouteOwner.ownsContinuation()) {
+ game.prefConfig.nativeTouchFingersToToggleKeyboard
+ } else 3
+ )) {
+ return true
+ }
+
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
enhancedTouchRouteOwner.finish()
nativeTouchPointerMap.clear()
@@ -499,7 +526,7 @@ class TouchInputHandler(private val game: Game) {
val actionIndex = event.actionIndex
- // 三指手势特殊处理
+ // Keep the legacy shortcut below DS5 and enhanced-touch routing when disabled.
if (event.actionMasked == MotionEvent.ACTION_POINTER_DOWN && event.pointerCount == 3) {
multiFingerDownTime = event.eventTime
for (ctx in touchContextMap) ctx?.cancelTouch()
@@ -1386,6 +1413,28 @@ class TouchInputHandler(private val game: Game) {
nonRootTouchpadHandler.cancelAll(game.conn)
}
+ fun cancelThreeFingerPanZoom() {
+ threeFingerPanZoom.cancel()
+ }
+
+ private fun cancelTouchesForPanZoom() {
+ multiFingerDownTime = 0L
+ twoFingerTapPending = false
+ twoFingerMoved = true
+ for (context in touchContextMap) {
+ context?.cancelTouch()
+ context?.setPointerCount(0)
+ }
+ if (enhancedTouchRouteOwner.ownsContinuation()) {
+ game.conn?.sendTouchEvent(
+ MoonBridge.LI_TOUCH_EVENT_CANCEL_ALL, 0,
+ 0f, 0f, 0f, 0f, 0f, MoonBridge.LI_ROT_UNKNOWN
+ )
+ }
+ enhancedTouchRouteOwner.finish()
+ nativeTouchPointerMap.clear()
+ }
+
/**
* 初始化触控上下文(由 Game 在 onCreate / prepareConnection 中调用)
*/
diff --git a/app/src/main/java/com/limelight/binding/input/touch/ThreeFingerPanZoomGesture.kt b/app/src/main/java/com/limelight/binding/input/touch/ThreeFingerPanZoomGesture.kt
new file mode 100644
index 0000000000..64c53182dd
--- /dev/null
+++ b/app/src/main/java/com/limelight/binding/input/touch/ThreeFingerPanZoomGesture.kt
@@ -0,0 +1,125 @@
+package com.limelight.binding.input.touch
+
+import android.os.Build
+import android.os.SystemClock
+import android.view.InputDevice
+import android.view.MotionEvent
+
+/** Owns a touchscreen gesture from the third contact until its terminal event. */
+internal class ThreeFingerPanZoomGesture(
+ private val movementThreshold: Float,
+ private val cancelHostTouches: () -> Unit,
+ private val panZoom: (MotionEvent) -> Unit,
+ private val toggleKeyboard: () -> Unit
+) {
+ private var active = false
+ private var startTime = 0L
+ private var keyboardTap = false
+ private val pointerIds = IntArray(3)
+ private val startX = FloatArray(3)
+ private val startY = FloatArray(3)
+
+ fun handle(event: MotionEvent, enabled: Boolean, keyboardFingers: Int): Boolean {
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) cancel()
+ if (!active) {
+ if (!enabled || event.actionMasked != MotionEvent.ACTION_POINTER_DOWN || event.pointerCount != 3) {
+ return false
+ }
+ active = true
+ startTime = event.eventTime
+ keyboardTap = keyboardFingers == 3
+ repeat(3) { index ->
+ pointerIds[index] = event.getPointerId(index)
+ startX[index] = event.getX(index)
+ startY[index] = event.getY(index)
+ }
+ cancelHostTouches()
+ beginPanZoom(event)
+ }
+
+ if (event.pointerCount > 3 ||
+ (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
+ event.flags and MotionEvent.FLAG_CANCELED != 0)
+ ) {
+ keyboardTap = false
+ }
+ if (keyboardTap) checkMovement(event)
+
+ val finished = event.actionMasked == MotionEvent.ACTION_UP ||
+ event.actionMasked == MotionEvent.ACTION_CANCEL
+ val shouldToggle = finished && event.actionMasked == MotionEvent.ACTION_UP && keyboardTap &&
+ event.eventTime - startTime in 0 until KEYBOARD_TAP_TIMEOUT_MS
+ if (finished) {
+ active = false
+ keyboardTap = false
+ }
+ panZoom(event)
+ if (shouldToggle) toggleKeyboard()
+ return true
+ }
+
+ fun cancel() {
+ if (!active) return
+ active = false
+ keyboardTap = false
+ val time = SystemClock.uptimeMillis()
+ val cancel = MotionEvent.obtain(time, time, MotionEvent.ACTION_CANCEL, 0f, 0f, 0)
+ cancel.source = InputDevice.SOURCE_TOUCHSCREEN
+ try {
+ panZoom(cancel)
+ } finally {
+ cancel.recycle()
+ }
+ }
+
+ private fun beginPanZoom(event: MotionEvent) {
+ // The detectors did not see the first two contacts. Seed their DOWN sequence locally.
+ val indices = (0 until event.pointerCount).filter { it != event.actionIndex }
+ val properties = Array(indices.size) { index ->
+ MotionEvent.PointerProperties().also { event.getPointerProperties(indices[index], it) }
+ }
+ val coords = Array(indices.size) { index ->
+ MotionEvent.PointerCoords().also { event.getPointerCoords(indices[index], it) }
+ }
+ for (count in 1..indices.size) {
+ val action = if (count == 1) MotionEvent.ACTION_DOWN else {
+ MotionEvent.ACTION_POINTER_DOWN or ((count - 1) shl MotionEvent.ACTION_POINTER_INDEX_SHIFT)
+ }
+ val initial = MotionEvent.obtain(
+ event.downTime, event.eventTime, action, count, properties, coords,
+ event.metaState, event.buttonState, event.xPrecision, event.yPrecision,
+ event.deviceId, event.edgeFlags, event.source, event.flags
+ )
+ try {
+ panZoom(initial)
+ } finally {
+ initial.recycle()
+ }
+ }
+ }
+
+ private fun checkMovement(event: MotionEvent) {
+ for (index in 0 until event.pointerCount) {
+ val original = pointerIds.indexOf(event.getPointerId(index))
+ if (original < 0) {
+ keyboardTap = false
+ return
+ }
+ // Include batched samples and lift coordinates, not only ACTION_MOVE endpoints.
+ for (sample in 0..event.historySize) {
+ val x = if (sample == event.historySize) event.getX(index) else event.getHistoricalX(index, sample)
+ val y = if (sample == event.historySize) event.getY(index) else event.getHistoricalY(index, sample)
+ val dx = x - startX[original]
+ val dy = y - startY[original]
+ if (dx * dx + dy * dy > movementThreshold * movementThreshold) {
+ keyboardTap = false
+ return
+ }
+ }
+ }
+ }
+
+ companion object {
+ private const val KEYBOARD_TAP_TIMEOUT_MS = 300L
+ }
+}
diff --git a/app/src/main/java/com/limelight/gamemenu/GameMenu.kt b/app/src/main/java/com/limelight/gamemenu/GameMenu.kt
index f1ed8fb76e..c3ea8f29b0 100644
--- a/app/src/main/java/com/limelight/gamemenu/GameMenu.kt
+++ b/app/src/main/java/com/limelight/gamemenu/GameMenu.kt
@@ -2190,19 +2190,22 @@ class GameMenu(
inlineControl = InlineControl.Segmented(buildTouchModeSegments(compactLabels = true))
))
- normalOptions.add(MenuOption(
- label = getString(R.string.game_menu_enable_pan_zoom).trim(),
- isWithGameFocus = false,
- runnable = Runnable {
- Toast.makeText(game,
- if (game.getisTouchOverrideEnabled()) getString(R.string.toast_pan_zoom_disabled) else getString(R.string.toast_pan_zoom_enabled),
- Toast.LENGTH_SHORT).show()
- game.setisTouchOverrideEnabled(!game.getisTouchOverrideEnabled())
- },
- iconKey = "game_menu_mouse_emulation",
- isShowIcon = true,
- isKeepDialog = true,
- inlineControl = InlineControl.Toggle(game.getisTouchOverrideEnabled())
+ // Row and checkbox share the action and both refresh the persisted state.
+ val threeFingerPanZoomToggle = Runnable {
+ val enabled = !game.prefConfig.enableThreeFingerPanZoom
+ game.prefConfig.enableThreeFingerPanZoom = enabled
+ android.preference.PreferenceManager.getDefaultSharedPreferences(game).edit {
+ putBoolean(PreferenceConfiguration.THREE_FINGER_PAN_ZOOM_PREF_STRING, enabled)
+ }
+ Toast.makeText(game,
+ if (enabled) getString(R.string.toast_three_finger_pan_zoom_enabled)
+ else getString(R.string.toast_three_finger_pan_zoom_disabled),
+ Toast.LENGTH_SHORT).show()
+ }
+ normalOptions.add(threeFingerPanZoomOption(
+ label = getString(R.string.game_menu_enable_three_finger_pan_zoom).trim(),
+ checked = game.prefConfig.enableThreeFingerPanZoom,
+ toggle = threeFingerPanZoomToggle
))
// 王冠功能
@@ -2336,6 +2339,7 @@ class GameMenu(
"game_menu_cancel" to R.drawable.ic_cancel_cute,
"mouse_mode" to R.drawable.ic_mouse_cute,
"game_menu_mouse_emulation" to R.drawable.ic_mouse_emulation_cute,
+ "game_menu_enable_three_finger_pan_zoom" to R.drawable.ic_mouse_emulation_cute,
"crown_function_menu" to R.drawable.ic_super_crown,
"crown_visibility" to R.drawable.ic_ui_settings,
"crown_touch" to R.drawable.ic_touch_settings,
diff --git a/app/src/main/java/com/limelight/gamemenu/GameMenuContract.kt b/app/src/main/java/com/limelight/gamemenu/GameMenuContract.kt
index 6cea83fcf7..ab0538e839 100644
--- a/app/src/main/java/com/limelight/gamemenu/GameMenuContract.kt
+++ b/app/src/main/java/com/limelight/gamemenu/GameMenuContract.kt
@@ -59,6 +59,17 @@ internal fun gameMenuChildDialogOption(
isKeepDialog = true
)
+internal fun threeFingerPanZoomOption(label: String, checked: Boolean, toggle: Runnable) = GameMenu.MenuOption(
+ label = label,
+ isWithGameFocus = false,
+ runnable = toggle,
+ iconKey = "game_menu_enable_three_finger_pan_zoom",
+ isShowIcon = true,
+ isKeepDialog = true,
+ inlineControl = GameMenu.InlineControl.Toggle(checked, toggle),
+ presentation = GameMenuOptionPresentation.COMPATIBLE_ACTION
+)
+
internal class GameMenuGuideDismissController {
private var dismissAction: (() -> Unit)? = null
diff --git a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.kt b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.kt
index 8d8bd712e4..bc81daa023 100644
--- a/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.kt
+++ b/app/src/main/java/com/limelight/preferences/PreferenceConfiguration.kt
@@ -106,6 +106,7 @@ class PreferenceConfiguration {
var enhanceTouchZoneDivider = 0 //Assigned to NativeTouchContext.ENHANCED_TOUCH_ZONE_DIVIDER
var pointerVelocityFactor = 0f //Assigned to NativeTouchContext.POINTER_VELOCITY_FACTOR
var nativeTouchFingersToToggleKeyboard = 0 // Number of fingers to tap to toggle local on-screen keyboard in native touch mode.
+ var enableThreeFingerPanZoom = true // 三指平移/缩放(游戏菜单开关持久化,默认开)
var videoFormat: FormatOption = FormatOption.AUTO
var deadzonePercentage = 0
@@ -677,6 +678,7 @@ class PreferenceConfiguration {
const val SYNC_TOUCH_EVENT_WITH_DISPLAY_PREF_STRING = "checkbox_sync_touch_event_with_display"
const val ENABLE_KEYBOARD_TOGGLE_IN_NATIVE_TOUCH = "checkbox_enable_keyboard_toggle_in_native_touch"
const val NATIVE_TOUCH_FINGERS_TO_TOGGLE_KEYBOARD_PREF_STRING = "seekbar_keyboard_toggle_fingers_native_touch"
+ const val THREE_FINGER_PAN_ZOOM_PREF_STRING = "checkbox_three_finger_pan_zoom"
const val AUDIO_CONFIG_PREF_STRING = "list_audio_config"
/** Audio codec preference: "auto" | "opus" | "ac3" | "eac3" */
const val AUDIO_CODEC_PREF_STRING = "list_audio_codec"
@@ -1287,6 +1289,9 @@ class PreferenceConfiguration {
config.nativeTouchFingersToToggleKeyboard = -1 // completely disable keyboard toggle in multi-point touch
}
+ // 三指平移/缩放(游戏菜单"三指平移/缩放"开关,持久化)
+ config.enableThreeFingerPanZoom = prefs.getBoolean(THREE_FINGER_PAN_ZOOM_PREF_STRING, true)
+
// Enhance touch settings
config.enhancedTouchOnWhichSide = prefs.getBoolean(ENHANCED_TOUCH_ON_RIGHT_PREF_STRING, true) // by default, enhanced touch zone is on the right side.
config.enhanceTouchZoneDivider = prefs.getInt(ENHANCED_TOUCH_ZONE_DIVIDER_PREF_STRING, 50) // decides where to divide native touch zone & enhance touch zone
diff --git a/app/src/main/java/com/limelight/utils/PanZoomHandler.kt b/app/src/main/java/com/limelight/utils/PanZoomHandler.kt
index b34d9a3b46..ae046fa359 100644
--- a/app/src/main/java/com/limelight/utils/PanZoomHandler.kt
+++ b/app/src/main/java/com/limelight/utils/PanZoomHandler.kt
@@ -6,15 +6,11 @@ import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.View
-import com.limelight.Game
-import com.limelight.preferences.PreferenceConfiguration
-
class PanZoomHandler(
context: Context,
- private val game: Game,
private val streamView: View,
private val cursorOverlay: View,
- private val prefConfig: PreferenceConfiguration
+ private val onScaleEnd: () -> Unit
) {
private val scaleGestureDetector: ScaleGestureDetector
private val gestureDetector: GestureDetector
@@ -133,7 +129,7 @@ class PanZoomHandler(
}
override fun onScaleEnd(detector: ScaleGestureDetector) {
- game.updatePipAutoEnter()
+ onScaleEnd()
}
}
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 0f7a83e994..a714330a0a 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -1508,9 +1508,9 @@
两者
卡片配置
请在设置中开启麦克风重定向
- 平移与缩放
- 已关闭平移/缩放
- 已开启平移/缩放
+ 三指平移/缩放
+ 已关闭三指平移/缩放
+ 已开启三指平移/缩放
发送超级命令时发生错误: %s
未获得安装权限,下载已取消
检查更新失败,请稍后重试
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
index 5c10abbfde..56d2bd6812 100644
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ b/app/src/main/res/values-zh-rTW/strings.xml
@@ -1,5 +1,8 @@
+ 三指平移/縮放
+ 已關閉三指平移/縮放
+ 已開啟三指平移/縮放
電腦已刪除
電腦未配對
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 9a7df7b3a0..4450cfa45d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1789,9 +1789,9 @@ Tap again to replace it.
Adjust game menu opacity, currently %1$d%%
,
Enable microphone redirection in Settings first
- Pan and Zoom
- Pan/Zoom disabled
- Pan/Zoom enabled
+ Three-Finger Pan and Zoom
+ Three-finger Pan/Zoom disabled
+ Three-finger Pan/Zoom enabled
Couldn\'t send super command: %s
Install permission denied, download cancelled
Failed to check for updates, please try later
diff --git a/app/src/test/java/com/limelight/gamemenu/ThreeFingerPanZoomOptionTest.kt b/app/src/test/java/com/limelight/gamemenu/ThreeFingerPanZoomOptionTest.kt
new file mode 100644
index 0000000000..114090b758
--- /dev/null
+++ b/app/src/test/java/com/limelight/gamemenu/ThreeFingerPanZoomOptionTest.kt
@@ -0,0 +1,26 @@
+package com.limelight.gamemenu
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ThreeFingerPanZoomOptionTest {
+ @Test
+ fun rowAndCheckboxShareActionAndRequestMenuRefresh() {
+ var count = 0
+ val action = Runnable { count++ }
+ val option = threeFingerPanZoomOption("Pan/Zoom", true, action)
+ val inline = option.inlineControl as GameMenu.InlineControl.Toggle
+ assertSame(action, option.runnable)
+ assertSame(action, inline.toggleAction)
+ assertEquals(GameMenuOptionPresentation.COMPATIBLE_ACTION, option.presentation)
+ assertTrue(option.isKeepDialog)
+ assertFalse(option.isWithGameFocus)
+ option.runnable!!.run()
+ assertEquals(1, count)
+ inline.toggleAction!!.run()
+ assertEquals(2, count)
+ }
+}