feat: --new-display + --start-app
This commit is contained in:
@@ -55,9 +55,7 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
|
||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
||||
import io.github.miuzarte.scrcpyforandroid.password.PasswordPickerPopupContent
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
|
||||
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||
import io.github.miuzarte.scrcpyforandroid.services.AppWakeLocks
|
||||
import io.github.miuzarte.scrcpyforandroid.services.DeviceAdbBackgroundRunner
|
||||
@@ -125,13 +123,6 @@ private const val ADB_TCP_PROBE_TIMEOUT_MS = 500
|
||||
private const val PREVIEW_CARD_ITEM_KEY = "preview_card"
|
||||
private const val PREVIEW_CARD_ITEM_INDEX = 3
|
||||
|
||||
private data class StartAppRequest(
|
||||
val packageName: String,
|
||||
val displayId: Int?,
|
||||
val forceStop: Boolean,
|
||||
val matchedAppLabel: String? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DeviceTabScreen(
|
||||
scrollBehavior: ScrollBehavior,
|
||||
@@ -314,36 +305,6 @@ fun DeviceTabPage(
|
||||
}
|
||||
}
|
||||
|
||||
val adbConnected = adbSession.isConnected
|
||||
val statusLine = adbSession.statusLine
|
||||
val isQuickConnected = adbSession.isQuickConnected
|
||||
val currentTarget = adbSession.currentTarget
|
||||
val connectedDeviceLabel = adbSession.connectedDeviceLabel
|
||||
val connectedScrcpyProfileId = adbSession.connectedScrcpyProfileId
|
||||
val audioForwardingSupported = adbSession.audioForwardingSupported
|
||||
val cameraMirroringSupported = adbSession.cameraMirroringSupported
|
||||
val recentTasks = scrcpy.listings.recentTasks
|
||||
|
||||
fun resolveScrcpyBundle(profileId: String): ScrcpyOptions.Bundle {
|
||||
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||
return soBundleShared
|
||||
}
|
||||
return scrcpyProfilesState.profiles.firstOrNull { it.id == profileId }?.bundle
|
||||
?: soBundleShared
|
||||
}
|
||||
|
||||
val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
|
||||
|
||||
val virtualButtonLayout = remember(asBundle.virtualButtonsLayout) {
|
||||
VirtualButtonActions.splitLayout(
|
||||
VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout)
|
||||
)
|
||||
}
|
||||
|
||||
var quickConnectInputTemp by rememberSaveable(qdBundle.quickConnectInput) {
|
||||
mutableStateOf(qdBundle.quickConnectInput)
|
||||
}
|
||||
|
||||
var savedShortcuts by remember {
|
||||
mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList))
|
||||
}
|
||||
@@ -358,6 +319,44 @@ fun DeviceTabPage(
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveScrcpyBundle(profileId: String): ScrcpyOptions.Bundle {
|
||||
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||
return soBundleShared
|
||||
}
|
||||
return scrcpyProfilesState.profiles.firstOrNull { it.id == profileId }?.bundle
|
||||
?: soBundleShared
|
||||
}
|
||||
|
||||
val adbConnected = adbSession.isConnected
|
||||
val statusLine = adbSession.statusLine
|
||||
val isQuickConnected = adbSession.isQuickConnected
|
||||
val currentTarget = adbSession.currentTarget
|
||||
val connectedDeviceLabel = adbSession.connectedDeviceLabel
|
||||
val connectedScrcpyProfileId =
|
||||
if (adbConnected && currentTarget != null)
|
||||
savedShortcuts.get(currentTarget.host, currentTarget.port)
|
||||
?.scrcpyProfileId
|
||||
?: adbSession.connectedScrcpyProfileId
|
||||
else
|
||||
adbSession.connectedScrcpyProfileId
|
||||
|
||||
val connectedScrcpyBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
|
||||
val audioForwardingSupported = adbSession.audioForwardingSupported
|
||||
val cameraMirroringSupported = adbSession.cameraMirroringSupported
|
||||
val recentTasks = scrcpy.listings.recentTasks
|
||||
|
||||
val sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
|
||||
|
||||
val virtualButtonLayout = remember(asBundle.virtualButtonsLayout) {
|
||||
VirtualButtonActions.splitLayout(
|
||||
VirtualButtonActions.parseStoredLayout(asBundle.virtualButtonsLayout)
|
||||
)
|
||||
}
|
||||
|
||||
var quickConnectInputTemp by rememberSaveable(qdBundle.quickConnectInput) {
|
||||
mutableStateOf(qdBundle.quickConnectInput)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the current ADB connection and stop any running scrcpy session.
|
||||
*
|
||||
@@ -606,62 +605,17 @@ fun DeviceTabPage(
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun resolveStartAppRequest(
|
||||
scrcpy: Scrcpy,
|
||||
options: ClientOptions,
|
||||
): StartAppRequest? {
|
||||
val raw = options.startApp.trim()
|
||||
if (raw.isBlank()) {
|
||||
return null
|
||||
LaunchedEffect(adbConnected, currentTarget?.host, currentTarget?.port, savedShortcuts) {
|
||||
val target = currentTarget ?: return@LaunchedEffect
|
||||
if (!adbConnected) return@LaunchedEffect
|
||||
val boundProfileId = savedShortcuts
|
||||
.get(target.host, target.port)
|
||||
?.scrcpyProfileId
|
||||
?: ScrcpyOptions.GLOBAL_PROFILE_ID
|
||||
if (boundProfileId != adbSession.connectedScrcpyProfileId) {
|
||||
adbSession = adbSession.copy(connectedScrcpyProfileId = boundProfileId)
|
||||
logEvent("当前连接设备已切换为配置: $boundProfileId")
|
||||
}
|
||||
|
||||
var query = raw
|
||||
val forceStop = query.startsWith("+")
|
||||
if (forceStop) {
|
||||
query = query.drop(1).trimStart()
|
||||
}
|
||||
require(query.isNotBlank()) { "应用名或包名不能为空" }
|
||||
|
||||
val displayId = when {
|
||||
options.videoSource != VideoSource.DISPLAY -> null
|
||||
options.displayId >= 0 -> options.displayId
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (!query.startsWith("?")) {
|
||||
return StartAppRequest(
|
||||
packageName = query,
|
||||
displayId = displayId,
|
||||
forceStop = forceStop,
|
||||
)
|
||||
}
|
||||
|
||||
val searchName = query.drop(1).trim()
|
||||
require(searchName.isNotBlank()) {
|
||||
"应用名不能为空"
|
||||
}
|
||||
|
||||
val apps = scrcpy.listings.getApps(forceRefresh = false)
|
||||
val matches = apps.filter {
|
||||
it.label?.startsWith(searchName, ignoreCase = true) == true
|
||||
}
|
||||
|
||||
require(matches.isNotEmpty()) {
|
||||
"未找到应用名以 \"$searchName\" 开头的应用"
|
||||
}
|
||||
require(matches.size == 1) {
|
||||
"按应用名匹配到多个应用: " +
|
||||
matches.take(5).joinToString {
|
||||
"${it.label ?: it.packageName} (${it.packageName})"
|
||||
}
|
||||
}
|
||||
|
||||
return StartAppRequest(
|
||||
packageName = matches[0].packageName,
|
||||
displayId = displayId,
|
||||
forceStop = forceStop,
|
||||
matchedAppLabel = matches[0].label,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun startScrcpySession(
|
||||
@@ -676,42 +630,15 @@ fun DeviceTabPage(
|
||||
?: options
|
||||
val session = scrcpy.start(resolvedOptions)
|
||||
pendingScrollToPreview = true
|
||||
val startAppRequest = runCatching {
|
||||
resolveStartAppRequest(scrcpy, resolvedOptions)
|
||||
}.getOrElse { error ->
|
||||
logEvent(
|
||||
"启动应用请求无效: " +
|
||||
"${error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName}",
|
||||
Log.WARN,
|
||||
error,
|
||||
)
|
||||
null
|
||||
}
|
||||
startAppRequest?.let { request ->
|
||||
if (resolvedOptions.newDisplay.isNotBlank() && request.displayId == null) {
|
||||
logEvent(
|
||||
"当前实现无法获取 new display 的真实 displayId,应用会启动到默认显示",
|
||||
Log.WARN,
|
||||
)
|
||||
}
|
||||
if (resolvedOptions.startApp.isNotBlank() && resolvedOptions.control) {
|
||||
runCatching {
|
||||
adbCoordinator.startApp(
|
||||
packageName = request.packageName,
|
||||
displayId = request.displayId,
|
||||
forceStop = request.forceStop,
|
||||
)
|
||||
scrcpy.startApp(resolvedOptions.startApp)
|
||||
}.onSuccess {
|
||||
val appLabelPart = request.matchedAppLabel?.let { " ($it)" }.orEmpty()
|
||||
logEvent(
|
||||
"已启动应用: " +
|
||||
"${request.packageName}$appLabelPart"
|
||||
+ request.displayId?.let { " @display=$it" }.orEmpty()
|
||||
)
|
||||
logEvent("已请求 scrcpy 启动应用: ${resolvedOptions.startApp}")
|
||||
}.onFailure { error ->
|
||||
logEvent(
|
||||
"启动应用失败: " +
|
||||
"${request.packageName} " +
|
||||
"(${error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName})",
|
||||
"通过 scrcpy 控制通道启动应用失败: " +
|
||||
"${error.message?.takeIf { it.isNotBlank() } ?: error.javaClass.simpleName}",
|
||||
Log.WARN,
|
||||
error,
|
||||
)
|
||||
@@ -1082,6 +1009,8 @@ fun DeviceTabPage(
|
||||
SectionSmallTitle("Scrcpy")
|
||||
ConfigPanel(
|
||||
busy = busy,
|
||||
activeProfileId = connectedScrcpyProfileId,
|
||||
activeBundle = connectedScrcpyBundle,
|
||||
audioForwardingSupported = audioForwardingSupported,
|
||||
cameraMirroringSupported = cameraMirroringSupported,
|
||||
adbConnecting = adbConnecting,
|
||||
@@ -1222,12 +1151,28 @@ fun DeviceTabPage(
|
||||
},
|
||||
onLaunchTask = { task ->
|
||||
showRecentTasksSheet = false
|
||||
if (sessionInfo == null) runBusy("启动 scrcpy") {
|
||||
startScrcpySession(startAppOverride = task.packageName)
|
||||
}
|
||||
else runBusy("启动应用") {
|
||||
adbCoordinator.startApp(packageName = task.packageName)
|
||||
logEvent("已启动应用: ${task.packageName}")
|
||||
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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -48,6 +48,7 @@ import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop
|
||||
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSpinner
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
|
||||
@@ -100,7 +101,6 @@ import top.yukonga.miuix.kmp.overlay.OverlayDialog
|
||||
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
||||
import top.yukonga.miuix.kmp.preference.ArrowPreference
|
||||
import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference
|
||||
import top.yukonga.miuix.kmp.preference.OverlaySpinnerPreference
|
||||
import top.yukonga.miuix.kmp.preference.SwitchPreference
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme
|
||||
import kotlin.math.roundToInt
|
||||
@@ -481,6 +481,9 @@ internal fun ScrcpyAllOptionsPage(
|
||||
val displayDropdownItems = rememberSaveable(displays, listRefreshVersion) {
|
||||
listOf("默认") + displays.map { "${it.id} (${it.width}x${it.height})" }
|
||||
}
|
||||
val displaySpinnerItems = remember(displayDropdownItems) {
|
||||
displayDropdownItems.map { SpinnerEntry(title = it) }
|
||||
}
|
||||
val displayDropdownIndex = rememberSaveable(
|
||||
soBundle.displayId,
|
||||
displays,
|
||||
@@ -488,6 +491,14 @@ internal fun ScrcpyAllOptionsPage(
|
||||
) {
|
||||
(displays.indexOfFirst { it.id == soBundle.displayId } + 1).coerceAtLeast(0)
|
||||
}
|
||||
val displayOverrideEndActionValue = remember(
|
||||
displays,
|
||||
listRefreshVersion,
|
||||
soBundle.displayId,
|
||||
) {
|
||||
if (displays.isEmpty() && soBundle.displayId >= 0) soBundle.displayId.toString()
|
||||
else null
|
||||
}
|
||||
|
||||
val cameras = scrcpy.listings.cameras
|
||||
val cameraDropdownItems = rememberSaveable(cameras, listRefreshVersion) {
|
||||
@@ -504,6 +515,9 @@ internal fun ScrcpyAllOptionsPage(
|
||||
}
|
||||
}
|
||||
}
|
||||
val cameraSpinnerItems = remember(cameraDropdownItems) {
|
||||
cameraDropdownItems.map { SpinnerEntry(title = it) }
|
||||
}
|
||||
val cameraDropdownIndex = rememberSaveable(
|
||||
soBundle.cameraId,
|
||||
cameras,
|
||||
@@ -511,6 +525,14 @@ internal fun ScrcpyAllOptionsPage(
|
||||
) {
|
||||
(cameras.indexOfFirst { it.id == soBundle.cameraId } + 1).coerceAtLeast(0)
|
||||
}
|
||||
val cameraOverrideEndActionValue = remember(
|
||||
cameras,
|
||||
listRefreshVersion,
|
||||
soBundle.cameraId,
|
||||
) {
|
||||
if (cameras.isEmpty() && soBundle.cameraId.isNotBlank()) soBundle.cameraId
|
||||
else null
|
||||
}
|
||||
|
||||
val cameraFacingItems = rememberSaveable {
|
||||
listOf("默认") + CameraFacing.entries
|
||||
@@ -535,6 +557,9 @@ internal fun ScrcpyAllOptionsPage(
|
||||
val cameraSizeDropdownItems = rememberSaveable(cameraSizes, listRefreshVersion) {
|
||||
listOf("默认", "自定义") + cameraSizes
|
||||
}
|
||||
val cameraSizeSpinnerItems = remember(cameraSizeDropdownItems) {
|
||||
cameraSizeDropdownItems.map { SpinnerEntry(title = it) }
|
||||
}
|
||||
val cameraSizeDropdownIndex = rememberSaveable(
|
||||
soBundle.cameraSize,
|
||||
soBundle.cameraSizeCustom,
|
||||
@@ -551,6 +576,23 @@ internal fun ScrcpyAllOptionsPage(
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
val cameraSizeOverrideEndActionValue = remember(
|
||||
cameraSizes,
|
||||
listRefreshVersion,
|
||||
soBundle.cameraSize,
|
||||
soBundle.cameraSizeCustom,
|
||||
soBundle.cameraSizeUseCustom,
|
||||
) {
|
||||
if (cameraSizes.isNotEmpty()) {
|
||||
null
|
||||
} else if (soBundle.cameraSizeUseCustom && soBundle.cameraSizeCustom.isNotBlank()) {
|
||||
soBundle.cameraSizeCustom
|
||||
} else if (soBundle.cameraSize.isNotBlank()) {
|
||||
soBundle.cameraSize
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
var cameraArInput by rememberSaveable(soBundle.cameraAr) {
|
||||
mutableStateOf(soBundle.cameraAr)
|
||||
@@ -615,6 +657,14 @@ internal fun ScrcpyAllOptionsPage(
|
||||
(videoEncoders.indexOfFirst { it.id == soBundle.videoEncoder } + 1)
|
||||
.coerceAtLeast(0)
|
||||
}
|
||||
val videoEncoderOverrideEndActionValue = remember(
|
||||
videoEncoders,
|
||||
listRefreshVersion,
|
||||
soBundle.videoEncoder,
|
||||
) {
|
||||
if (videoEncoders.isEmpty() && soBundle.videoEncoder.isNotBlank()) soBundle.videoEncoder
|
||||
else null
|
||||
}
|
||||
|
||||
val audioEncoders = scrcpy.listings.audioEncoders
|
||||
val audioEncoderItems by remember(audioEncoders, listRefreshVersion) {
|
||||
@@ -640,6 +690,14 @@ internal fun ScrcpyAllOptionsPage(
|
||||
(audioEncoders.indexOfFirst { it.id == soBundle.audioEncoder } + 1)
|
||||
.coerceAtLeast(0)
|
||||
}
|
||||
val audioEncoderOverrideEndActionValue = remember(
|
||||
audioEncoders,
|
||||
listRefreshVersion,
|
||||
soBundle.audioEncoder,
|
||||
) {
|
||||
if (audioEncoders.isEmpty() && soBundle.audioEncoder.isNotBlank()) soBundle.audioEncoder
|
||||
else null
|
||||
}
|
||||
|
||||
val displayImePolicyItems = rememberSaveable {
|
||||
listOf("默认") + DisplayImePolicy.entries
|
||||
@@ -702,6 +760,14 @@ internal fun ScrcpyAllOptionsPage(
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
val startAppOverrideEndActionValue = remember(
|
||||
apps,
|
||||
listRefreshVersion,
|
||||
soBundle.startApp,
|
||||
) {
|
||||
if (apps.isEmpty() && soBundle.startApp.isNotBlank()) soBundle.startApp
|
||||
else null
|
||||
}
|
||||
var startAppCustomInput by rememberSaveable(soBundle.startAppCustom) {
|
||||
mutableStateOf(soBundle.startAppCustom)
|
||||
}
|
||||
@@ -1091,11 +1157,12 @@ internal fun ScrcpyAllOptionsPage(
|
||||
}
|
||||
},
|
||||
)
|
||||
OverlayDropdownPreference(
|
||||
SuperSpinner(
|
||||
title = "监视器 ID",
|
||||
summary = "--display-id",
|
||||
items = displayDropdownItems,
|
||||
items = displaySpinnerItems,
|
||||
selectedIndex = displayDropdownIndex,
|
||||
overrideEndActionValue = displayOverrideEndActionValue,
|
||||
onSelectedIndexChange = {
|
||||
soBundle = soBundle.copy(
|
||||
displayId =
|
||||
@@ -1187,11 +1254,12 @@ internal fun ScrcpyAllOptionsPage(
|
||||
}
|
||||
},
|
||||
)
|
||||
OverlayDropdownPreference(
|
||||
SuperSpinner(
|
||||
title = "摄像头 ID",
|
||||
summary = "--camera-id",
|
||||
items = cameraDropdownItems,
|
||||
items = cameraSpinnerItems,
|
||||
selectedIndex = cameraDropdownIndex,
|
||||
overrideEndActionValue = cameraOverrideEndActionValue,
|
||||
onSelectedIndexChange = {
|
||||
soBundle = soBundle.copy(
|
||||
cameraId =
|
||||
@@ -1231,11 +1299,12 @@ internal fun ScrcpyAllOptionsPage(
|
||||
}
|
||||
},
|
||||
)
|
||||
OverlayDropdownPreference(
|
||||
SuperSpinner(
|
||||
title = "摄像头分辨率",
|
||||
summary = "--camera-size",
|
||||
items = cameraSizeDropdownItems,
|
||||
items = cameraSizeSpinnerItems,
|
||||
selectedIndex = cameraSizeDropdownIndex,
|
||||
overrideEndActionValue = cameraSizeOverrideEndActionValue,
|
||||
onSelectedIndexChange = {
|
||||
when (it) {
|
||||
0 -> {
|
||||
@@ -1419,11 +1488,12 @@ internal fun ScrcpyAllOptionsPage(
|
||||
},
|
||||
)
|
||||
// TODO: 等 MIUIX 发版, 在 OverlaySpinnerPreference / OverlayDropdownPreference 支持展开状态回调后, 在展开时触发获取
|
||||
OverlaySpinnerPreference(
|
||||
SuperSpinner(
|
||||
title = "视频编码器",
|
||||
summary = "--video-encoder",
|
||||
items = videoEncoderItems,
|
||||
selectedIndex = videoEncoderIndex,
|
||||
overrideEndActionValue = videoEncoderOverrideEndActionValue,
|
||||
onSelectedIndexChange = {
|
||||
soBundle = soBundle.copy(
|
||||
videoEncoder =
|
||||
@@ -1446,11 +1516,12 @@ internal fun ScrcpyAllOptionsPage(
|
||||
.fillMaxWidth()
|
||||
.padding(all = UiSpacing.Large),
|
||||
)
|
||||
OverlaySpinnerPreference(
|
||||
SuperSpinner(
|
||||
title = "音频编码器",
|
||||
summary = "--audio-encoder",
|
||||
items = audioEncoderItems,
|
||||
selectedIndex = audioEncoderIndex,
|
||||
overrideEndActionValue = audioEncoderOverrideEndActionValue,
|
||||
onSelectedIndexChange = {
|
||||
soBundle = soBundle.copy(
|
||||
audioEncoder =
|
||||
@@ -1564,11 +1635,12 @@ internal fun ScrcpyAllOptionsPage(
|
||||
}
|
||||
},
|
||||
)
|
||||
OverlaySpinnerPreference(
|
||||
SuperSpinner(
|
||||
title = "scrcpy 启动后打开应用",
|
||||
summary = "--start-app\nTODO: 未实现虚拟屏配合",
|
||||
summary = "--start-app",
|
||||
items = appDropdownItems,
|
||||
selectedIndex = appDropdownIndex,
|
||||
overrideEndActionValue = startAppOverrideEndActionValue,
|
||||
onSelectedIndexChange = {
|
||||
when (it) {
|
||||
0 -> {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.scaffolds
|
||||
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedback
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponent
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponentColors
|
||||
import top.yukonga.miuix.kmp.basic.BasicComponentDefaults
|
||||
import top.yukonga.miuix.kmp.basic.DropdownArrowEndAction
|
||||
import top.yukonga.miuix.kmp.basic.ListPopupColumn
|
||||
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
||||
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.SpinnerItemImpl
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.overlay.OverlayListPopup
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
|
||||
@Composable
|
||||
fun SuperSpinner(
|
||||
items: List<SpinnerEntry>,
|
||||
selectedIndex: Int,
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
titleColor: BasicComponentColors = BasicComponentDefaults.titleColor(),
|
||||
summary: String? = null,
|
||||
summaryColor: BasicComponentColors = BasicComponentDefaults.summaryColor(),
|
||||
spinnerColors: SpinnerColors = SpinnerDefaults.spinnerColors(),
|
||||
startAction: @Composable (() -> Unit)? = null,
|
||||
bottomAction: (@Composable () -> Unit)? = null,
|
||||
insideMargin: PaddingValues = BasicComponentDefaults.InsideMargin,
|
||||
maxHeight: Dp? = null,
|
||||
enabled: Boolean = true,
|
||||
showValue: Boolean = true,
|
||||
renderInRootScaffold: Boolean = true,
|
||||
onSelectedIndexChange: ((Int) -> Unit)? = null,
|
||||
overrideEndActionValue: String? = null,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isDropdownExpanded = rememberSaveable { mutableStateOf(false) }
|
||||
val isHoldDown = remember { mutableStateOf(false) }
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val currentHapticFeedback by rememberUpdatedState(hapticFeedback)
|
||||
|
||||
val itemsNotEmpty = items.isNotEmpty()
|
||||
val actualEnabled = enabled && itemsNotEmpty
|
||||
val forceOverrideValue = overrideEndActionValue != null
|
||||
val endActionText = overrideEndActionValue
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: if (showValue && itemsNotEmpty) items[selectedIndex].title.orEmpty() else null
|
||||
|
||||
val actionColor = if (actualEnabled) {
|
||||
MiuixTheme.colorScheme.onSurfaceVariantActions
|
||||
} else {
|
||||
MiuixTheme.colorScheme.disabledOnSecondaryVariant
|
||||
}
|
||||
|
||||
val handleClick = remember(actualEnabled) {
|
||||
{
|
||||
if (actualEnabled) {
|
||||
isDropdownExpanded.value = !isDropdownExpanded.value
|
||||
if (isDropdownExpanded.value) {
|
||||
isHoldDown.value = true
|
||||
currentHapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BasicComponent(
|
||||
modifier = modifier,
|
||||
interactionSource = interactionSource,
|
||||
insideMargin = insideMargin,
|
||||
title = title,
|
||||
titleColor = titleColor,
|
||||
summary = summary,
|
||||
summaryColor = summaryColor,
|
||||
startAction = startAction,
|
||||
endActions = {
|
||||
if (endActionText != null) {
|
||||
Text(
|
||||
text = endActionText,
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.align(Alignment.CenterVertically)
|
||||
.weight(1f, fill = false),
|
||||
fontSize = MiuixTheme.textStyles.body2.fontSize,
|
||||
color = actionColor,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
DropdownArrowEndAction(
|
||||
actionColor = actionColor,
|
||||
)
|
||||
if (itemsNotEmpty) {
|
||||
SuperSpinnerPopup(
|
||||
items = items,
|
||||
selectedIndex = selectedIndex,
|
||||
isDropdownExpanded = isDropdownExpanded.value,
|
||||
onDismiss = { isDropdownExpanded.value = false },
|
||||
onDismissFinished = { isHoldDown.value = false },
|
||||
maxHeight = maxHeight,
|
||||
hapticFeedback = hapticFeedback,
|
||||
spinnerColors = spinnerColors,
|
||||
renderInRootScaffold = renderInRootScaffold,
|
||||
onSelectedIndexChange = onSelectedIndexChange,
|
||||
forceNoSelectedState = forceOverrideValue,
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomAction = bottomAction,
|
||||
onClick = handleClick,
|
||||
holdDownState = isHoldDown.value,
|
||||
enabled = actualEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SuperSpinnerPopup(
|
||||
items: List<SpinnerEntry>,
|
||||
selectedIndex: Int,
|
||||
isDropdownExpanded: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onDismissFinished: () -> Unit,
|
||||
maxHeight: Dp?,
|
||||
hapticFeedback: HapticFeedback,
|
||||
spinnerColors: SpinnerColors,
|
||||
renderInRootScaffold: Boolean,
|
||||
onSelectedIndexChange: ((Int) -> Unit)?,
|
||||
forceNoSelectedState: Boolean,
|
||||
) {
|
||||
val onSelectState = rememberUpdatedState(onSelectedIndexChange)
|
||||
val currentOnDismiss by rememberUpdatedState(onDismiss)
|
||||
val currentHapticFeedback by rememberUpdatedState(hapticFeedback)
|
||||
val onItemSelected: (Int) -> Unit = remember {
|
||||
{ selectedIdx ->
|
||||
currentHapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm)
|
||||
onSelectState.value?.invoke(selectedIdx)
|
||||
currentOnDismiss()
|
||||
}
|
||||
}
|
||||
OverlayListPopup(
|
||||
show = isDropdownExpanded,
|
||||
alignment = PopupPositionProvider.Align.End,
|
||||
onDismissRequest = onDismiss,
|
||||
onDismissFinished = onDismissFinished,
|
||||
maxHeight = maxHeight,
|
||||
renderInRootScaffold = renderInRootScaffold,
|
||||
) {
|
||||
ListPopupColumn {
|
||||
items.forEachIndexed { index, spinnerEntry ->
|
||||
key(index) {
|
||||
SpinnerItemImpl(
|
||||
entry = spinnerEntry,
|
||||
entryCount = items.size,
|
||||
isSelected = !forceNoSelectedState && selectedIndex == index,
|
||||
index = index,
|
||||
spinnerColors = spinnerColors,
|
||||
dialogMode = false,
|
||||
onSelectedIndexChange = onItemSelected,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,6 +216,8 @@ class Scrcpy(
|
||||
|
||||
fun getCurrentSession(): Session.SessionInfo? = currentSessionState.value
|
||||
|
||||
suspend fun startApp(name: String) = session.startApp(name)
|
||||
|
||||
suspend fun injectKeycode(
|
||||
action: Int,
|
||||
keycode: Int,
|
||||
@@ -999,6 +1001,15 @@ class Scrcpy(
|
||||
|
||||
suspend fun clearAudioConsumer() = mutex.withLock { audioConsumer = null }
|
||||
|
||||
suspend fun startApp(name: String) = mutex.withLock {
|
||||
try {
|
||||
requireControlWriter().startApp(name)
|
||||
} catch (e: IllegalStateException) {
|
||||
Log.w(TAG, "startApp(): control channel not available", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun injectKeycode(
|
||||
action: Int,
|
||||
keycode: Int,
|
||||
@@ -1352,6 +1363,18 @@ class Scrcpy(
|
||||
output.flush()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun startApp(name: String) {
|
||||
val normalized = name.trim()
|
||||
val bytes = normalized.toByteArray(Charsets.UTF_8)
|
||||
require(normalized.isNotBlank()) { "start app name is blank" }
|
||||
require(bytes.size <= 255) { "start app name is too long" }
|
||||
output.writeByte(TYPE_START_APP)
|
||||
output.writeByte(bytes.size)
|
||||
output.write(bytes)
|
||||
output.flush()
|
||||
}
|
||||
|
||||
private fun writePosition(x: Int, y: Int, screenWidth: Int, screenHeight: Int) {
|
||||
output.writeInt(x)
|
||||
output.writeInt(y)
|
||||
@@ -1399,6 +1422,7 @@ class Scrcpy(
|
||||
private const val TYPE_INJECT_SCROLL_EVENT = 3
|
||||
private const val TYPE_BACK_OR_SCREEN_ON = 4
|
||||
private const val TYPE_SET_DISPLAY_POWER = 10
|
||||
private const val TYPE_START_APP = 16
|
||||
|
||||
private fun socketNameFor(scid: Int): String {
|
||||
return "scrcpy_%08x".format(scid)
|
||||
|
||||
@@ -399,6 +399,8 @@ internal fun VirtualButtonCard(
|
||||
@Composable
|
||||
internal fun ConfigPanel(
|
||||
busy: Boolean,
|
||||
activeProfileId: String,
|
||||
activeBundle: ScrcpyOptions.Bundle,
|
||||
audioForwardingSupported: Boolean,
|
||||
cameraMirroringSupported: Boolean,
|
||||
adbConnecting: Boolean,
|
||||
@@ -418,25 +420,33 @@ internal fun ConfigPanel(
|
||||
|
||||
val sessionStarted = sessionInfo != null
|
||||
|
||||
val soBundleShared by scrcpyOptions.bundleState.collectAsState()
|
||||
val soBundleSharedLatest by rememberUpdatedState(soBundleShared)
|
||||
var soBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) }
|
||||
val activeProfileIdLatest by rememberUpdatedState(activeProfileId)
|
||||
val activeBundleLatest by rememberUpdatedState(activeBundle)
|
||||
var soBundle by rememberSaveable(activeProfileId, activeBundle) { mutableStateOf(activeBundle) }
|
||||
val soBundleLatest by rememberUpdatedState(soBundle)
|
||||
LaunchedEffect(soBundleShared) {
|
||||
if (soBundle != soBundleShared) {
|
||||
soBundle = soBundleShared
|
||||
LaunchedEffect(activeProfileId, activeBundle) {
|
||||
if (soBundle != activeBundle) {
|
||||
soBundle = activeBundle
|
||||
}
|
||||
}
|
||||
LaunchedEffect(soBundle) {
|
||||
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||
if (soBundle != soBundleSharedLatest) {
|
||||
scrcpyOptions.saveBundle(soBundle)
|
||||
if (soBundle != activeBundleLatest) {
|
||||
if (activeProfileIdLatest == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||
scrcpyOptions.saveBundle(soBundle)
|
||||
} else {
|
||||
Storage.scrcpyProfiles.updateBundle(activeProfileIdLatest, soBundle)
|
||||
}
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
taskScope.launch {
|
||||
scrcpyOptions.saveBundle(soBundleLatest)
|
||||
if (activeProfileIdLatest == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||
scrcpyOptions.saveBundle(soBundleLatest)
|
||||
} else {
|
||||
Storage.scrcpyProfiles.updateBundle(activeProfileIdLatest, soBundleLatest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user