refactor: improve Snackbar functionality

- 增强 Codec 枚举,增加 type 和 lossless 属性以改进编解码器管理
- 引入 SnackbarController
- 改进 Settings.kt 中的设置处理,优化状态管理
- 增强 DeviceWidgets,支持编辑设备详情并改进 UI
- 重构 ReorderableList 和 VirtualButtons,优化布局和间距
- 重构 Scrpcy 编码器获取逻辑
This commit is contained in:
Miuzarte
2026-04-10 00:12:35 +08:00
parent 95dc24e676
commit 6c1564f461
27 changed files with 2715 additions and 2546 deletions

View File

@@ -8,6 +8,7 @@ import android.view.Surface
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.ArrayDeque
@@ -133,8 +134,8 @@ class NativeCoreFacade private constructor() {
videoFpsListeners.remove(listener)
}
suspend fun scrcpyBackOrScreenOn(action: Int = 0) {
session?.pressBackOrScreenOn(action)
suspend fun scrcpyBackOrTurnScreenOn(action: Int = 0) {
session?.pressBackOrTurnScreenOn(action)
}
/**
@@ -200,6 +201,7 @@ class NativeCoreFacade private constructor() {
@Volatile
private var instance: NativeCoreFacade? = null
// TODO ???
fun get(context: Context): NativeCoreFacade {
return instance ?: synchronized(this) {
instance ?: NativeCoreFacade().also { instance = it }
@@ -252,16 +254,19 @@ class NativeCoreFacade private constructor() {
decoder = null
Log.i(
TAG,
"createOrReplaceDecoder(): codec=${session.codecName} size=${session.width}x${session.height} persistent=true"
"createOrReplaceDecoder(): " +
"codec=${session.codec?.string ?: "null"}, " +
"size=${session.width}x${session.height}, " +
"persistent=true"
)
val newDecoder = AnnexBDecoder(
width = session.width,
height = session.height,
outputSurface = surface,
mimeType = when (session.codecName.lowercase()) {
"h264" -> "video/avc"
"h265" -> "video/hevc"
"av1" -> "video/av01"
mimeType = when (session.codec) {
Codec.H264 -> "video/avc"
Codec.H265 -> "video/hevc"
Codec.AV1 -> "video/av01"
else -> "video/avc"
},
onOutputSizeChanged = { width, height ->
@@ -334,38 +339,6 @@ class NativeCoreFacade private constructor() {
}
}
/**
* Attach a single consumer to the session manager to deliver incoming video packets
* to all active decoders.
*
* - Called when a session is active and at least one decoder exists. Packets are
* cached into [bootstrapPackets] to allow late-attaching decoders to catch up.
* - Kept only for documentation parity with the old multi-decoder design.
*/
@Deprecated("TODO: Determine if this is really unnecessary")
private suspend fun ensureVideoConsumerAttached(sessionMgr: Scrcpy.Session) {
sessionMgr.attachVideoConsumer { packet ->
cacheBootstrapPacket(packet)
packetCount += 1
if (packetCount == 1L || packetCount % 120L == 0L) {
Log.i(
TAG,
"videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}"
)
}
val currentDecoder = decoder ?: return@attachVideoConsumer
if (activeSurfaceId == null) return@attachVideoConsumer
runCatching {
currentDecoder.feedAnnexB(
packet.data,
packet.ptsUs,
packet.isKeyFrame,
packet.isConfig
)
}
}
}
private fun releaseAllDecoders() {
runCatching { decoder?.release() }
decoder = null

View File

@@ -58,6 +58,6 @@ fun Preset<Int>.indexOfOrNearest(raw: String): Int {
object ScrcpyPresets {
val MaxSize = Preset(listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840)) // px
val MaxFPS = Preset(listOf(0, 24, 30, 45, 60, 90, 120)) // fps
val AudioBitRate = Preset(listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512)) // Kbps
val AudioBitRate = Preset(listOf(0, 32, 64, 96, 128, 160, 192, 256, 320, 384, 512)) // Kbps
val CameraFps = Preset(listOf(0, 10, 15, 24, 30, 60, 120, 240, 480, 960)) // fps
}

View File

@@ -0,0 +1,16 @@
package io.github.miuzarte.scrcpyforandroid.constants
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
object ThemeModes {
data class Option(
val label: String,
val mode: ColorSchemeMode,
)
val baseOptions = listOf(
Option("跟随系统", ColorSchemeMode.System),
Option("浅色", ColorSchemeMode.Light),
Option("深色", ColorSchemeMode.Dark),
)
}

View File

@@ -16,8 +16,10 @@ object UiSpacing {
val SectionTitleTop = 12.dp
val SectionTitleBottom = 6.dp
val FieldLabelBottom = 4.dp
val CardContent = 12.dp
val Content = 12.dp
val ContentVertical = 12.dp
val ContentHorizontal = 12.dp
val CardTitle = 16.dp
val BottomContent = 64.dp
val BottomSheetBottom = 16.dp
val PageBottom = 64.dp
val SheetBottom = 16.dp
}

View File

@@ -38,7 +38,6 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
host: String? = null,
port: Int? = null,
name: String? = null,
online: Boolean? = null,
newPort: Int? = null,
updateNameOnlyWhenEmpty: Boolean = false,
): DeviceShortcuts {
@@ -48,6 +47,7 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
if (idx < 0) return this
val old = devices[idx]
val updateById = id != null
// 确定最终的属性值
val finalName = when {
@@ -55,23 +55,24 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name
else -> name
}
val finalPort = newPort ?: old.port
val finalOnline = online ?: old.online
val finalHost = if (updateById) host ?: old.host else old.host
val finalPort = if (updateById) port ?: old.port else newPort ?: old.port
// 若无任何变化,返回原实例
if (finalName == old.name && finalPort == old.port && finalOnline == old.online)
if (finalName == old.name && finalHost == old.host && finalPort == old.port)
return this
val newList = devices.toMutableList().apply {
this[idx] = DeviceShortcut(
name = finalName,
host = old.host,
host = finalHost,
port = finalPort,
online = finalOnline
)
}
return DeviceShortcuts(
if (newPort != null && newPort != old.port)
if ((updateById && (finalHost != old.host || finalPort != old.port))
|| (newPort != null && newPort != old.port)
)
newList.distinctBy { it.id }
else newList
)
@@ -118,7 +119,6 @@ data class DeviceShortcut(
val name: String = "",
val host: String,
val port: Int = Defaults.ADB_PORT,
val online: Boolean = false,
) {
val id: String get() = "$host:$port"

View File

@@ -9,6 +9,7 @@ import android.media.AudioTrack
import android.media.MediaCodec
import android.media.MediaFormat
import android.util.Log
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -42,9 +43,9 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
}"
)
when (codecId) {
AUDIO_CODEC_OPUS -> prepareOpus(data)
AUDIO_CODEC_AAC -> prepareAac(data)
AUDIO_CODEC_FLAC -> prepareFlac(data)
Codec.OPUS.id -> prepareOpus(data)
Codec.AAC.id -> prepareAac(data)
Codec.FLAC.id -> prepareFlac(data)
// RAW has no config packet
}
return
@@ -55,7 +56,7 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
Log.i(TAG, "feedPacket(): packets=$packetCount prepared=$prepared size=${data.size}")
}
if (codecId == AUDIO_CODEC_RAW) {
if (codecId == Codec.RAW.id) {
ensureRawAudioTrack()?.write(data, 0, data.size, AudioTrack.WRITE_NON_BLOCKING)
return
}
@@ -212,10 +213,6 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
companion object {
private const val TAG = "ScrcpyAudioPlayer"
const val AUDIO_CODEC_OPUS = 0x6f707573
const val AUDIO_CODEC_AAC = 0x00616163
const val AUDIO_CODEC_FLAC = 0x666c6163
const val AUDIO_CODEC_RAW = 0x00726177
private const val SAMPLE_RATE = 48000
private const val CHANNELS = 2
private const val CODEC_TIMEOUT_US = 10_000L

View File

@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.runtime.Composable
@@ -34,16 +33,17 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.services.EventLogger
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTileList
import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard
import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard
import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard
@@ -52,7 +52,9 @@ import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -67,7 +69,6 @@ import top.yukonga.miuix.kmp.basic.ListPopupDefaults
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.basic.TopAppBar
@@ -88,7 +89,7 @@ fun DeviceTabScreen(
nativeCore: NativeCoreFacade,
adbService: NativeAdbService,
scrcpy: Scrcpy,
snack: SnackbarHostState,
snackbar: SnackbarController,
scrollBehavior: ScrollBehavior,
onOpenVirtualButtonOrder: () -> Unit,
onSessionStartedChange: (Boolean) -> Unit,
@@ -139,7 +140,7 @@ fun DeviceTabScreen(
nativeCore = nativeCore,
adbService = adbService,
scrcpy = scrcpy,
snack = snack,
snackbar = snackbar,
scrollBehavior = scrollBehavior,
onSessionStartedChange = onSessionStartedChange,
onOpenAdvancedPage = onOpenAdvancedPage,
@@ -154,13 +155,14 @@ fun DeviceTabPage(
nativeCore: NativeCoreFacade,
adbService: NativeAdbService,
scrcpy: Scrcpy,
snack: SnackbarHostState,
snackbar: SnackbarController,
scrollBehavior: ScrollBehavior,
onSessionStartedChange: (Boolean) -> Unit,
onOpenAdvancedPage: () -> Unit,
onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit,
) {
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) }
val haptics = rememberAppHaptics()
val asBundleShared by appSettings.bundleState.collectAsState()
@@ -180,7 +182,7 @@ fun DeviceTabPage(
}
DisposableEffect(Unit) {
onDispose {
scope.launch {
taskScope.launch {
appSettings.saveBundle(asBundleLatest)
}
}
@@ -203,7 +205,7 @@ fun DeviceTabPage(
}
DisposableEffect(Unit) {
onDispose {
scope.launch {
taskScope.launch {
quickDevices.saveBundle(qdBundleLatest)
}
}
@@ -225,7 +227,7 @@ fun DeviceTabPage(
var sessionInfoWidth by rememberSaveable { mutableIntStateOf(0) }
var sessionInfoHeight by rememberSaveable { mutableIntStateOf(0) }
var sessionInfoDeviceName by rememberSaveable { mutableStateOf("") }
var sessionInfoCodec by rememberSaveable { mutableStateOf("") }
var sessionInfoCodec by rememberSaveable { mutableStateOf<Codec?>(null) }
var sessionInfoControlEnabled by rememberSaveable { mutableStateOf(false) }
var sessionInfo by remember {
mutableStateOf<Scrcpy.Session.SessionInfo?>(null)
@@ -243,7 +245,7 @@ fun DeviceTabPage(
height = sessionInfoHeight,
deviceName = sessionInfoDeviceName,
codecId = 0,
codecName = sessionInfoCodec,
codec = sessionInfoCodec,
audioCodecId = 0,
controlEnabled = sessionInfoControlEnabled,
host = currentTargetHost,
@@ -254,7 +256,7 @@ fun DeviceTabPage(
}
}
var previewControlsVisible by rememberSaveable { mutableStateOf(false) }
var editingDevice by rememberSaveable { mutableStateOf<DeviceShortcut?>(null) }
var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) }
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
var adbConnecting by rememberSaveable { mutableStateOf(false) }
@@ -288,6 +290,9 @@ fun DeviceTabPage(
qdBundle = qdBundle.copy(quickDevicesList = serialized)
}
}
val editingDevice = remember(savedShortcuts, editingDeviceId) {
editingDeviceId?.let(savedShortcuts::get)
}
/**
* Disconnect the current ADB connection and stop any running scrcpy session.
@@ -330,14 +335,12 @@ fun DeviceTabPage(
clearQuickOnlineForTarget?.let { target ->
if (target.host.isNotBlank())
savedShortcuts = savedShortcuts.update(
host = target.host, port = target.port, online = false
host = target.host, port = target.port
)
}
logMessage?.let { logEvent(it) }
if (!showSnackMessage.isNullOrBlank()) {
scope.launch {
snack.showSnackbar(showSnackMessage)
}
snackbar.show(showSnackMessage)
}
}
@@ -447,7 +450,7 @@ fun DeviceTabPage(
fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) {
// For non-adb actions (start/stop/pair/list refresh...).
if (busy) return
scope.launch(kotlinx.coroutines.Dispatchers.IO) {
taskScope.launch {
busy = true
try {
block()
@@ -456,9 +459,7 @@ fun DeviceTabPage(
} catch (e: IllegalArgumentException) {
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
logEvent("$label 参数错误: $detail", Log.WARN, e)
scope.launch {
snack.showSnackbar("$label 参数错误: $detail")
}
snackbar.show("$label 参数错误: $detail")
} catch (e: Exception) {
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
logEvent("$label 失败: $detail", Log.ERROR, e)
@@ -489,7 +490,7 @@ fun DeviceTabPage(
) {
// For manual adb operations from user actions.
if (adbConnecting) return
scope.launch {
taskScope.launch {
onStarted?.invoke()
adbConnecting = true
try {
@@ -499,9 +500,7 @@ fun DeviceTabPage(
} catch (e: IllegalArgumentException) {
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
logEvent("$label 参数错误: $detail", Log.WARN, e)
scope.launch {
snack.showSnackbar("$label 参数错误: $detail")
}
snackbar.show("$label 参数错误: $detail")
} catch (e: Exception) {
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
logEvent("$label 失败: $detail", Log.ERROR, e)
@@ -521,57 +520,6 @@ fun DeviceTabPage(
}
}
suspend fun refreshEncoderLists() {
if (!adbConnected) return
runCatching {
scrcpy.refreshEncoders()
}.onSuccess {
// Validate current selections
if (soBundleShared.videoEncoder.isNotBlank() && soBundleShared.videoEncoder !in scrcpy.videoEncoders) {
scrcpyOptions.updateBundle { it.copy(videoEncoder = "") }
}
if (soBundleShared.audioEncoder.isNotBlank() && soBundleShared.audioEncoder !in scrcpy.audioEncoders) {
scrcpyOptions.updateBundle { it.copy(audioEncoder = "") }
}
logEvent("编码器列表已刷新: video=${scrcpy.videoEncoders.size} audio=${scrcpy.audioEncoders.size}")
if (scrcpy.videoEncoders.isEmpty() && scrcpy.audioEncoders.isEmpty()) {
logEvent(
"提示: 编码器为空,请检查 server 路径/版本与设备系统日志",
Log.WARN
)
}
}.onFailure { e ->
logEvent(
"读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}",
Log.ERROR,
e
)
}
}
suspend fun refreshCameraSizeLists() {
if (!adbConnected) return
runCatching {
scrcpy.refreshCameraSizes()
}.onSuccess {
// Validate current selection
if (
soBundleShared.cameraSize.isNotBlank()
&& soBundleShared.cameraSize != "custom"
&& soBundleShared.cameraSize !in scrcpy.cameraSizes
) {
scrcpyOptions.updateBundle { it.copy(cameraSize = "") }
}
logEvent("camera sizes 已刷新: count=${scrcpy.cameraSizes.size}")
}.onFailure { e ->
logEvent(
"读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}",
Log.ERROR,
e
)
}
}
suspend fun handleAdbConnected(host: String, port: Int) {
currentTargetHost = host
currentTargetPort = port
@@ -586,8 +534,7 @@ fun DeviceTabPage(
connectedDeviceLabel = info.model
applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease)
savedShortcuts = savedShortcuts.update(
host = host,
port = port,
host = host, port = port,
name = fullLabel,
updateNameOnlyWhenEmpty = true
)
@@ -603,26 +550,7 @@ fun DeviceTabPage(
"android=${info.androidRelease.ifBlank { "unknown" }}, " +
"sdk=${info.sdkInt}"
)
scope.launch {
snack.showSnackbar("ADB 已连接")
}
refreshEncoderLists()
refreshCameraSizeLists()
}
LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort, savedShortcuts.size) {
val activeId = if (adbConnected && currentTargetHost.isNotBlank()) {
"$currentTargetHost:$currentTargetPort"
} else {
null
}
for (index in savedShortcuts.indices) {
val item = savedShortcuts[index]
val shouldOnline = activeId != null && item.id == activeId
if (item.online != shouldOnline) {
savedShortcuts = savedShortcuts.update(id = item.id, online = shouldOnline)
}
}
snackbar.show("ADB 已连接")
}
LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort) {
@@ -643,16 +571,12 @@ fun DeviceTabPage(
adbConnected = true
statusLine = "$host:$port"
logEvent("ADB 自动重连成功: $host:$port")
scope.launch {
snack.showSnackbar("ADB 自动重连成功")
}
snackbar.show("ADB 自动重连成功")
} catch (e: Exception) {
disconnectAdbConnection()
statusLine = "ADB 连接断开"
logEvent("ADB 自动重连失败: $e", Log.ERROR)
scope.launch {
snack.showSnackbar("ADB 自动重连失败")
}
snackbar.show("ADB 自动重连失败")
break
}
}
@@ -690,9 +614,7 @@ fun DeviceTabPage(
runAutoAdbConnect(target.host, target.port)
adbConnected = true
savedShortcuts = savedShortcuts.update(
host = target.host,
port = target.port,
online = true
host = target.host, port = target.port,
)
handleAdbConnected(target.host, target.port)
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
@@ -732,10 +654,8 @@ fun DeviceTabPage(
}?.port
if (portToReplace != null) {
savedShortcuts = savedShortcuts.update(
host = discoveredHost,
port = portToReplace,
host = discoveredHost, port = portToReplace,
newPort = discoveredPort,
online = false
)
logEvent(
"mDNS 发现新端口,已更新快速设备: $discoveredHost:$portToReplace -> $discoveredHost:$discoveredPort"
@@ -751,9 +671,7 @@ fun DeviceTabPage(
runAutoAdbConnect(discoveredHost, discoveredPort)
adbConnected = true
savedShortcuts = savedShortcuts.update(
host = discoveredHost,
port = discoveredPort,
online = true
host = discoveredHost, port = discoveredPort,
)
handleAdbConnected(discoveredHost, discoveredPort)
logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort")
@@ -779,13 +697,13 @@ fun DeviceTabPage(
sessionInfoWidth = sessionInfo?.width ?: 0
sessionInfoHeight = sessionInfo?.height ?: 0
sessionInfoDeviceName = sessionInfo?.deviceName.orEmpty()
sessionInfoCodec = sessionInfo?.codecName.orEmpty()
sessionInfoCodec = sessionInfo?.codec
sessionInfoControlEnabled = sessionInfo?.controlEnabled == true
} else {
sessionInfoWidth = 0
sessionInfoHeight = 0
sessionInfoDeviceName = ""
sessionInfoCodec = ""
sessionInfoCodec = null
sessionInfoControlEnabled = false
}
onSessionStartedChange(sessionInfo != null)
@@ -799,28 +717,6 @@ fun DeviceTabPage(
}
}
if (editingDevice != null) {
DeviceEditorScreen(
contentPadding = contentPadding,
device = editingDevice!!,
onSave = { updated ->
savedShortcuts = savedShortcuts.update(
id = editingDevice!!.id,
host = updated.host,
port = updated.port,
online = updated.online,
)
editingDevice = null
},
onDelete = {
savedShortcuts = savedShortcuts.remove(id = editingDevice!!.id)
editingDevice = null
},
onBack = { editingDevice = null },
)
return
}
val devicePreviewCardHeightDp = asBundle.devicePreviewCardHeightDp
val previewVirtualButtonShowText = asBundle.previewVirtualButtonShowText
@@ -843,27 +739,38 @@ fun DeviceTabPage(
)
}
itemsIndexed(savedShortcuts, key = { _, device -> device.id }) { _, device ->
val host = device.host
val port = device.port
val isConnectedTarget = adbConnected
&& currentTarget?.host == host
&& currentTarget.port == port
DeviceTile(
device = device,
actionText = if (!isConnectedTarget) "连接" else "断开",
item {
DeviceTileList(
devices = savedShortcuts,
isConnected = { device ->
adbConnected
&& currentTarget?.host == device.host
&& currentTarget.port == device.port
},
actionEnabled = !busy && !adbConnecting,
actionInProgress = adbConnecting && activeDeviceActionId == device.id,
onLongPress = { editingDevice = device },
onContentClick = {
scope.launch {
snack.showSnackbar("长按可编辑设备")
actionInProgress = { device ->
adbConnecting && activeDeviceActionId == device.id
},
editingDeviceId = editingDeviceId,
onClick = {},
onLongClick = { device ->
val connected = adbConnected
&& currentTarget?.host == device.host
&& currentTarget.port == device.port
if (connected) {
snackbar.show("无法修改已连接的设备")
} else {
editingDeviceId = device.id
}
},
onAction = {
onAction = { device ->
haptics.contextClick()
if (!isConnectedTarget) {
val host = device.host
val port = device.port
val connected = adbConnected
&& currentTarget?.host == host
&& currentTarget.port == port
if (!connected) {
runAdbConnect(
"连接 ADB",
onStarted = { activeDeviceActionId = device.id },
@@ -873,17 +780,15 @@ fun DeviceTabPage(
try {
connectWithTimeout(host, port)
adbConnected = true
isQuickConnected = false // 标记为快速设备连接
isQuickConnected = false
savedShortcuts = savedShortcuts.update(
host = host, port = port, online = true
host = host, port = port,
)
handleAdbConnected(host, port)
} catch (e: Exception) {
statusLine = "ADB 连接失败"
logEvent("ADB 连接失败: $e", Log.ERROR)
scope.launch {
snack.showSnackbar("ADB 连接失败")
}
snackbar.show("ADB 连接失败")
}
}
} else {
@@ -902,105 +807,118 @@ fun DeviceTabPage(
}
}
},
onEditorSave = { device, updated ->
savedShortcuts = savedShortcuts.update(
id = device.id,
name = updated.name,
host = updated.host,
port = updated.port,
)
editingDeviceId = null
},
onEditorDelete = { device ->
savedShortcuts = savedShortcuts.remove(id = device.id)
editingDeviceId = null
},
onEditorCancel = { editingDeviceId = null },
)
}
if (!adbConnected) item {
if (!adbConnected) {
// "快速连接"
QuickConnectCard(
input = qdBundle.quickConnectInput,
onValueChange = {
qdBundle = qdBundle.copy(quickConnectInput = it)
},
enabled = !adbConnecting,
onAddDevice = {
val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput)
?: return@QuickConnectCard
savedShortcuts = savedShortcuts.upsert(
DeviceShortcut(host = target.host, port = target.port)
)
Log.d(
"SavedShortcuts",
"size: ${savedShortcuts.size}, list: ${qdBundle.quickDevicesList}"
)
scope.launch {
snack.showSnackbar("已添加设备: ${target.host}:${target.port}")
}
},
onConnect = {
val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput)
?: return@QuickConnectCard
runAdbConnect(
"连接 ADB",
onStarted = { activeDeviceActionId = target.toString() },
onFinished = { activeDeviceActionId = null },
) {
disconnectCurrentTargetBeforeConnecting(target.host, target.port)
try {
connectWithTimeout(target.host, target.port)
adbConnected = true
isQuickConnected = true // 标记为快速连接
savedShortcuts = savedShortcuts.update(
host = target.host,
port = target.port,
online = true
)
handleAdbConnected(target.host, target.port)
} catch (e: Exception) {
statusLine = "ADB 连接失败"
logEvent("ADB 连接失败: $e", Log.ERROR)
scope.launch {
snack.showSnackbar("ADB 连接失败")
item {
QuickConnectCard(
input = qdBundle.quickConnectInput,
onValueChange = {
qdBundle = qdBundle.copy(quickConnectInput = it)
},
enabled = !adbConnecting,
onAddDevice = {
val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput)
?: return@QuickConnectCard
savedShortcuts = savedShortcuts.upsert(
DeviceShortcut(host = target.host, port = target.port)
)
Log.d(
"SavedShortcuts",
"size: ${savedShortcuts.size}, list: ${qdBundle.quickDevicesList}"
)
snackbar.show("已添加设备: ${target.host}:${target.port}")
},
onConnect = {
val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput)
?: return@QuickConnectCard
runAdbConnect(
"连接 ADB",
onStarted = { activeDeviceActionId = target.toString() },
onFinished = { activeDeviceActionId = null },
) {
disconnectCurrentTargetBeforeConnecting(target.host, target.port)
try {
connectWithTimeout(target.host, target.port)
adbConnected = true
isQuickConnected = true // 标记为快速连接
savedShortcuts = savedShortcuts.update(
host = target.host, port = target.port,
)
handleAdbConnected(target.host, target.port)
} catch (e: Exception) {
statusLine = "ADB 连接失败"
logEvent("ADB 连接失败: $e", Log.ERROR)
snackbar.show("ADB 连接失败")
}
}
}
},
)
SectionSmallTitle("无线配对")
// "使用配对码配对设备"
PairingCard(
busy = busy,
autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
onDiscoverTarget = {
adbService.discoverPairingService(
includeLanDevices = adbMdnsLanDiscovery,
)
},
onPair = { host, port, code ->
runBusy("执行配对") {
val resolvedHost = host.trim()
val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy
val resolvedCode = code.trim()
val ok = adbService.pair(
resolvedHost,
resolvedPort,
resolvedCode,
},
)
}
item {
SectionSmallTitle("无线配对", showLeadingSpacer = false)
// "使用配对码配对设备"
PairingCard(
busy = busy,
autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
onDiscoverTarget = {
adbService.discoverPairingService(
includeLanDevices = adbMdnsLanDiscovery,
)
logEvent(
if (ok) "配对成功" else "配对失败",
if (ok) Log.INFO else Log.ERROR
)
scope.launch {
snack.showSnackbar(if (ok) "配对成功" else "配对失败")
},
onPair = { host, port, code ->
runBusy("执行配对") {
val resolvedHost = host.trim()
val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy
val resolvedCode = code.trim()
val ok = adbService.pair(
resolvedHost,
resolvedPort,
resolvedCode,
)
logEvent(
if (ok) "配对成功" else "配对失败",
if (ok) Log.INFO else Log.ERROR
)
snackbar.show(if (ok) "配对成功" else "配对失败")
}
}
},
)
},
)
}
}
if (adbConnected) {
item {
SectionSmallTitle("Scrcpy", showLeadingSpacer = false)
ConfigPanel(
busy = busy,
snack = snack,
snackbar = snackbar,
audioForwardingSupported = audioForwardingSupported,
cameraMirroringSupported = cameraMirroringSupported,
adbConnecting = adbConnecting,
isQuickConnected = isQuickConnected,
onOpenAdvanced = onOpenAdvancedPage,
onStartStopHaptic = { haptics.contextClick() },
onStart = {
runBusy("启动 scrcpy") {
val options = scrcpyOptions.toClientOptions(soBundleShared)
val options = scrcpyOptions.toClientOptions(soBundleShared).fix()
val session = scrcpy.start(options)
sessionInfo = session.copy(
host = currentTargetHost,
@@ -1010,8 +928,8 @@ fun DeviceTabPage(
@SuppressLint("DefaultLocale")
val videoDetail =
if (!options.video) "off"
else if (videoBitRate <= 0) "${session.codecName} ${session.width}x${session.height} @default"
else "${session.codecName} ${session.width}x${session.height} " +
else if (videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default"
else "${session.codec?.string ?: "null"} ${session.width}x${session.height} " +
"@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps"
val audioDetail =
@@ -1025,9 +943,7 @@ fun DeviceTabPage(
", control=${options.control}, turnScreenOff=${options.turnScreenOff}" +
", maxSize=${options.maxSize}, maxFps=${options.maxFps}"
)
scope.launch {
snack.showSnackbar("scrcpy 已启动")
}
snackbar.show("scrcpy 已启动")
}
},
onStop = {
@@ -1036,9 +952,7 @@ fun DeviceTabPage(
sessionInfo = null
statusLine = "${currentTarget!!.host}:${currentTarget.port}"
logEvent("scrcpy 已停止")
scope.launch {
snack.showSnackbar("scrcpy 已停止")
}
snackbar.show("scrcpy 已停止")
}
},
sessionInfo = sessionInfo,
@@ -1094,19 +1008,21 @@ fun DeviceTabPage(
}
}
if (EventLogger.hasLogs()) item {
Spacer(Modifier.height(UiSpacing.PageItem))
Card {
TextField(
value = EventLogger.eventLog.joinToString(separator = "\n"),
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxWidth(),
)
if (EventLogger.hasLogs()) {
item {
SectionSmallTitle("日志", showLeadingSpacer = false)
Card {
TextField(
value = EventLogger.eventLog.joinToString(separator = "\n"),
onValueChange = {},
readOnly = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
item { Spacer(Modifier.height(UiSpacing.BottomContent)) }
item { Spacer(Modifier.height(UiSpacing.PageBottom)) }
}
}
@@ -1131,20 +1047,20 @@ private fun DeviceMenuPopup(
text = "快速设备排序",
optionSize = 3,
index = 0,
onClick = onReorderDevices,
onSelectedIndexChange = { onReorderDevices() },
)
DeviceMenuPopupItem(
text = "虚拟按钮排序",
optionSize = 3,
index = 1,
onClick = onOpenVirtualButtonOrder,
onSelectedIndexChange = { onOpenVirtualButtonOrder() },
)
DeviceMenuPopupItem(
text = "清空日志",
optionSize = 3,
index = 2,
enabled = canClearLogs,
onClick = onClearLogs,
onSelectedIndexChange = { onClearLogs() },
)
}
}
@@ -1156,8 +1072,7 @@ private fun DeviceMenuPopupItem(
optionSize: Int,
index: Int,
enabled: Boolean = true,
// TODO: (Int) -> Unit
onClick: () -> Unit,
onSelectedIndexChange: (Int) -> Unit,
) {
if (enabled) {
DropdownImpl(
@@ -1165,7 +1080,7 @@ private fun DeviceMenuPopupItem(
optionSize = optionSize,
isSelected = false,
index = index,
onSelectedIndexChange = { onClick() },
onSelectedIndexChange = onSelectedIndexChange,
)
return
}

View File

@@ -32,7 +32,9 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -53,6 +55,7 @@ fun FullscreenControlScreen(
val haptics = rememberAppHaptics()
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val activity = remember(context) { context as? Activity }
@@ -73,7 +76,7 @@ fun FullscreenControlScreen(
}
DisposableEffect(Unit) {
onDispose {
scope.launch {
taskScope.launch {
appSettings.saveBundle(asBundleLatest)
}
}

View File

@@ -12,9 +12,11 @@ import androidx.activity.compose.PredictiveBackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Devices
@@ -42,16 +44,20 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.ui.NavDisplay
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
import io.github.miuzarte.scrcpyforandroid.constants.UiMotion
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -61,6 +67,7 @@ import top.yukonga.miuix.kmp.basic.NavigationBarItem
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.SnackbarHost
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.ThemeController
@@ -84,6 +91,7 @@ fun MainScreen() {
val context = LocalContext.current
val appContext = context.applicationContext
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val activity = remember(context) { context as? Activity }
val initialOrientation = remember(activity) {
@@ -101,11 +109,14 @@ fun MainScreen() {
val adbService = remember(appContext) {
NativeAdbService(appContext)
}
val scrcpy = remember(appContext, adbService) {
Scrcpy(appContext, adbService)
}
val snackHostState = remember { SnackbarHostState() }
val snackbarController = remember(scope, snackHostState) {
SnackbarController(
scope = scope,
hostState = snackHostState,
)
}
val saveableStateHolder = rememberSaveableStateHolder()
val tabs = remember { MainBottomTabDestination.entries }
val pagerState = rememberPagerState(
@@ -137,6 +148,75 @@ fun MainScreen() {
// 2) switch tab back to Device
// 3) double-back to exit and disconnect adb/scrcpy
var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) }
val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
val asBundleLatest by rememberUpdatedState(asBundle)
LaunchedEffect(asBundleShared) {
if (asBundle != asBundleShared) {
asBundle = asBundleShared
}
}
LaunchedEffect(asBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (asBundle != asBundleSharedLatest) {
appSettings.saveBundle(asBundle)
}
}
DisposableEffect(Unit) {
onDispose {
taskScope.launch {
appSettings.saveBundle(asBundleLatest)
}
}
}
val qdBundleShared by quickDevices.bundleState.collectAsState()
val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared)
var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) }
val qdBundleLatest by rememberUpdatedState(qdBundle)
LaunchedEffect(qdBundleShared) {
if (qdBundle != qdBundleShared) {
qdBundle = qdBundleShared
}
}
LaunchedEffect(qdBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (qdBundle != qdBundleSharedLatest) {
quickDevices.saveBundle(qdBundle)
}
}
DisposableEffect(Unit) {
onDispose {
taskScope.launch {
quickDevices.saveBundle(qdBundleLatest)
}
}
}
val customServerUri = asBundle.customServerUri
.ifBlank { null }
val customServerVersion = asBundle.customServerVersion
.ifBlank { Scrcpy.DEFAULT_SERVER_VERSION }
val serverRemotePath = asBundle.serverRemotePath
.ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue }
val scrcpy = remember(
appContext,
adbService,
customServerUri,
customServerVersion,
serverRemotePath,
) {
Scrcpy(
appContext = appContext,
adbService = adbService,
customServerUri = customServerUri,
serverVersion = customServerVersion,
serverRemotePath = serverRemotePath,
)
}
fun handleBackNavigation() {
if (rootBackStack.size > 1) {
popRoot()
@@ -186,75 +266,12 @@ fun MainScreen() {
}
}
val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
val asBundleLatest by rememberUpdatedState(asBundle)
LaunchedEffect(asBundleShared) {
if (asBundle != asBundleShared) {
asBundle = asBundleShared
}
}
LaunchedEffect(asBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (asBundle != asBundleSharedLatest) {
appSettings.saveBundle(asBundle)
}
}
DisposableEffect(Unit) {
onDispose {
scope.launch {
appSettings.saveBundle(asBundleLatest)
}
}
}
val qdBundleShared by quickDevices.bundleState.collectAsState()
val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared)
var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) }
val qdBundleLatest by rememberUpdatedState(qdBundle)
LaunchedEffect(qdBundleShared) {
if (qdBundle != qdBundleShared) {
qdBundle = qdBundleShared
}
}
LaunchedEffect(qdBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (qdBundle != qdBundleSharedLatest) {
quickDevices.saveBundle(qdBundle)
}
}
DisposableEffect(Unit) {
onDispose {
scope.launch {
quickDevices.saveBundle(qdBundleLatest)
}
}
}
var sessionStarted by remember { mutableStateOf(false) }
var showReorderDevices by rememberSaveable { mutableStateOf(false) }
var fullscreenOrientation by rememberSaveable {
mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
}
var savedShortcuts by remember {
mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList))
}
LaunchedEffect(qdBundle.quickDevicesList) {
savedShortcuts = DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList)
}
LaunchedEffect(savedShortcuts) {
val serialized = savedShortcuts.marshalToString()
if (serialized != qdBundle.quickDevicesList) {
qdBundle = qdBundle.copy(quickDevicesList = serialized)
}
}
val themeMode = resolveThemeMode(asBundle.themeBaseIndex, asBundle.monet)
val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
val keepScreenOnWhenStreamingEnabled = asBundle.keepScreenOnWhenStreaming
// Keep-screen-on is controlled globally, so fullscreen and preview share the same behavior.
DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) {
@@ -336,7 +353,7 @@ fun MainScreen() {
nativeCore = nativeCore,
adbService = adbService,
scrcpy = scrcpy,
snack = snackHostState,
snackbar = snackbarController,
scrollBehavior = devicesPageScrollBehavior,
onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) },
onSessionStartedChange = { sessionStarted = it },
@@ -357,7 +374,7 @@ fun MainScreen() {
MainBottomTabDestination.Settings -> SettingsScreen(
scrollBehavior = settingsPageScrollBehavior,
snack = snackHostState,
snackbar = snackbarController,
onOpenReorderDevices = { showReorderDevices = true },
onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) },
onPickServer = {
@@ -382,10 +399,10 @@ fun MainScreen() {
}
entry(RootScreen.Advanced) {
AdvancedConfigScreen(
ScrcpyAllOptionsScreen(
onBack = ::popRoot,
scrollBehavior = advancedPageScrollBehavior,
snack = snackHostState,
snackbar = snackbarController,
scrcpy = scrcpy,
)
}
@@ -416,6 +433,13 @@ fun MainScreen() {
entryProvider = rootEntryProvider,
)
val themeMode = when (asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex)) {
1 -> if (!asBundle.monet) ColorSchemeMode.Light else ColorSchemeMode.MonetLight
2 -> if (!asBundle.monet) ColorSchemeMode.Dark else ColorSchemeMode.MonetDark
else -> if (!asBundle.monet) ColorSchemeMode.System else ColorSchemeMode.MonetSystem
}
val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
MiuixTheme(controller = themeController) {
NavDisplay(
entries = rootEntries,

View File

@@ -19,6 +19,9 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.extra.SuperBottomSheet
@@ -29,6 +32,7 @@ fun ReorderDevicesScreen(
onDismissRequest: () -> Unit,
) {
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val qdBundleShared by quickDevices.bundleState.collectAsState()
val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared)
@@ -47,7 +51,7 @@ fun ReorderDevicesScreen(
}
DisposableEffect(Unit) {
onDispose {
scope.launch {
taskScope.launch {
quickDevices.saveBundle(qdBundleLatest)
}
}
@@ -85,6 +89,6 @@ fun ReorderDevicesScreen(
savedShortcuts = savedShortcuts.move(fromIndex, toIndex)
},
).invoke()
Spacer(Modifier.height(UiSpacing.BottomSheetBottom))
Spacer(Modifier.height(UiSpacing.SheetBottom))
}
}

View File

@@ -1,433 +0,0 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.content.Intent
import android.os.Process
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.FileOpen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDropdown
import top.yukonga.miuix.kmp.extra.SuperSwitch
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import kotlin.math.roundToInt
import kotlin.system.exitProcess
private data class ThemeModeOption(
val label: String,
val mode: ColorSchemeMode,
)
private val THEME_BASE_OPTIONS = listOf(
ThemeModeOption("跟随系统", ColorSchemeMode.System),
ThemeModeOption("浅色", ColorSchemeMode.Light),
ThemeModeOption("深色", ColorSchemeMode.Dark),
)
fun resolveThemeMode(baseIndex: Int, monet: Boolean): ColorSchemeMode {
return when (baseIndex.coerceIn(0, 2)) {
0 -> if (monet) ColorSchemeMode.MonetSystem else ColorSchemeMode.System
1 -> if (monet) ColorSchemeMode.MonetLight else ColorSchemeMode.Light
else -> if (monet) ColorSchemeMode.MonetDark else ColorSchemeMode.Dark
}
}
private fun resolveThemeLabel(baseIndex: Int, monetEnabled: Boolean): String {
val base = THEME_BASE_OPTIONS.getOrNull(baseIndex.coerceIn(0, 2))?.label ?: "跟随系统"
return if (monetEnabled) "Monet$base" else base
}
@Composable
fun SettingsScreen(
scrollBehavior: ScrollBehavior,
snack: SnackbarHostState,
onOpenReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit,
onPickServer: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = "设置",
scrollBehavior = scrollBehavior,
)
},
) { pagePadding ->
SettingsPage(
contentPadding = pagePadding,
scrollBehavior = scrollBehavior,
snack = snack,
onOpenReorderDevices = onOpenReorderDevices,
onOpenVirtualButtonOrder = onOpenVirtualButtonOrder,
onPickServer = onPickServer,
)
}
}
@Composable
fun SettingsPage(
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
snack: SnackbarHostState,
onOpenReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit,
onPickServer: () -> Unit,
) {
val appContext = LocalContext.current.applicationContext
val appSettings = Storage.appSettings
var needMigration by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
needMigration = PreferenceMigration(appContext).needsMigration()
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
val asBundleLatest by rememberUpdatedState(asBundle)
LaunchedEffect(asBundleShared) {
if (asBundle != asBundleShared) {
asBundle = asBundleShared
}
}
LaunchedEffect(asBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (asBundle != asBundleSharedLatest) {
appSettings.saveBundle(asBundle)
}
}
DisposableEffect(Unit) {
onDispose {
scope.launch {
appSettings.saveBundle(asBundleLatest)
}
}
}
val baseModeItems = THEME_BASE_OPTIONS.map { it.label }
var serverRemotePathInput by rememberSaveable {
mutableStateOf(
if (asBundleShared.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) ""
else asBundleShared.serverRemotePath
)
}
LaunchedEffect(asBundleShared.serverRemotePath) {
serverRemotePathInput =
if (asBundleShared.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) ""
else asBundleShared.serverRemotePath
}
var adbKeyNameInput by rememberSaveable {
mutableStateOf(
if (asBundleShared.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) ""
else asBundleShared.adbKeyName
)
}
LaunchedEffect(asBundleShared.adbKeyName) {
adbKeyNameInput =
if (asBundleShared.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) ""
else asBundleShared.adbKeyName
}
// 设置
LazyColumn(
contentPadding = contentPadding,
scrollBehavior = scrollBehavior,
) {
item {
SectionSmallTitle("主题")
Card {
SuperDropdown(
title = "外观模式",
summary = resolveThemeLabel(asBundle.themeBaseIndex, asBundle.monet),
items = baseModeItems,
selectedIndex = asBundle.themeBaseIndex.coerceIn(0, baseModeItems.lastIndex),
onSelectedIndexChange = {
asBundle = asBundle.copy(themeBaseIndex = it)
},
)
SuperSwitch(
title = "Monet",
summary = "开启后使用 Monet 动态配色",
checked = asBundle.monet,
onCheckedChange = {
asBundle = asBundle.copy(monet = it)
},
)
}
SectionSmallTitle("投屏")
Card {
SuperSwitch(
title = "启用调试信息",
summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS",
checked = asBundle.fullscreenDebugInfo,
onCheckedChange = {
asBundle = asBundle.copy(fullscreenDebugInfo = it)
},
)
SuperSwitch(
title = "投屏时保持屏幕常亮",
summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开",
checked = asBundle.keepScreenOnWhenStreaming,
onCheckedChange = {
asBundle = asBundle.copy(keepScreenOnWhenStreaming = it)
},
)
SuperSlider(
title = "预览卡高度",
summary = "设备页预览卡高度",
value = asBundle.devicePreviewCardHeightDp.toFloat(),
onValueChange = {
asBundle = asBundle.copy(
devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120)
)
},
valueRange = 160f..600f,
steps = 600-160-2,
unit = "dp",
displayFormatter = { it.roundToInt().toString() },
inputInitialValue = asBundle.devicePreviewCardHeightDp.toString(),
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 120f..UShort.MAX_VALUE.toFloat(),
onInputConfirm = { input ->
input.toIntOrNull()?.let {
asBundle = asBundle.copy(
devicePreviewCardHeightDp = it.coerceAtLeast(120)
)
}
},
)
SuperArrow(
title = "快速设备排序",
summary = "手动排序设备页的快速设备",
onClick = onOpenReorderDevices,
)
SuperArrow(
title = "虚拟按钮排序",
summary = "手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外",
onClick = onOpenVirtualButtonOrder,
)
SuperSwitch(
title = "全屏显示虚拟按钮",
summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮",
checked = asBundle.showFullscreenVirtualButtons,
onCheckedChange = {
asBundle = asBundle.copy(showFullscreenVirtualButtons = it)
},
)
}
SectionSmallTitle("scrcpy-server")
Card {
Spacer(modifier = Modifier.padding(top = UiSpacing.CardContent))
Text(
text = "自定义 binary",
modifier = Modifier
.padding(horizontal = UiSpacing.CardTitle)
.padding(bottom = UiSpacing.FieldLabelBottom),
fontWeight = FontWeight.Medium,
)
TextField(
value = asBundle.customServerUri,
onValueChange = {},
readOnly = true,
label = "scrcpy-server-v3.3.4",
useLabelAsPlaceholder = asBundle.customServerUri.isBlank(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
trailingIcon = {
Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) {
if (asBundle.customServerUri.isNotBlank())
IconButton(onClick = {
asBundle = asBundle.copy(customServerUri = "")
}) {
Icon(Icons.Rounded.Clear, contentDescription = "清空")
}
IconButton(onClick = onPickServer) {
Icon(Icons.Rounded.FileOpen, contentDescription = "选择文件")
}
}
},
)
Text(
text = "Remote Path",
modifier = Modifier
.padding(horizontal = UiSpacing.CardTitle)
.padding(bottom = UiSpacing.FieldLabelBottom),
fontWeight = FontWeight.Medium,
)
SuperTextField(
value = serverRemotePathInput,
onValueChange = { serverRemotePathInput = it },
onFocusLost = {
if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue)
serverRemotePathInput = ""
asBundle = asBundle.copy(
serverRemotePath = serverRemotePathInput.ifBlank {
AppSettings.SERVER_REMOTE_PATH.defaultValue
}
)
},
label = AppSettings.SERVER_REMOTE_PATH.defaultValue,
useLabelAsPlaceholder = true,
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
}
SectionSmallTitle("ADB")
Card {
Text(
text = "自定义 ADB 密钥名",
modifier = Modifier
.padding(horizontal = UiSpacing.CardTitle)
.padding(top = UiSpacing.CardContent, bottom = UiSpacing.FieldLabelBottom),
fontWeight = FontWeight.Medium,
)
SuperTextField(
value = adbKeyNameInput,
onValueChange = { adbKeyNameInput = it },
onFocusLost = {
if (adbKeyNameInput == AppSettings.ADB_KEY_NAME.defaultValue)
adbKeyNameInput = ""
asBundle = asBundle.copy(
adbKeyName = adbKeyNameInput.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue }
)
},
label = AppSettings.ADB_KEY_NAME.defaultValue,
useLabelAsPlaceholder = true,
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
)
SuperSwitch(
title = "配对时自动启用发现服务",
summary = "打开配对弹窗后自动搜索可用配对端口",
checked = asBundle.adbPairingAutoDiscoverOnDialogOpen,
onCheckedChange = {
asBundle = asBundle.copy(
adbPairingAutoDiscoverOnDialogOpen = it
)
},
)
SuperSwitch(
title = "自动重连已配对设备",
summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)",
checked = asBundle.adbAutoReconnectPairedDevice,
onCheckedChange = {
asBundle = asBundle.copy(
adbAutoReconnectPairedDevice = it
)
},
)
}
// 这部分应该不会显示出来,
// 应用启动时就会执行迁移与旧数据的删除
AnimatedVisibility(needMigration) {
SectionSmallTitle("应用")
}
AnimatedVisibility(needMigration) {
Card {
SuperArrow(
title = "恢复旧版本配置",
summary = "从旧版本的 SharedPreferences 恢复至 DataStore",
onClick = {
scope.launch {
val migration = PreferenceMigration(appContext)
migration.migrate(clearSharedPrefs = false)
snack.showSnackbar("迁移完成,应用将重启")
delay(1000)
val intent = context.packageManager.getLaunchIntentForPackage(
context.packageName
)
intent?.apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
or Intent.FLAG_ACTIVITY_CLEAR_TASK
)
}
context.startActivity(intent)
Process.killProcess(Process.myPid())
exitProcess(0)
}
},
)
}
}
SectionSmallTitle("关于")
Card {
SuperArrow(
title = "前往仓库",
summary = "github.com/Miuzarte/ScrcpyForAndroid",
onClick = {
context.startActivity(
Intent(
Intent.ACTION_VIEW,
"https://github.com/Miuzarte/ScrcpyForAndroid".toUri(),
)
)
},
)
}
}
item { Spacer(Modifier.height(UiSpacing.BottomContent)) }
}
}

View File

@@ -0,0 +1,468 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.content.Intent
import android.os.Process
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.FileOpen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.core.net.toUri
import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.IconButton
import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.basic.TopAppBar
import top.yukonga.miuix.kmp.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDropdown
import top.yukonga.miuix.kmp.extra.SuperSwitch
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
import kotlin.math.roundToInt
import kotlin.system.exitProcess
@Composable
fun SettingsScreen(
scrollBehavior: ScrollBehavior,
snackbar: SnackbarController,
onOpenReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit,
onPickServer: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = "设置",
scrollBehavior = scrollBehavior,
)
},
) { pagePadding ->
SettingsPage(
contentPadding = pagePadding,
scrollBehavior = scrollBehavior,
snackbar = snackbar,
onOpenReorderDevices = onOpenReorderDevices,
onOpenVirtualButtonOrder = onOpenVirtualButtonOrder,
onPickServer = onPickServer,
)
}
}
@Composable
fun SettingsPage(
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
snackbar: SnackbarController,
onOpenReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit,
onPickServer: () -> Unit,
) {
val context = LocalContext.current
val appContext = context.applicationContext
var needMigration by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
needMigration = PreferenceMigration(appContext).needsMigration()
}
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val scope = rememberCoroutineScope()
val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) }
val asBundleLatest by rememberUpdatedState(asBundle)
LaunchedEffect(asBundleShared) {
if (asBundle != asBundleShared) {
asBundle = asBundleShared
}
}
LaunchedEffect(asBundle) {
delay(Settings.BUNDLE_SAVE_DELAY)
if (asBundle != asBundleSharedLatest) {
appSettings.saveBundle(asBundle)
}
}
DisposableEffect(Unit) {
onDispose {
taskScope.launch {
appSettings.saveBundle(asBundleLatest)
}
}
}
val themeItems = rememberSaveable { ThemeModes.baseOptions.map { it.label } }
val customServerVersionShowInput = rememberSaveable(asBundle.customServerUri) {
asBundle.customServerUri.isNotBlank()
}
var customServerVersionInput by rememberSaveable(asBundle.customServerVersion) {
mutableStateOf(asBundle.customServerVersion)
}
var serverRemotePathInput by rememberSaveable(asBundle.serverRemotePath) {
mutableStateOf(
if (asBundle.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) ""
else asBundle.serverRemotePath
)
}
var adbKeyNameInput by rememberSaveable(asBundle.adbKeyName) {
mutableStateOf(
if (asBundle.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) ""
else asBundle.adbKeyName
)
}
// 设置
LazyColumn(
contentPadding = contentPadding,
scrollBehavior = scrollBehavior,
) {
item {
SectionSmallTitle("主题", showLeadingSpacer = false)
Card {
SuperDropdown(
title = "外观模式",
summary = ThemeModes.baseOptions
.getOrNull(asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex))
?.label
?: "跟随系统",
items = themeItems,
selectedIndex = asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex),
onSelectedIndexChange = {
asBundle = asBundle.copy(themeBaseIndex = it)
},
)
SuperSwitch(
title = "Monet",
summary = "开启后使用 Monet 动态配色",
checked = asBundle.monet,
onCheckedChange = {
asBundle = asBundle.copy(monet = it)
},
)
}
}
item {
SectionSmallTitle("投屏")
Card {
SuperSwitch(
title = "启用调试信息",
summary = "在全屏界面悬浮显示分辨率、帧率和触点信息",
checked = asBundle.fullscreenDebugInfo,
onCheckedChange = {
asBundle = asBundle.copy(fullscreenDebugInfo = it)
},
)
SuperSwitch(
title = "投屏时保持屏幕常亮",
summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开",
checked = asBundle.keepScreenOnWhenStreaming,
onCheckedChange = {
asBundle = asBundle.copy(keepScreenOnWhenStreaming = it)
},
)
SuperSlider(
title = "预览卡高度",
summary = "设备页预览卡高度",
value = asBundle.devicePreviewCardHeightDp.toFloat(),
onValueChange = {
asBundle = asBundle.copy(
devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120)
)
},
valueRange = 160f..600f,
steps = 600 - 160 - 2,
unit = "dp",
displayFormatter = { it.roundToInt().toString() },
inputInitialValue = asBundle.devicePreviewCardHeightDp.toString(),
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 120f..UShort.MAX_VALUE.toFloat(),
onInputConfirm = { input ->
input.toIntOrNull()?.let {
asBundle = asBundle.copy(
devicePreviewCardHeightDp = it.coerceAtLeast(120)
)
}
},
)
SuperArrow(
title = "快速设备排序",
summary = "手动排序设备页的快速设备",
onClick = onOpenReorderDevices,
)
SuperArrow(
title = "虚拟按钮排序",
summary = "手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外",
onClick = onOpenVirtualButtonOrder,
)
SuperSwitch(
title = "全屏显示虚拟按钮",
summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮",
checked = asBundle.showFullscreenVirtualButtons,
onCheckedChange = {
asBundle = asBundle.copy(showFullscreenVirtualButtons = it)
},
)
}
}
item {
SectionSmallTitle("scrcpy-server", showLeadingSpacer = false)
Card {
Column(
modifier = Modifier.padding(vertical = UiSpacing.Large),
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
) {
Column(
modifier = Modifier.padding(horizontal = UiSpacing.Large),
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Text(
text = "自定义 binary",
fontWeight = FontWeight.Medium,
)
TextField(
value = asBundle.customServerUri,
onValueChange = {},
readOnly = true,
label = Scrcpy.DEFAULT_SERVER_ASSET_NAME,
useLabelAsPlaceholder = true,
modifier = Modifier.fillMaxWidth(),
trailingIcon = {
Row(
modifier = Modifier
.padding(end = UiSpacing.Medium),
) {
if (asBundle.customServerUri.isNotBlank())
IconButton(
onClick = {
asBundle = asBundle.copy(
customServerUri = "",
customServerVersion = "",
)
},
) {
Icon(
imageVector = Icons.Rounded.Clear,
contentDescription = "清空",
)
}
IconButton(onClick = onPickServer) {
Icon(
imageVector = Icons.Rounded.FileOpen,
contentDescription = "选择文件",
)
}
}
},
)
}
AnimatedVisibility(customServerVersionShowInput) {
Column(
modifier = Modifier.padding(horizontal = UiSpacing.Large),
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Text(
text = "自定义 binary version",
fontWeight = FontWeight.Medium,
)
SuperTextField(
value = customServerVersionInput,
onValueChange = { customServerVersionInput = it },
onFocusLost = {
if (customServerVersionInput == AppSettings.CUSTOM_SERVER_VERSION.defaultValue)
customServerVersionInput = ""
asBundle = asBundle.copy(
customServerVersion = customServerVersionInput
.ifBlank { AppSettings.CUSTOM_SERVER_VERSION.defaultValue }
)
},
label = Scrcpy.DEFAULT_SERVER_VERSION,
useLabelAsPlaceholder = true,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
Column(
modifier = Modifier.padding(horizontal = UiSpacing.Large),
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Text(
text = "Remote Path",
fontWeight = FontWeight.Medium,
)
SuperTextField(
value = serverRemotePathInput,
onValueChange = { serverRemotePathInput = it },
onFocusLost = {
if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue)
serverRemotePathInput = ""
asBundle = asBundle.copy(
serverRemotePath = serverRemotePathInput
.ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue }
)
},
label = Scrcpy.DEFAULT_REMOTE_PATH,
useLabelAsPlaceholder = true,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
item {
SectionSmallTitle("ADB", showLeadingSpacer = false)
Card {
Column(
modifier = Modifier.padding(vertical = UiSpacing.Large),
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
) {
Column(
modifier = Modifier.padding(horizontal = UiSpacing.Large),
verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Text(
text = "自定义 ADB 密钥名",
fontWeight = FontWeight.Medium,
)
SuperTextField(
value = adbKeyNameInput,
onValueChange = { adbKeyNameInput = it },
onFocusLost = {
if (adbKeyNameInput == AppSettings.ADB_KEY_NAME.defaultValue)
adbKeyNameInput = ""
asBundle = asBundle.copy(
adbKeyName = adbKeyNameInput
.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue }
)
},
label = AppSettings.ADB_KEY_NAME.defaultValue,
useLabelAsPlaceholder = true,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
SuperSwitch(
title = "配对时自动启用发现服务",
summary = "打开配对弹窗后自动搜索可用配对端口",
checked = asBundle.adbPairingAutoDiscoverOnDialogOpen,
onCheckedChange = {
asBundle = asBundle.copy(
adbPairingAutoDiscoverOnDialogOpen = it
)
},
)
SuperSwitch(
title = "自动重连已配对设备",
summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)",
checked = asBundle.adbAutoReconnectPairedDevice,
onCheckedChange = {
asBundle = asBundle.copy(
adbAutoReconnectPairedDevice = it
)
},
)
}
}
if (needMigration) item {
// 这部分应该不会显示出来,
// 应用启动时就会执行迁移与旧数据的删除
SectionSmallTitle("应用", showLeadingSpacer = false)
Card {
SuperArrow(
title = "恢复旧版本配置",
summary = "从旧版本的 SharedPreferences 恢复至 DataStore",
onClick = {
scope.launch {
val migration = PreferenceMigration(appContext)
migration.migrate(clearSharedPrefs = false)
snackbar.show("迁移完成,应用将重启")
delay(1000)
val intent = context.packageManager.getLaunchIntentForPackage(
context.packageName
)
intent?.apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
or Intent.FLAG_ACTIVITY_CLEAR_TASK
)
}
context.startActivity(intent)
Process.killProcess(Process.myPid())
exitProcess(0)
}
},
)
}
}
item {
SectionSmallTitle("关于", showLeadingSpacer = false)
Card {
SuperArrow(
title = "前往仓库",
summary = "github.com/Miuzarte/ScrcpyForAndroid",
onClick = {
context.startActivity(
Intent(
Intent.ACTION_VIEW,
"https://github.com/Miuzarte/ScrcpyForAndroid".toUri(),
)
)
},
)
}
}
item { Spacer(Modifier.height(UiSpacing.PageBottom)) }
}
}

View File

@@ -22,6 +22,9 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Card
@@ -67,6 +70,7 @@ internal fun VirtualButtonOrderPage(
scrollBehavior: ScrollBehavior,
) {
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
@@ -85,7 +89,7 @@ internal fun VirtualButtonOrderPage(
}
DisposableEffect(Unit) {
onDispose {
scope.launch {
taskScope.launch {
appSettings.saveBundle(asBundleLatest)
}
}

View File

@@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -122,7 +123,7 @@ private fun SliderInputDialog(
onDismissRequest = onDismissRequest,
onDismissFinished = onDismissFinished,
content = {
var text by remember(initialValue) { mutableStateOf(initialValue) }
var text by rememberSaveable(initialValue) { mutableStateOf(initialValue) }
SuperTextField(
modifier = Modifier.padding(bottom = 16.dp),

View File

@@ -11,23 +11,15 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
// TODO: 修改配置项时, validate() 判断是否合法, 非法则回退修改
// TODO: 管理冲突配置项的关系, 在更多参数页中隐藏冲突项
// TODO: 禁用配置项验证, 包括不隐藏冲突配置项
// TODO: 配置切换
// TODO: 参数预览框可编辑
data class ClientOptions(
// var serial: String = "", // to server
// --crop=width:height:x:y
var crop: String = "", // to server
// TODO
// TODO: implement
// --record
var recordFilename: String = "",
@@ -236,7 +228,28 @@ data class ClientOptions(
}
}
fun validate() {
fun fix(): ClientOptions {
when(videoSource) {
VideoSource.DISPLAY -> {
cameraId = ""
cameraFacing = CameraFacing.ANY
cameraSize = ""
cameraAr = ""
cameraFps = 0u
cameraHighSpeed = false
}
VideoSource.CAMERA -> {
displayId = 0
maxSize = 0u
maxFps = ""
newDisplay = ""
crop = ""
}
}
return this
}
fun validate(): ClientOptions {
if (!video) {
videoPlayback = false
powerOn = false
@@ -494,6 +507,8 @@ data class ClientOptions(
)
}
}
return this
}
fun toServerParams(scid: UInt): ServerParams {

View File

@@ -10,6 +10,9 @@ import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.EncoderType
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import kotlinx.coroutines.Dispatchers
@@ -49,7 +52,7 @@ class Scrcpy(
private val adbService: NativeAdbService,
private val serverAsset: String = DEFAULT_SERVER_ASSET,
private val customServerUri: String? = null,
private val serverVersion: String = "3.3.4",
private val serverVersion: String = DEFAULT_SERVER_VERSION,
private val serverRemotePath: String = DEFAULT_REMOTE_PATH,
) {
private val session = Session(adbService)
@@ -64,36 +67,29 @@ class Scrcpy(
@Volatile
private var audioPlayer: ScrcpyAudioPlayer? = null
// Cached encoder and camera size data
private val _videoEncoders = mutableListOf<String>()
private val _audioEncoders = mutableListOf<String>()
private val _videoEncoderTypes = mutableMapOf<String, String>()
private val _audioEncoderTypes = mutableMapOf<String, String>()
private val _cameraSizes = mutableListOf<String>()
val videoEncoders: List<String> get() = _videoEncoders.toList()
val audioEncoders: List<String> get() = _audioEncoders.toList()
val videoEncoderTypes: Map<String, String> get() = _videoEncoderTypes.toMap()
val audioEncoderTypes: Map<String, String> get() = _audioEncoderTypes.toMap()
val cameraSizes: List<String> get() = _cameraSizes.toList()
val listings = Listings()
companion object {
private const val TAG = "Scrcpy"
const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4"
const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v3.3.4"
const val DEFAULT_SERVER_VERSION = "3.3.4"
const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar"
// Regex patterns for parsing server output
private val VIDEO_ENCODER_REGEX = Regex("--video-encoder=([\\w.\\-]+)")
private val AUDIO_ENCODER_REGEX = Regex("--audio-encoder=([\\w.\\-]+)")
private val VIDEO_ENCODER_FALLBACK_REGEX = Regex("""--video-encoder=['"]?([^'"\s]+)""")
private val AUDIO_ENCODER_FALLBACK_REGEX = Regex("""--audio-encoder=['"]?([^'"\s]+)""")
private val VIDEO_ENCODER_TYPE_REGEX =
Regex("""--video-codec=\S+\s+--video-encoder=(\S+).*?\((hw|sw)\)""")
private val AUDIO_ENCODER_TYPE_REGEX =
Regex("""--audio-codec=\S+\s+--audio-encoder=(\S+).*?\((hw|sw)\)""")
private val CAMERA_SIZE_REGEX = Regex("--camera-size=([0-9]+x[0-9]+)")
private val CAMERA_SIZE_FALLBACK_REGEX = Regex("\\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\\b")
private const val PREVIEW_LINES = 32
private val VIDEO_ENCODER_INFO_REGEX =
Regex("""--video-codec=(\S+)\s+--video-encoder=(\S+)\s+\((hw|sw)\)(\s+\[vendor])?(?:\s+\(alias for (\S+)\))?""")
private val AUDIO_ENCODER_INFO_REGEX =
Regex("""--audio-codec=(\S+)\s+--audio-encoder=(\S+)\s+\((hw|sw)\)(\s+\[vendor])?(?:\s+\(alias for (\S+)\))?""")
private val DISPLAY_REGEX =
Regex("""--display-id=(\d+)\s+\((\d+)x(\d+)\)""")
private val CAMERA_SIZE_REGEX =
Regex("""\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\b""")
private val CAMERA_INFO_REGEX =
Regex("""--camera-id=(\S+)\s+\(([^,]+),\s*([0-9]+x[0-9]+),\s*fps=\[([0-9,\s]+)\]\)""")
private val APP_REGEX =
Regex("""^\s*([*-])\s+(.+?)\s{2,}([A-Za-z0-9._]+)\s*$""", RegexOption.MULTILINE)
fun generateScid(): UInt {
// Only use 31 bits to avoid issues with signed values on the Java-side
@@ -169,7 +165,7 @@ class Scrcpy(
Log.i(
TAG, "start(): Session started successfully - device=${info.deviceName}, " +
"video=${if (options.video) "${info.codecName} ${info.width}x${info.height}" else "off"}, " +
"video=${if (options.video) "${info.codec?.string ?: "null"} ${info.width}x${info.height}" else "off"}, " +
"audio=${if (options.audio) options.audioCodec.string else "off"}, " +
"control=${options.control}"
)
@@ -218,8 +214,6 @@ class Scrcpy(
fun getCurrentSession(): Session.SessionInfo? = currentSession
fun getLastServerCommand(): String? = session.getLastServerCommand()
sealed class ListResult {
data class Encoders(
val videoEncoders: List<String>,
@@ -229,67 +223,176 @@ class Scrcpy(
val rawOutput: String = "",
) : ListResult()
data class Displays(
val displays: List<DisplayInfo>,
val rawOutput: String = "",
) : ListResult()
data class Cameras(
val cameras: List<CameraInfo>,
val rawOutput: String = "",
) : ListResult()
data class CameraSizes(
val sizes: List<String>,
val rawOutput: String = "",
) : ListResult()
data class Apps(
val apps: List<AppInfo>,
val rawOutput: String = "",
) : ListResult()
}
/**
* Refresh encoder lists from the device.
* Results are cached and can be accessed via videoEncoders, audioEncoders, etc.
*
* @throws Exception if the operation fails
*/
suspend fun refreshEncoders() {
val result = listOptions(ListOptions.ENCODERS) as ListResult.Encoders
_videoEncoders.clear()
_videoEncoders.addAll(result.videoEncoders)
_audioEncoders.clear()
_audioEncoders.addAll(result.audioEncoders)
_videoEncoderTypes.clear()
_videoEncoderTypes.putAll(result.videoEncoderTypes)
_audioEncoderTypes.clear()
_audioEncoderTypes.putAll(result.audioEncoderTypes)
inner class Listings {
private val encodersMutex = Mutex()
private val displaysMutex = Mutex()
private val camerasMutex = Mutex()
private val cameraSizesMutex = Mutex()
private val appsMutex = Mutex()
@Volatile
private var cachedVideoEncoders: List<EncoderInfo>? = null
@Volatile
private var cachedAudioEncoders: List<EncoderInfo>? = null
@Volatile
private var cachedDisplays: List<DisplayInfo>? = null
@Volatile
private var cachedCameras: List<CameraInfo>? = null
@Volatile
private var cachedCameraSizes: List<String>? = null
@Volatile
private var cachedApps: List<AppInfo>? = null
val videoEncoders: List<EncoderInfo> get() = cachedVideoEncoders.orEmpty()
val audioEncoders: List<EncoderInfo> get() = cachedAudioEncoders.orEmpty()
val displays: List<DisplayInfo> get() = cachedDisplays.orEmpty()
val cameras: List<CameraInfo> get() = cachedCameras.orEmpty()
val cameraSizes: List<String> get() = cachedCameraSizes.orEmpty()
val apps: List<AppInfo> get() = cachedApps.orEmpty()
suspend fun getVideoEncoders(forceRefresh: Boolean = false): List<EncoderInfo> {
cachedVideoEncoders?.takeUnless { forceRefresh }?.let { return it }
return getEncoders(forceRefresh).first
}
suspend fun getAudioEncoders(forceRefresh: Boolean = false): List<EncoderInfo> {
cachedAudioEncoders?.takeUnless { forceRefresh }?.let { return it }
return getEncoders(forceRefresh).second
}
private suspend fun getEncoders(forceRefresh: Boolean = false)
: Pair<List<EncoderInfo>, List<EncoderInfo>> {
if (!forceRefresh && cachedVideoEncoders != null && cachedAudioEncoders != null)
return cachedVideoEncoders.orEmpty() to cachedAudioEncoders.orEmpty()
return encodersMutex.withLock {
if (!forceRefresh && cachedVideoEncoders != null && cachedAudioEncoders != null)
return@withLock cachedVideoEncoders.orEmpty() to cachedAudioEncoders.orEmpty()
val output = executeList(ListOptions.ENCODERS)
val (video, audio) = parseEncoders(output)
cachedVideoEncoders = video
cachedAudioEncoders = audio
logListPreview(
list = ListOptions.ENCODERS,
countSummary = "video=${video.size} audio=${audio.size}",
output = output,
)
video to audio
}
}
suspend fun getDisplays(forceRefresh: Boolean = false): List<DisplayInfo> {
cachedDisplays?.takeUnless { forceRefresh }?.let { return it }
return displaysMutex.withLock {
cachedDisplays?.takeUnless { forceRefresh } ?: run {
val output = executeList(ListOptions.DISPLAYS)
val parsed = parseDisplays(output)
cachedDisplays = parsed
logListPreview(
list = ListOptions.DISPLAYS,
countSummary = "displays=${parsed.size}",
output = output,
)
parsed
}
}
}
suspend fun getCameras(forceRefresh: Boolean = false): List<CameraInfo> {
cachedCameras?.takeUnless { forceRefresh }?.let { return it }
return camerasMutex.withLock {
cachedCameras?.takeUnless { forceRefresh } ?: run {
val output = executeList(ListOptions.CAMERAS)
val parsed = parseCameras(output)
cachedCameras = parsed
logListPreview(
list = ListOptions.CAMERAS,
countSummary = "cameras=${parsed.size}",
output = output,
)
parsed
}
}
}
suspend fun getCameraSizes(forceRefresh: Boolean = false): List<String> {
cachedCameraSizes?.takeUnless { forceRefresh }?.let { return it }
return cameraSizesMutex.withLock {
cachedCameraSizes?.takeUnless { forceRefresh } ?: run {
val output = executeList(ListOptions.CAMERA_SIZES)
val parsed = parseCameraSizes(output)
.sortedWith(compareByDescending { size ->
size.substringBefore('x').toIntOrNull() ?: 0
})
cachedCameraSizes = parsed
logListPreview(
list = ListOptions.CAMERA_SIZES,
countSummary = "sizes=${parsed.size}",
output = output,
)
parsed
}
}
}
suspend fun getApps(forceRefresh: Boolean = false): List<AppInfo> {
cachedApps?.takeUnless { forceRefresh }?.let { return it }
return appsMutex.withLock {
cachedApps?.takeUnless { forceRefresh } ?: run {
val output = executeList(ListOptions.APPS)
val parsed = parseApps(output)
cachedApps = parsed
logListPreview(
list = ListOptions.APPS,
countSummary = "apps=${parsed.size}",
output = output,
)
parsed
}
}
}
Log.i(TAG, "refreshEncoders(): video=${_videoEncoders.size}, audio=${_audioEncoders.size}")
}
/**
* Refresh camera sizes from the device.
* Results are cached and can be accessed via cameraSizes.
*
* @throws Exception if the operation fails
*/
suspend fun refreshCameraSizes() {
val result = listOptions(ListOptions.CAMERA_SIZES) as ListResult.CameraSizes
_cameraSizes.clear()
_cameraSizes.addAll(result.sizes.sortedWith(compareByDescending { size ->
size.substringBefore('x').toIntOrNull() ?: 0
}))
private suspend fun executeList(list: ListOptions): String = withContext(Dispatchers.IO) {
require(list != ListOptions.NULL) { "Nothing to do with ListOptions.NULL" }
Log.i(TAG, "refreshCameraSizes(): sizes=${_cameraSizes.size}")
}
/**
* List various options from the scrcpy server.
*
* @param list The type of list to retrieve (ENCODERS, CAMERA_SIZES, etc.)
* @return ListResult containing the requested information
*/
suspend fun listOptions(list: ListOptions): ListResult = withContext(Dispatchers.IO) {
val serverJar = if (customServerUri.isNullOrBlank()) {
extractAssetToCache(serverAsset)
} else {
extractUriToCache(customServerUri.toUri())
}
// Push server jar to device
adbService.push(serverJar.toPath(), serverRemotePath)
val scid = generateScid()
// Create ClientOptions for listing
val options = ClientOptions(
video = false,
audio = false,
@@ -297,10 +400,7 @@ class Scrcpy(
cleanUp = false,
list = list,
)
val serverParams = options.toServerParams(scid)
// Build server command
val serverCommand = serverParams.build(
"CLASSPATH=$serverRemotePath",
"app_process",
@@ -310,121 +410,133 @@ class Scrcpy(
)
Log.i(TAG, "listOptions(): cmd=$serverCommand")
// Execute shell command and capture output (merge stderr into stdout)
val output = adbService.shell("$serverCommand 2>&1")
// Parse output based on list option
return@withContext when (list) {
ListOptions.NULL -> {
throw IllegalArgumentException("Nothing to do with ListOptions.NULL")
}
ListOptions.ENCODERS -> {
val parsed = parseEncoderLists(output)
val preview = output.lineSequence().take(PREVIEW_LINES).joinToString("\n")
Log.i(
TAG,
"listOptions(ENCODERS): parsed video=${parsed.videoEncoders.size} audio=${parsed.audioEncoders.size}, outputPreview=\n$preview",
)
ListResult.Encoders(
videoEncoders = parsed.videoEncoders,
audioEncoders = parsed.audioEncoders,
videoEncoderTypes = parsed.videoEncoderTypes,
audioEncoderTypes = parsed.audioEncoderTypes,
rawOutput = output,
)
}
ListOptions.DISPLAYS -> {
throw Exception("TODO")
}
ListOptions.CAMERAS -> {
throw Exception("TODO")
}
ListOptions.CAMERA_SIZES -> {
val parsed = parseCameraSizeLists(output)
val preview = output.lineSequence().take(PREVIEW_LINES).joinToString("\n")
Log.i(
TAG,
"listOptions(CAMERA_SIZES): parsed sizes=${parsed.sizes.size}, outputPreview=\n$preview",
)
ListResult.CameraSizes(
sizes = parsed.sizes,
rawOutput = output,
)
}
else -> {
throw IllegalArgumentException("Unsupported list option: $list")
}
}
adbService.shell("$serverCommand 2>&1")
}
private fun parseEncoderLists(output: String): ParsedEncoders {
val video = LinkedHashSet<String>()
val audio = LinkedHashSet<String>()
val videoTypes = linkedMapOf<String, String>()
val audioTypes = linkedMapOf<String, String>()
VIDEO_ENCODER_REGEX.findAll(output).forEach { match ->
video.add(match.groupValues[1])
}
AUDIO_ENCODER_REGEX.findAll(output).forEach { match ->
audio.add(match.groupValues[1])
}
// Fallback for log formats that include codec+encoder in one line.
VIDEO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match ->
video.add(match.groupValues[1])
}
AUDIO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match ->
audio.add(match.groupValues[1])
}
VIDEO_ENCODER_TYPE_REGEX.findAll(output).forEach { match ->
val name = match.groupValues[1]
val type = match.groupValues[2]
if (name.isNotBlank() && type.isNotBlank() && !videoTypes.containsKey(name)) {
videoTypes[name] = type
}
}
AUDIO_ENCODER_TYPE_REGEX.findAll(output).forEach { match ->
val name = match.groupValues[1]
val type = match.groupValues[2]
if (name.isNotBlank() && type.isNotBlank() && !audioTypes.containsKey(name)) {
audioTypes[name] = type
}
}
return ParsedEncoders(
videoEncoders = video.toList(),
audioEncoders = audio.toList(),
videoEncoderTypes = videoTypes,
audioEncoderTypes = audioTypes,
)
private fun logListPreview(list: ListOptions, countSummary: String, output: String) {
val preview = output.lineSequence().take(32).joinToString("\n")
Log.i(TAG, "listOptions($list): parsed $countSummary, outputPreview=\n$preview")
}
private fun parseCameraSizeLists(output: String): ParsedCameraSizes {
private fun parseEncoders(output: String): Pair<List<EncoderInfo>, List<EncoderInfo>> {
val videoInfos = linkedMapOf<String, EncoderInfo>()
val audioInfos = linkedMapOf<String, EncoderInfo>()
VIDEO_ENCODER_INFO_REGEX.findAll(output).forEach { match ->
val info = EncoderInfo(
codec = Codec.fromString(match.groupValues[1], Codec.Type.VIDEO),
id = match.groupValues[2],
type = if (match.groupValues[3] == EncoderType.HARDWARE.s) {
EncoderType.HARDWARE
} else {
EncoderType.SOFTWARE
},
isVendor = match.groupValues[4].isNotBlank(),
aliasOf = match.groupValues[5].ifBlank { null },
)
videoInfos.putIfAbsent(info.id, info)
}
AUDIO_ENCODER_INFO_REGEX.findAll(output).forEach { match ->
val info = EncoderInfo(
codec = Codec.fromString(match.groupValues[1], Codec.Type.AUDIO),
id = match.groupValues[2],
type = if (match.groupValues[3] == EncoderType.HARDWARE.s) {
EncoderType.HARDWARE
} else {
EncoderType.SOFTWARE
},
isVendor = match.groupValues[4].isNotBlank(),
aliasOf = match.groupValues[5].ifBlank { null },
)
audioInfos.putIfAbsent(info.id, info)
}
return videoInfos.values.toList() to audioInfos.values.toList()
}
private fun parseDisplays(output: String): List<DisplayInfo> {
val displays = LinkedHashSet<DisplayInfo>()
DISPLAY_REGEX.findAll(output).forEach { match ->
displays.add(
DisplayInfo(
id = match.groupValues[1].toInt(),
width = match.groupValues[2].toInt(),
height = match.groupValues[3].toInt(),
)
)
}
return displays.toList()
}
private fun parseCameras(output: String): List<CameraInfo> {
val cameras = LinkedHashSet<CameraInfo>()
CAMERA_INFO_REGEX.findAll(output).forEach { match ->
val facing = match.groupValues[2]
val activeSize = match.groupValues[3]
val fpsValues = match.groupValues[4]
.split(',')
.mapNotNull { it.trim().toIntOrNull() }
cameras.add(
CameraInfo(
id = match.groupValues[1],
facing = CameraFacing.fromString(facing),
activeSize = activeSize,
fps = fpsValues.map(Int::toUShort),
)
)
}
return cameras.toList()
}
private fun parseCameraSizes(output: String): List<String> {
val sizes = LinkedHashSet<String>()
CAMERA_SIZE_REGEX.findAll(output).forEach { match ->
sizes.add(match.groupValues[1])
}
CAMERA_SIZE_FALLBACK_REGEX.findAll(output).forEach { match ->
sizes.add(match.groupValues[1])
}
return ParsedCameraSizes(sizes = sizes.toList())
return sizes.toList()
}
private data class ParsedEncoders(
val videoEncoders: List<String>,
val audioEncoders: List<String>,
val videoEncoderTypes: Map<String, String>,
val audioEncoderTypes: Map<String, String>,
private fun parseApps(output: String): List<AppInfo> {
val apps = LinkedHashSet<AppInfo>()
APP_REGEX.findAll(output).forEach { match ->
apps.add(
AppInfo(
system = match.groupValues[1] == "*",
label = match.groupValues[2].trim(),
packageName = match.groupValues[3].trim(),
)
)
}
return apps.toList()
}
data class EncoderInfo(
val codec: Codec,
val id: String,
val type: EncoderType,
val isVendor: Boolean,
val aliasOf: String? = null,
)
private data class ParsedCameraSizes(
val sizes: List<String>,
data class CameraInfo(
val id: String,
val facing: CameraFacing,
val activeSize: String,
val fps: List<UShort>,
)
data class DisplayInfo(
val id: Int,
val width: Int,
val height: Int,
)
data class AppInfo(
val system: Boolean,
val label: String,
val packageName: String,
)
private suspend fun executeServer(
@@ -502,8 +614,6 @@ class Scrcpy(
@Volatile
private var audioReaderThread: Thread? = null
@Volatile
private var lastServerCommand: String? = null
private val serverLogBuffer = ArrayDeque<String>()
suspend fun start(
@@ -516,7 +626,6 @@ class Scrcpy(
val socketName = socketNameFor(scid.toInt())
try {
lastServerCommand = serverCommand
val serverStream = adbService.openShellStream(serverCommand)
val serverLogThread = startServerLogThread(serverStream, socketName)
Thread.sleep(SERVER_BOOT_DELAY_MS)
@@ -567,27 +676,58 @@ class Scrcpy(
}
val deviceName = readDeviceName(firstInput)
val audioCodecId =
if (options.audio) audioCodecIdFromName(options.audioCodec.string)
else 0
val codecId: Int
val audioCodecId = if (options.audio) {
val aInput = checkNotNull(audioInput)
when (val streamCodecId = aInput.readInt()) {
AUDIO_DISABLED -> {
Log.w(TAG, "audio disabled by server")
if (options.requireAudio) {
throw IllegalStateException(
"Audio is required but was disabled by the server"
)
}
0
}
AUDIO_ERROR -> {
Log.e(TAG, "audio stream configuration error from server")
if (options.requireAudio) {
throw IllegalStateException(
"Audio is required but the server failed to configure audio capture"
)
}
0
}
else -> {
Log.i(
TAG,
"audio stream codec=0x${streamCodecId.toUInt().toString(16)}"
)
streamCodecId
}
}
} else {
0
}
val videoCodecId: Int
val width: Int
val height: Int
if (options.video) {
val vInput = checkNotNull(videoInput)
codecId = vInput.readInt()
videoCodecId = vInput.readInt()
width = vInput.readInt()
height = vInput.readInt()
} else {
codecId = 0
videoCodecId = 0
width = 0
height = 0
}
val sessionInfo = SessionInfo(
deviceName = deviceName,
codecId = codecId,
codecName = codecName(codecId),
codecId = videoCodecId,
codec = Codec.fromId(videoCodecId, Codec.Type.VIDEO),
width = width,
height = height,
audioCodecId = audioCodecId,
@@ -669,9 +809,7 @@ class Scrcpy(
}
}
suspend fun clearVideoConsumer() = mutex.withLock {
videoConsumer = null
}
suspend fun clearVideoConsumer() = mutex.withLock { videoConsumer = null }
suspend fun attachAudioConsumer(consumer: (AudioPacket) -> Unit): Unit = mutex.withLock {
val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
@@ -682,31 +820,6 @@ class Scrcpy(
audioReaderThread = thread(start = true, name = "scrcpy-audio-reader") {
try {
val streamCodecId = try {
aInput.readInt()
} catch (e: Exception) {
Log.w(TAG, "audio codec header read failed", e)
return@thread
}
when (streamCodecId) {
AUDIO_DISABLED -> {
Log.w(TAG, "audio disabled by server")
return@thread
}
AUDIO_ERROR -> {
Log.e(TAG, "audio stream configuration error from server")
return@thread
}
else -> {
Log.i(
TAG,
"audio stream codec=0x${streamCodecId.toUInt().toString(16)}"
)
}
}
while (activeSession === session && !aStream.closed) {
try {
val ptsAndFlags = aInput.readLong()
@@ -742,18 +855,20 @@ class Scrcpy(
}
}
suspend fun clearAudioConsumer() = mutex.withLock {
audioConsumer = null
}
suspend fun clearAudioConsumer() = mutex.withLock { audioConsumer = null }
suspend fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) =
mutex.withLock {
try {
requireControlWriter().injectKeycode(action, keycode, repeat, metaState)
} catch (e: IllegalStateException) {
Log.w(TAG, "injectKeycode(): control channel not available", e)
}
suspend fun injectKeycode(
action: Int,
keycode: Int,
repeat: Int = 0,
metaState: Int = 0,
) = mutex.withLock {
try {
requireControlWriter().injectKeycode(action, keycode, repeat, metaState)
} catch (e: IllegalStateException) {
Log.w(TAG, "injectKeycode(): control channel not available", e)
}
}
suspend fun injectText(text: String) = mutex.withLock {
try {
@@ -798,7 +913,7 @@ class Scrcpy(
screenHeight: Int,
hScroll: Float,
vScroll: Float,
buttons: Int
buttons: Int,
) = mutex.withLock {
try {
requireControlWriter().injectScroll(
@@ -815,9 +930,9 @@ class Scrcpy(
}
}
suspend fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock {
suspend fun pressBackOrTurnScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock {
try {
requireControlWriter().pressBackOrScreenOn(action)
requireControlWriter().pressBackOrTurnScreenOn(action)
} catch (e: IllegalStateException) {
Log.w(TAG, "pressBackOrScreenOn(): control channel not available", e)
}
@@ -865,8 +980,6 @@ class Scrcpy(
fun isStarted(): Boolean = activeSession != null
fun getLastServerCommand(): String? = lastServerCommand
private fun requireControlWriter(): ControlWriter {
val session = activeSession
?: throw IllegalStateException("scrcpy control channel not available")
@@ -948,27 +1061,10 @@ class Scrcpy(
return buffer.copyOf(length).toString(Charsets.UTF_8)
}
private fun codecName(codecId: Int) =
when (codecId) {
VIDEO_CODEC_H264 -> "h264"
VIDEO_CODEC_H265 -> "h265"
VIDEO_CODEC_AV1 -> "av1"
else -> "unknown"
}
private fun audioCodecIdFromName(name: String) =
when (name.lowercase()) {
"opus" -> AUDIO_CODEC_OPUS
"aac" -> AUDIO_CODEC_AAC
"raw" -> AUDIO_CODEC_RAW
"flac" -> AUDIO_CODEC_FLAC
else -> 0
}
data class SessionInfo(
val deviceName: String,
val codecId: Int,
val codecName: String,
val codec: Codec?,
val width: Int,
val height: Int,
val audioCodecId: Int = 0,
@@ -1100,7 +1196,7 @@ class Scrcpy(
}
@Synchronized
fun pressBackOrScreenOn(action: Int) {
fun pressBackOrTurnScreenOn(action: Int) {
output.writeByte(TYPE_BACK_OR_SCREEN_ON)
output.writeByte(action)
output.flush()
@@ -1151,14 +1247,6 @@ class Scrcpy(
private const val PACKET_FLAG_KEY_FRAME = 1L shl 62
private const val PACKET_PTS_MASK = (1L shl 62) - 1
private const val VIDEO_CODEC_H264 = 0x68323634
private const val VIDEO_CODEC_H265 = 0x68323635
private const val VIDEO_CODEC_AV1 = 0x00617631
private const val AUDIO_CODEC_OPUS = 0x6f707573
private const val AUDIO_CODEC_AAC = 0x00616163
private const val AUDIO_CODEC_FLAC = 0x666c6163
private const val AUDIO_CODEC_RAW = 0x00726177
private const val AUDIO_DISABLED = 0
private const val AUDIO_ERROR = 1

View File

@@ -125,7 +125,7 @@ data class ServerParams(
cmd.add("audio=false")
}
if (audioBitRate > 0) {
if (audioCodec.isLossyAudio()!!) {
if (!audioCodec.isLossless) {
// 比官方实现多个判断编解码器类型
cmd.add("audio_bit_rate=$audioBitRate")
}

View File

@@ -38,7 +38,6 @@ class Shared {
fun Duration.toTick(): Tick = Tick(this.inWholeMicroseconds)
}
enum class ListOptions(val value: Int) {
NULL(0x0),
ENCODERS(0x1), // --list-encoders
@@ -52,6 +51,11 @@ class Shared {
infix fun has(other: ListOptions) = this and other != 0
}
enum class EncoderType(val s: String) {
HARDWARE("hw"),
SOFTWARE("sw"),
}
enum class LogLevel(val string: String) {
VERBOSE("verbose"),
DEBUG("debug"),
@@ -60,26 +64,27 @@ class Shared {
ERROR("error");
}
enum class Codec(val string: String, val displayName: String) {
H264("h264", "H.264"), // default, ignore when passing
H265("h265", "H.265"),
AV1("av1", "AV1"),
OPUS("opus", "Opus"), // default, ignore when passing
AAC("aac", "AAC"),
FLAC("flac", "FLAC"),
RAW("raw", "RAW"); // wav raw
enum class Codec(
val string: String,
val displayName: String,
val type: Type,
val id: Int,
val isLossless: Boolean,
) {
H264("h264", "H.264", Type.VIDEO, 0x68323634, false), // default, ignore when passing
H265("h265", "H.265", Type.VIDEO, 0x68323635, false),
AV1("av1", "AV1", Type.VIDEO, 0x00617631, false),
fun isLossyAudio(): Boolean? = when (this) {
OPUS, AAC -> true
FLAC, RAW -> false
else -> null
}
OPUS("opus", "Opus", Type.AUDIO, 0x6f707573, false), // default, ignore when passing
AAC("aac", "AAC", Type.AUDIO, 0x00616163, false),
FLAC("flac", "FLAC", Type.AUDIO, 0x666c6163, true),
RAW("raw", "RAW", Type.AUDIO, 0x00726177, true); // wav raw
enum class Type { VIDEO, AUDIO }
companion object {
val VIDEO = listOf(H264, H265, AV1)
val AUDIO = listOf(OPUS, AAC, FLAC, RAW)
val VIDEO = entries.filter { it.type == Type.VIDEO }
val AUDIO = entries.filter { it.type == Type.AUDIO }
fun fromString(value: String, type: Type = Type.VIDEO) =
entries.find { it.string.equals(value, ignoreCase = true) }
@@ -87,6 +92,9 @@ class Shared {
Type.VIDEO -> H264
Type.AUDIO -> OPUS
}
fun fromId(value: Int, type: Type? = null): Codec? =
entries.find { it.id == value && (type == null || it.type == type) }
}
}
@@ -177,4 +185,4 @@ class Shared {
FALLBACK("fallback"),
HIDE("hide");
}
}
}

View File

@@ -1,64 +0,0 @@
package io.github.miuzarte.scrcpyforandroid.services
import android.content.Context
import androidx.core.content.edit
import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys
import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
internal fun loadQuickDevices(context: Context): List<DeviceShortcut> {
val raw = context.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE)
.getString(AppPreferenceKeys.QUICK_DEVICES, "")
.orEmpty()
if (raw.isBlank()) return emptyList()
val result = mutableListOf<DeviceShortcut>()
raw.lineSequence().forEach { line ->
val parts = line.split("|", limit = 3)
when (parts.size) {
3 -> {
val name = parts[0].trim()
val host = parts[1].trim()
val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT
if (host.isNotBlank()) {
result.add(
DeviceShortcut(
name = name,
host = host,
port = port,
online = false,
),
)
}
}
2 -> {
// Backward compatibility with old format: name|host:port
val name = parts[0].trim()
val host = parts[1].substringBefore(":").trim()
val port = parts[1].substringAfter(":", Defaults.ADB_PORT.toString()).trim()
.toIntOrNull() ?: Defaults.ADB_PORT
if (host.isNotBlank()) {
result.add(
DeviceShortcut(
name = name,
host = host,
port = port,
online = false,
),
)
}
}
}
}
return result
}
internal fun saveQuickDevices(context: Context, quickDevices: List<DeviceShortcut>) {
val raw = quickDevices.joinToString("\n") { "${it.name}|${it.host}|${it.port}" }
context.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE)
.edit {
putString(AppPreferenceKeys.QUICK_DEVICES, raw)
}
}

View File

@@ -0,0 +1,31 @@
package io.github.miuzarte.scrcpyforandroid.services
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.SnackbarDuration
import top.yukonga.miuix.kmp.basic.SnackbarHostState
class SnackbarController(
private val scope: CoroutineScope,
val hostState: SnackbarHostState,
val defaultActionLabel: String? = null,
val defaultWithDismissAction: Boolean? = null,
val defaultDuration: SnackbarDuration? = null,
) {
fun show(
message: String,
actionLabel: String? = defaultActionLabel,
withDismissAction: Boolean = defaultWithDismissAction ?: true,
duration: SnackbarDuration = defaultDuration ?: SnackbarDuration.Short,
scope: CoroutineScope = this.scope,
) {
scope.launch {
hostState.showSnackbar(
message = message,
actionLabel = actionLabel,
withDismissAction = withDismissAction,
duration = duration,
)
}
}
}

View File

@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import kotlinx.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize
@@ -47,9 +48,13 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
stringPreferencesKey("custom_server_uri"),
""
)
val CUSTOM_SERVER_VERSION = Pair(
stringPreferencesKey("custom_server_version"),
""
)
val SERVER_REMOTE_PATH = Pair(
stringPreferencesKey("server_remote_path"),
"/data/local/tmp/scrcpy-server.jar"
Scrcpy.DEFAULT_REMOTE_PATH,
)
val ADB_KEY_NAME = Pair(
stringPreferencesKey("adb_key_name"),
@@ -83,6 +88,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
// Scrcpy Server Settings
val customServerUri by setting(CUSTOM_SERVER_URI)
val customServerVersion by setting(CUSTOM_SERVER_VERSION)
val serverRemotePath by setting(SERVER_REMOTE_PATH)
// ADB Settings
@@ -102,6 +108,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val previewVirtualButtonShowText: Boolean,
val virtualButtonsLayout: String,
val customServerUri: String,
val customServerVersion: String,
val serverRemotePath: String,
val adbKeyName: String,
val adbPairingAutoDiscoverOnDialogOpen: Boolean,
@@ -120,6 +127,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText },
bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout },
bundleField(CUSTOM_SERVER_URI) { bundle: Bundle -> bundle.customServerUri },
bundleField(CUSTOM_SERVER_VERSION) { bundle: Bundle -> bundle.customServerVersion },
bundleField(SERVER_REMOTE_PATH) { bundle: Bundle -> bundle.serverRemotePath },
bundleField(ADB_KEY_NAME) { bundle: Bundle -> bundle.adbKeyName },
bundleField(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN) { bundle: Bundle -> bundle.adbPairingAutoDiscoverOnDialogOpen },
@@ -139,6 +147,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT),
virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT),
customServerUri = preferences.read(CUSTOM_SERVER_URI),
customServerVersion = preferences.read(CUSTOM_SERVER_VERSION),
serverRemotePath = preferences.read(SERVER_REMOTE_PATH),
adbKeyName = preferences.read(ADB_KEY_NAME),
adbPairingAutoDiscoverOnDialogOpen = preferences.read(

View File

@@ -8,6 +8,7 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
@@ -155,7 +156,7 @@ abstract class Settings(
val scope = rememberCoroutineScope()
val state = asState(pair)
return remember(state.value) {
return rememberSaveable(state.value) {
object : MutableState<T> {
override var value: T
get() = state.value

View File

@@ -50,6 +50,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
@@ -75,10 +76,13 @@ import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -88,7 +92,6 @@ import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.CardDefaults
import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.SnackbarHostState
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField
@@ -157,7 +160,7 @@ internal fun StatusCard(
),
secondSmall = StatusSmallCardSpec(
"编解码器",
sessionInfo.codecName,
sessionInfo.codec?.displayName ?: "null",
),
)
}
@@ -295,7 +298,7 @@ internal fun PreviewCard(
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(UiSpacing.CardContent),
.padding(UiSpacing.ContentVertical),
) {
Button(
onClick = {
@@ -341,7 +344,7 @@ internal fun VirtualButtonCard(
onAction = onAction,
modifier = Modifier
.fillMaxWidth()
.padding(UiSpacing.CardContent),
.padding(UiSpacing.ContentVertical),
)
}
}
@@ -349,9 +352,10 @@ internal fun VirtualButtonCard(
@Composable
internal fun ConfigPanel(
busy: Boolean,
snack: SnackbarHostState,
snackbar: SnackbarController,
audioForwardingSupported: Boolean,
cameraMirroringSupported: Boolean,
adbConnecting: Boolean,
isQuickConnected: Boolean,
onOpenAdvanced: () -> Unit,
onStartStopHaptic: (() -> Unit)? = null,
@@ -360,7 +364,7 @@ internal fun ConfigPanel(
sessionInfo: Scrcpy.Session.SessionInfo?,
onDisconnect: () -> Unit = {},
) {
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val sessionStarted = sessionInfo != null
@@ -381,7 +385,7 @@ internal fun ConfigPanel(
}
DisposableEffect(Unit) {
onDispose {
scope.launch {
taskScope.launch {
scrcpyOptions.saveBundle(soBundleLatest)
}
}
@@ -405,7 +409,6 @@ internal fun ConfigPanel(
.coerceAtLeast(0)
}
SectionSmallTitle("Scrcpy")
Card {
SuperSwitch(
title = "音频转发",
@@ -425,33 +428,29 @@ internal fun ConfigPanel(
val codec = Codec.AUDIO[it]
soBundle = soBundle.copy(audioCodec = codec.string)
if (codec == Codec.FLAC) {
scope.launch {
snack.showSnackbar("注意FLAC编解码会引入较大的延迟")
}
snackbar.show("注意FLAC编解码会引入较大的延迟")
}
},
enabled = !sessionStarted && soBundle.audio,
)
AnimatedVisibility(audioBitRateVisibility) {
SuperSlider(
title = "音频码率",
summary = "--audio-bit-rate",
value = if (soBundle.audioBitRate <= 0) 0f
else (ScrcpyPresets.AudioBitRate
.indexOfOrNearest(soBundle.audioBitRate / 1000) + 1
).toFloat(),
onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.size)
soBundle = soBundle.copy(
audioBitRate =
if (idx == 0) 0
else ScrcpyPresets.AudioBitRate[idx - 1] * 1000
)
},
valueRange = 0f..ScrcpyPresets.AudioBitRate.size.toFloat(),
steps = (ScrcpyPresets.AudioBitRate.size - 1).coerceAtLeast(0),
unit = "Kbps",
zeroStateText = "默认",
SuperSlider(
title = "音频码率",
summary = "--audio-bit-rate",
value = ScrcpyPresets.AudioBitRate
.indexOfOrNearest(soBundle.audioBitRate / 1000)
.toFloat(),
onValueChange = { value ->
val idx = value.roundToInt()
.coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex)
soBundle = soBundle.copy(
audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000
)
},
valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(),
steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0),
unit = "Kbps",
zeroStateText = "默认",
displayText = (soBundle.audioBitRate / 1_000).toString(),
inputInitialValue =
if (soBundle.audioBitRate <= 0) ""
@@ -475,9 +474,7 @@ internal fun ConfigPanel(
val codec = Codec.VIDEO[it]
soBundle = soBundle.copy(videoCodec = codec.string)
if (codec == Codec.AV1) {
scope.launch {
snack.showSnackbar("注意绝大部分设备不支持AV1硬件编码")
}
snackbar.show("注意绝大部分设备不支持AV1硬件编码")
}
},
enabled = !sessionStarted,
@@ -528,8 +525,8 @@ internal fun ConfigPanel(
)
SuperArrow(
title = "高级参数",
summary = "更多 scrcpy 启动参数",
title = "更多参数",
summary = "所有 scrcpy 参数",
onClick = onOpenAdvanced,
enabled = !sessionStarted,
)
@@ -537,8 +534,8 @@ internal fun ConfigPanel(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
.padding(horizontal = UiSpacing.ContentVertical)
.padding(bottom = UiSpacing.ContentVertical),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
if (isQuickConnected) TextButton(
@@ -601,9 +598,10 @@ private fun PairingDialog(
var code by rememberSaveable(showDialog) { mutableStateOf("") }
var discoveringPort by rememberSaveable(showDialog) { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
suspend fun doDiscover() {
if (onDiscoverTarget == null || discoveringPort || !enabled) return
if (!(enabled && onDiscoverTarget != null && !discoveringPort)) return
discoveringPort = true
val found = withContext(Dispatchers.IO) { onDiscoverTarget.invoke() }
if (found != null) {
@@ -619,13 +617,6 @@ private fun PairingDialog(
}
}
fun clearInputs() {
host = ""
port = ""
code = ""
discoveringPort = false
}
SuperDialog(
show = showDialog,
title = "使用配对码配对设备",
@@ -634,80 +625,94 @@ private fun PairingDialog(
onDismissRequest()
},
onDismissFinished = {
clearInputs()
onDismissFinished()
},
content = {
TextField(
value = host,
onValueChange = { host = it },
label = "IP 地址",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = UiSpacing.CardContent),
)
TextField(
value = port,
onValueChange = { port = it.filter(Char::isDigit) },
label = "端口",
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = UiSpacing.CardContent),
)
TextField(
value = code,
onValueChange = { code = it },
label = "WLAN 配对码",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = UiSpacing.CardContent),
)
TextButton(
text = if (discoveringPort) "发现中..." else "自动发现",
onClick = {
if (onDiscoverTarget == null || discoveringPort || !enabled) return@TextButton
scope.launch {
doDiscover()
}
},
enabled = enabled && onDiscoverTarget != null && !discoveringPort,
modifier = Modifier
.fillMaxWidth()
.padding(
top = UiSpacing.Medium,
bottom = UiSpacing.CardContent,
Column(
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
) {
TextField(
value = host,
onValueChange = { host = it },
label = "IP 地址",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
)
Row(
modifier = Modifier
.padding(bottom = UiSpacing.PopupHorizontal),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.PopupHorizontal),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) },
),
modifier = Modifier.fillMaxWidth()
)
TextField(
value = port,
onValueChange = { port = it.filter(Char::isDigit) },
label = "端口",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Next) },
),
modifier = Modifier.fillMaxWidth()
)
TextField(
value = code,
onValueChange = { code = it },
label = "WLAN 配对码",
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { focusManager.clearFocus() },
),
modifier = Modifier.fillMaxWidth()
)
}
Spacer(Modifier.height(UiSpacing.ContentVertical * 2))
Column(
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
) {
TextButton(
text = "取消",
text = if (!discoveringPort) "自动发现" else "发现中...",
onClick = {
onDismissRequest()
if (enabled && onDiscoverTarget != null && !discoveringPort)
scope.launch { doDiscover() }
},
modifier = Modifier.weight(1f),
)
TextButton(
text = "配对",
onClick = {
onConfirm(host.trim(), port.trim(), code.trim())
onDismissRequest()
},
enabled = enabled &&
host.trim().isNotBlank() &&
port.trim().isNotBlank() &&
code.trim().isNotBlank(),
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary(),
enabled = enabled && onDiscoverTarget != null && !discoveringPort,
modifier = Modifier.fillMaxWidth(),
)
Row(
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal),
) {
TextButton(
text = "取消",
onClick = {
onDismissRequest()
},
modifier = Modifier.weight(1f),
)
TextButton(
text = "配对",
onClick = {
onConfirm(host.trim(), port.trim(), code.trim())
onDismissRequest()
},
enabled = enabled &&
host.trim().isNotBlank() &&
port.trim().isNotBlank() &&
code.trim().isNotBlank(),
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
}
},
)
@@ -816,9 +821,9 @@ fun FullscreenControlScreen(
Box(
modifier = Modifier
.align(Alignment.TopStart)
.padding(start = UiSpacing.CardContent, top = UiSpacing.CardContent)
.padding(start = UiSpacing.ContentVertical, top = UiSpacing.ContentVertical)
.background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = UiSpacing.CardContent, vertical = UiSpacing.Medium),
.padding(horizontal = UiSpacing.ContentVertical, vertical = UiSpacing.Medium),
) {
Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.Tiny)) {
Text(
@@ -877,6 +882,7 @@ private fun ScrcpyVideoSurface(
) {
var currentSurface by remember { mutableStateOf<Surface?>(null) }
val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
LaunchedEffect(session, currentSurface) {
val surface = currentSurface
@@ -889,7 +895,7 @@ private fun ScrcpyVideoSurface(
onDispose {
val surface = currentSurface
if (surface != null) {
scope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) }
taskScope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) }
}
}
}
@@ -924,7 +930,7 @@ private fun ScrcpyVideoSurface(
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
val surface = currentSurface
if (surface != null) {
scope.launch {
taskScope.launch {
nativeCore.detachVideoSurface(surface, releaseDecoder = false)
}
surface.release()
@@ -944,48 +950,70 @@ private fun ScrcpyVideoSurface(
@Composable
internal fun DeviceTile(
device: DeviceShortcut,
actionText: String,
isConnected: Boolean,
actionEnabled: Boolean,
actionInProgress: Boolean,
onLongPress: () -> Unit,
onContentClick: () -> Unit,
editing: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onAction: () -> Unit,
onEditorSave: (DeviceShortcut) -> Unit,
onEditorDelete: () -> Unit,
onEditorCancel: () -> Unit,
) {
val haptics = rememberAppHaptics()
var name by rememberSaveable(device.id) { mutableStateOf(device.name) }
var host by rememberSaveable(device.id) { mutableStateOf(device.host) }
var port by rememberSaveable(device.id) { mutableStateOf(device.port.toString()) }
LaunchedEffect(device) {
name = device.name
host = device.host
port = device.port.toString()
}
Card(
colors = CardDefaults.defaultColors(
color = if (device.online) MiuixTheme.colorScheme.surfaceContainer
else MiuixTheme.colorScheme.surfaceContainer.copy(alpha = 0.6f),
color =
if (isConnected) MiuixTheme.colorScheme.surfaceContainer
else MiuixTheme.colorScheme.surfaceContainer.copy(alpha = 0.6f),
),
pressFeedbackType = PressFeedbackType.Sink,
pressFeedbackType = if (!editing) PressFeedbackType.Sink else PressFeedbackType.None,
onClick = haptics.contextClick,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = onContentClick,
onLongClick = onLongPress,
.then(
if (!isConnected)
Modifier.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
else Modifier
)
.padding(UiSpacing.PageItem),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.weight(1f)
.padding(end = UiSpacing.CardContent),
.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
// statu dot
Box(
modifier = Modifier
.size(8.dp)
.background(
color = if (device.online) Color(0xFF44C74F) else MiuixTheme.colorScheme.outline,
color =
if (isConnected) Color(0xFF44C74F)
else MiuixTheme.colorScheme.outline,
shape = CircleShape,
),
)
Spacer(modifier = Modifier.width(UiSpacing.PageItem))
Column(modifier = Modifier.weight(1f)) {
// device name/address
Column {
Text(
device.name.ifBlank { device.host },
fontWeight = FontWeight.SemiBold,
@@ -1003,20 +1031,121 @@ internal fun DeviceTile(
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (actionInProgress) {
CircularProgressIndicator(progress = null)
Spacer(Modifier.width(UiSpacing.Medium))
}
TextButton(
text = actionText,
text = if (!isConnected) "连接" else "断开",
onClick = onAction,
enabled = actionEnabled && !actionInProgress,
)
}
}
AnimatedVisibility(editing) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.ContentVertical)
.padding(bottom = UiSpacing.ContentVertical),
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical)
) {
SuperTextField(
value = name,
onValueChange = { name = it },
label = "设备名称",
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
SuperTextField(
value = host,
onValueChange = { host = it },
label = "IP 地址",
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
SuperTextField(
value = port,
onValueChange = { port = it.filter(Char::isDigit) },
label = "端口",
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
) {
TextButton(
text = "取消",
onClick = onEditorCancel,
modifier = Modifier.weight(1f),
)
TextButton(
text = "删除",
onClick = onEditorDelete,
modifier = Modifier.weight(1f),
)
TextButton(
text = "保存",
onClick = {
val trimmedHost = host.trim()
if (trimmedHost.isNotBlank()) {
onEditorSave(
DeviceShortcut(
name = name.trim(),
host = trimmedHost,
port = port.toIntOrNull() ?: Defaults.ADB_PORT,
),
)
}
},
modifier = Modifier.weight(1f),
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
}
}
}
}
@Composable
internal fun DeviceTileList(
devices: List<DeviceShortcut>,
isConnected: (DeviceShortcut) -> Boolean,
actionEnabled: Boolean,
actionInProgress: (DeviceShortcut) -> Boolean,
editingDeviceId: String?,
onClick: (DeviceShortcut) -> Unit,
onLongClick: (DeviceShortcut) -> Unit,
onAction: (DeviceShortcut) -> Unit,
onEditorSave: (DeviceShortcut, DeviceShortcut) -> Unit,
onEditorDelete: (DeviceShortcut) -> Unit,
onEditorCancel: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
) {
devices.forEach { device ->
DeviceTile(
device = device,
isConnected = isConnected(device),
actionEnabled = actionEnabled,
actionInProgress = actionInProgress(device),
editing = editingDeviceId == device.id,
onClick = { onClick(device) },
onLongClick = { onLongClick(device) },
onAction = { onAction(device) },
onEditorSave = { updated -> onEditorSave(device, updated) },
onEditorDelete = { onEditorDelete(device) },
onEditorCancel = onEditorCancel,
)
}
}
}
@@ -1029,161 +1158,58 @@ internal fun QuickConnectCard(
onAddDevice: () -> Unit,
enabled: Boolean = true,
) {
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
Card(
colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.primaryContainer),
pressFeedbackType = if (enabled) PressFeedbackType.Tilt else PressFeedbackType.None,
pressFeedbackType =
if (enabled) PressFeedbackType.Tilt
else PressFeedbackType.None,
insideMargin = PaddingValues(UiSpacing.Content),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
start = UiSpacing.Large,
end = UiSpacing.Large,
top = UiSpacing.PageItem,
bottom = UiSpacing.Small,
),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Rounded.AddLink,
contentDescription = "快速连接",
tint = MiuixTheme.colorScheme.onPrimaryContainer,
)
Spacer(modifier = Modifier.width(UiSpacing.Medium))
Text(
"快速连接",
fontWeight = FontWeight.Bold,
color = MiuixTheme.colorScheme.onPrimaryContainer,
)
}
SuperTextField(
value = input,
onValueChange = onValueChange,
label = "IP:PORT",
enabled = enabled,
useLabelAsPlaceholder = true,
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.SectionTitleLeadingGap),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
onFocusLost = onFocusChange,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
TextButton(
text = "添加设备",
onClick = onAddDevice,
modifier = Modifier.weight(1f),
enabled = enabled,
)
TextButton(
text = "连接",
onClick = onConnect,
modifier = Modifier.weight(1f),
enabled = enabled,
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}
}
}
@Composable
internal fun DeviceEditorScreen(
contentPadding: PaddingValues,
device: DeviceShortcut,
onSave: (DeviceShortcut) -> Unit,
onDelete: () -> Unit,
onBack: () -> Unit,
) {
var name by rememberSaveable(device.id) { mutableStateOf(device.name) }
var host by rememberSaveable(device.id) { mutableStateOf(device.host) }
var port by rememberSaveable(device.id) { mutableStateOf(device.port.toString()) }
BackHandler(enabled = true, onBack = onBack)
Column(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding)
.padding(UiSpacing.PageHorizontal),
) {
SectionSmallTitle("编辑设备")
Card {
TextField(
value = name,
onValueChange = { name = it },
label = "设备名称",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(top = UiSpacing.CardContent),
)
TextField(
value = host,
onValueChange = { host = it },
label = "IP 地址",
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(top = UiSpacing.CardContent),
)
TextField(
value = port,
onValueChange = { port = it.filter(Char::isDigit) },
label = "端口",
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(top = UiSpacing.CardContent),
)
Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical)) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(UiSpacing.CardContent),
modifier = Modifier.padding(horizontal = UiSpacing.Small),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) {
Icon(
Icons.Rounded.AddLink,
contentDescription = "快速连接",
tint = MiuixTheme.colorScheme.onPrimaryContainer,
)
Text(
"快速连接",
fontWeight = FontWeight.Bold,
color = MiuixTheme.colorScheme.onPrimaryContainer,
)
}
SuperTextField(
value = input,
onValueChange = onValueChange,
label = "IP:PORT",
enabled = enabled,
useLabelAsPlaceholder = true,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
onFocusLost = onFocusChange,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal),
) {
TextButton(
text = "返回",
onClick = onBack,
text = "添加设备",
onClick = onAddDevice,
modifier = Modifier.weight(1f),
enabled = enabled,
)
TextButton(
text = "删除",
onClick = onDelete,
modifier = Modifier.weight(1f),
)
TextButton(
text = "保存",
onClick = {
val p = port.toIntOrNull() ?: Defaults.ADB_PORT
val h = host.trim()
if (h.isNotBlank()) {
onSave(
DeviceShortcut(
name = name.trim(),
host = h,
port = p,
online = device.online,
),
)
}
},
text = "连接",
onClick = onConnect,
modifier = Modifier.weight(1f),
enabled = enabled,
colors = ButtonDefaults.textButtonColorsPrimary(),
)
}

View File

@@ -78,44 +78,40 @@ class ReorderableList(
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small)
) {
if (item.icon != null) {
Icon(
item.icon,
contentDescription = item.title
)
Spacer(Modifier.padding(horizontal = 4.dp))
}
if (item.icon != null) Icon(
imageVector = item.icon,
contentDescription = item.title
)
Column {
Text(
text = item.title,
color = MiuixTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
if (item.subtitle.isNotBlank()) {
Text(
text = item.subtitle,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
fontSize = 13.sp,
)
}
if (item.subtitle.isNotBlank()) Text(
text = item.subtitle,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
fontSize = 13.sp,
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small)
) {
if (showCheckbox) {
Checkbox(
state = if (item.checked) ToggleableState.On else ToggleableState.Off,
onClick = {
onCheckboxChange?.invoke(item.id, !item.checked)
},
enabled = item.checkboxEnabled
)
Spacer(Modifier.padding(horizontal = 4.dp))
}
if (showCheckbox) Checkbox(
state = if (item.checked) ToggleableState.On else ToggleableState.Off,
onClick = {
onCheckboxChange?.invoke(item.id, !item.checked)
},
enabled = item.checkboxEnabled
)
IconButton(
onClick = {},
onClick = {
haptics.contextClick()
},
modifier = Modifier
.draggableHandle(
onDragStarted = {
@@ -193,7 +189,7 @@ class ReorderableList(
)
}
}
Spacer(Modifier.padding(UiSpacing.CardContent))
Spacer(Modifier.padding(UiSpacing.ContentVertical))
Text(
text = item.title,
color = MiuixTheme.colorScheme.onSurface,

View File

@@ -302,7 +302,7 @@ class VirtualButtonBar(
action.icon,
contentDescription = action.title,
modifier = Modifier
.padding(end = UiSpacing.CardContent),
.padding(end = UiSpacing.ContentVertical),
)
},
title = action.title,