refactor: new preferences impl with data store

This commit is contained in:
Miuzarte
2026-03-27 01:32:25 +08:00
parent c567ed1bbb
commit 189d4a76b9
17 changed files with 1420 additions and 711 deletions

View File

@@ -34,5 +34,7 @@
顺序无关
- 多配置切换
- [音频延迟](https://developer.android.com/ndk/guides/audio/audio-latency)
- 横屏布局
- 原生悬浮窗
- 原生悬浮窗 [Petterpx/FloatingX](https://github.com/Petterpx/FloatingX)

View File

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

View File

@@ -1,7 +1,7 @@
package io.github.miuzarte.scrcpyforandroid.constants
object ScrcpyPresets {
val MaxSize = listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840)
val MaxSize = listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840) // px
val MaxFPS = listOf(0, 24, 30, 45, 60, 90, 120)
val AudioBitRate = listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512)
val AudioBitRate = listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512) // Kbps
}

View File

@@ -1,18 +1,75 @@
package io.github.miuzarte.scrcpyforandroid.models
internal data class ConnectionTarget(
val host: String,
val port: Int,
)
import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
/**
* A compact shortcut entry for quick-connect lists shown in the UI.
* `online` indicates whether the device was reachable when last probed.
*/
internal data class DeviceShortcut(
val id: String,
val name: String,
data class DeviceShortcut(
val name: String = "",
val host: String,
val port: Int,
val online: Boolean,
)
val port: Int = AppDefaults.ADB_PORT,
val online: Boolean = false,
) {
val id: String get() = "$host:$port"
fun marshalToString(
separator: String = "|",
): String = listOf(
name.trim(), host.trim(), port.toString()
).joinToString(
separator = separator
)
companion object {
fun unmarshalFrom(
s: String,
delimiter: String = "|",
): DeviceShortcut? {
val parts = s.split(delimiter, limit = 3)
return when (parts.size) {
3 -> {
val name = parts[0].trim()
val host = parts[1].trim()
val port = parts[2].trim().toIntOrNull() ?: AppDefaults.ADB_PORT
if (host.isNotBlank()) DeviceShortcut(
name = name,
host = host,
port = port,
)
else null
}
else -> null
}
}
}
}
data class ConnectionTarget(
val host: String,
val port: Int = AppDefaults.ADB_PORT,
) {
fun marshalToString(): String = "$host:$port"
companion object {
fun unmarshalFrom(s: String): ConnectionTarget? {
val parts = s.split(":", limit = 2)
return when (parts.size) {
2 -> ConnectionTarget(
host = parts[0].trim(),
port = parts[1].trim().toIntOrNull() ?: AppDefaults.ADB_PORT,
)
1 -> ConnectionTarget(
host = parts[0].trim(),
port = AppDefaults.ADB_PORT,
)
0 -> ConnectionTarget(
host = s.trim(),
port = AppDefaults.ADB_PORT,
)
else -> null
}
}
}
}

View File

