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 package io.github.miuzarte.scrcpyforandroid.models
import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
// Composable 用, 不可变 List // Composable 用, 不可变 List
class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut> by devices { 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, name: String? = null,
startScrcpyOnConnect: Boolean? = null, startScrcpyOnConnect: Boolean? = null,
openFullscreenOnStart: Boolean? = null, openFullscreenOnStart: Boolean? = null,
scrcpyProfileId: String? = null,
newPort: Int? = null, newPort: Int? = null,
updateNameOnlyWhenEmpty: Boolean = false, updateNameOnlyWhenEmpty: Boolean = false,
): DeviceShortcuts { ): DeviceShortcuts {
@@ -51,38 +53,28 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
val old = devices[idx] val old = devices[idx]
val updateById = id != null val updateById = id != null
// 确定最终的属性值 val updated = DeviceShortcut(
val finalName = when { name = when {
name == null -> old.name name == null -> old.name
updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name
else -> name else -> name
} },
val finalHost = if (updateById) host ?: old.host else old.host host = if (updateById) host ?: old.host else old.host,
val finalPort = if (updateById) port ?: old.port else newPort ?: old.port port = if (updateById) port ?: old.port else newPort ?: old.port,
val finalStartScrcpyOnConnect = startScrcpyOnConnect ?: old.startScrcpyOnConnect startScrcpyOnConnect = startScrcpyOnConnect ?: old.startScrcpyOnConnect,
val finalOpenFullscreenOnStart = openFullscreenOnStart ?: old.openFullscreenOnStart openFullscreenOnStart = openFullscreenOnStart ?: old.openFullscreenOnStart,
scrcpyProfileId = scrcpyProfileId ?: old.scrcpyProfileId,
)
// 若无任何变化,返回原实例 // 若无任何变化,返回原实例
if ( if (updated == old) return this
finalName == old.name
&& finalHost == old.host
&& finalPort == old.port
&& finalStartScrcpyOnConnect == old.startScrcpyOnConnect
&& finalOpenFullscreenOnStart == old.openFullscreenOnStart
)
return this
val newList = devices.toMutableList().apply { val newList = devices.toMutableList()
this[idx] = DeviceShortcut( .apply {
name = finalName, this[idx] = updated
host = finalHost, }
port = finalPort,
startScrcpyOnConnect = finalStartScrcpyOnConnect,
openFullscreenOnStart = finalOpenFullscreenOnStart,
)
}
return DeviceShortcuts( 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) || (newPort != null && newPort != old.port)
) )
newList.distinctBy { it.id } newList.distinctBy { it.id }
@@ -133,6 +125,7 @@ data class DeviceShortcut(
val port: Int = Defaults.ADB_PORT, val port: Int = Defaults.ADB_PORT,
val startScrcpyOnConnect: Boolean = false, val startScrcpyOnConnect: Boolean = false,
val openFullscreenOnStart: Boolean = false, val openFullscreenOnStart: Boolean = false,
val scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
) { ) {
val id: String get() = "$host:$port" val id: String get() = "$host:$port"
@@ -144,6 +137,7 @@ data class DeviceShortcut(
port.toString(), port.toString(),
if (startScrcpyOnConnect) "1" else "0", if (startScrcpyOnConnect) "1" else "0",
if (openFullscreenOnStart) "1" else "0", if (openFullscreenOnStart) "1" else "0",
scrcpyProfileId.trim(),
).joinToString( ).joinToString(
separator = separator separator = separator
) )
@@ -156,19 +150,28 @@ data class DeviceShortcut(
): DeviceShortcut? { ): DeviceShortcut? {
val parts = s.split(separator) val parts = s.split(separator)
return when (parts.size) { return when (parts.size) {
3, 4, 5 -> { 3, 4, 5, 6 -> {
val name = parts[0].trim() val name = parts[0].trim()
val host = parts[1].trim() val host = parts[1].trim()
val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT
val startScrcpyOnConnect = parts.getOrNull(3)?.trim() == "1"
val openFullscreenOnStart = val startScrcpyOnConnect = parts.getOrNull(3)
startScrcpyOnConnect && parts.getOrNull(4)?.trim() == "1" ?.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( if (host.isNotBlank()) DeviceShortcut(
name = name, name = name,
host = host, host = host,
port = port, port = port,
startScrcpyOnConnect = startScrcpyOnConnect, startScrcpyOnConnect = startScrcpyOnConnect,
openFullscreenOnStart = openFullscreenOnStart, openFullscreenOnStart = openFullscreenOnStart,
scrcpyProfileId = scrcpyProfileId,
) )
else null 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.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource 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.AppWakeLocks
import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo 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.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions 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.ConfigPanel
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTileList import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTileList
import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard
@@ -213,6 +216,7 @@ fun DeviceTabPage(
// read only // read only
val soBundleShared by scrcpyOptions.bundleState.collectAsState() val soBundleShared by scrcpyOptions.bundleState.collectAsState()
val scrcpyProfilesState by scrcpyProfiles.state.collectAsState()
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
@@ -234,6 +238,9 @@ fun DeviceTabPage(
var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) } var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) }
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) } var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
var adbConnecting by rememberSaveable { mutableStateOf(false) } var adbConnecting by rememberSaveable { mutableStateOf(false) }
var connectedScrcpyProfileId by rememberSaveable {
mutableStateOf(ScrcpyOptions.GLOBAL_PROFILE_ID)
}
var audioForwardingSupported by rememberSaveable { mutableStateOf(true) } var audioForwardingSupported by rememberSaveable { mutableStateOf(true) }
var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) } var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) }
@@ -250,6 +257,14 @@ fun DeviceTabPage(
ConnectionTarget(currentTargetHost, currentTargetPort) ConnectionTarget(currentTargetHost, currentTargetPort)
else null 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 sessionReconnectBlacklistHosts = remember { mutableSetOf<String>() }
val virtualButtonLayout = remember(asBundle.virtualButtonsLayout) { val virtualButtonLayout = remember(asBundle.virtualButtonsLayout) {
@@ -307,6 +322,8 @@ fun DeviceTabPage(
adbConnected = false adbConnected = false
currentTargetHost = "" currentTargetHost = ""
currentTargetPort = Defaults.ADB_PORT currentTargetPort = Defaults.ADB_PORT
connectedScrcpyProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID
AppRuntime.currentConnectionTarget = null
audioForwardingSupported = true audioForwardingSupported = true
cameraMirroringSupported = true cameraMirroringSupported = true
statusLine = "未连接" statusLine = "未连接"
@@ -334,11 +351,19 @@ fun DeviceTabPage(
} }
fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) { fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) {
val currentBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
val audioSupported = sdkInt !in 0..<30 val audioSupported = sdkInt !in 0..<30
audioForwardingSupported = audioSupported audioForwardingSupported = audioSupported
if (!audioSupported && soBundleShared.audio) { if (!audioSupported && currentBundle.audio) {
scope.launch { 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( logEvent(
"设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭", "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭",
@@ -347,9 +372,16 @@ fun DeviceTabPage(
} }
val cameraSupported = sdkInt !in 0..<31 val cameraSupported = sdkInt !in 0..<31
cameraMirroringSupported = cameraSupported cameraMirroringSupported = cameraSupported
if (!cameraSupported && soBundleShared.videoSource == "camera") { if (!cameraSupported && currentBundle.videoSource == "camera") {
scope.launch { 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( logEvent(
"设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring已切换为 display", "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring已切换为 display",
@@ -576,7 +608,8 @@ fun DeviceTabPage(
} }
suspend fun startScrcpySession(openFullscreen: Boolean = false) { 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) val session = scrcpy.start(options)
pendingScrollToPreview = true pendingScrollToPreview = true
val startAppRequest = runCatching { val startAppRequest = runCatching {
@@ -630,14 +663,14 @@ fun DeviceTabPage(
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
val videoDetail = val videoDetail =
if (!options.video) "off" 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} " + 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 = val audioDetail =
if (!soBundleShared.audio) "off" if (!activeBundle.audio) "off"
else if (soBundleShared.audioBitRate <= 0) "${options.audioCodec} default source=${options.audioSource}" else if (activeBundle.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 ""}" else "${options.audioCodec} ${activeBundle.audioBitRate / 1_000f}Kbps source=${options.audioSource}${if (!options.audioPlayback) "(no-playback)" else ""}"
logEvent( logEvent(
"scrcpy 已启动: device=${session.deviceName}" + "scrcpy 已启动: device=${session.deviceName}" +
@@ -649,7 +682,8 @@ fun DeviceTabPage(
} }
suspend fun stopScrcpySession() { suspend fun stopScrcpySession() {
val options = scrcpyOptions.toClientOptions(soBundleShared).fix() val activeBundle = resolveScrcpyBundle(connectedScrcpyProfileId)
val options = scrcpyOptions.toClientOptions(activeBundle).fix()
scrcpy.stop() scrcpy.stop()
if (options.killAdbOnClose) { if (options.killAdbOnClose) {
// TODO // TODO
@@ -667,9 +701,12 @@ fun DeviceTabPage(
host: String, port: Int, host: String, port: Int,
autoStartScrcpy: Boolean = false, autoStartScrcpy: Boolean = false,
autoEnterFullScreen: Boolean = false, autoEnterFullScreen: Boolean = false,
scrcpyProfileId: String = ScrcpyOptions.GLOBAL_PROFILE_ID,
) { ) {
currentTargetHost = host currentTargetHost = host
currentTargetPort = port currentTargetPort = port
connectedScrcpyProfileId = scrcpyProfileId
AppRuntime.currentConnectionTarget = ConnectionTarget(host, port)
val info = fetchConnectedDeviceInfo(NativeAdbService, host, port) val info = fetchConnectedDeviceInfo(NativeAdbService, host, port)
val fullLabel = if (info.serial.isNotBlank()) { val fullLabel = if (info.serial.isNotBlank()) {
@@ -739,7 +776,11 @@ fun DeviceTabPage(
savedShortcuts = savedShortcuts.update( savedShortcuts = savedShortcuts.update(
host = target.host, port = target.port, 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}") logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
} catch (_: Exception) { } catch (_: Exception) {
} }
@@ -796,7 +837,11 @@ fun DeviceTabPage(
savedShortcuts = savedShortcuts.update( savedShortcuts = savedShortcuts.update(
host = discoveredHost, port = discoveredPort, host = discoveredHost, port = discoveredPort,
) )
handleAdbConnected(discoveredHost, discoveredPort) handleAdbConnected(
discoveredHost,
discoveredPort,
scrcpyProfileId = knownDevice.scrcpyProfileId,
)
logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort") logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort")
} catch (_: Exception) { } catch (_: Exception) {
} }
@@ -888,6 +933,7 @@ fun DeviceTabPage(
autoStartScrcpy = device.startScrcpyOnConnect, autoStartScrcpy = device.startScrcpyOnConnect,
autoEnterFullScreen = device.startScrcpyOnConnect autoEnterFullScreen = device.startScrcpyOnConnect
&& device.openFullscreenOnStart, && device.openFullscreenOnStart,
scrcpyProfileId = device.scrcpyProfileId,
) )
} catch (e: Exception) { } catch (e: Exception) {
statusLine = "ADB 连接失败" statusLine = "ADB 连接失败"
@@ -919,6 +965,7 @@ fun DeviceTabPage(
port = updated.port, port = updated.port,
startScrcpyOnConnect = updated.startScrcpyOnConnect, startScrcpyOnConnect = updated.startScrcpyOnConnect,
openFullscreenOnStart = updated.openFullscreenOnStart, openFullscreenOnStart = updated.openFullscreenOnStart,
scrcpyProfileId = updated.scrcpyProfileId,
) )
}, },
onEditorDelete = { device -> onEditorDelete = { device ->
@@ -966,7 +1013,11 @@ fun DeviceTabPage(
savedShortcuts = savedShortcuts.update( savedShortcuts = savedShortcuts.update(
host = target.host, port = target.port, host = target.host, port = target.port,
) )
handleAdbConnected(target.host, target.port) handleAdbConnected(
target.host,
target.port,
scrcpyProfileId = ScrcpyOptions.GLOBAL_PROFILE_ID,
)
} catch (e: Exception) { } catch (e: Exception) {
statusLine = "ADB 连接失败" statusLine = "ADB 连接失败"
logEvent("ADB 连接失败: $e", Log.ERROR) 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.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.widgets.ScrcpyVideoSurface 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.VirtualButtonActions
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
import kotlinx.coroutines.CoroutineScope 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack 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.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue 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.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType 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.ScrcpyPresets
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing 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.Crop
import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn 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.LogLevel
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource 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.services.LocalSnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import io.github.miuzarte.scrcpyforandroid.storage.Settings 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.scrcpyOptions
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyProfiles
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -58,18 +71,27 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import top.yukonga.miuix.kmp.basic.Card 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.Icon
import top.yukonga.miuix.kmp.basic.IconButton 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.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SnackbarHost import top.yukonga.miuix.kmp.basic.SnackbarHost
import top.yukonga.miuix.kmp.basic.SpinnerEntry 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.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.basic.TopAppBar import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.MiuixIcons
import top.yukonga.miuix.kmp.icon.extended.Store import top.yukonga.miuix.kmp.icon.extended.Store
import top.yukonga.miuix.kmp.icon.extended.Tune 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.ArrowPreference
import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference import top.yukonga.miuix.kmp.preference.OverlayDropdownPreference
import top.yukonga.miuix.kmp.preference.OverlaySpinnerPreference import top.yukonga.miuix.kmp.preference.OverlaySpinnerPreference
@@ -81,8 +103,85 @@ internal fun ScrcpyAllOptionsScreen(
scrollBehavior: ScrollBehavior, scrollBehavior: ScrollBehavior,
scrcpy: Scrcpy, scrcpy: Scrcpy,
) { ) {
val snackbar = LocalSnackbarController.current
val navigator = LocalRootNavigator.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( Scaffold(
topBar = { topBar = {
TopAppBar( 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, scrollBehavior = scrollBehavior,
) )
}, },
snackbarHost = { SnackbarHost(snackbar.hostState) }, snackbarHost = {
val snackbar = LocalSnackbarController.current
SnackbarHost(snackbar.hostState)
},
) { contentPadding -> ) { contentPadding ->
ScrcpyAllOptionsPage( ScrcpyAllOptionsPage(
contentPadding = contentPadding, contentPadding = contentPadding,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
scrcpy = scrcpy, 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, contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior, scrollBehavior: ScrollBehavior,
scrcpy: Scrcpy, 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 focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -122,28 +343,60 @@ internal fun ScrcpyAllOptionsPage(
var refreshBusy by rememberSaveable { mutableStateOf(false) } var refreshBusy by rememberSaveable { mutableStateOf(false) }
var listRefreshVersion by rememberSaveable { mutableIntStateOf(0) } var listRefreshVersion by rememberSaveable { mutableIntStateOf(0) }
val soBundleShared by scrcpyOptions.bundleState.collectAsState() var selectedProfileId by selectedProfileIdState
val soBundleSharedLatest by rememberUpdatedState(soBundleShared) val selectedProfileIdLatest by rememberUpdatedState(selectedProfileId)
var soBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) } var soBundle by soBundleState
// 验证配置项合法性用于回滚更改 var lastValidSoBundle by lastValidSoBundleState
var lastValidSoBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) }
val soBundleLatest by rememberUpdatedState(soBundle) val soBundleLatest by rememberUpdatedState(soBundle)
LaunchedEffect(soBundleShared) { val profileTabs = remember(scrcpyProfilesState.profiles) {
if (soBundle != soBundleShared) { scrcpyProfilesState.profiles.map { it.name }
soBundle = soBundleShared }
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) { LaunchedEffect(soBundle) {
delay(Settings.BUNDLE_SAVE_DELAY) delay(Settings.BUNDLE_SAVE_DELAY)
if (soBundle != soBundleSharedLatest) { val currentProfileId = selectedProfileIdLatest
scrcpyOptions.saveBundle(soBundle) val savedBundle = resolveProfileBundle(currentProfileId)
if (soBundle != savedBundle) {
onSaveBundleForProfile(currentProfileId, soBundle)
} }
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
taskScope.launch { taskScope.launch {
scrcpyOptions.saveBundle(soBundleLatest) onSaveBundleForProfile(selectedProfileIdLatest, soBundleLatest)
} }
} }
} }
@@ -462,6 +715,32 @@ internal fun ScrcpyAllOptionsPage(
contentPadding = contentPadding, contentPadding = contentPadding,
scrollBehavior = scrollBehavior, 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 { item {
Card { Card {
TextField( TextField(
@@ -1585,3 +1864,217 @@ internal fun ScrcpyAllOptionsPage(
item { Spacer(Modifier.height(UiSpacing.PageBottom)) } 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, icon = action.icon,
title = if (action.keycode == null) action.title else "${action.title} (${action.keycode})", title = if (action.keycode == null) action.title else "${action.title} (${action.keycode})",
subtitle = if (item.showOutside) "显示在外部" else "显示在更多菜单内", subtitle = if (item.showOutside) "显示在外部" else "显示在更多菜单内",
checked = item.showOutside, endActions = listOf(
checkboxEnabled = action != VirtualButtonAction.MORE, 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) 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.content.Context
import android.os.Build import android.os.Build
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbMdnsDiscoverer import io.github.miuzarte.scrcpyforandroid.nativecore.AdbMdnsDiscoverer
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
@@ -17,4 +18,5 @@ object AppRuntime {
} }
var scrcpy: Scrcpy? = null 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( val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair(
intPreferencesKey("device_preview_card_height_dp"), intPreferencesKey("device_preview_card_height_dp"),
1080/3 1080 / 3
) )
val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair( val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair(
booleanPreferencesKey("preview_virtual_button_show_text"), booleanPreferencesKey("preview_virtual_button_show_text"),

View File

@@ -25,6 +25,9 @@ import kotlinx.parcelize.Parcelize
class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") { class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
companion object { companion object {
const val GLOBAL_PROFILE_ID = "global"
const val GLOBAL_PROFILE_NAME = "全局"
val CROP = Pair( val CROP = Pair(
stringPreferencesKey("crop"), stringPreferencesKey("crop"),
"", "",
@@ -249,6 +252,65 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
booleanPreferencesKey("vd_system_decorations"), booleanPreferencesKey("vd_system_decorations"),
true, 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) val crop by setting(CROP)
@@ -424,7 +486,7 @@ class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences) val bundleState: StateFlow<Bundle> = createBundleState(::bundleFromPreferences)
private fun bundleFromPreferences(preferences: Preferences) = Bundle( private fun bundleFromPreferences(preferences: Preferences) = defaultBundle().copy(
crop = preferences.read(CROP), crop = preferences.read(CROP),
recordFilename = preferences.read(RECORD_FILENAME), recordFilename = preferences.read(RECORD_FILENAME),
videoCodecOptions = preferences.read(VIDEO_CODEC_OPTIONS), 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 appSettings: AppSettings by lazy { AppSettings(appContext) }
val quickDevices: QuickDevices by lazy { QuickDevices(appContext) } val quickDevices: QuickDevices by lazy { QuickDevices(appContext) }
val scrcpyOptions: ScrcpyOptions by lazy { ScrcpyOptions(appContext) } val scrcpyOptions: ScrcpyOptions by lazy { ScrcpyOptions(appContext) }
val scrcpyProfiles: ScrcpyProfiles by lazy { ScrcpyProfiles(appContext) }
val adbClientData: AdbClientData by lazy { AdbClientData(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.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.services.LocalSnackbarController 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.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
@@ -919,6 +920,8 @@ internal fun DeviceTile(
onEditorCancel: () -> Unit, onEditorCancel: () -> Unit,
) { ) {
val haptics = rememberAppHaptics() val haptics = rememberAppHaptics()
val snackbar = LocalSnackbarController.current
val scrcpyProfilesState by Storage.scrcpyProfiles.state.collectAsState()
var draft by remember(editing, device.id) { var draft by remember(editing, device.id) {
mutableStateOf(if (editing) device else null) mutableStateOf(if (editing) device else null)
@@ -943,6 +946,7 @@ internal fun DeviceTile(
startScrcpyOnConnect = currentDraft.startScrcpyOnConnect, startScrcpyOnConnect = currentDraft.startScrcpyOnConnect,
openFullscreenOnStart = currentDraft.startScrcpyOnConnect openFullscreenOnStart = currentDraft.startScrcpyOnConnect
&& currentDraft.openFullscreenOnStart, && currentDraft.openFullscreenOnStart,
scrcpyProfileId = currentDraft.scrcpyProfileId,
) )
if (updated != device) { if (updated != device) {
onEditorSave(updated) onEditorSave(updated)
@@ -952,6 +956,15 @@ internal fun DeviceTile(
val currentDraft = draft ?: device val currentDraft = draft ?: device
val currentOriginalDraft = originalDraft ?: device val currentOriginalDraft = originalDraft ?: device
val currentDraftPortText = draftPortText ?: device.port.toString() 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( Card(
colors = CardDefaults.defaultColors( 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( Row(
modifier = Modifier modifier = Modifier

View File

@@ -1,11 +1,14 @@
package io.github.miuzarte.scrcpyforandroid.widgets package io.github.miuzarte.scrcpyforandroid.widgets
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.DragIndicator import androidx.compose.material.icons.rounded.DragIndicator
@@ -34,18 +37,31 @@ class ReorderableList(
private val orientation: Orientation = Orientation.Column, private val orientation: Orientation = Orientation.Column,
private val onSettle: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> }, private val onSettle: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> },
private val modifier: Modifier = Modifier, private val modifier: Modifier = Modifier,
private val showCheckbox: Boolean = false,
private val onCheckboxChange: ((String, Boolean) -> Unit)? = null,
) { ) {
enum class Orientation { Column, Row; } 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( data class Item(
val id: String, val id: String,
val icon: ImageVector? = null, val icon: ImageVector? = null,
val title: String, val title: String,
val subtitle: String, val subtitle: String,
val checked: Boolean = true, val onClick: (() -> Unit)? = null,
val checkboxEnabled: Boolean = true, val dragEnabled: Boolean = true,
val endActions: List<EndAction> = emptyList(),
) )
@Composable @Composable
@@ -69,6 +85,7 @@ class ReorderableList(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(IntrinsicSize.Min)
.padding( .padding(
horizontal = UiSpacing.CardTitle, horizontal = UiSpacing.CardTitle,
vertical = UiSpacing.FieldLabelBottom vertical = UiSpacing.FieldLabelBottom
@@ -77,6 +94,16 @@ class ReorderableList(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Row( Row(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.then(
if (item.onClick != null) {
Modifier.clickable(onClick = item.onClick)
} else {
Modifier
}
),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small) horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small)
) { ) {
@@ -101,33 +128,34 @@ class ReorderableList(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small) horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small)
) { ) {
if (showCheckbox) Checkbox( item.endActions.forEach { action ->
state = if (item.checked) ToggleableState.On else ToggleableState.Off, EndActionView(
onClick = { action = action,
onCheckboxChange?.invoke(item.id, !item.checked) fallbackContentDescription = item.title,
},
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,
) )
} }
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, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Row( Row(
modifier = Modifier.then(
if (item.onClick != null) {
Modifier.clickable(onClick = item.onClick)
} else {
Modifier
}
),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (showCheckbox) { item.endActions.forEach { action ->
Checkbox( EndActionView(
state = if (item.checked) ToggleableState.On else ToggleableState.Off, action = action,
onClick = { fallbackContentDescription = item.title,
onCheckboxChange?.invoke(item.id, !item.checked)
},
enabled = item.checkboxEnabled
) )
Spacer(Modifier.padding(horizontal = 4.dp)) Spacer(Modifier.padding(horizontal = 4.dp))
} }
IconButton( if (item.dragEnabled) {
onClick = {}, IconButton(
modifier = Modifier onClick = {},
.draggableHandle( modifier = Modifier
onDragStarted = { .draggableHandle(
haptics.longPress() onDragStarted = {
}, haptics.longPress()
onDragStopped = { },
haptics.confirm() onDragStopped = {
}, haptics.confirm()
), },
) { ),
Icon( ) {
Icons.Rounded.DragIndicator, Icon(
contentDescription = "拖动排序", Icons.Rounded.DragIndicator,
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary, contentDescription = "拖动排序",
) tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
} }
} }
Spacer(Modifier.padding(UiSpacing.ContentVertical)) 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,
)
}
}
}