enhance: Settings and Virtual Button Management

- 用 `reorderable` 重构排序组件
- 重构虚拟按钮配置储存逻辑
- 虚拟图标现在可以选择是否显示文字描述
- 统一所有图标风格为 `Rounded`
- `StatusCard` 状态卡大卡的颜色现在优先取决于应用内设置,而非系统设置
This commit is contained in:
Miuzarte
2026-03-22 17:32:24 +08:00
parent 4c7cf64b98
commit 57d2af638a
18 changed files with 598 additions and 502 deletions

View File

@@ -96,6 +96,7 @@ dependencies {
implementation("org.lsposed.libcxx:libcxx:27.0.12077973")
implementation("org.bouncycastle:bcpkix-jdk18on:1.80")
implementation("org.conscrypt:conscrypt-android:2.5.2")
implementation("sh.calvin.reorderable:reorderable:3.0.0")
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))

View File

@@ -65,9 +65,9 @@ object AppDefaults {
const val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = true
const val KEEP_SCREEN_ON_WHEN_STREAMING = false
const val DEVICE_PREVIEW_CARD_HEIGHT_DP = 320
const val VIRTUAL_BUTTONS_OUTSIDE = "more,home,back"
const val VIRTUAL_BUTTONS_IN_MORE =
"app_switch,menu,notification,volume_up,volume_down,volume_mute,power,screenshot"
const val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = true
const val VIRTUAL_BUTTONS_LAYOUT =
"more:1,app_switch:1,home:0,back:1,menu:0,notification:0,volume_up:0,volume_down:0,volume_mute:0,power:0,screenshot:0"
const val CUSTOM_SERVER_URI = ""

View File

@@ -67,8 +67,8 @@ object AppPreferenceKeys {
const val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = "show_fullscreen_virtual_buttons"
const val KEEP_SCREEN_ON_WHEN_STREAMING = "keep_screen_on_when_streaming"
const val DEVICE_PREVIEW_CARD_HEIGHT_DP = "device_preview_card_height_dp"
const val VIRTUAL_BUTTONS_OUTSIDE = "virtual_buttons_outside"
const val VIRTUAL_BUTTONS_IN_MORE = "virtual_buttons_in_more"
const val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = "preview_virtual_button_show_text"
const val VIRTUAL_BUTTONS_LAYOUT = "virtual_buttons_layout"
const val CUSTOM_SERVER_URI = "custom_server_uri"

View File

@@ -7,26 +7,87 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun performHaptics(type: HapticFeedbackType): Unit {
LocalHapticFeedback.current.performHapticFeedback(type)
}
@Immutable
data class AppHaptics(
val press: () -> Unit,
val confirm: () -> Unit,
val contextClick: () -> Unit,
val gestureEnd: () -> Unit,
val gestureThresholdActivate: () -> Unit,
val keyboardTap: () -> Unit,
val longPress: () -> Unit,
val reject: () -> Unit,
val segmentFrequentTick: () -> Unit,
val segmentTick: () -> Unit,
val textHandleMove: () -> Unit,
val toggleOff: () -> Unit,
val toggleOn: () -> Unit,
val virtualKey: () -> Unit,
)
@Composable
fun rememberAppHaptics(): AppHaptics {
val hapticFeedback = LocalHapticFeedback.current
val pressAction = rememberUpdatedState {
val performConfirm = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm)
}
val performContextClick = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick)
}
val confirmAction = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm)
val performGestureEnd = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
}
val performGestureThresholdActivate = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureThresholdActivate)
}
val performKeyboardTan = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.KeyboardTap)
}
val performLongPress = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
val performReject = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.Reject)
}
val performSegmentFrequentTick = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
val performSegmentTick = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
val performTextHandleMove = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
val performToggleOff = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.ToggleOff)
}
val performToggleOn = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.ToggleOn)
}
val performVirtualKey = rememberUpdatedState {
hapticFeedback.performHapticFeedback(HapticFeedbackType.VirtualKey)
}
return remember {
AppHaptics(
press = { pressAction.value.invoke() },
confirm = { confirmAction.value.invoke() },
confirm = { performConfirm.value.invoke() },
contextClick = { performContextClick.value.invoke() },
gestureEnd = { performGestureEnd.value.invoke() },
gestureThresholdActivate = { performGestureThresholdActivate.value.invoke() },
keyboardTap = { performKeyboardTan.value.invoke() },
longPress = { performLongPress.value.invoke() },
reject = { performReject.value.invoke() },
segmentFrequentTick = { performSegmentFrequentTick.value.invoke() },
segmentTick = { performSegmentTick.value.invoke() },
textHandleMove = { performTextHandleMove.value.invoke() },
toggleOff = { performToggleOff.value.invoke() },
toggleOn = { performToggleOn.value.invoke() },
virtualKey = { performVirtualKey.value.invoke() },
)
}
}

View File

