feat: IME bringing up; clipboard syncing
This commit is contained in:
14
README.md
14
README.md
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
- 控制时可拉起本机输入法,且支持输入中文
|
||||||
- 低延迟音频链路 (默认未启用)
|
- 低延迟音频链路 (默认未启用)
|
||||||
- 受控设备播放 `USAGE_MEDIA` 流时 ([namidaco/namida](https://github.com/namidaco/namida)) ,两设备的音频延迟只差半拍 (没有具体测量能力)
|
- 受控设备播放 `USAGE_MEDIA` 流时 ([namidaco/namida](https://github.com/namidaco/namida)) ,两设备的音频延迟只差半拍 (没有具体测量能力)
|
||||||
- 受控设备播放 `USAGE_GAME` 流时 (明日方舟 Bilibili 服) ,仍存在 100~200ms 的有感延迟
|
- 受控设备播放 `USAGE_GAME` 流时 (明日方舟 Bilibili 服) ,仍存在 100~200ms 的有感延迟
|
||||||
@@ -37,6 +38,9 @@
|
|||||||
|
|
||||||
- 因为没有设备用于 (也懒得) 测试,应用可能无法正常运行在安卓版本较低的设备上,特别是画中画功能,非常取决于国产 ROM 的实现
|
- 因为没有设备用于 (也懒得) 测试,应用可能无法正常运行在安卓版本较低的设备上,特别是画中画功能,非常取决于国产 ROM 的实现
|
||||||
- 关闭画中画后不会停止 scrcpy 串流,仍然需要回到应用中点击停止
|
- 关闭画中画后不会停止 scrcpy 串流,仍然需要回到应用中点击停止
|
||||||
|
- 跨设备输入中文
|
||||||
|
- 实现方式为利用剪贴板同步,会导致受控机剪贴板历史被填充输入历史
|
||||||
|
- 不知道为什么有时候会上屏失败
|
||||||
- 虚拟按键的截图实现方式为发送
|
- 虚拟按键的截图实现方式为发送
|
||||||
`keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为
|
`keycode 120`,安卓官方([keycodes.h#349](https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h#349))的定义为
|
||||||
`System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启
|
`System Request / Print Screen key.`,不同的厂商有不同的实现,在某些类原生(`AxionOS`) 上的行为是软重启
|
||||||
@@ -50,6 +54,16 @@
|
|||||||
- 密码锁屏无法捕获: [LSPosed/DisableFlagSecure](https://github.com/LSPosed/DisableFlagSecure)
|
- 密码锁屏无法捕获: [LSPosed/DisableFlagSecure](https://github.com/LSPosed/DisableFlagSecure)
|
||||||
- 开机自动启用 adb: [gist/906291](https://gist.github.com/Miuzarte/9062915f1615d5eebd363c759fda496c)
|
- 开机自动启用 adb: [gist/906291](https://gist.github.com/Miuzarte/9062915f1615d5eebd363c759fda496c)
|
||||||
|
|
||||||
|
## NOT-TODO
|
||||||
|
|
||||||
|
应该有东西的但我不记得要写什么了
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
1. 虚拟屏不显示输入法 / 输入法显示在主屏幕
|
||||||
|
- 将 `--display-ime-policy` 设置为 `local`
|
||||||
|
- 自行在悬浮球中拉起本机输入法
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
- JDK 17+
|
- JDK 17+
|
||||||
|
|||||||
2
TODO.md
2
TODO.md
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
|
adb shell page (in nav dock)
|
||||||
|
|
||||||
## PARAMS
|
## PARAMS
|
||||||
|
|
||||||
### LOWER PRIORITY
|
### LOWER PRIORITY
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ android {
|
|||||||
applicationId = "io.github.miuzarte.scrcpyforandroid"
|
applicationId = "io.github.miuzarte.scrcpyforandroid"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 14
|
versionCode = 15
|
||||||
versionName = "0.1.8"
|
versionName = "0.1.9"
|
||||||
|
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
cmake {
|
cmake {
|
||||||
|
|||||||
@@ -52,12 +52,12 @@ import io.github.miuzarte.scrcpyforandroid.password.PasswordEntry
|
|||||||
import io.github.miuzarte.scrcpyforandroid.password.PasswordRepository
|
import io.github.miuzarte.scrcpyforandroid.password.PasswordRepository
|
||||||
import io.github.miuzarte.scrcpyforandroid.password.PasswordSanitizer
|
import io.github.miuzarte.scrcpyforandroid.password.PasswordSanitizer
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
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.LocalSnackbarController
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
|
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
|
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
|||||||
@@ -3,46 +3,31 @@ package io.github.miuzarte.scrcpyforandroid.pages
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.compose.LocalActivity
|
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.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.sizeIn
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
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.MoreVert
|
||||||
import androidx.compose.material.icons.rounded.Refresh
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
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.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
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.models.DeviceShortcuts
|
||||||
import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent
|
import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
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.scrcpy.Scrcpy
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.AppWakeLocks
|
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.BlurredBar
|
||||||
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
|
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
|
||||||
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
|
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.ConfigPanel
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTileList
|
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTileList
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard
|
import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard
|
import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard
|
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.StatusCard
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
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.PopupPositionProvider
|
||||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||||
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
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.Text
|
||||||
import top.yukonga.miuix.kmp.basic.TextField
|
import top.yukonga.miuix.kmp.basic.TextField
|
||||||
import top.yukonga.miuix.kmp.basic.TopAppBar
|
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||||
import top.yukonga.miuix.kmp.blur.layerBackdrop
|
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.overlay.OverlayListPopup
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles
|
||||||
@@ -293,11 +273,14 @@ fun DeviceTabPage(
|
|||||||
var adbSession by rememberSaveable { mutableStateOf(DeviceAdbSessionState()) }
|
var adbSession by rememberSaveable { mutableStateOf(DeviceAdbSessionState()) }
|
||||||
val sessionInfo by scrcpy.currentSessionState.collectAsState()
|
val sessionInfo by scrcpy.currentSessionState.collectAsState()
|
||||||
val listingsRefreshBusy by scrcpy.listings.refreshBusyState.collectAsState()
|
val listingsRefreshBusy by scrcpy.listings.refreshBusyState.collectAsState()
|
||||||
|
val listingsRefreshVersion by scrcpy.listings.refreshVersionState.collectAsState()
|
||||||
var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) }
|
var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
|
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
var adbConnecting by rememberSaveable { mutableStateOf(false) }
|
var adbConnecting by rememberSaveable { mutableStateOf(false) }
|
||||||
var pendingScrollToPreview by rememberSaveable { mutableStateOf(false) }
|
var pendingScrollToPreview by rememberSaveable { mutableStateOf(false) }
|
||||||
var showRecentTasksSheet 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 listState = rememberLazyListState()
|
||||||
val isPreviewCardVisible by remember(listState) {
|
val isPreviewCardVisible by remember(listState) {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
@@ -341,9 +324,8 @@ fun DeviceTabPage(
|
|||||||
adbSession.connectedScrcpyProfileId
|
adbSession.connectedScrcpyProfileId
|
||||||
|
|
||||||
val connectedScrcpyBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
|
val connectedScrcpyBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
|
||||||
val audioForwardingSupported = adbSession.audioForwardingSupported
|
val apps = remember(listingsRefreshVersion) { scrcpy.listings.apps }
|
||||||
val cameraMirroringSupported = adbSession.cameraMirroringSupported
|
val recentTasks = remember(listingsRefreshVersion) { scrcpy.listings.recentTasks }
|
||||||
val recentTasks = scrcpy.listings.recentTasks
|
|
||||||
|
|
||||||
val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
|
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) {
|
var quickConnectInputTemp by rememberSaveable(qdBundle.quickConnectInput) {
|
||||||
mutableStateOf(qdBundle.quickConnectInput)
|
mutableStateOf(qdBundle.quickConnectInput)
|
||||||
}
|
}
|
||||||
@@ -1011,21 +1055,26 @@ fun DeviceTabPage(
|
|||||||
busy = busy,
|
busy = busy,
|
||||||
activeProfileId = connectedScrcpyProfileId,
|
activeProfileId = connectedScrcpyProfileId,
|
||||||
activeBundle = connectedScrcpyBundle,
|
activeBundle = connectedScrcpyBundle,
|
||||||
audioForwardingSupported = audioForwardingSupported,
|
hideSimpleConfigItems = asBundle.hideSimpleConfigItems,
|
||||||
cameraMirroringSupported = cameraMirroringSupported,
|
audioForwardingSupported = adbSession.audioForwardingSupported,
|
||||||
|
cameraMirroringSupported = adbSession.cameraMirroringSupported,
|
||||||
adbConnecting = adbConnecting,
|
adbConnecting = adbConnecting,
|
||||||
isQuickConnected = isQuickConnected,
|
isQuickConnected = isQuickConnected,
|
||||||
recentTasksEndAction = {
|
allAppsEndActionText = when {
|
||||||
Text(
|
listingsRefreshBusy -> "..."
|
||||||
text = when {
|
apps.isNotEmpty() -> apps.size.toString()
|
||||||
listingsRefreshBusy -> "..."
|
else -> "空"
|
||||||
recentTasks.isNotEmpty() -> recentTasks.size.toString()
|
},
|
||||||
else -> "空"
|
onOpenAllApps = {
|
||||||
},
|
showAllAppsSheet = true
|
||||||
color = colorScheme.onSurfaceVariantActions,
|
if (apps.isEmpty() && !listingsRefreshBusy) {
|
||||||
fontSize = textStyles.body2.fontSize,
|
scope.launch(Dispatchers.IO) { refreshApps() }
|
||||||
modifier = Modifier.padding(end = UiSpacing.ContentVertical),
|
}
|
||||||
)
|
},
|
||||||
|
recentTasksEndActionText = when {
|
||||||
|
listingsRefreshBusy -> "..."
|
||||||
|
recentTasks.isNotEmpty() -> recentTasks.size.toString()
|
||||||
|
else -> "空"
|
||||||
},
|
},
|
||||||
onOpenRecentTasks = {
|
onOpenRecentTasks = {
|
||||||
showRecentTasksSheet = true
|
showRecentTasksSheet = true
|
||||||
@@ -1078,6 +1127,29 @@ fun DeviceTabPage(
|
|||||||
onOpenFullscreen = {
|
onOpenFullscreen = {
|
||||||
context.startActivity(StreamActivity.createIntent(context))
|
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,
|
autoBringIntoView = pendingScrollToPreview,
|
||||||
onAutoBringIntoViewConsumed = { pendingScrollToPreview = false },
|
onAutoBringIntoViewConsumed = { pendingScrollToPreview = false },
|
||||||
)
|
)
|
||||||
@@ -1090,14 +1162,37 @@ fun DeviceTabPage(
|
|||||||
moreActions = virtualButtonLayout.second,
|
moreActions = virtualButtonLayout.second,
|
||||||
showText = asBundle.previewVirtualButtonShowText,
|
showText = asBundle.previewVirtualButtonShowText,
|
||||||
onAction = { action ->
|
onAction = { action ->
|
||||||
if (action == VirtualButtonAction.PASSWORD_INPUT) {
|
when (action) {
|
||||||
snackbar.show("当前页面无法拉起验证")
|
VirtualButtonAction.RECENT_TASKS -> {
|
||||||
return@VirtualButtonCard
|
showRecentTasksSheet = true
|
||||||
}
|
if (recentTasks.isEmpty() && !listingsRefreshBusy) {
|
||||||
val keycode = action.keycode ?: return@VirtualButtonCard
|
scope.launch(Dispatchers.IO) { refreshApps() }
|
||||||
runBusy("发送 ${action.title}") {
|
scope.launch(Dispatchers.IO) { refreshRecentTasks() }
|
||||||
scrcpy.injectKeycode(0, keycode)
|
}
|
||||||
scrcpy.injectKeycode(1, keycode)
|
}
|
||||||
|
|
||||||
|
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 =
|
passwordPopupContent =
|
||||||
@@ -1131,10 +1226,43 @@ fun DeviceTabPage(
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
RecentTasksBottomSheet(
|
AppListBottomSheet(
|
||||||
show = showRecentTasksSheet,
|
show = showRecentTasksSheet,
|
||||||
tasks = recentTasks,
|
title = "最近任务",
|
||||||
scrcpy = scrcpy,
|
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,
|
refreshBusy = listingsRefreshBusy,
|
||||||
onDismissRequest = { showRecentTasksSheet = false },
|
onDismissRequest = { showRecentTasksSheet = false },
|
||||||
onRefresh = {
|
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
|
@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
|
@Composable
|
||||||
private fun DeviceMenuPopupItem(
|
private fun DeviceMenuPopupItem(
|
||||||
text: String,
|
text: String,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package io.github.miuzarte.scrcpyforandroid.pages
|
|||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.KeyEvent
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.LocalActivity
|
import androidx.activity.compose.LocalActivity
|
||||||
import androidx.compose.foundation.background
|
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.services.LocalSnackbarController
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
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.ScrcpyVideoSurface
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
||||||
@@ -83,6 +86,8 @@ fun FullscreenControlScreen(
|
|||||||
|
|
||||||
val snackbar = LocalSnackbarController.current
|
val snackbar = LocalSnackbarController.current
|
||||||
val currentSession by scrcpy.currentSessionState.collectAsState()
|
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 asBundleShared by appSettings.bundleState.collectAsState()
|
||||||
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
|
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
|
||||||
@@ -125,8 +130,13 @@ fun FullscreenControlScreen(
|
|||||||
moreActions = buttonItems.second,
|
moreActions = buttonItems.second,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val recentTasks = remember(listingsRefreshVersion) { scrcpy.listings.recentTasks }
|
||||||
|
val apps = remember(listingsRefreshVersion) { scrcpy.listings.apps }
|
||||||
|
|
||||||
var currentFps by remember { mutableFloatStateOf(0f) }
|
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) {
|
DisposableEffect(activity) {
|
||||||
val window = activity?.window
|
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(
|
Scaffold(
|
||||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||||
snackbarHost = { SnackbarHost(snackbar.hostState) },
|
snackbarHost = { SnackbarHost(snackbar.hostState) },
|
||||||
@@ -189,13 +259,16 @@ fun FullscreenControlScreen(
|
|||||||
) {
|
) {
|
||||||
val session = currentSession ?: return@Box
|
val session = currentSession ?: return@Box
|
||||||
FullscreenControlPage(
|
FullscreenControlPage(
|
||||||
|
scrcpy = scrcpy,
|
||||||
session = session,
|
session = session,
|
||||||
onDismiss = onBack,
|
onDismiss = onBack,
|
||||||
showDebugInfo = fullscreenDebugInfo && !isInPip,
|
showDebugInfo = fullscreenDebugInfo && !isInPip,
|
||||||
currentFps = currentFps,
|
currentFps = currentFps,
|
||||||
|
imeRequestToken = imeRequestToken,
|
||||||
enableBackHandler = false,
|
enableBackHandler = false,
|
||||||
interactive = !isInPip,
|
interactive = !isInPip,
|
||||||
onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged,
|
onVideoBoundsInWindowChanged = onVideoBoundsInWindowChanged,
|
||||||
|
onImeCommitText = ::commitImeText,
|
||||||
onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
|
onInjectTouch = { action, pointerId, x, y, pressure, buttons ->
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
scrcpy.injectTouch(
|
scrcpy.injectTouch(
|
||||||
@@ -217,10 +290,33 @@ fun FullscreenControlScreen(
|
|||||||
bar.Fullscreen(
|
bar.Fullscreen(
|
||||||
modifier = Modifier.align(Alignment.BottomCenter),
|
modifier = Modifier.align(Alignment.BottomCenter),
|
||||||
onAction = { action ->
|
onAction = { action ->
|
||||||
if (action == VirtualButtonAction.PASSWORD_INPUT) {
|
when (action) {
|
||||||
snackbar.show("当前页面无法拉起验证")
|
VirtualButtonAction.RECENT_TASKS -> {
|
||||||
} else {
|
showRecentTasksSheet = true
|
||||||
action.keycode?.let { sendKeycode(it) }
|
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) {
|
passwordPopupContent = if (fragmentActivity == null) {
|
||||||
@@ -243,10 +339,33 @@ fun FullscreenControlScreen(
|
|||||||
actions = floatingActions,
|
actions = floatingActions,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
onAction = { action ->
|
onAction = { action ->
|
||||||
if (action == VirtualButtonAction.PASSWORD_INPUT) {
|
when (action) {
|
||||||
snackbar.show("当前页面无法拉起验证")
|
VirtualButtonAction.RECENT_TASKS -> {
|
||||||
} else {
|
showRecentTasksSheet = true
|
||||||
action.keycode?.let { sendKeycode(it) }
|
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) {
|
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
|
@Composable
|
||||||
fun FullscreenControlPage(
|
fun FullscreenControlPage(
|
||||||
|
scrcpy: Scrcpy,
|
||||||
session: Scrcpy.Session.SessionInfo,
|
session: Scrcpy.Session.SessionInfo,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
showDebugInfo: Boolean,
|
showDebugInfo: Boolean,
|
||||||
currentFps: Float,
|
currentFps: Float,
|
||||||
|
imeRequestToken: Int = 0,
|
||||||
enableBackHandler: Boolean = true,
|
enableBackHandler: Boolean = true,
|
||||||
interactive: Boolean = true,
|
interactive: Boolean = true,
|
||||||
onVideoBoundsInWindowChanged: (Rect?) -> Unit = {},
|
onVideoBoundsInWindowChanged: (Rect?) -> Unit = {},
|
||||||
|
onImeCommitText: suspend (String) -> Unit,
|
||||||
onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
|
onInjectTouch: suspend (action: Int, pointerId: Long, x: Int, y: Int, pressure: Float, buttons: Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
BackHandler(enabled = enableBackHandler, onBack = onDismiss)
|
BackHandler(enabled = enableBackHandler, onBack = onDismiss)
|
||||||
@@ -372,6 +565,27 @@ fun FullscreenControlPage(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
session = session,
|
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.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
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.Settings
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -31,7 +30,6 @@ fun ReorderDevicesScreen(
|
|||||||
show: Boolean,
|
show: Boolean,
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||||
|
|
||||||
val qdBundleShared by quickDevices.bundleState.collectAsState()
|
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.Crop
|
||||||
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay
|
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
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.SuperSlider
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSpinner
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSpinner
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
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.BlurredBar
|
||||||
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
|
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
|
||||||
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
|
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -583,14 +583,11 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
soBundle.cameraSizeCustom,
|
soBundle.cameraSizeCustom,
|
||||||
soBundle.cameraSizeUseCustom,
|
soBundle.cameraSizeUseCustom,
|
||||||
) {
|
) {
|
||||||
if (cameraSizes.isNotEmpty()) {
|
when {
|
||||||
null
|
cameraSizes.isNotEmpty() -> null
|
||||||
} else if (soBundle.cameraSizeUseCustom && soBundle.cameraSizeCustom.isNotBlank()) {
|
soBundle.cameraSizeUseCustom && soBundle.cameraSizeCustom.isNotBlank() -> soBundle.cameraSizeCustom
|
||||||
soBundle.cameraSizeCustom
|
soBundle.cameraSize.isNotBlank() -> soBundle.cameraSize
|
||||||
} else if (soBundle.cameraSize.isNotBlank()) {
|
else -> null
|
||||||
soBundle.cameraSize
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1583,7 +1580,7 @@ internal fun ScrcpyAllOptionsPage(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
SwitchPreference(
|
SwitchPreference(
|
||||||
title = "禁用结束后清理",
|
title = "禁用 MediaCodec 出错时自动降级",
|
||||||
summary = "--no-downsize-on-error",
|
summary = "--no-downsize-on-error",
|
||||||
checked = !soBundle.downsizeOnError,
|
checked = !soBundle.downsizeOnError,
|
||||||
onCheckedChange = {
|
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(
|
SwitchPreference(
|
||||||
title = "禁用结束后清理",
|
title = "禁用结束后清理",
|
||||||
summary = "--no-cleanup",
|
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.ThemeModes
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
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.SuperSlider
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
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.BlurredBar
|
||||||
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
|
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
|
||||||
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
|
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -286,6 +286,14 @@ fun SettingsPage(
|
|||||||
asBundle = asBundle.copy(fullscreenDebugInfo = it)
|
asBundle = asBundle.copy(fullscreenDebugInfo = it)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
SwitchPreference(
|
||||||
|
title = "设备页隐藏简单设置项",
|
||||||
|
summary = "启用后设备页仅保留更多参数、所有应用、最近任务和启动/停止按钮",
|
||||||
|
checked = asBundle.hideSimpleConfigItems,
|
||||||
|
onCheckedChange = {
|
||||||
|
asBundle = asBundle.copy(hideSimpleConfigItems = it)
|
||||||
|
},
|
||||||
|
)
|
||||||
SuperSlider(
|
SuperSlider(
|
||||||
title = "预览卡高度",
|
title = "预览卡高度",
|
||||||
summary = "设备页预览卡高度",
|
summary = "设备页预览卡高度",
|
||||||
@@ -343,6 +351,16 @@ fun SettingsPage(
|
|||||||
asBundle = asBundle.copy(showFullscreenFloatingButton = it)
|
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 androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
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.Settings
|
||||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||||
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
|
import io.github.miuzarte.scrcpyforandroid.ui.BlurredBar
|
||||||
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
|
import io.github.miuzarte.scrcpyforandroid.ui.LocalEnableBlur
|
||||||
import io.github.miuzarte.scrcpyforandroid.ui.rememberBlurBackdrop
|
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.VirtualButtonAction
|
||||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
||||||
import kotlinx.coroutines.CoroutineScope
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.foundation.layout.PaddingValues
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import top.yukonga.miuix.kmp.basic.SmallTitle
|
import top.yukonga.miuix.kmp.basic.SmallTitle
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SectionSmallTitle(
|
fun SectionSmallTitle(
|
||||||
text: String,
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
textColor: Color = colorScheme.onBackgroundVariant,
|
||||||
insideMargin: PaddingValues = PaddingValues(16.dp, 8.dp),
|
insideMargin: PaddingValues = PaddingValues(16.dp, 8.dp),
|
||||||
) {
|
) {
|
||||||
SmallTitle(
|
SmallTitle(
|
||||||
text = text,
|
text = text,
|
||||||
|
modifier = modifier,
|
||||||
|
textColor = textColor,
|
||||||
insideMargin = insideMargin,
|
insideMargin = insideMargin,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -153,13 +153,13 @@ data class ClientOptions(
|
|||||||
var disableScreensaver: Boolean = false,
|
var disableScreensaver: Boolean = false,
|
||||||
|
|
||||||
// var forwardKeyRepeat: Boolean,
|
// var forwardKeyRepeat: Boolean,
|
||||||
// var legacyPaste: Boolean,
|
var legacyPaste: Boolean = false,
|
||||||
|
|
||||||
// --power-off-on-close
|
// --power-off-on-close
|
||||||
var powerOffOnClose: Boolean = false, // to server
|
var powerOffOnClose: Boolean = false, // to server
|
||||||
|
|
||||||
// --no-clipboard-autosync
|
// --no-clipboard-autosync
|
||||||
// var clipboardAutosync: Boolean = true, // to server
|
var clipboardAutosync: Boolean = true, // to server
|
||||||
|
|
||||||
// --no-downsize-on-error
|
// --no-downsize-on-error
|
||||||
var downsizeOnError: Boolean = true, // to server
|
var downsizeOnError: Boolean = true, // to server
|
||||||
@@ -555,6 +555,8 @@ data class ClientOptions(
|
|||||||
cameraFps = cameraFps,
|
cameraFps = cameraFps,
|
||||||
|
|
||||||
powerOffOnClose = powerOffOnClose,
|
powerOffOnClose = powerOffOnClose,
|
||||||
|
legacyPaste = legacyPaste,
|
||||||
|
clipboardAutosync = clipboardAutosync,
|
||||||
|
|
||||||
downsizeOnError = downsizeOnError,
|
downsizeOnError = downsizeOnError,
|
||||||
|
|
||||||
@@ -569,4 +571,4 @@ data class ClientOptions(
|
|||||||
list = list,
|
list = list,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package io.github.miuzarte.scrcpyforandroid.scrcpy
|
package io.github.miuzarte.scrcpyforandroid.scrcpy
|
||||||
|
|
||||||
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
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.EncoderType
|
||||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
|
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
|
||||||
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
|
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.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -61,7 +67,24 @@ class Scrcpy(
|
|||||||
private val lowLatency: Boolean = false,
|
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)
|
private val _currentSessionState = MutableStateFlow<Session.SessionInfo?>(null)
|
||||||
val currentSessionState: StateFlow<Session.SessionInfo?> = _currentSessionState.asStateFlow()
|
val currentSessionState: StateFlow<Session.SessionInfo?> = _currentSessionState.asStateFlow()
|
||||||
@@ -143,8 +166,9 @@ class Scrcpy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create session info
|
// Create session info
|
||||||
_currentSessionState.value = info
|
_currentSessionState.value = info.copy(legacyPaste = options.legacyPaste)
|
||||||
isRunning = true
|
isRunning = true
|
||||||
|
startClipboardSync()
|
||||||
|
|
||||||
// Setup video consumer (notify NativeCoreFacade to setup decoders)
|
// Setup video consumer (notify NativeCoreFacade to setup decoders)
|
||||||
if (options.video) {
|
if (options.video) {
|
||||||
@@ -204,6 +228,7 @@ class Scrcpy(
|
|||||||
audioPlayer = null
|
audioPlayer = null
|
||||||
isRunning = false
|
isRunning = false
|
||||||
_currentSessionState.value = null
|
_currentSessionState.value = null
|
||||||
|
stopClipboardSync()
|
||||||
Log.i(TAG, "stop(): Session stopped successfully")
|
Log.i(TAG, "stop(): Session stopped successfully")
|
||||||
true
|
true
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -214,8 +239,6 @@ class Scrcpy(
|
|||||||
|
|
||||||
fun isStarted(): Boolean = isRunning && session.isStarted()
|
fun isStarted(): Boolean = isRunning && session.isStarted()
|
||||||
|
|
||||||
fun getCurrentSession(): Session.SessionInfo? = currentSessionState.value
|
|
||||||
|
|
||||||
suspend fun startApp(name: String) = session.startApp(name)
|
suspend fun startApp(name: String) = session.startApp(name)
|
||||||
|
|
||||||
suspend fun injectKeycode(
|
suspend fun injectKeycode(
|
||||||
@@ -232,6 +255,8 @@ class Scrcpy(
|
|||||||
|
|
||||||
suspend fun injectText(text: String) = session.injectText(text)
|
suspend fun injectText(text: String) = session.injectText(text)
|
||||||
|
|
||||||
|
suspend fun setClipboard(text: String, paste: Boolean) = session.setClipboard(text, paste)
|
||||||
|
|
||||||
suspend fun injectTouch(
|
suspend fun injectTouch(
|
||||||
action: Int,
|
action: Int,
|
||||||
pointerId: Long,
|
pointerId: Long,
|
||||||
@@ -278,34 +303,69 @@ class Scrcpy(
|
|||||||
_currentSessionState.value = current.copy(width = width, height = height)
|
_currentSessionState.value = current.copy(width = width, height = height)
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class ListResult {
|
private fun startClipboardSync() {
|
||||||
data class Encoders(
|
synchronized(clipboardSyncLock) {
|
||||||
val videoEncoders: List<String>,
|
lastKnownLocalClipboardText = LocalInputService.getClipboardText(appContext)
|
||||||
val audioEncoders: List<String>,
|
pendingRemoteClipboardText = null
|
||||||
val videoEncoderTypes: Map<String, String> = emptyMap(),
|
if (!clipboardListenerRegistered) {
|
||||||
val audioEncoderTypes: Map<String, String> = emptyMap(),
|
clipboardManager?.addPrimaryClipChangedListener(clipboardChangedListener)
|
||||||
val rawOutput: String = "",
|
clipboardListenerRegistered = true
|
||||||
) : ListResult()
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class Displays(
|
private fun stopClipboardSync() {
|
||||||
val displays: List<DisplayInfo>,
|
synchronized(clipboardSyncLock) {
|
||||||
val rawOutput: String = "",
|
if (clipboardListenerRegistered) {
|
||||||
) : ListResult()
|
clipboardManager?.removePrimaryClipChangedListener(clipboardChangedListener)
|
||||||
|
clipboardListenerRegistered = false
|
||||||
|
}
|
||||||
|
pendingRemoteClipboardText = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class Cameras(
|
private fun handleLocalClipboardChanged() {
|
||||||
val cameras: List<CameraInfo>,
|
val text = LocalInputService.getClipboardText(appContext)
|
||||||
val rawOutput: String = "",
|
val shouldSync = synchronized(clipboardSyncLock) {
|
||||||
) : ListResult()
|
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(
|
private fun handleRemoteClipboardText(text: String) {
|
||||||
val sizes: List<String>,
|
val shouldWrite = synchronized(clipboardSyncLock) {
|
||||||
val rawOutput: String = "",
|
if (text == lastKnownLocalClipboardText) {
|
||||||
) : ListResult()
|
return@synchronized false
|
||||||
|
}
|
||||||
data class Apps(
|
pendingRemoteClipboardText = text
|
||||||
val apps: List<AppInfo>,
|
lastKnownLocalClipboardText = text
|
||||||
val rawOutput: String = "",
|
true
|
||||||
) : ListResult()
|
}
|
||||||
|
if (!shouldWrite) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LocalInputService.setClipboardText(appContext, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class Listings {
|
inner class Listings {
|
||||||
@@ -740,7 +800,9 @@ class Scrcpy(
|
|||||||
* Session manager for scrcpy protocol.
|
* Session manager for scrcpy protocol.
|
||||||
* Handles socket communication, video/audio streaming, and control input.
|
* Handles socket communication, video/audio streaming, and control input.
|
||||||
*/
|
*/
|
||||||
class Session {
|
class Session(
|
||||||
|
private val onRemoteClipboardText: (String) -> Unit,
|
||||||
|
) {
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -758,6 +820,9 @@ class Scrcpy(
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var audioReaderThread: Thread? = null
|
private var audioReaderThread: Thread? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var controlReaderThread: Thread? = null
|
||||||
|
|
||||||
private val serverLogBuffer = ArrayDeque<String>()
|
private val serverLogBuffer = ArrayDeque<String>()
|
||||||
|
|
||||||
suspend fun start(
|
suspend fun start(
|
||||||
@@ -881,6 +946,10 @@ class Scrcpy(
|
|||||||
val controlWriter = controlStream?.let { stream ->
|
val controlWriter = controlStream?.let { stream ->
|
||||||
ControlWriter(DataOutputStream(stream.outputStream))
|
ControlWriter(DataOutputStream(stream.outputStream))
|
||||||
}
|
}
|
||||||
|
val controlInput = controlStream?.let { stream ->
|
||||||
|
if (stream === firstStream) firstInput
|
||||||
|
else DataInputStream(BufferedInputStream(stream.inputStream))
|
||||||
|
}
|
||||||
|
|
||||||
val newSession = ActiveSession(
|
val newSession = ActiveSession(
|
||||||
info = sessionInfo,
|
info = sessionInfo,
|
||||||
@@ -892,9 +961,13 @@ class Scrcpy(
|
|||||||
audioStream = audioStream,
|
audioStream = audioStream,
|
||||||
audioInput = audioInput,
|
audioInput = audioInput,
|
||||||
controlStream = controlStream,
|
controlStream = controlStream,
|
||||||
|
controlInput = controlInput,
|
||||||
controlWriter = controlWriter,
|
controlWriter = controlWriter,
|
||||||
)
|
)
|
||||||
activeSession = newSession
|
activeSession = newSession
|
||||||
|
if (options.control && options.clipboardAutosync)
|
||||||
|
startControlReaderThread(newSession)
|
||||||
|
|
||||||
return sessionInfo
|
return sessionInfo
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
val tail = snapshotServerLogs()
|
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(
|
suspend fun injectTouch(
|
||||||
action: Int,
|
action: Int,
|
||||||
pointerId: Long,
|
pointerId: Long,
|
||||||
@@ -1121,6 +1202,12 @@ class Scrcpy(
|
|||||||
}
|
}
|
||||||
audioReaderThread = null
|
audioReaderThread = null
|
||||||
|
|
||||||
|
if (Thread.currentThread() !== controlReaderThread) {
|
||||||
|
runCatching { controlReaderThread?.interrupt() }
|
||||||
|
runCatching { controlReaderThread?.join(300) }
|
||||||
|
}
|
||||||
|
controlReaderThread = null
|
||||||
|
|
||||||
runCatching { session.controlStream?.close() }
|
runCatching { session.controlStream?.close() }
|
||||||
runCatching { session.audioStream?.close() }
|
runCatching { session.audioStream?.close() }
|
||||||
runCatching { session.videoStream?.close() }
|
runCatching { session.videoStream?.close() }
|
||||||
@@ -1140,6 +1227,57 @@ class Scrcpy(
|
|||||||
?: throw IllegalStateException("scrcpy control channel not available")
|
?: 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(
|
private fun startServerLogThread(
|
||||||
serverStream: AdbSocketStream,
|
serverStream: AdbSocketStream,
|
||||||
socketName: String
|
socketName: String
|
||||||
@@ -1223,6 +1361,7 @@ class Scrcpy(
|
|||||||
val height: Int,
|
val height: Int,
|
||||||
val audioCodecId: Int = 0,
|
val audioCodecId: Int = 0,
|
||||||
val controlEnabled: Boolean,
|
val controlEnabled: Boolean,
|
||||||
|
val legacyPaste: Boolean = false,
|
||||||
val host: String = "",
|
val host: String = "",
|
||||||
val port: Int = Defaults.ADB_PORT,
|
val port: Int = Defaults.ADB_PORT,
|
||||||
)
|
)
|
||||||
@@ -1286,6 +1425,7 @@ class Scrcpy(
|
|||||||
val audioStream: AdbSocketStream?,
|
val audioStream: AdbSocketStream?,
|
||||||
val audioInput: DataInputStream?,
|
val audioInput: DataInputStream?,
|
||||||
val controlStream: AdbSocketStream?,
|
val controlStream: AdbSocketStream?,
|
||||||
|
val controlInput: DataInputStream?,
|
||||||
val controlWriter: ControlWriter?,
|
val controlWriter: ControlWriter?,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1309,6 +1449,17 @@ class Scrcpy(
|
|||||||
output.flush()
|
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
|
@Synchronized
|
||||||
fun injectTouch(
|
fun injectTouch(
|
||||||
action: Int,
|
action: Int,
|
||||||
@@ -1415,14 +1566,20 @@ class Scrcpy(
|
|||||||
|
|
||||||
private const val AUDIO_DISABLED = 0
|
private const val AUDIO_DISABLED = 0
|
||||||
private const val AUDIO_ERROR = 1
|
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_KEYCODE = 0
|
||||||
private const val TYPE_INJECT_TEXT = 1
|
private const val TYPE_INJECT_TEXT = 1
|
||||||
private const val TYPE_INJECT_TOUCH_EVENT = 2
|
private const val TYPE_INJECT_TOUCH_EVENT = 2
|
||||||
private const val TYPE_INJECT_SCROLL_EVENT = 3
|
private const val TYPE_INJECT_SCROLL_EVENT = 3
|
||||||
private const val TYPE_BACK_OR_SCREEN_ON = 4
|
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_SET_DISPLAY_POWER = 10
|
||||||
private const val TYPE_START_APP = 16
|
private const val TYPE_START_APP = 16
|
||||||
|
private const val CLIPBOARD_SEQUENCE_INVALID = 0L
|
||||||
|
|
||||||
private fun socketNameFor(scid: Int): String {
|
private fun socketNameFor(scid: Int): String {
|
||||||
return "scrcpy_%08x".format(scid)
|
return "scrcpy_%08x".format(scid)
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ data class ServerParams(
|
|||||||
// var forceAdbForward: Boolean,
|
// var forceAdbForward: Boolean,
|
||||||
|
|
||||||
var powerOffOnClose: Boolean,
|
var powerOffOnClose: Boolean,
|
||||||
|
var legacyPaste: Boolean,
|
||||||
|
var clipboardAutosync: Boolean,
|
||||||
|
|
||||||
var downsizeOnError: Boolean,
|
var downsizeOnError: Boolean,
|
||||||
// var tcpip: Boolean,
|
// var tcpip: Boolean,
|
||||||
@@ -236,9 +238,10 @@ data class ServerParams(
|
|||||||
if (powerOffOnClose) {
|
if (powerOffOnClose) {
|
||||||
cmd.add("power_off_on_close=true")
|
cmd.add("power_off_on_close=true")
|
||||||
}
|
}
|
||||||
// not implemented
|
if (legacyPaste) {
|
||||||
val clipBoardAutosync = false
|
cmd.add("legacy_paste=true")
|
||||||
if (!clipBoardAutosync) {
|
}
|
||||||
|
if (!clipboardAutosync) {
|
||||||
cmd.add("clipboard_autosync=false")
|
cmd.add("clipboard_autosync=false")
|
||||||
}
|
}
|
||||||
if (!downsizeOnError) {
|
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"),
|
floatPreferencesKey("fullscreen_floating_button_y_fraction"),
|
||||||
0.72f
|
0.72f
|
||||||
)
|
)
|
||||||
|
val HIDE_SIMPLE_CONFIG_ITEMS = Pair(
|
||||||
|
booleanPreferencesKey("hide_simple_config_items"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair(
|
val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair(
|
||||||
intPreferencesKey("device_preview_card_height_dp"),
|
intPreferencesKey("device_preview_card_height_dp"),
|
||||||
1080 / 3
|
1080 / 3
|
||||||
@@ -72,7 +76,21 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
|||||||
)
|
)
|
||||||
val VIRTUAL_BUTTONS_LAYOUT = Pair(
|
val VIRTUAL_BUTTONS_LAYOUT = Pair(
|
||||||
stringPreferencesKey("virtual_buttons_layout"),
|
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(
|
val CUSTOM_SERVER_URI = Pair(
|
||||||
stringPreferencesKey("custom_server_uri"),
|
stringPreferencesKey("custom_server_uri"),
|
||||||
@@ -111,6 +129,10 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
|||||||
booleanPreferencesKey("password_require_auth"),
|
booleanPreferencesKey("password_require_auth"),
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
val REALTIME_CLIPBOARD_SYNC_TO_DEVICE = Pair(
|
||||||
|
booleanPreferencesKey("realtime_clipboard_sync_to_device"),
|
||||||
|
true
|
||||||
|
)
|
||||||
val LAST_UPDATE_CHECK_AT = Pair(
|
val LAST_UPDATE_CHECK_AT = Pair(
|
||||||
longPreferencesKey("last_update_check_at"),
|
longPreferencesKey("last_update_check_at"),
|
||||||
0L
|
0L
|
||||||
@@ -132,6 +154,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
|||||||
val showFullscreenFloatingButton by setting(SHOW_FULLSCREEN_FLOATING_BUTTON)
|
val showFullscreenFloatingButton by setting(SHOW_FULLSCREEN_FLOATING_BUTTON)
|
||||||
val fullscreenFloatingButtonXFraction by setting(FULLSCREEN_FLOATING_BUTTON_X_FRACTION)
|
val fullscreenFloatingButtonXFraction by setting(FULLSCREEN_FLOATING_BUTTON_X_FRACTION)
|
||||||
val fullscreenFloatingButtonYFraction by setting(FULLSCREEN_FLOATING_BUTTON_Y_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 devicePreviewCardHeightDp by setting(DEVICE_PREVIEW_CARD_HEIGHT_DP)
|
||||||
val previewVirtualButtonShowText by setting(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT)
|
val previewVirtualButtonShowText by setting(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT)
|
||||||
val virtualButtonsLayout by setting(VIRTUAL_BUTTONS_LAYOUT)
|
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 adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY)
|
||||||
val adbAutoLoadAppListOnConnect by setting(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT)
|
val adbAutoLoadAppListOnConnect by setting(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT)
|
||||||
val passwordRequireAuth by setting(PASSWORD_REQUIRE_AUTH)
|
val passwordRequireAuth by setting(PASSWORD_REQUIRE_AUTH)
|
||||||
|
val realtimeClipboardSyncToDevice by setting(REALTIME_CLIPBOARD_SYNC_TO_DEVICE)
|
||||||
val lastUpdateCheckAt by setting(LAST_UPDATE_CHECK_AT)
|
val lastUpdateCheckAt by setting(LAST_UPDATE_CHECK_AT)
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
@@ -164,6 +188,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
|||||||
val showFullscreenFloatingButton: Boolean,
|
val showFullscreenFloatingButton: Boolean,
|
||||||
val fullscreenFloatingButtonXFraction: Float,
|
val fullscreenFloatingButtonXFraction: Float,
|
||||||
val fullscreenFloatingButtonYFraction: Float,
|
val fullscreenFloatingButtonYFraction: Float,
|
||||||
|
val hideSimpleConfigItems: Boolean,
|
||||||
val devicePreviewCardHeightDp: Int,
|
val devicePreviewCardHeightDp: Int,
|
||||||
val previewVirtualButtonShowText: Boolean,
|
val previewVirtualButtonShowText: Boolean,
|
||||||
val virtualButtonsLayout: String,
|
val virtualButtonsLayout: String,
|
||||||
@@ -176,6 +201,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
|||||||
val adbMdnsLanDiscovery: Boolean,
|
val adbMdnsLanDiscovery: Boolean,
|
||||||
val adbAutoLoadAppListOnConnect: Boolean,
|
val adbAutoLoadAppListOnConnect: Boolean,
|
||||||
val passwordRequireAuth: Boolean,
|
val passwordRequireAuth: Boolean,
|
||||||
|
val realtimeClipboardSyncToDevice: Boolean,
|
||||||
val lastUpdateCheckAt: Long,
|
val lastUpdateCheckAt: Long,
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
}
|
}
|
||||||
@@ -193,6 +219,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
|||||||
bundleField(SHOW_FULLSCREEN_FLOATING_BUTTON) { bundle: Bundle -> bundle.showFullscreenFloatingButton },
|
bundleField(SHOW_FULLSCREEN_FLOATING_BUTTON) { bundle: Bundle -> bundle.showFullscreenFloatingButton },
|
||||||
bundleField(FULLSCREEN_FLOATING_BUTTON_X_FRACTION) { bundle: Bundle -> bundle.fullscreenFloatingButtonXFraction },
|
bundleField(FULLSCREEN_FLOATING_BUTTON_X_FRACTION) { bundle: Bundle -> bundle.fullscreenFloatingButtonXFraction },
|
||||||
bundleField(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION) { bundle: Bundle -> bundle.fullscreenFloatingButtonYFraction },
|
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(DEVICE_PREVIEW_CARD_HEIGHT_DP) { bundle: Bundle -> bundle.devicePreviewCardHeightDp },
|
||||||
bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText },
|
bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText },
|
||||||
bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout },
|
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_MDNS_LAN_DISCOVERY) { bundle: Bundle -> bundle.adbMdnsLanDiscovery },
|
||||||
bundleField(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) { bundle: Bundle -> bundle.adbAutoLoadAppListOnConnect },
|
bundleField(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT) { bundle: Bundle -> bundle.adbAutoLoadAppListOnConnect },
|
||||||
bundleField(PASSWORD_REQUIRE_AUTH) { bundle: Bundle -> bundle.passwordRequireAuth },
|
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 },
|
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),
|
showFullscreenFloatingButton = preferences.read(SHOW_FULLSCREEN_FLOATING_BUTTON),
|
||||||
fullscreenFloatingButtonXFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_X_FRACTION),
|
fullscreenFloatingButtonXFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_X_FRACTION),
|
||||||
fullscreenFloatingButtonYFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION),
|
fullscreenFloatingButtonYFraction = preferences.read(FULLSCREEN_FLOATING_BUTTON_Y_FRACTION),
|
||||||
|
hideSimpleConfigItems = preferences.read(HIDE_SIMPLE_CONFIG_ITEMS),
|
||||||
devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP),
|
devicePreviewCardHeightDp = preferences.read(DEVICE_PREVIEW_CARD_HEIGHT_DP),
|
||||||
previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT),
|
previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT),
|
||||||
virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT),
|
virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT),
|
||||||
@@ -237,6 +266,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
|||||||
adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY),
|
adbMdnsLanDiscovery = preferences.read(ADB_MDNS_LAN_DISCOVERY),
|
||||||
adbAutoLoadAppListOnConnect = preferences.read(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT),
|
adbAutoLoadAppListOnConnect = preferences.read(ADB_AUTO_LOAD_APP_LIST_ON_CONNECT),
|
||||||
passwordRequireAuth = preferences.read(PASSWORD_REQUIRE_AUTH),
|
passwordRequireAuth = preferences.read(PASSWORD_REQUIRE_AUTH),
|
||||||
|
realtimeClipboardSyncToDevice = preferences.read(REALTIME_CLIPBOARD_SYNC_TO_DEVICE),
|
||||||
lastUpdateCheckAt = preferences.read(LAST_UPDATE_CHECK_AT),
|
lastUpdateCheckAt = preferences.read(LAST_UPDATE_CHECK_AT),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -188,6 +188,14 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
|||||||
booleanPreferencesKey("power_off_on_close"),
|
booleanPreferencesKey("power_off_on_close"),
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
val LEGACY_PASTE = Pair(
|
||||||
|
booleanPreferencesKey("legacy_paste"),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
val CLIPBOARD_AUTOSYNC = Pair(
|
||||||
|
booleanPreferencesKey("clipboard_autosync"),
|
||||||
|
true,
|
||||||
|
)
|
||||||
val DOWNSIZE_ON_ERROR = Pair(
|
val DOWNSIZE_ON_ERROR = Pair(
|
||||||
booleanPreferencesKey("downsize_on_error"),
|
booleanPreferencesKey("downsize_on_error"),
|
||||||
true,
|
true,
|
||||||
@@ -294,6 +302,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
|||||||
stayAwake = STAY_AWAKE.defaultValue,
|
stayAwake = STAY_AWAKE.defaultValue,
|
||||||
disableScreensaver = DISABLE_SCREENSAVER.defaultValue,
|
disableScreensaver = DISABLE_SCREENSAVER.defaultValue,
|
||||||
powerOffOnClose = POWER_OFF_ON_CLOSE.defaultValue,
|
powerOffOnClose = POWER_OFF_ON_CLOSE.defaultValue,
|
||||||
|
legacyPaste = LEGACY_PASTE.defaultValue,
|
||||||
|
clipboardAutosync = CLIPBOARD_AUTOSYNC.defaultValue,
|
||||||
downsizeOnError = DOWNSIZE_ON_ERROR.defaultValue,
|
downsizeOnError = DOWNSIZE_ON_ERROR.defaultValue,
|
||||||
cleanup = CLEANUP.defaultValue,
|
cleanup = CLEANUP.defaultValue,
|
||||||
powerOn = POWER_ON.defaultValue,
|
powerOn = POWER_ON.defaultValue,
|
||||||
@@ -353,6 +363,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
|||||||
val stayAwake by setting(STAY_AWAKE)
|
val stayAwake by setting(STAY_AWAKE)
|
||||||
val disableScreensaver by setting(DISABLE_SCREENSAVER)
|
val disableScreensaver by setting(DISABLE_SCREENSAVER)
|
||||||
val powerOffOnClose by setting(POWER_OFF_ON_CLOSE)
|
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 cleanup by setting(CLEANUP)
|
||||||
val powerOn by setting(POWER_ON)
|
val powerOn by setting(POWER_ON)
|
||||||
val video by setting(VIDEO)
|
val video by setting(VIDEO)
|
||||||
@@ -409,6 +421,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
|||||||
val stayAwake: Boolean,
|
val stayAwake: Boolean,
|
||||||
val disableScreensaver: Boolean,
|
val disableScreensaver: Boolean,
|
||||||
val powerOffOnClose: Boolean,
|
val powerOffOnClose: Boolean,
|
||||||
|
val legacyPaste: Boolean,
|
||||||
|
val clipboardAutosync: Boolean,
|
||||||
val downsizeOnError: Boolean,
|
val downsizeOnError: Boolean,
|
||||||
val cleanup: Boolean,
|
val cleanup: Boolean,
|
||||||
val powerOn: Boolean,
|
val powerOn: Boolean,
|
||||||
@@ -469,6 +483,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
|||||||
bundleField(STAY_AWAKE) { bundle: Bundle -> bundle.stayAwake },
|
bundleField(STAY_AWAKE) { bundle: Bundle -> bundle.stayAwake },
|
||||||
bundleField(DISABLE_SCREENSAVER) { bundle: Bundle -> bundle.disableScreensaver },
|
bundleField(DISABLE_SCREENSAVER) { bundle: Bundle -> bundle.disableScreensaver },
|
||||||
bundleField(POWER_OFF_ON_CLOSE) { bundle: Bundle -> bundle.powerOffOnClose },
|
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(CLEANUP) { bundle: Bundle -> bundle.cleanup },
|
||||||
bundleField(POWER_ON) { bundle: Bundle -> bundle.powerOn },
|
bundleField(POWER_ON) { bundle: Bundle -> bundle.powerOn },
|
||||||
bundleField(VIDEO) { bundle: Bundle -> bundle.video },
|
bundleField(VIDEO) { bundle: Bundle -> bundle.video },
|
||||||
@@ -529,6 +545,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
|||||||
stayAwake = preferences.read(STAY_AWAKE),
|
stayAwake = preferences.read(STAY_AWAKE),
|
||||||
disableScreensaver = preferences.read(DISABLE_SCREENSAVER),
|
disableScreensaver = preferences.read(DISABLE_SCREENSAVER),
|
||||||
powerOffOnClose = preferences.read(POWER_OFF_ON_CLOSE),
|
powerOffOnClose = preferences.read(POWER_OFF_ON_CLOSE),
|
||||||
|
legacyPaste = preferences.read(LEGACY_PASTE),
|
||||||
|
clipboardAutosync = preferences.read(CLIPBOARD_AUTOSYNC),
|
||||||
downsizeOnError = preferences.read(DOWNSIZE_ON_ERROR),
|
downsizeOnError = preferences.read(DOWNSIZE_ON_ERROR),
|
||||||
cleanup = preferences.read(CLEANUP),
|
cleanup = preferences.read(CLEANUP),
|
||||||
powerOn = preferences.read(POWER_ON),
|
powerOn = preferences.read(POWER_ON),
|
||||||
@@ -604,6 +622,8 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
|||||||
stayAwake = bundle.stayAwake,
|
stayAwake = bundle.stayAwake,
|
||||||
disableScreensaver = bundle.disableScreensaver,
|
disableScreensaver = bundle.disableScreensaver,
|
||||||
powerOffOnClose = bundle.powerOffOnClose,
|
powerOffOnClose = bundle.powerOffOnClose,
|
||||||
|
legacyPaste = bundle.legacyPaste,
|
||||||
|
clipboardAutosync = bundle.clipboardAutosync,
|
||||||
downsizeOnError = bundle.downsizeOnError,
|
downsizeOnError = bundle.downsizeOnError,
|
||||||
cleanup = bundle.cleanup,
|
cleanup = bundle.cleanup,
|
||||||
powerOn = bundle.powerOn,
|
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
|
package io.github.miuzarte.scrcpyforandroid.widgets
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.view.KeyEvent
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.foundation.background
|
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.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
@@ -86,6 +85,8 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import top.yukonga.miuix.kmp.basic.Button
|
import top.yukonga.miuix.kmp.basic.Button
|
||||||
import top.yukonga.miuix.kmp.basic.ButtonDefaults
|
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.preference.SwitchPreference
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme.textStyles
|
||||||
import top.yukonga.miuix.kmp.utils.PressFeedbackType
|
import top.yukonga.miuix.kmp.utils.PressFeedbackType
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -253,6 +255,10 @@ internal fun PreviewCard(
|
|||||||
sessionInfo: Scrcpy.Session.SessionInfo?,
|
sessionInfo: Scrcpy.Session.SessionInfo?,
|
||||||
previewHeightDp: Int,
|
previewHeightDp: Int,
|
||||||
onOpenFullscreen: () -> Unit,
|
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,
|
autoBringIntoView: Boolean = false,
|
||||||
onAutoBringIntoViewConsumed: () -> Unit = {},
|
onAutoBringIntoViewConsumed: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
@@ -335,6 +341,10 @@ internal fun PreviewCard(
|
|||||||
ScrcpyVideoSurface(
|
ScrcpyVideoSurface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
session = sessionInfo,
|
session = sessionInfo,
|
||||||
|
imeRequestToken = imeRequestToken,
|
||||||
|
onImeCommitText = onImeCommitText,
|
||||||
|
onImeDeleteSurroundingText = onImeDeleteSurroundingText,
|
||||||
|
onImeKeyEvent = onImeKeyEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -401,14 +411,17 @@ internal fun ConfigPanel(
|
|||||||
busy: Boolean,
|
busy: Boolean,
|
||||||
activeProfileId: String,
|
activeProfileId: String,
|
||||||
activeBundle: ScrcpyOptions.Bundle,
|
activeBundle: ScrcpyOptions.Bundle,
|
||||||
|
hideSimpleConfigItems: Boolean,
|
||||||
audioForwardingSupported: Boolean,
|
audioForwardingSupported: Boolean,
|
||||||
cameraMirroringSupported: Boolean,
|
cameraMirroringSupported: Boolean,
|
||||||
adbConnecting: Boolean,
|
adbConnecting: Boolean,
|
||||||
isQuickConnected: Boolean,
|
isQuickConnected: Boolean,
|
||||||
recentTasksEndAction: @Composable (RowScope.() -> Unit)? = null,
|
allAppsEndActionText: String,
|
||||||
onOpenRecentTasks: (() -> Unit)? = null,
|
onOpenAllApps: () -> Unit,
|
||||||
|
recentTasksEndActionText: String,
|
||||||
|
onOpenRecentTasks: () -> Unit,
|
||||||
onOpenAdvanced: () -> Unit,
|
onOpenAdvanced: () -> Unit,
|
||||||
onStartStopHaptic: (() -> Unit)? = null,
|
onStartStopHaptic: () -> Unit,
|
||||||
onStart: () -> Unit,
|
onStart: () -> Unit,
|
||||||
onStop: () -> Unit,
|
onStop: () -> Unit,
|
||||||
sessionInfo: Scrcpy.Session.SessionInfo?,
|
sessionInfo: Scrcpy.Session.SessionInfo?,
|
||||||
@@ -470,149 +483,167 @@ internal fun ConfigPanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Card {
|
Card {
|
||||||
SwitchPreference(
|
if (!hideSimpleConfigItems) {
|
||||||
title = "音频转发",
|
SwitchPreference(
|
||||||
summary = "转发设备音频到本机 (Android 11+)",
|
title = "音频转发",
|
||||||
checked = soBundle.audio,
|
summary = "转发设备音频到本机 (Android 11+)",
|
||||||
onCheckedChange = { soBundle = soBundle.copy(audio = it) },
|
checked = soBundle.audio,
|
||||||
enabled = !sessionStarted
|
onCheckedChange = { soBundle = soBundle.copy(audio = it) },
|
||||||
&& audioForwardingSupported,
|
enabled = !sessionStarted
|
||||||
)
|
&& audioForwardingSupported,
|
||||||
|
)
|
||||||
|
|
||||||
OverlayDropdownPreference(
|
OverlayDropdownPreference(
|
||||||
title = "音频编码",
|
title = "音频编码",
|
||||||
summary = "--audio-codec",
|
summary = "--audio-codec",
|
||||||
items = audioCodecItems,
|
items = audioCodecItems,
|
||||||
selectedIndex = audioCodecIndex,
|
selectedIndex = audioCodecIndex,
|
||||||
onSelectedIndexChange = {
|
onSelectedIndexChange = {
|
||||||
val codec = Codec.AUDIO[it]
|
val codec = Codec.AUDIO[it]
|
||||||
soBundle = soBundle.copy(audioCodec = codec.string)
|
soBundle = soBundle.copy(audioCodec = codec.string)
|
||||||
if (codec == Codec.FLAC) {
|
if (codec == Codec.FLAC) {
|
||||||
snackbar.show("注意:FLAC编解码会引入较大的延迟")
|
snackbar.show("注意:FLAC编解码会引入较大的延迟")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !sessionStarted && soBundle.audio,
|
enabled = !sessionStarted && soBundle.audio,
|
||||||
)
|
)
|
||||||
AnimatedVisibility(audioBitRateVisibility) {
|
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(
|
SuperSlider(
|
||||||
title = "音频码率",
|
title = "视频码率",
|
||||||
summary = "--audio-bit-rate",
|
summary = "--video-bit-rate",
|
||||||
value = ScrcpyPresets.AudioBitRate
|
value = soBundle.videoBitRate / 1_000_000f,
|
||||||
.indexOfOrNearest(soBundle.audioBitRate / 1000)
|
onValueChange = { mbps ->
|
||||||
.toFloat(),
|
|
||||||
onValueChange = { value ->
|
|
||||||
val idx = value.roundToInt()
|
|
||||||
.coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex)
|
|
||||||
soBundle = soBundle.copy(
|
soBundle = soBundle.copy(
|
||||||
audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000
|
videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(),
|
valueRange = 0f..40f,
|
||||||
steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0),
|
steps = 400 - 1,
|
||||||
unit = "Kbps",
|
unit = "Mbps",
|
||||||
zeroStateText = "默认",
|
zeroStateText = "默认",
|
||||||
displayText = (soBundle.audioBitRate / 1_000).toString(),
|
displayFormatter = { String.format("%.1f", it) },
|
||||||
inputInitialValue =
|
inputInitialValue =
|
||||||
if (soBundle.audioBitRate <= 0) ""
|
if (soBundle.videoBitRate <= 0) ""
|
||||||
else (soBundle.audioBitRate / 1_000).toString(),
|
else String.format("%.1f", soBundle.videoBitRate / 1_000_000f),
|
||||||
inputFilter = { it.filter(Char::isDigit) },
|
inputFilter = { text ->
|
||||||
inputValueRange = 0f..UShort.MAX_VALUE.toFloat(),
|
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 ->
|
onInputConfirm = { raw ->
|
||||||
raw.toIntOrNull()
|
raw.toFloatOrNull()?.let { parsed ->
|
||||||
?.takeIf { it >= 0 }
|
if (parsed >= 0f) {
|
||||||
?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) }
|
soBundle = soBundle.copy(
|
||||||
|
videoBitRate = (parsed * 1_000_000f).roundToInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
enabled = !sessionStarted,
|
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(
|
ArrowPreference(
|
||||||
title = "更多参数",
|
title = if (!hideSimpleConfigItems) "更多参数" else "所有参数",
|
||||||
summary = "所有 scrcpy 参数",
|
summary = "所有 scrcpy 参数",
|
||||||
onClick = onOpenAdvanced,
|
onClick = onOpenAdvanced,
|
||||||
enabled = !sessionStarted,
|
enabled = !sessionStarted,
|
||||||
)
|
)
|
||||||
if (onOpenRecentTasks != null) {
|
|
||||||
ArrowPreference(
|
ArrowPreference(
|
||||||
title = "最近任务",
|
title = "所有应用",
|
||||||
endActions = {
|
endActions = {
|
||||||
recentTasksEndAction?.invoke(this)
|
Text(
|
||||||
},
|
text = allAppsEndActionText,
|
||||||
onClick = onOpenRecentTasks,
|
color = colorScheme.onSurfaceVariantActions,
|
||||||
enabled = !busy && !adbConnecting,
|
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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = UiSpacing.ContentVertical)
|
.padding(all = UiSpacing.ContentVertical),
|
||||||
.padding(bottom = UiSpacing.ContentVertical),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
|
||||||
) {
|
) {
|
||||||
if (isQuickConnected) TextButton(
|
if (isQuickConnected) TextButton(
|
||||||
text = "断开",
|
text = "断开",
|
||||||
onClick = {
|
onClick = {
|
||||||
onStartStopHaptic?.invoke()
|
onStartStopHaptic()
|
||||||
onDisconnect()
|
onDisconnect()
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f / 4f),
|
modifier = Modifier.weight(1f / 4f),
|
||||||
@@ -621,7 +652,7 @@ internal fun ConfigPanel(
|
|||||||
if (!sessionStarted) TextButton(
|
if (!sessionStarted) TextButton(
|
||||||
text = "启动",
|
text = "启动",
|
||||||
onClick = {
|
onClick = {
|
||||||
onStartStopHaptic?.invoke()
|
onStartStopHaptic()
|
||||||
onStart()
|
onStart()
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f),
|
modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f),
|
||||||
@@ -631,7 +662,7 @@ internal fun ConfigPanel(
|
|||||||
if (sessionStarted) TextButton(
|
if (sessionStarted) TextButton(
|
||||||
text = "停止",
|
text = "停止",
|
||||||
onClick = {
|
onClick = {
|
||||||
onStartStopHaptic?.invoke()
|
onStartStopHaptic()
|
||||||
onStop()
|
onStop()
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f),
|
modifier = Modifier.weight(if (isQuickConnected) 3f / 4f else 1f),
|
||||||
@@ -813,14 +844,19 @@ private fun PairingDialog(
|
|||||||
fun ScrcpyVideoSurface(
|
fun ScrcpyVideoSurface(
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
session: Scrcpy.Session.SessionInfo?,
|
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 taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val imeInputMutex = remember { Mutex() }
|
||||||
|
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
var currentSurface by remember { mutableStateOf<Surface?>(null) }
|
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)
|
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) {
|
DisposableEffect(lifecycleOwner, session, currentSurface) {
|
||||||
val surface = currentSurface
|
val surface = currentSurface
|
||||||
if (
|
if (
|
||||||
@@ -882,8 +925,42 @@ fun ScrcpyVideoSurface(
|
|||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
factory = { context ->
|
factory = { context ->
|
||||||
SurfaceView(context).apply {
|
ScrcpyInputSurfaceView(context).apply {
|
||||||
currentSurfaceView = this
|
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 {
|
holder.addCallback(object : SurfaceHolder.Callback {
|
||||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||||
val newSurface = holder.surface
|
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.VolumeOff
|
||||||
import androidx.compose.material.icons.automirrored.rounded.VolumeUp
|
import androidx.compose.material.icons.automirrored.rounded.VolumeUp
|
||||||
import androidx.compose.material.icons.rounded.Apps
|
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.Home
|
||||||
|
import androidx.compose.material.icons.rounded.Keyboard
|
||||||
import androidx.compose.material.icons.rounded.Menu
|
import androidx.compose.material.icons.rounded.Menu
|
||||||
import androidx.compose.material.icons.rounded.MoreVert
|
import androidx.compose.material.icons.rounded.MoreVert
|
||||||
import androidx.compose.material.icons.rounded.Notifications
|
import androidx.compose.material.icons.rounded.Notifications
|
||||||
@@ -145,6 +148,30 @@ enum class VirtualButtonAction(
|
|||||||
"填充锁屏密码",
|
"填充锁屏密码",
|
||||||
Icons.Rounded.Password,
|
Icons.Rounded.Password,
|
||||||
null
|
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