@@ -11,9 +11,13 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -22,6 +26,8 @@ import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.ScrollBehavior
@@ -69,10 +75,12 @@ internal fun AdvancedConfigPage(
contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior,
snackbarHostState: SnackbarHostState,
sessionStarted: Boolean,
audioEnabled: Boolean,
noControl: Boolean,
onNoControlChange: (Boolean) -> Unit,
// sessionStarted: Boolean,
// audioEnabled: Boolean,
// noControl: Boolean,
// onNoControlChange: (Boolean) -> Unit,
audioDup: Boolean,
onAudioDupChange: (Boolean) -> Unit,
audioSourcePreset: String,
@@ -109,6 +117,7 @@ internal fun AdvancedConfigPage(
onMaxSizeInputChange: (String) -> Unit,
maxFpsInput: String,
onMaxFpsInputChange: (String) -> Unit,
videoEncoderDropdownItems: List<String>,
videoEncoderTypeMap: Map<String, String>,
videoEncoderIndex: Int,
@@ -121,27 +130,28 @@ internal fun AdvancedConfigPage(
onAudioEncoderChange: (String) -> Unit,
audioCodecOptions: String,
onAudioCodecOptionsChange: (String) -> Unit,
onRefreshEncoders: () -> Unit,
onRefreshCameraSizes: () -> Unit,
newDisplayWidth: String,
onNewDisplayWidthChange: (String) -> Unit,
newDisplayHeight: String,
onNewDisplayHeightChange: (String) -> Unit,
newDisplayDpi: String,
onNewDisplayDpiChange: (String) -> Unit,
displayIdInput: String,
onDisplayIdInputChange: (String) -> Unit,
cropWidth: String,
onCropWidthChange: (String) -> Unit,
cropHeight: String,
onCropHeightChange: (String) -> Unit,
cropX: String,
onCropXChange: (String) -> Unit,
cropY: String,
onCropYChange: (String) -> Unit,
) {
val context = LocalContext.current
val appSettings = remember(context) { AppSettings(context) }
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
val maxSizePresetIndex =
presetIndexFromInputForAdvancedPage(maxSizeInput, ScrcpyPresets.MaxSize)
val maxFpsPresetIndex = presetIndexFromInputForAdvancedPage(maxFpsInput, ScrcpyPresets.MaxFPS)
@@ -179,6 +189,10 @@ internal fun AdvancedConfigPage(
}
}
var turnScreenOff by scrcpyOptions.turnScreenOff.asMutableState()
var control by scrcpyOptions.control.asMutableState()
var video by scrcpyOptions.video.asMutableState()
// 高级参数
AppPageLazyColumn(
contentPadding = contentPadding,
@@ -190,30 +204,27 @@ internal fun AdvancedConfigPage(
title = "启动后关闭屏幕",
summary = "--turn-screen-off",
checked = turnScreenOff,
onCheckedChange = { value ->
onTurnScreenOffChange(value)
if (value) scope.launch {
onCheckedChange = {
turnScreenOff = it
if (it) scope.launch {
// github.com/Genymobile/scrcpy/issues/3376
// github.com/Genymobile/scrcpy/issues/4587
// github.com/Genymobile/scrcpy/issues/5676
snackbarHostState.showSnackbar("注意:大部分设备在关闭屏幕后刷新率会降低/减半")
}
},
enabled = !sessionStarted && !noControl,
)
SuperSwitch(
title = "禁用控制",
summary = "--no-control",
checked = noControl,
onCheckedChange = onNoControlChange,
enabled = !sessionStarted,
checked = !control,
onCheckedChange = { control = !it },
)
SuperSwitch(
title = "禁用视频",
summary = "--no-video",
checked = noVideo,
onCheckedChange = onNoVideoChange,
enabled = !sessionStarted,
checked = !video,
onCheckedChange = { video = !it },
)
}
}
@@ -225,15 +236,14 @@ internal fun AdvancedConfigPage(
summary = "--video-source",
items = videoSourceItems,
selectedIndex = videoSourceIndex,
onSelectedIndexChange = { index ->
onVideoSourcePresetChange(VIDEO_SOURCE_OPTIONS[index].first)
onSelectedIndexChange = {
videoSourcePreset = VIDEO_SOURCE_OPTIONS[it].first
},
enabled = !sessionStarted,
)
if (videoSourcePreset == "display") {
TextField(
value = displayIdInput,
onValueChange = onDisplayIdInputChange,
onValueChange = { displayIdInput = it },
label = "--display-id",
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
@@ -246,7 +256,7 @@ internal fun AdvancedConfigPage(
if (videoSourcePreset == "camera") {
TextField(
value = cameraIdInput,
onValueChange = onCameraIdInputChange,
onValueChange = { cameraIdInput = it },
label = "--camera-id",
singleLine = true,
modifier = Modifier
@@ -258,17 +268,15 @@ internal fun AdvancedConfigPage(
title = "重新获取 Camera Sizes",
summary = "--list-camera-sizes",
onClick = onRefreshCameraSizes,
enabled = !sessionStarted,
)
SuperDropdown(
title = "摄像头朝向",
summary = "--camera-facing",
items = cameraFacingItems,
selectedIndex = cameraFacingIndex,
onSelectedIndexChange = { index ->
onCameraFacingPresetChange(CAMERA_FACING_OPTIONS[index].first)
onSelectedIndexChange = {
cameraFacingPreset = CAMERA_FACING_OPTIONS[it].first
},
enabled = !sessionStarted,
)
SuperDropdown(
title = "摄像头分辨率",
@@ -278,21 +286,18 @@ internal fun AdvancedConfigPage(
0,
(cameraSizeDropdownItems.size - 1).coerceAtLeast(0)
),
onSelectedIndexChange = { index ->
onCameraSizePresetChange(
when (index) {
0 -> ""
cameraSizeDropdownItems.lastIndex -> "custom"
else -> cameraSizeDropdownItems[index]
},
)
onSelectedIndexChange = {
cameraSizePreset = when (it) {
0 -> ""
cameraSizeDropdownItems.lastIndex -> "custom"
else -> cameraSizeDropdownItems[it]
}
},
enabled = !sessionStarted,
)
if (cameraSizePreset == "custom") {
TextField(
value = cameraSizeCustom,
onValueChange = onCameraSizeCustomChange,
onValueChange = { cameraSizeCustom = it },
label = "--camera-size",
singleLine = true,
modifier = Modifier
@@ -303,7 +308,7 @@ internal fun AdvancedConfigPage(
}
TextField(
value = cameraArInput,
onValueChange = onCameraArInputChange,
onValueChange = { cameraArInput = it },
label = "--camera-ar",
singleLine = true,
modifier = Modifier
@@ -318,11 +323,10 @@ internal fun AdvancedConfigPage(
onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, CAMERA_FPS_PRESETS.lastIndex)
val preset = CAMERA_FPS_PRESETS[idx]
onCameraFpsInputChange(if (preset == 0) "" else preset.toString())
cameraFpsInput = if (preset == 0) "" else preset.toString()
},
valueRange = 0f..CAMERA_FPS_PRESETS.lastIndex.toFloat(),
steps = (CAMERA_FPS_PRESETS.size - 2).coerceAtLeast(0),
enabled = !sessionStarted,
unit = "fps",
zeroStateText = "默认",
showUnitWhenZeroState = false,
@@ -334,16 +338,14 @@ internal fun AdvancedConfigPage(
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE,
onInputConfirm = {
val normalized = it.ifBlank { "" }
onCameraFpsInputChange(if (normalized == "0") "" else normalized)
cameraFpsInput = if (normalized == "0") "" else normalized
},
)
SuperSwitch(
title = "高帧率模式",
summary = "--camera-high-speed",
checked = cameraHighSpeed,
onCheckedChange = onCameraHighSpeedChange,
enabled = !sessionStarted,
onCheckedChange = { cameraHighSpeed = it },
)
}
}
@@ -356,15 +358,12 @@ internal fun AdvancedConfigPage(
summary = "--audio-source",
items = audioSourceItems,
selectedIndex = audioSourceIndex,
onSelectedIndexChange = { index ->
onAudioSourcePresetChange(AUDIO_SOURCE_OPTIONS[index].first)
},
enabled = !sessionStarted && audioEnabled,
onSelectedIndexChange = { audioSourcePreset = AUDIO_SOURCE_OPTIONS[it].first },
)
if (audioSourcePreset == "custom") {
TextField(
value = audioSourceCustom,
onValueChange = onAudioSourceCustomChange,
onValueChange = { audioSourceCustom = it },
label = "--audio-source",
singleLine = true,
modifier = Modifier
@@ -377,21 +376,19 @@ internal fun AdvancedConfigPage(
title = "音频双路输出",
summary = "--audio-dup",
checked = audioDup,
onCheckedChange = onAudioDupChange,
enabled = !sessionStarted && audioEnabled,
onCheckedChange = { audioDup = it },
)
SuperSwitch(
title = "仅转发不播放",
summary = "--no-audio-playback",
checked = noAudioPlayback,
onCheckedChange = onNoAudioPlaybackChange,
enabled = !sessionStarted && audioEnabled,
onCheckedChange = { noAudioPlayback = it },
)
SuperSwitch(
title = "音频失败时终止 [TODO]",
summary = "--require-audio",
checked = requireAudio,
onCheckedChange = onRequireAudioChange,
onCheckedChange = { requireAudio = it },
enabled = false,
)
}
@@ -406,11 +403,10 @@ internal fun AdvancedConfigPage(
onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex)
val preset = ScrcpyPresets.MaxSize[idx]
onMaxSizeInputChange(if (preset == 0) "" else preset.toString())
maxSizeInput = if (preset == 0) "" else preset.toString()
},
valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(),
steps = (ScrcpyPresets.MaxSize.size - 2).coerceAtLeast(0),
enabled = !sessionStarted,
unit = "px",
zeroStateText = "关闭",
showUnitWhenZeroState = false,
@@ -421,10 +417,7 @@ internal fun AdvancedConfigPage(
inputInitialValue = maxSizeInput,
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE,
onInputConfirm = {
val normalized = it.ifBlank { "" }
onMaxSizeInputChange(normalized)
},
onInputConfirm = { maxSizeInput = it.ifBlank { "" } },
)
SuperSlide(
title = "最大帧率",
@@ -433,11 +426,10 @@ internal fun AdvancedConfigPage(
onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex)
val preset = ScrcpyPresets.MaxFPS[idx]
onMaxFpsInputChange(if (preset == 0) "" else preset.toString())
maxFpsInput = if (preset == 0) "" else preset.toString()
},
valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(),
steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0),
enabled = !sessionStarted,
unit = "fps",
zeroStateText = "关闭",
showUnitWhenZeroState = false,
@@ -448,10 +440,7 @@ internal fun AdvancedConfigPage(
inputInitialValue = maxFpsInput,
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE,
onInputConfirm = {
val normalized = it.ifBlank { "" }
onMaxFpsInputChange(normalized)
},
onInputConfirm = { maxFpsInput = it.ifBlank { "" } },
)
}
}
@@ -462,21 +451,17 @@ internal fun AdvancedConfigPage(
title = "重新获取编码器列表",
summary = "--list-encoders",
onClick = onRefreshEncoders,
enabled = !sessionStarted,
)
SuperSpinner(
title = "视频编码器",
summary = "--video-encoder",
items = videoEncoderEntries,
selectedIndex = videoEncoderIndex,
onSelectedIndexChange = { index ->
onVideoEncoderChange(if (index == 0) "" else videoEncoderDropdownItems[index])
},
enabled = !sessionStarted,
onSelectedIndexChange = { videoEncoder = it },
)
TextField(
value = videoCodecOptions,
onValueChange = onVideoCodecOptionsChange,
onValueChange = { videoCodecOptions = it },
label = "--video-codec-options",
singleLine = true,
modifier = Modifier
@@ -489,14 +474,11 @@ internal fun AdvancedConfigPage(
summary = "--audio-encoder",
items = audioEncoderEntries,
selectedIndex = audioEncoderIndex,
onSelectedIndexChange = { index ->
onAudioEncoderChange(if (index == 0) "" else audioEncoderDropdownItems[index])
},
enabled = !sessionStarted && audioEnabled,
onSelectedIndexChange = { audioEncoder = it },
)
TextField(
value = audioCodecOptions,
onValueChange = onAudioCodecOptionsChange,
onValueChange = { audioCodecOptions = it },
label = "--audio-codec-options",
singleLine = true,
modifier = Modifier
@@ -528,7 +510,7 @@ internal fun AdvancedConfigPage(
) {
TextField(
value = newDisplayWidth,
onValueChange = onNewDisplayWidthChange,
onValueChange = { newDisplayWidth = it },
label = "width",
singleLine = true,
keyboardOptions = KeyboardOptions(
@@ -542,7 +524,7 @@ internal fun AdvancedConfigPage(
)
TextField(
value = newDisplayHeight,
onValueChange = onNewDisplayHeightChange,
onValueChange = { newDisplayHeight = it },
label = "height",
singleLine = true,
keyboardOptions = KeyboardOptions(
@@ -556,7 +538,7 @@ internal fun AdvancedConfigPage(
)
TextField(
value = newDisplayDpi,
onValueChange = onNewDisplayDpiChange,
onValueChange = { newDisplayDpi = it },
label = "dpi",
singleLine = true,
keyboardOptions = KeyboardOptions(
@@ -597,7 +579,7 @@ internal fun AdvancedConfigPage(
) {
TextField(
value = cropWidth,
onValueChange = onCropWidthChange,
onValueChange = { cropWidth = it },
label = "width",
singleLine = true,
keyboardOptions = KeyboardOptions(
@@ -611,7 +593,7 @@ internal fun AdvancedConfigPage(
)
TextField(
value = cropHeight,
onValueChange = onCropHeightChange,
onValueChange = { cropHeight = it },
label = "height",
singleLine = true,
keyboardOptions = KeyboardOptions(
@@ -630,7 +612,7 @@ internal fun AdvancedConfigPage(
) {
TextField(
value = cropX,
onValueChange = onCropXChange,
onValueChange = { cropX = it },
label = "x",
singleLine = true,
keyboardOptions = KeyboardOptions(
@@ -644,7 +626,7 @@ internal fun AdvancedConfigPage(
)
TextField(
value = cropY,
onValueChange = onCropYChange,
onValueChange = { cropY = it },
label = "y",
singleLine = true,
keyboardOptions = KeyboardOptions(

View File

@@ -32,14 +32,8 @@ import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
import io.github.miuzarte.scrcpyforandroid.services.DevicePageSettings
import io.github.miuzarte.scrcpyforandroid.services.EventLogger
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo
@@ -47,10 +41,11 @@ import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings
import io.github.miuzarte.scrcpyforandroid.services.loadQuickDevices
import io.github.miuzarte.scrcpyforandroid.services.parseQuickTarget
import io.github.miuzarte.scrcpyforandroid.services.replaceQuickDevicePort
import io.github.miuzarte.scrcpyforandroid.services.saveDevicePageSettings
import io.github.miuzarte.scrcpyforandroid.services.saveQuickDevices
import io.github.miuzarte.scrcpyforandroid.services.updateQuickDeviceNameIfEmpty
import io.github.miuzarte.scrcpyforandroid.services.upsertQuickDevice
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile
@@ -105,7 +100,6 @@ private val DeviceShortcutStateListSaver =
if (parts.size != 5) return@mapNotNull null
val port = parts[3].toIntOrNull() ?: return@mapNotNull null
DeviceShortcut(
id = parts[0],
name = parts[1],
host = parts[2],
port = port,
@@ -129,75 +123,7 @@ fun DeviceTabScreen(
scrcpy: Scrcpy,
snack: SnackbarHostState,
scrollBehavior: ScrollBehavior,
virtualButtonsLayout: String,
showPreviewVirtualButtonText: Boolean,
previewCardHeightDp: Int,
themeBaseIndex: Int,
videoCodec: String,
onVideoCodecChange: (String) -> Unit,
audioEnabled: Boolean,
onAudioEnabledChange: (Boolean) -> Unit,
audioCodec: String,
onAudioCodecChange: (String) -> Unit,
noControl: Boolean,
onNoControlChange: (Boolean) -> Unit,
videoEncoder: String,
onVideoEncoderChange: (String) -> Unit,
videoCodecOptions: String,
onVideoCodecOptionsChange: (String) -> Unit,
audioEncoder: String,
onAudioEncoderChange: (String) -> Unit,
audioCodecOptions: String,
onAudioCodecOptionsChange: (String) -> Unit,
audioDup: Boolean,
onAudioDupChange: (Boolean) -> Unit,
audioSourcePreset: String,
onAudioSourcePresetChange: (String) -> Unit,
audioSourceCustom: String,
onAudioSourceCustomChange: (String) -> Unit,
videoSourcePreset: String,
onVideoSourcePresetChange: (String) -> Unit,
cameraIdInput: String,
onCameraIdInputChange: (String) -> Unit,
cameraFacingPreset: String,
onCameraFacingPresetChange: (String) -> Unit,
cameraSizePreset: String,
onCameraSizePresetChange: (String) -> Unit,
cameraSizeCustom: String,
onCameraSizeCustomChange: (String) -> Unit,
cameraArInput: String,
onCameraArInputChange: (String) -> Unit,
cameraFpsInput: String,
onCameraFpsInputChange: (String) -> Unit,
cameraHighSpeed: Boolean,
onCameraHighSpeedChange: (Boolean) -> Unit,
noAudioPlayback: Boolean,
onNoAudioPlaybackChange: (Boolean) -> Unit,
noVideo: Boolean,
requireAudio: Boolean,
onRequireAudioChange: (Boolean) -> Unit,
turnScreenOff: Boolean,
onTurnScreenOffChange: (Boolean) -> Unit,
maxSizeInput: String,
onMaxSizeInputChange: (String) -> Unit,
maxFpsInput: String,
onMaxFpsInputChange: (String) -> Unit,
newDisplayWidth: String,
onNewDisplayWidthChange: (String) -> Unit,
newDisplayHeight: String,
onNewDisplayHeightChange: (String) -> Unit,
newDisplayDpi: String,
onNewDisplayDpiChange: (String) -> Unit,
displayIdInput: String,
onDisplayIdInputChange: (String) -> Unit,
cropWidth: String,
onCropWidthChange: (String) -> Unit,
cropHeight: String,
onCropHeightChange: (String) -> Unit,
cropX: String,
onCropXChange: (String) -> Unit,
cropY: String,
onCropYChange: (String) -> Unit,
videoEncoderOptions: List<String>,
onVideoEncoderOptionsChange: (List<String>) -> Unit,
onVideoEncoderTypeMapChange: (Map<String, String>) -> Unit,
@@ -214,12 +140,15 @@ fun DeviceTabScreen(
onOpenReorderDevicesActionChange: ((() -> Unit)?) -> Unit,
onOpenAdvancedPage: () -> Unit,
onOpenFullscreenPage: (ScrcpySessionInfo) -> Unit,
adbPairingAutoDiscoverOnDialogOpen: Boolean,
adbAutoReconnectPairedDevice: Boolean,
adbMdnsLanDiscoveryEnabled: Boolean,
) {
val context = LocalContext.current
val appSettings = remember(context) { AppSettings(context) }
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val haptics = rememberAppHaptics()
val virtualButtonsLayout by appSettings.virtualButtonsLayout.asState()
val virtualButtonLayout = remember(virtualButtonsLayout) {
VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout))
}
@@ -285,8 +214,8 @@ fun DeviceTabScreen(
var audioForwardingSupported by rememberSaveable { mutableStateOf(true) }
var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) }
var bitRateMbps by rememberSaveable { mutableFloatStateOf(initialSettings.videoBitRateMbps) }
var bitRateInput by rememberSaveable { mutableStateOf(initialSettings.videoBitRateInput) }
var videoBitRateMbps by rememberSaveable { mutableFloatStateOf(initialSettings.videoBitRateMbps) }
var videoBitRateInput by rememberSaveable { mutableStateOf(initialSettings.videoBitRateInput) }
var audioBitRateKbps by rememberSaveable { mutableIntStateOf(initialSettings.audioBitRateKbps) }
val currentTarget = if (currentTargetHost.isNotBlank()) ConnectionTarget(
currentTargetHost,
@@ -364,11 +293,14 @@ fun DeviceTabScreen(
disconnectAdbConnection(clearQuickOnlineForTarget = current)
}
var audio by scrcpyOptions.audio.asMutableState()
var videoSource by scrcpyOptions.videoSource.asMutableState()
fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) {
val audioSupported = sdkInt !in 0..<30
audioForwardingSupported = audioSupported
if (!audioSupported && audioEnabled) {
onAudioEnabledChange(false)
if (!audioSupported && audio) {
audio = false
logEvent(
"设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭",
Log.WARN
@@ -376,8 +308,8 @@ fun DeviceTabScreen(
}
val cameraSupported = sdkInt !in 0..<31
cameraMirroringSupported = cameraSupported
if (!cameraSupported && videoSourcePreset == "camera") {
onVideoSourcePresetChange("display")
if (!cameraSupported && videoSource == "camera") {
videoSource = "display"
logEvent(
"设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring已切换为 display",
Log.WARN
@@ -531,6 +463,9 @@ fun DeviceTabScreen(
}
}
var videoEncoder by scrcpyOptions.videoEncoder.asMutableState()
var audioEncoder by scrcpyOptions.audioEncoder.asMutableState()
suspend fun refreshEncoderLists() {
if (!adbConnected) return
runCatching {
@@ -542,10 +477,10 @@ fun DeviceTabScreen(
onVideoEncoderTypeMapChange(lists.videoEncoderTypes)
onAudioEncoderTypeMapChange(lists.audioEncoderTypes)
if (videoEncoder.isNotBlank() && videoEncoder !in videoEncoderOptions) {
onVideoEncoderChange("")
videoEncoder = ""
}
if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) {
onAudioEncoderChange("")
audioEncoder = ""
}
EventLogger.logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}")
if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) {
@@ -571,6 +506,8 @@ fun DeviceTabScreen(
}
}
var cameraSize by scrcpyOptions.cameraSize.asMutableState()
suspend fun refreshCameraSizeLists() {
if (!adbConnected) return
runCatching {
@@ -578,8 +515,8 @@ fun DeviceTabScreen(
}.onSuccess { result ->
val lists = result as Scrcpy.ListResult.CameraSizes
onCameraSizeOptionsChange(lists.sizes)
if (cameraSizePreset.isNotBlank() && cameraSizePreset != "custom" && cameraSizePreset !in lists.sizes) {
onCameraSizePresetChange("")
if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in lists.sizes) {
cameraSize = ""
}
EventLogger.logEvent("camera sizes 已刷新: count=${lists.sizes.size}")
if (lists.sizes.isEmpty()) {
@@ -624,88 +561,11 @@ fun DeviceTabScreen(
refreshCameraSizeLists()
}
LaunchedEffect(bitRateInput) {
val parsed = bitRateInput.toFloatOrNull() ?: return@LaunchedEffect
bitRateMbps = parsed.coerceAtLeast(0.1f)
LaunchedEffect(videoBitRateInput) {
val parsed = videoBitRateInput.toFloatOrNull() ?: return@LaunchedEffect
videoBitRateMbps = parsed.coerceAtLeast(0.1f)
}
LaunchedEffect(
quickConnectInput,
audioBitRateKbps,
bitRateMbps,
bitRateInput,
turnScreenOff,
noControl,
noVideo,
videoSourcePreset,
displayIdInput,
cameraIdInput,
cameraFacingPreset,
cameraSizePreset,
cameraSizeCustom,
cameraArInput,
cameraFpsInput,
cameraHighSpeed,
audioSourcePreset,
audioSourceCustom,
audioDup,
noAudioPlayback,
requireAudio,
maxSizeInput,
maxFpsInput,
videoEncoder,
videoCodecOptions,
audioEncoder,
audioCodecOptions,
newDisplayWidth,
newDisplayHeight,
newDisplayDpi,
cropWidth,
cropHeight,
cropX,
cropY,
) {
saveDevicePageSettings(
context,
DevicePageSettings(
quickConnectInput = quickConnectInput,
audioBitRateKbps = audioBitRateKbps,
audioBitRateInput = audioBitRateKbps.toString(),
videoBitRateMbps = bitRateMbps,
videoBitRateInput = bitRateInput,
turnScreenOff = turnScreenOff,
noControl = noControl,
noVideo = noVideo,
videoSourcePreset = videoSourcePreset,
displayIdInput = displayIdInput,
cameraIdInput = cameraIdInput,
cameraFacingPreset = cameraFacingPreset,
cameraSizePreset = cameraSizePreset,
cameraSizeCustom = cameraSizeCustom,
cameraAr = cameraArInput,
cameraFps = cameraFpsInput,
cameraHighSpeed = cameraHighSpeed,
audioSourcePreset = audioSourcePreset,
audioSourceCustom = audioSourceCustom,
audioDup = audioDup,
noAudioPlayback = noAudioPlayback,
requireAudio = requireAudio,
maxSizeInput = maxSizeInput,
maxFpsInput = maxFpsInput,
videoEncoder = videoEncoder,
videoCodecOptions = videoCodecOptions,
audioEncoder = audioEncoder,
audioCodecOptions = audioCodecOptions,
newDisplayWidth = newDisplayWidth,
newDisplayHeight = newDisplayHeight,
newDisplayDpi = newDisplayDpi,
cropWidth = cropWidth,
cropHeight = cropHeight,
cropX = cropX,
cropY = cropY,
),
)
}
LaunchedEffect(Unit) {
if (quickDevices.isEmpty()) {
@@ -762,7 +622,10 @@ fun DeviceTabScreen(
}
}
LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscoveryEnabled) {
val adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asState()
val adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asState()
val adbMdnsLanDiscovery by appSettings.adbMdnsLanDiscovery.asState()
LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscovery) {
if (adbConnected || !adbAutoReconnectPairedDevice) return@LaunchedEffect
// Background auto reconnect pipeline:
@@ -809,7 +672,7 @@ fun DeviceTabScreen(
val discovered = withContext(Dispatchers.IO) {
adbService.discoverConnectService(
timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS,
includeLanDevices = adbMdnsLanDiscoveryEnabled,
includeLanDevices = adbMdnsLanDiscovery,
)
}
@@ -985,6 +848,8 @@ fun DeviceTabScreen(
editingDeviceId = null
}
val devicePreviewCardHeightDp by appSettings.devicePreviewCardHeightDp.asState()
val previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asState()
// 设备
AppPageLazyColumn(
contentPadding = contentPadding,
@@ -998,7 +863,6 @@ fun DeviceTabScreen(
sessionInfo = sessionInfo,
busyLabel = null,
connectedDeviceLabel = connectedDeviceLabel,
themeBaseIndex = themeBaseIndex,
)
}
@@ -1099,7 +963,7 @@ fun DeviceTabScreen(
autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
onDiscoverTarget = {
adbService.discoverPairingService(
includeLanDevices = adbMdnsLanDiscoveryEnabled,
includeLanDevices = adbMdnsLanDiscovery,
)
},
onPair = { host, port, code ->
@@ -1128,139 +992,34 @@ fun DeviceTabScreen(
item {
ConfigPanel(
busy = busy,
bitRateMbps = bitRateMbps,
onBitRateSliderChange = {
bitRateMbps = it
@SuppressLint("DefaultLocale")
bitRateInput = String.format("%.1f", it)
},
onBitRateInputChange = { bitRateInput = it },
audioBitRateKbps = audioBitRateKbps,
onAudioBitRateChange = { audioBitRateKbps = it },
videoCodec = videoCodec,
onVideoCodecChange = onVideoCodecChange,
audioEnabled = audioEnabled,
onAudioEnabledChange = onAudioEnabledChange,
audioForwardingSupported = audioForwardingSupported,
audioCodec = audioCodec,
onAudioCodecChange = onAudioCodecChange,
onOpenAdvanced = onOpenAdvancedPage,
onStartStopHaptic = { haptics.contextClick() },
onStart = {
runBusy("启动 scrcpy") {
if (noVideo && !audioEnabled) {
throw IllegalArgumentException("--no-video 需要同时启用音频")
}
if (audioEnabled && audioSourcePreset == "custom" && audioSourceCustom.isBlank()) {
throw IllegalArgumentException("audio-source 选择自定义时不能为空")
}
val resolvedVideoSource = videoSourcePreset.trim().ifBlank { "display" }
if (resolvedVideoSource == "camera" && !cameraMirroringSupported) {
throw IllegalArgumentException("camera mirroring 需要 Android 12+ (SDK 31+)")
}
val resolvedCameraSize = when (cameraSizePreset) {
"custom" -> cameraSizeCustom.trim()
else -> cameraSizePreset.trim()
}
if (resolvedVideoSource == "camera" && cameraSizePreset == "custom" && resolvedCameraSize.isBlank()) {
throw IllegalArgumentException("camera-size 选择自定义时不能为空")
}
val resolvedCameraId = cameraIdInput.trim()
val resolvedCameraFacing = cameraFacingPreset.trim()
if (resolvedVideoSource == "camera" && resolvedCameraId.isNotBlank() && resolvedCameraFacing.isNotBlank()) {
throw IllegalArgumentException("camera-id 与 camera-facing 不能同时设置")
}
val resolvedCameraAr = cameraArInput.trim()
val resolvedCameraFps =
cameraFpsInput.filter(Char::isDigit).toIntOrNull() ?: 0
if (resolvedVideoSource == "camera" && cameraHighSpeed && resolvedCameraFps <= 0) {
throw IllegalArgumentException("启用 --camera-high-speed 时,--camera-fps 不能为 0")
}
val maxSize =
maxSizeInput.filter(Char::isDigit).toIntOrNull()?.takeIf { it > 0 }
?: 0
val maxFps =
maxFpsInput.filter(Char::isDigit).toIntOrNull()?.toFloat() ?: 0f
if (resolvedVideoSource == "camera" && resolvedCameraSize.isNotBlank() && (maxSize > 0 || resolvedCameraAr.isNotBlank())) {
throw IllegalArgumentException("显式 camera-size 时不能同时设置 --max-size 或 --camera-ar")
}
val bitRateBps = (bitRateMbps * 1_000_000).toInt()
val audioBitRateBps = (audioBitRateKbps.coerceAtLeast(1)) * 1_000
val resolvedAudioSource = when (audioSourcePreset) {
"custom" -> audioSourceCustom.trim()
else -> audioSourcePreset.trim()
}
val newDisplayArg = buildNewDisplayArg(
newDisplayWidth.filter(Char::isDigit),
newDisplayHeight.filter(Char::isDigit),
newDisplayDpi.filter(Char::isDigit),
)
val displayId = displayIdInput.filter(Char::isDigit).toIntOrNull()
?.takeIf { it > 0 }
val crop = buildCropArg(
cropWidth.filter(Char::isDigit),
cropHeight.filter(Char::isDigit),
cropX.filter(Char::isDigit),
cropY.filter(Char::isDigit),
)
val effectiveTurnScreenOff = turnScreenOff && !noControl
val options = ClientOptions(
crop = crop,
videoCodecOptions = videoCodecOptions,
audioCodecOptions = audioCodecOptions,
videoEncoder = videoEncoder,
audioEncoder = audioEncoder,
cameraId = resolvedCameraId,
cameraSize = resolvedCameraSize,
cameraAr = resolvedCameraAr,
cameraFps = resolvedCameraFps.toUShort(),
videoCodec = Codec.fromString(videoCodec),
audioCodec = Codec.fromString(audioCodec),
videoSource = if (resolvedVideoSource == "camera") VideoSource.CAMERA else VideoSource.DISPLAY,
audioSource = if (resolvedAudioSource.isNotBlank()) AudioSource.fromString(
resolvedAudioSource
) else AudioSource.AUTO,
cameraFacing = if (resolvedCameraFacing.isNotBlank()) CameraFacing.fromString(
resolvedCameraFacing
) else CameraFacing.ANY,
maxSize = maxSize.toUShort(),
videoBitRate = bitRateBps.toUInt(),
audioBitRate = audioBitRateBps.toUInt(),
maxFps = if (maxFps > 0f) maxFps.toString() else "",
displayId = (displayId ?: 0).toUInt(),
control = !noControl,
video = !noVideo,
audio = audioEnabled,
requireAudio = requireAudio,
audioPlayback = !noAudioPlayback,
turnScreenOff = effectiveTurnScreenOff,
audioDup = audioDup,
newDisplay = newDisplayArg,
cameraHighSpeed = cameraHighSpeed,
)
// Start scrcpy using Scrcpy class
val session = scrcpy.start(
options = options,
)
val options = scrcpyOptions.toClientOptions()
val session = scrcpy.start(options)
sessionInfo = session
statusLine = "scrcpy 运行中"
@SuppressLint("DefaultLocale")
val videoDetail = if (noVideo) {
val videoDetail = if (!options.video) {
"off"
} else {
"${session.codec} ${session.width}x${session.height} " +
"@${String.format("%.1f", bitRateMbps)}Mbps"
"@${String.format("%.1f", videoBitRateMbps)}Mbps"
}
val audioDetail = if (!audioEnabled) {
val audioDetail = if (!audio) {
"off"
} else {
val playback = if (noAudioPlayback) "(no-playback)" else ""
"$audioCodec ${audioBitRateKbps}kbps source=${resolvedAudioSource.ifBlank { "default" }}$playback"
val playback = if (!options.audioPlayback) "(no-playback)" else ""
"${options.audioCodec} ${audioBitRateKbps}kbps source=${options.audioSource}$playback"
}
logEvent("scrcpy 已启动: device=${session.deviceName}, video=$videoDetail, audio=$audioDetail, control=${!noControl}, turnScreenOff=$effectiveTurnScreenOff, maxSize=${if (maxSize > 0) maxSize else "auto"}, maxFps=${if (maxFps > 0f) maxFps else "auto"}")
logEvent(
"scrcpy 已启动: device=${session.deviceName}" +
", video=$videoDetail, audio=$audioDetail" +
", control=${options.control}, turnScreenOff=${options.turnScreenOff}" +
", maxSize=${options.maxSize}, maxFps=${options.maxFps}"
)
scope.launch {
snack.showSnackbar("scrcpy 已启动")
}
@@ -1293,7 +1052,7 @@ fun DeviceTabScreen(
PreviewCard(
sessionInfo = sessionInfo,
nativeCore = nativeCore,
previewHeightDp = previewCardHeightDp.coerceAtLeast(120),
previewHeightDp = devicePreviewCardHeightDp.coerceAtLeast(120),
controlsVisible = previewControlsVisible,
onTapped = {
previewControlsVisible = !previewControlsVisible
@@ -1302,15 +1061,15 @@ fun DeviceTabScreen(
val info = sessionInfo ?: return@PreviewCard
onOpenFullscreenPage(info)
},
onOpenFullscreenHaptic = { haptics.contextClick() },
)
}
item {
VirtualButtonCard(
busy = busy,
outsideActions = virtualButtonLayout.first,
moreActions = virtualButtonLayout.second,
showText = showPreviewVirtualButtonText,
showText = previewVirtualButtonShowText,
onAction = ::sendVirtualButtonAction,
)
}

View File

@@ -52,10 +52,8 @@ import io.github.miuzarte.scrcpyforandroid.constants.UiMotion
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
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.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -97,6 +95,7 @@ private sealed interface RootScreen : NavKey {
@Composable
fun MainPage() {
val context = LocalContext.current
val activity = remember(context) { context as? Activity }
val initialOrientation = remember(activity) {
activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
@@ -106,8 +105,6 @@ fun MainPage() {
val adbService = remember(context) { NativeAdbService(context) }
val scrcpy = remember(context) { Scrcpy(context) }
val initialSettings = remember(context) { loadMainSettings(context) }
val initialDeviceSettings = remember(context) { loadDevicePageSettings(context) }
val snackHostState = remember { SnackbarHostState() }
val tabs = remember { MainTabDestination.entries }
val pagerState = rememberPagerState(
@@ -132,20 +129,49 @@ fun MainPage() {
restore = { restored -> restored.toList() },
)
var audioEnabled by rememberSaveable { mutableStateOf(initialSettings.audioEnabled) }
var audioCodec by rememberSaveable { mutableStateOf(initialSettings.audioCodec) }
var videoCodec by rememberSaveable { mutableStateOf(initialSettings.videoCodec) }
var themeBaseIndex by rememberSaveable { mutableIntStateOf(initialSettings.themeBaseIndex) }
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 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) }
val appSettings = remember(context) { AppSettings(context) }
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
/*
val initialSettings = remember(context) {
loadMainSettings(context)
}
var audioEnabled by rememberSaveable {
mutableStateOf(initialSettings.audioEnabled)
}
var audioCodec by rememberSaveable {
mutableStateOf(initialSettings.audioCodec)
}
var videoCodec by rememberSaveable {
mutableStateOf(initialSettings.videoCodec)
}
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 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)
}
var adbPairingAutoDiscoverOnDialogOpen by rememberSaveable {
mutableStateOf(initialSettings.adbPairingAutoDiscoverOnDialogOpen)
}
@@ -155,36 +181,102 @@ fun MainPage() {
var adbMdnsLanDiscoveryEnabled by rememberSaveable {
mutableStateOf(initialSettings.adbMdnsLanDiscoveryEnabled)
}
var noControl by rememberSaveable { mutableStateOf(initialDeviceSettings.noControl) }
var videoEncoder by rememberSaveable { mutableStateOf(initialDeviceSettings.videoEncoder) }
var videoCodecOptions by rememberSaveable { mutableStateOf(initialDeviceSettings.videoCodecOptions) }
var audioEncoder by rememberSaveable { mutableStateOf(initialDeviceSettings.audioEncoder) }
var audioCodecOptions by rememberSaveable { mutableStateOf(initialDeviceSettings.audioCodecOptions) }
var audioDup by rememberSaveable { mutableStateOf(initialDeviceSettings.audioDup) }
var audioSourcePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.audioSourcePreset) }
var audioSourceCustom by rememberSaveable { mutableStateOf(initialDeviceSettings.audioSourceCustom) }
var videoSourcePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.videoSourcePreset) }
var cameraIdInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraIdInput) }
var cameraFacingPreset by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraFacingPreset) }
var cameraSizePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraSizePreset) }
var cameraSizeCustom by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraSizeCustom) }
var cameraArInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraAr) }
var cameraFpsInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraFps) }
var cameraHighSpeed by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraHighSpeed) }
var noAudioPlayback by rememberSaveable { mutableStateOf(initialDeviceSettings.noAudioPlayback) }
var noVideo by rememberSaveable { mutableStateOf(initialDeviceSettings.noVideo) }
var requireAudio by rememberSaveable { mutableStateOf(initialDeviceSettings.requireAudio) }
var turnScreenOff by rememberSaveable { mutableStateOf(initialDeviceSettings.turnScreenOff) }
var maxSizeInput by rememberSaveable { mutableStateOf(initialDeviceSettings.maxSizeInput) }
var maxFpsInput by rememberSaveable { mutableStateOf(initialDeviceSettings.maxFpsInput) }
var newDisplayWidth by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayWidth) }
var newDisplayHeight by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayHeight) }
var newDisplayDpi by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayDpi) }
var displayIdInput by rememberSaveable { mutableStateOf(initialDeviceSettings.displayIdInput) }
var cropWidth by rememberSaveable { mutableStateOf(initialDeviceSettings.cropWidth) }
var cropHeight by rememberSaveable { mutableStateOf(initialDeviceSettings.cropHeight) }
var cropX by rememberSaveable { mutableStateOf(initialDeviceSettings.cropX) }
var cropY by rememberSaveable { mutableStateOf(initialDeviceSettings.cropY) }
val initialDeviceSettings = remember(context) {
loadDevicePageSettings(context)
}
var noControl by rememberSaveable {
mutableStateOf(initialDeviceSettings.noControl)
}
var videoEncoder by rememberSaveable {
mutableStateOf(initialDeviceSettings.videoEncoder)
}
var videoCodecOptions by rememberSaveable {
mutableStateOf(initialDeviceSettings.videoCodecOptions)
}
var audioEncoder by rememberSaveable {
mutableStateOf(initialDeviceSettings.audioEncoder)
}
var audioCodecOptions by rememberSaveable {
mutableStateOf(initialDeviceSettings.audioCodecOptions)
}
var audioDup by rememberSaveable {
mutableStateOf(initialDeviceSettings.audioDup)
}
var audioSourcePreset by rememberSaveable {
mutableStateOf(initialDeviceSettings.audioSourcePreset)
}
var audioSourceCustom by rememberSaveable {
mutableStateOf(initialDeviceSettings.audioSourceCustom)
}
var videoSourcePreset by rememberSaveable {
mutableStateOf(initialDeviceSettings.videoSourcePreset)
}
var cameraIdInput by rememberSaveable {
mutableStateOf(initialDeviceSettings.cameraIdInput)
}
var cameraFacingPreset by rememberSaveable {
mutableStateOf(initialDeviceSettings.cameraFacingPreset)
}
var cameraSizePreset by rememberSaveable {
mutableStateOf(initialDeviceSettings.cameraSizePreset)
}
var cameraSizeCustom by rememberSaveable {
mutableStateOf(initialDeviceSettings.cameraSizeCustom)
}
var cameraArInput by rememberSaveable {
mutableStateOf(initialDeviceSettings.cameraAr)
}
var cameraFpsInput by rememberSaveable {
mutableStateOf(initialDeviceSettings.cameraFps)
}
var cameraHighSpeed by rememberSaveable {
mutableStateOf(initialDeviceSettings.cameraHighSpeed)
}
var noAudioPlayback by rememberSaveable {
mutableStateOf(initialDeviceSettings.noAudioPlayback)
}
var noVideo by rememberSaveable {
mutableStateOf(initialDeviceSettings.noVideo)
}
var requireAudio by rememberSaveable {
mutableStateOf(initialDeviceSettings.requireAudio)
}
var turnScreenOff by rememberSaveable {
mutableStateOf(initialDeviceSettings.turnScreenOff)
}
var maxSizeInput by rememberSaveable {
mutableStateOf(initialDeviceSettings.maxSizeInput)
}
var maxFpsInput by rememberSaveable {
mutableStateOf(initialDeviceSettings.maxFpsInput)
}
var newDisplayWidth by rememberSaveable {
mutableStateOf(initialDeviceSettings.newDisplayWidth)
}
var newDisplayHeight by rememberSaveable {
mutableStateOf(initialDeviceSettings.newDisplayHeight)
}
var newDisplayDpi by rememberSaveable {
mutableStateOf(initialDeviceSettings.newDisplayDpi)
}
var displayIdInput by rememberSaveable {
mutableStateOf(initialDeviceSettings.displayIdInput)
}
var cropWidth by rememberSaveable {
mutableStateOf(initialDeviceSettings.cropWidth)
}
var cropHeight by rememberSaveable {
mutableStateOf(initialDeviceSettings.cropHeight)
}
var cropX by rememberSaveable {
mutableStateOf(initialDeviceSettings.cropX)
}
var cropY by rememberSaveable {
mutableStateOf(initialDeviceSettings.cropY)
}
*/
val videoEncoderOptions = remember { mutableStateListOf<String>() }
val audioEncoderOptions = remember { mutableStateListOf<String>() }
val videoEncoderTypeMap = remember { mutableStateMapOf<String, String>() }
@@ -201,7 +293,10 @@ fun MainPage() {
var fullscreenOrientation by rememberSaveable {
mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
}
val themeMode = resolveThemeMode(themeBaseIndex, monetEnabled)
var themeBaseIndex by appSettings.themeBaseIndex.asMutableState()
var monet by appSettings.monet.asMutableState()
val themeMode = resolveThemeMode(themeBaseIndex, monet)
val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
// Restore system orientation when MainPage leaves composition.
@@ -211,6 +306,7 @@ fun MainPage() {
}
}
val keepScreenOnWhenStreamingEnabled by appSettings.keepScreenOnWhenStreaming.asMutableState()
// Keep-screen-on is controlled globally, so fullscreen and preview share the same behavior.
DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) {
val window = activity?.window
@@ -234,49 +330,7 @@ fun MainPage() {
activity?.requestedOrientation = targetOrientation
}
LaunchedEffect(
audioEnabled,
audioCodec,
videoCodec,
themeBaseIndex,
monetEnabled,
fullscreenDebugInfoEnabled,
showFullscreenVirtualButtons,
showPreviewVirtualButtonText,
keepScreenOnWhenStreamingEnabled,
devicePreviewCardHeightDp,
virtualButtonsLayout,
customServerUri,
serverRemotePath,
adbKeyName,
adbPairingAutoDiscoverOnDialogOpen,
adbAutoReconnectPairedDevice,
adbMdnsLanDiscoveryEnabled,
) {
saveMainSettings(
context,
MainSettings(
audioEnabled = audioEnabled,
audioCodec = audioCodec,
videoCodec = videoCodec,
themeBaseIndex = themeBaseIndex,
monetEnabled = monetEnabled,
fullscreenDebugInfoEnabled = fullscreenDebugInfoEnabled,
showFullscreenVirtualButtons = showFullscreenVirtualButtons,
showPreviewVirtualButtonText = showPreviewVirtualButtonText,
keepScreenOnWhenStreamingEnabled = keepScreenOnWhenStreamingEnabled,
devicePreviewCardHeightDp = devicePreviewCardHeightDp,
virtualButtonsLayout = virtualButtonsLayout,
customServerUri = customServerUri,
serverRemotePath = serverRemotePath,
adbKeyName = adbKeyName,
adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice,
adbMdnsLanDiscoveryEnabled = adbMdnsLanDiscoveryEnabled,
),
)
}
val adbKeyName by appSettings.adbKeyName.asMutableState()
LaunchedEffect(adbKeyName) {
adbService.keyName = adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME }
}
@@ -340,17 +394,19 @@ fun MainPage() {
}
}
val picker =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
customServerUri = uri.toString()
var customServerUri by appSettings.customServerUri.asMutableState()
val picker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
if (uri == null) return@rememberLauncherForActivityResult
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
}
customServerUri = uri.toString()
}
val rootEntryProvider = entryProvider<NavKey> {
entry(RootScreen.Home) {
@@ -432,80 +488,14 @@ fun MainPage() {
scrcpy = scrcpy,
snack = snackHostState,
scrollBehavior = deviceScrollBehavior,
virtualButtonsLayout = virtualButtonsLayout,
showPreviewVirtualButtonText = showPreviewVirtualButtonText,
previewCardHeightDp = devicePreviewCardHeightDp,
themeBaseIndex = themeBaseIndex,
videoCodec = videoCodec,
onVideoCodecChange = { videoCodec = it },
audioEnabled = audioEnabled,
onAudioEnabledChange = { audioEnabled = it },
audioCodec = audioCodec,
onAudioCodecChange = { audioCodec = it },
noControl = noControl,
/*
onNoControlChange = {
noControl = it
if (it) {
turnScreenOff = false
}
},
videoEncoder = videoEncoder,
onVideoEncoderChange = { videoEncoder = it },
videoCodecOptions = videoCodecOptions,
onVideoCodecOptionsChange = { videoCodecOptions = it },
audioEncoder = audioEncoder,
onAudioEncoderChange = { audioEncoder = it },
audioCodecOptions = audioCodecOptions,
onAudioCodecOptionsChange = { audioCodecOptions = it },
audioDup = audioDup,
onAudioDupChange = { audioDup = it },
audioSourcePreset = audioSourcePreset,
onAudioSourcePresetChange = { audioSourcePreset = it },
audioSourceCustom = audioSourceCustom,
onAudioSourceCustomChange = { audioSourceCustom = it },
videoSourcePreset = videoSourcePreset,
onVideoSourcePresetChange = { videoSourcePreset = it },
cameraIdInput = cameraIdInput,
onCameraIdInputChange = { cameraIdInput = it },
cameraFacingPreset = cameraFacingPreset,
onCameraFacingPresetChange = { cameraFacingPreset = it },
cameraSizePreset = cameraSizePreset,
onCameraSizePresetChange = { cameraSizePreset = it },
cameraSizeCustom = cameraSizeCustom,
onCameraSizeCustomChange = { cameraSizeCustom = it },
cameraArInput = cameraArInput,
onCameraArInputChange = { cameraArInput = it },
cameraFpsInput = cameraFpsInput,
onCameraFpsInputChange = { cameraFpsInput = it },
cameraHighSpeed = cameraHighSpeed,
onCameraHighSpeedChange = { cameraHighSpeed = it },
noAudioPlayback = noAudioPlayback,
onNoAudioPlaybackChange = { noAudioPlayback = it },
noVideo = noVideo,
requireAudio = requireAudio,
onRequireAudioChange = { requireAudio = it },
turnScreenOff = turnScreenOff,
onTurnScreenOffChange = { turnScreenOff = it },
maxSizeInput = maxSizeInput,
onMaxSizeInputChange = { maxSizeInput = it },
maxFpsInput = maxFpsInput,
onMaxFpsInputChange = { maxFpsInput = it },
newDisplayWidth = newDisplayWidth,
onNewDisplayWidthChange = { newDisplayWidth = it },
newDisplayHeight = newDisplayHeight,
onNewDisplayHeightChange = { newDisplayHeight = it },
newDisplayDpi = newDisplayDpi,
onNewDisplayDpiChange = { newDisplayDpi = it },
displayIdInput = displayIdInput,
onDisplayIdInputChange = { displayIdInput = it },
cropWidth = cropWidth,
onCropWidthChange = { cropWidth = it },
cropHeight = cropHeight,
onCropHeightChange = { cropHeight = it },
cropX = cropX,
onCropXChange = { cropX = it },
cropY = cropY,
onCropYChange = { cropY = it },
*/
videoEncoderOptions = videoEncoderOptions,
onVideoEncoderOptionsChange = {
videoEncoderOptions.clear()
@@ -558,9 +548,6 @@ fun MainPage() {
),
)
},
adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice,
adbMdnsLanDiscoveryEnabled = adbMdnsLanDiscoveryEnabled,
)
}
@@ -574,33 +561,12 @@ fun MainPage() {
) { pagePadding ->
SettingsScreen(
contentPadding = pagePadding,
themeBaseIndex = themeBaseIndex,
onThemeBaseIndexChange = { themeBaseIndex = it },
monetEnabled = monetEnabled,
onMonetEnabledChange = { monetEnabled = it },
fullscreenDebugInfoEnabled = fullscreenDebugInfoEnabled,
onFullscreenDebugInfoEnabledChange = {
fullscreenDebugInfoEnabled = it
},
keepScreenOnWhenStreamingEnabled = keepScreenOnWhenStreamingEnabled,
onKeepScreenOnWhenStreamingEnabledChange = {
keepScreenOnWhenStreamingEnabled = it
},
devicePreviewCardHeightDp = devicePreviewCardHeightDp,
onDevicePreviewCardHeightDpChange = {
devicePreviewCardHeightDp = it.coerceAtLeast(120)
},
onOpenReorderDevices = {
openReorderDevicesAction?.invoke()
},
onOpenVirtualButtonOrder = {
rootBackStack.add(RootScreen.VirtualButtonOrder)
},
showFullscreenVirtualButtons = showFullscreenVirtualButtons,
onShowFullscreenVirtualButtonsChange = {
showFullscreenVirtualButtons = it
},
customServerUri = customServerUri,
onPickServer = {
picker.launch(
arrayOf(
@@ -610,19 +576,6 @@ fun MainPage() {
)
)
},
onClearServer = { customServerUri = null },
serverRemotePath = serverRemotePath,
onServerRemotePathChange = { serverRemotePath = it },
adbKeyName = adbKeyName,
onAdbKeyNameChange = { adbKeyName = it },
adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
onAdbPairingAutoDiscoverOnDialogOpenChange = {
adbPairingAutoDiscoverOnDialogOpen = it
},
adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice,
onAdbAutoReconnectPairedDeviceChange = {
adbAutoReconnectPairedDevice = it
},
scrollBehavior = settingsScrollBehavior,
)
}
@@ -632,6 +585,8 @@ fun MainPage() {
}
}
val videoEncoder by scrcpyOptions.videoEncoder.asState()
val audioEncoder by scrcpyOptions.audioEncoder.asState()
entry(RootScreen.Advanced) {
val videoEncoderDropdownItems = listOf("默认") + videoEncoderOptions
val audioEncoderDropdownItems = listOf("默认") + audioEncoderOptions
@@ -808,11 +763,11 @@ fun MainPage() {
@Composable
private fun DeviceMenuPopup(
show: Boolean,
canClearLogs: Boolean,
onDismissRequest: () -> Unit,
onReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit,
onClearLogs: () -> Unit,
canClearLogs: Boolean,
) {
SuperListPopup(
show = show,

View File

@@ -11,6 +11,9 @@ 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.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
@@ -19,6 +22,8 @@ import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon
@@ -43,11 +48,11 @@ private val THEME_BASE_OPTIONS = listOf(
ThemeModeOption("深色", ColorSchemeMode.Dark),
)
fun resolveThemeMode(baseIndex: Int, monetEnabled: Boolean): ColorSchemeMode {
fun resolveThemeMode(baseIndex: Int, monet: Boolean): ColorSchemeMode {
return when (baseIndex.coerceIn(0, 2)) {
0 -> if (monetEnabled) ColorSchemeMode.MonetSystem else ColorSchemeMode.System
1 -> if (monetEnabled) ColorSchemeMode.MonetLight else ColorSchemeMode.Light
else -> if (monetEnabled) ColorSchemeMode.MonetDark else ColorSchemeMode.Dark
0 -> if (monet) ColorSchemeMode.MonetSystem else ColorSchemeMode.System
1 -> if (monet) ColorSchemeMode.MonetLight else ColorSchemeMode.Light
else -> if (monet) ColorSchemeMode.MonetDark else ColorSchemeMode.Dark
}
}
@@ -59,36 +64,37 @@ private fun resolveThemeLabel(baseIndex: Int, monetEnabled: Boolean): String {
@Composable
fun SettingsScreen(
contentPadding: PaddingValues,
themeBaseIndex: Int,
onThemeBaseIndexChange: (Int) -> Unit,
monetEnabled: Boolean,
onMonetEnabledChange: (Boolean) -> Unit,
fullscreenDebugInfoEnabled: Boolean,
onFullscreenDebugInfoEnabledChange: (Boolean) -> Unit,
keepScreenOnWhenStreamingEnabled: Boolean,
onKeepScreenOnWhenStreamingEnabledChange: (Boolean) -> Unit,
devicePreviewCardHeightDp: Int,
onDevicePreviewCardHeightDpChange: (Int) -> Unit,
showFullscreenVirtualButtons: Boolean,
onOpenReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit,
onShowFullscreenVirtualButtonsChange: (Boolean) -> Unit,
customServerUri: String?,
onPickServer: () -> Unit,
onClearServer: () -> Unit,
serverRemotePath: String,
onServerRemotePathChange: (String) -> Unit,
adbKeyName: String,
onAdbKeyNameChange: (String) -> Unit,
adbPairingAutoDiscoverOnDialogOpen: Boolean,
onAdbPairingAutoDiscoverOnDialogOpenChange: (Boolean) -> Unit,
adbAutoReconnectPairedDevice: Boolean,
onAdbAutoReconnectPairedDeviceChange: (Boolean) -> Unit,
scrollBehavior: ScrollBehavior,
) {
val baseModeItems = THEME_BASE_OPTIONS.map { it.label }
val context = LocalContext.current
val appSettings = remember(context) { AppSettings(context) }
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val baseModeItems = THEME_BASE_OPTIONS.map { it.label }
// 主题
var themeBaseIndex by appSettings.themeBaseIndex.asMutableState()
var monet by appSettings.monet.asMutableState()
// 投屏
var fullscreenDebugInfo by appSettings.fullscreenDebugInfo.asMutableState()
var keepScreenOnWhenStreaming by appSettings.keepScreenOnWhenStreaming.asMutableState()
var devicePreviewCardHeightDp by appSettings.devicePreviewCardHeightDp.asMutableState()
var showFullscreenVirtualButtons by appSettings.showFullscreenVirtualButtons.asMutableState()
// scrcpy-server
var customServerUri by appSettings.customServerUri.asMutableState()
var serverRemotePath by appSettings.serverRemotePath.asMutableState()
// ADB
var adbKeyName by appSettings.adbKeyName.asMutableState()
var adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asMutableState()
var adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asMutableState()
// 设置
AppPageLazyColumn(
contentPadding = contentPadding,
@@ -99,16 +105,16 @@ fun SettingsScreen(
Card {
SuperDropdown(
title = "外观模式",
summary = resolveThemeLabel(themeBaseIndex, monetEnabled),
summary = resolveThemeLabel(themeBaseIndex, monet),
items = baseModeItems,
selectedIndex = themeBaseIndex.coerceIn(0, baseModeItems.lastIndex),
onSelectedIndexChange = onThemeBaseIndexChange,
onSelectedIndexChange = { themeBaseIndex = it },
)
SuperSwitch(
title = "Monet",
summary = "开启后使用 Monet 动态配色",
checked = monetEnabled,
onCheckedChange = onMonetEnabledChange,
checked = monet,
onCheckedChange = { monet = it },
)
}
@@ -117,23 +123,21 @@ fun SettingsScreen(
SuperSwitch(
title = "启用调试信息",
summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS",
checked = fullscreenDebugInfoEnabled,
onCheckedChange = onFullscreenDebugInfoEnabledChange,
checked = fullscreenDebugInfo,
onCheckedChange = { fullscreenDebugInfo = it },
)
SuperSwitch(
title = "投屏时保持屏幕常亮",
summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开",
checked = keepScreenOnWhenStreamingEnabled,
onCheckedChange = onKeepScreenOnWhenStreamingEnabledChange,
checked = keepScreenOnWhenStreaming,
onCheckedChange = { keepScreenOnWhenStreaming = it },
)
SuperSlide(
title = "预览卡高度",
summary = "设备页预览卡高度",
value = devicePreviewCardHeightDp.toFloat(),
onValueChange = {
onDevicePreviewCardHeightDpChange(
it.roundToInt().coerceAtLeast(120)
)
devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120)
},
valueRange = 160f..600f,
steps = 439,
@@ -142,9 +146,10 @@ fun SettingsScreen(
inputInitialValue = devicePreviewCardHeightDp.toString(),
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 120f..Float.MAX_VALUE,
onInputConfirm = { raw ->
raw.toIntOrNull()
?.let { onDevicePreviewCardHeightDpChange(it.coerceAtLeast(120)) }
onInputConfirm = { input ->
input.toIntOrNull()?.let {
devicePreviewCardHeightDp = it.coerceAtLeast(120)
}
},
)
SuperArrow(
@@ -161,7 +166,7 @@ fun SettingsScreen(
title = "全屏显示虚拟按钮",
summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮",
checked = showFullscreenVirtualButtons,
onCheckedChange = onShowFullscreenVirtualButtonsChange,
onCheckedChange = { showFullscreenVirtualButtons = it },
)
}
@@ -176,20 +181,21 @@ fun SettingsScreen(
fontWeight = FontWeight.Medium,
)
TextField(
value = customServerUri ?: "",
value = customServerUri,
onValueChange = {},
readOnly = true,
label = "scrcpy-server-v3.3.4",
useLabelAsPlaceholder = customServerUri == null,
useLabelAsPlaceholder = customServerUri.isBlank(),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent),
trailingIcon = {
Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) {
if (customServerUri != null) IconButton(onClick = onClearServer) {
Icon(Icons.Rounded.Clear, contentDescription = "清空")
}
if (customServerUri.isNotBlank())
IconButton(onClick = { customServerUri = "" }) {
Icon(Icons.Rounded.Clear, contentDescription = "清空")
}
IconButton(onClick = onPickServer) {
Icon(Icons.Rounded.FileOpen, contentDescription = "选择文件")
}
@@ -205,7 +211,7 @@ fun SettingsScreen(
)
TextField(
value = serverRemotePath,
onValueChange = onServerRemotePathChange,
onValueChange = { serverRemotePath = it },
label = AppDefaults.SERVER_REMOTE_PATH,
useLabelAsPlaceholder = true,
singleLine = true,
@@ -227,7 +233,7 @@ fun SettingsScreen(
)
TextField(
value = adbKeyName,
onValueChange = onAdbKeyNameChange,
onValueChange = { adbKeyName = it },
label = AppDefaults.ADB_KEY_NAME,
useLabelAsPlaceholder = true,
singleLine = true,
@@ -240,13 +246,13 @@ fun SettingsScreen(
title = "配对时自动启用发现服务",
summary = "打开配对弹窗后自动搜索可用配对端口",
checked = adbPairingAutoDiscoverOnDialogOpen,
onCheckedChange = onAdbPairingAutoDiscoverOnDialogOpenChange,
onCheckedChange = { adbPairingAutoDiscoverOnDialogOpen = it },
)
SuperSwitch(
title = "自动重连已配对设备",
summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)",
checked = adbAutoReconnectPairedDevice,
onCheckedChange = onAdbAutoReconnectPairedDeviceChange,
onCheckedChange = { adbAutoReconnectPairedDevice = it },
)
}

View File

@@ -68,7 +68,7 @@ class Shared {
AAC("aac"),
FLAC("flac"),
RAW("raw"); // wav raw
companion object {
fun fromString(value: String): Codec {
return entries.find { it.string.equals(value, ignoreCase = true) } ?: H264
@@ -79,7 +79,7 @@ class Shared {
enum class VideoSource(val string: String) {
DISPLAY("display"), // default, ignore when passing
CAMERA("camera");
companion object {
fun fromString(value: String): VideoSource {
return entries.find { it.string.equals(value, ignoreCase = true) } ?: DISPLAY
@@ -100,7 +100,7 @@ class Shared {
VOICE_CALL_UPLINK("voice-call-uplink"),
VOICE_CALL_DOWNLINK("voice-call-downlink"),
VOICE_PERFORMANCE("voice-performance");
companion object {
fun fromString(value: String): AudioSource {
return entries.find { it.string.equals(value, ignoreCase = true) } ?: AUTO
@@ -113,7 +113,7 @@ class Shared {
FRONT("front"),
BACK("back"),
EXTERNAL("external");
companion object {
fun fromString(value: String): CameraFacing {
return entries.find { it.string.equals(value, ignoreCase = true) } ?: ANY
@@ -142,6 +142,13 @@ class Shared {
else -> error("Invalid rotation value")
}
}
companion object {
fun fromInt(value: Int): Orientation {
return entries.getOrNull(value)
?: error("Invalid orientation value: $value")
}
}
}
enum class OrientationLock(val string: String) {

View File

@@ -25,7 +25,6 @@ internal fun loadQuickDevices(context: Context): List<DeviceShortcut> {
if (host.isNotBlank()) {
result.add(
DeviceShortcut(
id = "$host:$port",
name = name,
host = host,
port = port,
@@ -44,7 +43,6 @@ internal fun loadQuickDevices(context: Context): List<DeviceShortcut> {
if (host.isNotBlank()) {
result.add(
DeviceShortcut(
id = "$host:$port",
name = name,
host = host,
port = port,
@@ -83,11 +81,9 @@ internal fun upsertQuickDevice(
port: Int,
online: Boolean,
) {
val id = "$host:$port"
val idx = quickDevices.indexOfFirst { it.id == id }
val idx = quickDevices.indexOfFirst { it.id == "$host:$port" }
val existingName = if (idx >= 0) quickDevices[idx].name else ""
val item = DeviceShortcut(
id = id,
name = existingName,
host = host,
port = port,
@@ -124,7 +120,6 @@ internal fun replaceQuickDevicePort(
val old = quickDevices[idx]
val updated = old.copy(
id = "$host:$newPort",
port = newPort,
online = online,
)

View File

@@ -0,0 +1,111 @@
package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
class AppSettings(context: Context) : Settings(context, "AppSettings") {
companion object {
private val THEME_BASE_INDEX = Pair(
intPreferencesKey("theme_base_index"),
0
)
private val MONET = Pair(
booleanPreferencesKey("monet"),
false
)
private val FULLSCREEN_DEBUG_INFO = Pair(
booleanPreferencesKey("fullscreen_debug_info"),
false
)
private val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = Pair(
booleanPreferencesKey("show_fullscreen_virtual_buttons"),
true
)
private val KEEP_SCREEN_ON_WHEN_STREAMING = Pair(
booleanPreferencesKey("keep_screen_on_when_streaming"),
false
)
private val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair(
intPreferencesKey("device_preview_card_height_dp"),
320
)
private val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair(
booleanPreferencesKey("preview_virtual_button_show_text"),
true
)
private val VIRTUAL_BUTTONS_LAYOUT = Pair(
stringPreferencesKey("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"
)
private val CUSTOM_SERVER_URI = Pair(
stringPreferencesKey("custom_server_uri"),
""
)
private val SERVER_REMOTE_PATH = Pair(
stringPreferencesKey("server_remote_path"),
"/data/local/tmp/scrcpy-server.jar"
)
private val ADB_KEY_NAME = Pair(
stringPreferencesKey("adb_key_name"),
"scrcpy"
)
private val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = Pair(
booleanPreferencesKey("adb_pairing_auto_discover_on_dialog_open"),
true
)
private val ADB_AUTO_RECONNECT_PAIRED_DEVICE = Pair(
booleanPreferencesKey("adb_auto_reconnect_paired_device"),
true
)
private val ADB_MDNS_LAN_DISCOVERY = Pair(
booleanPreferencesKey("adb_mdns_lan_discovery"),
true
)
}
// Theme Settings
val themeBaseIndex by setting(THEME_BASE_INDEX)
val monet by setting(MONET)
// Scrcpy Settings
val fullscreenDebugInfo by setting(FULLSCREEN_DEBUG_INFO)
val showFullscreenVirtualButtons by setting(SHOW_FULLSCREEN_VIRTUAL_BUTTONS)
val keepScreenOnWhenStreaming by setting(KEEP_SCREEN_ON_WHEN_STREAMING)
val devicePreviewCardHeightDp by setting(DEVICE_PREVIEW_CARD_HEIGHT_DP)
val previewVirtualButtonShowText by setting(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT)
val virtualButtonsLayout by setting(VIRTUAL_BUTTONS_LAYOUT)
// Scrcpy Server Settings
val customServerUri by setting(CUSTOM_SERVER_URI)
val serverRemotePath by setting(SERVER_REMOTE_PATH)
// ADB Settings
val adbKeyName by setting(ADB_KEY_NAME)
val adbPairingAutoDiscoverOnDialogOpen by setting(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN)
val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE)
val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY)
override suspend fun toMap(): Map<String, Any> {
return mapOf(
THEME_BASE_INDEX.name to themeBaseIndex.get(),
MONET.name to monet.get(),
FULLSCREEN_DEBUG_INFO.name to fullscreenDebugInfo.get(),
SHOW_FULLSCREEN_VIRTUAL_BUTTONS.name to showFullscreenVirtualButtons.get(),
KEEP_SCREEN_ON_WHEN_STREAMING.name to keepScreenOnWhenStreaming.get(),
DEVICE_PREVIEW_CARD_HEIGHT_DP.name to devicePreviewCardHeightDp.get(),
PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT.name to previewVirtualButtonShowText.get(),
VIRTUAL_BUTTONS_LAYOUT.name to virtualButtonsLayout.get(),
CUSTOM_SERVER_URI.name to customServerUri.get(),
SERVER_REMOTE_PATH.name to serverRemotePath.get(),
ADB_KEY_NAME.name to adbKeyName.get(),
ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN.name to adbPairingAutoDiscoverOnDialogOpen.get(),
ADB_AUTO_RECONNECT_PAIRED_DEVICE.name to adbAutoReconnectPairedDevice.get(),
ADB_MDNS_LAN_DISCOVERY.name to adbMdnsLanDiscovery.get()
)
}
// TODO?
override fun validate(): Boolean = true
}

View File

@@ -0,0 +1,128 @@
package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
/**
* 使用委托方式的 AppSettings 示例
*
* 使用方式:
* ```
* // 获取值
* val theme = appSettings.themeBaseIndex.get()
*
* // 设置值
* appSettings.themeBaseIndex.set(1)
*
* // 观察变化Flow
* appSettings.themeBaseIndex.observe().collect { value -> }
*
* // 在 Composable 中观察State
* val theme by appSettings.themeBaseIndex.observeAsState()
* ```
*/
class AppSettingsNew(context: Context) : Settings(context, "AppSettings") {
companion object {
private val THEME_BASE_INDEX = Pair(
intPreferencesKey("theme_base_index"),
0
)
private val MONET = Pair(
booleanPreferencesKey("monet"),
false
)
private val FULLSCREEN_DEBUG_INFO = Pair(
booleanPreferencesKey("fullscreen_debug_info"),
false
)
private val SHOW_FULLSCREEN_VIRTUAL_BUTTONS = Pair(
booleanPreferencesKey("show_fullscreen_virtual_buttons"),
true
)
private val KEEP_SCREEN_ON_WHEN_STREAMING = Pair(
booleanPreferencesKey("keep_screen_on_when_streaming"),
false
)
private val DEVICE_PREVIEW_CARD_HEIGHT_DP = Pair(
intPreferencesKey("device_preview_card_height_dp"),
320
)
private val PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT = Pair(
booleanPreferencesKey("preview_virtual_button_show_text"),
true
)
private val VIRTUAL_BUTTONS_LAYOUT = Pair(
stringPreferencesKey("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"
)
private val CUSTOM_SERVER_URI = Pair(
stringPreferencesKey("custom_server_uri"),
""
)
private val SERVER_REMOTE_PATH = Pair(
stringPreferencesKey("server_remote_path"),
"/data/local/tmp/scrcpy-server.jar"
)
private val ADB_KEY_NAME = Pair(
stringPreferencesKey("adb_key_name"),
"scrcpy"
)
private val ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN = Pair(
booleanPreferencesKey("adb_pairing_auto_discover_on_dialog_open"),
true
)
private val ADB_AUTO_RECONNECT_PAIRED_DEVICE = Pair(
booleanPreferencesKey("adb_auto_reconnect_paired_device"),
true
)
private val ADB_MDNS_LAN_DISCOVERY = Pair(
booleanPreferencesKey("adb_mdns_lan_discovery"),
true
)
}
// Theme Settings
val themeBaseIndex by setting(THEME_BASE_INDEX)
val monet by setting(MONET)
// Scrcpy Settings
val fullscreenDebugInfo by setting(FULLSCREEN_DEBUG_INFO)
val showFullscreenVirtualButtons by setting(SHOW_FULLSCREEN_VIRTUAL_BUTTONS)
val keepScreenOnWhenStreaming by setting(KEEP_SCREEN_ON_WHEN_STREAMING)
val devicePreviewCardHeightDp by setting(DEVICE_PREVIEW_CARD_HEIGHT_DP)
val previewVirtualButtonShowText by setting(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT)
val virtualButtonsLayout by setting(VIRTUAL_BUTTONS_LAYOUT)
// Scrcpy Server Settings
val customServerUri by setting(CUSTOM_SERVER_URI)
val serverRemotePath by setting(SERVER_REMOTE_PATH)
// ADB Settings
val adbKeyName by setting(ADB_KEY_NAME)
val adbPairingAutoDiscoverOnDialogOpen by setting(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN)
val adbAutoReconnectPairedDevice by setting(ADB_AUTO_RECONNECT_PAIRED_DEVICE)
val adbMdnsLanDiscovery by setting(ADB_MDNS_LAN_DISCOVERY)
override suspend fun toMap(): Map<String, Any> {
return mapOf(
THEME_BASE_INDEX.name to themeBaseIndex.get(),
MONET.name to monet.get(),
FULLSCREEN_DEBUG_INFO.name to fullscreenDebugInfo.get(),
SHOW_FULLSCREEN_VIRTUAL_BUTTONS.name to showFullscreenVirtualButtons.get(),
KEEP_SCREEN_ON_WHEN_STREAMING.name to keepScreenOnWhenStreaming.get(),
DEVICE_PREVIEW_CARD_HEIGHT_DP.name to devicePreviewCardHeightDp.get(),
PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT.name to previewVirtualButtonShowText.get(),
VIRTUAL_BUTTONS_LAYOUT.name to virtualButtonsLayout.get(),
CUSTOM_SERVER_URI.name to customServerUri.get(),
SERVER_REMOTE_PATH.name to serverRemotePath.get(),
ADB_KEY_NAME.name to adbKeyName.get(),
ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN.name to adbPairingAutoDiscoverOnDialogOpen.get(),
ADB_AUTO_RECONNECT_PAIRED_DEVICE.name to adbAutoReconnectPairedDevice.get(),
ADB_MDNS_LAN_DISCOVERY.name to adbMdnsLanDiscovery.get()
)
}
override fun validate(): Boolean = true
}

View File

@@ -0,0 +1,171 @@
package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class QuickDevices(context: Context) : Settings(context, "QuickDevices") {
companion object {
private val QUICK_DEVICES = Pair(
stringPreferencesKey("quick_devices"),
"",
)
private val QUICK_CONNECT_INPUT = Pair(
stringPreferencesKey("quick_connect_input"),
"",
)
}
override suspend fun toMap(): Map<String, Any> {
return mapOf(
QUICK_DEVICES.name to getQuickDevicesRaw(),
QUICK_CONNECT_INPUT.name to getQuickConnectInput(),
)
}
override fun validate(): Boolean = true
private suspend fun getQuickDevicesRaw(): String = getValue(QUICK_DEVICES)
private suspend fun setQuickDevicesRaw(value: String) = setValue(QUICK_DEVICES, value)
private fun observeQuickDevicesRaw(): Flow<String> = observe(QUICK_DEVICES)
suspend fun getQuickDevices(): List<DeviceShortcut> {
val raw = getQuickDevicesRaw()
if (raw.isBlank()) return emptyList()
val result = mutableListOf<DeviceShortcut>()
raw.lineSequence().forEach { line ->
DeviceShortcut.unmarshalFrom(line)?.let { result.add(it) }
}
return result
}
suspend fun setQuickDevices(quickDevices: List<DeviceShortcut>) =
setQuickDevicesRaw(quickDevices.joinToString("\n") { it.marshalToString() })
fun observeQuickDevices(): Flow<List<DeviceShortcut>> = observeQuickDevicesRaw()
.map { raw ->
if (raw.isBlank()) return@map emptyList()
val result = mutableListOf<DeviceShortcut>()
raw.lineSequence().forEach { line ->
DeviceShortcut.unmarshalFrom(line)?.let { result.add(it) }
}
result
}
/**
* 插入或更新快速设备
*/
suspend fun upsertQuickDevice(
host: String,
port: Int,
online: Boolean,
index: Int? = 0,
) {
val quickDevices = getQuickDevices().toMutableList()
val id = "$host:$port"
val idx = quickDevices.indexOfFirst { it.id == id }
val existingName = if (idx >= 0) quickDevices[idx].name else ""
val item = DeviceShortcut(
name = existingName,
host = host,
port = port,
online = online,
)
if (idx >= 0) {
quickDevices[idx] = item
} else {
if (index != null)
quickDevices.add(index, item)
else quickDevices.add(item)
}
setQuickDevices(quickDevices)
}
/**
* 如果设备名称为空,则更新为备用名称
*/
suspend fun updateQuickDeviceNameIfEmpty(
host: String,
port: Int,
fallbackName: String,
) {
val quickDevices = getQuickDevices().toMutableList()
val idx = quickDevices.indexOfFirst { it.host == host && it.port == port }
if (idx >= 0 && quickDevices[idx].name.isBlank()) {
quickDevices[idx] = quickDevices[idx].copy(name = fallbackName)
setQuickDevices(quickDevices)
}
}
/**
* 替换快速设备的端口
*/
suspend fun replaceQuickDevicePort(
host: String,
oldPort: Int,
newPort: Int,
online: Boolean,
) {
val quickDevices = getQuickDevices().toMutableList()
val idx = quickDevices.indexOfFirst { it.host == host && it.port == oldPort }
if (idx < 0) return
val old = quickDevices[idx]
val updated = old.copy(
port = newPort,
online = online,
)
quickDevices[idx] = updated
val dedup = quickDevices.distinctBy { it.id }
setQuickDevices(dedup)
}
/**
* 删除快速设备
*/
suspend fun removeQuickDevice(id: String) {
val quickDevices = getQuickDevices().toMutableList()
quickDevices.removeAll { it.id == id }
setQuickDevices(quickDevices)
}
/**
* 更新设备在线状态
*/
suspend fun updateDeviceOnlineStatus(host: String, port: Int, online: Boolean) {
val quickDevices = getQuickDevices().toMutableList()
val idx = quickDevices.indexOfFirst { it.host == host && it.port == port }
if (idx >= 0) {
quickDevices[idx] = quickDevices[idx].copy(online = online)
setQuickDevices(quickDevices)
}
}
/**
* 更新设备名称
*/
suspend fun updateDeviceName(id: String, name: String) {
val quickDevices = getQuickDevices().toMutableList()
val idx = quickDevices.indexOfFirst { it.id == id }
if (idx >= 0) {
quickDevices[idx] = quickDevices[idx].copy(name = name)
setQuickDevices(quickDevices)
}
}
/**
* 清空所有快速设备
*/
suspend fun clearQuickDevices() {
setQuickDevicesRaw("")
}
private suspend fun getQuickConnectInput(): String = getValue(QUICK_CONNECT_INPUT)
private suspend fun setQuickConnectInput(value: String) = setValue(QUICK_CONNECT_INPUT, value)
private fun observeQuickConnectInput(): Flow<String> = observe(QUICK_CONNECT_INPUT)
}

View File

@@ -0,0 +1,402 @@
package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.DisplayImePolicy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.LogLevel
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Orientation
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
import kotlinx.coroutines.runBlocking
class ScrcpyOptions(context: Context) : Settings(context, "ScrcpyOptions") {
companion object {
private val CROP = Pair(
stringPreferencesKey("crop"),
""
)
private val RECORD_FILENAME = Pair(
stringPreferencesKey("record_filename"),
""
)
private val VIDEO_CODEC_OPTIONS = Pair(
stringPreferencesKey("video_codec_options"),
""
)
private val AUDIO_CODEC_OPTIONS = Pair(
stringPreferencesKey("audio_codec_options"),
""
)
private val VIDEO_ENCODER = Pair(
stringPreferencesKey("video_encoder"),
""
)
private val AUDIO_ENCODER = Pair(
stringPreferencesKey("audio_encoder"),
""
)
private val CAMERA_ID = Pair(
stringPreferencesKey("camera_id"),
""
)
private val CAMERA_SIZE = Pair(
stringPreferencesKey("camera_size"),
""
)
private val CAMERA_AR = Pair(
stringPreferencesKey("camera_ar"),
""
)
private val CAMERA_FPS = Pair(
intPreferencesKey("camera_fps"),
0
)
private val LOG_LEVEL = Pair(
stringPreferencesKey("log_level"),
"info"
)
private val VIDEO_CODEC = Pair(
stringPreferencesKey("video_codec"),
"h264"
)
private val AUDIO_CODEC = Pair(
stringPreferencesKey("audio_codec"),
"opus"
)
private val VIDEO_SOURCE = Pair(
stringPreferencesKey("video_source"),
"display"
)
private val AUDIO_SOURCE = Pair(
stringPreferencesKey("audio_source"),
"output"
)
private val RECORD_FORMAT = Pair(
stringPreferencesKey("record_format"),
"auto"
)
private val CAMERA_FACING = Pair(
stringPreferencesKey("camera_facing"),
"any"
)
private val MAX_SIZE = Pair(
intPreferencesKey("max_size"),
0
)
private val VIDEO_BIT_RATE = Pair(
intPreferencesKey("video_bit_rate"),
8000000
)
private val AUDIO_BIT_RATE = Pair(
intPreferencesKey("audio_bit_rate"),
128000
)
private val MAX_FPS = Pair(
stringPreferencesKey("max_fps"),
""
)
private val ANGLE = Pair(
stringPreferencesKey("angle"),
""
)
private val CAPTURE_ORIENTATION = Pair(
intPreferencesKey("capture_orientation"),
0
)
private val CAPTURE_ORIENTATION_LOCK = Pair(
stringPreferencesKey("capture_orientation_lock"),
"unlocked"
)
private val DISPLAY_ORIENTATION = Pair(
intPreferencesKey("display_orientation"),
0
)
private val RECORD_ORIENTATION = Pair(
intPreferencesKey("record_orientation"),
0
)
private val DISPLAY_IME_POLICY = Pair(
stringPreferencesKey("display_ime_policy"),
"undefined"
)
private val DISPLAY_ID = Pair(
intPreferencesKey("display_id"),
0
)
private val SCREEN_OFF_TIMEOUT = Pair(
longPreferencesKey("screen_off_timeout"),
-1
)
private val SHOW_TOUCHES = Pair(
booleanPreferencesKey("show_touches"),
false
)
private val FULLSCREEN = Pair(
booleanPreferencesKey("fullscreen"),
false
)
private val CONTROL = Pair(
booleanPreferencesKey("control"),
true
)
private val VIDEO_PLAYBACK = Pair(
booleanPreferencesKey("video_playback"),
true
)
private val AUDIO_PLAYBACK = Pair(
booleanPreferencesKey("audio_playback"),
true
)
private val TURN_SCREEN_OFF = Pair(
booleanPreferencesKey("turn_screen_off"),
false
)
private val STAY_AWAKE = Pair(
booleanPreferencesKey("stay_awake"),
false
)
private val DISABLE_SCREENSAVER = Pair(
booleanPreferencesKey("disable_screensaver"),
false
)
private val POWER_OFF_ON_CLOSE = Pair(
booleanPreferencesKey("power_off_on_close"),
false
)
private val CLEANUP = Pair(
booleanPreferencesKey("cleanup"),
true
)
private val POWER_ON = Pair(
booleanPreferencesKey("power_on"),
true
)
private val VIDEO = Pair(
booleanPreferencesKey("video"),
true
)
private val AUDIO = Pair(
booleanPreferencesKey("audio"),
true
)
private val REQUIRE_AUDIO = Pair(
booleanPreferencesKey("require_audio"),
false
)
private val KILL_ADB_ON_CLOSE = Pair(
booleanPreferencesKey("kill_adb_on_close"),
false
)
private val CAMERA_HIGH_SPEED = Pair(
booleanPreferencesKey("camera_high_speed"),
false
)
private val LIST = Pair(
stringPreferencesKey("list"),
"null"
)
private val AUDIO_DUP = Pair(
booleanPreferencesKey("audio_dup"),
false
)
private val NEW_DISPLAY = Pair(
stringPreferencesKey("new_display"),
""
)
private val START_APP = Pair(
stringPreferencesKey("start_app"),
""
)
private val VD_DESTROY_CONTENT = Pair(
booleanPreferencesKey("vd_destroy_content"),
true
)
private val VD_SYSTEM_DECORATIONS = Pair(
booleanPreferencesKey("vd_system_decorations"),
true
)
}
val crop by setting(CROP)
val recordFilename by setting(RECORD_FILENAME)
val videoCodecOptions by setting(VIDEO_CODEC_OPTIONS)
val audioCodecOptions by setting(AUDIO_CODEC_OPTIONS)
val videoEncoder by setting(VIDEO_ENCODER)
val audioEncoder by setting(AUDIO_ENCODER)
val cameraId by setting(CAMERA_ID)
val cameraSize by setting(CAMERA_SIZE)
val cameraAr by setting(CAMERA_AR)
val cameraFps by setting(CAMERA_FPS)
val logLevel by setting(LOG_LEVEL)
val videoCodec by setting(VIDEO_CODEC)
val audioCodec by setting(AUDIO_CODEC)
val videoSource by setting(VIDEO_SOURCE)
val audioSource by setting(AUDIO_SOURCE)
val recordFormat by setting(RECORD_FORMAT)
val cameraFacing by setting(CAMERA_FACING)
val maxSize by setting(MAX_SIZE)
val videoBitRate by setting(VIDEO_BIT_RATE)
val audioBitRate by setting(AUDIO_BIT_RATE)
val maxFps by setting(MAX_FPS)
val angle by setting(ANGLE)
val captureOrientation by setting(CAPTURE_ORIENTATION)
val captureOrientationLock by setting(CAPTURE_ORIENTATION_LOCK)
val displayOrientation by setting(DISPLAY_ORIENTATION)
val recordOrientation by setting(RECORD_ORIENTATION)
val displayImePolicy by setting(DISPLAY_IME_POLICY)
val displayId by setting(DISPLAY_ID)
val screenOffTimeout by setting(SCREEN_OFF_TIMEOUT)
val showTouches by setting(SHOW_TOUCHES)
val fullscreen by setting(FULLSCREEN)
val control by setting(CONTROL)
val videoPlayback by setting(VIDEO_PLAYBACK)
val audioPlayback by setting(AUDIO_PLAYBACK)
val turnScreenOff by setting(TURN_SCREEN_OFF)
val stayAwake by setting(STAY_AWAKE)
val disableScreensaver by setting(DISABLE_SCREENSAVER)
val powerOffOnClose by setting(POWER_OFF_ON_CLOSE)
val cleanup by setting(CLEANUP)
val powerOn by setting(POWER_ON)
val video by setting(VIDEO)
val audio by setting(AUDIO)
val requireAudio by setting(REQUIRE_AUDIO)
val killAdbOnClose by setting(KILL_ADB_ON_CLOSE)
val cameraHighSpeed by setting(CAMERA_HIGH_SPEED)
val list by setting(LIST)
val audioDup by setting(AUDIO_DUP)
val newDisplay by setting(NEW_DISPLAY)
val startApp by setting(START_APP)
val vdDestroyContent by setting(VD_DESTROY_CONTENT)
val vdSystemDecorations by setting(VD_SYSTEM_DECORATIONS)
override suspend fun toMap(): Map<String, Any> {
return mapOf(
CROP.name to crop.get(),
RECORD_FILENAME.name to recordFilename.get(),
VIDEO_CODEC_OPTIONS.name to videoCodecOptions.get(),
AUDIO_CODEC_OPTIONS.name to audioCodecOptions.get(),
VIDEO_ENCODER.name to videoEncoder.get(),
AUDIO_ENCODER.name to audioEncoder.get(),
CAMERA_ID.name to cameraId.get(),
CAMERA_SIZE.name to cameraSize.get(),
CAMERA_AR.name to cameraAr.get(),
CAMERA_FPS.name to cameraFps.get(),
LOG_LEVEL.name to logLevel.get(),
VIDEO_CODEC.name to videoCodec.get(),
AUDIO_CODEC.name to audioCodec.get(),
VIDEO_SOURCE.name to videoSource.get(),
AUDIO_SOURCE.name to audioSource.get(),
RECORD_FORMAT.name to recordFormat.get(),
CAMERA_FACING.name to cameraFacing.get(),
MAX_SIZE.name to maxSize.get(),
VIDEO_BIT_RATE.name to videoBitRate.get(),
AUDIO_BIT_RATE.name to audioBitRate.get(),
MAX_FPS.name to maxFps.get(),
ANGLE.name to angle.get(),
CAPTURE_ORIENTATION.name to captureOrientation.get(),
CAPTURE_ORIENTATION_LOCK.name to captureOrientationLock.get(),
DISPLAY_ORIENTATION.name to displayOrientation.get(),
RECORD_ORIENTATION.name to recordOrientation.get(),
DISPLAY_IME_POLICY.name to displayImePolicy.get(),
DISPLAY_ID.name to displayId.get(),
SCREEN_OFF_TIMEOUT.name to screenOffTimeout.get(),
SHOW_TOUCHES.name to showTouches.get(),
FULLSCREEN.name to fullscreen.get(),
CONTROL.name to control.get(),
VIDEO_PLAYBACK.name to videoPlayback.get(),
AUDIO_PLAYBACK.name to audioPlayback.get(),
TURN_SCREEN_OFF.name to turnScreenOff.get(),
STAY_AWAKE.name to stayAwake.get(),
DISABLE_SCREENSAVER.name to disableScreensaver.get(),
POWER_OFF_ON_CLOSE.name to powerOffOnClose.get(),
CLEANUP.name to cleanup.get(),
POWER_ON.name to powerOn.get(),
VIDEO.name to video.get(),
AUDIO.name to audio.get(),
REQUIRE_AUDIO.name to requireAudio.get(),
KILL_ADB_ON_CLOSE.name to killAdbOnClose.get(),
CAMERA_HIGH_SPEED.name to cameraHighSpeed.get(),
LIST.name to list.get(),
AUDIO_DUP.name to audioDup.get(),
NEW_DISPLAY.name to newDisplay.get(),
START_APP.name to startApp.get(),
VD_DESTROY_CONTENT.name to vdDestroyContent.get(),
VD_SYSTEM_DECORATIONS.name to vdSystemDecorations.get()
)
}
override fun validate(): Boolean = runBlocking {
runCatching {
toClientOptions().validate()
true
}.getOrDefault(false)
}
// TODO: 处理空值
suspend fun toClientOptions(): ClientOptions {
return ClientOptions(
crop = crop.get(),
recordFilename = recordFilename.get(),
videoCodecOptions = videoCodecOptions.get(),
audioCodecOptions = audioCodecOptions.get(),
videoEncoder = videoEncoder.get(),
audioEncoder = audioEncoder.get(),
cameraId = cameraId.get(),
cameraSize = cameraSize.get(),
cameraAr = cameraAr.get(),
cameraFps = cameraFps.get().toUShort(),
logLevel = LogLevel.valueOf(logLevel.get().uppercase()),
videoCodec = Codec.valueOf(videoCodec.get().uppercase()),
audioCodec = Codec.valueOf(audioCodec.get().uppercase()),
videoSource = VideoSource.valueOf(videoSource.get().uppercase()),
audioSource = AudioSource.valueOf(audioSource.get().uppercase()),
recordFormat = ClientOptions.RecordFormat.valueOf(recordFormat.get().uppercase()),
cameraFacing = CameraFacing.valueOf(cameraFacing.get().uppercase()),
maxSize = maxSize.get().toUShort(),
videoBitRate = videoBitRate.get().toUInt(),
audioBitRate = audioBitRate.get().toUInt(),
maxFps = maxFps.get(),
angle = angle.get(),
captureOrientation = Orientation.fromInt(captureOrientation.get()),
captureOrientationLock = OrientationLock.valueOf(
captureOrientationLock.get().uppercase()
),
displayOrientation = Orientation.fromInt(displayOrientation.get()),
recordOrientation = Orientation.fromInt(recordOrientation.get()),
displayImePolicy = DisplayImePolicy.valueOf(displayImePolicy.get().uppercase()),
displayId = displayId.get().toUInt(),
screenOffTimeout = Tick(screenOffTimeout.get()),
showTouches = showTouches.get(),
fullscreen = fullscreen.get(),
control = control.get(),
videoPlayback = videoPlayback.get(),
audioPlayback = audioPlayback.get(),
turnScreenOff = turnScreenOff.get(),
stayAwake = stayAwake.get(),
disableScreensaver = disableScreensaver.get(),
powerOffOnClose = powerOffOnClose.get(),
cleanUp = cleanup.get(),
powerOn = powerOn.get(),
video = video.get(),
audio = audio.get(),
requireAudio = requireAudio.get(),
killAdbOnClose = killAdbOnClose.get(),
cameraHighSpeed = cameraHighSpeed.get(),
list = ListOptions.valueOf(list.get().uppercase()),
audioDup = audioDup.get(),
newDisplay = newDisplay.get(),
startApp = startApp.get(),
vdDestroyContent = vdDestroyContent.get(),
vdSystemDecorations = vdSystemDecorations.get()
)
}
}

View File

@@ -0,0 +1,115 @@
package io.github.miuzarte.scrcpyforandroid.storage
import android.content.Context
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlin.reflect.KProperty
private const val TAG = "Settings"
abstract class Settings(
context: Context,
private val name: String,
corruptionHandler: ReplaceFileCorruptionHandler<Preferences>? =
ReplaceFileCorruptionHandler {
Log.e(TAG, "Preferences corrupted, resetting.", it)
emptyPreferences()
}
) {
data class Pair<T>(
val key: Preferences.Key<T>,
val defaultValue: T,
) {
val name: String get() = key.name
}
/**
* 设置项委托类,自动提供 get/set/observe/observeAsState/asMutableState 方法
*/
inner class SettingProperty<T>(
private val pair: Pair<T>
) {
operator fun getValue(thisRef: Any?, property: KProperty<*>): SettingProperty<T> = this
suspend fun get(): T = getValue(pair)
suspend fun set(value: T) = setValue(pair, value)
fun observe(): Flow<T> = this@Settings.observe(pair)
@Composable
fun asState(): State<T> = this@Settings.asState(pair)
@Composable
fun asMutableState(): MutableState<T> = this@Settings.asMutableState(pair)
}
// 为 Context 添加扩展委托属性,确保 DataStore 单例
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = this.name,
corruptionHandler = corruptionHandler,
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
)
abstract suspend fun toMap(): Map<String, Any>
abstract fun validate(): Boolean
// 对外暴露的 DataStore 实例
protected val dataStore: DataStore<Preferences> = context.dataStore
protected fun <T> setting(pair: Pair<T>): SettingProperty<T> =
SettingProperty(pair)
protected suspend fun <T> getValue(pair: Pair<T>): T =
dataStore.data.first()[pair.key] ?: pair.defaultValue
protected suspend fun <T> setValue(pair: Pair<T>, value: T) {
dataStore.edit { preferences -> preferences[pair.key] = value }
}
protected fun <T> observe(pair: Pair<T>): Flow<T> =
dataStore.data.map { preferences -> preferences[pair.key] ?: pair.defaultValue }
@Composable
protected fun <T> asState(pair: Pair<T>): State<T> =
observe(pair).collectAsState(initial = pair.defaultValue)
@Composable
protected fun <T> asMutableState(pair: Pair<T>): MutableState<T> {
val scope = rememberCoroutineScope()
val state = asState(pair)
return remember(state.value) {
object : MutableState<T> {
override var value: T
get() = state.value
set(newValue) {
scope.launch {
setValue(pair, newValue)
}
}
override fun component1(): T = value
override fun component2(): (T) -> Unit = { value = it }
}
}
}
}

View File

@@ -53,6 +53,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -70,6 +71,8 @@ import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -92,12 +95,16 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor
import top.yukonga.miuix.kmp.utils.PressFeedbackType
import kotlin.math.roundToInt
// TODO: migrate to scrcpy.Shared
@Deprecated("scrcpy.Shared.Codec")
private val VIDEO_CODEC_OPTIONS = listOf(
"h264" to "H.264",
"h265" to "H.265",
"av1" to "AV1",
)
@Deprecated("scrcpy.Shared.Codec")
private val AUDIO_CODEC_OPTIONS = listOf(
"opus" to "Opus",
"aac" to "AAC",
@@ -124,8 +131,14 @@ internal fun StatusCard(
sessionInfo: ScrcpySessionInfo?,
busyLabel: String?,
connectedDeviceLabel: String,
themeBaseIndex: Int,
) {
val context = LocalContext.current
val appSettings = remember(context) { AppSettings(context) }
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val themeBaseIndex by appSettings.themeBaseIndex.asState()
val cleanStatusLine = normalizeStatusLine(statusLine)
// 根据应用主题设置决定是否使用深色模式
@@ -261,9 +274,9 @@ internal fun PreviewCard(
previewHeightDp: Int,
controlsVisible: Boolean,
onTapped: () -> Unit,
onOpenFullscreenHaptic: (() -> Unit)? = null,
onOpenFullscreen: () -> Unit,
) {
val haptics = rememberAppHaptics()
val alpha by animateFloatAsState(if (controlsVisible) 1f else 0f, label = "preview-controls")
Card {
@@ -312,7 +325,7 @@ internal fun PreviewCard(
Button(
onClick = {
if (alpha > 0.1) {
onOpenFullscreenHaptic?.invoke()
haptics.contextClick()
onOpenFullscreen()
}
},
@@ -361,94 +374,99 @@ internal fun VirtualButtonCard(
@Composable
internal fun ConfigPanel(
busy: Boolean,
bitRateMbps: Float,
onBitRateSliderChange: (Float) -> Unit,
onBitRateInputChange: (String) -> Unit,
audioBitRateKbps: Int,
onAudioBitRateChange: (Int) -> Unit,
videoCodec: String,
onVideoCodecChange: (String) -> Unit,
audioEnabled: Boolean,
onAudioEnabledChange: (Boolean) -> Unit,
audioForwardingSupported: Boolean,
audioCodec: String,
onAudioCodecChange: (String) -> Unit,
onOpenAdvanced: () -> Unit,
onStartStopHaptic: (() -> Unit)? = null,
onStart: () -> Unit,
onStop: () -> Unit,
sessionStarted: Boolean,
) {
val videoCodecItems = remember { VIDEO_CODEC_OPTIONS.map { it.second } }
val videoCodecIndex =
VIDEO_CODEC_OPTIONS.indexOfFirst { it.first == videoCodec }.coerceAtLeast(0)
val audioCodecItems = remember { AUDIO_CODEC_OPTIONS.map { it.second } }
val audioCodecIndex =
AUDIO_CODEC_OPTIONS.indexOfFirst { it.first == audioCodec }.coerceAtLeast(0)
val audioBitRatePresetIndex =
presetIndexFromInput(audioBitRateKbps.toString(), ScrcpyPresets.AudioBitRate)
val context = LocalContext.current
// val appSettings = remember(context) { AppSettings(context) }
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
SectionSmallTitle("Scrcpy")
Card {
var audio by scrcpyOptions.audio.asMutableState()
SuperSwitch(
title = "音频转发",
summary = "转发设备音频到本机 (Android 11+)",
checked = audioEnabled,
onCheckedChange = onAudioEnabledChange,
checked = audio,
onCheckedChange = { audio = it },
enabled = !sessionStarted && audioForwardingSupported,
)
var audioCodec by scrcpyOptions.audioCodec.asMutableState()
val audioCodecItems = remember { AUDIO_CODEC_OPTIONS.map { it.second } }
val audioCodecIndex = AUDIO_CODEC_OPTIONS.indexOfFirst {
it.first == audioCodec
}.coerceAtLeast(0)
var audioBitRate by scrcpyOptions.audioBitRate.asMutableState()
val audioBitRateKbps = audioBitRate * 1_000f
val audioBitRatePresetIndex = presetIndexFromInput(
audioBitRateKbps.toString(),
ScrcpyPresets.AudioBitRate
)
SuperDropdown(
title = "音频编码",
summary = "--audio-codec",
items = audioCodecItems,
selectedIndex = audioCodecIndex,
onSelectedIndexChange = { onAudioCodecChange(AUDIO_CODEC_OPTIONS[it].first) },
enabled = !sessionStarted && audioEnabled,
onSelectedIndexChange = { audioCodec = AUDIO_CODEC_OPTIONS[it].first },
enabled = !sessionStarted && audio,
)
if (audioEnabled && (audioCodec == "opus" || audioCodec == "aac")) {
if (audio && (audioCodec == "opus" || audioCodec == "aac")) {
SuperSlide(
title = "音频码率",
summary = "--audio-bit-rate",
value = audioBitRatePresetIndex.toFloat(),
onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex)
onAudioBitRateChange(ScrcpyPresets.AudioBitRate[idx])
audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1024
},
valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(),
steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0),
enabled = !sessionStarted,
unit = "Kbps",
displayText = audioBitRateKbps.toString(),
inputInitialValue = audioBitRateKbps.toString(),
displayText = audioBitRate.toString(),
inputInitialValue = audioBitRate.toString(),
inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 1f..Float.MAX_VALUE,
onInputConfirm = { raw ->
raw.toIntOrNull()?.takeIf { it > 0 }?.let { onAudioBitRateChange(it) }
raw.toIntOrNull()?.takeIf { it > 0 }?.let { audioBitRate = it }
},
)
}
var videoCodec by scrcpyOptions.videoCodec.asMutableState()
val videoCodecItems = remember { VIDEO_CODEC_OPTIONS.map { it.second } }
val videoCodecIndex = VIDEO_CODEC_OPTIONS.indexOfFirst {
it.first == videoCodec
}.coerceAtLeast(0)
var videoBitRate by scrcpyOptions.videoBitRate.asMutableState()
val videoBitRateMbps = videoBitRate / 1_000_000f
SuperDropdown(
title = "视频编码",
summary = "--video-codec",
items = videoCodecItems,
selectedIndex = videoCodecIndex,
onSelectedIndexChange = { onVideoCodecChange(VIDEO_CODEC_OPTIONS[it].first) },
onSelectedIndexChange = { videoCodec = VIDEO_CODEC_OPTIONS[it].first },
enabled = !sessionStarted,
)
SuperSlide(
title = "视频码率",
summary = "--video-bit-rate",
value = bitRateMbps,
onValueChange = {
onBitRateSliderChange(it)
onBitRateInputChange(formatBitRate(it))
value = videoBitRateMbps,
onValueChange = { mbps ->
videoBitRate = (mbps * 1_000_000).toInt()
},
valueRange = 0.1f..40f,
steps = 399,
enabled = !sessionStarted,
unit = "Mbps",
displayFormatter = { formatBitRate(it) },
inputInitialValue = formatBitRate(bitRateMbps),
inputInitialValue = formatBitRate(videoBitRateMbps),
inputFilter = { text ->
var dotUsed = false
text.filter { ch ->
@@ -467,18 +485,19 @@ internal fun ConfigPanel(
onInputConfirm = { raw ->
raw.toFloatOrNull()?.let { parsed ->
if (parsed >= 0.1f) {
onBitRateSliderChange(parsed)
onBitRateInputChange(formatBitRate(parsed))
videoBitRate = (parsed * 1_000_000).toInt()
}
}
},
)
SuperArrow(
title = "高级参数",
summary = "更多 scrcpy 启动参数",
onClick = onOpenAdvanced,
enabled = !sessionStarted,
)
TextButton(
text = if (sessionStarted) "停止" else "启动",
onClick = {
@@ -1353,7 +1372,6 @@ internal fun DeviceEditorScreen(
if (h.isNotBlank()) {
onSave(
DeviceShortcut(
id = "$h:$p",
name = name.trim(),
host = h,
port = p,