refactor: touch implementation, ratio switching

- 重写了触控实现
- 串流过程中自动切换横竖屏
- 禁用了全屏控制页中的预测性返回手势
This commit is contained in:
Miuzarte
2026-03-20 21:03:27 +08:00
parent 43d3b3f818
commit 737ad40923
10 changed files with 249 additions and 155 deletions

View File

@@ -18,6 +18,7 @@
<activity
android:name=".MainActivity"
android:configChanges="keyboardHidden|orientation|screenSize|smallestScreenSize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

View File

@@ -33,6 +33,9 @@ class NativeCoreFacade(private val appContext: Context) {
private val mainHandler = Handler(Looper.getMainLooper())
private val bootstrapLock = Any()
private val bootstrapPackets = ArrayDeque<CachedPacket>()
@Volatile
private var latestConfigPacket: CachedPacket? = null
private var packetCount: Long = 0
@Volatile
@@ -181,6 +184,7 @@ class NativeCoreFacade(private val appContext: Context) {
releaseAllDecoders()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
if (!request.noVideo) {
surfaceMap.forEach { (tag, surface) ->
@@ -221,6 +225,7 @@ class NativeCoreFacade(private val appContext: Context) {
releaseAllDecoders()
synchronized(bootstrapLock) {
bootstrapPackets.clear()
latestConfigPacket = null
}
currentSessionInfo = null
sessionManager.clearVideoConsumer()
@@ -578,6 +583,20 @@ class NativeCoreFacade(private val appContext: Context) {
isKeyFrame = packet.isKeyFrame,
)
synchronized(bootstrapLock) {
if (cached.isConfig) {
latestConfigPacket = cached
bootstrapPackets.clear()
bootstrapPackets.addLast(cached)
return
}
if (cached.isKeyFrame) {
bootstrapPackets.clear()
latestConfigPacket?.let { bootstrapPackets.addLast(it) }
bootstrapPackets.addLast(cached)
return
}
while (bootstrapPackets.size >= MAX_BOOTSTRAP_PACKETS) {
bootstrapPackets.removeFirst()
}

View File

@@ -161,8 +161,8 @@ internal class AdbPairingKey(
}
}
private const val ANDROID_PUBKEY_MODULUS_SIZE = 2048 / 8
private const val ANDROID_PUBKEY_MODULUS_SIZE_WORDS = ANDROID_PUBKEY_MODULUS_SIZE / 4
private const val ANDROID_PUBKEY_MODULUS_SIZE = 2048 / 8 // 256
private const val ANDROID_PUBKEY_MODULUS_SIZE_WORDS = ANDROID_PUBKEY_MODULUS_SIZE / 4 // 64
private const val RSA_PUBLIC_KEY_SIZE = 524
private fun BigInteger.toAdbEncoded(): IntArray {

View File

@@ -190,21 +190,8 @@ internal class DirectAdbConnection(
) : AutoCloseable {
private val sha1DigestInfoPrefix = byteArrayOf(
0x30,
0x21,
0x30,
0x09,
0x06,
0x05,
0x2B,
0x0E,
0x03,
0x02,
0x1A,
0x05,
0x00,
0x04,
0x14,
0x30, 0x21, 0x30, 0x09, 0x06, 0x05, 0x2B, 0x0E,
0x03, 0x02, 0x1A, 0x05, 0x00, 0x04, 0x14,
)
private val socket = Socket()

View File

@@ -1,7 +1,7 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.app.Activity
import android.content.pm.ActivityInfo
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -42,8 +42,13 @@ fun FullscreenControlPage(
virtualButtonsInMore: List<String>,
showDebugInfo: Boolean,
showVirtualButtons: Boolean,
onVideoSizeChanged: (width: Int, height: Int) -> Unit,
onDismiss: () -> Unit,
) {
// 全屏控制页不使用预测性返回手势,
// 省得处理解码的问题
BackHandler(enabled = true, onBack = onDismiss)
val context = LocalContext.current
val haptics = rememberAppHaptics()
val activity = remember(context) { context as? Activity }
@@ -56,9 +61,6 @@ fun FullscreenControlPage(
moreActions = virtualButtonLayout.second,
)
}
val initialOrientation = remember(activity) {
activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
var session by remember(launch) {
mutableStateOf(
ScrcpySessionInfo(
@@ -72,18 +74,6 @@ fun FullscreenControlPage(
}
var currentFps by remember { mutableFloatStateOf(0f) }
DisposableEffect(activity, session.width, session.height) {
val targetOrientation = if (session.width >= session.height) {
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
}
activity?.requestedOrientation = targetOrientation
onDispose {
activity?.requestedOrientation = initialOrientation
}
}
DisposableEffect(activity) {
val window = activity?.window
if (window != null) {
@@ -107,6 +97,7 @@ fun FullscreenControlPage(
DisposableEffect(nativeCore) {
val listener: (Int, Int) -> Unit = { w, h ->
session = session.copy(width = w, height = h)
onVideoSizeChanged(w, h)
}
nativeCore.addVideoSizeListener(listener)
onDispose {
@@ -151,6 +142,7 @@ fun FullscreenControlPage(
screenWidth = session.width,
screenHeight = session.height,
pressure = pressure,
actionButton = 0,
buttons = buttons,
)
},

View File

@@ -1,6 +1,8 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.app.Activity
import android.content.Intent
import android.content.pm.ActivityInfo
import android.net.Uri
import androidx.activity.compose.PredictiveBackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -17,6 +19,7 @@ import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
@@ -86,6 +89,10 @@ private sealed interface RootScreen : NavKey {
@Composable
fun MainPage() {
val context = LocalContext.current
val activity = remember(context) { context as? Activity }
val initialOrientation = remember(activity) {
activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
val nativeCore = remember(context) { NativeCoreFacade.get(context.applicationContext) }
val initialSettings = remember(context) { loadMainSettings(context) }
val initialDeviceSettings = remember(context) { loadDevicePageSettings(context) }
@@ -182,9 +189,26 @@ fun MainPage() {
var openReorderDevicesAction by remember { mutableStateOf<(() -> Unit)?>(null) }
var canClearLogs by remember { mutableStateOf(false) }
var showDeviceMenu by rememberSaveable { mutableStateOf(false) }
var fullscreenOrientation by rememberSaveable {
mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
}
val themeMode = resolveThemeMode(themeBaseIndex, monetEnabled)
val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
DisposableEffect(activity) {
onDispose {
activity?.requestedOrientation = initialOrientation
}
}
LaunchedEffect(activity, currentRootScreen, fullscreenOrientation) {
val targetOrientation = when (currentRootScreen) {
is RootScreen.Fullscreen -> fullscreenOrientation
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
activity?.requestedOrientation = targetOrientation
}
LaunchedEffect(
audioEnabled,
audioCodec,
@@ -232,9 +256,6 @@ fun MainPage() {
nativeCore.setAdbKeyName(adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME })
}
val canNavigateBack =
rootBackStack.size > 1 || pagerState.currentPage != MainTabDestination.Device.ordinal
fun popRoot() {
if (rootBackStack.size > 1) {
rootBackStack.removeAt(rootBackStack.lastIndex)
@@ -257,6 +278,9 @@ fun MainPage() {
}
}
val canNavigateBack = rootBackStack.size > 1 ||
pagerState.currentPage != MainTabDestination.Device.ordinal
PredictiveBackHandler(enabled = canNavigateBack) { progress ->
try {
progress.collect { }
@@ -467,6 +491,12 @@ fun MainPage() {
},
onOpenAdvancedPage = { rootBackStack.add(RootScreen.Advanced) },
onOpenFullscreenPage = { session ->
fullscreenOrientation =
if (session.width >= session.height) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
rootBackStack.add(
RootScreen.Fullscreen(
launch = FullscreenControlLaunch(
@@ -511,6 +541,10 @@ fun MainPage() {
onDevicePreviewCardHeightDpChange = {
devicePreviewCardHeightDp = it.coerceAtLeast(120)
},
showFullscreenVirtualButtons = showFullscreenVirtualButtons,
onShowFullscreenVirtualButtonsChange = {
showFullscreenVirtualButtons = it
},
customServerUri = customServerUri,
onPickServer = {
picker.launch(
@@ -692,6 +726,13 @@ fun MainPage() {
virtualButtonsInMore = virtualButtonsInMore,
showDebugInfo = fullscreenDebugInfoEnabled,
showVirtualButtons = showFullscreenVirtualButtons,
onVideoSizeChanged = { width, height ->
fullscreenOrientation = if (width >= height) {
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
} else {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
},
onDismiss = { popRoot() },
)
}

View File

@@ -65,6 +65,8 @@ fun SettingsScreen(
onKeepScreenOnWhenStreamingEnabledChange: (Boolean) -> Unit,
devicePreviewCardHeightDp: Int,
onDevicePreviewCardHeightDpChange: (Int) -> Unit,
showFullscreenVirtualButtons: Boolean,
onShowFullscreenVirtualButtonsChange: (Boolean) -> Unit,
customServerUri: String?,
onPickServer: () -> Unit,
onClearServer: () -> Unit,
@@ -138,6 +140,12 @@ fun SettingsScreen(
?.let { onDevicePreviewCardHeightDpChange(it.coerceAtLeast(120)) }
},
)
SuperSwitch(
title = "全屏显示虚拟按钮",
summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮",
checked = showFullscreenVirtualButtons,
onCheckedChange = onShowFullscreenVirtualButtonsChange,
)
}
SectionSmallTitle(text = "scrcpy-server")

View File

@@ -27,7 +27,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
@@ -114,6 +113,8 @@ private object UiMotionActions {
const val POINTER_UP = 6
}
private const val FULLSCREEN_TOUCH_LOG_TAG = "FullscreenTouch"
@Composable
internal fun StatusCard(
statusLine: String,
@@ -647,7 +648,11 @@ fun FullscreenControlScreen(
var touchAreaSize by remember { mutableStateOf(IntSize.Zero) }
val activePointerIds = remember { linkedSetOf<Int>() }
val activePointerPositions = remember { linkedMapOf<Int, Offset>() }
val activePointerDevicePositions = remember { linkedMapOf<Int, Pair<Int, Int>>() }
val pointerLabels = remember { linkedMapOf<Int, Int>() }
var nextPointerLabel by remember { mutableIntStateOf(1) }
var activeTouchCount by remember { mutableIntStateOf(0) }
var activeTouchDebug by remember { mutableStateOf("") }
BackHandler(enabled = enableBackHandler, onBack = onDismiss)
BoxWithConstraints(
@@ -659,125 +664,161 @@ fun FullscreenControlScreen(
return@pointerInteropFilter true
}
val sessionAspect = if (session.height == 0) {
16f / 9f
} else {
session.width.toFloat() / session.height.toFloat()
}
val containerWidth = touchAreaSize.width.toFloat()
val containerHeight = touchAreaSize.height.toFloat()
val containerAspect = containerWidth / containerHeight
val contentWidth: Float
val contentHeight: Float
if (sessionAspect > containerAspect) {
contentWidth = containerWidth
contentHeight = containerWidth / sessionAspect
} else {
contentHeight = containerHeight
contentWidth = containerHeight * sessionAspect
}
val contentLeft = (containerWidth - contentWidth) / 2f
val contentTop = (containerHeight - contentHeight) / 2f
fun isInsideContent(rawX: Float, rawY: Float): Boolean {
return rawX in contentLeft..(contentLeft + contentWidth) &&
rawY in contentTop..(contentTop + contentHeight)
}
fun mapToDevice(rawX: Float, rawY: Float): Pair<Int, Int> {
val x = ((rawX / touchAreaSize.width) * session.width).roundToInt()
val normalizedX = ((rawX - contentLeft) / contentWidth).coerceIn(0f, 1f)
val normalizedY = ((rawY - contentTop) / contentHeight).coerceIn(0f, 1f)
val x = (normalizedX * (session.width - 1).coerceAtLeast(0)).roundToInt()
.coerceIn(0, (session.width - 1).coerceAtLeast(0))
val y = ((rawY / touchAreaSize.height) * session.height).roundToInt()
val y = (normalizedY * (session.height - 1).coerceAtLeast(0)).roundToInt()
.coerceIn(0, (session.height - 1).coerceAtLeast(0))
return x to y
}
fun syncActivePointersFromEvent(skipPointerId: Int? = null) {
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
if (!activePointerIds.contains(pointerId) || pointerId == skipPointerId) continue
val px = event.getX(i)
val py = event.getY(i)
activePointerPositions[pointerId] = Offset(px, py)
val (x, y) = mapToDevice(px, py)
onInjectTouch(
UiMotionActions.MOVE,
pointerId.toLong(),
x,
y,
event.getPressure(i).coerceIn(0f, 1f),
1
)
fun pointerLabel(pointerId: Int): Int {
val existing = pointerLabels[pointerId]
if (existing != null) {
return existing
}
val assigned = nextPointerLabel
nextPointerLabel += 1
pointerLabels[pointerId] = assigned
return assigned
}
fun refreshTouchDebug() {
if (activePointerIds.isEmpty()) {
activeTouchDebug = ""
return
}
activeTouchDebug = activePointerIds
.sortedBy { pointerLabel(it) }
.joinToString(separator = "\n") { pointerId ->
val label = pointerLabel(pointerId)
val pos = activePointerDevicePositions[pointerId]
if (pos == null) {
"#$label(id=$pointerId):?"
} else {
"#$label(id=$pointerId):${pos.first},${pos.second}"
}
}
}
fun releasePointer(pointerId: Int, reason: String) {
if (!activePointerIds.contains(pointerId)) return
val pos = activePointerPositions[pointerId] ?: Offset.Zero
val (x, y) = mapToDevice(pos.x, pos.y)
// val label = pointerLabel(pointerId)
// Log.d(
// FULLSCREEN_TOUCH_LOG_TAG,
// "抬起($reason): pointer#$label(id=$pointerId) x=$x y=$y"
// )
onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 0)
activePointerIds -= pointerId
activePointerPositions.remove(pointerId)
activePointerDevicePositions.remove(pointerId)
pointerLabels.remove(pointerId)
}
if (event.actionMasked == MotionEvent.ACTION_CANCEL) {
val toCancel = activePointerIds.toList()
for (pointerId in toCancel) {
releasePointer(pointerId, reason = "cancel")
}
activeTouchCount = activePointerIds.size
refreshTouchDebug()
return@pointerInteropFilter true
}
val eventPointerIds = HashSet<Int>(event.pointerCount)
val eventPositions = HashMap<Int, Offset>(event.pointerCount)
val eventPressures = HashMap<Int, Float>(event.pointerCount)
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
eventPointerIds += pointerId
eventPositions[pointerId] = Offset(event.getX(i), event.getY(i))
eventPressures[pointerId] = event.getPressure(i).coerceIn(0f, 1f)
}
val disappearedPointers = activePointerIds.filter { it !in eventPointerIds }
for (pointerId in disappearedPointers) {
releasePointer(pointerId, reason = "missing")
}
val endedPointerId = when (event.actionMasked) {
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> event.getPointerId(event.actionIndex)
else -> null
}
val justPressed = HashSet<Int>()
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
if (pointerId == endedPointerId) continue
val raw = eventPositions[pointerId] ?: continue
val pressure = eventPressures[pointerId] ?: 0f
if (!activePointerIds.contains(pointerId)) {
if (!isInsideContent(raw.x, raw.y)) continue
val (x, y) = mapToDevice(raw.x, raw.y)
// val label = pointerLabel(pointerId)
// Log.d(
// FULLSCREEN_TOUCH_LOG_TAG,
// "按下: pointer#$label(id=$pointerId) x=$x y=$y"
// )
activePointerIds += pointerId
activePointerPositions[pointerId] = raw
activePointerDevicePositions[pointerId] = x to y
justPressed += pointerId
onInjectTouch(UiMotionActions.DOWN, pointerId.toLong(), x, y, pressure, 0)
}
}
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
activePointerIds.clear()
activePointerPositions.clear()
activeTouchCount = 0
val (x, y) = mapToDevice(event.x, event.y)
val pointerId = event.getPointerId(0)
activePointerIds += pointerId
activePointerPositions[pointerId] = Offset(event.x, event.y)
activeTouchCount = activePointerIds.size
onInjectTouch(
UiMotionActions.DOWN,
pointerId.toLong(),
x,
y,
event.getPressure(0).coerceIn(0f, 1f),
1
)
}
MotionEvent.ACTION_POINTER_DOWN -> {
val index = event.actionIndex
val px = event.getX(index)
val py = event.getY(index)
val (x, y) = mapToDevice(px, py)
val pointerId = event.getPointerId(index)
activePointerIds += pointerId
activePointerPositions[pointerId] = Offset(px, py)
activeTouchCount = activePointerIds.size
onInjectTouch(
UiMotionActions.POINTER_DOWN,
pointerId.toLong(),
x,
y,
event.getPressure(index).coerceIn(0f, 1f),
1
)
syncActivePointersFromEvent()
}
MotionEvent.ACTION_MOVE -> {
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
if (!activePointerIds.contains(pointerId)) continue
val px = event.getX(i)
val py = event.getY(i)
activePointerPositions[pointerId] = Offset(px, py)
val (x, y) = mapToDevice(px, py)
onInjectTouch(
UiMotionActions.MOVE,
pointerId.toLong(),
x,
y,
event.getPressure(i).coerceIn(0f, 1f),
1
)
}
}
MotionEvent.ACTION_POINTER_UP -> {
val index = event.actionIndex
val px = event.getX(index)
val py = event.getY(index)
val (x, y) = mapToDevice(px, py)
val pointerId = event.getPointerId(index)
onInjectTouch(UiMotionActions.POINTER_UP, pointerId.toLong(), x, y, 0f, 1)
activePointerIds -= pointerId
activePointerPositions.remove(pointerId)
activeTouchCount = activePointerIds.size
syncActivePointersFromEvent(skipPointerId = pointerId)
}
MotionEvent.ACTION_UP -> {
val (x, y) = mapToDevice(event.x, event.y)
val pointerId = event.getPointerId(0)
onInjectTouch(UiMotionActions.UP, pointerId.toLong(), x, y, 0f, 1)
activePointerIds.clear()
activePointerPositions.clear()
activeTouchCount = 0
}
MotionEvent.ACTION_CANCEL -> {
for (pointerId in activePointerIds) {
val pos = activePointerPositions[pointerId] ?: Offset.Zero
val (x, y) = mapToDevice(pos.x, pos.y)
onInjectTouch(UiMotionActions.CANCEL, pointerId.toLong(), x, y, 0f, 0)
}
activePointerIds.clear()
activePointerPositions.clear()
activeTouchCount = 0
}
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
if (!activePointerIds.contains(pointerId)) continue
if (pointerId == endedPointerId) continue
if (pointerId in justPressed) continue
val raw = eventPositions[pointerId] ?: continue
val pressure = eventPressures[pointerId] ?: 0f
activePointerPositions[pointerId] = raw
val (x, y) = mapToDevice(raw.x, raw.y)
activePointerDevicePositions[pointerId] = x to y
onInjectTouch(UiMotionActions.MOVE, pointerId.toLong(), x, y, pressure, 0)
}
if (endedPointerId != null) {
val endPos = eventPositions[endedPointerId]
if (endPos != null) {
activePointerPositions[endedPointerId] = endPos
}
releasePointer(endedPointerId, reason = "event")
}
activeTouchCount = activePointerIds.size
refreshTouchDebug()
true
}
.onSizeChanged { touchAreaSize = it },
@@ -815,7 +856,7 @@ fun FullscreenControlScreen(
modifier = Modifier
.align(Alignment.TopStart)
.padding(start = UiSpacing.CardContent, top = UiSpacing.CardContent)
.background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(25))
.background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = UiSpacing.CardContent, vertical = UiSpacing.Medium),
) {
Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.Tiny)) {
@@ -825,14 +866,19 @@ fun FullscreenControlScreen(
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
@SuppressLint("DefaultLocale")
Text(
text = "FPS: ${String.format("%.1f", currentFps.coerceAtLeast(0f))}",
color = Color.White,
fontSize = 13.sp,
)
Text(
text = "触点: $activeTouchCount",
color = Color.White,
fontSize = 13.sp,
)
@SuppressLint("DefaultLocale")
Text(
text = "FPS: ${String.format("%.1f", currentFps.coerceAtLeast(0f))}",
if (activeTouchDebug.isNotEmpty()) Text(
text = activeTouchDebug,
color = Color.White,
fontSize = 13.sp,
)