From 47d140a5c7d539b4596dc74910a85369345edfd4 Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Wed, 15 Apr 2026 20:55:52 +0800 Subject: [PATCH] feat: `--new-display` + `--start-app` --- .../scrcpyforandroid/pages/DeviceTabScreen.kt | 209 +++++++----------- .../pages/ScrcpyAllOptionsScreen.kt | 94 +++++++- .../scaffolds/SuperSpinner.kt | 182 +++++++++++++++ .../scrcpyforandroid/scrcpy/Scrcpy.kt | 24 ++ .../scrcpyforandroid/widgets/DeviceWidgets.kt | 28 ++- 5 files changed, 385 insertions(+), 152 deletions(-) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSpinner.kt diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt index f9290d5..a69bb61 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt @@ -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() } - - 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() } + + 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}") + } + } } }, ) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt index 16d7f53..363e4ab 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt @@ -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 -> { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSpinner.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSpinner.kt new file mode 100644 index 0000000..819205c --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSpinner.kt @@ -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, + 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, + 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, + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt index df91887..2fa0fbc 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt @@ -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) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index 0b00805..1f5fb42 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -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) + } } } }