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.bouncycastle:bcpkix-jdk18on:1.80")
implementation("org.conscrypt:conscrypt-android:2.5.2") implementation("org.conscrypt:conscrypt-android:2.5.2")
implementation("sh.calvin.reorderable:reorderable:3.0.0") implementation("sh.calvin.reorderable:reorderable:3.0.0")
implementation("androidx.datastore:datastore-preferences:1.2.1")
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))

View File

@@ -1,7 +1,7 @@
package io.github.miuzarte.scrcpyforandroid.constants package io.github.miuzarte.scrcpyforandroid.constants
object ScrcpyPresets { 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 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 package io.github.miuzarte.scrcpyforandroid.models
internal data class ConnectionTarget( import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults
val host: String,
val port: Int,
)
/** data class DeviceShortcut(
* A compact shortcut entry for quick-connect lists shown in the UI. val name: String = "",
* `online` indicates whether the device was reachable when last probed.
*/
internal data class DeviceShortcut(
val id: String,
val name: String,
val host: String, val host: String,
val port: Int, val port: Int = AppDefaults.ADB_PORT,
val online: Boolean, 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.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@@ -22,6 +26,8 @@ import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets
import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide 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 kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.ScrollBehavior import top.yukonga.miuix.kmp.basic.ScrollBehavior
@@ -69,10 +75,12 @@ internal fun AdvancedConfigPage(
contentPadding: PaddingValues, contentPadding: PaddingValues,
scrollBehavior: ScrollBehavior, scrollBehavior: ScrollBehavior,
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
sessionStarted: Boolean, // sessionStarted: Boolean,
audioEnabled: Boolean,
noControl: Boolean, // audioEnabled: Boolean,
onNoControlChange: (Boolean) -> Unit,
// noControl: Boolean,
// onNoControlChange: (Boolean) -> Unit,
audioDup: Boolean, audioDup: Boolean,
onAudioDupChange: (Boolean) -> Unit, onAudioDupChange: (Boolean) -> Unit,
audioSourcePreset: String, audioSourcePreset: String,
@@ -109,6 +117,7 @@ internal fun AdvancedConfigPage(
onMaxSizeInputChange: (String) -> Unit, onMaxSizeInputChange: (String) -> Unit,
maxFpsInput: String, maxFpsInput: String,
onMaxFpsInputChange: (String) -> Unit, onMaxFpsInputChange: (String) -> Unit,
videoEncoderDropdownItems: List<String>, videoEncoderDropdownItems: List<String>,
videoEncoderTypeMap: Map<String, String>, videoEncoderTypeMap: Map<String, String>,
videoEncoderIndex: Int, videoEncoderIndex: Int,
@@ -121,27 +130,28 @@ internal fun AdvancedConfigPage(
onAudioEncoderChange: (String) -> Unit, onAudioEncoderChange: (String) -> Unit,
audioCodecOptions: String, audioCodecOptions: String,
onAudioCodecOptionsChange: (String) -> Unit, onAudioCodecOptionsChange: (String) -> Unit,
onRefreshEncoders: () -> Unit, onRefreshEncoders: () -> Unit,
onRefreshCameraSizes: () -> Unit, onRefreshCameraSizes: () -> Unit,
newDisplayWidth: String, newDisplayWidth: String,
onNewDisplayWidthChange: (String) -> Unit,
newDisplayHeight: String, newDisplayHeight: String,
onNewDisplayHeightChange: (String) -> Unit,
newDisplayDpi: String, newDisplayDpi: String,
onNewDisplayDpiChange: (String) -> Unit,
displayIdInput: String, displayIdInput: String,
onDisplayIdInputChange: (String) -> Unit,
cropWidth: String, cropWidth: String,
onCropWidthChange: (String) -> Unit,
cropHeight: String, cropHeight: String,
onCropHeightChange: (String) -> Unit,
cropX: String, cropX: String,
onCropXChange: (String) -> Unit,
cropY: String, 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 focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val maxSizePresetIndex = val maxSizePresetIndex =
presetIndexFromInputForAdvancedPage(maxSizeInput, ScrcpyPresets.MaxSize) presetIndexFromInputForAdvancedPage(maxSizeInput, ScrcpyPresets.MaxSize)
val maxFpsPresetIndex = presetIndexFromInputForAdvancedPage(maxFpsInput, ScrcpyPresets.MaxFPS) 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( AppPageLazyColumn(
contentPadding = contentPadding, contentPadding = contentPadding,
@@ -190,30 +204,27 @@ internal fun AdvancedConfigPage(
title = "启动后关闭屏幕", title = "启动后关闭屏幕",
summary = "--turn-screen-off", summary = "--turn-screen-off",
checked = turnScreenOff, checked = turnScreenOff,
onCheckedChange = { value -> onCheckedChange = {
onTurnScreenOffChange(value) turnScreenOff = it
if (value) scope.launch { if (it) scope.launch {
// github.com/Genymobile/scrcpy/issues/3376 // github.com/Genymobile/scrcpy/issues/3376
// github.com/Genymobile/scrcpy/issues/4587 // github.com/Genymobile/scrcpy/issues/4587
// github.com/Genymobile/scrcpy/issues/5676 // github.com/Genymobile/scrcpy/issues/5676
snackbarHostState.showSnackbar("注意:大部分设备在关闭屏幕后刷新率会降低/减半") snackbarHostState.showSnackbar("注意:大部分设备在关闭屏幕后刷新率会降低/减半")
} }
}, },
enabled = !sessionStarted && !noControl,
) )
SuperSwitch( SuperSwitch(
title = "禁用控制", title = "禁用控制",
summary = "--no-control", summary = "--no-control",
checked = noControl, checked = !control,
onCheckedChange = onNoControlChange, onCheckedChange = { control = !it },
enabled = !sessionStarted,
) )
SuperSwitch( SuperSwitch(
title = "禁用视频", title = "禁用视频",
summary = "--no-video", summary = "--no-video",
checked = noVideo, checked = !video,
onCheckedChange = onNoVideoChange, onCheckedChange = { video = !it },
enabled = !sessionStarted,
) )
} }
} }
@@ -225,15 +236,14 @@ internal fun AdvancedConfigPage(
summary = "--video-source", summary = "--video-source",
items = videoSourceItems, items = videoSourceItems,
selectedIndex = videoSourceIndex, selectedIndex = videoSourceIndex,
onSelectedIndexChange = { index -> onSelectedIndexChange = {
onVideoSourcePresetChange(VIDEO_SOURCE_OPTIONS[index].first) videoSourcePreset = VIDEO_SOURCE_OPTIONS[it].first
}, },
enabled = !sessionStarted,
) )
if (videoSourcePreset == "display") { if (videoSourcePreset == "display") {
TextField( TextField(
value = displayIdInput, value = displayIdInput,
onValueChange = onDisplayIdInputChange, onValueChange = { displayIdInput = it },
label = "--display-id", label = "--display-id",
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
@@ -246,7 +256,7 @@ internal fun AdvancedConfigPage(
if (videoSourcePreset == "camera") { if (videoSourcePreset == "camera") {
TextField( TextField(
value = cameraIdInput, value = cameraIdInput,
onValueChange = onCameraIdInputChange, onValueChange = { cameraIdInput = it },
label = "--camera-id", label = "--camera-id",
singleLine = true, singleLine = true,
modifier = Modifier modifier = Modifier
@@ -258,17 +268,15 @@ internal fun AdvancedConfigPage(
title = "重新获取 Camera Sizes", title = "重新获取 Camera Sizes",
summary = "--list-camera-sizes", summary = "--list-camera-sizes",
onClick = onRefreshCameraSizes, onClick = onRefreshCameraSizes,
enabled = !sessionStarted,
) )
SuperDropdown( SuperDropdown(
title = "摄像头朝向", title = "摄像头朝向",
summary = "--camera-facing", summary = "--camera-facing",
items = cameraFacingItems, items = cameraFacingItems,
selectedIndex = cameraFacingIndex, selectedIndex = cameraFacingIndex,
onSelectedIndexChange = { index -> onSelectedIndexChange = {
onCameraFacingPresetChange(CAMERA_FACING_OPTIONS[index].first) cameraFacingPreset = CAMERA_FACING_OPTIONS[it].first
}, },
enabled = !sessionStarted,
) )
SuperDropdown( SuperDropdown(
title = "摄像头分辨率", title = "摄像头分辨率",
@@ -278,21 +286,18 @@ internal fun AdvancedConfigPage(
0, 0,
(cameraSizeDropdownItems.size - 1).coerceAtLeast(0) (cameraSizeDropdownItems.size - 1).coerceAtLeast(0)
), ),
onSelectedIndexChange = { index -> onSelectedIndexChange = {
onCameraSizePresetChange( cameraSizePreset = when (it) {
when (index) { 0 -> ""
0 -> "" cameraSizeDropdownItems.lastIndex -> "custom"
cameraSizeDropdownItems.lastIndex -> "custom" else -> cameraSizeDropdownItems[it]
else -> cameraSizeDropdownItems[index] }
},
)
}, },
enabled = !sessionStarted,
) )
if (cameraSizePreset == "custom") { if (cameraSizePreset == "custom") {
TextField( TextField(
value = cameraSizeCustom, value = cameraSizeCustom,
onValueChange = onCameraSizeCustomChange, onValueChange = { cameraSizeCustom = it },
label = "--camera-size", label = "--camera-size",
singleLine = true, singleLine = true,
modifier = Modifier modifier = Modifier
@@ -303,7 +308,7 @@ internal fun AdvancedConfigPage(
} }
TextField( TextField(
value = cameraArInput, value = cameraArInput,
onValueChange = onCameraArInputChange, onValueChange = { cameraArInput = it },
label = "--camera-ar", label = "--camera-ar",
singleLine = true, singleLine = true,
modifier = Modifier modifier = Modifier
@@ -318,11 +323,10 @@ internal fun AdvancedConfigPage(
onValueChange = { value -> onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, CAMERA_FPS_PRESETS.lastIndex) val idx = value.roundToInt().coerceIn(0, CAMERA_FPS_PRESETS.lastIndex)
val preset = CAMERA_FPS_PRESETS[idx] 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(), valueRange = 0f..CAMERA_FPS_PRESETS.lastIndex.toFloat(),
steps = (CAMERA_FPS_PRESETS.size - 2).coerceAtLeast(0), steps = (CAMERA_FPS_PRESETS.size - 2).coerceAtLeast(0),
enabled = !sessionStarted,
unit = "fps", unit = "fps",
zeroStateText = "默认", zeroStateText = "默认",
showUnitWhenZeroState = false, showUnitWhenZeroState = false,
@@ -334,16 +338,14 @@ internal fun AdvancedConfigPage(
inputFilter = { it.filter(Char::isDigit) }, inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE, inputValueRange = 0f..Float.MAX_VALUE,
onInputConfirm = { onInputConfirm = {
val normalized = it.ifBlank { "" } cameraFpsInput = if (normalized == "0") "" else normalized
onCameraFpsInputChange(if (normalized == "0") "" else normalized)
}, },
) )
SuperSwitch( SuperSwitch(
title = "高帧率模式", title = "高帧率模式",
summary = "--camera-high-speed", summary = "--camera-high-speed",
checked = cameraHighSpeed, checked = cameraHighSpeed,
onCheckedChange = onCameraHighSpeedChange, onCheckedChange = { cameraHighSpeed = it },
enabled = !sessionStarted,
) )
} }
} }
@@ -356,15 +358,12 @@ internal fun AdvancedConfigPage(
summary = "--audio-source", summary = "--audio-source",
items = audioSourceItems, items = audioSourceItems,
selectedIndex = audioSourceIndex, selectedIndex = audioSourceIndex,
onSelectedIndexChange = { index -> onSelectedIndexChange = { audioSourcePreset = AUDIO_SOURCE_OPTIONS[it].first },
onAudioSourcePresetChange(AUDIO_SOURCE_OPTIONS[index].first)
},
enabled = !sessionStarted && audioEnabled,
) )
if (audioSourcePreset == "custom") { if (audioSourcePreset == "custom") {
TextField( TextField(
value = audioSourceCustom, value = audioSourceCustom,
onValueChange = onAudioSourceCustomChange, onValueChange = { audioSourceCustom = it },
label = "--audio-source", label = "--audio-source",
singleLine = true, singleLine = true,
modifier = Modifier modifier = Modifier
@@ -377,21 +376,19 @@ internal fun AdvancedConfigPage(
title = "音频双路输出", title = "音频双路输出",
summary = "--audio-dup", summary = "--audio-dup",
checked = audioDup, checked = audioDup,
onCheckedChange = onAudioDupChange, onCheckedChange = { audioDup = it },
enabled = !sessionStarted && audioEnabled,
) )
SuperSwitch( SuperSwitch(
title = "仅转发不播放", title = "仅转发不播放",
summary = "--no-audio-playback", summary = "--no-audio-playback",
checked = noAudioPlayback, checked = noAudioPlayback,
onCheckedChange = onNoAudioPlaybackChange, onCheckedChange = { noAudioPlayback = it },
enabled = !sessionStarted && audioEnabled,
) )
SuperSwitch( SuperSwitch(
title = "音频失败时终止 [TODO]", title = "音频失败时终止 [TODO]",
summary = "--require-audio", summary = "--require-audio",
checked = requireAudio, checked = requireAudio,
onCheckedChange = onRequireAudioChange, onCheckedChange = { requireAudio = it },
enabled = false, enabled = false,
) )
} }
@@ -406,11 +403,10 @@ internal fun AdvancedConfigPage(
onValueChange = { value -> onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex)
val preset = ScrcpyPresets.MaxSize[idx] 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(), valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(),
steps = (ScrcpyPresets.MaxSize.size - 2).coerceAtLeast(0), steps = (ScrcpyPresets.MaxSize.size - 2).coerceAtLeast(0),
enabled = !sessionStarted,
unit = "px", unit = "px",
zeroStateText = "关闭", zeroStateText = "关闭",
showUnitWhenZeroState = false, showUnitWhenZeroState = false,
@@ -421,10 +417,7 @@ internal fun AdvancedConfigPage(
inputInitialValue = maxSizeInput, inputInitialValue = maxSizeInput,
inputFilter = { it.filter(Char::isDigit) }, inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE, inputValueRange = 0f..Float.MAX_VALUE,
onInputConfirm = { onInputConfirm = { maxSizeInput = it.ifBlank { "" } },
val normalized = it.ifBlank { "" }
onMaxSizeInputChange(normalized)
},
) )
SuperSlide( SuperSlide(
title = "最大帧率", title = "最大帧率",
@@ -433,11 +426,10 @@ internal fun AdvancedConfigPage(
onValueChange = { value -> onValueChange = { value ->
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex) val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex)
val preset = ScrcpyPresets.MaxFPS[idx] 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(), valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(),
steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0), steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0),
enabled = !sessionStarted,
unit = "fps", unit = "fps",
zeroStateText = "关闭", zeroStateText = "关闭",
showUnitWhenZeroState = false, showUnitWhenZeroState = false,
@@ -448,10 +440,7 @@ internal fun AdvancedConfigPage(
inputInitialValue = maxFpsInput, inputInitialValue = maxFpsInput,
inputFilter = { it.filter(Char::isDigit) }, inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 0f..Float.MAX_VALUE, inputValueRange = 0f..Float.MAX_VALUE,
onInputConfirm = { onInputConfirm = { maxFpsInput = it.ifBlank { "" } },
val normalized = it.ifBlank { "" }
onMaxFpsInputChange(normalized)
},
) )
} }
} }
@@ -462,21 +451,17 @@ internal fun AdvancedConfigPage(
title = "重新获取编码器列表", title = "重新获取编码器列表",
summary = "--list-encoders", summary = "--list-encoders",
onClick = onRefreshEncoders, onClick = onRefreshEncoders,
enabled = !sessionStarted,
) )
SuperSpinner( SuperSpinner(
title = "视频编码器", title = "视频编码器",
summary = "--video-encoder", summary = "--video-encoder",
items = videoEncoderEntries, items = videoEncoderEntries,
selectedIndex = videoEncoderIndex, selectedIndex = videoEncoderIndex,
onSelectedIndexChange = { index -> onSelectedIndexChange = { videoEncoder = it },
onVideoEncoderChange(if (index == 0) "" else videoEncoderDropdownItems[index])
},
enabled = !sessionStarted,
) )
TextField( TextField(
value = videoCodecOptions, value = videoCodecOptions,
onValueChange = onVideoCodecOptionsChange, onValueChange = { videoCodecOptions = it },
label = "--video-codec-options", label = "--video-codec-options",
singleLine = true, singleLine = true,
modifier = Modifier modifier = Modifier
@@ -489,14 +474,11 @@ internal fun AdvancedConfigPage(
summary = "--audio-encoder", summary = "--audio-encoder",
items = audioEncoderEntries, items = audioEncoderEntries,
selectedIndex = audioEncoderIndex, selectedIndex = audioEncoderIndex,
onSelectedIndexChange = { index -> onSelectedIndexChange = { audioEncoder = it },
onAudioEncoderChange(if (index == 0) "" else audioEncoderDropdownItems[index])
},
enabled = !sessionStarted && audioEnabled,
) )
TextField( TextField(
value = audioCodecOptions, value = audioCodecOptions,
onValueChange = onAudioCodecOptionsChange, onValueChange = { audioCodecOptions = it },
label = "--audio-codec-options", label = "--audio-codec-options",
singleLine = true, singleLine = true,
modifier = Modifier modifier = Modifier
@@ -528,7 +510,7 @@ internal fun AdvancedConfigPage(
) { ) {
TextField( TextField(
value = newDisplayWidth, value = newDisplayWidth,
onValueChange = onNewDisplayWidthChange, onValueChange = { newDisplayWidth = it },
label = "width", label = "width",
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -542,7 +524,7 @@ internal fun AdvancedConfigPage(
) )
TextField( TextField(
value = newDisplayHeight, value = newDisplayHeight,
onValueChange = onNewDisplayHeightChange, onValueChange = { newDisplayHeight = it },
label = "height", label = "height",
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -556,7 +538,7 @@ internal fun AdvancedConfigPage(
) )
TextField( TextField(
value = newDisplayDpi, value = newDisplayDpi,
onValueChange = onNewDisplayDpiChange, onValueChange = { newDisplayDpi = it },
label = "dpi", label = "dpi",
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -597,7 +579,7 @@ internal fun AdvancedConfigPage(
) { ) {
TextField( TextField(
value = cropWidth, value = cropWidth,
onValueChange = onCropWidthChange, onValueChange = { cropWidth = it },
label = "width", label = "width",
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -611,7 +593,7 @@ internal fun AdvancedConfigPage(
) )
TextField( TextField(
value = cropHeight, value = cropHeight,
onValueChange = onCropHeightChange, onValueChange = { cropHeight = it },
label = "height", label = "height",
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -630,7 +612,7 @@ internal fun AdvancedConfigPage(
) { ) {
TextField( TextField(
value = cropX, value = cropX,
onValueChange = onCropXChange, onValueChange = { cropX = it },
label = "x", label = "x",
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@@ -644,7 +626,7 @@ internal fun AdvancedConfigPage(
) )
TextField( TextField(
value = cropY, value = cropY,
onValueChange = onCropYChange, onValueChange = { cropY = it },
label = "y", label = "y",
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions( 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.models.DeviceShortcut
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn 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.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.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
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo 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.loadQuickDevices
import io.github.miuzarte.scrcpyforandroid.services.parseQuickTarget import io.github.miuzarte.scrcpyforandroid.services.parseQuickTarget
import io.github.miuzarte.scrcpyforandroid.services.replaceQuickDevicePort 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.saveQuickDevices
import io.github.miuzarte.scrcpyforandroid.services.updateQuickDeviceNameIfEmpty import io.github.miuzarte.scrcpyforandroid.services.updateQuickDeviceNameIfEmpty
import io.github.miuzarte.scrcpyforandroid.services.upsertQuickDevice 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.ConfigPanel
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile
@@ -105,7 +100,6 @@ private val DeviceShortcutStateListSaver =
if (parts.size != 5) return@mapNotNull null if (parts.size != 5) return@mapNotNull null
val port = parts[3].toIntOrNull() ?: return@mapNotNull null val port = parts[3].toIntOrNull() ?: return@mapNotNull null
DeviceShortcut( DeviceShortcut(
id = parts[0],
name = parts[1], name = parts[1],
host = parts[2], host = parts[2],
port = port, port = port,
@@ -129,75 +123,7 @@ fun DeviceTabScreen(
scrcpy: Scrcpy, scrcpy: Scrcpy,
snack: SnackbarHostState, snack: SnackbarHostState,
scrollBehavior: ScrollBehavior, 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>, videoEncoderOptions: List<String>,
onVideoEncoderOptionsChange: (List<String>) -> Unit, onVideoEncoderOptionsChange: (List<String>) -> Unit,
onVideoEncoderTypeMapChange: (Map<String, String>) -> Unit, onVideoEncoderTypeMapChange: (Map<String, String>) -> Unit,
@@ -214,12 +140,15 @@ fun DeviceTabScreen(
onOpenReorderDevicesActionChange: ((() -> Unit)?) -> Unit, onOpenReorderDevicesActionChange: ((() -> Unit)?) -> Unit,
onOpenAdvancedPage: () -> Unit, onOpenAdvancedPage: () -> Unit,
onOpenFullscreenPage: (ScrcpySessionInfo) -> Unit, onOpenFullscreenPage: (ScrcpySessionInfo) -> Unit,
adbPairingAutoDiscoverOnDialogOpen: Boolean,
adbAutoReconnectPairedDevice: Boolean,
adbMdnsLanDiscoveryEnabled: Boolean,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val appSettings = remember(context) { AppSettings(context) }
val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
val haptics = rememberAppHaptics() val haptics = rememberAppHaptics()
val virtualButtonsLayout by appSettings.virtualButtonsLayout.asState()
val virtualButtonLayout = remember(virtualButtonsLayout) { val virtualButtonLayout = remember(virtualButtonsLayout) {
VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout)) VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout))
} }
@@ -285,8 +214,8 @@ fun DeviceTabScreen(
var audioForwardingSupported by rememberSaveable { mutableStateOf(true) } var audioForwardingSupported by rememberSaveable { mutableStateOf(true) }
var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) } var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) }
var bitRateMbps by rememberSaveable { mutableFloatStateOf(initialSettings.videoBitRateMbps) } var videoBitRateMbps by rememberSaveable { mutableFloatStateOf(initialSettings.videoBitRateMbps) }
var bitRateInput by rememberSaveable { mutableStateOf(initialSettings.videoBitRateInput) } var videoBitRateInput by rememberSaveable { mutableStateOf(initialSettings.videoBitRateInput) }
var audioBitRateKbps by rememberSaveable { mutableIntStateOf(initialSettings.audioBitRateKbps) } var audioBitRateKbps by rememberSaveable { mutableIntStateOf(initialSettings.audioBitRateKbps) }
val currentTarget = if (currentTargetHost.isNotBlank()) ConnectionTarget( val currentTarget = if (currentTargetHost.isNotBlank()) ConnectionTarget(
currentTargetHost, currentTargetHost,
@@ -364,11 +293,14 @@ fun DeviceTabScreen(
disconnectAdbConnection(clearQuickOnlineForTarget = current) disconnectAdbConnection(clearQuickOnlineForTarget = current)
} }
var audio by scrcpyOptions.audio.asMutableState()
var videoSource by scrcpyOptions.videoSource.asMutableState()
fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) { fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) {
val audioSupported = sdkInt !in 0..<30 val audioSupported = sdkInt !in 0..<30
audioForwardingSupported = audioSupported audioForwardingSupported = audioSupported
if (!audioSupported && audioEnabled) { if (!audioSupported && audio) {
onAudioEnabledChange(false) audio = false
logEvent( logEvent(
"设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭", "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭",
Log.WARN Log.WARN
@@ -376,8 +308,8 @@ fun DeviceTabScreen(
} }
val cameraSupported = sdkInt !in 0..<31 val cameraSupported = sdkInt !in 0..<31
cameraMirroringSupported = cameraSupported cameraMirroringSupported = cameraSupported
if (!cameraSupported && videoSourcePreset == "camera") { if (!cameraSupported && videoSource == "camera") {
onVideoSourcePresetChange("display") videoSource = "display"
logEvent( logEvent(
"设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring已切换为 display", "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring已切换为 display",
Log.WARN Log.WARN
@@ -531,6 +463,9 @@ fun DeviceTabScreen(
} }
} }
var videoEncoder by scrcpyOptions.videoEncoder.asMutableState()
var audioEncoder by scrcpyOptions.audioEncoder.asMutableState()
suspend fun refreshEncoderLists() { suspend fun refreshEncoderLists() {
if (!adbConnected) return if (!adbConnected) return
runCatching { runCatching {
@@ -542,10 +477,10 @@ fun DeviceTabScreen(
onVideoEncoderTypeMapChange(lists.videoEncoderTypes) onVideoEncoderTypeMapChange(lists.videoEncoderTypes)
onAudioEncoderTypeMapChange(lists.audioEncoderTypes) onAudioEncoderTypeMapChange(lists.audioEncoderTypes)
if (videoEncoder.isNotBlank() && videoEncoder !in videoEncoderOptions) { if (videoEncoder.isNotBlank() && videoEncoder !in videoEncoderOptions) {
onVideoEncoderChange("") videoEncoder = ""
} }
if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) { if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) {
onAudioEncoderChange("") audioEncoder = ""
} }
EventLogger.logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}") EventLogger.logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}")
if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) { if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) {
@@ -571,6 +506,8 @@ fun DeviceTabScreen(
} }
} }
var cameraSize by scrcpyOptions.cameraSize.asMutableState()
suspend fun refreshCameraSizeLists() { suspend fun refreshCameraSizeLists() {
if (!adbConnected) return if (!adbConnected) return
runCatching { runCatching {
@@ -578,8 +515,8 @@ fun DeviceTabScreen(
}.onSuccess { result -> }.onSuccess { result ->
val lists = result as Scrcpy.ListResult.CameraSizes val lists = result as Scrcpy.ListResult.CameraSizes
onCameraSizeOptionsChange(lists.sizes) onCameraSizeOptionsChange(lists.sizes)
if (cameraSizePreset.isNotBlank() && cameraSizePreset != "custom" && cameraSizePreset !in lists.sizes) { if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in lists.sizes) {
onCameraSizePresetChange("") cameraSize = ""
} }
EventLogger.logEvent("camera sizes 已刷新: count=${lists.sizes.size}") EventLogger.logEvent("camera sizes 已刷新: count=${lists.sizes.size}")
if (lists.sizes.isEmpty()) { if (lists.sizes.isEmpty()) {
@@ -624,88 +561,11 @@ fun DeviceTabScreen(
refreshCameraSizeLists() refreshCameraSizeLists()
} }
LaunchedEffect(bitRateInput) { LaunchedEffect(videoBitRateInput) {
val parsed = bitRateInput.toFloatOrNull() ?: return@LaunchedEffect val parsed = videoBitRateInput.toFloatOrNull() ?: return@LaunchedEffect
bitRateMbps = parsed.coerceAtLeast(0.1f) 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) { LaunchedEffect(Unit) {
if (quickDevices.isEmpty()) { 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 if (adbConnected || !adbAutoReconnectPairedDevice) return@LaunchedEffect
// Background auto reconnect pipeline: // Background auto reconnect pipeline:
@@ -809,7 +672,7 @@ fun DeviceTabScreen(
val discovered = withContext(Dispatchers.IO) { val discovered = withContext(Dispatchers.IO) {
adbService.discoverConnectService( adbService.discoverConnectService(
timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS, timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS,
includeLanDevices = adbMdnsLanDiscoveryEnabled, includeLanDevices = adbMdnsLanDiscovery,
) )
} }
@@ -985,6 +848,8 @@ fun DeviceTabScreen(
editingDeviceId = null editingDeviceId = null
} }
val devicePreviewCardHeightDp by appSettings.devicePreviewCardHeightDp.asState()
val previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asState()
// 设备 // 设备
AppPageLazyColumn( AppPageLazyColumn(
contentPadding = contentPadding, contentPadding = contentPadding,
@@ -998,7 +863,6 @@ fun DeviceTabScreen(
sessionInfo = sessionInfo, sessionInfo = sessionInfo,
busyLabel = null, busyLabel = null,
connectedDeviceLabel = connectedDeviceLabel, connectedDeviceLabel = connectedDeviceLabel,
themeBaseIndex = themeBaseIndex,
) )
} }
@@ -1099,7 +963,7 @@ fun DeviceTabScreen(
autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
onDiscoverTarget = { onDiscoverTarget = {
adbService.discoverPairingService( adbService.discoverPairingService(
includeLanDevices = adbMdnsLanDiscoveryEnabled, includeLanDevices = adbMdnsLanDiscovery,
) )
}, },
onPair = { host, port, code -> onPair = { host, port, code ->
@@ -1128,139 +992,34 @@ fun DeviceTabScreen(
item { item {
ConfigPanel( ConfigPanel(
busy = busy, 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, audioForwardingSupported = audioForwardingSupported,
audioCodec = audioCodec,
onAudioCodecChange = onAudioCodecChange,
onOpenAdvanced = onOpenAdvancedPage, onOpenAdvanced = onOpenAdvancedPage,
onStartStopHaptic = { haptics.contextClick() }, onStartStopHaptic = { haptics.contextClick() },
onStart = { onStart = {
runBusy("启动 scrcpy") { runBusy("启动 scrcpy") {
if (noVideo && !audioEnabled) { val options = scrcpyOptions.toClientOptions()
throw IllegalArgumentException("--no-video 需要同时启用音频") val session = scrcpy.start(options)
}
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,
)
sessionInfo = session sessionInfo = session
statusLine = "scrcpy 运行中" statusLine = "scrcpy 运行中"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
val videoDetail = if (noVideo) { val videoDetail = if (!options.video) {
"off" "off"
} else { } else {
"${session.codec} ${session.width}x${session.height} " + "${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" "off"
} else { } else {
val playback = if (noAudioPlayback) "(no-playback)" else "" val playback = if (!options.audioPlayback) "(no-playback)" else ""
"$audioCodec ${audioBitRateKbps}kbps source=${resolvedAudioSource.ifBlank { "default" }}$playback" "${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 { scope.launch {
snack.showSnackbar("scrcpy 已启动") snack.showSnackbar("scrcpy 已启动")
} }
@@ -1293,7 +1052,7 @@ fun DeviceTabScreen(
PreviewCard( PreviewCard(
sessionInfo = sessionInfo, sessionInfo = sessionInfo,
nativeCore = nativeCore, nativeCore = nativeCore,
previewHeightDp = previewCardHeightDp.coerceAtLeast(120), previewHeightDp = devicePreviewCardHeightDp.coerceAtLeast(120),
controlsVisible = previewControlsVisible, controlsVisible = previewControlsVisible,
onTapped = { onTapped = {
previewControlsVisible = !previewControlsVisible previewControlsVisible = !previewControlsVisible
@@ -1302,15 +1061,15 @@ fun DeviceTabScreen(
val info = sessionInfo ?: return@PreviewCard val info = sessionInfo ?: return@PreviewCard
onOpenFullscreenPage(info) onOpenFullscreenPage(info)
}, },
onOpenFullscreenHaptic = { haptics.contextClick() },
) )
} }
item { item {
VirtualButtonCard( VirtualButtonCard(
busy = busy, busy = busy,
outsideActions = virtualButtonLayout.first, outsideActions = virtualButtonLayout.first,
moreActions = virtualButtonLayout.second, moreActions = virtualButtonLayout.second,
showText = showPreviewVirtualButtonText, showText = previewVirtualButtonShowText,
onAction = ::sendVirtualButtonAction, 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.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.MainSettings import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions
import io.github.miuzarte.scrcpyforandroid.services.loadMainSettings
import io.github.miuzarte.scrcpyforandroid.services.saveMainSettings
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -97,6 +95,7 @@ private sealed interface RootScreen : NavKey {
@Composable @Composable
fun MainPage() { fun MainPage() {
val context = LocalContext.current val context = LocalContext.current
val activity = remember(context) { context as? Activity } val activity = remember(context) { context as? Activity }
val initialOrientation = remember(activity) { val initialOrientation = remember(activity) {
activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
@@ -106,8 +105,6 @@ fun MainPage() {
val adbService = remember(context) { NativeAdbService(context) } val adbService = remember(context) { NativeAdbService(context) }
val scrcpy = remember(context) { Scrcpy(context) } val scrcpy = remember(context) { Scrcpy(context) }
val initialSettings = remember(context) { loadMainSettings(context) }
val initialDeviceSettings = remember(context) { loadDevicePageSettings(context) }
val snackHostState = remember { SnackbarHostState() } val snackHostState = remember { SnackbarHostState() }
val tabs = remember { MainTabDestination.entries } val tabs = remember { MainTabDestination.entries }
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
@@ -132,20 +129,49 @@ fun MainPage() {
restore = { restored -> restored.toList() }, restore = { restored -> restored.toList() },
) )
var audioEnabled by rememberSaveable { mutableStateOf(initialSettings.audioEnabled) } val appSettings = remember(context) { AppSettings(context) }
var audioCodec by rememberSaveable { mutableStateOf(initialSettings.audioCodec) } val scrcpyOptions = remember(context) { ScrcpyOptions(context) }
var videoCodec by rememberSaveable { mutableStateOf(initialSettings.videoCodec) }
var themeBaseIndex by rememberSaveable { mutableIntStateOf(initialSettings.themeBaseIndex) } /*
var monetEnabled by rememberSaveable { mutableStateOf(initialSettings.monetEnabled) } val initialSettings = remember(context) {
var fullscreenDebugInfoEnabled by rememberSaveable { mutableStateOf(initialSettings.fullscreenDebugInfoEnabled) } loadMainSettings(context)
var showFullscreenVirtualButtons by rememberSaveable { mutableStateOf(initialSettings.showFullscreenVirtualButtons) } }
var showPreviewVirtualButtonText by rememberSaveable { mutableStateOf(initialSettings.showPreviewVirtualButtonText) } var audioEnabled by rememberSaveable {
var keepScreenOnWhenStreamingEnabled by rememberSaveable { mutableStateOf(initialSettings.keepScreenOnWhenStreamingEnabled) } mutableStateOf(initialSettings.audioEnabled)
var devicePreviewCardHeightDp by rememberSaveable { mutableIntStateOf(initialSettings.devicePreviewCardHeightDp) } }
var virtualButtonsLayout by rememberSaveable { mutableStateOf(initialSettings.virtualButtonsLayout) } var audioCodec by rememberSaveable {
var customServerUri by rememberSaveable { mutableStateOf(initialSettings.customServerUri) } mutableStateOf(initialSettings.audioCodec)
var serverRemotePath by rememberSaveable { mutableStateOf(initialSettings.serverRemotePath) } }
var adbKeyName by rememberSaveable { mutableStateOf(initialSettings.adbKeyName) } 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 { var adbPairingAutoDiscoverOnDialogOpen by rememberSaveable {
mutableStateOf(initialSettings.adbPairingAutoDiscoverOnDialogOpen) mutableStateOf(initialSettings.adbPairingAutoDiscoverOnDialogOpen)
} }
@@ -155,36 +181,102 @@ fun MainPage() {
var adbMdnsLanDiscoveryEnabled by rememberSaveable { var adbMdnsLanDiscoveryEnabled by rememberSaveable {
mutableStateOf(initialSettings.adbMdnsLanDiscoveryEnabled) mutableStateOf(initialSettings.adbMdnsLanDiscoveryEnabled)
} }
var noControl by rememberSaveable { mutableStateOf(initialDeviceSettings.noControl) }
var videoEncoder by rememberSaveable { mutableStateOf(initialDeviceSettings.videoEncoder) } val initialDeviceSettings = remember(context) {
var videoCodecOptions by rememberSaveable { mutableStateOf(initialDeviceSettings.videoCodecOptions) } loadDevicePageSettings(context)
var audioEncoder by rememberSaveable { mutableStateOf(initialDeviceSettings.audioEncoder) } }
var audioCodecOptions by rememberSaveable { mutableStateOf(initialDeviceSettings.audioCodecOptions) } var noControl by rememberSaveable {
var audioDup by rememberSaveable { mutableStateOf(initialDeviceSettings.audioDup) } mutableStateOf(initialDeviceSettings.noControl)
var audioSourcePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.audioSourcePreset) } }
var audioSourceCustom by rememberSaveable { mutableStateOf(initialDeviceSettings.audioSourceCustom) } var videoEncoder by rememberSaveable {
var videoSourcePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.videoSourcePreset) } mutableStateOf(initialDeviceSettings.videoEncoder)
var cameraIdInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraIdInput) } }
var cameraFacingPreset by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraFacingPreset) } var videoCodecOptions by rememberSaveable {
var cameraSizePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraSizePreset) } mutableStateOf(initialDeviceSettings.videoCodecOptions)
var cameraSizeCustom by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraSizeCustom) } }
var cameraArInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraAr) } var audioEncoder by rememberSaveable {
var cameraFpsInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraFps) } mutableStateOf(initialDeviceSettings.audioEncoder)
var cameraHighSpeed by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraHighSpeed) } }
var noAudioPlayback by rememberSaveable { mutableStateOf(initialDeviceSettings.noAudioPlayback) } var audioCodecOptions by rememberSaveable {
var noVideo by rememberSaveable { mutableStateOf(initialDeviceSettings.noVideo) } mutableStateOf(initialDeviceSettings.audioCodecOptions)
var requireAudio by rememberSaveable { mutableStateOf(initialDeviceSettings.requireAudio) } }
var turnScreenOff by rememberSaveable { mutableStateOf(initialDeviceSettings.turnScreenOff) } var audioDup by rememberSaveable {
var maxSizeInput by rememberSaveable { mutableStateOf(initialDeviceSettings.maxSizeInput) } mutableStateOf(initialDeviceSettings.audioDup)
var maxFpsInput by rememberSaveable { mutableStateOf(initialDeviceSettings.maxFpsInput) } }
var newDisplayWidth by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayWidth) } var audioSourcePreset by rememberSaveable {
var newDisplayHeight by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayHeight) } mutableStateOf(initialDeviceSettings.audioSourcePreset)
var newDisplayDpi by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayDpi) } }
var displayIdInput by rememberSaveable { mutableStateOf(initialDeviceSettings.displayIdInput) } var audioSourceCustom by rememberSaveable {
var cropWidth by rememberSaveable { mutableStateOf(initialDeviceSettings.cropWidth) } mutableStateOf(initialDeviceSettings.audioSourceCustom)
var cropHeight by rememberSaveable { mutableStateOf(initialDeviceSettings.cropHeight) } }
var cropX by rememberSaveable { mutableStateOf(initialDeviceSettings.cropX) } var videoSourcePreset by rememberSaveable {
var cropY by rememberSaveable { mutableStateOf(initialDeviceSettings.cropY) } 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 videoEncoderOptions = remember { mutableStateListOf<String>() }
val audioEncoderOptions = remember { mutableStateListOf<String>() } val audioEncoderOptions = remember { mutableStateListOf<String>() }
val videoEncoderTypeMap = remember { mutableStateMapOf<String, String>() } val videoEncoderTypeMap = remember { mutableStateMapOf<String, String>() }
@@ -201,7 +293,10 @@ fun MainPage() {
var fullscreenOrientation by rememberSaveable { var fullscreenOrientation by rememberSaveable {
mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) 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) } val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) }
// Restore system orientation when MainPage leaves composition. // 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. // Keep-screen-on is controlled globally, so fullscreen and preview share the same behavior.
DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) { DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) {
val window = activity?.window val window = activity?.window
@@ -234,49 +330,7 @@ fun MainPage() {
activity?.requestedOrientation = targetOrientation activity?.requestedOrientation = targetOrientation
} }
LaunchedEffect( val adbKeyName by appSettings.adbKeyName.asMutableState()
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,
),
)
}
LaunchedEffect(adbKeyName) { LaunchedEffect(adbKeyName) {
adbService.keyName = adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME } adbService.keyName = adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME }
} }
@@ -340,17 +394,19 @@ fun MainPage() {
} }
} }
val picker = var customServerUri by appSettings.customServerUri.asMutableState()
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> val picker = rememberLauncherForActivityResult(
if (uri == null) return@rememberLauncherForActivityResult ActivityResultContracts.OpenDocument()
runCatching { ) { uri: Uri? ->
context.contentResolver.takePersistableUriPermission( if (uri == null) return@rememberLauncherForActivityResult
uri, runCatching {
Intent.FLAG_GRANT_READ_URI_PERMISSION context.contentResolver.takePersistableUriPermission(
) uri,
} Intent.FLAG_GRANT_READ_URI_PERMISSION
customServerUri = uri.toString() )
} }
customServerUri = uri.toString()
}
val rootEntryProvider = entryProvider<NavKey> { val rootEntryProvider = entryProvider<NavKey> {
entry(RootScreen.Home) { entry(RootScreen.Home) {
@@ -432,80 +488,14 @@ fun MainPage() {
scrcpy = scrcpy, scrcpy = scrcpy,
snack = snackHostState, snack = snackHostState,
scrollBehavior = deviceScrollBehavior, 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 = { onNoControlChange = {
noControl = it noControl = it
if (it) { if (it) {
turnScreenOff = false 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, videoEncoderOptions = videoEncoderOptions,
onVideoEncoderOptionsChange = { onVideoEncoderOptionsChange = {
videoEncoderOptions.clear() videoEncoderOptions.clear()
@@ -558,9 +548,6 @@ fun MainPage() {
), ),
) )
}, },
adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice,
adbMdnsLanDiscoveryEnabled = adbMdnsLanDiscoveryEnabled,
) )
} }
@@ -574,33 +561,12 @@ fun MainPage() {
) { pagePadding -> ) { pagePadding ->
SettingsScreen( SettingsScreen(
contentPadding = pagePadding, 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 = { onOpenReorderDevices = {
openReorderDevicesAction?.invoke() openReorderDevicesAction?.invoke()
}, },
onOpenVirtualButtonOrder = { onOpenVirtualButtonOrder = {
rootBackStack.add(RootScreen.VirtualButtonOrder) rootBackStack.add(RootScreen.VirtualButtonOrder)
}, },
showFullscreenVirtualButtons = showFullscreenVirtualButtons,
onShowFullscreenVirtualButtonsChange = {
showFullscreenVirtualButtons = it
},
customServerUri = customServerUri,
onPickServer = { onPickServer = {
picker.launch( picker.launch(
arrayOf( 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, scrollBehavior = settingsScrollBehavior,
) )
} }
@@ -632,6 +585,8 @@ fun MainPage() {
} }
} }
val videoEncoder by scrcpyOptions.videoEncoder.asState()
val audioEncoder by scrcpyOptions.audioEncoder.asState()
entry(RootScreen.Advanced) { entry(RootScreen.Advanced) {
val videoEncoderDropdownItems = listOf("默认") + videoEncoderOptions val videoEncoderDropdownItems = listOf("默认") + videoEncoderOptions
val audioEncoderDropdownItems = listOf("默认") + audioEncoderOptions val audioEncoderDropdownItems = listOf("默认") + audioEncoderOptions
@@ -808,11 +763,11 @@ fun MainPage() {
@Composable @Composable
private fun DeviceMenuPopup( private fun DeviceMenuPopup(
show: Boolean, show: Boolean,
canClearLogs: Boolean,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onReorderDevices: () -> Unit, onReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit, onOpenVirtualButtonOrder: () -> Unit,
onClearLogs: () -> Unit, onClearLogs: () -> Unit,
canClearLogs: Boolean,
) { ) {
SuperListPopup( SuperListPopup(
show = show, 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.Clear
import androidx.compose.material.icons.rounded.FileOpen import androidx.compose.material.icons.rounded.FileOpen
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight 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.constants.UiSpacing
import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn
import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide 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 io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle
import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Card
import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.basic.Icon
@@ -43,11 +48,11 @@ private val THEME_BASE_OPTIONS = listOf(
ThemeModeOption("深色", ColorSchemeMode.Dark), ThemeModeOption("深色", ColorSchemeMode.Dark),
) )
fun resolveThemeMode(baseIndex: Int, monetEnabled: Boolean): ColorSchemeMode { fun resolveThemeMode(baseIndex: Int, monet: Boolean): ColorSchemeMode {
return when (baseIndex.coerceIn(0, 2)) { return when (baseIndex.coerceIn(0, 2)) {
0 -> if (monetEnabled) ColorSchemeMode.MonetSystem else ColorSchemeMode.System 0 -> if (monet) ColorSchemeMode.MonetSystem else ColorSchemeMode.System
1 -> if (monetEnabled) ColorSchemeMode.MonetLight else ColorSchemeMode.Light 1 -> if (monet) ColorSchemeMode.MonetLight else ColorSchemeMode.Light
else -> if (monetEnabled) ColorSchemeMode.MonetDark else ColorSchemeMode.Dark else -> if (monet) ColorSchemeMode.MonetDark else ColorSchemeMode.Dark
} }
} }
@@ -59,36 +64,37 @@ private fun resolveThemeLabel(baseIndex: Int, monetEnabled: Boolean): String {
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
contentPadding: PaddingValues, 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, onOpenReorderDevices: () -> Unit,
onOpenVirtualButtonOrder: () -> Unit, onOpenVirtualButtonOrder: () -> Unit,
onShowFullscreenVirtualButtonsChange: (Boolean) -> Unit,
customServerUri: String?,
onPickServer: () -> Unit, 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, scrollBehavior: ScrollBehavior,
) { ) {
val baseModeItems = THEME_BASE_OPTIONS.map { it.label }
val context = LocalContext.current 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( AppPageLazyColumn(
contentPadding = contentPadding, contentPadding = contentPadding,
@@ -99,16 +105,16 @@ fun SettingsScreen(
Card { Card {
SuperDropdown( SuperDropdown(
title = "外观模式", title = "外观模式",
summary = resolveThemeLabel(themeBaseIndex, monetEnabled), summary = resolveThemeLabel(themeBaseIndex, monet),
items = baseModeItems, items = baseModeItems,
selectedIndex = themeBaseIndex.coerceIn(0, baseModeItems.lastIndex), selectedIndex = themeBaseIndex.coerceIn(0, baseModeItems.lastIndex),
onSelectedIndexChange = onThemeBaseIndexChange, onSelectedIndexChange = { themeBaseIndex = it },
) )
SuperSwitch( SuperSwitch(
title = "Monet", title = "Monet",
summary = "开启后使用 Monet 动态配色", summary = "开启后使用 Monet 动态配色",
checked = monetEnabled, checked = monet,
onCheckedChange = onMonetEnabledChange, onCheckedChange = { monet = it },
) )
} }
@@ -117,23 +123,21 @@ fun SettingsScreen(
SuperSwitch( SuperSwitch(
title = "启用调试信息", title = "启用调试信息",
summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS", summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS",
checked = fullscreenDebugInfoEnabled, checked = fullscreenDebugInfo,
onCheckedChange = onFullscreenDebugInfoEnabledChange, onCheckedChange = { fullscreenDebugInfo = it },
) )
SuperSwitch( SuperSwitch(
title = "投屏时保持屏幕常亮", title = "投屏时保持屏幕常亮",
summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开", summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开",
checked = keepScreenOnWhenStreamingEnabled, checked = keepScreenOnWhenStreaming,
onCheckedChange = onKeepScreenOnWhenStreamingEnabledChange, onCheckedChange = { keepScreenOnWhenStreaming = it },
) )
SuperSlide( SuperSlide(
title = "预览卡高度", title = "预览卡高度",
summary = "设备页预览卡高度", summary = "设备页预览卡高度",
value = devicePreviewCardHeightDp.toFloat(), value = devicePreviewCardHeightDp.toFloat(),
onValueChange = { onValueChange = {
onDevicePreviewCardHeightDpChange( devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120)
it.roundToInt().coerceAtLeast(120)
)
}, },
valueRange = 160f..600f, valueRange = 160f..600f,
steps = 439, steps = 439,
@@ -142,9 +146,10 @@ fun SettingsScreen(
inputInitialValue = devicePreviewCardHeightDp.toString(), inputInitialValue = devicePreviewCardHeightDp.toString(),
inputFilter = { it.filter(Char::isDigit) }, inputFilter = { it.filter(Char::isDigit) },
inputValueRange = 120f..Float.MAX_VALUE, inputValueRange = 120f..Float.MAX_VALUE,
onInputConfirm = { raw -> onInputConfirm = { input ->
raw.toIntOrNull() input.toIntOrNull()?.let {
?.let { onDevicePreviewCardHeightDpChange(it.coerceAtLeast(120)) } devicePreviewCardHeightDp = it.coerceAtLeast(120)
}
}, },
) )
SuperArrow( SuperArrow(
@@ -161,7 +166,7 @@ fun SettingsScreen(
title = "全屏显示虚拟按钮", title = "全屏显示虚拟按钮",
summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮", summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮",
checked = showFullscreenVirtualButtons, checked = showFullscreenVirtualButtons,
onCheckedChange = onShowFullscreenVirtualButtonsChange, onCheckedChange = { showFullscreenVirtualButtons = it },
) )
} }
@@ -176,20 +181,21 @@ fun SettingsScreen(
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
TextField( TextField(
value = customServerUri ?: "", value = customServerUri,
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = "scrcpy-server-v3.3.4", label = "scrcpy-server-v3.3.4",
useLabelAsPlaceholder = customServerUri == null, useLabelAsPlaceholder = customServerUri.isBlank(),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent) .padding(horizontal = UiSpacing.CardContent)
.padding(bottom = UiSpacing.CardContent), .padding(bottom = UiSpacing.CardContent),
trailingIcon = { trailingIcon = {
Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) { Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) {
if (customServerUri != null) IconButton(onClick = onClearServer) { if (customServerUri.isNotBlank())
Icon(Icons.Rounded.Clear, contentDescription = "清空") IconButton(onClick = { customServerUri = "" }) {
} Icon(Icons.Rounded.Clear, contentDescription = "清空")
}
IconButton(onClick = onPickServer) { IconButton(onClick = onPickServer) {
Icon(Icons.Rounded.FileOpen, contentDescription = "选择文件") Icon(Icons.Rounded.FileOpen, contentDescription = "选择文件")
} }
@@ -205,7 +211,7 @@ fun SettingsScreen(
) )
TextField( TextField(
value = serverRemotePath, value = serverRemotePath,
onValueChange = onServerRemotePathChange, onValueChange = { serverRemotePath = it },
label = AppDefaults.SERVER_REMOTE_PATH, label = AppDefaults.SERVER_REMOTE_PATH,
useLabelAsPlaceholder = true, useLabelAsPlaceholder = true,
singleLine = true, singleLine = true,
@@ -227,7 +233,7 @@ fun SettingsScreen(
) )
TextField( TextField(
value = adbKeyName, value = adbKeyName,
onValueChange = onAdbKeyNameChange, onValueChange = { adbKeyName = it },
label = AppDefaults.ADB_KEY_NAME, label = AppDefaults.ADB_KEY_NAME,
useLabelAsPlaceholder = true, useLabelAsPlaceholder = true,
singleLine = true, singleLine = true,
@@ -240,13 +246,13 @@ fun SettingsScreen(
title = "配对时自动启用发现服务", title = "配对时自动启用发现服务",
summary = "打开配对弹窗后自动搜索可用配对端口", summary = "打开配对弹窗后自动搜索可用配对端口",
checked = adbPairingAutoDiscoverOnDialogOpen, checked = adbPairingAutoDiscoverOnDialogOpen,
onCheckedChange = onAdbPairingAutoDiscoverOnDialogOpenChange, onCheckedChange = { adbPairingAutoDiscoverOnDialogOpen = it },
) )
SuperSwitch( SuperSwitch(
title = "自动重连已配对设备", title = "自动重连已配对设备",
summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)", summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)",
checked = adbAutoReconnectPairedDevice, checked = adbAutoReconnectPairedDevice,
onCheckedChange = onAdbAutoReconnectPairedDeviceChange, onCheckedChange = { adbAutoReconnectPairedDevice = it },
) )
} }

View File

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

View File

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