feat: multi scrcpy options profiles
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)()
|
)()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package io.github.miuzarte.scrcpyforandroid.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Parcel
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions.Companion.GLOBAL_PROFILE_ID
|
||||||
|
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions.Companion.GLOBAL_PROFILE_NAME
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class ScrcpyProfiles(context: Context) : Settings(context, "ScrcpyProfiles") {
|
||||||
|
companion object {
|
||||||
|
private val PROFILES_JSON = Pair(
|
||||||
|
stringPreferencesKey("profiles_json"),
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Profile(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val bundle: ScrcpyOptions.Bundle,
|
||||||
|
val isBuiltinGlobal: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val profiles: List<Profile>,
|
||||||
|
) {
|
||||||
|
val globalProfile: Profile get() = profiles.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val profilesJson by setting(PROFILES_JSON)
|
||||||
|
|
||||||
|
val state: StateFlow<State> = createBundleState(::stateFromPreferences)
|
||||||
|
|
||||||
|
private fun stateFromPreferences(preferences: androidx.datastore.preferences.core.Preferences): State {
|
||||||
|
val raw = preferences.read(PROFILES_JSON)
|
||||||
|
return normalizeState(
|
||||||
|
runCatching { decodeState(raw) }.getOrNull() ?: State(emptyList())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun loadState() = loadBundle(::stateFromPreferences)
|
||||||
|
|
||||||
|
suspend fun saveState(newState: State) {
|
||||||
|
profilesJson.set(encodeState(normalizeState(newState)))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getProfiles(): List<Profile> = loadState().profiles
|
||||||
|
|
||||||
|
suspend fun getProfile(id: String): Profile? = loadState().profiles.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
suspend fun getProfileOrGlobal(id: String?): Profile {
|
||||||
|
val state = loadState()
|
||||||
|
return state.profiles.firstOrNull { it.id == id } ?: state.globalProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getBundleOrGlobal(id: String?): ScrcpyOptions.Bundle =
|
||||||
|
getProfileOrGlobal(id).bundle
|
||||||
|
|
||||||
|
suspend fun updateBundle(id: String, bundle: ScrcpyOptions.Bundle): State {
|
||||||
|
val current = loadState()
|
||||||
|
val profiles = current.profiles.map {
|
||||||
|
if (it.id == id) it.copy(bundle = bundle)
|
||||||
|
else it
|
||||||
|
}
|
||||||
|
val next = normalizeState(current.copy(profiles = profiles))
|
||||||
|
saveState(next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createProfile(
|
||||||
|
requestedName: String,
|
||||||
|
bundle: ScrcpyOptions.Bundle = ScrcpyOptions.defaultBundle(),
|
||||||
|
): Profile {
|
||||||
|
val current = loadState()
|
||||||
|
val profile = Profile(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
name = ensureUniqueName(current, requestedName),
|
||||||
|
bundle = bundle,
|
||||||
|
)
|
||||||
|
val next = normalizeState(current.copy(profiles = current.profiles + profile))
|
||||||
|
saveState(next)
|
||||||
|
return next.profiles.first { it.id == profile.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun duplicateProfile(
|
||||||
|
sourceId: String,
|
||||||
|
requestedName: String = "",
|
||||||
|
): Profile? {
|
||||||
|
val current = loadState()
|
||||||
|
val source = current.profiles.firstOrNull { it.id == sourceId } ?: return null
|
||||||
|
return createProfile(
|
||||||
|
requestedName = requestedName.ifBlank { source.name },
|
||||||
|
bundle = source.bundle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun renameProfile(id: String, requestedName: String): Profile? {
|
||||||
|
if (id == GLOBAL_PROFILE_ID) return null
|
||||||
|
val current = loadState()
|
||||||
|
val existing = current.profiles.firstOrNull { it.id == id } ?: return null
|
||||||
|
val updated = existing.copy(
|
||||||
|
name = ensureUniqueName(current, requestedName, excludeId = id),
|
||||||
|
)
|
||||||
|
val next = normalizeState(
|
||||||
|
current.copy(
|
||||||
|
profiles = current.profiles.map {
|
||||||
|
if (it.id == id) updated else it
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
saveState(next)
|
||||||
|
return next.profiles.firstOrNull { it.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteProfile(id: String): Boolean {
|
||||||
|
if (id == GLOBAL_PROFILE_ID) return false
|
||||||
|
val current = loadState()
|
||||||
|
if (current.profiles.none { it.id == id }) return false
|
||||||
|
val next = normalizeState(
|
||||||
|
current.copy(
|
||||||
|
profiles = current.profiles.filterNot { it.id == id }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
saveState(next)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun moveProfile(fromIndex: Int, toIndex: Int): State {
|
||||||
|
val current = loadState()
|
||||||
|
if (fromIndex !in current.profiles.indices || toIndex !in current.profiles.indices) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
if (fromIndex == toIndex || fromIndex == 0 || toIndex == 0) {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
val mutable = current.profiles.toMutableList()
|
||||||
|
val item = mutable.removeAt(fromIndex)
|
||||||
|
val target = if (toIndex > fromIndex) toIndex - 1 else toIndex
|
||||||
|
mutable.add(target, item)
|
||||||
|
val next = normalizeState(current.copy(profiles = mutable))
|
||||||
|
saveState(next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ensureUniqueName(
|
||||||
|
state: State,
|
||||||
|
requestedName: String,
|
||||||
|
excludeId: String? = null,
|
||||||
|
): String {
|
||||||
|
val baseName = requestedName.trim().ifBlank { "配置" }
|
||||||
|
if (baseName == GLOBAL_PROFILE_NAME) return ensureUniqueName(state, "配置", excludeId)
|
||||||
|
val existingNames = state.profiles
|
||||||
|
.filterNot { it.id == excludeId }
|
||||||
|
.map { it.name }
|
||||||
|
.toSet()
|
||||||
|
if (baseName !in existingNames) return baseName
|
||||||
|
var suffix = 1
|
||||||
|
while (true) {
|
||||||
|
val candidate = "$baseName ($suffix)"
|
||||||
|
if (candidate !in existingNames) return candidate
|
||||||
|
suffix++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeState(state: State): State {
|
||||||
|
val global = state.profiles.firstOrNull { it.id == GLOBAL_PROFILE_ID }
|
||||||
|
?.copy(name = GLOBAL_PROFILE_NAME, isBuiltinGlobal = true)
|
||||||
|
?: Profile(
|
||||||
|
id = GLOBAL_PROFILE_ID,
|
||||||
|
name = GLOBAL_PROFILE_NAME,
|
||||||
|
bundle = ScrcpyOptions.defaultBundle(),
|
||||||
|
isBuiltinGlobal = true,
|
||||||
|
)
|
||||||
|
val usedNames = linkedSetOf(global.name)
|
||||||
|
val others = buildList {
|
||||||
|
state.profiles
|
||||||
|
.asSequence()
|
||||||
|
.filterNot { it.id == GLOBAL_PROFILE_ID }
|
||||||
|
.forEach { profile ->
|
||||||
|
val baseName = profile.name.trim().ifBlank { "配置" }
|
||||||
|
.takeUnless { it == GLOBAL_PROFILE_NAME }
|
||||||
|
?: "配置"
|
||||||
|
var normalizedName = baseName
|
||||||
|
if (normalizedName in usedNames) {
|
||||||
|
var suffix = 1
|
||||||
|
while (true) {
|
||||||
|
val candidate = "$baseName ($suffix)"
|
||||||
|
if (candidate !in usedNames) {
|
||||||
|
normalizedName = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
suffix++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usedNames += normalizedName
|
||||||
|
add(profile.copy(name = normalizedName, isBuiltinGlobal = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return State(listOf(global) + others)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeState(state: State): String {
|
||||||
|
val array = JSONArray()
|
||||||
|
for (profile in state.profiles) {
|
||||||
|
array.put(
|
||||||
|
JSONObject()
|
||||||
|
.put("id", profile.id)
|
||||||
|
.put("name", profile.name)
|
||||||
|
.put("bundle", encodeBundle(profile.bundle))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return array.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeState(raw: String): State {
|
||||||
|
if (raw.isBlank()) return State(emptyList())
|
||||||
|
val array = JSONArray(raw)
|
||||||
|
val profiles = buildList {
|
||||||
|
for (index in 0 until array.length()) {
|
||||||
|
val item = array.optJSONObject(index) ?: continue
|
||||||
|
val id = item.optString("id").trim()
|
||||||
|
val name = item.optString("name").trim()
|
||||||
|
val bundleRaw = item.optString("bundle")
|
||||||
|
if (id.isBlank()) continue
|
||||||
|
add(
|
||||||
|
Profile(
|
||||||
|
id = id,
|
||||||
|
name = name.ifBlank { "配置" },
|
||||||
|
bundle = decodeBundle(bundleRaw),
|
||||||
|
isBuiltinGlobal = id == GLOBAL_PROFILE_ID,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return State(profiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeBundle(bundle: ScrcpyOptions.Bundle): String {
|
||||||
|
val parcel = Parcel.obtain()
|
||||||
|
return try {
|
||||||
|
parcel.writeParcelable(bundle, 0)
|
||||||
|
Base64.encodeToString(parcel.marshall(), Base64.NO_WRAP)
|
||||||
|
} finally {
|
||||||
|
parcel.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeBundle(raw: String): ScrcpyOptions.Bundle {
|
||||||
|
if (raw.isBlank()) return ScrcpyOptions.defaultBundle()
|
||||||
|
val bytes = runCatching { Base64.decode(raw, Base64.DEFAULT) }.getOrNull()
|
||||||
|
?: return ScrcpyOptions.defaultBundle()
|
||||||
|
val parcel = Parcel.obtain()
|
||||||
|
return try {
|
||||||
|
parcel.unmarshall(bytes, 0, bytes.size)
|
||||||
|
parcel.setDataPosition(0)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
parcel.readParcelable(
|
||||||
|
ScrcpyOptions.Bundle::class.java.classLoader,
|
||||||
|
ScrcpyOptions.Bundle::class.java,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
parcel.readParcelable(ScrcpyOptions.Bundle::class.java.classLoader)
|
||||||
|
} ?: ScrcpyOptions.defaultBundle()
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
ScrcpyOptions.defaultBundle()
|
||||||
|
} finally {
|
||||||
|
parcel.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,5 +13,6 @@ object Storage {
|
|||||||
val appSettings: AppSettings by lazy { AppSettings(appContext) }
|
val 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) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user