@@ -5,7 +5,6 @@ import android.util.Log
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -48,11 +47,8 @@ import io.github.miuzarte.scrcpyforandroid.widgets.LogsPanel
import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard
import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard
import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
import io.github.miuzarte.scrcpyforandroid.widgets.SortDropPayload
import io.github.miuzarte.scrcpyforandroid.widgets.SortTransferDirection
import io.github.miuzarte.scrcpyforandroid.widgets.SortableCardItem
import io.github.miuzarte.scrcpyforandroid.widgets.SortableCardList
import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
@@ -73,7 +69,6 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
import kotlin.math.roundToInt
private const val ADB_CONNECT_TIMEOUT_MS = 3_000L
private const val ADB_KEEPALIVE_INTERVAL_MS = 3_000L
@@ -125,9 +120,10 @@ fun DeviceTabScreen(
nativeCore: NativeCoreFacade,
snack: SnackbarHostState,
scrollBehavior: ScrollBehavior,
virtualButtonsOutside: List<String>,
virtualButtonsInMore: List<String>,
virtualButtonsLayout: String,
showPreviewVirtualButtonText: Boolean,
previewCardHeightDp: Int,
themeBaseIndex: Int,
customServerUri: String?,
serverRemotePath: String,
onServerRemotePathChange: (String) -> Unit,
@@ -218,8 +214,8 @@ fun DeviceTabScreen(
) {
val context = LocalContext.current
val haptics = rememberAppHaptics()
val virtualButtonLayout = remember(virtualButtonsOutside, virtualButtonsInMore) {
VirtualButtonActions.resolveLayout(virtualButtonsOutside, virtualButtonsInMore)
val virtualButtonLayout = remember(virtualButtonsLayout) {
VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout))
}
val initialSettings = remember(context) { loadDevicePageSettings(context) }
val scope = rememberCoroutineScope()
@@ -945,34 +941,33 @@ fun DeviceTabScreen(
SuperBottomSheet(
show = showReorderSheet,
title = "调整设备排序",
title = "快速设备排序",
onDismissRequest = { showReorderSheet = false },
) {
SortableCardList(
title = "设备列表",
items = quickDevices.map { device ->
SortableCardItem(
id = device.id,
title = device.name.ifBlank { device.host },
subtitle = "${device.host}:${device.port}",
)
},
modifier = Modifier.padding(horizontal = UiSpacing.CardContent),
transferDirection = SortTransferDirection.NONE,
onLongPressHaptic = { haptics.press() },
onDrop = { payload: SortDropPayload ->
val fromIndex = quickDevices.indexOfFirst { it.id == payload.itemId }
if (fromIndex < 0) return@SortableCardList
val steps = (payload.deltaY / 54f).roundToInt()
if (steps == 0) return@SortableCardList
val toIndex = (fromIndex + steps).coerceIn(0, quickDevices.lastIndex)
if (toIndex == fromIndex) return@SortableCardList
val moved = quickDevices.removeAt(fromIndex)
quickDevices.add(toIndex, moved)
saveQuickDevices(context, quickDevices)
},
)
Spacer(Modifier.height(UiSpacing.Large))
val list = remember {
ReorderableList(
{
quickDevices.map { device ->
ReorderableList.Item(
id = device.id,
title = device.name.ifBlank { device.host },
subtitle = "${device.host}:${device.port}",
)
}
},
onSettle = { fromIndex, toIndex ->
if (fromIndex < 0) return@ReorderableList
val to = toIndex.coerceIn(0, quickDevices.size)
if (fromIndex == to) return@ReorderableList
val moved = quickDevices.removeAt(fromIndex)
quickDevices.add(to.coerceIn(0, quickDevices.size), moved)
saveQuickDevices(context, quickDevices)
},
)
}
list()
Spacer(Modifier.height(UiSpacing.BottomSheetBottom))
}
fun sendVirtualButtonAction(action: VirtualButtonAction) {
@@ -1022,6 +1017,7 @@ fun DeviceTabScreen(
sessionInfo = sessionInfo,
busyLabel = null,
connectedDeviceLabel = connectedDeviceLabel,
themeBaseIndex = themeBaseIndex,
)
}
@@ -1043,7 +1039,7 @@ fun DeviceTabScreen(
}
},
onAction = {
haptics.press()
haptics.contextClick()
if (isConnectedTarget) {
activeDeviceActionId = device.id
runAdbConnect("断开 ADB", onFinished = { activeDeviceActionId = null }) {
@@ -1114,7 +1110,7 @@ fun DeviceTabScreen(
}
},
)
SectionSmallTitle(text = "无线配对")
SectionSmallTitle("无线配对")
// "使用配对码配对设备"
PairingCard(
busy = busy,
@@ -1167,7 +1163,7 @@ fun DeviceTabScreen(
audioCodec = audioCodec,
onAudioCodecChange = onAudioCodecChange,
onOpenAdvanced = onOpenAdvancedPage,
onStartStopHaptic = { haptics.press() },
onStartStopHaptic = { haptics.contextClick() },
onStart = {
runBusy("启动 scrcpy") {
if (noVideo && !audioEnabled) {
@@ -1316,17 +1312,13 @@ fun DeviceTabScreen(
previewHeightDp = previewCardHeightDp.coerceAtLeast(120),
controlsVisible = previewControlsVisible,
onTapped = {
previewControlsVisible = true
scope.launch {
delay(2000)
previewControlsVisible = false
}
previewControlsVisible = !previewControlsVisible
},
onOpenFullscreen = {
val info = sessionInfo ?: return@PreviewCard
onOpenFullscreenPage(info)
},
onOpenFullscreenHaptic = { haptics.press() },
onOpenFullscreenHaptic = { haptics.contextClick() },
)
}
item {
@@ -1334,8 +1326,7 @@ fun DeviceTabScreen(
busy = busy,
outsideActions = virtualButtonLayout.first,
moreActions = virtualButtonLayout.second,
onPressHaptic = { haptics.press() },
onConfirmHaptic = { haptics.confirm() },
showText = showPreviewVirtualButtonText,
onAction = ::sendVirtualButtonAction,
)
}

View File

@@ -38,8 +38,7 @@ data class FullscreenControlLaunch(
fun FullscreenControlPage(
launch: FullscreenControlLaunch,
nativeCore: NativeCoreFacade,
virtualButtonsOutside: List<String>,
virtualButtonsInMore: List<String>,
virtualButtonsLayout: String,
showDebugInfo: Boolean,
showVirtualButtons: Boolean,
onVideoSizeChanged: (width: Int, height: Int) -> Unit,
@@ -51,8 +50,8 @@ fun FullscreenControlPage(
val context = LocalContext.current
val haptics = rememberAppHaptics()
val activity = remember(context) { context as? Activity }
val virtualButtonLayout = remember(virtualButtonsOutside, virtualButtonsInMore) {
VirtualButtonActions.resolveLayout(virtualButtonsOutside, virtualButtonsInMore)
val virtualButtonLayout = remember(virtualButtonsLayout) {
VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout))
}
val bar = remember(virtualButtonLayout) {
VirtualButtonBar(
@@ -150,8 +149,6 @@ fun FullscreenControlPage(
if (showVirtualButtons) {
bar.Fullscreen(
modifier = Modifier.align(Alignment.BottomCenter),
onPressHaptic = { haptics.press() },
onConfirmHaptic = { haptics.confirm() },
onAction = { action ->
action.keycode?.let(::sendKeycode)
},

View File

@@ -19,9 +19,9 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.rounded.Devices
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -54,7 +54,6 @@ import io.github.miuzarte.scrcpyforandroid.services.MainSettings
import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings
import io.github.miuzarte.scrcpyforandroid.services.loadMainSettings
import io.github.miuzarte.scrcpyforandroid.services.saveMainSettings
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -82,8 +81,8 @@ private enum class MainTabDestination(
val label: String,
val icon: ImageVector,
) {
Device(title = "设备", label = "设备", icon = Icons.Default.Devices),
Settings(title = "设置", label = "设置", icon = Icons.Default.Settings),
Device(title = "设备", label = "设备", icon = Icons.Rounded.Devices),
Settings(title = "设置", label = "设置", icon = Icons.Rounded.Settings),
}
private sealed interface RootScreen : NavKey {
@@ -134,14 +133,10 @@ fun MainPage() {
var monetEnabled by rememberSaveable { mutableStateOf(initialSettings.monetEnabled) }
var fullscreenDebugInfoEnabled by rememberSaveable { mutableStateOf(initialSettings.fullscreenDebugInfoEnabled) }
var showFullscreenVirtualButtons by rememberSaveable { mutableStateOf(initialSettings.showFullscreenVirtualButtons) }
var showPreviewVirtualButtonText by rememberSaveable { mutableStateOf(initialSettings.showPreviewVirtualButtonText) }
var keepScreenOnWhenStreamingEnabled by rememberSaveable { mutableStateOf(initialSettings.keepScreenOnWhenStreamingEnabled) }
var devicePreviewCardHeightDp by rememberSaveable { mutableIntStateOf(initialSettings.devicePreviewCardHeightDp) }
var virtualButtonsOutside by rememberSaveable(stateSaver = stringListSaver) {
mutableStateOf(VirtualButtonActions.parseStoredIds(initialSettings.virtualButtonsOutside))
}
var virtualButtonsInMore by rememberSaveable(stateSaver = stringListSaver) {
mutableStateOf(VirtualButtonActions.parseStoredIds(initialSettings.virtualButtonsInMore))
}
var virtualButtonsLayout by rememberSaveable { mutableStateOf(initialSettings.virtualButtonsLayout) }
var customServerUri by rememberSaveable { mutableStateOf(initialSettings.customServerUri) }
var serverRemotePath by rememberSaveable { mutableStateOf(initialSettings.serverRemotePath) }
var adbKeyName by rememberSaveable { mutableStateOf(initialSettings.adbKeyName) }
@@ -241,10 +236,10 @@ fun MainPage() {
monetEnabled,
fullscreenDebugInfoEnabled,
showFullscreenVirtualButtons,
showPreviewVirtualButtonText,
keepScreenOnWhenStreamingEnabled,
devicePreviewCardHeightDp,
virtualButtonsOutside,
virtualButtonsInMore,
virtualButtonsLayout,
customServerUri,
serverRemotePath,
adbKeyName,
@@ -262,10 +257,10 @@ fun MainPage() {
monetEnabled = monetEnabled,
fullscreenDebugInfoEnabled = fullscreenDebugInfoEnabled,
showFullscreenVirtualButtons = showFullscreenVirtualButtons,
showPreviewVirtualButtonText = showPreviewVirtualButtonText,
keepScreenOnWhenStreamingEnabled = keepScreenOnWhenStreamingEnabled,
devicePreviewCardHeightDp = devicePreviewCardHeightDp,
virtualButtonsOutside = VirtualButtonActions.encodeStoredIds(virtualButtonsOutside),
virtualButtonsInMore = VirtualButtonActions.encodeStoredIds(virtualButtonsInMore),
virtualButtonsLayout = virtualButtonsLayout,
customServerUri = customServerUri,
serverRemotePath = serverRemotePath,
adbKeyName = adbKeyName,
@@ -398,7 +393,7 @@ fun MainPage() {
holdDownState = showDeviceMenu,
) {
Icon(
Icons.Default.MoreVert,
Icons.Rounded.MoreVert,
contentDescription = "更多"
)
}
@@ -429,8 +424,10 @@ fun MainPage() {
nativeCore = nativeCore,
snack = snackHostState,
scrollBehavior = deviceScrollBehavior,
virtualButtonsOutside = virtualButtonsOutside,
virtualButtonsInMore = virtualButtonsInMore,
virtualButtonsLayout = virtualButtonsLayout,
showPreviewVirtualButtonText = showPreviewVirtualButtonText,
previewCardHeightDp = devicePreviewCardHeightDp,
themeBaseIndex = themeBaseIndex,
customServerUri = customServerUri,
serverRemotePath = serverRemotePath,
onServerRemotePathChange = { serverRemotePath = it },
@@ -556,7 +553,6 @@ fun MainPage() {
),
)
},
previewCardHeightDp = devicePreviewCardHeightDp,
adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice,
adbMdnsLanDiscoveryEnabled = adbMdnsLanDiscoveryEnabled,
@@ -589,6 +585,12 @@ fun MainPage() {
onDevicePreviewCardHeightDpChange = {
devicePreviewCardHeightDp = it.coerceAtLeast(120)
},
onOpenReorderDevices = {
openReorderDevicesAction?.invoke()
},
onOpenVirtualButtonOrder = {
rootBackStack.add(RootScreen.VirtualButtonOrder)
},
showFullscreenVirtualButtons = showFullscreenVirtualButtons,
onShowFullscreenVirtualButtonsChange = {
showFullscreenVirtualButtons = it
@@ -756,12 +758,12 @@ fun MainPage() {
VirtualButtonOrderPage(
contentPadding = pagePadding,
scrollBehavior = advancedScrollBehavior,
outsideIds = virtualButtonsOutside,
moreIds = virtualButtonsInMore,
onLayoutChange = { outside, more ->
virtualButtonsOutside = outside
virtualButtonsInMore = more
layoutString = virtualButtonsLayout,
onLayoutChange = { layout ->
virtualButtonsLayout = layout
},
showPreviewText = showPreviewVirtualButtonText,
onShowPreviewTextChange = { showPreviewVirtualButtonText = it },
)
}
}
@@ -770,8 +772,7 @@ fun MainPage() {
FullscreenControlPage(
launch = screen.launch,
nativeCore = nativeCore,
virtualButtonsOutside = virtualButtonsOutside,
virtualButtonsInMore = virtualButtonsInMore,
virtualButtonsLayout = virtualButtonsLayout,
showDebugInfo = fullscreenDebugInfoEnabled,
showVirtualButtons = showFullscreenVirtualButtons,
onVideoSizeChanged = { width, height ->
@@ -817,7 +818,7 @@ private fun DeviceMenuPopup(
) {
ListPopupColumn {
DeviceMenuPopupItem(
text = "调整设备排序",
text = "快速设备排序",
optionSize = 3,
index = 0,
onClick = onReorderDevices,

View File

@@ -1,5 +1,6 @@
package io.github.miuzarte.scrcpyforandroid.pages
import android.content.Intent
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -7,11 +8,13 @@ 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.filled.Clear
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.FileOpen
import androidx.compose.runtime.Composable
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.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
@@ -23,6 +26,7 @@ import top.yukonga.miuix.kmp.basic.IconButton
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.extra.SuperArrow
import top.yukonga.miuix.kmp.extra.SuperDropdown
import top.yukonga.miuix.kmp.extra.SuperSwitch
import top.yukonga.miuix.kmp.theme.ColorSchemeMode
@@ -66,6 +70,8 @@ fun SettingsScreen(
devicePreviewCardHeightDp: Int,
onDevicePreviewCardHeightDpChange: (Int) -> Unit,
showFullscreenVirtualButtons: Boolean,
onOpenReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit,
onShowFullscreenVirtualButtonsChange: (Boolean) -> Unit,
customServerUri: String?,
onPickServer: () -> Unit,
@@ -81,6 +87,7 @@ fun SettingsScreen(
scrollBehavior: ScrollBehavior,
) {
val baseModeItems = THEME_BASE_OPTIONS.map { it.label }
val context = LocalContext.current
// 设置
AppPageLazyColumn(
@@ -88,7 +95,7 @@ fun SettingsScreen(
scrollBehavior = scrollBehavior,
) {
item {
SectionSmallTitle(text = "主题")
SectionSmallTitle("主题")
Card {
SuperDropdown(
title = "外观模式",
@@ -105,7 +112,7 @@ fun SettingsScreen(
)
}
SectionSmallTitle(text = "投屏")
SectionSmallTitle("投屏")
Card {
SuperSwitch(
title = "启用调试信息",
@@ -114,8 +121,8 @@ fun SettingsScreen(
onCheckedChange = onFullscreenDebugInfoEnabledChange,
)
SuperSwitch(
title = "投屏时不允许息屏",
summary = "Scrcpy 启动后保持本机常亮,避免锁屏导致 ADB 断开",
title = "投屏时保持屏幕常亮",
summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开",
checked = keepScreenOnWhenStreamingEnabled,
onCheckedChange = onKeepScreenOnWhenStreamingEnabledChange,
)
@@ -140,6 +147,16 @@ fun SettingsScreen(
?.let { onDevicePreviewCardHeightDpChange(it.coerceAtLeast(120)) }
},
)
SuperArrow(
title = "快速设备排序",
summary = "手动排序设备页的快速设备",
onClick = onOpenReorderDevices,
)
SuperArrow(
title = "虚拟按钮排序",
summary = "手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外",
onClick = onOpenVirtualButtonOrder,
)
SuperSwitch(
title = "全屏显示虚拟按钮",
summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮",
@@ -148,7 +165,7 @@ fun SettingsScreen(
)
}
SectionSmallTitle(text = "scrcpy-server")
SectionSmallTitle("scrcpy-server")
Card {
Spacer(modifier = Modifier.padding(top = UiSpacing.CardContent))
Text(
@@ -171,10 +188,10 @@ fun SettingsScreen(
trailingIcon = {
Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) {
if (customServerUri != null) IconButton(onClick = onClearServer) {
Icon(Icons.Default.Clear, contentDescription = "清空")
Icon(Icons.Rounded.Clear, contentDescription = "清空")
}
IconButton(onClick = onPickServer) {
Icon(Icons.Default.FolderOpen, contentDescription = "选择文件")
Icon(Icons.Rounded.FileOpen, contentDescription = "选择文件")
}
}
},
@@ -199,7 +216,7 @@ fun SettingsScreen(
)
}
SectionSmallTitle(text = "ADB")
SectionSmallTitle("ADB")
Card {
Text(
text = "自定义 ADB 密钥名",
@@ -232,6 +249,21 @@ fun SettingsScreen(
onCheckedChange = onAdbAutoReconnectPairedDeviceChange,
)
}
SectionSmallTitle("关于")
Card {
SuperArrow(
title = "前往仓库",
summary = "github.com/Miuzarte/ScrcpyForAndroid",
onClick = {
val intent = Intent(
Intent.ACTION_VIEW,
"https://github.com/Miuzarte/ScrcpyForAndroid".toUri()
)
context.startActivity(intent)
},
)
}
}
// TODO: 放进 [AppPageLazyColumn] 里

View File

@@ -1,169 +1,86 @@
package io.github.miuzarte.scrcpyforandroid.pages
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Modifier
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import androidx.compose.runtime.setValue
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.widgets.SortDropPayload
import io.github.miuzarte.scrcpyforandroid.widgets.SortTransferDirection
import io.github.miuzarte.scrcpyforandroid.widgets.SortableCardItem
import io.github.miuzarte.scrcpyforandroid.widgets.SortableCardList
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.ScrollBehavior
import top.yukonga.miuix.kmp.basic.Text
import kotlin.math.roundToInt
private const val LIST_REORDER_STEP_PX = 54f
private const val LIST_TRANSFER_STEP_PX = 72f
import top.yukonga.miuix.kmp.extra.SuperSwitch
@Composable
internal fun VirtualButtonOrderPage(
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
outsideIds: List<String>,
moreIds: List<String>,
onLayoutChange: (outside: List<String>, more: List<String>) -> Unit,
layoutString: String,
onLayoutChange: (String) -> Unit,
showPreviewText: Boolean,
onShowPreviewTextChange: (Boolean) -> Unit,
) {
val haptics = rememberAppHaptics()
val normalized = remember(outsideIds, moreIds) {
VirtualButtonActions.resolveLayout(outsideIds, moreIds)
}
val outsideState = remember { normalized.first.map { it.id }.toMutableStateList() }
val moreState = remember { normalized.second.map { it.id }.toMutableStateList() }
LaunchedEffect(outsideIds, moreIds) {
val resolved = VirtualButtonActions.resolveLayout(outsideIds, moreIds)
outsideState.clear()
outsideState.addAll(resolved.first.map { it.id })
moreState.clear()
moreState.addAll(resolved.second.map { it.id })
var buttonItems by remember(layoutString) {
mutableStateOf(VirtualButtonActions.parseStoredLayout(layoutString))
}
fun emit() {
onLayoutChange(outsideState.toList(), moreState.toList())
}
fun reorderInside(
list: androidx.compose.runtime.snapshots.SnapshotStateList<String>,
itemId: String,
deltaY: Float
) {
val fromIndex = list.indexOf(itemId)
if (fromIndex < 0) return
val steps = (deltaY / LIST_REORDER_STEP_PX).roundToInt()
if (steps == 0) return
val toIndex = (fromIndex + steps).coerceIn(0, list.lastIndex)
if (toIndex == fromIndex) return
val moved = list.removeAt(fromIndex)
list.add(toIndex, moved)
emit()
}
fun transferToOther(
from: androidx.compose.runtime.snapshots.SnapshotStateList<String>,
to: androidx.compose.runtime.snapshots.SnapshotStateList<String>,
itemId: String,
deltaY: Float,
) {
val fromIndex = from.indexOf(itemId)
if (fromIndex < 0) return
val steps = (deltaY / LIST_REORDER_STEP_PX).roundToInt()
val baseIndex = fromIndex + steps
val insertIndex = baseIndex.coerceIn(0, to.size)
from.removeAt(fromIndex)
to.add(insertIndex, itemId)
emit()
}
fun handleOutsideDrop(payload: SortDropPayload) {
val transfer =
payload.transferDirection == SortTransferDirection.TO_RIGHT && payload.deltaX >= LIST_TRANSFER_STEP_PX
if (transfer) {
if (payload.itemId == VirtualButtonAction.MORE.id) return
transferToOther(outsideState, moreState, payload.itemId, payload.deltaY)
} else {
reorderInside(outsideState, payload.itemId, payload.deltaY)
}
}
fun handleMoreDrop(payload: SortDropPayload) {
val transfer =
payload.transferDirection == SortTransferDirection.TO_LEFT && payload.deltaX <= -LIST_TRANSFER_STEP_PX
if (transfer) {
transferToOther(moreState, outsideState, payload.itemId, payload.deltaY)
} else {
reorderInside(moreState, payload.itemId, payload.deltaY)
}
fun emitChanges() {
onLayoutChange(VirtualButtonActions.encodeStoredLayout(buttonItems))
}
AppPageLazyColumn(
contentPadding = contentPadding,
scrollBehavior = scrollBehavior,
) {
// 按钮显示文本开关
item {
Card(modifier = Modifier.fillMaxWidth()) {
Text(
text = "长按可在两列间拖动排序\n“更多”不可移入右侧菜单",
modifier = Modifier.padding(UiSpacing.CardContent),
Card {
SuperSwitch(
title = "按钮显示文本",
summary = "超过3个建议关闭只对预览卡下方的虚拟按钮生效",
checked = showPreviewText,
onCheckedChange = onShowPreviewTextChange,
)
}
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
) {
SortableCardList(
title = "外部按钮",
items = outsideState.map { id ->
val action = VirtualButtonAction.entries.first { it.id == id }
SortableCardItem(
ReorderableList(
itemsProvider = {
buttonItems.map { item ->
val action = item.action
ReorderableList.Item(
id = action.id,
title = action.title,
subtitle = "显示在预览与全屏底部",
icon = action.icon,
title = if (action.keycode == null) action.title else "${action.title} (${action.keycode})",
subtitle = if (item.showOutside) "显示在外部" else "显示在更多菜单内",
checked = item.showOutside,
checkboxEnabled = action != VirtualButtonAction.MORE,
)
},
modifier = Modifier
.weight(1f)
.padding(end = UiSpacing.Small),
transferDirection = SortTransferDirection.TO_RIGHT,
onLongPressHaptic = haptics.press,
onDrop = ::handleOutsideDrop,
)
SortableCardList(
title = "更多菜单",
items = moreState.map { id ->
val action = VirtualButtonAction.entries.first { it.id == id }
SortableCardItem(
id = action.id,
title = action.title,
subtitle = "显示在“更多”弹窗中",
)
},
modifier = Modifier
.weight(1f)
.padding(start = UiSpacing.Small),
transferDirection = SortTransferDirection.TO_LEFT,
onLongPressHaptic = haptics.press,
onDrop = ::handleMoreDrop,
)
}
}
},
orientation = ReorderableList.Orientation.Column,
onSettle = { fromIndex, toIndex ->
buttonItems = buttonItems.toMutableList().apply {
add(toIndex, removeAt(fromIndex))
}
emitChanges()
},
showCheckbox = true,
onCheckboxChange = { id, checked ->
buttonItems = buttonItems.map { item ->
if (item.action.id == id) {
item.copy(showOutside = checked)
} else {
item
}
}
emitChanges()
},
)()
}
}
}

View File

@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -22,6 +24,7 @@ fun AppPageLazyColumn(
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
itemSpacing: Dp = UiSpacing.PageItem,
horizontalPadding: Dp = UiSpacing.PageHorizontal,
verticalPadding: Dp = UiSpacing.PageVertical,
@@ -44,6 +47,7 @@ fun AppPageLazyColumn(
.overScrollVertical()
.nestedScroll(scrollBehavior.nestedScrollConnection)
.padding(contentPadding),
state = state,
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = verticalPadding),
verticalArrangement = Arrangement.spacedBy(itemSpacing),
content = content,

View File

@@ -13,10 +13,10 @@ internal data class MainSettings(
val monetEnabled: Boolean = AppDefaults.MONET,
val fullscreenDebugInfoEnabled: Boolean = AppDefaults.FULLSCREEN_DEBUG_INFO,
val showFullscreenVirtualButtons: Boolean = AppDefaults.SHOW_FULLSCREEN_VIRTUAL_BUTTONS,
val showPreviewVirtualButtonText: Boolean = AppDefaults.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT,
val keepScreenOnWhenStreamingEnabled: Boolean = AppDefaults.KEEP_SCREEN_ON_WHEN_STREAMING,
val devicePreviewCardHeightDp: Int = AppDefaults.DEVICE_PREVIEW_CARD_HEIGHT_DP,
val virtualButtonsOutside: String = AppDefaults.VIRTUAL_BUTTONS_OUTSIDE,
val virtualButtonsInMore: String = AppDefaults.VIRTUAL_BUTTONS_IN_MORE,
val virtualButtonsLayout: String = AppDefaults.VIRTUAL_BUTTONS_LAYOUT,
val customServerUri: String? = AppDefaults.CUSTOM_SERVER_URI,
val serverRemotePath: String = AppDefaults.SERVER_REMOTE_PATH_INPUT,
val adbKeyName: String = AppDefaults.ADB_KEY_NAME_INPUT,
@@ -101,6 +101,10 @@ internal fun loadMainSettings(context: Context): MainSettings {
AppPreferenceKeys.SHOW_FULLSCREEN_VIRTUAL_BUTTONS,
AppDefaults.SHOW_FULLSCREEN_VIRTUAL_BUTTONS,
),
showPreviewVirtualButtonText = prefs.getBoolean(
AppPreferenceKeys.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT,
AppDefaults.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT,
),
keepScreenOnWhenStreamingEnabled = prefs.getBoolean(
AppPreferenceKeys.KEEP_SCREEN_ON_WHEN_STREAMING,
AppDefaults.KEEP_SCREEN_ON_WHEN_STREAMING,
@@ -109,14 +113,10 @@ internal fun loadMainSettings(context: Context): MainSettings {
AppPreferenceKeys.DEVICE_PREVIEW_CARD_HEIGHT_DP,
AppDefaults.DEVICE_PREVIEW_CARD_HEIGHT_DP,
).coerceAtLeast(120),
virtualButtonsOutside = prefs.getString(
AppPreferenceKeys.VIRTUAL_BUTTONS_OUTSIDE,
AppDefaults.VIRTUAL_BUTTONS_OUTSIDE,
).orEmpty().ifBlank { AppDefaults.VIRTUAL_BUTTONS_OUTSIDE },
virtualButtonsInMore = prefs.getString(
AppPreferenceKeys.VIRTUAL_BUTTONS_IN_MORE,
AppDefaults.VIRTUAL_BUTTONS_IN_MORE,
).orEmpty().ifBlank { AppDefaults.VIRTUAL_BUTTONS_IN_MORE },
virtualButtonsLayout = prefs.getString(
AppPreferenceKeys.VIRTUAL_BUTTONS_LAYOUT,
AppDefaults.VIRTUAL_BUTTONS_LAYOUT,
).orEmpty().ifBlank { AppDefaults.VIRTUAL_BUTTONS_LAYOUT },
customServerUri = prefs.getString(
AppPreferenceKeys.CUSTOM_SERVER_URI,
AppDefaults.CUSTOM_SERVER_URI
@@ -177,6 +177,10 @@ internal fun saveMainSettings(context: Context, settings: MainSettings) {
AppPreferenceKeys.SHOW_FULLSCREEN_VIRTUAL_BUTTONS,
settings.showFullscreenVirtualButtons,
)
.putBoolean(
AppPreferenceKeys.PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT,
settings.showPreviewVirtualButtonText,
)
.putBoolean(
AppPreferenceKeys.KEEP_SCREEN_ON_WHEN_STREAMING,
settings.keepScreenOnWhenStreamingEnabled,
@@ -186,12 +190,8 @@ internal fun saveMainSettings(context: Context, settings: MainSettings) {
settings.devicePreviewCardHeightDp.coerceAtLeast(120),
)
.putString(
AppPreferenceKeys.VIRTUAL_BUTTONS_OUTSIDE,
settings.virtualButtonsOutside,
)
.putString(
AppPreferenceKeys.VIRTUAL_BUTTONS_IN_MORE,
settings.virtualButtonsInMore,
AppPreferenceKeys.VIRTUAL_BUTTONS_LAYOUT,
settings.virtualButtonsLayout,
)
.putString(
AppPreferenceKeys.CUSTOM_SERVER_URI,

View File

@@ -30,9 +30,9 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddLink
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.rounded.AddLink
import androidx.compose.material.icons.rounded.CheckCircleOutline
import androidx.compose.material.icons.rounded.Fullscreen
import androidx.compose.material.icons.rounded.LinkOff
import androidx.compose.material.icons.rounded.Wifi
import androidx.compose.runtime.Composable
@@ -123,18 +123,28 @@ internal fun StatusCard(
sessionInfo: ScrcpySessionInfo?,
busyLabel: String?,
connectedDeviceLabel: String,
themeBaseIndex: Int,
) {
val cleanStatusLine = normalizeStatusLine(statusLine)
// 根据应用主题设置决定是否使用深色模式
val isDarkTheme = when (themeBaseIndex) {
0 -> isSystemInDarkTheme() // 跟随系统
1 -> false // 浅色
2 -> true // 深色
else -> isSystemInDarkTheme()
}
val spec = when {
streaming && sessionInfo != null -> {
val streamCardColor = when {
isDynamicColor -> MiuixTheme.colorScheme.secondaryContainer
isSystemInDarkTheme() -> Color(0xFF1A3825)
isDarkTheme -> Color(0xFF1A3825)
else -> Color(0xFFDFFAE4)
}
val streamTextColor = when {
isDynamicColor -> MiuixTheme.colorScheme.onSecondaryContainer
isSystemInDarkTheme() -> Color.White
isDarkTheme -> Color.White
else -> Color(0xFF111111)
}
val streamIconColor = if (isDynamicColor) {
@@ -300,12 +310,17 @@ internal fun PreviewCard(
) {
Button(
onClick = {
onOpenFullscreenHaptic?.invoke()
onOpenFullscreen()
if (alpha > 0.1) {
onOpenFullscreenHaptic?.invoke()
onOpenFullscreen()
}
},
modifier = Modifier.alpha(alpha),
) {
Icon(Icons.Default.Fullscreen, contentDescription = "全屏")
Icon(
Icons.Rounded.Fullscreen,
contentDescription = "全屏",
)
Spacer(Modifier.width(UiSpacing.SectionTitleBottom))
Text("全屏")
}
@@ -320,8 +335,7 @@ internal fun VirtualButtonCard(
busy: Boolean,
outsideActions: List<VirtualButtonAction>,
moreActions: List<VirtualButtonAction>,
onPressHaptic: () -> Unit,
onConfirmHaptic: () -> Unit,
showText: Boolean,
onAction: (VirtualButtonAction) -> Unit,
) {
val bar = remember(outsideActions, moreActions) {
@@ -334,8 +348,7 @@ internal fun VirtualButtonCard(
Card {
bar.Preview(
enabled = !busy,
onPressHaptic = onPressHaptic,
onConfirmHaptic = onConfirmHaptic,
showText = showText,
onAction = onAction,
modifier = Modifier
.fillMaxWidth()
@@ -374,7 +387,7 @@ internal fun ConfigPanel(
val audioBitRatePresetIndex =
presetIndexFromInput(audioBitRateKbps.toString(), ScrcpyPresets.AudioBitRate)
SectionSmallTitle(text = "Scrcpy")
SectionSmallTitle("Scrcpy")
Card {
SuperSwitch(
title = "音频转发",
@@ -1021,7 +1034,7 @@ internal fun DeviceTile(
else MiuixTheme.colorScheme.surfaceContainer.copy(alpha = 0.6f),
),
pressFeedbackType = PressFeedbackType.Sink,
onClick = haptics.press,
onClick = haptics.contextClick,
) {
Row(
modifier = Modifier
@@ -1109,7 +1122,7 @@ internal fun QuickConnectCard(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.AddLink,
Icons.Rounded.AddLink,
contentDescription = "快速连接",
tint = MiuixTheme.colorScheme.onPrimaryContainer,
)
@@ -1180,7 +1193,7 @@ internal fun DeviceEditorScreen(
.padding(contentPadding)
.padding(UiSpacing.PageHorizontal),
) {
SectionSmallTitle(text = "编辑设备")
SectionSmallTitle("编辑设备")
Card {
TextField(
value = name,

View File

@@ -0,0 +1,217 @@
package io.github.miuzarte.scrcpyforandroid.widgets
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.DragIndicator
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.state.ToggleableState
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import sh.calvin.reorderable.ReorderableColumn
import sh.calvin.reorderable.ReorderableRow
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Checkbox
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme
class ReorderableList(
private val itemsProvider: () -> List<Item>,
private val orientation: Orientation = Orientation.Column,
private val onSettle: (fromIndex: Int, toIndex: Int) -> Unit = { _, _ -> },
private val modifier: Modifier = Modifier,
private val showCheckbox: Boolean = false,
private val onCheckboxChange: ((String, Boolean) -> Unit)? = null,
) {
enum class Orientation { Column, Row }
data class Item(
val id: String,
val icon: ImageVector? = null,
val title: String,
val subtitle: String,
val checked: Boolean = true,
val checkboxEnabled: Boolean = true,
)
@Composable
operator fun invoke() {
val haptics = rememberAppHaptics()
val items = itemsProvider()
when (orientation) {
Orientation.Column -> {
ReorderableColumn(
list = items,
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(UiSpacing.Small),
onSettle = onSettle,
onMove = haptics.segmentTick,
) { _, item, _ ->
key(item.id) {
ReorderableItem {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = UiSpacing.CardTitle,
vertical = UiSpacing.FieldLabelBottom
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (item.icon != null) {
Icon(
item.icon,
contentDescription = item.title
)
Spacer(Modifier.padding(horizontal = 4.dp))
}
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,
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (showCheckbox) {
Checkbox(
state = if (item.checked) ToggleableState.On else ToggleableState.Off,
onClick = {
onCheckboxChange?.invoke(item.id, !item.checked)
},
enabled = item.checkboxEnabled
)
Spacer(Modifier.padding(horizontal = 4.dp))
}
IconButton(
onClick = {},
modifier = Modifier
.draggableHandle(
onDragStarted = {
haptics.longPress()
},
onDragStopped = {
haptics.confirm()
},
),
) {
Icon(
Icons.Rounded.DragIndicator,
contentDescription = "拖动排序",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
}
}
}
}
Orientation.Row -> {
ReorderableRow(
list = items,
modifier = modifier.fillMaxHeight(),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small),
onSettle = onSettle,
onMove = haptics.segmentTick,
) { _, item, _ ->
key(item.id) {
ReorderableItem {
Card(
modifier = Modifier.fillMaxHeight(),
) {
Column(
modifier = Modifier
.padding(
horizontal = UiSpacing.CardTitle,
vertical = UiSpacing.FieldLabelBottom
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
if (showCheckbox) {
Checkbox(
state = if (item.checked) ToggleableState.On else ToggleableState.Off,
onClick = {
onCheckboxChange?.invoke(item.id, !item.checked)
},
enabled = item.checkboxEnabled
)
Spacer(Modifier.padding(horizontal = 4.dp))
}
IconButton(
onClick = {},
modifier = Modifier
.draggableHandle(
onDragStarted = {
haptics.longPress()
},
onDragStopped = {
haptics.confirm()
},
),
) {
Icon(
Icons.Rounded.DragIndicator,
contentDescription = "拖动排序",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
Spacer(Modifier.padding(UiSpacing.CardContent))
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,
)
}
}
}
}
}
}
}
}
}
}

View File

@@ -1,158 +0,0 @@
package io.github.miuzarte.scrcpyforandroid.widgets
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DragIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.theme.MiuixTheme
enum class SortTransferDirection {
NONE,
TO_LEFT,
TO_RIGHT,
}
data class SortableCardItem(
val id: String,
val title: String,
val subtitle: String = "",
)
data class SortDropPayload(
val itemId: String,
val deltaX: Float,
val deltaY: Float,
val transferDirection: SortTransferDirection,
)
/**
* Displays a titled list of sortable cards.
*
* Each item can be long-pressed and dragged. When a drag ends the
* `onDrop` callback receives a payload describing the item id and
* the drag delta which callers can use to perform reordering or
* transfer actions.
*
* @param title The section title displayed above the list.
* @param items The list of items to render as sortable cards.
* @param modifier Optional [Modifier] applied to the outer column.
* @param transferDirection Hint for transfer direction used by callers.
* @param onLongPressHaptic Optional callback invoked when an item is long-pressed.
* @param onDrop Callback invoked when a dragged item is dropped.
*/
@Composable
fun SortableCardList(
title: String,
items: List<SortableCardItem>,
modifier: Modifier = Modifier,
transferDirection: SortTransferDirection = SortTransferDirection.NONE,
onLongPressHaptic: (() -> Unit)? = null,
onDrop: (SortDropPayload) -> Unit,
) {
Column(modifier = modifier) {
SectionSmallTitle(text = title, showLeadingSpacer = false)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(UiSpacing.FieldLabelBottom),
) {
items.forEach { item ->
var dragX by remember(item.id) { mutableFloatStateOf(0f) }
var dragY by remember(item.id) { mutableFloatStateOf(0f) }
var dragging by remember(item.id) { androidx.compose.runtime.mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.zIndex(if (dragging) 1f else 0f)
.graphicsLayer {
translationX = if (dragging) dragX else 0f
translationY = if (dragging) dragY else 0f
}
.pointerInput(item.id) {
detectDragGesturesAfterLongPress(
onDragStart = {
dragging = true
dragX = 0f
dragY = 0f
onLongPressHaptic?.invoke()
},
onDrag = { change, dragAmount ->
change.consume()
dragX += dragAmount.x
dragY += dragAmount.y
},
onDragEnd = {
onDrop(
SortDropPayload(
itemId = item.id,
deltaX = dragX,
deltaY = dragY,
transferDirection = transferDirection,
),
)
dragging = false
dragX = 0f
dragY = 0f
},
onDragCancel = {
dragging = false
dragX = 0f
dragY = 0f
},
)
},
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = UiSpacing.CardContent,
vertical = UiSpacing.FieldLabelBottom
),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
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,
)
}
}
Icon(
Icons.Default.DragIndicator,
contentDescription = "拖动排序",
tint = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
}
}

View File

@@ -81,7 +81,7 @@ internal fun StatusCardLayout(
.fillMaxHeight(),
colors = defaultColors(color = spec.big.containerColor),
pressFeedbackType = PressFeedbackType.Tilt,
onClick = haptics.press,
onClick = haptics.contextClick,
) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
@@ -158,7 +158,7 @@ private fun StatusMetricCard(spec: StatusSmallCardSpec, modifier: Modifier) {
modifier = modifier,
insideMargin = PaddingValues(UiSpacing.Large),
pressFeedbackType = PressFeedbackType.Tilt,
onClick = haptics.press,
onClick = haptics.contextClick,
) {
Text(
text = spec.title,

View File

@@ -4,21 +4,23 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.VolumeDown
import androidx.compose.material.icons.automirrored.filled.VolumeOff
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.automirrored.rounded.VolumeDown
import androidx.compose.material.icons.automirrored.rounded.VolumeOff
import androidx.compose.material.icons.automirrored.rounded.VolumeUp
import androidx.compose.material.icons.rounded.Apps
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.PowerSettingsNew
import androidx.compose.material.icons.rounded.Screenshot
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -28,15 +30,19 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.UiAndroidKeycodes
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import top.yukonga.miuix.kmp.basic.Button
import top.yukonga.miuix.kmp.basic.ButtonDefaults
import top.yukonga.miuix.kmp.basic.DropdownImpl
import top.yukonga.miuix.kmp.basic.Icon
import top.yukonga.miuix.kmp.basic.ListPopupColumn
import top.yukonga.miuix.kmp.basic.ListPopupDefaults
import top.yukonga.miuix.kmp.basic.PopupPositionProvider
import top.yukonga.miuix.kmp.basic.SpinnerDefaults
import top.yukonga.miuix.kmp.basic.SpinnerEntry
import top.yukonga.miuix.kmp.basic.SpinnerItemImpl
import top.yukonga.miuix.kmp.basic.Text
import top.yukonga.miuix.kmp.extra.SuperListPopup
import top.yukonga.miuix.kmp.theme.MiuixTheme
@@ -50,13 +56,13 @@ enum class VirtualButtonAction(
MORE(
"more",
"更多",
Icons.Default.MoreVert,
Icons.Rounded.MoreVert,
null
),
HOME(
"home",
"主页",
Icons.Default.Home,
Icons.Rounded.Home,
UiAndroidKeycodes.HOME
),
BACK(
@@ -68,97 +74,87 @@ enum class VirtualButtonAction(
APP_SWITCH(
"app_switch",
"多任务",
Icons.Default.Apps,
Icons.Rounded.Apps,
UiAndroidKeycodes.APP_SWITCH
),
MENU(
"menu",
"菜单",
Icons.Default.Menu,
Icons.Rounded.Menu,
UiAndroidKeycodes.MENU
),
NOTIFICATION(
"notification",
"通知栏",
Icons.Default.Notifications,
Icons.Rounded.Notifications,
UiAndroidKeycodes.NOTIFICATION
),
VOLUME_UP(
"volume_up",
"音量+",
Icons.AutoMirrored.Filled.VolumeUp,
Icons.AutoMirrored.Rounded.VolumeUp,
UiAndroidKeycodes.VOLUME_UP
),
VOLUME_DOWN(
"volume_down",
"音量-",
Icons.AutoMirrored.Filled.VolumeDown,
Icons.AutoMirrored.Rounded.VolumeDown,
UiAndroidKeycodes.VOLUME_DOWN
),
VOLUME_MUTE(
"volume_mute",
"静音",
Icons.AutoMirrored.Filled.VolumeOff,
Icons.AutoMirrored.Rounded.VolumeOff,
UiAndroidKeycodes.VOLUME_MUTE
),
POWER(
"power",
"锁屏",
Icons.Default.PowerSettingsNew,
Icons.Rounded.PowerSettingsNew,
UiAndroidKeycodes.POWER
),
SCREENSHOT(
"screenshot",
"截图",
Icons.Default.PhotoCamera,
Icons.Rounded.Screenshot,
UiAndroidKeycodes.SYSRQ
),
}
data class VirtualButtonItem(
val action: VirtualButtonAction,
val showOutside: Boolean,
)
object VirtualButtonActions {
val all = VirtualButtonAction.entries
private val byId = all.associateBy { it.id }
fun parseStoredIds(raw: String): List<String> {
if (raw.isBlank()) return emptyList()
fun parseStoredLayout(raw: String): List<VirtualButtonItem> {
if (raw.isBlank())
return parseStoredLayout(AppDefaults.VIRTUAL_BUTTONS_LAYOUT)
return raw.split(',').mapNotNull { item ->
val id = item.trim()
id.ifBlank { null }
val parts = item.trim().split(':')
if (parts.size != 2) return@mapNotNull null
val id = parts[0]
val showOutside = parts[1] == "1"
val action = byId[id] ?: return@mapNotNull null
VirtualButtonItem(action, showOutside)
}
}
fun encodeStoredIds(ids: List<String>): String = ids.joinToString(",")
fun resolveLayout(
outsideIds: List<String>,
moreIds: List<String>,
): Pair<List<VirtualButtonAction>, List<VirtualButtonAction>> {
val outside = mutableListOf<VirtualButtonAction>()
val overflow = mutableListOf<VirtualButtonAction>()
val used = mutableSetOf<String>()
outsideIds.forEach { id ->
val action = byId[id] ?: return@forEach
if (used.add(action.id)) outside.add(action)
fun encodeStoredLayout(items: List<VirtualButtonItem>): String {
return items.joinToString(",") { item ->
"${item.action.id}:${if (item.showOutside) "1" else "0"}"
}
}
if (used.add(VirtualButtonAction.MORE.id)) {
outside.add(VirtualButtonAction.MORE)
}
moreIds.forEach { id ->
if (id == VirtualButtonAction.MORE.id) return@forEach
val action = byId[id] ?: return@forEach
if (used.add(action.id)) overflow.add(action)
}
all.forEach { action ->
if (action == VirtualButtonAction.MORE) return@forEach
if (used.add(action.id)) overflow.add(action)
}
return outside to overflow
fun splitLayout(items: List<VirtualButtonItem>): Pair<List<VirtualButtonAction>, List<VirtualButtonAction>> {
val outside = items.filter { it.showOutside }.map { it.action }
val more = items.filter { !it.showOutside }.map { it.action }
return outside to more
}
}
@@ -169,11 +165,11 @@ class VirtualButtonBar(
@Composable
fun Preview(
enabled: Boolean,
onPressHaptic: () -> Unit,
onConfirmHaptic: () -> Unit,
showText: Boolean,
onAction: (VirtualButtonAction) -> Unit,
modifier: Modifier = Modifier,
) {
val haptics = rememberAppHaptics()
val activeContainerColor = MiuixTheme.colorScheme.primary
val disabledContainerColor = MiuixTheme.colorScheme.primary.copy(alpha = 0.35f)
val activeContentColor = MiuixTheme.colorScheme.onPrimary
@@ -188,7 +184,7 @@ class VirtualButtonBar(
Box(modifier = Modifier.weight(1f)) {
Button(
onClick = {
onPressHaptic()
haptics.contextClick()
if (action == VirtualButtonAction.MORE) {
showMorePopup = true
} else {
@@ -201,6 +197,7 @@ class VirtualButtonBar(
color = activeContainerColor,
disabledColor = disabledContainerColor,
),
insideMargin = PaddingValues(0.dp),
) {
val contentColor = if (enabled) activeContentColor else disabledContentColor
Icon(
@@ -209,15 +206,16 @@ class VirtualButtonBar(
modifier = Modifier.size(18.dp),
tint = contentColor,
)
androidx.compose.foundation.layout.Spacer(Modifier.width(UiSpacing.Small))
Text(action.title, color = contentColor)
if (showText) {
Spacer(Modifier.width(UiSpacing.Small))
Text(action.title, color = contentColor)
}
}
if (action == VirtualButtonAction.MORE) {
MorePopup(
show = showMorePopup,
moreActions = moreActions,
onDismiss = { showMorePopup = false },
onConfirmHaptic = onConfirmHaptic,
onAction = {
onAction(it)
showMorePopup = false
@@ -232,11 +230,10 @@ class VirtualButtonBar(
@Composable
fun Fullscreen(
onPressHaptic: () -> Unit,
onConfirmHaptic: () -> Unit,
onAction: (VirtualButtonAction) -> Unit,
modifier: Modifier = Modifier,
) {
val haptics = rememberAppHaptics()
var showMorePopup by remember { mutableStateOf(false) }
Row(
@@ -247,7 +244,7 @@ class VirtualButtonBar(
Box(modifier = Modifier.weight(1f)) {
Button(
onClick = {
onPressHaptic()
haptics.contextClick()
if (action == VirtualButtonAction.MORE) {
showMorePopup = true
} else {
@@ -270,7 +267,6 @@ class VirtualButtonBar(
show = showMorePopup,
moreActions = moreActions,
onDismiss = { showMorePopup = false },
onConfirmHaptic = onConfirmHaptic,
onAction = {
onAction(it)
showMorePopup = false
@@ -288,10 +284,26 @@ class VirtualButtonBar(
show: Boolean,
moreActions: List<VirtualButtonAction>,
onDismiss: () -> Unit,
onConfirmHaptic: () -> Unit,
onAction: (VirtualButtonAction) -> Unit,
renderInRootScaffold: Boolean,
) {
val haptics = rememberAppHaptics()
val spinnerItems = remember(moreActions) {
moreActions.map { action ->
SpinnerEntry(
icon = {
Icon(
action.icon,
contentDescription = action.title,
modifier = Modifier
.padding(end = UiSpacing.CardContent),
)
},
title = action.title,
)
}
}
SuperListPopup(
show = show,
popupPositionProvider = ListPopupDefaults.ContextMenuPositionProvider,
@@ -301,15 +313,17 @@ class VirtualButtonBar(
enableWindowDim = false,
) {
ListPopupColumn {
moreActions.forEachIndexed { index, action ->
DropdownImpl(
text = action.title,
optionSize = moreActions.size,
spinnerItems.forEachIndexed { index, entry ->
SpinnerItemImpl(
entry = entry,
entryCount = spinnerItems.size,
isSelected = false,
index = index,
onSelectedIndexChange = {
onConfirmHaptic()
onAction(action)
spinnerColors = SpinnerDefaults.spinnerColors(),
dialogMode = false,
onSelectedIndexChange = { selectedIdx ->
haptics.confirm()
onAction(moreActions[selectedIdx])
},
)
}