feat: IME bringing up; clipboard syncing

This commit is contained in:
Miuzarte
2026-04-21 00:36:08 +08:00
parent 47d140a5c7
commit d031839a3a
23 changed files with 1289 additions and 419 deletions

View File

@@ -54,8 +54,8 @@ android {
applicationId = "io.github.miuzarte.scrcpyforandroid"
minSdk = 26
targetSdk = 37
versionCode = 14
versionName = "0.1.8"
versionCode = 15
versionName = "0.1.9"
externalNativeBuild {
cmake {

View File

@@ -52,12 +52,12 @@ import io.github.miuzarte.scrcpyforandroid.password.PasswordEntry
import io.github.miuzarte.scrcpyforandroid.password.PasswordRepository
import io.github.miuzarte.scrcpyforandroid.password.PasswordSanitizer
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob

View File

@@ -3,46 +3,31 @@ package io.github.miuzarte.scrcpyforandroid.pages
import android.annotation.SuppressLint
import android.util.Log
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Android
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.BlendModeColorFilter
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
@@ -55,6 +40,7 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SectionSmallTitle
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
import io.github.miuzarte.scrcpyforandroid.services.AppWakeLocks
@@ -73,12 +59,13 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyProfiles
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
import io.github.miuzarte.scrcpyforandroid.widgets.AppListBottomSheet
import io.github.miuzarte.scrcpyforandroid.widgets.AppListEntry
import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTileList
import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard
import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard
import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
@@ -99,17 +86,10 @@ import top.yukonga.miuix.kmp.basic.ListPopupDefaults
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SpinnerColors
import top.yukonga.miuix.kmp.basic.SpinnerDefaults
import top.yukonga.miuix.kmp.basic.SpinnerEntry
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.blur.layerBackdrop
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.basic.Check
import top.yukonga.miuix.kmp.icon.extended.Store
import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles
@@ -293,11 +273,14 @@ fun DeviceTabPage(
var adbSession by rememberSaveable { mutableStateOf(DeviceAdbSessionState()) }
val sessionInfo by scrcpy.currentSessionState.collectAsState()
val listingsRefreshBusy by scrcpy.listings.refreshBusyState.collectAsState()
val listingsRefreshVersion by scrcpy.listings.refreshVersionState.collectAsState()
var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) }
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
var adbConnecting by rememberSaveable { mutableStateOf(false) }
var pendingScrollToPreview by rememberSaveable { mutableStateOf(false) }
var showRecentTasksSheet by rememberSaveable { mutableStateOf(false) }
var showAllAppsSheet by rememberSaveable { mutableStateOf(false) }
var imeRequestToken by rememberSaveable { mutableIntStateOf(0) }
val listState = rememberLazyListState()
val isPreviewCardVisible by remember(listState) {
derivedStateOf {
@@ -341,9 +324,8 @@ fun DeviceTabPage(
adbSession.connectedScrcpyProfileId
val connectedScrcpyBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
val audioForwardingSupported = adbSession.audioForwardingSupported
val cameraMirroringSupported = adbSession.cameraMirroringSupported
val recentTasks = scrcpy.listings.recentTasks
val apps = remember(listingsRefreshVersion) { scrcpy.listings.apps }
val recentTasks = remember(listingsRefreshVersion) { scrcpy.listings.recentTasks }
val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
@@ -353,6 +335,68 @@ fun DeviceTabPage(
)
}
suspend fun refreshApps() {
runCatching {
scrcpy.listings.getApps(forceRefresh = true)
}.onFailure { error ->
logEvent("获取应用列表失败: ${error.message}", Log.WARN, error)
withContext(Dispatchers.Main) {
snackbar.show("获取应用列表失败")
}
}
}
suspend fun refreshRecentTasks() {
runCatching {
scrcpy.listings.getRecentTasks(forceRefresh = true)
}.onFailure { error ->
logEvent("获取最近任务失败: ${error.message}", Log.WARN, error)
withContext(Dispatchers.Main) {
snackbar.show("获取最近任务失败")
}
}
}
suspend fun pasteLocalClipboard() {
val text =
io.github.miuzarte.scrcpyforandroid.services.LocalInputService.getClipboardText(context)
?.takeIf { it.isNotBlank() }
if (text == null) {
snackbar.show("本机剪贴板为空或不是文本")
return
}
val useLegacyPaste = connectedScrcpyBundle.legacyPaste
runCatching {
withContext(Dispatchers.IO) {
if (useLegacyPaste) {
scrcpy.injectText(text)
} else {
scrcpy.setClipboard(text, paste = true)
}
}
logEvent(
if (useLegacyPaste) "已使用 legacy paste 注入本机剪贴板文本"
else "已同步本机剪贴板到设备并触发粘贴"
)
}.onFailure { error ->
logEvent("本机剪贴板粘贴失败: ${error.message}", Log.WARN, error)
snackbar.show(
if (useLegacyPaste) "legacy 粘贴失败"
else "剪贴板同步粘贴失败,可尝试开启 --legacy-paste"
)
}
}
suspend fun commitImeText(text: String) {
submitImeText(scrcpy, text) { error, useClipboardPaste ->
logEvent("输入法文本提交失败: ${error.message}", Log.WARN, error)
snackbar.show(
if (useClipboardPaste) "非 ASCII 文本粘贴失败"
else "文本输入失败"
)
}
}
var quickConnectInputTemp by rememberSaveable(qdBundle.quickConnectInput) {
mutableStateOf(qdBundle.quickConnectInput)
}
@@ -1011,21 +1055,26 @@ fun DeviceTabPage(
busy = busy,
activeProfileId = connectedScrcpyProfileId,
activeBundle = connectedScrcpyBundle,
audioForwardingSupported = audioForwardingSupported,
cameraMirroringSupported = cameraMirroringSupported,
hideSimpleConfigItems = asBundle.hideSimpleConfigItems,
audioForwardingSupported = adbSession.audioForwardingSupported,
cameraMirroringSupported = adbSession.cameraMirroringSupported,
adbConnecting = adbConnecting,
isQuickConnected = isQuickConnected,
recentTasksEndAction = {
Text(
text = when {
listingsRefreshBusy -> "..."
recentTasks.isNotEmpty() -> recentTasks.size.toString()
else -> ""
},
color = colorScheme.onSurfaceVariantActions,
fontSize = textStyles.body2.fontSize,
modifier = Modifier.padding(end = UiSpacing.ContentVertical),
)
allAppsEndActionText = when {
listingsRefreshBusy -> "..."
apps.isNotEmpty() -> apps.size.toString()
else -> ""
},
onOpenAllApps = {
showAllAppsSheet = true
if (apps.isEmpty() && !listingsRefreshBusy) {
scope.launch(Dispatchers.IO) { refreshApps() }
}
},
recentTasksEndActionText = when {
listingsRefreshBusy -> "..."
recentTasks.isNotEmpty() -> recentTasks.size.toString()
else -> ""
},
onOpenRecentTasks = {
showRecentTasksSheet = true
@@ -1078,6 +1127,29 @@ fun DeviceTabPage(
onOpenFullscreen = {
context.startActivity(StreamActivity.createIntent(context))
},
imeRequestToken = imeRequestToken,
onImeCommitText = { text ->
commitImeText(text)
},
onImeDeleteSurroundingText = { beforeLength, _ ->
withContext(Dispatchers.IO) {
repeat(beforeLength.coerceAtLeast(1)) {
scrcpy.injectKeycode(0, android.view.KeyEvent.KEYCODE_DEL)
scrcpy.injectKeycode(1, android.view.KeyEvent.KEYCODE_DEL)
}
}
},
onImeKeyEvent = { event ->
withContext(Dispatchers.IO) {
scrcpy.injectKeycode(
action = event.action,
keycode = event.keyCode,
repeat = event.repeatCount,
metaState = event.metaState,
)
}
true
},
autoBringIntoView = pendingScrollToPreview,
onAutoBringIntoViewConsumed = { pendingScrollToPreview = false },
)
@@ -1090,14 +1162,37 @@ fun DeviceTabPage(
moreActions = virtualButtonLayout.second,
showText = asBundle.previewVirtualButtonShowText,
onAction = { action ->
if (action == VirtualButtonAction.PASSWORD_INPUT) {
snackbar.show("当前页面无法拉起验证")
return@VirtualButtonCard
}
val keycode = action.keycode ?: return@VirtualButtonCard
runBusy("发送 ${action.title}") {
scrcpy.injectKeycode(0, keycode)
scrcpy.injectKeycode(1, keycode)
when (action) {
VirtualButtonAction.RECENT_TASKS -> {
showRecentTasksSheet = true
if (recentTasks.isEmpty() && !listingsRefreshBusy) {
scope.launch(Dispatchers.IO) { refreshApps() }
scope.launch(Dispatchers.IO) { refreshRecentTasks() }
}
}
VirtualButtonAction.ALL_APPS -> {
showAllAppsSheet = true
if (apps.isEmpty() && !listingsRefreshBusy) {
scope.launch(Dispatchers.IO) { refreshApps() }
}
}
VirtualButtonAction.TOGGLE_IME -> {
imeRequestToken++
}
VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> {
scope.launch { pasteLocalClipboard() }
}
else -> {
val keycode = action.keycode ?: return@VirtualButtonCard
runBusy("发送 ${action.title}") {
scrcpy.injectKeycode(0, keycode)
scrcpy.injectKeycode(1, keycode)
}
}
}
},
passwordPopupContent =
@@ -1131,10 +1226,43 @@ fun DeviceTabPage(
}
RecentTasksBottomSheet(
AppListBottomSheet(
show = showRecentTasksSheet,
tasks = recentTasks,
scrcpy = scrcpy,
title = "最近任务",
loadingText = "最近任务加载中",
emptyText = "没有可用的最近任务",
entries = recentTasks.map { task ->
val app = scrcpy.listings.findCachedApp(task.packageName)
AppListEntry(
key = task.packageName,
title = app?.label?.takeIf { it.isNotBlank() } ?: task.packageName,
summary = if (app?.label != null) task.packageName else null,
system = app?.system,
onClick = {
showRecentTasksSheet = false
if (sessionInfo == null) runBusy("启动 scrcpy") {
startScrcpySession(startAppOverride = task.packageName)
}
else runBusy("启动应用") {
runCatching {
scrcpy.startApp(task.packageName)
}.onSuccess {
logEvent("已在当前显示启动应用: ${task.packageName}")
}.onFailure { error ->
snackbar.show("通过 scrcpy 控制通道启动应用失败,回退 ADB")
logEvent(
"通过 scrcpy 控制通道启动应用失败,回退 ADB" +
": ${error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName}",
Log.WARN,
error,
)
adbCoordinator.startApp(packageName = task.packageName)
logEvent("已通过 ADB 启动应用: ${task.packageName}")
}
}
},
)
},
refreshBusy = listingsRefreshBusy,
onDismissRequest = { showRecentTasksSheet = false },
onRefresh = {
@@ -1149,33 +1277,52 @@ fun DeviceTabPage(
}
}
},
onLaunchTask = { task ->
showRecentTasksSheet = false
if (sessionInfo == null) {
runBusy("启动 scrcpy") {
startScrcpySession(startAppOverride = task.packageName)
}
} else {
runBusy("启动应用") {
runCatching {
scrcpy.startApp(task.packageName)
}.onSuccess {
logEvent("已在当前显示启动应用: ${task.packageName}")
}.onFailure { error ->
snackbar.show("通过 scrcpy 控制通道启动应用失败,回退 ADB")
logEvent(
"通过 scrcpy 控制通道启动应用失败,回退 ADB" +
": ${error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName}",
Log.WARN,
error,
)
adbCoordinator.startApp(packageName = task.packageName)
logEvent("已通过 ADB 启动应用: ${task.packageName}")
}
}
}
},
)
AppListBottomSheet(
show = showAllAppsSheet,
title = "所有应用",
loadingText = "应用列表加载中",
emptyText = "没有可用的应用列表",
entries = apps.map { app ->
AppListEntry(
key = app.packageName,
title = app.label?.takeIf { it.isNotBlank() } ?: app.packageName,
summary = if (app.label != null) app.packageName else null,
system = app.system,
onClick = {
showAllAppsSheet = false
if (sessionInfo == null) {
runBusy("启动 scrcpy") {
startScrcpySession(startAppOverride = app.packageName)
}
} else {
runBusy("启动应用") {
runCatching {
scrcpy.startApp(app.packageName)
}.onSuccess {
logEvent("已在当前显示启动应用: ${app.packageName}")
}.onFailure { error ->
snackbar.show("通过 scrcpy 控制通道启动应用失败,回退 ADB")
logEvent(
"通过 scrcpy 控制通道启动应用失败,回退 ADB" +
": ${error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName}",
Log.WARN,
error,
)
adbCoordinator.startApp(packageName = app.packageName)
logEvent("已通过 ADB 启动应用: ${app.packageName}")
}
}
}
},
)
},
refreshBusy = listingsRefreshBusy,
onDismissRequest = { showAllAppsSheet = false },
onRefresh = { scope.launch(Dispatchers.IO) { refreshApps() } },
)
}
@Composable
@@ -1217,158 +1364,6 @@ private fun DeviceMenuPopup(
}
}
@Composable
private fun RecentTasksBottomSheet(
show: Boolean,
tasks: List<Scrcpy.RecentTaskInfo>,
scrcpy: Scrcpy,
refreshBusy: Boolean,
onDismissRequest: () -> Unit,
onRefresh: () -> Unit,
onLaunchTask: (Scrcpy.RecentTaskInfo) -> Unit,
) {
val spinnerColors = SpinnerDefaults.spinnerColors()
OverlayBottomSheet(
show = show,
title = "最近任务",
defaultWindowInsetsPadding = false,
onDismissRequest = onDismissRequest,
endAction = {
IconButton(
onClick = {
if (!refreshBusy) {
onRefresh()
}
},
) {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = "刷新最近任务",
)
}
},
) {
Column(
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
when {
tasks.isEmpty() && refreshBusy -> {
Text(
text = "最近任务加载中",
modifier = Modifier.padding(horizontal = UiSpacing.Large),
)
}
tasks.isEmpty() -> {
Text(
text = "没有可用的最近任务",
modifier = Modifier.padding(horizontal = UiSpacing.Large),
)
}
else -> {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 420.dp),
) {
items(tasks.size) { index ->
val task = tasks[index]
val app = scrcpy.listings.findCachedApp(task.packageName)
val entry = SpinnerEntry(
icon = app?.system?.let { system ->
{ modifier ->
Icon(
imageVector =
if (system) Icons.Rounded.Android // MiuixIcons.Tune
else MiuixIcons.Store,
contentDescription = task.packageName,
modifier = modifier,
)
}
},
title = app?.label?.takeIf { it.isNotBlank() } ?: task.packageName,
summary = app?.let { task.packageName },
)
RecentTaskSheetItem(
entry = entry,
entryCount = tasks.size,
index = index,
spinnerColors = spinnerColors,
onClick = { onLaunchTask(task) },
)
}
}
}
}
Spacer(Modifier.height(UiSpacing.SheetBottom))
}
}
}
@Composable
private fun RecentTaskSheetItem(
entry: SpinnerEntry,
entryCount: Int,
index: Int,
spinnerColors: SpinnerColors,
onClick: () -> Unit,
) {
val additionalTopPadding = if (index == 0) 20.dp else 12.dp
val additionalBottomPadding = if (index == entryCount - 1) 20.dp else 12.dp
val checkColorFilter = remember {
BlendModeColorFilter(Color.Transparent, BlendMode.SrcIn)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.drawBehind { drawRect(spinnerColors.containerColor) }
.clickable(onClick = onClick)
.padding(horizontal = 20.dp)
.padding(top = additionalTopPadding, bottom = additionalBottomPadding),
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
entry.icon?.invoke(
Modifier
.sizeIn(minWidth = 26.dp, minHeight = 26.dp)
.padding(end = 12.dp)
)
Column {
entry.title?.let {
Text(
text = it,
fontSize = textStyles.body1.fontSize,
fontWeight = FontWeight.Medium,
color = spinnerColors.contentColor,
)
}
entry.summary?.let {
Text(
text = it,
fontSize = textStyles.body2.fontSize,
color = spinnerColors.summaryColor,
)
}
}
}
Image(
imageVector = MiuixIcons.Basic.Check,
contentDescription = null,
modifier = Modifier
.padding(start = 12.dp)
.sizeIn(minWidth = 20.dp, minHeight = 20.dp),
colorFilter = checkColorFilter,
)
}
}
@Composable
private fun DeviceMenuPopupItem(
text: String,

View File

@@ -3,6 +3,7 @@ package io.github.miuzarte.scrcpyforandroid.pages
import android.annotation.SuppressLint
import android.graphics.Rect
import android.util.Log
import android.view.KeyEvent
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.background
@@ -52,6 +53,8 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.widgets.AppListBottomSheet
import io.github.miuzarte.scrcpyforandroid.widgets.AppListEntry
import io.github.miuzarte.scrcpyforandroid.widgets.ScrcpyVideoSurface
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
@@ -83,6 +86,8 @@ fun FullscreenControlScreen(
val snackbar = LocalSnackbarController.current
val currentSession by scrcpy.currentSessionState.collectAsState()
val listingsRefreshBusy by scrcpy.listings.refreshBusyState.collectAsState()
val listingsRefreshVersion by scrcpy.listings.refreshVersionState.collectAsState()
val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
@@ -125,8 +130,13 @@ fun FullscreenControlScreen(
moreActions = buttonItems.second,
)
}
val recentTasks = remember(listingsRefreshVersion) { scrcpy.listings.recentTasks }
val apps = remember(listingsRefreshVersion) { scrcpy.listings.apps }
var currentFps by remember { mutableFloatStateOf(0f) }
var showRecentTasksSheet by rememberSaveable { mutableStateOf(false) }
var showAllAppsSheet by rememberSaveable { mutableStateOf(false) }
var imeRequestToken by rememberSaveable { mutableIntStateOf(0) }
DisposableEffect(activity) {
val window = activity?.window
@@ -178,6 +188,66 @@ fun FullscreenControlScreen(
}
}
suspend fun refreshApps() {
runCatching {
withContext(Dispatchers.IO) {
scrcpy.listings.getApps(forceRefresh = true)
}
}.onFailure { error ->
snackbar.show("获取应用列表失败")
Log.w("FullscreenControlPage", "refreshApps failed", error)
}
}
suspend fun refreshRecentTasks() {
runCatching {
withContext(Dispatchers.IO) {
scrcpy.listings.getRecentTasks(forceRefresh = true)
}
}.onFailure { error ->
snackbar.show("获取最近任务失败")
Log.w("FullscreenControlPage", "refreshRecentTasks failed", error)
}
}
suspend fun pasteLocalClipboard() {
val session = currentSession ?: return
val text = io.github.miuzarte.scrcpyforandroid.services.LocalInputService.getClipboardText(
activity ?: return
)
?.takeIf { it.isNotBlank() }
if (text == null) {
snackbar.show("本机剪贴板为空或不是文本")
return
}
val useLegacyPaste = session.legacyPaste
runCatching {
withContext(Dispatchers.IO) {
if (useLegacyPaste) {
scrcpy.injectText(text)
} else {
scrcpy.setClipboard(text, paste = true)
}
}
}.onFailure { error ->
Log.w("FullscreenControlPage", "pasteLocalClipboard failed", error)
snackbar.show(
if (useLegacyPaste) "legacy 粘贴失败"
else "剪贴板同步粘贴失败,可尝试开启 --legacy-paste"
)
}
}
suspend fun commitImeText(text: String) {
submitImeText(scrcpy, text) { error, useClipboardPaste ->
Log.w("FullscreenControlPage", "commitImeText failed", error)
snackbar.show(
if (useClipboardPaste) "非 ASCII 文本粘贴失败"
else "文本输入失败"
)
}
}
Scaffold(
contentWindowInsets = WindowInsets(0, 0, 0, 0),
snackbarHost = { SnackbarHost(snackbar.hostState) },
@@ -189,13 +259,16 @@ fun FullscreenControlScreen(
) {
val session = currentSession ?: return@Box
FullscreenControlPage(
scrcpy = scrcpy,
session = session,
onDismiss = onBack,
showDebugInfo = fullscreenDebugInfo && !isInPip,
currentFps = currentFps,
imeRequestToken = imeRequestToken,
enableBackHandler = false,
interactive = !isInPip,
onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged,
onImeCommitText = ::commitImeText,
onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
withContext(Dispatchers.IO) {
scrcpy.injectTouch(
@@ -217,10 +290,33 @@ fun FullscreenControlScreen(
bar.Fullscreen(
modifier = Modifier.align(Alignment.BottomCenter),
onAction = { action ->
if (action == VirtualButtonAction.PASSWORD_INPUT) {
snackbar.show("当前页面无法拉起验证")
} else {
action.keycode?.let { sendKeycode(it) }
when (action) {
VirtualButtonAction.RECENT_TASKS -> {
showRecentTasksSheet = true
if (recentTasks.isEmpty() && !listingsRefreshBusy) {
refreshApps()
refreshRecentTasks()
}
}
VirtualButtonAction.ALL_APPS -> {
showAllAppsSheet = true
if (apps.isEmpty() && !listingsRefreshBusy) {
refreshApps()
}
}
VirtualButtonAction.TOGGLE_IME -> {
imeRequestToken++
}
VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> {
pasteLocalClipboard()
}
else -> {
action.keycode?.let { sendKeycode(it) }
}
}
},
passwordPopupContent = if (fragmentActivity == null) {
@@ -243,10 +339,33 @@ fun FullscreenControlScreen(
actions = floatingActions,
modifier = Modifier.fillMaxSize(),
onAction = { action ->
if (action == VirtualButtonAction.PASSWORD_INPUT) {
snackbar.show("当前页面无法拉起验证")
} else {
action.keycode?.let { sendKeycode(it) }
when (action) {
VirtualButtonAction.RECENT_TASKS -> {
showRecentTasksSheet = true
if (recentTasks.isEmpty() && !listingsRefreshBusy) {
refreshApps()
refreshRecentTasks()
}
}
VirtualButtonAction.ALL_APPS -> {
showAllAppsSheet = true
if (apps.isEmpty() && !listingsRefreshBusy) {
refreshApps()
}
}
VirtualButtonAction.TOGGLE_IME -> {
imeRequestToken++
}
VirtualButtonAction.PASTE_LOCAL_CLIPBOARD -> {
pasteLocalClipboard()
}
else -> {
action.keycode?.let { sendKeycode(it) }
}
}
},
passwordPopupContent = if (fragmentActivity == null) {
@@ -263,6 +382,77 @@ fun FullscreenControlScreen(
},
)
}
AppListBottomSheet(
show = showRecentTasksSheet,
title = "最近任务",
loadingText = "最近任务加载中",
emptyText = "没有可用的最近任务",
entries = recentTasks.map { task ->
val app = scrcpy.listings.findCachedApp(task.packageName)
AppListEntry(
key = task.packageName,
title = app?.label?.takeIf { it.isNotBlank() } ?: task.packageName,
summary = if (app?.label != null) task.packageName else null,
system = app?.system,
onClick = {
showRecentTasksSheet = false
taskScope.launch {
runCatching {
withContext(Dispatchers.IO) {
scrcpy.startApp(task.packageName)
}
}.onFailure { error ->
snackbar.show("启动应用失败: ${error.message ?: error.javaClass.simpleName}")
}
}
},
)
},
refreshBusy = listingsRefreshBusy,
onDismissRequest = { showRecentTasksSheet = false },
onRefresh = {
taskScope.launch(Dispatchers.Main) {
refreshApps()
refreshRecentTasks()
}
},
)
AppListBottomSheet(
show = showAllAppsSheet,
title = "所有应用",
loadingText = "应用列表加载中",
emptyText = "没有可用的应用列表",
entries = apps.map { app ->
AppListEntry(
key = app.packageName,
title = app.label?.takeIf { it.isNotBlank() } ?: app.packageName,
summary = if (app.label != null) app.packageName else null,
system = app.system,
onClick = {
showAllAppsSheet = false
taskScope.launch {
runCatching {
withContext(Dispatchers.IO) {
scrcpy.startApp(app.packageName)
}
}.onFailure { error ->
snackbar.show("启动应用失败: ${error.message ?: error.javaClass.simpleName}")
}
}
},
)
},
refreshBusy = listingsRefreshBusy,
onDismissRequest = { showAllAppsSheet = false },
onRefresh = {
taskScope.launch(Dispatchers.Main) {
refreshApps()
}
},
)
}
}
}
@@ -285,13 +475,16 @@ fun FullscreenControlScreen(
*/
@Composable
fun FullscreenControlPage(
scrcpy: Scrcpy,
session: Scrcpy.Session.SessionInfo,
onDismiss: () -> Unit,
showDebugInfo: Boolean,
currentFps: Float,
imeRequestToken: Int = 0,
enableBackHandler: Boolean = true,
interactive: Boolean = true,
onVideoBoundsInWindowChanged: (Rect?) -> Unit = {},
onImeCommitText: suspend (String) -> Unit,
onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
) {
BackHandler(enabled = enableBackHandler, onBack = onDismiss)
@@ -372,6 +565,27 @@ fun FullscreenControlPage(
)
},
session = session,
imeRequestToken = imeRequestToken,
onImeCommitText = onImeCommitText,
onImeDeleteSurroundingText = { beforeLength, _ ->
withContext(Dispatchers.IO) {
repeat(beforeLength.coerceAtLeast(1)) {
scrcpy.injectKeycode(0, KeyEvent.KEYCODE_DEL)
scrcpy.injectKeycode(1, KeyEvent.KEYCODE_DEL)
}
}
},
onImeKeyEvent = { event ->
withContext(Dispatchers.IO) {
scrcpy.injectKeycode(
action = event.action,
keycode = event.keyCode,
repeat = event.repeatCount,
metaState = event.metaState,
)
}
true
},
)
}

View File

@@ -0,0 +1,27 @@
package io.github.miuzarte.scrcpyforandroid.pages
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal suspend fun submitImeText(
scrcpy: Scrcpy,
text: String,
onFailure: suspend (error: Throwable, useClipboardPaste: Boolean) -> Unit,
) {
if (text.isBlank()) {
return
}
val useClipboardPaste = text.any { it.code > 0x7F }
runCatching {
withContext(Dispatchers.IO) {
if (useClipboardPaste) {
scrcpy.setClipboard(text, paste = true)
} else {
scrcpy.injectText(text)
}
}
}.onFailure { error ->
onFailure(error, useClipboardPaste)
}
}

View File

@@ -9,16 +9,15 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -31,7 +30,6 @@ fun ReorderDevicesScreen(
show: Boolean,
onDismissRequest: () -> Unit,
) {
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val qdBundleShared by quickDevices.bundleState.collectAsState()

View File

@@ -47,6 +47,7 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSpinner
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
@@ -69,7 +70,6 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyProfiles
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -583,14 +583,11 @@ internal fun ScrcpyAllOptionsPage(
soBundle.cameraSizeCustom,
soBundle.cameraSizeUseCustom,
) {
if (cameraSizes.isNotEmpty()) {
null
} else if (soBundle.cameraSizeUseCustom && soBundle.cameraSizeCustom.isNotBlank()) {
soBundle.cameraSizeCustom
} else if (soBundle.cameraSize.isNotBlank()) {
soBundle.cameraSize
} else {
null
when {
cameraSizes.isNotEmpty() -> null
soBundle.cameraSizeUseCustom && soBundle.cameraSizeCustom.isNotBlank() -> soBundle.cameraSizeCustom
soBundle.cameraSize.isNotBlank() -> soBundle.cameraSize
else -> null
}
}
@@ -1583,7 +1580,7 @@ internal fun ScrcpyAllOptionsPage(
},
)
SwitchPreference(
title = "禁用结束后清理",
title = "禁用 MediaCodec 出错时自动降级",
summary = "--no-downsize-on-error",
checked = !soBundle.downsizeOnError,
onCheckedChange = {
@@ -1597,6 +1594,26 @@ internal fun ScrcpyAllOptionsPage(
)
},
)
SwitchPreference(
title = "粘贴兼容回退仅支持ASCII/英文字符)",
summary = "--legacy-paste",
checked = soBundle.legacyPaste,
onCheckedChange = {
soBundle = soBundle.copy(
legacyPaste = it
)
},
)
SwitchPreference(
title = "禁用剪贴板双向同步",
summary = "--no-clipboard-autosync",
checked = !soBundle.clipboardAutosync,
onCheckedChange = {
soBundle = soBundle.copy(
clipboardAutosync = !it
)
},
)
SwitchPreference(
title = "禁用结束后清理",
summary = "--no-cleanup",

View File

@@ -34,6 +34,7 @@ import io.github.miuzarte.scrcpyforandroid.LockscreenPasswordActivity
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SectionSmallTitle
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
@@ -47,7 +48,6 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -286,6 +286,14 @@ fun SettingsPage(
asBundle = asBundle.copy(fullscreenDebugInfo = it)
},
)
SwitchPreference(
title = "设备页隐藏简单设置项",
summary = "启用后设备页仅保留更多参数、所有应用、最近任务和启动/停止按钮",
checked = asBundle.hideSimpleConfigItems,
onCheckedChange = {
asBundle = asBundle.copy(hideSimpleConfigItems = it)
},
)
SuperSlider(
title = "预览卡高度",
summary = "设备页预览卡高度",
@@ -343,6 +351,16 @@ fun SettingsPage(
asBundle = asBundle.copy(showFullscreenFloatingButton = it)
},
)
SwitchPreference(
title = "实时同步剪贴板到受控机",
summary = "本机剪贴板更新后会自动同步到受控机" +
"\n禁用后需要使用虚拟按钮中的粘贴才能粘贴本机内容" +
"\nMIUI 完全不允许后台监听剪贴板,因此该选项在小米设备上可能无效",
checked = asBundle.realtimeClipboardSyncToDevice,
onCheckedChange = {
asBundle = asBundle.copy(realtimeClipboardSyncToDevice = it)
},
)
}
}

View File

@@ -21,12 +21,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.ReorderableList
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import kotlinx.coroutines.CoroutineScope

View File

@@ -1,4 +1,4 @@
package io.github.miuzarte.scrcpyforandroid.widgets
package io.github.miuzarte.scrcpyforandroid.scaffolds
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement

View File

@@ -1,17 +1,24 @@
package io.github.miuzarte.scrcpyforandroid.widgets
package io.github.miuzarte.scrcpyforandroid.scaffolds
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import top.yukonga.miuix.kmp.basic.SmallTitle
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
@Composable
fun SectionSmallTitle(
text: String,
modifier: Modifier = Modifier,
textColor: Color = colorScheme.onBackgroundVariant,
insideMargin: PaddingValues = PaddingValues(16.dp, 8.dp),
) {
SmallTitle(
text = text,
modifier = modifier,
textColor = textColor,
insideMargin = insideMargin,
)
}

View File

@@ -153,13 +153,13 @@ data class ClientOptions(
var disableScreensaver: Boolean = false,
// var forwardKeyRepeat: Boolean,
// var legacyPaste: Boolean,
var legacyPaste: Boolean = false,
// --power-off-on-close
var powerOffOnClose: Boolean = false, // to server
// --no-clipboard-autosync
// var clipboardAutosync: Boolean = true, // to server
var clipboardAutosync: Boolean = true, // to server
// --no-downsize-on-error
var downsizeOnError: Boolean = true, // to server
@@ -555,6 +555,8 @@ data class ClientOptions(
cameraFps = cameraFps,
powerOffOnClose = powerOffOnClose,
legacyPaste = legacyPaste,
clipboardAutosync = clipboardAutosync,
downsizeOnError = downsizeOnError,
@@ -569,4 +571,4 @@ data class ClientOptions(
list = list,
)
}
}
}

View File

@@ -1,5 +1,6 @@
package io.github.miuzarte.scrcpyforandroid.scrcpy
import android.content.ClipboardManager
import android.content.Context
import android.net.Uri
import android.util.Log
@@ -15,11 +16,16 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.EncoderType
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.LocalInputService
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -61,7 +67,24 @@ class Scrcpy(
private val lowLatency: Boolean = false,
) {
private val session = Session()
private val session = Session(::handleRemoteClipboardText)
private val backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val clipboardSyncLock = Any()
private val clipboardManager by lazy {
appContext.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
}
@Volatile
private var clipboardListenerRegistered = false
@Volatile
private var lastKnownLocalClipboardText: String? = null
@Volatile
private var pendingRemoteClipboardText: String? = null
private val clipboardChangedListener = ClipboardManager.OnPrimaryClipChangedListener {
handleLocalClipboardChanged()
}
private val _currentSessionState = MutableStateFlow<Session.SessionInfo?>(null)
val currentSessionState: StateFlow<Session.SessionInfo?> = _currentSessionState.asStateFlow()
@@ -143,8 +166,9 @@ class Scrcpy(
}
// Create session info
_currentSessionState.value = info
_currentSessionState.value = info.copy(legacyPaste = options.legacyPaste)
isRunning = true
startClipboardSync()
// Setup video consumer (notify NativeCoreFacade to setup decoders)
if (options.video) {
@@ -204,6 +228,7 @@ class Scrcpy(
audioPlayer = null
isRunning = false
_currentSessionState.value = null
stopClipboardSync()
Log.i(TAG, "stop(): Session stopped successfully")
true
} catch (e: Exception) {
@@ -214,8 +239,6 @@ class Scrcpy(
fun isStarted(): Boolean = isRunning && session.isStarted()
fun getCurrentSession(): Session.SessionInfo? = currentSessionState.value
suspend fun startApp(name: String) = session.startApp(name)
suspend fun injectKeycode(
@@ -232,6 +255,8 @@ class Scrcpy(
suspend fun injectText(text: String) = session.injectText(text)
suspend fun setClipboard(text: String, paste: Boolean) = session.setClipboard(text, paste)
suspend fun injectTouch(
action: Int,
pointerId: Long,
@@ -278,34 +303,69 @@ class Scrcpy(
_currentSessionState.value = current.copy(width = width, height = height)
}
sealed class ListResult {
data class Encoders(
val videoEncoders: List<String>,
val audioEncoders: List<String>,
val videoEncoderTypes: Map<String, String> = emptyMap(),
val audioEncoderTypes: Map<String, String> = emptyMap(),
val rawOutput: String = "",
) : ListResult()
private fun startClipboardSync() {
synchronized(clipboardSyncLock) {
lastKnownLocalClipboardText = LocalInputService.getClipboardText(appContext)
pendingRemoteClipboardText = null
if (!clipboardListenerRegistered) {
clipboardManager?.addPrimaryClipChangedListener(clipboardChangedListener)
clipboardListenerRegistered = true
}
}
}
data class Displays(
val displays: List<DisplayInfo>,
val rawOutput: String = "",
) : ListResult()
private fun stopClipboardSync() {
synchronized(clipboardSyncLock) {
if (clipboardListenerRegistered) {
clipboardManager?.removePrimaryClipChangedListener(clipboardChangedListener)
clipboardListenerRegistered = false
}
pendingRemoteClipboardText = null
}
}
data class Cameras(
val cameras: List<CameraInfo>,
val rawOutput: String = "",
) : ListResult()
private fun handleLocalClipboardChanged() {
val text = LocalInputService.getClipboardText(appContext)
val shouldSync = synchronized(clipboardSyncLock) {
if (text == lastKnownLocalClipboardText) {
return@synchronized false
}
lastKnownLocalClipboardText = text
if (text != null && text == pendingRemoteClipboardText) {
pendingRemoteClipboardText = null
return@synchronized false
}
true
}
if (!shouldSync) {
return
}
if (!isRunning || !appSettings.bundleState.value.realtimeClipboardSyncToDevice) {
return
}
val content = text?.takeIf { it.isNotBlank() } ?: return
backgroundScope.launch {
runCatching {
session.setClipboard(content, paste = false)
}.onFailure { error ->
Log.w(TAG, "realtime clipboard sync failed", error)
}
}
}
data class CameraSizes(
val sizes: List<String>,
val rawOutput: String = "",
) : ListResult()
data class Apps(
val apps: List<AppInfo>,
val rawOutput: String = "",
) : ListResult()
private fun handleRemoteClipboardText(text: String) {
val shouldWrite = synchronized(clipboardSyncLock) {
if (text == lastKnownLocalClipboardText) {
return@synchronized false
}
pendingRemoteClipboardText = text
lastKnownLocalClipboardText = text
true
}
if (!shouldWrite) {
return
}
LocalInputService.setClipboardText(appContext, text)
}
inner class Listings {
@@ -740,7 +800,9 @@ class Scrcpy(
* Session manager for scrcpy protocol.
* Handles socket communication, video/audio streaming, and control input.
*/
class Session {
class Session(
private val onRemoteClipboardText: (String) -> Unit,
) {
private val mutex = Mutex()
@Volatile
@@ -758,6 +820,9 @@ class Scrcpy(
@Volatile
private var audioReaderThread: Thread? = null
@Volatile
private var controlReaderThread: Thread? = null
private val serverLogBuffer = ArrayDeque<String>()
suspend fun start(
@@ -881,6 +946,10 @@ class Scrcpy(
val controlWriter = controlStream?.let { stream ->
ControlWriter(DataOutputStream(stream.outputStream))
}
val controlInput = controlStream?.let { stream ->
if (stream === firstStream) firstInput
else DataInputStream(BufferedInputStream(stream.inputStream))
}
val newSession = ActiveSession(
info = sessionInfo,
@@ -892,9 +961,13 @@ class Scrcpy(
audioStream = audioStream,
audioInput = audioInput,
controlStream = controlStream,
controlInput = controlInput,
controlWriter = controlWriter,
)
activeSession = newSession
if (options.control && options.clipboardAutosync)
startControlReaderThread(newSession)
return sessionInfo
} catch (t: Throwable) {
val tail = snapshotServerLogs()
@@ -1031,6 +1104,14 @@ class Scrcpy(
}
}
suspend fun setClipboard(text: String, paste: Boolean) = mutex.withLock {
try {
requireControlWriter().setClipboard(text, paste)
} catch (e: IllegalStateException) {
Log.w(TAG, "setClipboard(): control channel not available", e)
}
}
suspend fun injectTouch(
action: Int,
pointerId: Long,
@@ -1121,6 +1202,12 @@ class Scrcpy(
}
audioReaderThread = null
if (Thread.currentThread() !== controlReaderThread) {
runCatching { controlReaderThread?.interrupt() }
runCatching { controlReaderThread?.join(300) }
}
controlReaderThread = null
runCatching { session.controlStream?.close() }
runCatching { session.audioStream?.close() }
runCatching { session.videoStream?.close() }
@@ -1140,6 +1227,57 @@ class Scrcpy(
?: throw IllegalStateException("scrcpy control channel not available")
}
private fun startControlReaderThread(session: ActiveSession) {
val controlInput = session.controlInput ?: return
val controlStream = session.controlStream ?: return
if (controlReaderThread?.isAlive == true) return
controlReaderThread = thread(start = true, name = "scrcpy-control-reader") {
try {
while (activeSession === session && !controlStream.closed) {
try {
when (controlInput.readUnsignedByte()) {
DEVICE_MSG_TYPE_CLIPBOARD -> {
val size = controlInput.readInt()
if (size !in 0..DEVICE_MSG_MAX_SIZE) {
throw IllegalStateException("Invalid clipboard size: $size")
}
val payload = ByteArray(size)
controlInput.readFully(payload)
onRemoteClipboardText(payload.toString(Charsets.UTF_8))
}
DEVICE_MSG_TYPE_ACK_CLIPBOARD -> {
controlInput.readLong()
}
DEVICE_MSG_TYPE_UHID_OUTPUT -> {
controlInput.readUnsignedShort()
val size = controlInput.readUnsignedShort()
controlInput.skipBytes(size)
}
else -> {
throw IllegalStateException("Unknown device message type")
}
}
} catch (_: EOFException) {
break
} catch (_: InterruptedException) {
if (activeSession !== session || controlStream.closed) {
break
}
Thread.interrupted()
} catch (e: Exception) {
Log.w(TAG, "control reader failed", e)
break
}
}
} finally {
}
}
}
private fun startServerLogThread(
serverStream: AdbSocketStream,
socketName: String
@@ -1223,6 +1361,7 @@ class Scrcpy(
val height: Int,
val audioCodecId: Int = 0,
val controlEnabled: Boolean,
val legacyPaste: Boolean = false,
val host: String = "",
val port: Int = Defaults.ADB_PORT,
)
@@ -1286,6 +1425,7 @@ class Scrcpy(
val audioStream: AdbSocketStream?,
val audioInput: DataInputStream?,
val controlStream: AdbSocketStream?,
val controlInput: DataInputStream?,
val controlWriter: ControlWriter?,
)
@@ -1309,6 +1449,17 @@ class Scrcpy(
output.flush()
}
@Synchronized
fun setClipboard(text: String, paste: Boolean) {
val bytes = text.toByteArray(Charsets.UTF_8)
output.writeByte(TYPE_SET_CLIPBOARD)
output.writeLong(CLIPBOARD_SEQUENCE_INVALID)
output.writeByte(if (paste) 1 else 0)
output.writeInt(bytes.size)
output.write(bytes)
output.flush()
}
@Synchronized
fun injectTouch(
action: Int,
@@ -1415,14 +1566,20 @@ class Scrcpy(
private const val AUDIO_DISABLED = 0
private const val AUDIO_ERROR = 1
private const val DEVICE_MSG_MAX_SIZE = 1 shl 18
private const val DEVICE_MSG_TYPE_CLIPBOARD = 0
private const val DEVICE_MSG_TYPE_ACK_CLIPBOARD = 1
private const val DEVICE_MSG_TYPE_UHID_OUTPUT = 2
private const val TYPE_INJECT_KEYCODE = 0
private const val TYPE_INJECT_TEXT = 1
private const val TYPE_INJECT_TOUCH_EVENT = 2
private const val TYPE_INJECT_SCROLL_EVENT = 3
private const val TYPE_BACK_OR_SCREEN_ON = 4
private const val TYPE_SET_CLIPBOARD = 9
private const val TYPE_SET_DISPLAY_POWER = 10
private const val TYPE_START_APP = 16
private const val CLIPBOARD_SEQUENCE_INVALID = 0L
private fun socketNameFor(scid: Int): String {
return "scrcpy_%08x".format(scid)

View File

@@ -72,6 +72,8 @@ data class ServerParams(
// var forceAdbForward: Boolean,
var powerOffOnClose: Boolean,
var legacyPaste: Boolean,
var clipboardAutosync: Boolean,
var downsizeOnError: Boolean,
// var tcpip: Boolean,
@@ -236,9 +238,10 @@ data class ServerParams(
if (powerOffOnClose) {
cmd.add("power_off_on_close=true")
}
// not implemented
val clipBoardAutosync = false
if (!clipBoardAutosync) {
if (legacyPaste) {
cmd.add("legacy_paste=true")
}
if (!clipboardAutosync) {
cmd.add("clipboard_autosync=false")
}
if (!downsizeOnError) {

View File

@@ -0,0 +1,45 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.content.ClipData
import android.content.ClipDescription
import android.content.ClipboardManager
import android.content.Context
import android.view.View
import android.view.inputmethod.InputMethodManager
object LocalInputService {
fun showSoftKeyboard(view: View) {
view.requestFocus()
val inputManager =
view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
?: return
inputManager.showSoftInput(view, 0)
}
fun getClipboardText(context: Context): String? {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
?: return null
if (!clipboard.hasPrimaryClip()) {
return null
}
val clip = clipboard.primaryClip ?: return null
if (clip.itemCount <= 0) {
return null
}
val description = clipboard.primaryClipDescription
if (description != null &&
!description.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) &&
!description.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML) &&
!description.hasMimeType(ClipDescription.MIMETYPE_TEXT_URILIST)
) {
return null
}
return clip.getItemAt(0).coerceToText(context)?.toString()
}
fun setClipboardText(context: Context, text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
?: return
clipboard.setPrimaryClip(ClipData.newPlainText("scrcpy", text))
}
}

View File

@@ -62,6 +62,10 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
floatPreferencesKey("fullscreen_floating_button_y_fraction"),
0.72f
)
val HIDE_SIMPLE_CONFIG_ITEMS = Pair(
booleanPreferencesKey("hide_simple_config_items"),
false,
)
val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair(
intPreferencesKey("device_preview_card_height_dp"),
1080 / 3
@@ -72,7 +76,21 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
)
val VIRTUAL_BUTTONS_LAYOUT = Pair(
stringPreferencesKey("virtual_buttons_layout"),
"more:1,password_input:0,app_switch:1,home:0,back:1,menu:0,notification:0,volume_up:0,volume_down:0,volume_mute:0,power:0,screenshot:0"
"more:1" +
",app_switch:1,home:0,back:1" +
",password_input:0" +
",all_apps:0" +
",recent_tasks:0" +
",toggle_ime:0" +
",paste_local_clipboard:0" +
",menu:0,notification:0" +
",volume_up:0,volume_down:0,volume_mute:0" +
",power:0,screenshot:0" +
""
)
val CUSTOM_SERVER_URI = Pair(
stringPreferencesKey("custom_server_uri"),
@@ -111,6 +129,10 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
booleanPreferencesKey("password_require_auth"),
true
)
val REALTIME_CLIPBOARD_SYNC_TO_DEVICE = Pair(
booleanPreferencesKey("realtime_clipboard_sync_to_device"),
true
)
val LAST_UPDATE_CHECK_AT = Pair(
longPreferencesKey("last_update_check_at"),
0L
@@ -132,6 +154,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val showFullscreenFloatingButton by setting(SHOW_FULLSCREEN_FLOATING_BUTTON)
val fullscreenFloatingButtonXFraction by setting(FULLSCREEN_FLOATING_BUTTON_X_FRACTION)
val fullscreenFloatingButtonYFraction by setting(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION)
val hideSimpleConfigItems by setting(HIDE_SIMPLE_CONFIG_ITEMS)
val devicePreviewCardHeightDp by setting(DEVICE_PREVIEW_CARD_HEIGHT_DP)
val previewVirtualButtonShowText by setting(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT)
val virtualButtonsLayout by setting(VIRTUAL_BUTTONS_LAYOUT)
@@ -148,6 +171,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY)
val adbAutoLoadAppListOnConnect by setting(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT)
val passwordRequireAuth by setting(PASSWORD_REQUIRE_AUTH)
val realtimeClipboardSyncToDevice by setting(REALTIME_CLIPBOARD_SYNC_TO_DEVICE)
val lastUpdateCheckAt by setting(LAST_UPDATE_CHECK_AT)
@Parcelize
@@ -164,6 +188,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val showFullscreenFloatingButton: Boolean,
val fullscreenFloatingButtonXFraction: Float,
val fullscreenFloatingButtonYFraction: Float,
val hideSimpleConfigItems: Boolean,
val devicePreviewCardHeightDp: Int,
val previewVirtualButtonShowText: Boolean,
val virtualButtonsLayout: String,
@@ -176,6 +201,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val adbMdnsLanDiscovery: Boolean,
val adbAutoLoadAppListOnConnect: Boolean,
val passwordRequireAuth: Boolean,
val realtimeClipboardSyncToDevice: Boolean,
val lastUpdateCheckAt: Long,
) : Parcelable {
}
@@ -193,6 +219,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
bundleField(SHOW_FULLSCREEN_FLOATING_BUTTON) { bundle: Bundle -> bundle.showFullscreenFloatingButton },
bundleField(FULLSCREEN_FLOATING_BUTTON_X_FRACTION) { bundle: Bundle -> bundle.fullscreenFloatingButtonXFraction },
bundleField(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION) { bundle: Bundle -> bundle.fullscreenFloatingButtonYFraction },
bundleField(HIDE_SIMPLE_CONFIG_ITEMS) { bundle: Bundle -> bundle.hideSimpleConfigItems },
bundleField(DEVICE_PREVIEW_CARD_HEIGHT_DP) { bundle: Bundle -> bundle.devicePreviewCardHeightDp },
bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText },
bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout },
@@ -205,6 +232,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
bundleField(ADB_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery },
bundleField(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) { bundle: Bundle -> bundle.adbAutoLoadAppListOnConnect },
bundleField(PASSWORD_REQUIRE_AUTH) { bundle: Bundle -> bundle.passwordRequireAuth },
bundleField(REALTIME_CLIPBOARD_SYNC_TO_DEVICE) { bundle: Bundle -> bundle.realtimeClipboardSyncToDevice },
bundleField(LAST_UPDATE_CHECK_AT) { bundle: Bundle -> bundle.lastUpdateCheckAt },
)
@@ -223,6 +251,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
showFullscreenFloatingButton = preferences.read(SHOW_FULLSCREEN_FLOATING_BUTTON),
fullscreenFloatingButtonXFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_X_FRACTION),
fullscreenFloatingButtonYFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION),
hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS),
devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP),
previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT),
virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT),
@@ -237,6 +266,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY),
adbAutoLoadAppListOnConnect = preferences.read(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT),
passwordRequireAuth = preferences.read(PASSWORD_REQUIRE_AUTH),
realtimeClipboardSyncToDevice = preferences.read(REALTIME_CLIPBOARD_SYNC_TO_DEVICE),
lastUpdateCheckAt = preferences.read(LAST_UPDATE_CHECK_AT),
)

View File

@@ -188,6 +188,14 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
booleanPreferencesKey("power_off_on_close"),
false,
)
val LEGACY_PASTE = Pair(
booleanPreferencesKey("legacy_paste"),
false,
)
val CLIPBOARD_AUTOSYNC = Pair(
booleanPreferencesKey("clipboard_autosync"),
true,
)
val DOWNSIZE_ON_ERROR = Pair(
booleanPreferencesKey("downsize_on_error"),
true,
@@ -294,6 +302,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
stayAwake = STAY_AWAKE.defaultValue,
disableScreensaver = DISABLE_SCREENSAVER.defaultValue,
powerOffOnClose = POWER_OFF_ON_CLOSE.defaultValue,
legacyPaste = LEGACY_PASTE.defaultValue,
clipboardAutosync = CLIPBOARD_AUTOSYNC.defaultValue,
downsizeOnError = DOWNSIZE_ON_ERROR.defaultValue,
cleanup = CLEANUP.defaultValue,
powerOn = POWER_ON.defaultValue,
@@ -353,6 +363,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
val stayAwake by setting(STAY_AWAKE)
val disableScreensaver by setting(DISABLE_SCREENSAVER)
val powerOffOnClose by setting(POWER_OFF_ON_CLOSE)
val legacyPaste by setting(LEGACY_PASTE)
val clipboardAutosync by setting(CLIPBOARD_AUTOSYNC)
val cleanup by setting(CLEANUP)
val powerOn by setting(POWER_ON)
val video by setting(VIDEO)
@@ -409,6 +421,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
val stayAwake: Boolean,
val disableScreensaver: Boolean,
val powerOffOnClose: Boolean,
val legacyPaste: Boolean,
val clipboardAutosync: Boolean,
val downsizeOnError: Boolean,
val cleanup: Boolean,
val powerOn: Boolean,
@@ -469,6 +483,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
bundleField(STAY_AWAKE) { bundle: Bundle -> bundle.stayAwake },
bundleField(DISABLE_SCREENSAVER) { bundle: Bundle -> bundle.disableScreensaver },
bundleField(POWER_OFF_ON_CLOSE) { bundle: Bundle -> bundle.powerOffOnClose },
bundleField(LEGACY_PASTE) { bundle: Bundle -> bundle.legacyPaste },
bundleField(CLIPBOARD_AUTOSYNC) { bundle: Bundle -> bundle.clipboardAutosync },
bundleField(CLEANUP) { bundle: Bundle -> bundle.cleanup },
bundleField(POWER_ON) { bundle: Bundle -> bundle.powerOn },
bundleField(VIDEO) { bundle: Bundle -> bundle.video },
@@ -529,6 +545,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
stayAwake = preferences.read(STAY_AWAKE),
disableScreensaver = preferences.read(DISABLE_SCREENSAVER),
powerOffOnClose = preferences.read(POWER_OFF_ON_CLOSE),
legacyPaste = preferences.read(LEGACY_PASTE),
clipboardAutosync = preferences.read(CLIPBOARD_AUTOSYNC),
downsizeOnError = preferences.read(DOWNSIZE_ON_ERROR),
cleanup = preferences.read(CLEANUP),
powerOn = preferences.read(POWER_ON),
@@ -604,6 +622,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
stayAwake = bundle.stayAwake,
disableScreensaver = bundle.disableScreensaver,
powerOffOnClose = bundle.powerOffOnClose,
legacyPaste = bundle.legacyPaste,
clipboardAutosync = bundle.clipboardAutosync,
downsizeOnError = bundle.downsizeOnError,
cleanup = bundle.cleanup,
powerOn = bundle.powerOn,

View File

@@ -0,0 +1,151 @@
package io.github.miuzarte.scrcpyforandroid.widgets
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Android
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.SpinnerColors
import top.yukonga.miuix.kmp.basic.SpinnerDefaults
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Store
import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet
import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles
data class AppListEntry(
val key: String,
val title: String,
val summary: String? = null,
val system: Boolean? = null,
val onClick: () -> Unit,
)
@Composable
fun AppListBottomSheet(
show: Boolean,
title: String,
loadingText: String,
emptyText: String,
entries: List<AppListEntry>,
refreshBusy: Boolean,
onDismissRequest: () -> Unit,
onRefresh: () -> Unit,
) {
val spinnerColors = SpinnerDefaults.spinnerColors()
OverlayBottomSheet(
show = show,
title = title,
defaultWindowInsetsPadding = false,
onDismissRequest = onDismissRequest,
endAction = {
IconButton(
onClick = { if (!refreshBusy) onRefresh() },
) {
Icon(
imageVector = Icons.Rounded.Refresh,
contentDescription = "刷新$title",
)
}
},
) {
when {
entries.isEmpty() && refreshBusy -> {
Text(
text = loadingText,
modifier = Modifier
.fillMaxWidth()
.padding(UiSpacing.Large),
textAlign = TextAlign.Center,
)
}
entries.isEmpty() -> {
Text(
text = emptyText,
modifier = Modifier
.fillMaxWidth()
.padding(UiSpacing.Large),
textAlign = TextAlign.Center,
)
}
else -> {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(2f / 3f),
) {
items(items = entries, key = { it.key }) { entry ->
AppListBottomSheetItem(
entry = entry,
spinnerColors = spinnerColors,
)
}
}
}
}
}
}
@Composable
private fun AppListBottomSheetItem(
entry: AppListEntry,
spinnerColors: SpinnerColors,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.background(spinnerColors.containerColor)
.clickable(onClick = entry.onClick)
.padding(horizontal = 20.dp, vertical = 12.dp),
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector =
if (entry.system == true) Icons.Rounded.Android
else MiuixIcons.Store,
contentDescription = entry.title.ifBlank { entry.summary ?: "" },
modifier = Modifier
.sizeIn(minWidth = 26.dp, minHeight = 26.dp)
.padding(end = 12.dp),
)
Column {
Text(
text = entry.title,
fontSize = textStyles.body1.fontSize,
color = spinnerColors.contentColor,
)
entry.summary?.let {
Text(
text = it,
fontSize = textStyles.body2.fontSize,
color = spinnerColors.summaryColor,
)
}
}
}
}
}

View File

@@ -1,9 +1,9 @@
package io.github.miuzarte.scrcpyforandroid.widgets
import android.annotation.SuppressLint
import android.view.KeyEvent
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
@@ -86,6 +85,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.ButtonDefaults
@@ -104,6 +105,7 @@ import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference
import top.yukonga.miuix.kmp.preference.SwitchPreference
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor
import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles
import top.yukonga.miuix.kmp.utils.PressFeedbackType
import kotlin.math.roundToInt
@@ -253,6 +255,10 @@ internal fun PreviewCard(
sessionInfo: Scrcpy.Session.SessionInfo?,
previewHeightDp: Int,
onOpenFullscreen: () -> Unit,
imeRequestToken: Int = 0,
onImeCommitText: (suspend (String) -> Unit)? = null,
onImeDeleteSurroundingText: (suspend (beforeLength: Int, afterLength: Int) -> Unit)? = null,
onImeKeyEvent: (suspend (KeyEvent) -> Boolean)? = null,
autoBringIntoView: Boolean = false,
onAutoBringIntoViewConsumed: () -> Unit = {},
) {
@@ -335,6 +341,10 @@ internal fun PreviewCard(
ScrcpyVideoSurface(
modifier = Modifier.fillMaxSize(),
session = sessionInfo,
imeRequestToken = imeRequestToken,
onImeCommitText = onImeCommitText,
onImeDeleteSurroundingText = onImeDeleteSurroundingText,
onImeKeyEvent = onImeKeyEvent,
)
}
}
@@ -401,14 +411,17 @@ internal fun ConfigPanel(
busy: Boolean,
activeProfileId: String,
activeBundle: ScrcpyOptions.Bundle,
hideSimpleConfigItems: Boolean,
audioForwardingSupported: Boolean,
cameraMirroringSupported: Boolean,
adbConnecting: Boolean,
isQuickConnected: Boolean,
recentTasksEndAction: @Composable (RowScope.() -> Unit)? = null,
onOpenRecentTasks: (() -> Unit)? = null,
allAppsEndActionText: String,
onOpenAllApps: () -> Unit,
recentTasksEndActionText: String,
onOpenRecentTasks: () -> Unit,
onOpenAdvanced: () -> Unit,
onStartStopHaptic: (() -> Unit)? = null,
onStartStopHaptic: () -> Unit,
onStart: () -> Unit,
onStop: () -> Unit,
sessionInfo: Scrcpy.Session.SessionInfo?,
@@ -470,149 +483,167 @@ internal fun ConfigPanel(
}
Card {
SwitchPreference(
title = "音频转发",
summary = "转发设备音频到本机 (Android 11+)",
checked = soBundle.audio,
onCheckedChange = { soBundle = soBundle.copy(audio = it) },
enabled = !sessionStarted
&& audioForwardingSupported,
)
if (!hideSimpleConfigItems) {
SwitchPreference(
title = "音频转发",
summary = "转发设备音频到本机 (Android 11+)",
checked = soBundle.audio,
onCheckedChange = { soBundle = soBundle.copy(audio = it) },
enabled = !sessionStarted
&& audioForwardingSupported,
)
OverlayDropdownPreference(
title = "音频编码",
summary = "--audio-codec",
items = audioCodecItems,
selectedIndex = audioCodecIndex,
onSelectedIndexChange = {
val codec = Codec.AUDIO[it]
soBundle = soBundle.copy(audioCodec = codec.string)
if (codec == Codec.FLAC) {
snackbar.show("注意FLAC编解码会引入较大的延迟")
}
},
enabled = !sessionStarted && soBundle.audio,
)
AnimatedVisibility(audioBitRateVisibility) {
OverlayDropdownPreference(
title = "音频编码",
summary = "--audio-codec",
items = audioCodecItems,
selectedIndex = audioCodecIndex,
onSelectedIndexChange = {
val codec = Codec.AUDIO[it]
soBundle = soBundle.copy(audioCodec = codec.string)
if (codec == Codec.FLAC) {
snackbar.show("注意FLAC编解码会引入较大的延迟")
}
},
enabled = !sessionStarted && soBundle.audio,
)
AnimatedVisibility(audioBitRateVisibility) {
SuperSlider(
title = "音频码率",
summary = "--audio-bit-rate",
value = ScrcpyPresets.AudioBitRate
.indexOfOrNearest(soBundle.audioBitRate / 1000)
.toFloat(),
onValueChange = { value ->
val idx = value.roundToInt()
.coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex)
soBundle = soBundle.copy(
audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000
)
},
valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(),
steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0),
unit = "Kbps",
zeroStateText = "默认",
displayText = (soBundle.audioBitRate / 1_000).toString(),
inputInitialValue =
if (soBundle.audioBitRate <= 0) ""
else (soBundle.audioBitRate / 1_000).toString(),
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..UShort.MAX_VALUE.toFloat(),
onInputConfirm = { raw ->
raw.toIntOrNull()
?.takeIf { it >= 0 }
?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) }
},
enabled = !sessionStarted,
)
}
OverlayDropdownPreference(
title = "视频编码",
summary = "--video-codec",
items = videoCodecItems,
selectedIndex = videoCodecIndex,
onSelectedIndexChange = {
val codec = Codec.VIDEO[it]
soBundle = soBundle.copy(videoCodec = codec.string)
if (codec == Codec.AV1) {
snackbar.show("注意绝大部分设备不支持AV1硬件编码")
}
},
enabled = !sessionStarted,
)
@SuppressLint("DefaultLocale")
SuperSlider(
title = "频码率",
summary = "--audio-bit-rate",
value = ScrcpyPresets.AudioBitRate
.indexOfOrNearest(soBundle.audioBitRate / 1000)
.toFloat(),
onValueChange = { value ->
val idx = value.roundToInt()
.coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex)
title = "频码率",
summary = "--video-bit-rate",
value = soBundle.videoBitRate / 1_000_000f,
onValueChange = { mbps ->
soBundle = soBundle.copy(
audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000
videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10)
)
},
valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(),
steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0),
unit = "Kbps",
valueRange = 0f..40f,
steps = 400 - 1,
unit = "Mbps",
zeroStateText = "默认",
displayText = (soBundle.audioBitRate / 1_000).toString(),
displayFormatter = { String.format("%.1f", it) },
inputInitialValue =
if (soBundle.audioBitRate <= 0) ""
else (soBundle.audioBitRate / 1_000).toString(),
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..UShort.MAX_VALUE.toFloat(),
if (soBundle.videoBitRate <= 0) ""
else String.format("%.1f", soBundle.videoBitRate / 1_000_000f),
inputFilter = { text ->
var dotUsed = false
text.filter { ch ->
when {
ch.isDigit() -> true
ch == '.' && !dotUsed -> {
dotUsed = true
true
}
else -> false
}
}
},
inputValueRange = 0f..UInt.MAX_VALUE.toFloat(),
onInputConfirm = { raw ->
raw.toIntOrNull()
?.takeIf { it >= 0 }
?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) }
raw.toFloatOrNull()?.let { parsed ->
if (parsed >= 0f) {
soBundle = soBundle.copy(
videoBitRate = (parsed * 1_000_000f).roundToInt()
)
}
}
},
enabled = !sessionStarted,
)
}
OverlayDropdownPreference(
title = "视频编码",
summary = "--video-codec",
items = videoCodecItems,
selectedIndex = videoCodecIndex,
onSelectedIndexChange = {
val codec = Codec.VIDEO[it]
soBundle = soBundle.copy(videoCodec = codec.string)
if (codec == Codec.AV1) {
snackbar.show("注意绝大部分设备不支持AV1硬件编码")
}
},
enabled = !sessionStarted,
)
@SuppressLint("DefaultLocale")
SuperSlider(
title = "视频码率",
summary = "--video-bit-rate",
value = soBundle.videoBitRate / 1_000_000f,
onValueChange = { mbps ->
soBundle = soBundle.copy(
videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10)
)
},
valueRange = 0f..40f,
steps = 400 - 1,
unit = "Mbps",
zeroStateText = "默认",
displayFormatter = { String.format("%.1f", it) },
inputInitialValue =
if (soBundle.videoBitRate <= 0) ""
else String.format("%.1f", soBundle.videoBitRate / 1_000_000f),
inputFilter = { text ->
var dotUsed = false
text.filter { ch ->
when {
ch.isDigit() -> true
ch == '.' && !dotUsed -> {
dotUsed = true
true
}
else -> false
}
}
},
inputValueRange = 0f..UInt.MAX_VALUE.toFloat(),
onInputConfirm = { raw ->
raw.toFloatOrNull()?.let { parsed ->
if (parsed >= 0f) {
soBundle = soBundle.copy(
videoBitRate = (parsed * 1_000_000f).roundToInt()
)
}
}
},
enabled = !sessionStarted,
)
ArrowPreference(
title = "更多参数",
title = if (!hideSimpleConfigItems) "更多参数" else "所有参数",
summary = "所有 scrcpy 参数",
onClick = onOpenAdvanced,
enabled = !sessionStarted,
)
if (onOpenRecentTasks != null) {
ArrowPreference(
title = "最近任务",
endActions = {
recentTasksEndAction?.invoke(this)
},
onClick = onOpenRecentTasks,
enabled = !busy && !adbConnecting,
)
}
ArrowPreference(
title = "所有应用",
endActions = {
Text(
text = allAppsEndActionText,
color = colorScheme.onSurfaceVariantActions,
fontSize = textStyles.body2.fontSize,
modifier = Modifier.padding(end = UiSpacing.ContentVertical),
)
},
onClick = onOpenAllApps,
enabled = !busy && !adbConnecting,
)
ArrowPreference(
title = "最近任务",
endActions = {
Text(
text = recentTasksEndActionText,
color = colorScheme.onSurfaceVariantActions,
fontSize = textStyles.body2.fontSize,
modifier = Modifier.padding(end = UiSpacing.ContentVertical),
)
},
onClick = onOpenRecentTasks,
enabled = !busy && !adbConnecting,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.ContentVertical)
.padding(bottom = UiSpacing.ContentVertical),
.padding(all = UiSpacing.ContentVertical),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
if (isQuickConnected) TextButton(
text = "断开",
onClick = {
onStartStopHaptic?.invoke()
onStartStopHaptic()
onDisconnect()
},
modifier = Modifier.weight(1f / 4f),
@@ -621,7 +652,7 @@ internal fun ConfigPanel(
if (!sessionStarted) TextButton(
text = "启动",
onClick = {
onStartStopHaptic?.invoke()
onStartStopHaptic()
onStart()
},
modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f),
@@ -631,7 +662,7 @@ internal fun ConfigPanel(
if (sessionStarted) TextButton(
text = "停止",
onClick = {
onStartStopHaptic?.invoke()
onStartStopHaptic()
onStop()
},
modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f),
@@ -813,14 +844,19 @@ private fun PairingDialog(
fun ScrcpyVideoSurface(
modifier: Modifier,
session: Scrcpy.Session.SessionInfo?,
imeRequestToken: Int = 0,
onImeCommitText: (suspend (String) -> Unit)? = null,
onImeDeleteSurroundingText: (suspend (beforeLength: Int, afterLength: Int) -> Unit)? = null,
onImeKeyEvent: (suspend (KeyEvent) -> Boolean)? = null,
) {
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val scope = rememberCoroutineScope()
val imeInputMutex = remember { Mutex() }
val lifecycleOwner = LocalLifecycleOwner.current
var currentSurface by remember { mutableStateOf<Surface?>(null) }
var currentSurfaceView by remember { mutableStateOf<SurfaceView?>(null) }
var currentSurfaceView by remember { mutableStateOf<ScrcpyInputSurfaceView?>(null) }
val latestSession by rememberUpdatedState(session)
@@ -840,6 +876,13 @@ fun ScrcpyVideoSurface(
}
}
LaunchedEffect(imeRequestToken, currentSurfaceView) {
if (imeRequestToken == 0) return@LaunchedEffect
val surfaceView = currentSurfaceView ?: return@LaunchedEffect
surfaceView.setCommitTextEnabled(true)
io.github.miuzarte.scrcpyforandroid.services.LocalInputService.showSoftKeyboard(surfaceView)
}
DisposableEffect(lifecycleOwner, session, currentSurface) {
val surface = currentSurface
if (
@@ -882,8 +925,42 @@ fun ScrcpyVideoSurface(
AndroidView(
modifier = modifier,
factory = { context ->
SurfaceView(context).apply {
ScrcpyInputSurfaceView(context).apply {
currentSurfaceView = this
inputCallbacks = object : ScrcpyInputSurfaceView.InputCallbacks {
override fun handleKeyEvent(event: KeyEvent): Boolean {
val handler = onImeKeyEvent ?: return false
taskScope.launch {
imeInputMutex.withLock {
handler(event)
}
}
return true
}
override fun handleCommitText(text: CharSequence): Boolean {
val handler = onImeCommitText ?: return false
taskScope.launch {
imeInputMutex.withLock {
handler(text.toString())
}
}
return true
}
override fun handleDeleteSurroundingText(
beforeLength: Int,
afterLength: Int
): Boolean {
val handler = onImeDeleteSurroundingText ?: return false
taskScope.launch {
imeInputMutex.withLock {
handler(beforeLength, afterLength)
}
}
return true
}
}
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
val newSurface = holder.surface

View File

@@ -0,0 +1,66 @@
package io.github.miuzarte.scrcpyforandroid.widgets
import android.content.Context
import android.text.InputType
import android.util.AttributeSet
import android.view.KeyEvent
import android.view.SurfaceView
import android.view.inputmethod.BaseInputConnection
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
class ScrcpyInputSurfaceView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : SurfaceView(context, attrs, defStyleAttr) {
interface InputCallbacks {
fun handleKeyEvent(event: KeyEvent): Boolean
fun handleCommitText(text: CharSequence): Boolean
fun handleDeleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean
}
var inputCallbacks: InputCallbacks? = null
private var commitTextEnabled = false
fun setCommitTextEnabled(enabled: Boolean) {
commitTextEnabled = enabled
isFocusable = enabled
isFocusableInTouchMode = enabled
if (enabled) requestFocus()
else clearFocus()
}
override fun onCheckIsTextEditor(): Boolean {
return commitTextEnabled || super.onCheckIsTextEditor()
}
override fun onKeyPreIme(keyCode: Int, event: KeyEvent): Boolean {
if (inputCallbacks?.handleKeyEvent(event) == true) return true
return super.onKeyPreIme(keyCode, event)
}
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
if (!commitTextEnabled) return super.onCreateInputConnection(outAttrs)
outAttrs.inputType = InputType.TYPE_CLASS_TEXT
outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI
return object : BaseInputConnection(this, false) {
override fun commitText(text: CharSequence, newCursorPosition: Int): Boolean {
if (inputCallbacks?.handleCommitText(text) == true) return true
return super.commitText(text, newCursorPosition)
}
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
if (inputCallbacks?.handleDeleteSurroundingText(beforeLength, afterLength) == true)
return true
return super.deleteSurroundingText(beforeLength, afterLength)
}
override fun sendKeyEvent(event: KeyEvent): Boolean {
if (inputCallbacks?.handleKeyEvent(event) == true) return true
return super.sendKeyEvent(event)
}
}
}
}

View File

@@ -21,7 +21,10 @@ import androidx.compose.material.icons.automirrored.rounded.VolumeDown
import androidx.compose.material.icons.automirrored.rounded.VolumeOff
import androidx.compose.material.icons.automirrored.rounded.VolumeUp
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.ContentPaste
import androidx.compose.material.icons.rounded.DashboardCustomize
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Keyboard
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Notifications
@@ -145,6 +148,30 @@ enum class VirtualButtonAction(
"填充锁屏密码",
Icons.Rounded.Password,
null
),
ALL_APPS(
"all_apps",
"所有应用",
Icons.Rounded.Apps,
null
),
RECENT_TASKS(
"recent_tasks",
"最近任务",
Icons.Rounded.DashboardCustomize,
null
),
TOGGLE_IME(
"toggle_ime",
"拉起输入法",
Icons.Rounded.Keyboard,
null
),
PASTE_LOCAL_CLIPBOARD(
"paste_local_clipboard",
"粘贴本机剪贴板",
Icons.Rounded.ContentPaste,
null
);
}