feat: multi scrcpy options profiles

This commit is contained in:
Miuzarte
2026-04-12 15:41:41 +08:00
parent 2f3505a49f
commit 436d74f888
12 changed files with 1112 additions and 131 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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