feat: IME bringing up; clipboard syncing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user