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) + } +}