feat: --new-display + --start-app

This commit is contained in:
Miuzarte
2026-04-15 20:55:52 +08:00
parent 4f7ad1fb7d
commit 47d140a5c7
5 changed files with 385 additions and 152 deletions

View File

@@ -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") {
if (sessionInfo == null) {
runBusy("启动 scrcpy") {
startScrcpySession(startAppOverride = task.packageName)
}
else runBusy("启动应用") {
} 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("已启动应用: ${task.packageName}")
logEvent("通过 ADB 启动应用: ${task.packageName}")
}
}
}
},
)

View File

@@ -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 -> {

View File

@@ -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,
)
}
}
}
}
}

View File

@@ -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)

View File

@@ -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) {
if (soBundle != activeBundleLatest) {
if (activeProfileIdLatest == ScrcpyOptions.GLOBAL_PROFILE_ID) {
scrcpyOptions.saveBundle(soBundle)
} else {
Storage.scrcpyProfiles.updateBundle(activeProfileIdLatest, soBundle)
}
}
}
DisposableEffect(Unit) {
onDispose {
taskScope.launch {
if (activeProfileIdLatest == ScrcpyOptions.GLOBAL_PROFILE_ID) {
scrcpyOptions.saveBundle(soBundleLatest)
} else {
Storage.scrcpyProfiles.updateBundle(activeProfileIdLatest, soBundleLatest)
}
}
}
}