feat: multi scrcpy options profiles
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.models
|
||||
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
|
||||
|
||||
// Composable 用, 不可变 List
|
||||
class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut> by devices {
|
||||
@@ -40,6 +41,7 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
|
||||
name: String? = null,
|
||||
startScrcpyOnConnect: Boolean? = null,
|
||||
openFullscreenOnStart: Boolean? = null,
|
||||
scrcpyProfileId: String? = null,
|
||||
newPort: Int? = null,
|
||||
updateNameOnlyWhenEmpty: Boolean = false,
|
||||
): DeviceShortcuts {
|
||||
@@ -51,38 +53,28 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
|
||||
val old = devices[idx]
|
||||
val updateById = id != null
|
||||
|
||||
// 确定最终的属性值
|
||||
val finalName = when {
|
||||
name == null -> old.name
|
||||
updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name
|
||||
else -> name
|
||||
}
|
||||
val finalHost = if (updateById) host ?: old.host else old.host
|
||||
val finalPort = if (updateById) port ?: old.port else newPort ?: old.port
|
||||
val finalStartScrcpyOnConnect = startScrcpyOnConnect ?: old.startScrcpyOnConnect
|
||||
val finalOpenFullscreenOnStart = openFullscreenOnStart ?: old.openFullscreenOnStart
|
||||
val updated = DeviceShortcut(
|
||||
name = when {
|
||||
name == null -> old.name
|
||||
updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name
|
||||
else -> name
|
||||
},
|
||||
host = if (updateById) host ?: old.host else old.host,
|
||||
port = if (updateById) port ?: old.port else newPort ?: old.port,
|
||||
startScrcpyOnConnect = startScrcpyOnConnect ?: old.startScrcpyOnConnect,
|
||||
openFullscreenOnStart = openFullscreenOnStart ?: old.openFullscreenOnStart,
|
||||
scrcpyProfileId = scrcpyProfileId ?: old.scrcpyProfileId,
|
||||
)
|
||||
|
||||
// 若无任何变化,返回原实例
|
||||
if (
|
||||
finalName == old.name
|
||||
&& finalHost == old.host
|
||||
&& finalPort == old.port
|
||||
&& finalStartScrcpyOnConnect == old.startScrcpyOnConnect
|
||||
&& finalOpenFullscreenOnStart == old.openFullscreenOnStart
|
||||
)
|
||||
return this
|
||||
if (updated == old) return this
|
||||
|
||||
val newList = devices.toMutableList().apply {
|
||||
this[idx] = DeviceShortcut(
|
||||
name = finalName,
|
||||
host = finalHost,
|
||||
port = finalPort,
|
||||
startScrcpyOnConnect = finalStartScrcpyOnConnect,
|
||||
openFullscreenOnStart = finalOpenFullscreenOnStart,
|
||||
)
|
||||
}
|
||||
val newList = devices.toMutableList()
|
||||
.apply {
|
||||
this[idx] = updated
|
||||
}
|
||||
return DeviceShortcuts(
|
||||
if ((updateById && (finalHost != old.host || finalPort != old.port))
|
||||
if ((updateById && (updated.host != old.host || updated.port != old.port))
|
||||
|| (newPort != null && newPort != old.port)
|
||||
)
|
||||
newList.distinctBy { it.id }
|
||||
@@ -133,6 +125,7 @@ data class DeviceShortcut(
|
||||
val port: Int = Defaults.ADB_PORT,
|
||||
val startScrcpyOnConnect: Boolean = false,
|
||||
val openFullscreenOnStart: Boolean = false,
|
||||
val scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
|
||||
) {
|
||||
val id: String get() = "$host:$port"
|
||||
|
||||
@@ -144,6 +137,7 @@ data class DeviceShortcut(
|
||||
port.toString(),
|
||||
if (startScrcpyOnConnect) "1" else "0",
|
||||
if (openFullscreenOnStart) "1" else "0",
|
||||
scrcpyProfileId.trim(),
|
||||
).joinToString(
|
||||
separator = separator
|
||||
)
|
||||
@@ -156,19 +150,28 @@ data class DeviceShortcut(
|
||||
): DeviceShortcut? {
|
||||
val parts = s.split(separator)
|
||||
return when (parts.size) {
|
||||
3, 4, 5 -> {
|
||||
3, 4, 5, 6 -> {
|
||||
val name = parts[0].trim()
|
||||
val host = parts[1].trim()
|
||||
val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT
|
||||
val startScrcpyOnConnect = parts.getOrNull(3)?.trim() == "1"
|
||||
val openFullscreenOnStart =
|
||||
startScrcpyOnConnect && parts.getOrNull(4)?.trim() == "1"
|
||||
|
||||
val startScrcpyOnConnect = parts.getOrNull(3)
|
||||
?.trim() == "1"
|
||||
val openFullscreenOnStart = startScrcpyOnConnect
|
||||
&& parts.getOrNull(4)
|
||||
?.trim() == "1"
|
||||
val scrcpyProfileId = parts.getOrNull(5)
|
||||
?.trim()
|
||||
.takeUnless { it.isNullOrBlank() }
|
||||
?: ScrcpyOptions.GLOBAL_PROFILE_ID
|
||||
|
||||
if (host.isNotBlank()) DeviceShortcut(
|
||||
name = name,
|
||||
host = host,
|
||||
port = port,
|
||||
startScrcpyOnConnect = startScrcpyOnConnect,
|
||||
openFullscreenOnStart = openFullscreenOnStart,
|
||||
scrcpyProfileId = scrcpyProfileId,
|
||||
)
|
||||
else null
|
||||
}
|
||||
|
||||
@@ -37,15 +37,18 @@ import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||
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.EventLogger
|
||||
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
|
||||
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
||||
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyProfiles
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTileList
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard
|
||||
@@ -213,6 +216,7 @@ fun DeviceTabPage(
|
||||
|
||||
// read only
|
||||
val soBundleShared by scrcpyOptions.bundleState.collectAsState()
|
||||
val scrcpyProfilesState by scrcpyProfiles.state.collectAsState()
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
@@ -234,6 +238,9 @@ fun DeviceTabPage(
|
||||
var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var adbConnecting by rememberSaveable { mutableStateOf(false) }
|
||||
var connectedScrcpyProfileId by rememberSaveable {
|
||||
mutableStateOf(ScrcpyOptions.GLOBAL_PROFILE_ID)
|
||||
}
|
||||
|
||||
var audioForwardingSupported by rememberSaveable { mutableStateOf(true) }
|
||||
var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) }
|
||||
@@ -250,6 +257,14 @@ fun DeviceTabPage(
|
||||
ConnectionTarget(currentTargetHost, currentTargetPort)
|
||||
else null
|
||||
|
||||
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) {
|
||||
@@ -307,6 +322,8 @@ fun DeviceTabPage(
|
||||
adbConnected = false
|
||||
currentTargetHost = ""
|
||||
currentTargetPort = Defaults.ADB_PORT
|
||||
connectedScrcpyProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID
|
||||
AppRuntime.currentConnectionTarget = null
|
||||
audioForwardingSupported = true
|
||||
cameraMirroringSupported = true
|
||||
statusLine = "未连接"
|
||||
@@ -334,11 +351,19 @@ fun DeviceTabPage(
|
||||
}
|
||||
|
||||
fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) {
|
||||
val currentBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
|
||||
val audioSupported = sdkInt !in 0..<30
|
||||
audioForwardingSupported = audioSupported
|
||||
if (!audioSupported && soBundleShared.audio) {
|
||||
if (!audioSupported && currentBundle.audio) {
|
||||
scope.launch {
|
||||
scrcpyOptions.updateBundle { it.copy(audio = false) }
|
||||
if (connectedScrcpyProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||
scrcpyOptions.updateBundle { it.copy(audio = false) }
|
||||
} else {
|
||||
scrcpyProfiles.updateBundle(
|
||||
connectedScrcpyProfileId,
|
||||
currentBundle.copy(audio = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
logEvent(
|
||||
"设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭",
|
||||
@@ -347,9 +372,16 @@ fun DeviceTabPage(
|
||||
}
|
||||
val cameraSupported = sdkInt !in 0..<31
|
||||
cameraMirroringSupported = cameraSupported
|
||||
if (!cameraSupported && soBundleShared.videoSource == "camera") {
|
||||
if (!cameraSupported && currentBundle.videoSource == "camera") {
|
||||
scope.launch {
|
||||
scrcpyOptions.updateBundle { it.copy(videoSource = "display") }
|
||||
if (connectedScrcpyProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||
scrcpyOptions.updateBundle { it.copy(videoSource = "display") }
|
||||
} else {
|
||||
scrcpyProfiles.updateBundle(
|
||||
connectedScrcpyProfileId,
|
||||
currentBundle.copy(videoSource = "display"),
|
||||
)
|
||||
}
|
||||
}
|
||||
logEvent(
|
||||
"设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring,已切换为 display",
|
||||
@@ -576,7 +608,8 @@ fun DeviceTabPage(
|
||||
}
|
||||
|
||||
suspend fun startScrcpySession(openFullscreen: Boolean = false) {
|
||||
val options = scrcpyOptions.toClientOptions(soBundleShared).fix()
|
||||
val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
|
||||
val options = scrcpyOptions.toClientOptions(activeBundle).fix()
|
||||
val session = scrcpy.start(options)
|
||||
pendingScrollToPreview = true
|
||||
val startAppRequest = runCatching {
|
||||
@@ -630,14 +663,14 @@ fun DeviceTabPage(
|
||||
@SuppressLint("DefaultLocale")
|
||||
val videoDetail =
|
||||
if (!options.video) "off"
|
||||
else if (soBundleShared.videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default"
|
||||
else if (activeBundle.videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default"
|
||||
else "${session.codec?.string ?: "null"} ${session.width}x${session.height} " +
|
||||
"@${String.format("%.1f", soBundleShared.videoBitRate / 1_000_000f)}Mbps"
|
||||
"@${String.format("%.1f", activeBundle.videoBitRate / 1_000_000f)}Mbps"
|
||||
|
||||
val audioDetail =
|
||||
if (!soBundleShared.audio) "off"
|
||||
else if (soBundleShared.audioBitRate <= 0) "${options.audioCodec} default source=${options.audioSource}"
|
||||
else "${options.audioCodec} ${soBundleShared.audioBitRate / 1_000f}Kbps source=${options.audioSource}${if (!options.audioPlayback) "(no-playback)" else ""}"
|
||||
if (!activeBundle.audio) "off"
|
||||
else if (activeBundle.audioBitRate <= 0) "${options.audioCodec} default source=${options.audioSource}"
|
||||
else "${options.audioCodec} ${activeBundle.audioBitRate / 1_000f}Kbps source=${options.audioSource}${if (!options.audioPlayback) "(no-playback)" else ""}"
|
||||
|
||||
logEvent(
|
||||
"scrcpy 已启动: device=${session.deviceName}" +
|
||||
@@ -649,7 +682,8 @@ fun DeviceTabPage(
|
||||
}
|
||||
|
||||
suspend fun stopScrcpySession() {
|
||||
val options = scrcpyOptions.toClientOptions(soBundleShared).fix()
|
||||
val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
|
||||
val options = scrcpyOptions.toClientOptions(activeBundle).fix()
|
||||
scrcpy.stop()
|
||||
if (options.killAdbOnClose) {
|
||||
// TODO
|
||||
@@ -667,9 +701,12 @@ fun DeviceTabPage(
|
||||
host: String, port: Int,
|
||||
autoStartScrcpy: Boolean = false,
|
||||
autoEnterFullScreen: Boolean = false,
|
||||
scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
|
||||
) {
|
||||
currentTargetHost = host
|
||||
currentTargetPort = port
|
||||
connectedScrcpyProfileId = scrcpyProfileId
|
||||
AppRuntime.currentConnectionTarget = ConnectionTarget(host, port)
|
||||
|
||||
val info = fetchConnectedDeviceInfo(NativeAdbService, host, port)
|
||||
val fullLabel = if (info.serial.isNotBlank()) {
|
||||
@@ -739,7 +776,11 @@ fun DeviceTabPage(
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = target.host, port = target.port,
|
||||
)
|
||||
handleAdbConnected(target.host, target.port)
|
||||
handleAdbConnected(
|
||||
target.host,
|
||||
target.port,
|
||||
scrcpyProfileId = target.scrcpyProfileId,
|
||||
)
|
||||
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
@@ -796,7 +837,11 @@ fun DeviceTabPage(
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = discoveredHost, port = discoveredPort,
|
||||
)
|
||||
handleAdbConnected(discoveredHost, discoveredPort)
|
||||
handleAdbConnected(
|
||||
discoveredHost,
|
||||
discoveredPort,
|
||||
scrcpyProfileId = knownDevice.scrcpyProfileId,
|
||||
)
|
||||
logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort")
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
@@ -888,6 +933,7 @@ fun DeviceTabPage(
|
||||
autoStartScrcpy = device.startScrcpyOnConnect,
|
||||
autoEnterFullScreen = device.startScrcpyOnConnect
|
||||
&& device.openFullscreenOnStart,
|
||||
scrcpyProfileId = device.scrcpyProfileId,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
statusLine = "ADB 连接失败"
|
||||
@@ -919,6 +965,7 @@ fun DeviceTabPage(
|
||||
port = updated.port,
|
||||
startScrcpyOnConnect = updated.startScrcpyOnConnect,
|
||||
openFullscreenOnStart = updated.openFullscreenOnStart,
|
||||
scrcpyProfileId = updated.scrcpyProfileId,
|
||||
)
|
||||
},
|
||||
onEditorDelete = { device ->
|
||||
@@ -966,7 +1013,11 @@ fun DeviceTabPage(
|
||||
savedShortcuts = savedShortcuts.update(
|
||||
host = target.host, port = target.port,
|
||||
)
|
||||
handleAdbConnected(target.host, target.port)
|
||||
handleAdbConnected(
|
||||
target.host,
|
||||
target.port,
|
||||
scrcpyProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
statusLine = "ADB 连接失败"
|
||||
logEvent("ADB 连接失败: $e", Log.ERROR)
|
||||
|
||||
@@ -51,7 +51,6 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.ScrcpyVideoSurface
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.VideoOutputTarget
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@@ -10,13 +10,19 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -33,8 +39,10 @@ import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
|
||||
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
|
||||
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
|
||||
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop
|
||||
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay
|
||||
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
|
||||
@@ -48,9 +56,14 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
|
||||
import io.github.miuzarte.scrcpyforandroid.services.AppRuntime
|
||||
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyProfiles
|
||||
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -58,18 +71,27 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import top.yukonga.miuix.kmp.basic.Card
|
||||
import top.yukonga.miuix.kmp.basic.DropdownImpl
|
||||
import top.yukonga.miuix.kmp.basic.Icon
|
||||
import top.yukonga.miuix.kmp.basic.IconButton
|
||||
import top.yukonga.miuix.kmp.basic.ListPopupColumn
|
||||
import top.yukonga.miuix.kmp.basic.ListPopupDefaults
|
||||
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
|
||||
import top.yukonga.miuix.kmp.basic.Scaffold
|
||||
import top.yukonga.miuix.kmp.basic.ScrollBehavior
|
||||
import top.yukonga.miuix.kmp.basic.SnackbarHost
|
||||
import top.yukonga.miuix.kmp.basic.SpinnerEntry
|
||||
import top.yukonga.miuix.kmp.basic.TabRowWithContour
|
||||
import top.yukonga.miuix.kmp.basic.Text
|
||||
import top.yukonga.miuix.kmp.basic.TextButton
|
||||
import top.yukonga.miuix.kmp.basic.TextField
|
||||
import top.yukonga.miuix.kmp.basic.TopAppBar
|
||||
import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.extended.Store
|
||||
import top.yukonga.miuix.kmp.icon.extended.Tune
|
||||
import top.yukonga.miuix.kmp.overlay.OverlayBottomSheet
|
||||
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
|
||||
@@ -81,8 +103,85 @@ internal fun ScrcpyAllOptionsScreen(
|
||||
scrollBehavior: ScrollBehavior,
|
||||
scrcpy: Scrcpy,
|
||||
) {
|
||||
val snackbar = LocalSnackbarController.current
|
||||
val navigator = LocalRootNavigator.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var showProfileMenu by rememberSaveable { mutableStateOf(false) }
|
||||
var showManageProfilesSheet by rememberSaveable { mutableStateOf(false) }
|
||||
val qdBundleShared by quickDevices.bundleState.collectAsState()
|
||||
val soBundleShared by scrcpyOptions.bundleState.collectAsState()
|
||||
val scrcpyProfilesState by scrcpyProfiles.state.collectAsState()
|
||||
val initialSelectedProfileId = remember(qdBundleShared.quickDevicesList) {
|
||||
val currentTarget = AppRuntime.currentConnectionTarget
|
||||
if (currentTarget == null) {
|
||||
ScrcpyOptions.GLOBAL_PROFILE_ID
|
||||
} else {
|
||||
DeviceShortcuts.unmarshalFrom(qdBundleShared.quickDevicesList)
|
||||
.get(currentTarget.host, currentTarget.port)
|
||||
?.scrcpyProfileId
|
||||
?: ScrcpyOptions.GLOBAL_PROFILE_ID
|
||||
}
|
||||
}
|
||||
val selectedProfileIdState = rememberSaveable(initialSelectedProfileId) {
|
||||
mutableStateOf(initialSelectedProfileId)
|
||||
}
|
||||
var selectedProfileId by selectedProfileIdState
|
||||
val soBundleState = rememberSaveable(selectedProfileId, soBundleShared, scrcpyProfilesState) {
|
||||
mutableStateOf(
|
||||
if (selectedProfileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||
soBundleShared
|
||||
} else {
|
||||
scrcpyProfilesState.profiles
|
||||
.firstOrNull { it.id == selectedProfileId }
|
||||
?.bundle ?: soBundleShared
|
||||
}
|
||||
)
|
||||
}
|
||||
val lastValidSoBundleState = rememberSaveable(selectedProfileId) {
|
||||
mutableStateOf(soBundleState.value)
|
||||
}
|
||||
val currentConnectedDeviceName = remember(qdBundleShared.quickDevicesList) {
|
||||
val currentTarget = AppRuntime.currentConnectionTarget
|
||||
if (currentTarget == null) {
|
||||
null
|
||||
} else {
|
||||
DeviceShortcuts.unmarshalFrom(qdBundleShared.quickDevicesList)
|
||||
.get(currentTarget.host, currentTarget.port)
|
||||
?.name
|
||||
?.ifBlank { currentTarget.host }
|
||||
?: currentTarget.host
|
||||
}
|
||||
}
|
||||
var activeProfileDialog by rememberSaveable { mutableStateOf<ProfileDialogMode?>(null) }
|
||||
var profileDialogTargetId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var profileDialogInput by rememberSaveable { mutableStateOf("新配置") }
|
||||
var deletingProfileId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
||||
suspend fun saveBundleForProfile(profileId: String, bundle: ScrcpyOptions.Bundle) {
|
||||
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||
scrcpyOptions.saveBundle(bundle)
|
||||
} else {
|
||||
scrcpyProfiles.updateBundle(profileId, bundle)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun rebindDeletedProfileReferences(profileId: String) {
|
||||
val shortcuts = DeviceShortcuts.unmarshalFrom(qdBundleShared.quickDevicesList)
|
||||
val updated = shortcuts.copy(
|
||||
devices = shortcuts.map { device ->
|
||||
if (device.scrcpyProfileId == profileId) {
|
||||
device.copy(scrcpyProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID)
|
||||
} else {
|
||||
device
|
||||
}
|
||||
}
|
||||
)
|
||||
if (updated != shortcuts) {
|
||||
quickDevices.updateBundle { bundle ->
|
||||
bundle.copy(quickDevicesList = updated.marshalToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -95,15 +194,129 @@ internal fun ScrcpyAllOptionsScreen(
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { showProfileMenu = true },
|
||||
holdDownState = showProfileMenu,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.MoreVert,
|
||||
contentDescription = "配置管理",
|
||||
)
|
||||
}
|
||||
ProfileMenuPopup(
|
||||
show = showProfileMenu,
|
||||
onDismissRequest = { showProfileMenu = false },
|
||||
onManageProfiles = {
|
||||
showManageProfilesSheet = true
|
||||
showProfileMenu = false
|
||||
},
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbar.hostState) },
|
||||
snackbarHost = {
|
||||
val snackbar = LocalSnackbarController.current
|
||||
SnackbarHost(snackbar.hostState)
|
||||
},
|
||||
) { contentPadding ->
|
||||
ScrcpyAllOptionsPage(
|
||||
contentPadding = contentPadding,
|
||||
scrollBehavior = scrollBehavior,
|
||||
scrcpy = scrcpy,
|
||||
qdBundleShared = qdBundleShared,
|
||||
soBundleShared = soBundleShared,
|
||||
scrcpyProfilesState = scrcpyProfilesState,
|
||||
selectedProfileIdState = selectedProfileIdState,
|
||||
soBundleState = soBundleState,
|
||||
lastValidSoBundleState = lastValidSoBundleState,
|
||||
currentConnectedDeviceName = currentConnectedDeviceName,
|
||||
onSaveBundleForProfile = ::saveBundleForProfile,
|
||||
)
|
||||
|
||||
|
||||
ProfileNameDialog(
|
||||
mode = activeProfileDialog,
|
||||
initialInput = profileDialogInput,
|
||||
onDismissRequest = {
|
||||
activeProfileDialog = null
|
||||
profileDialogTargetId = null
|
||||
},
|
||||
onConfirm = { input ->
|
||||
scope.launch {
|
||||
when (activeProfileDialog) {
|
||||
ProfileDialogMode.Create -> {
|
||||
saveBundleForProfile(selectedProfileId, soBundleState.value)
|
||||
val created = scrcpyProfiles.createProfile(
|
||||
requestedName = input,
|
||||
bundle = soBundleState.value,
|
||||
)
|
||||
selectedProfileId = created.id
|
||||
}
|
||||
|
||||
ProfileDialogMode.Rename -> {
|
||||
val profileId = profileDialogTargetId ?: return@launch
|
||||
scrcpyProfiles.renameProfile(
|
||||
id = profileId,
|
||||
requestedName = input,
|
||||
)
|
||||
}
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
profileDialogTargetId = null
|
||||
activeProfileDialog = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
ManageProfilesSheet(
|
||||
show = showManageProfilesSheet,
|
||||
profiles = scrcpyProfilesState.profiles,
|
||||
selectedProfileId = selectedProfileId,
|
||||
onDismissRequest = { showManageProfilesSheet = false },
|
||||
onCreateProfile = {
|
||||
profileDialogTargetId = null
|
||||
profileDialogInput = ""
|
||||
activeProfileDialog = ProfileDialogMode.Create
|
||||
},
|
||||
onRenameProfile = { profileId ->
|
||||
profileDialogTargetId = profileId
|
||||
profileDialogInput = scrcpyProfilesState.profiles
|
||||
.firstOrNull { it.id == profileId }
|
||||
?.name.orEmpty()
|
||||
activeProfileDialog = ProfileDialogMode.Rename
|
||||
},
|
||||
onDeleteProfile = { profileId ->
|
||||
deletingProfileId = profileId
|
||||
},
|
||||
onMoveProfile = { fromIndex, toIndex ->
|
||||
scope.launch {
|
||||
scrcpyProfiles.moveProfile(fromIndex, toIndex)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
DeleteProfileDialog(
|
||||
show = deletingProfileId != null,
|
||||
profileName = scrcpyProfilesState.profiles
|
||||
.firstOrNull { it.id == deletingProfileId }
|
||||
?.name.orEmpty(),
|
||||
onDismissRequest = { deletingProfileId = null },
|
||||
onConfirm = {
|
||||
scope.launch {
|
||||
val profileId = deletingProfileId ?: return@launch
|
||||
val deleted = scrcpyProfiles.deleteProfile(profileId)
|
||||
if (deleted) {
|
||||
rebindDeletedProfileReferences(profileId)
|
||||
if (selectedProfileId == profileId) {
|
||||
selectedProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID
|
||||
}
|
||||
}
|
||||
deletingProfileId = null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -113,6 +326,14 @@ internal fun ScrcpyAllOptionsPage(
|
||||
contentPadding: PaddingValues,
|
||||
scrollBehavior: ScrollBehavior,
|
||||
scrcpy: Scrcpy,
|
||||
qdBundleShared: io.github.miuzarte.scrcpyforandroid.storage.QuickDevices.Bundle,
|
||||
soBundleShared: ScrcpyOptions.Bundle,
|
||||
scrcpyProfilesState: io.github.miuzarte.scrcpyforandroid.storage.ScrcpyProfiles.State,
|
||||
selectedProfileIdState: MutableState<String>,
|
||||
soBundleState: MutableState<ScrcpyOptions.Bundle>,
|
||||
lastValidSoBundleState: MutableState<ScrcpyOptions.Bundle>,
|
||||
currentConnectedDeviceName: String?,
|
||||
onSaveBundleForProfile: suspend (String, ScrcpyOptions.Bundle) -> Unit,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -122,28 +343,60 @@ internal fun ScrcpyAllOptionsPage(
|
||||
var refreshBusy by rememberSaveable { mutableStateOf(false) }
|
||||
var listRefreshVersion by rememberSaveable { mutableIntStateOf(0) }
|
||||
|
||||
val soBundleShared by scrcpyOptions.bundleState.collectAsState()
|
||||
val soBundleSharedLatest by rememberUpdatedState(soBundleShared)
|
||||
var soBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) }
|
||||
// 验证配置项合法性用于回滚更改
|
||||
var lastValidSoBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) }
|
||||
var selectedProfileId by selectedProfileIdState
|
||||
val selectedProfileIdLatest by rememberUpdatedState(selectedProfileId)
|
||||
var soBundle by soBundleState
|
||||
var lastValidSoBundle by lastValidSoBundleState
|
||||
val soBundleLatest by rememberUpdatedState(soBundle)
|
||||
LaunchedEffect(soBundleShared) {
|
||||
if (soBundle != soBundleShared) {
|
||||
soBundle = soBundleShared
|
||||
val profileTabs = remember(scrcpyProfilesState.profiles) {
|
||||
scrcpyProfilesState.profiles.map { it.name }
|
||||
}
|
||||
val profileIds = remember(scrcpyProfilesState.profiles) {
|
||||
scrcpyProfilesState.profiles.map { it.id }
|
||||
}
|
||||
val selectedProfileIndex = remember(selectedProfileId, profileIds) {
|
||||
profileIds.indexOf(selectedProfileId).coerceAtLeast(0)
|
||||
}
|
||||
fun resolveProfileBundle(profileId: String): ScrcpyOptions.Bundle {
|
||||
if (profileId == ScrcpyOptions.GLOBAL_PROFILE_ID) return soBundleShared
|
||||
return scrcpyProfilesState.profiles.firstOrNull { it.id == profileId }?.bundle
|
||||
?: soBundleShared
|
||||
}
|
||||
|
||||
suspend fun bindCurrentConnectedDevice(profileId: String) {
|
||||
val target = AppRuntime.currentConnectionTarget ?: return
|
||||
val shortcuts = DeviceShortcuts.unmarshalFrom(qdBundleShared.quickDevicesList)
|
||||
val updated = shortcuts.update(
|
||||
host = target.host,
|
||||
port = target.port,
|
||||
scrcpyProfileId = profileId,
|
||||
)
|
||||
if (updated != shortcuts) {
|
||||
quickDevices.updateBundle { bundle ->
|
||||
bundle.copy(quickDevicesList = updated.marshalToString())
|
||||
}
|
||||
}
|
||||
lastValidSoBundle = soBundleShared
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedProfileId, soBundleShared, scrcpyProfilesState) {
|
||||
val bundle = resolveProfileBundle(selectedProfileId)
|
||||
if (soBundle != bundle) {
|
||||
soBundle = bundle
|
||||
}
|
||||
lastValidSoBundle = bundle
|
||||
}
|
||||
LaunchedEffect(soBundle) {
|
||||
delay(Settings.BUNDLE_SAVE_DELAY)
|
||||
if (soBundle != soBundleSharedLatest) {
|
||||
scrcpyOptions.saveBundle(soBundle)
|
||||
val currentProfileId = selectedProfileIdLatest
|
||||
val savedBundle = resolveProfileBundle(currentProfileId)
|
||||
if (soBundle != savedBundle) {
|
||||
onSaveBundleForProfile(currentProfileId, soBundle)
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
taskScope.launch {
|
||||
scrcpyOptions.saveBundle(soBundleLatest)
|
||||
onSaveBundleForProfile(selectedProfileIdLatest, soBundleLatest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -462,6 +715,32 @@ internal fun ScrcpyAllOptionsPage(
|
||||
contentPadding = contentPadding,
|
||||
scrollBehavior = scrollBehavior,
|
||||
) {
|
||||
item {
|
||||
TabRowWithContour(
|
||||
tabs = profileTabs,
|
||||
selectedTabIndex = selectedProfileIndex,
|
||||
onTabSelected = { index ->
|
||||
val nextProfileId = profileIds.getOrNull(index) ?: return@TabRowWithContour
|
||||
if (nextProfileId == selectedProfileId) return@TabRowWithContour
|
||||
scope.launch {
|
||||
onSaveBundleForProfile(selectedProfileId, soBundle)
|
||||
bindCurrentConnectedDevice(nextProfileId)
|
||||
selectedProfileId = nextProfileId
|
||||
val profileName = profileTabs.getOrElse(index) { "全局" }
|
||||
currentConnectedDeviceName?.let { deviceName ->
|
||||
snackbar.show("$deviceName 已切换到配置 $profileName")
|
||||
}
|
||||
}
|
||||
},
|
||||
// TODO
|
||||
// listState = contourTabListState,
|
||||
modifier = Modifier.padding(bottom = UiSpacing.ContentVertical),
|
||||
minWidth = 96.dp,
|
||||
maxWidth = 128.dp,
|
||||
height = 64.dp,
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Card {
|
||||
TextField(
|
||||
@@ -1585,3 +1864,217 @@ internal fun ScrcpyAllOptionsPage(
|
||||
item { Spacer(Modifier.height(UiSpacing.PageBottom)) }
|
||||
}
|
||||
}
|
||||
|
||||
private enum class ProfileDialogMode {
|
||||
Create,
|
||||
Rename,
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileMenuPopup(
|
||||
show: Boolean,
|
||||
onDismissRequest: () -> Unit,
|
||||
onManageProfiles: () -> Unit,
|
||||
) {
|
||||
OverlayListPopup(
|
||||
show = show,
|
||||
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
|
||||
alignment = PopupPositionProvider.Align.TopEnd,
|
||||
onDismissRequest = onDismissRequest,
|
||||
enableWindowDim = false,
|
||||
) {
|
||||
ListPopupColumn {
|
||||
ProfileMenuPopupItem(
|
||||
text = "管理配置",
|
||||
optionSize = 1,
|
||||
index = 0,
|
||||
onSelectedIndexChange = { onManageProfiles() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileMenuPopupItem(
|
||||
text: String,
|
||||
optionSize: Int,
|
||||
index: Int,
|
||||
enabled: Boolean = true,
|
||||
onSelectedIndexChange: (Int) -> Unit,
|
||||
) {
|
||||
if (enabled) {
|
||||
DropdownImpl(
|
||||
text = text,
|
||||
optionSize = optionSize,
|
||||
isSelected = false,
|
||||
index = index,
|
||||
onSelectedIndexChange = onSelectedIndexChange,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = UiSpacing.PopupHorizontal)
|
||||
.padding(
|
||||
top = if (index == 0) UiSpacing.PopupHorizontal else UiSpacing.PageItem,
|
||||
bottom = if (index == optionSize - 1) UiSpacing.PopupHorizontal else UiSpacing.PageItem,
|
||||
),
|
||||
color = top.yukonga.miuix.kmp.theme.MiuixTheme.colorScheme.disabledOnSecondaryVariant,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileNameDialog(
|
||||
mode: ProfileDialogMode?,
|
||||
initialInput: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirm: (String) -> Unit,
|
||||
) {
|
||||
if (mode == null) return
|
||||
val focusManager = LocalFocusManager.current
|
||||
var input by rememberSaveable(mode, initialInput) { mutableStateOf(initialInput) }
|
||||
OverlayDialog(
|
||||
show = true,
|
||||
title = when (mode) {
|
||||
ProfileDialogMode.Create -> "新建配置"
|
||||
ProfileDialogMode.Rename -> "重命名配置"
|
||||
},
|
||||
summary = "名称重复时会自动追加序号",
|
||||
onDismissRequest = onDismissRequest,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
|
||||
) {
|
||||
TextField(
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
label = "配置名称",
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { focusManager.clearFocus() },
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal),
|
||||
) {
|
||||
top.yukonga.miuix.kmp.basic.TextButton(
|
||||
text = "取消",
|
||||
onClick = onDismissRequest,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
top.yukonga.miuix.kmp.basic.TextButton(
|
||||
text = "确定",
|
||||
onClick = { onConfirm(input) },
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = top.yukonga.miuix.kmp.basic.ButtonDefaults.textButtonColorsPrimary(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManageProfilesSheet(
|
||||
show: Boolean,
|
||||
profiles: List<io.github.miuzarte.scrcpyforandroid.storage.ScrcpyProfiles.Profile>,
|
||||
selectedProfileId: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onCreateProfile: () -> Unit,
|
||||
onRenameProfile: (String) -> Unit,
|
||||
onDeleteProfile: (String) -> Unit,
|
||||
onMoveProfile: (fromIndex: Int, toIndex: Int) -> Unit,
|
||||
) {
|
||||
OverlayBottomSheet(
|
||||
show = show,
|
||||
title = "管理配置",
|
||||
onDismissRequest = onDismissRequest,
|
||||
endAction = {
|
||||
IconButton(
|
||||
onClick = onCreateProfile,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Add,
|
||||
contentDescription = "新建配置",
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
ReorderableList(
|
||||
itemsProvider = {
|
||||
profiles.map { profile ->
|
||||
ReorderableList.Item(
|
||||
id = profile.id,
|
||||
title = profile.name,
|
||||
subtitle = when (profile.id) {
|
||||
selectedProfileId -> "当前配置"
|
||||
else -> ""
|
||||
},
|
||||
onClick =
|
||||
if (profile.id != ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||
{ onRenameProfile(profile.id) }
|
||||
} else null,
|
||||
dragEnabled = profile.id != ScrcpyOptions.GLOBAL_PROFILE_ID,
|
||||
endActions = buildList {
|
||||
if (profile.id != ScrcpyOptions.GLOBAL_PROFILE_ID) {
|
||||
add(
|
||||
ReorderableList.EndAction.Icon(
|
||||
icon = Icons.Rounded.Edit,
|
||||
contentDescription = "重命名配置",
|
||||
onClick = { onRenameProfile(profile.id) },
|
||||
)
|
||||
)
|
||||
add(
|
||||
ReorderableList.EndAction.Icon(
|
||||
icon = Icons.Rounded.DeleteOutline,
|
||||
contentDescription = "删除配置",
|
||||
onClick = { onDeleteProfile(profile.id) },
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
onSettle = onMoveProfile,
|
||||
).invoke()
|
||||
Spacer(Modifier.height(UiSpacing.SheetBottom))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteProfileDialog(
|
||||
show: Boolean,
|
||||
profileName: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
if (!show) return
|
||||
OverlayDialog(
|
||||
show = true,
|
||||
title = "删除配置",
|
||||
summary = "确认删除 \"$profileName\"?",
|
||||
onDismissRequest = onDismissRequest,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal),
|
||||
) {
|
||||
TextButton(
|
||||
text = "取消",
|
||||
onClick = onDismissRequest,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(
|
||||
text = "删除",
|
||||
onClick = onConfirm,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors = top.yukonga.miuix.kmp.basic.ButtonDefaults.textButtonColorsPrimary(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,8 +127,27 @@ internal fun VirtualButtonOrderPage(
|
||||
icon = action.icon,
|
||||
title = if (action.keycode == null) action.title else "${action.title} (${action.keycode})",
|
||||
subtitle = if (item.showOutside) "显示在外部" else "显示在更多菜单内",
|
||||
checked = item.showOutside,
|
||||
checkboxEnabled = action != VirtualButtonAction.MORE,
|
||||
endActions = listOf(
|
||||
ReorderableList.EndAction.Checkbox(
|
||||
checked = item.showOutside,
|
||||
enabled = action != VirtualButtonAction.MORE,
|
||||
onClick = {
|
||||
val checked = !item.showOutside
|
||||
buttonItems = buttonItems.map { current ->
|
||||
if (current.action.id == action.id) {
|
||||
current.copy(showOutside = checked)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
asBundle = asBundle.copy(
|
||||
virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(
|
||||
buttonItems
|
||||
)
|
||||
)
|
||||
},
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
@@ -141,19 +160,6 @@ internal fun VirtualButtonOrderPage(
|
||||
virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems)
|
||||
)
|
||||
},
|
||||
showCheckbox = true,
|
||||
onCheckboxChange = { id, checked ->
|
||||
buttonItems = buttonItems.map { item ->
|
||||
if (item.action.id == id) {
|
||||
item.copy(showOutside = checked)
|
||||
} else {
|
||||
item
|
||||
}
|
||||
}
|
||||
asBundle = asBundle.copy(
|
||||
virtualButtonsLayout = VirtualButtonActions.encodeStoredLayout(buttonItems)
|
||||
)
|
||||
},
|
||||
)()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package io.github.miuzarte.scrcpyforandroid.services
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
|
||||
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbMdnsDiscoverer
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
|
||||
@@ -17,4 +18,5 @@ object AppRuntime {
|
||||
}
|
||||
|
||||
var scrcpy: Scrcpy? = null
|
||||
var currentConnectionTarget: ConnectionTarget? = null
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
|
||||
)
|
||||
val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair(
|
||||
intPreferencesKey("device_preview_card_height_dp"),
|
||||
1080/3
|
||||
1080 / 3
|
||||
)
|
||||
val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair(
|
||||
booleanPreferencesKey("preview_virtual_button_show_text"),
|
||||
|
||||
@@ -25,6 +25,9 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
||||
companion object {
|
||||
const val GLOBAL_PROFILE_ID = "global"
|
||||
const val GLOBAL_PROFILE_NAME = "全局"
|
||||
|
||||
val CROP = Pair(
|
||||
stringPreferencesKey("crop"),
|
||||
"",
|
||||
@@ -249,6 +252,65 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
||||
booleanPreferencesKey("vd_system_decorations"),
|
||||
true,
|
||||
)
|
||||
|
||||
fun defaultBundle() = Bundle(
|
||||
crop = CROP.defaultValue,
|
||||
recordFilename = RECORD_FILENAME.defaultValue,
|
||||
videoCodecOptions = VIDEO_CODEC_OPTIONS.defaultValue,
|
||||
audioCodecOptions = AUDIO_CODEC_OPTIONS.defaultValue,
|
||||
videoEncoder = VIDEO_ENCODER.defaultValue,
|
||||
audioEncoder = AUDIO_ENCODER.defaultValue,
|
||||
cameraId = CAMERA_ID.defaultValue,
|
||||
cameraSize = CAMERA_SIZE.defaultValue,
|
||||
cameraSizeCustom = CAMERA_SIZE_CUSTOM.defaultValue,
|
||||
cameraSizeUseCustom = CAMERA_SIZE_USE_CUSTOM.defaultValue,
|
||||
cameraAr = CAMERA_AR.defaultValue,
|
||||
cameraFps = CAMERA_FPS.defaultValue,
|
||||
logLevel = LOG_LEVEL.defaultValue,
|
||||
videoCodec = VIDEO_CODEC.defaultValue,
|
||||
audioCodec = AUDIO_CODEC.defaultValue,
|
||||
videoSource = VIDEO_SOURCE.defaultValue,
|
||||
audioSource = AUDIO_SOURCE.defaultValue,
|
||||
recordFormat = RECORD_FORMAT.defaultValue,
|
||||
cameraFacing = CAMERA_FACING.defaultValue,
|
||||
maxSize = MAX_SIZE.defaultValue,
|
||||
videoBitRate = VIDEO_BIT_RATE.defaultValue,
|
||||
audioBitRate = AUDIO_BIT_RATE.defaultValue,
|
||||
maxFps = MAX_FPS.defaultValue,
|
||||
angle = ANGLE.defaultValue,
|
||||
captureOrientation = CAPTURE_ORIENTATION.defaultValue,
|
||||
captureOrientationLock = CAPTURE_ORIENTATION_LOCK.defaultValue,
|
||||
displayOrientation = DISPLAY_ORIENTATION.defaultValue,
|
||||
recordOrientation = RECORD_ORIENTATION.defaultValue,
|
||||
displayImePolicy = DISPLAY_IME_POLICY.defaultValue,
|
||||
displayId = DISPLAY_ID.defaultValue,
|
||||
screenOffTimeout = SCREEN_OFF_TIMEOUT.defaultValue,
|
||||
showTouches = SHOW_TOUCHES.defaultValue,
|
||||
fullscreen = FULLSCREEN.defaultValue,
|
||||
control = CONTROL.defaultValue,
|
||||
videoPlayback = VIDEO_PLAYBACK.defaultValue,
|
||||
audioPlayback = AUDIO_PLAYBACK.defaultValue,
|
||||
turnScreenOff = TURN_SCREEN_OFF.defaultValue,
|
||||
stayAwake = STAY_AWAKE.defaultValue,
|
||||
disableScreensaver = DISABLE_SCREENSAVER.defaultValue,
|
||||
powerOffOnClose = POWER_OFF_ON_CLOSE.defaultValue,
|
||||
downsizeOnError = DOWNSIZE_ON_ERROR.defaultValue,
|
||||
cleanup = CLEANUP.defaultValue,
|
||||
powerOn = POWER_ON.defaultValue,
|
||||
video = VIDEO.defaultValue,
|
||||
audio = AUDIO.defaultValue,
|
||||
requireAudio = REQUIRE_AUDIO.defaultValue,
|
||||
killAdbOnClose = KILL_ADB_ON_CLOSE.defaultValue,
|
||||
cameraHighSpeed = CAMERA_HIGH_SPEED.defaultValue,
|
||||
list = LIST.defaultValue,
|
||||
audioDup = AUDIO_DUP.defaultValue,
|
||||
newDisplay = NEW_DISPLAY.defaultValue,
|
||||
startApp = START_APP.defaultValue,
|
||||
startAppCustom = START_APP_CUSTOM.defaultValue,
|
||||
startAppUseCustom = START_APP_USE_CUSTOM.defaultValue,
|
||||
vdDestroyContent = VD_DESTROY_CONTENT.defaultValue,
|
||||
vdSystemDecorations = VD_SYSTEM_DECORATIONS.defaultValue,
|
||||
)
|
||||
}
|
||||
|
||||
val crop by setting(CROP)
|
||||
@@ -424,7 +486,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
|
||||
|
||||
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
|
||||
|
||||
private fun bundleFromPreferences(preferences: Preferences) = Bundle(
|
||||
private fun bundleFromPreferences(preferences: Preferences) = defaultBundle().copy(
|
||||
crop = preferences.read(CROP),
|
||||
recordFilename = preferences.read(RECORD_FILENAME),
|
||||
videoCodecOptions = preferences.read(VIDEO_CODEC_OPTIONS),
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Parcel
|
||||
import android.util.Base64
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions.Companion.GLOBAL_PROFILE_ID
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions.Companion.GLOBAL_PROFILE_NAME
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.UUID
|
||||
|
||||
class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") {
|
||||
companion object {
|
||||
private val PROFILES_JSON = Pair(
|
||||
stringPreferencesKey("profiles_json"),
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
data class Profile(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val bundle: ScrcpyOptions.Bundle,
|
||||
val isBuiltinGlobal: Boolean = false,
|
||||
)
|
||||
|
||||
data class State(
|
||||
val profiles: List<Profile>,
|
||||
) {
|
||||
val globalProfile: Profile get() = profiles.first()
|
||||
}
|
||||
|
||||
private val profilesJson by setting(PROFILES_JSON)
|
||||
|
||||
val state: StateFlow<State> = createBundleState(::stateFromPreferences)
|
||||
|
||||
private fun stateFromPreferences(preferences: androidx.datastore.preferences.core.Preferences): State {
|
||||
val raw = preferences.read(PROFILES_JSON)
|
||||
return normalizeState(
|
||||
runCatching { decodeState(raw) }.getOrNull() ?: State(emptyList())
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun loadState() = loadBundle(::stateFromPreferences)
|
||||
|
||||
suspend fun saveState(newState: State) {
|
||||
profilesJson.set(encodeState(normalizeState(newState)))
|
||||
}
|
||||
|
||||
suspend fun getProfiles(): List<Profile> = loadState().profiles
|
||||
|
||||
suspend fun getProfile(id: String): Profile? = loadState().profiles.firstOrNull { it.id == id }
|
||||
|
||||
suspend fun getProfileOrGlobal(id: String?): Profile {
|
||||
val state = loadState()
|
||||
return state.profiles.firstOrNull { it.id == id } ?: state.globalProfile
|
||||
}
|
||||
|
||||
suspend fun getBundleOrGlobal(id: String?): ScrcpyOptions.Bundle =
|
||||
getProfileOrGlobal(id).bundle
|
||||
|
||||
suspend fun updateBundle(id: String, bundle: ScrcpyOptions.Bundle): State {
|
||||
val current = loadState()
|
||||
val profiles = current.profiles.map {
|
||||
if (it.id == id) it.copy(bundle = bundle)
|
||||
else it
|
||||
}
|
||||
val next = normalizeState(current.copy(profiles = profiles))
|
||||
saveState(next)
|
||||
return next
|
||||
}
|
||||
|
||||
suspend fun createProfile(
|
||||
requestedName: String,
|
||||
bundle: ScrcpyOptions.Bundle = ScrcpyOptions.defaultBundle(),
|
||||
): Profile {
|
||||
val current = loadState()
|
||||
val profile = Profile(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = ensureUniqueName(current, requestedName),
|
||||
bundle = bundle,
|
||||
)
|
||||
val next = normalizeState(current.copy(profiles = current.profiles + profile))
|
||||
saveState(next)
|
||||
return next.profiles.first { it.id == profile.id }
|
||||
}
|
||||
|
||||
suspend fun duplicateProfile(
|
||||
sourceId: String,
|
||||
requestedName: String = "",
|
||||
): Profile? {
|
||||
val current = loadState()
|
||||
val source = current.profiles.firstOrNull { it.id == sourceId } ?: return null
|
||||
return createProfile(
|
||||
requestedName = requestedName.ifBlank { source.name },
|
||||
bundle = source.bundle,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun renameProfile(id: String, requestedName: String): Profile? {
|
||||
if (id == GLOBAL_PROFILE_ID) return null
|
||||
val current = loadState()
|
||||
val existing = current.profiles.firstOrNull { it.id == id } ?: return null
|
||||
val updated = existing.copy(
|
||||
name = ensureUniqueName(current, requestedName, excludeId = id),
|
||||
)
|
||||
val next = normalizeState(
|
||||
current.copy(
|
||||
profiles = current.profiles.map {
|
||||
if (it.id == id) updated else it
|
||||
}
|
||||
)
|
||||
)
|
||||
saveState(next)
|
||||
return next.profiles.firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
suspend fun deleteProfile(id: String): Boolean {
|
||||
if (id == GLOBAL_PROFILE_ID) return false
|
||||
val current = loadState()
|
||||
if (current.profiles.none { it.id == id }) return false
|
||||
val next = normalizeState(
|
||||
current.copy(
|
||||
profiles = current.profiles.filterNot { it.id == id }
|
||||
)
|
||||
)
|
||||
saveState(next)
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun moveProfile(fromIndex: Int, toIndex: Int): State {
|
||||
val current = loadState()
|
||||
if (fromIndex !in current.profiles.indices || toIndex !in current.profiles.indices) {
|
||||
return current
|
||||
}
|
||||
if (fromIndex == toIndex || fromIndex == 0 || toIndex == 0) {
|
||||
return current
|
||||
}
|
||||
val mutable = current.profiles.toMutableList()
|
||||
val item = mutable.removeAt(fromIndex)
|
||||
val target = if (toIndex > fromIndex) toIndex - 1 else toIndex
|
||||
mutable.add(target, item)
|
||||
val next = normalizeState(current.copy(profiles = mutable))
|
||||
saveState(next)
|
||||
return next
|
||||
}
|
||||
|
||||
fun ensureUniqueName(
|
||||
state: State,
|
||||
requestedName: String,
|
||||
excludeId: String? = null,
|
||||
): String {
|
||||
val baseName = requestedName.trim().ifBlank { "配置" }
|
||||
if (baseName == GLOBAL_PROFILE_NAME) return ensureUniqueName(state, "配置", excludeId)
|
||||
val existingNames = state.profiles
|
||||
.filterNot { it.id == excludeId }
|
||||
.map { it.name }
|
||||
.toSet()
|
||||
if (baseName !in existingNames) return baseName
|
||||
var suffix = 1
|
||||
while (true) {
|
||||
val candidate = "$baseName ($suffix)"
|
||||
if (candidate !in existingNames) return candidate
|
||||
suffix++
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeState(state: State): State {
|
||||
val global = state.profiles.firstOrNull { it.id == GLOBAL_PROFILE_ID }
|
||||
?.copy(name = GLOBAL_PROFILE_NAME, isBuiltinGlobal = true)
|
||||
?: Profile(
|
||||
id = GLOBAL_PROFILE_ID,
|
||||
name = GLOBAL_PROFILE_NAME,
|
||||
bundle = ScrcpyOptions.defaultBundle(),
|
||||
isBuiltinGlobal = true,
|
||||
)
|
||||
val usedNames = linkedSetOf(global.name)
|
||||
val others = buildList {
|
||||
state.profiles
|
||||
.asSequence()
|
||||
.filterNot { it.id == GLOBAL_PROFILE_ID }
|
||||
.forEach { profile ->
|
||||
val baseName = profile.name.trim().ifBlank { "配置" }
|
||||
.takeUnless { it == GLOBAL_PROFILE_NAME }
|
||||
?: "配置"
|
||||
var normalizedName = baseName
|
||||
if (normalizedName in usedNames) {
|
||||
var suffix = 1
|
||||
while (true) {
|
||||
val candidate = "$baseName ($suffix)"
|
||||
if (candidate !in usedNames) {
|
||||
normalizedName = candidate
|
||||
break
|
||||
}
|
||||
suffix++
|
||||
}
|
||||
}
|
||||
usedNames += normalizedName
|
||||
add(profile.copy(name = normalizedName, isBuiltinGlobal = false))
|
||||
}
|
||||
}
|
||||
return State(listOf(global) + others)
|
||||
}
|
||||
|
||||
private fun encodeState(state: State): String {
|
||||
val array = JSONArray()
|
||||
for (profile in state.profiles) {
|
||||
array.put(
|
||||
JSONObject()
|
||||
.put("id", profile.id)
|
||||
.put("name", profile.name)
|
||||
.put("bundle", encodeBundle(profile.bundle))
|
||||
)
|
||||
}
|
||||
return array.toString()
|
||||
}
|
||||
|
||||
private fun decodeState(raw: String): State {
|
||||
if (raw.isBlank()) return State(emptyList())
|
||||
val array = JSONArray(raw)
|
||||
val profiles = buildList {
|
||||
for (index in 0 until array.length()) {
|
||||
val item = array.optJSONObject(index) ?: continue
|
||||
val id = item.optString("id").trim()
|
||||
val name = item.optString("name").trim()
|
||||
val bundleRaw = item.optString("bundle")
|
||||
if (id.isBlank()) continue
|
||||
add(
|
||||
Profile(
|
||||
id = id,
|
||||
name = name.ifBlank { "配置" },
|
||||
bundle = decodeBundle(bundleRaw),
|
||||
isBuiltinGlobal = id == GLOBAL_PROFILE_ID,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return State(profiles)
|
||||
}
|
||||
|
||||
private fun encodeBundle(bundle: ScrcpyOptions.Bundle): String {
|
||||
val parcel = Parcel.obtain()
|
||||
return try {
|
||||
parcel.writeParcelable(bundle, 0)
|
||||
Base64.encodeToString(parcel.marshall(), Base64.NO_WRAP)
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeBundle(raw: String): ScrcpyOptions.Bundle {
|
||||
if (raw.isBlank()) return ScrcpyOptions.defaultBundle()
|
||||
val bytes = runCatching { Base64.decode(raw, Base64.DEFAULT) }.getOrNull()
|
||||
?: return ScrcpyOptions.defaultBundle()
|
||||
val parcel = Parcel.obtain()
|
||||
return try {
|
||||
parcel.unmarshall(bytes, 0, bytes.size)
|
||||
parcel.setDataPosition(0)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
parcel.readParcelable(
|
||||
ScrcpyOptions.Bundle::class.java.classLoader,
|
||||
ScrcpyOptions.Bundle::class.java,
|
||||
)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
parcel.readParcelable(ScrcpyOptions.Bundle::class.java.classLoader)
|
||||
} ?: ScrcpyOptions.defaultBundle()
|
||||
} catch (_: Throwable) {
|
||||
ScrcpyOptions.defaultBundle()
|
||||
} finally {
|
||||
parcel.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,6 @@ object Storage {
|
||||
val appSettings: AppSettings by lazy { AppSettings(appContext) }
|
||||
val quickDevices: QuickDevices by lazy { QuickDevices(appContext) }
|
||||
val scrcpyOptions: ScrcpyOptions by lazy { ScrcpyOptions(appContext) }
|
||||
val scrcpyProfiles: ScrcpyProfiles by lazy { ScrcpyProfiles(appContext) }
|
||||
val adbClientData: AdbClientData by lazy { AdbClientData(appContext) }
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
|
||||
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
|
||||
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Settings
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage
|
||||
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
|
||||
@@ -919,6 +920,8 @@ internal fun DeviceTile(
|
||||
onEditorCancel: () -> Unit,
|
||||
) {
|
||||
val haptics = rememberAppHaptics()
|
||||
val snackbar = LocalSnackbarController.current
|
||||
val scrcpyProfilesState by Storage.scrcpyProfiles.state.collectAsState()
|
||||
|
||||
var draft by remember(editing, device.id) {
|
||||
mutableStateOf(if (editing) device else null)
|
||||
@@ -943,6 +946,7 @@ internal fun DeviceTile(
|
||||
startScrcpyOnConnect = currentDraft.startScrcpyOnConnect,
|
||||
openFullscreenOnStart = currentDraft.startScrcpyOnConnect
|
||||
&& currentDraft.openFullscreenOnStart,
|
||||
scrcpyProfileId = currentDraft.scrcpyProfileId,
|
||||
)
|
||||
if (updated != device) {
|
||||
onEditorSave(updated)
|
||||
@@ -952,6 +956,15 @@ internal fun DeviceTile(
|
||||
val currentDraft = draft ?: device
|
||||
val currentOriginalDraft = originalDraft ?: device
|
||||
val currentDraftPortText = draftPortText ?: device.port.toString()
|
||||
val profileNames = remember(scrcpyProfilesState.profiles) {
|
||||
scrcpyProfilesState.profiles.map { it.name }
|
||||
}
|
||||
val profileIds = remember(scrcpyProfilesState.profiles) {
|
||||
scrcpyProfilesState.profiles.map { it.id }
|
||||
}
|
||||
val profileDropdownIndex = remember(currentDraft.scrcpyProfileId, profileIds) {
|
||||
profileIds.indexOf(currentDraft.scrcpyProfileId).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.defaultColors(
|
||||
@@ -1087,6 +1100,20 @@ internal fun DeviceTile(
|
||||
},
|
||||
)
|
||||
}
|
||||
OverlayDropdownPreference(
|
||||
title = "Scrcpy 配置",
|
||||
items = profileNames,
|
||||
selectedIndex = profileDropdownIndex,
|
||||
onSelectedIndexChange = {
|
||||
val profileId = profileIds.getOrElse(it) {
|
||||
ScrcpyOptions.GLOBAL_PROFILE_ID
|
||||
}
|
||||
val profileName = profileNames.getOrElse(it) { "全局" }
|
||||
val deviceName = currentDraft.name.ifBlank { currentDraft.host }
|
||||
draft = currentDraft.copy(scrcpyProfileId = profileId)
|
||||
snackbar.show("$deviceName 已切换到配置 $profileName")
|
||||
},
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package io.github.miuzarte.scrcpyforandroid.widgets
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.DragIndicator
|
||||
@@ -34,18 +37,31 @@ class ReorderableList(
|
||||
private val orientation: Orientation = Orientation.Column,
|
||||
private val onSettle: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> },
|
||||
private val modifier: Modifier = Modifier,
|
||||
private val showCheckbox: Boolean = false,
|
||||
private val onCheckboxChange: ((String, Boolean) -> Unit)? = null,
|
||||
) {
|
||||
enum class Orientation { Column, Row; }
|
||||
|
||||
sealed interface EndAction {
|
||||
data class Icon(
|
||||
val icon: ImageVector,
|
||||
val contentDescription: String,
|
||||
val onClick: () -> Unit,
|
||||
) : EndAction
|
||||
|
||||
data class Checkbox(
|
||||
val checked: Boolean,
|
||||
val enabled: Boolean = true,
|
||||
val onClick: () -> Unit,
|
||||
) : EndAction
|
||||
}
|
||||
|
||||
data class Item(
|
||||
val id: String,
|
||||
val icon: ImageVector? = null,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val checked: Boolean = true,
|
||||
val checkboxEnabled: Boolean = true,
|
||||
val onClick: (() -> Unit)? = null,
|
||||
val dragEnabled: Boolean = true,
|
||||
val endActions: List<EndAction> = emptyList(),
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -69,6 +85,7 @@ class ReorderableList(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.padding(
|
||||
horizontal = UiSpacing.CardTitle,
|
||||
vertical = UiSpacing.FieldLabelBottom
|
||||
@@ -77,6 +94,16 @@ class ReorderableList(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.then(
|
||||
if (item.onClick != null) {
|
||||
Modifier.clickable(onClick = item.onClick)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small)
|
||||
) {
|
||||
@@ -101,33 +128,34 @@ class ReorderableList(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small)
|
||||
) {
|
||||
if (showCheckbox) Checkbox(
|
||||
state = if (item.checked) ToggleableState.On else ToggleableState.Off,
|
||||
onClick = {
|
||||
onCheckboxChange?.invoke(item.id, !item.checked)
|
||||
},
|
||||
enabled = item.checkboxEnabled
|
||||
)
|
||||
IconButton(
|
||||
onClick = {
|
||||
haptics.contextClick()
|
||||
},
|
||||
modifier = Modifier
|
||||
.draggableHandle(
|
||||
onDragStarted = {
|
||||
haptics.longPress()
|
||||
},
|
||||
onDragStopped = {
|
||||
haptics.confirm()
|
||||
},
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.DragIndicator,
|
||||
contentDescription = "拖动排序",
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
item.endActions.forEach { action ->
|
||||
EndActionView(
|
||||
action = action,
|
||||
fallbackContentDescription = item.title,
|
||||
)
|
||||
}
|
||||
if (item.dragEnabled) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
haptics.contextClick()
|
||||
},
|
||||
modifier = Modifier
|
||||
.draggableHandle(
|
||||
onDragStarted = {
|
||||
haptics.longPress()
|
||||
},
|
||||
onDragStopped = {
|
||||
haptics.confirm()
|
||||
},
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.DragIndicator,
|
||||
contentDescription = "拖动排序",
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,35 +186,41 @@ class ReorderableList(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.then(
|
||||
if (item.onClick != null) {
|
||||
Modifier.clickable(onClick = item.onClick)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (showCheckbox) {
|
||||
Checkbox(
|
||||
state = if (item.checked) ToggleableState.On else ToggleableState.Off,
|
||||
onClick = {
|
||||
onCheckboxChange?.invoke(item.id, !item.checked)
|
||||
},
|
||||
enabled = item.checkboxEnabled
|
||||
item.endActions.forEach { action ->
|
||||
EndActionView(
|
||||
action = action,
|
||||
fallbackContentDescription = item.title,
|
||||
)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
}
|
||||
IconButton(
|
||||
onClick = {},
|
||||
modifier = Modifier
|
||||
.draggableHandle(
|
||||
onDragStarted = {
|
||||
haptics.longPress()
|
||||
},
|
||||
onDragStopped = {
|
||||
haptics.confirm()
|
||||
},
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.DragIndicator,
|
||||
contentDescription = "拖动排序",
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
if (item.dragEnabled) {
|
||||
IconButton(
|
||||
onClick = {},
|
||||
modifier = Modifier
|
||||
.draggableHandle(
|
||||
onDragStarted = {
|
||||
haptics.longPress()
|
||||
},
|
||||
onDragStopped = {
|
||||
haptics.confirm()
|
||||
},
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.DragIndicator,
|
||||
contentDescription = "拖动排序",
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.padding(UiSpacing.ContentVertical))
|
||||
@@ -211,3 +245,29 @@ class ReorderableList(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EndActionView(
|
||||
action: ReorderableList.EndAction,
|
||||
fallbackContentDescription: String,
|
||||
) {
|
||||
when (action) {
|
||||
is ReorderableList.EndAction.Checkbox -> Checkbox(
|
||||
state = if (action.checked) ToggleableState.On else ToggleableState.Off,
|
||||
onClick = action.onClick,
|
||||
enabled = action.enabled,
|
||||
)
|
||||
|
||||
is ReorderableList.EndAction.Icon -> IconButton(
|
||||
onClick = action.onClick,
|
||||
) {
|
||||
Icon(
|
||||
action.icon,
|
||||
contentDescription = action.contentDescription.ifBlank {
|
||||
fallbackContentDescription
|
||||
},
|
||||
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user