From 189d4a76b9613526d23097a9b7c1ad833b803f8a Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Fri, 27 Mar 2026 01:32:25 +0800 Subject: [PATCH] refactor: new preferences impl with data store --- TODO.md | 4 +- app/build.gradle.kts | 1 + .../constants/ScrcpyPresets.kt | 4 +- .../scrcpyforandroid/models/DeviceModels.kt | 85 +++- .../pages/AdvancedConfigPage.kt | 158 +++---- .../scrcpyforandroid/pages/DevicePage.kt | 349 +++------------ .../scrcpyforandroid/pages/MainPage.kt | 375 +++++++--------- .../scrcpyforandroid/pages/SettingsPage.kt | 108 ++--- .../scrcpyforandroid/scrcpy/Shared.kt | 15 +- .../services/QuickDeviceStore.kt | 7 +- .../scrcpyforandroid/storage/AppSettings.kt | 111 +++++ .../storage/AppSettings_New.kt | 128 ++++++ .../scrcpyforandroid/storage/QuickDevices.kt | 171 ++++++++ .../scrcpyforandroid/storage/ScrcpyOptions.kt | 402 ++++++++++++++++++ .../scrcpyforandroid/storage/Settings.kt | 115 +++++ .../storage/StorageMigration.kt | 0 .../scrcpyforandroid/widgets/DeviceWidgets.kt | 98 +++-- 17 files changed, 1420 insertions(+), 711 deletions(-) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings_New.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/StorageMigration.kt diff --git a/TODO.md b/TODO.md index 6847934..923de22 100644 --- a/TODO.md +++ b/TODO.md @@ -34,5 +34,7 @@ 顺序无关 +- 多配置切换 +- [音频延迟](https://developer.android.com/ndk/guides/audio/audio-latency) - 横屏布局 -- 原生悬浮窗 +- 原生悬浮窗 [Petterpx/FloatingX](https://github.com/Petterpx/FloatingX) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4b5d668..066ef38 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -97,6 +97,7 @@ dependencies { implementation("org.bouncycastle:bcpkix-jdk18on:1.80") implementation("org.conscrypt:conscrypt-android:2.5.2") implementation("sh.calvin.reorderable:reorderable:3.0.0") + implementation("androidx.datastore:datastore-preferences:1.2.1") testImplementation(libs.junit) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt index daa7025..481ab23 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ScrcpyPresets.kt @@ -1,7 +1,7 @@ package io.github.miuzarte.scrcpyforandroid.constants object ScrcpyPresets { - val MaxSize = listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840) + val MaxSize = listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840) // px val MaxFPS = listOf(0, 24, 30, 45, 60, 90, 120) - val AudioBitRate = listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512) + val AudioBitRate = listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512) // Kbps } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt index 18b099a..38253ef 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/models/DeviceModels.kt @@ -1,18 +1,75 @@ package io.github.miuzarte.scrcpyforandroid.models -internal data class ConnectionTarget( - val host: String, - val port: Int, -) +import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults -/** - * A compact shortcut entry for quick-connect lists shown in the UI. - * `online` indicates whether the device was reachable when last probed. - */ -internal data class DeviceShortcut( - val id: String, - val name: String, +data class DeviceShortcut( + val name: String = "", val host: String, - val port: Int, - val online: Boolean, -) + val port: Int = AppDefaults.ADB_PORT, + val online: Boolean = false, +) { + val id: String get() = "$host:$port" + + fun marshalToString( + separator: String = "|", + ): String = listOf( + name.trim(), host.trim(), port.toString() + ).joinToString( + separator = separator + ) + + companion object { + fun unmarshalFrom( + s: String, + delimiter: String = "|", + ): DeviceShortcut? { + val parts = s.split(delimiter, limit = 3) + return when (parts.size) { + 3 -> { + val name = parts[0].trim() + val host = parts[1].trim() + val port = parts[2].trim().toIntOrNull() ?: AppDefaults.ADB_PORT + if (host.isNotBlank()) DeviceShortcut( + name = name, + host = host, + port = port, + ) + else null + } + + else -> null + } + } + } +} + +data class ConnectionTarget( + val host: String, + val port: Int = AppDefaults.ADB_PORT, +) { + fun marshalToString(): String = "$host:$port" + + companion object { + fun unmarshalFrom(s: String): ConnectionTarget? { + val parts = s.split(":", limit = 2) + return when (parts.size) { + 2 -> ConnectionTarget( + host = parts[0].trim(), + port = parts[1].trim().toIntOrNull() ?: AppDefaults.ADB_PORT, + ) + + 1 -> ConnectionTarget( + host = parts[0].trim(), + port = AppDefaults.ADB_PORT, + ) + + 0 -> ConnectionTarget( + host = s.trim(), + port = AppDefaults.ADB_PORT, + ) + + else -> null + } + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt index e0db43a..d2a3e1a 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt @@ -11,9 +11,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -22,6 +26,8 @@ import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide +import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import kotlinx.coroutines.launch import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.ScrollBehavior @@ -69,10 +75,12 @@ internal fun AdvancedConfigPage( contentPadding: PaddingValues, scrollBehavior: ScrollBehavior, snackbarHostState: SnackbarHostState, - sessionStarted: Boolean, - audioEnabled: Boolean, - noControl: Boolean, - onNoControlChange: (Boolean) -> Unit, +// sessionStarted: Boolean, + + // audioEnabled: Boolean, + +// noControl: Boolean, +// onNoControlChange: (Boolean) -> Unit, audioDup: Boolean, onAudioDupChange: (Boolean) -> Unit, audioSourcePreset: String, @@ -109,6 +117,7 @@ internal fun AdvancedConfigPage( onMaxSizeInputChange: (String) -> Unit, maxFpsInput: String, onMaxFpsInputChange: (String) -> Unit, + videoEncoderDropdownItems: List, videoEncoderTypeMap: Map, videoEncoderIndex: Int, @@ -121,27 +130,28 @@ internal fun AdvancedConfigPage( onAudioEncoderChange: (String) -> Unit, audioCodecOptions: String, onAudioCodecOptionsChange: (String) -> Unit, + onRefreshEncoders: () -> Unit, onRefreshCameraSizes: () -> Unit, + newDisplayWidth: String, - onNewDisplayWidthChange: (String) -> Unit, newDisplayHeight: String, - onNewDisplayHeightChange: (String) -> Unit, newDisplayDpi: String, - onNewDisplayDpiChange: (String) -> Unit, displayIdInput: String, - onDisplayIdInputChange: (String) -> Unit, cropWidth: String, - onCropWidthChange: (String) -> Unit, cropHeight: String, - onCropHeightChange: (String) -> Unit, cropX: String, - onCropXChange: (String) -> Unit, cropY: String, - onCropYChange: (String) -> Unit, ) { + val context = LocalContext.current + + val appSettings = remember(context) { AppSettings(context) } + val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + val maxSizePresetIndex = presetIndexFromInputForAdvancedPage(maxSizeInput, ScrcpyPresets.MaxSize) val maxFpsPresetIndex = presetIndexFromInputForAdvancedPage(maxFpsInput, ScrcpyPresets.MaxFPS) @@ -179,6 +189,10 @@ internal fun AdvancedConfigPage( } } + var turnScreenOff by scrcpyOptions.turnScreenOff.asMutableState() + var control by scrcpyOptions.control.asMutableState() + var video by scrcpyOptions.video.asMutableState() + // 高级参数 AppPageLazyColumn( contentPadding = contentPadding, @@ -190,30 +204,27 @@ internal fun AdvancedConfigPage( title = "启动后关闭屏幕", summary = "--turn-screen-off", checked = turnScreenOff, - onCheckedChange = { value -> - onTurnScreenOffChange(value) - if (value) scope.launch { + onCheckedChange = { + turnScreenOff = it + if (it) scope.launch { // github.com/Genymobile/scrcpy/issues/3376 // github.com/Genymobile/scrcpy/issues/4587 // github.com/Genymobile/scrcpy/issues/5676 snackbarHostState.showSnackbar("注意:大部分设备在关闭屏幕后刷新率会降低/减半") } }, - enabled = !sessionStarted && !noControl, ) SuperSwitch( title = "禁用控制", summary = "--no-control", - checked = noControl, - onCheckedChange = onNoControlChange, - enabled = !sessionStarted, + checked = !control, + onCheckedChange = { control = !it }, ) SuperSwitch( title = "禁用视频", summary = "--no-video", - checked = noVideo, - onCheckedChange = onNoVideoChange, - enabled = !sessionStarted, + checked = !video, + onCheckedChange = { video = !it }, ) } } @@ -225,15 +236,14 @@ internal fun AdvancedConfigPage( summary = "--video-source", items = videoSourceItems, selectedIndex = videoSourceIndex, - onSelectedIndexChange = { index -> - onVideoSourcePresetChange(VIDEO_SOURCE_OPTIONS[index].first) + onSelectedIndexChange = { + videoSourcePreset = VIDEO_SOURCE_OPTIONS[it].first }, - enabled = !sessionStarted, ) if (videoSourcePreset == "display") { TextField( value = displayIdInput, - onValueChange = onDisplayIdInputChange, + onValueChange = { displayIdInput = it }, label = "--display-id", singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), @@ -246,7 +256,7 @@ internal fun AdvancedConfigPage( if (videoSourcePreset == "camera") { TextField( value = cameraIdInput, - onValueChange = onCameraIdInputChange, + onValueChange = { cameraIdInput = it }, label = "--camera-id", singleLine = true, modifier = Modifier @@ -258,17 +268,15 @@ internal fun AdvancedConfigPage( title = "重新获取 Camera Sizes", summary = "--list-camera-sizes", onClick = onRefreshCameraSizes, - enabled = !sessionStarted, ) SuperDropdown( title = "摄像头朝向", summary = "--camera-facing", items = cameraFacingItems, selectedIndex = cameraFacingIndex, - onSelectedIndexChange = { index -> - onCameraFacingPresetChange(CAMERA_FACING_OPTIONS[index].first) + onSelectedIndexChange = { + cameraFacingPreset = CAMERA_FACING_OPTIONS[it].first }, - enabled = !sessionStarted, ) SuperDropdown( title = "摄像头分辨率", @@ -278,21 +286,18 @@ internal fun AdvancedConfigPage( 0, (cameraSizeDropdownItems.size - 1).coerceAtLeast(0) ), - onSelectedIndexChange = { index -> - onCameraSizePresetChange( - when (index) { - 0 -> "" - cameraSizeDropdownItems.lastIndex -> "custom" - else -> cameraSizeDropdownItems[index] - }, - ) + onSelectedIndexChange = { + cameraSizePreset = when (it) { + 0 -> "" + cameraSizeDropdownItems.lastIndex -> "custom" + else -> cameraSizeDropdownItems[it] + } }, - enabled = !sessionStarted, ) if (cameraSizePreset == "custom") { TextField( value = cameraSizeCustom, - onValueChange = onCameraSizeCustomChange, + onValueChange = { cameraSizeCustom = it }, label = "--camera-size", singleLine = true, modifier = Modifier @@ -303,7 +308,7 @@ internal fun AdvancedConfigPage( } TextField( value = cameraArInput, - onValueChange = onCameraArInputChange, + onValueChange = { cameraArInput = it }, label = "--camera-ar", singleLine = true, modifier = Modifier @@ -318,11 +323,10 @@ internal fun AdvancedConfigPage( onValueChange = { value -> val idx = value.roundToInt().coerceIn(0, CAMERA_FPS_PRESETS.lastIndex) val preset = CAMERA_FPS_PRESETS[idx] - onCameraFpsInputChange(if (preset == 0) "" else preset.toString()) + cameraFpsInput = if (preset == 0) "" else preset.toString() }, valueRange = 0f..CAMERA_FPS_PRESETS.lastIndex.toFloat(), steps = (CAMERA_FPS_PRESETS.size - 2).coerceAtLeast(0), - enabled = !sessionStarted, unit = "fps", zeroStateText = "默认", showUnitWhenZeroState = false, @@ -334,16 +338,14 @@ internal fun AdvancedConfigPage( inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 0f..Float.MAX_VALUE, onInputConfirm = { - val normalized = it.ifBlank { "" } - onCameraFpsInputChange(if (normalized == "0") "" else normalized) + cameraFpsInput = if (normalized == "0") "" else normalized }, ) SuperSwitch( title = "高帧率模式", summary = "--camera-high-speed", checked = cameraHighSpeed, - onCheckedChange = onCameraHighSpeedChange, - enabled = !sessionStarted, + onCheckedChange = { cameraHighSpeed = it }, ) } } @@ -356,15 +358,12 @@ internal fun AdvancedConfigPage( summary = "--audio-source", items = audioSourceItems, selectedIndex = audioSourceIndex, - onSelectedIndexChange = { index -> - onAudioSourcePresetChange(AUDIO_SOURCE_OPTIONS[index].first) - }, - enabled = !sessionStarted && audioEnabled, + onSelectedIndexChange = { audioSourcePreset = AUDIO_SOURCE_OPTIONS[it].first }, ) if (audioSourcePreset == "custom") { TextField( value = audioSourceCustom, - onValueChange = onAudioSourceCustomChange, + onValueChange = { audioSourceCustom = it }, label = "--audio-source", singleLine = true, modifier = Modifier @@ -377,21 +376,19 @@ internal fun AdvancedConfigPage( title = "音频双路输出", summary = "--audio-dup", checked = audioDup, - onCheckedChange = onAudioDupChange, - enabled = !sessionStarted && audioEnabled, + onCheckedChange = { audioDup = it }, ) SuperSwitch( title = "仅转发不播放", summary = "--no-audio-playback", checked = noAudioPlayback, - onCheckedChange = onNoAudioPlaybackChange, - enabled = !sessionStarted && audioEnabled, + onCheckedChange = { noAudioPlayback = it }, ) SuperSwitch( title = "音频失败时终止 [TODO]", summary = "--require-audio", checked = requireAudio, - onCheckedChange = onRequireAudioChange, + onCheckedChange = { requireAudio = it }, enabled = false, ) } @@ -406,11 +403,10 @@ internal fun AdvancedConfigPage( onValueChange = { value -> val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) val preset = ScrcpyPresets.MaxSize[idx] - onMaxSizeInputChange(if (preset == 0) "" else preset.toString()) + maxSizeInput = if (preset == 0) "" else preset.toString() }, valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(), steps = (ScrcpyPresets.MaxSize.size - 2).coerceAtLeast(0), - enabled = !sessionStarted, unit = "px", zeroStateText = "关闭", showUnitWhenZeroState = false, @@ -421,10 +417,7 @@ internal fun AdvancedConfigPage( inputInitialValue = maxSizeInput, inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 0f..Float.MAX_VALUE, - onInputConfirm = { - val normalized = it.ifBlank { "" } - onMaxSizeInputChange(normalized) - }, + onInputConfirm = { maxSizeInput = it.ifBlank { "" } }, ) SuperSlide( title = "最大帧率", @@ -433,11 +426,10 @@ internal fun AdvancedConfigPage( onValueChange = { value -> val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex) val preset = ScrcpyPresets.MaxFPS[idx] - onMaxFpsInputChange(if (preset == 0) "" else preset.toString()) + maxFpsInput = if (preset == 0) "" else preset.toString() }, valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(), steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0), - enabled = !sessionStarted, unit = "fps", zeroStateText = "关闭", showUnitWhenZeroState = false, @@ -448,10 +440,7 @@ internal fun AdvancedConfigPage( inputInitialValue = maxFpsInput, inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 0f..Float.MAX_VALUE, - onInputConfirm = { - val normalized = it.ifBlank { "" } - onMaxFpsInputChange(normalized) - }, + onInputConfirm = { maxFpsInput = it.ifBlank { "" } }, ) } } @@ -462,21 +451,17 @@ internal fun AdvancedConfigPage( title = "重新获取编码器列表", summary = "--list-encoders", onClick = onRefreshEncoders, - enabled = !sessionStarted, ) SuperSpinner( title = "视频编码器", summary = "--video-encoder", items = videoEncoderEntries, selectedIndex = videoEncoderIndex, - onSelectedIndexChange = { index -> - onVideoEncoderChange(if (index == 0) "" else videoEncoderDropdownItems[index]) - }, - enabled = !sessionStarted, + onSelectedIndexChange = { videoEncoder = it }, ) TextField( value = videoCodecOptions, - onValueChange = onVideoCodecOptionsChange, + onValueChange = { videoCodecOptions = it }, label = "--video-codec-options", singleLine = true, modifier = Modifier @@ -489,14 +474,11 @@ internal fun AdvancedConfigPage( summary = "--audio-encoder", items = audioEncoderEntries, selectedIndex = audioEncoderIndex, - onSelectedIndexChange = { index -> - onAudioEncoderChange(if (index == 0) "" else audioEncoderDropdownItems[index]) - }, - enabled = !sessionStarted && audioEnabled, + onSelectedIndexChange = { audioEncoder = it }, ) TextField( value = audioCodecOptions, - onValueChange = onAudioCodecOptionsChange, + onValueChange = { audioCodecOptions = it }, label = "--audio-codec-options", singleLine = true, modifier = Modifier @@ -528,7 +510,7 @@ internal fun AdvancedConfigPage( ) { TextField( value = newDisplayWidth, - onValueChange = onNewDisplayWidthChange, + onValueChange = { newDisplayWidth = it }, label = "width", singleLine = true, keyboardOptions = KeyboardOptions( @@ -542,7 +524,7 @@ internal fun AdvancedConfigPage( ) TextField( value = newDisplayHeight, - onValueChange = onNewDisplayHeightChange, + onValueChange = { newDisplayHeight = it }, label = "height", singleLine = true, keyboardOptions = KeyboardOptions( @@ -556,7 +538,7 @@ internal fun AdvancedConfigPage( ) TextField( value = newDisplayDpi, - onValueChange = onNewDisplayDpiChange, + onValueChange = { newDisplayDpi = it }, label = "dpi", singleLine = true, keyboardOptions = KeyboardOptions( @@ -597,7 +579,7 @@ internal fun AdvancedConfigPage( ) { TextField( value = cropWidth, - onValueChange = onCropWidthChange, + onValueChange = { cropWidth = it }, label = "width", singleLine = true, keyboardOptions = KeyboardOptions( @@ -611,7 +593,7 @@ internal fun AdvancedConfigPage( ) TextField( value = cropHeight, - onValueChange = onCropHeightChange, + onValueChange = { cropHeight = it }, label = "height", singleLine = true, keyboardOptions = KeyboardOptions( @@ -630,7 +612,7 @@ internal fun AdvancedConfigPage( ) { TextField( value = cropX, - onValueChange = onCropXChange, + onValueChange = { cropX = it }, label = "x", singleLine = true, keyboardOptions = KeyboardOptions( @@ -644,7 +626,7 @@ internal fun AdvancedConfigPage( ) TextField( value = cropY, - onValueChange = onCropYChange, + onValueChange = { cropY = it }, label = "y", singleLine = true, keyboardOptions = KeyboardOptions( diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt index 6e2ed6d..833341d 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt @@ -32,14 +32,8 @@ import io.github.miuzarte.scrcpyforandroid.models.ConnectionTarget import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn -import io.github.miuzarte.scrcpyforandroid.scrcpy.ClientOptions import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy -import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.AudioSource -import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing -import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions -import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource -import io.github.miuzarte.scrcpyforandroid.services.DevicePageSettings import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo @@ -47,10 +41,11 @@ import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings import io.github.miuzarte.scrcpyforandroid.services.loadQuickDevices import io.github.miuzarte.scrcpyforandroid.services.parseQuickTarget import io.github.miuzarte.scrcpyforandroid.services.replaceQuickDevicePort -import io.github.miuzarte.scrcpyforandroid.services.saveDevicePageSettings import io.github.miuzarte.scrcpyforandroid.services.saveQuickDevices import io.github.miuzarte.scrcpyforandroid.services.updateQuickDeviceNameIfEmpty import io.github.miuzarte.scrcpyforandroid.services.upsertQuickDevice +import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile @@ -105,7 +100,6 @@ private val DeviceShortcutStateListSaver = if (parts.size != 5) return@mapNotNull null val port = parts[3].toIntOrNull() ?: return@mapNotNull null DeviceShortcut( - id = parts[0], name = parts[1], host = parts[2], port = port, @@ -129,75 +123,7 @@ fun DeviceTabScreen( scrcpy: Scrcpy, snack: SnackbarHostState, scrollBehavior: ScrollBehavior, - virtualButtonsLayout: String, - showPreviewVirtualButtonText: Boolean, - previewCardHeightDp: Int, - themeBaseIndex: Int, - videoCodec: String, - onVideoCodecChange: (String) -> Unit, - audioEnabled: Boolean, - onAudioEnabledChange: (Boolean) -> Unit, - audioCodec: String, - onAudioCodecChange: (String) -> Unit, - noControl: Boolean, - onNoControlChange: (Boolean) -> Unit, - videoEncoder: String, - onVideoEncoderChange: (String) -> Unit, - videoCodecOptions: String, - onVideoCodecOptionsChange: (String) -> Unit, - audioEncoder: String, - onAudioEncoderChange: (String) -> Unit, - audioCodecOptions: String, - onAudioCodecOptionsChange: (String) -> Unit, - audioDup: Boolean, - onAudioDupChange: (Boolean) -> Unit, - audioSourcePreset: String, - onAudioSourcePresetChange: (String) -> Unit, - audioSourceCustom: String, - onAudioSourceCustomChange: (String) -> Unit, - videoSourcePreset: String, - onVideoSourcePresetChange: (String) -> Unit, - cameraIdInput: String, - onCameraIdInputChange: (String) -> Unit, - cameraFacingPreset: String, - onCameraFacingPresetChange: (String) -> Unit, - cameraSizePreset: String, - onCameraSizePresetChange: (String) -> Unit, - cameraSizeCustom: String, - onCameraSizeCustomChange: (String) -> Unit, - cameraArInput: String, - onCameraArInputChange: (String) -> Unit, - cameraFpsInput: String, - onCameraFpsInputChange: (String) -> Unit, - cameraHighSpeed: Boolean, - onCameraHighSpeedChange: (Boolean) -> Unit, - noAudioPlayback: Boolean, - onNoAudioPlaybackChange: (Boolean) -> Unit, - noVideo: Boolean, - requireAudio: Boolean, - onRequireAudioChange: (Boolean) -> Unit, - turnScreenOff: Boolean, - onTurnScreenOffChange: (Boolean) -> Unit, - maxSizeInput: String, - onMaxSizeInputChange: (String) -> Unit, - maxFpsInput: String, - onMaxFpsInputChange: (String) -> Unit, - newDisplayWidth: String, - onNewDisplayWidthChange: (String) -> Unit, - newDisplayHeight: String, - onNewDisplayHeightChange: (String) -> Unit, - newDisplayDpi: String, - onNewDisplayDpiChange: (String) -> Unit, - displayIdInput: String, - onDisplayIdInputChange: (String) -> Unit, - cropWidth: String, - onCropWidthChange: (String) -> Unit, - cropHeight: String, - onCropHeightChange: (String) -> Unit, - cropX: String, - onCropXChange: (String) -> Unit, - cropY: String, - onCropYChange: (String) -> Unit, + videoEncoderOptions: List, onVideoEncoderOptionsChange: (List) -> Unit, onVideoEncoderTypeMapChange: (Map) -> Unit, @@ -214,12 +140,15 @@ fun DeviceTabScreen( onOpenReorderDevicesActionChange: ((() -> Unit)?) -> Unit, onOpenAdvancedPage: () -> Unit, onOpenFullscreenPage: (ScrcpySessionInfo) -> Unit, - adbPairingAutoDiscoverOnDialogOpen: Boolean, - adbAutoReconnectPairedDevice: Boolean, - adbMdnsLanDiscoveryEnabled: Boolean, ) { val context = LocalContext.current + + val appSettings = remember(context) { AppSettings(context) } + val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + val haptics = rememberAppHaptics() + + val virtualButtonsLayout by appSettings.virtualButtonsLayout.asState() val virtualButtonLayout = remember(virtualButtonsLayout) { VirtualButtonActions.splitLayout(VirtualButtonActions.parseStoredLayout(virtualButtonsLayout)) } @@ -285,8 +214,8 @@ fun DeviceTabScreen( var audioForwardingSupported by rememberSaveable { mutableStateOf(true) } var cameraMirroringSupported by rememberSaveable { mutableStateOf(true) } - var bitRateMbps by rememberSaveable { mutableFloatStateOf(initialSettings.videoBitRateMbps) } - var bitRateInput by rememberSaveable { mutableStateOf(initialSettings.videoBitRateInput) } + var videoBitRateMbps by rememberSaveable { mutableFloatStateOf(initialSettings.videoBitRateMbps) } + var videoBitRateInput by rememberSaveable { mutableStateOf(initialSettings.videoBitRateInput) } var audioBitRateKbps by rememberSaveable { mutableIntStateOf(initialSettings.audioBitRateKbps) } val currentTarget = if (currentTargetHost.isNotBlank()) ConnectionTarget( currentTargetHost, @@ -364,11 +293,14 @@ fun DeviceTabScreen( disconnectAdbConnection(clearQuickOnlineForTarget = current) } + var audio by scrcpyOptions.audio.asMutableState() + var videoSource by scrcpyOptions.videoSource.asMutableState() + fun applyConnectedDeviceCapabilities(sdkInt: Int, release: String) { val audioSupported = sdkInt !in 0..<30 audioForwardingSupported = audioSupported - if (!audioSupported && audioEnabled) { - onAudioEnabledChange(false) + if (!audioSupported && audio) { + audio = false logEvent( "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持音频转发,已自动关闭", Log.WARN @@ -376,8 +308,8 @@ fun DeviceTabScreen( } val cameraSupported = sdkInt !in 0..<31 cameraMirroringSupported = cameraSupported - if (!cameraSupported && videoSourcePreset == "camera") { - onVideoSourcePresetChange("display") + if (!cameraSupported && videoSource == "camera") { + videoSource = "display" logEvent( "设备 Android ${release.ifBlank { "?" }} (SDK $sdkInt) 不支持 camera mirroring,已切换为 display", Log.WARN @@ -531,6 +463,9 @@ fun DeviceTabScreen( } } + var videoEncoder by scrcpyOptions.videoEncoder.asMutableState() + var audioEncoder by scrcpyOptions.audioEncoder.asMutableState() + suspend fun refreshEncoderLists() { if (!adbConnected) return runCatching { @@ -542,10 +477,10 @@ fun DeviceTabScreen( onVideoEncoderTypeMapChange(lists.videoEncoderTypes) onAudioEncoderTypeMapChange(lists.audioEncoderTypes) if (videoEncoder.isNotBlank() && videoEncoder !in videoEncoderOptions) { - onVideoEncoderChange("") + videoEncoder = "" } if (audioEncoder.isNotBlank() && audioEncoder !in audioEncoderOptions) { - onAudioEncoderChange("") + audioEncoder = "" } EventLogger.logEvent("编码器列表已刷新: video=${lists.videoEncoders.size} audio=${lists.audioEncoders.size}") if (lists.videoEncoders.isEmpty() && lists.audioEncoders.isEmpty()) { @@ -571,6 +506,8 @@ fun DeviceTabScreen( } } + var cameraSize by scrcpyOptions.cameraSize.asMutableState() + suspend fun refreshCameraSizeLists() { if (!adbConnected) return runCatching { @@ -578,8 +515,8 @@ fun DeviceTabScreen( }.onSuccess { result -> val lists = result as Scrcpy.ListResult.CameraSizes onCameraSizeOptionsChange(lists.sizes) - if (cameraSizePreset.isNotBlank() && cameraSizePreset != "custom" && cameraSizePreset !in lists.sizes) { - onCameraSizePresetChange("") + if (cameraSize.isNotBlank() && cameraSize != "custom" && cameraSize !in lists.sizes) { + cameraSize = "" } EventLogger.logEvent("camera sizes 已刷新: count=${lists.sizes.size}") if (lists.sizes.isEmpty()) { @@ -624,88 +561,11 @@ fun DeviceTabScreen( refreshCameraSizeLists() } - LaunchedEffect(bitRateInput) { - val parsed = bitRateInput.toFloatOrNull() ?: return@LaunchedEffect - bitRateMbps = parsed.coerceAtLeast(0.1f) + LaunchedEffect(videoBitRateInput) { + val parsed = videoBitRateInput.toFloatOrNull() ?: return@LaunchedEffect + videoBitRateMbps = parsed.coerceAtLeast(0.1f) } - LaunchedEffect( - quickConnectInput, - audioBitRateKbps, - bitRateMbps, - bitRateInput, - turnScreenOff, - noControl, - noVideo, - videoSourcePreset, - displayIdInput, - cameraIdInput, - cameraFacingPreset, - cameraSizePreset, - cameraSizeCustom, - cameraArInput, - cameraFpsInput, - cameraHighSpeed, - audioSourcePreset, - audioSourceCustom, - audioDup, - noAudioPlayback, - requireAudio, - maxSizeInput, - maxFpsInput, - videoEncoder, - videoCodecOptions, - audioEncoder, - audioCodecOptions, - newDisplayWidth, - newDisplayHeight, - newDisplayDpi, - cropWidth, - cropHeight, - cropX, - cropY, - ) { - saveDevicePageSettings( - context, - DevicePageSettings( - quickConnectInput = quickConnectInput, - audioBitRateKbps = audioBitRateKbps, - audioBitRateInput = audioBitRateKbps.toString(), - videoBitRateMbps = bitRateMbps, - videoBitRateInput = bitRateInput, - turnScreenOff = turnScreenOff, - noControl = noControl, - noVideo = noVideo, - videoSourcePreset = videoSourcePreset, - displayIdInput = displayIdInput, - cameraIdInput = cameraIdInput, - cameraFacingPreset = cameraFacingPreset, - cameraSizePreset = cameraSizePreset, - cameraSizeCustom = cameraSizeCustom, - cameraAr = cameraArInput, - cameraFps = cameraFpsInput, - cameraHighSpeed = cameraHighSpeed, - audioSourcePreset = audioSourcePreset, - audioSourceCustom = audioSourceCustom, - audioDup = audioDup, - noAudioPlayback = noAudioPlayback, - requireAudio = requireAudio, - maxSizeInput = maxSizeInput, - maxFpsInput = maxFpsInput, - videoEncoder = videoEncoder, - videoCodecOptions = videoCodecOptions, - audioEncoder = audioEncoder, - audioCodecOptions = audioCodecOptions, - newDisplayWidth = newDisplayWidth, - newDisplayHeight = newDisplayHeight, - newDisplayDpi = newDisplayDpi, - cropWidth = cropWidth, - cropHeight = cropHeight, - cropX = cropX, - cropY = cropY, - ), - ) - } LaunchedEffect(Unit) { if (quickDevices.isEmpty()) { @@ -762,7 +622,10 @@ fun DeviceTabScreen( } } - LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscoveryEnabled) { + val adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asState() + val adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asState() + val adbMdnsLanDiscovery by appSettings.adbMdnsLanDiscovery.asState() + LaunchedEffect(adbConnected, adbAutoReconnectPairedDevice, adbMdnsLanDiscovery) { if (adbConnected || !adbAutoReconnectPairedDevice) return@LaunchedEffect // Background auto reconnect pipeline: @@ -809,7 +672,7 @@ fun DeviceTabScreen( val discovered = withContext(Dispatchers.IO) { adbService.discoverConnectService( timeoutMs = ADB_AUTO_RECONNECT_DISCOVER_TIMEOUT_MS, - includeLanDevices = adbMdnsLanDiscoveryEnabled, + includeLanDevices = adbMdnsLanDiscovery, ) } @@ -985,6 +848,8 @@ fun DeviceTabScreen( editingDeviceId = null } + val devicePreviewCardHeightDp by appSettings.devicePreviewCardHeightDp.asState() + val previewVirtualButtonShowText by appSettings.previewVirtualButtonShowText.asState() // 设备 AppPageLazyColumn( contentPadding = contentPadding, @@ -998,7 +863,6 @@ fun DeviceTabScreen( sessionInfo = sessionInfo, busyLabel = null, connectedDeviceLabel = connectedDeviceLabel, - themeBaseIndex = themeBaseIndex, ) } @@ -1099,7 +963,7 @@ fun DeviceTabScreen( autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, onDiscoverTarget = { adbService.discoverPairingService( - includeLanDevices = adbMdnsLanDiscoveryEnabled, + includeLanDevices = adbMdnsLanDiscovery, ) }, onPair = { host, port, code -> @@ -1128,139 +992,34 @@ fun DeviceTabScreen( item { ConfigPanel( busy = busy, - bitRateMbps = bitRateMbps, - onBitRateSliderChange = { - bitRateMbps = it - @SuppressLint("DefaultLocale") - bitRateInput = String.format("%.1f", it) - }, - onBitRateInputChange = { bitRateInput = it }, - audioBitRateKbps = audioBitRateKbps, - onAudioBitRateChange = { audioBitRateKbps = it }, - videoCodec = videoCodec, - onVideoCodecChange = onVideoCodecChange, - audioEnabled = audioEnabled, - onAudioEnabledChange = onAudioEnabledChange, audioForwardingSupported = audioForwardingSupported, - audioCodec = audioCodec, - onAudioCodecChange = onAudioCodecChange, onOpenAdvanced = onOpenAdvancedPage, onStartStopHaptic = { haptics.contextClick() }, onStart = { runBusy("启动 scrcpy") { - if (noVideo && !audioEnabled) { - throw IllegalArgumentException("--no-video 需要同时启用音频") - } - if (audioEnabled && audioSourcePreset == "custom" && audioSourceCustom.isBlank()) { - throw IllegalArgumentException("audio-source 选择自定义时不能为空") - } - val resolvedVideoSource = videoSourcePreset.trim().ifBlank { "display" } - if (resolvedVideoSource == "camera" && !cameraMirroringSupported) { - throw IllegalArgumentException("camera mirroring 需要 Android 12+ (SDK 31+)") - } - val resolvedCameraSize = when (cameraSizePreset) { - "custom" -> cameraSizeCustom.trim() - else -> cameraSizePreset.trim() - } - if (resolvedVideoSource == "camera" && cameraSizePreset == "custom" && resolvedCameraSize.isBlank()) { - throw IllegalArgumentException("camera-size 选择自定义时不能为空") - } - val resolvedCameraId = cameraIdInput.trim() - val resolvedCameraFacing = cameraFacingPreset.trim() - if (resolvedVideoSource == "camera" && resolvedCameraId.isNotBlank() && resolvedCameraFacing.isNotBlank()) { - throw IllegalArgumentException("camera-id 与 camera-facing 不能同时设置") - } - val resolvedCameraAr = cameraArInput.trim() - val resolvedCameraFps = - cameraFpsInput.filter(Char::isDigit).toIntOrNull() ?: 0 - if (resolvedVideoSource == "camera" && cameraHighSpeed && resolvedCameraFps <= 0) { - throw IllegalArgumentException("启用 --camera-high-speed 时,--camera-fps 不能为 0") - } - val maxSize = - maxSizeInput.filter(Char::isDigit).toIntOrNull()?.takeIf { it > 0 } - ?: 0 - val maxFps = - maxFpsInput.filter(Char::isDigit).toIntOrNull()?.toFloat() ?: 0f - if (resolvedVideoSource == "camera" && resolvedCameraSize.isNotBlank() && (maxSize > 0 || resolvedCameraAr.isNotBlank())) { - throw IllegalArgumentException("显式 camera-size 时不能同时设置 --max-size 或 --camera-ar") - } - val bitRateBps = (bitRateMbps * 1_000_000).toInt() - val audioBitRateBps = (audioBitRateKbps.coerceAtLeast(1)) * 1_000 - val resolvedAudioSource = when (audioSourcePreset) { - "custom" -> audioSourceCustom.trim() - else -> audioSourcePreset.trim() - } - val newDisplayArg = buildNewDisplayArg( - newDisplayWidth.filter(Char::isDigit), - newDisplayHeight.filter(Char::isDigit), - newDisplayDpi.filter(Char::isDigit), - ) - val displayId = displayIdInput.filter(Char::isDigit).toIntOrNull() - ?.takeIf { it > 0 } - val crop = buildCropArg( - cropWidth.filter(Char::isDigit), - cropHeight.filter(Char::isDigit), - cropX.filter(Char::isDigit), - cropY.filter(Char::isDigit), - ) - val effectiveTurnScreenOff = turnScreenOff && !noControl - - val options = ClientOptions( - crop = crop, - videoCodecOptions = videoCodecOptions, - audioCodecOptions = audioCodecOptions, - videoEncoder = videoEncoder, - audioEncoder = audioEncoder, - cameraId = resolvedCameraId, - cameraSize = resolvedCameraSize, - cameraAr = resolvedCameraAr, - cameraFps = resolvedCameraFps.toUShort(), - videoCodec = Codec.fromString(videoCodec), - audioCodec = Codec.fromString(audioCodec), - videoSource = if (resolvedVideoSource == "camera") VideoSource.CAMERA else VideoSource.DISPLAY, - audioSource = if (resolvedAudioSource.isNotBlank()) AudioSource.fromString( - resolvedAudioSource - ) else AudioSource.AUTO, - cameraFacing = if (resolvedCameraFacing.isNotBlank()) CameraFacing.fromString( - resolvedCameraFacing - ) else CameraFacing.ANY, - maxSize = maxSize.toUShort(), - videoBitRate = bitRateBps.toUInt(), - audioBitRate = audioBitRateBps.toUInt(), - maxFps = if (maxFps > 0f) maxFps.toString() else "", - displayId = (displayId ?: 0).toUInt(), - control = !noControl, - video = !noVideo, - audio = audioEnabled, - requireAudio = requireAudio, - audioPlayback = !noAudioPlayback, - turnScreenOff = effectiveTurnScreenOff, - audioDup = audioDup, - newDisplay = newDisplayArg, - cameraHighSpeed = cameraHighSpeed, - ) - - // Start scrcpy using Scrcpy class - val session = scrcpy.start( - options = options, - ) - + val options = scrcpyOptions.toClientOptions() + val session = scrcpy.start(options) sessionInfo = session statusLine = "scrcpy 运行中" @SuppressLint("DefaultLocale") - val videoDetail = if (noVideo) { + val videoDetail = if (!options.video) { "off" } else { "${session.codec} ${session.width}x${session.height} " + - "@${String.format("%.1f", bitRateMbps)}Mbps" + "@${String.format("%.1f", videoBitRateMbps)}Mbps" } - val audioDetail = if (!audioEnabled) { + val audioDetail = if (!audio) { "off" } else { - val playback = if (noAudioPlayback) "(no-playback)" else "" - "$audioCodec ${audioBitRateKbps}kbps source=${resolvedAudioSource.ifBlank { "default" }}$playback" + val playback = if (!options.audioPlayback) "(no-playback)" else "" + "${options.audioCodec} ${audioBitRateKbps}kbps source=${options.audioSource}$playback" } - logEvent("scrcpy 已启动: device=${session.deviceName}, video=$videoDetail, audio=$audioDetail, control=${!noControl}, turnScreenOff=$effectiveTurnScreenOff, maxSize=${if (maxSize > 0) maxSize else "auto"}, maxFps=${if (maxFps > 0f) maxFps else "auto"}") + logEvent( + "scrcpy 已启动: device=${session.deviceName}" + + ", video=$videoDetail, audio=$audioDetail" + + ", control=${options.control}, turnScreenOff=${options.turnScreenOff}" + + ", maxSize=${options.maxSize}, maxFps=${options.maxFps}" + ) scope.launch { snack.showSnackbar("scrcpy 已启动") } @@ -1293,7 +1052,7 @@ fun DeviceTabScreen( PreviewCard( sessionInfo = sessionInfo, nativeCore = nativeCore, - previewHeightDp = previewCardHeightDp.coerceAtLeast(120), + previewHeightDp = devicePreviewCardHeightDp.coerceAtLeast(120), controlsVisible = previewControlsVisible, onTapped = { previewControlsVisible = !previewControlsVisible @@ -1302,15 +1061,15 @@ fun DeviceTabScreen( val info = sessionInfo ?: return@PreviewCard onOpenFullscreenPage(info) }, - onOpenFullscreenHaptic = { haptics.contextClick() }, ) } + item { VirtualButtonCard( busy = busy, outsideActions = virtualButtonLayout.first, moreActions = virtualButtonLayout.second, - showText = showPreviewVirtualButtonText, + showText = previewVirtualButtonShowText, onAction = ::sendVirtualButtonAction, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt index 75b2b10..612ce29 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainPage.kt @@ -52,10 +52,8 @@ import io.github.miuzarte.scrcpyforandroid.constants.UiMotion import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy -import io.github.miuzarte.scrcpyforandroid.services.MainSettings -import io.github.miuzarte.scrcpyforandroid.services.loadDevicePageSettings -import io.github.miuzarte.scrcpyforandroid.services.loadMainSettings -import io.github.miuzarte.scrcpyforandroid.services.saveMainSettings +import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -97,6 +95,7 @@ private sealed interface RootScreen : NavKey { @Composable fun MainPage() { val context = LocalContext.current + val activity = remember(context) { context as? Activity } val initialOrientation = remember(activity) { activity?.requestedOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED @@ -106,8 +105,6 @@ fun MainPage() { val adbService = remember(context) { NativeAdbService(context) } val scrcpy = remember(context) { Scrcpy(context) } - val initialSettings = remember(context) { loadMainSettings(context) } - val initialDeviceSettings = remember(context) { loadDevicePageSettings(context) } val snackHostState = remember { SnackbarHostState() } val tabs = remember { MainTabDestination.entries } val pagerState = rememberPagerState( @@ -132,20 +129,49 @@ fun MainPage() { restore = { restored -> restored.toList() }, ) - var audioEnabled by rememberSaveable { mutableStateOf(initialSettings.audioEnabled) } - var audioCodec by rememberSaveable { mutableStateOf(initialSettings.audioCodec) } - var videoCodec by rememberSaveable { mutableStateOf(initialSettings.videoCodec) } - var themeBaseIndex by rememberSaveable { mutableIntStateOf(initialSettings.themeBaseIndex) } - var monetEnabled by rememberSaveable { mutableStateOf(initialSettings.monetEnabled) } - var fullscreenDebugInfoEnabled by rememberSaveable { mutableStateOf(initialSettings.fullscreenDebugInfoEnabled) } - var showFullscreenVirtualButtons by rememberSaveable { mutableStateOf(initialSettings.showFullscreenVirtualButtons) } - var showPreviewVirtualButtonText by rememberSaveable { mutableStateOf(initialSettings.showPreviewVirtualButtonText) } - var keepScreenOnWhenStreamingEnabled by rememberSaveable { mutableStateOf(initialSettings.keepScreenOnWhenStreamingEnabled) } - var devicePreviewCardHeightDp by rememberSaveable { mutableIntStateOf(initialSettings.devicePreviewCardHeightDp) } - var virtualButtonsLayout by rememberSaveable { mutableStateOf(initialSettings.virtualButtonsLayout) } - var customServerUri by rememberSaveable { mutableStateOf(initialSettings.customServerUri) } - var serverRemotePath by rememberSaveable { mutableStateOf(initialSettings.serverRemotePath) } - var adbKeyName by rememberSaveable { mutableStateOf(initialSettings.adbKeyName) } + val appSettings = remember(context) { AppSettings(context) } + val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + + /* + val initialSettings = remember(context) { + loadMainSettings(context) + } + var audioEnabled by rememberSaveable { + mutableStateOf(initialSettings.audioEnabled) + } + var audioCodec by rememberSaveable { + mutableStateOf(initialSettings.audioCodec) + } + var videoCodec by rememberSaveable { + mutableStateOf(initialSettings.videoCodec) + } + var fullscreenDebugInfoEnabled by rememberSaveable { + mutableStateOf(initialSettings.fullscreenDebugInfoEnabled) + } + var showFullscreenVirtualButtons by rememberSaveable { + mutableStateOf(initialSettings.showFullscreenVirtualButtons) + } + var showPreviewVirtualButtonText by rememberSaveable { + mutableStateOf(initialSettings.showPreviewVirtualButtonText) + } + var keepScreenOnWhenStreamingEnabled by rememberSaveable { + mutableStateOf(initialSettings.keepScreenOnWhenStreamingEnabled) + } + var devicePreviewCardHeightDp by rememberSaveable { + mutableIntStateOf(initialSettings.devicePreviewCardHeightDp) + } + var virtualButtonsLayout by rememberSaveable { + mutableStateOf(initialSettings.virtualButtonsLayout) + } + var customServerUri by rememberSaveable { + mutableStateOf(initialSettings.customServerUri) + } + var serverRemotePath by rememberSaveable { + mutableStateOf(initialSettings.serverRemotePath) + } + var adbKeyName by rememberSaveable { + mutableStateOf(initialSettings.adbKeyName) + } var adbPairingAutoDiscoverOnDialogOpen by rememberSaveable { mutableStateOf(initialSettings.adbPairingAutoDiscoverOnDialogOpen) } @@ -155,36 +181,102 @@ fun MainPage() { var adbMdnsLanDiscoveryEnabled by rememberSaveable { mutableStateOf(initialSettings.adbMdnsLanDiscoveryEnabled) } - var noControl by rememberSaveable { mutableStateOf(initialDeviceSettings.noControl) } - var videoEncoder by rememberSaveable { mutableStateOf(initialDeviceSettings.videoEncoder) } - var videoCodecOptions by rememberSaveable { mutableStateOf(initialDeviceSettings.videoCodecOptions) } - var audioEncoder by rememberSaveable { mutableStateOf(initialDeviceSettings.audioEncoder) } - var audioCodecOptions by rememberSaveable { mutableStateOf(initialDeviceSettings.audioCodecOptions) } - var audioDup by rememberSaveable { mutableStateOf(initialDeviceSettings.audioDup) } - var audioSourcePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.audioSourcePreset) } - var audioSourceCustom by rememberSaveable { mutableStateOf(initialDeviceSettings.audioSourceCustom) } - var videoSourcePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.videoSourcePreset) } - var cameraIdInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraIdInput) } - var cameraFacingPreset by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraFacingPreset) } - var cameraSizePreset by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraSizePreset) } - var cameraSizeCustom by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraSizeCustom) } - var cameraArInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraAr) } - var cameraFpsInput by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraFps) } - var cameraHighSpeed by rememberSaveable { mutableStateOf(initialDeviceSettings.cameraHighSpeed) } - var noAudioPlayback by rememberSaveable { mutableStateOf(initialDeviceSettings.noAudioPlayback) } - var noVideo by rememberSaveable { mutableStateOf(initialDeviceSettings.noVideo) } - var requireAudio by rememberSaveable { mutableStateOf(initialDeviceSettings.requireAudio) } - var turnScreenOff by rememberSaveable { mutableStateOf(initialDeviceSettings.turnScreenOff) } - var maxSizeInput by rememberSaveable { mutableStateOf(initialDeviceSettings.maxSizeInput) } - var maxFpsInput by rememberSaveable { mutableStateOf(initialDeviceSettings.maxFpsInput) } - var newDisplayWidth by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayWidth) } - var newDisplayHeight by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayHeight) } - var newDisplayDpi by rememberSaveable { mutableStateOf(initialDeviceSettings.newDisplayDpi) } - var displayIdInput by rememberSaveable { mutableStateOf(initialDeviceSettings.displayIdInput) } - var cropWidth by rememberSaveable { mutableStateOf(initialDeviceSettings.cropWidth) } - var cropHeight by rememberSaveable { mutableStateOf(initialDeviceSettings.cropHeight) } - var cropX by rememberSaveable { mutableStateOf(initialDeviceSettings.cropX) } - var cropY by rememberSaveable { mutableStateOf(initialDeviceSettings.cropY) } + + val initialDeviceSettings = remember(context) { + loadDevicePageSettings(context) + } + var noControl by rememberSaveable { + mutableStateOf(initialDeviceSettings.noControl) + } + var videoEncoder by rememberSaveable { + mutableStateOf(initialDeviceSettings.videoEncoder) + } + var videoCodecOptions by rememberSaveable { + mutableStateOf(initialDeviceSettings.videoCodecOptions) + } + var audioEncoder by rememberSaveable { + mutableStateOf(initialDeviceSettings.audioEncoder) + } + var audioCodecOptions by rememberSaveable { + mutableStateOf(initialDeviceSettings.audioCodecOptions) + } + var audioDup by rememberSaveable { + mutableStateOf(initialDeviceSettings.audioDup) + } + var audioSourcePreset by rememberSaveable { + mutableStateOf(initialDeviceSettings.audioSourcePreset) + } + var audioSourceCustom by rememberSaveable { + mutableStateOf(initialDeviceSettings.audioSourceCustom) + } + var videoSourcePreset by rememberSaveable { + mutableStateOf(initialDeviceSettings.videoSourcePreset) + } + var cameraIdInput by rememberSaveable { + mutableStateOf(initialDeviceSettings.cameraIdInput) + } + var cameraFacingPreset by rememberSaveable { + mutableStateOf(initialDeviceSettings.cameraFacingPreset) + } + var cameraSizePreset by rememberSaveable { + mutableStateOf(initialDeviceSettings.cameraSizePreset) + } + var cameraSizeCustom by rememberSaveable { + mutableStateOf(initialDeviceSettings.cameraSizeCustom) + } + var cameraArInput by rememberSaveable { + mutableStateOf(initialDeviceSettings.cameraAr) + } + var cameraFpsInput by rememberSaveable { + mutableStateOf(initialDeviceSettings.cameraFps) + } + var cameraHighSpeed by rememberSaveable { + mutableStateOf(initialDeviceSettings.cameraHighSpeed) + } + var noAudioPlayback by rememberSaveable { + mutableStateOf(initialDeviceSettings.noAudioPlayback) + } + var noVideo by rememberSaveable { + mutableStateOf(initialDeviceSettings.noVideo) + } + var requireAudio by rememberSaveable { + mutableStateOf(initialDeviceSettings.requireAudio) + } + var turnScreenOff by rememberSaveable { + mutableStateOf(initialDeviceSettings.turnScreenOff) + } + var maxSizeInput by rememberSaveable { + mutableStateOf(initialDeviceSettings.maxSizeInput) + } + var maxFpsInput by rememberSaveable { + mutableStateOf(initialDeviceSettings.maxFpsInput) + } + var newDisplayWidth by rememberSaveable { + mutableStateOf(initialDeviceSettings.newDisplayWidth) + } + var newDisplayHeight by rememberSaveable { + mutableStateOf(initialDeviceSettings.newDisplayHeight) + } + var newDisplayDpi by rememberSaveable { + mutableStateOf(initialDeviceSettings.newDisplayDpi) + } + var displayIdInput by rememberSaveable { + mutableStateOf(initialDeviceSettings.displayIdInput) + } + var cropWidth by rememberSaveable { + mutableStateOf(initialDeviceSettings.cropWidth) + } + var cropHeight by rememberSaveable { + mutableStateOf(initialDeviceSettings.cropHeight) + } + var cropX by rememberSaveable { + mutableStateOf(initialDeviceSettings.cropX) + } + var cropY by rememberSaveable { + mutableStateOf(initialDeviceSettings.cropY) + } + */ + val videoEncoderOptions = remember { mutableStateListOf() } val audioEncoderOptions = remember { mutableStateListOf() } val videoEncoderTypeMap = remember { mutableStateMapOf() } @@ -201,7 +293,10 @@ fun MainPage() { var fullscreenOrientation by rememberSaveable { mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) } - val themeMode = resolveThemeMode(themeBaseIndex, monetEnabled) + + var themeBaseIndex by appSettings.themeBaseIndex.asMutableState() + var monet by appSettings.monet.asMutableState() + val themeMode = resolveThemeMode(themeBaseIndex, monet) val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } // Restore system orientation when MainPage leaves composition. @@ -211,6 +306,7 @@ fun MainPage() { } } + val keepScreenOnWhenStreamingEnabled by appSettings.keepScreenOnWhenStreaming.asMutableState() // Keep-screen-on is controlled globally, so fullscreen and preview share the same behavior. DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) { val window = activity?.window @@ -234,49 +330,7 @@ fun MainPage() { activity?.requestedOrientation = targetOrientation } - LaunchedEffect( - audioEnabled, - audioCodec, - videoCodec, - themeBaseIndex, - monetEnabled, - fullscreenDebugInfoEnabled, - showFullscreenVirtualButtons, - showPreviewVirtualButtonText, - keepScreenOnWhenStreamingEnabled, - devicePreviewCardHeightDp, - virtualButtonsLayout, - customServerUri, - serverRemotePath, - adbKeyName, - adbPairingAutoDiscoverOnDialogOpen, - adbAutoReconnectPairedDevice, - adbMdnsLanDiscoveryEnabled, - ) { - saveMainSettings( - context, - MainSettings( - audioEnabled = audioEnabled, - audioCodec = audioCodec, - videoCodec = videoCodec, - themeBaseIndex = themeBaseIndex, - monetEnabled = monetEnabled, - fullscreenDebugInfoEnabled = fullscreenDebugInfoEnabled, - showFullscreenVirtualButtons = showFullscreenVirtualButtons, - showPreviewVirtualButtonText = showPreviewVirtualButtonText, - keepScreenOnWhenStreamingEnabled = keepScreenOnWhenStreamingEnabled, - devicePreviewCardHeightDp = devicePreviewCardHeightDp, - virtualButtonsLayout = virtualButtonsLayout, - customServerUri = customServerUri, - serverRemotePath = serverRemotePath, - adbKeyName = adbKeyName, - adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, - adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice, - adbMdnsLanDiscoveryEnabled = adbMdnsLanDiscoveryEnabled, - ), - ) - } - + val adbKeyName by appSettings.adbKeyName.asMutableState() LaunchedEffect(adbKeyName) { adbService.keyName = adbKeyName.ifBlank { AppDefaults.ADB_KEY_NAME } } @@ -340,17 +394,19 @@ fun MainPage() { } } - val picker = - rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> - if (uri == null) return@rememberLauncherForActivityResult - runCatching { - context.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION - ) - } - customServerUri = uri.toString() + var customServerUri by appSettings.customServerUri.asMutableState() + val picker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri == null) return@rememberLauncherForActivityResult + runCatching { + context.contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) } + customServerUri = uri.toString() + } val rootEntryProvider = entryProvider { entry(RootScreen.Home) { @@ -432,80 +488,14 @@ fun MainPage() { scrcpy = scrcpy, snack = snackHostState, scrollBehavior = deviceScrollBehavior, - virtualButtonsLayout = virtualButtonsLayout, - showPreviewVirtualButtonText = showPreviewVirtualButtonText, - previewCardHeightDp = devicePreviewCardHeightDp, - themeBaseIndex = themeBaseIndex, - videoCodec = videoCodec, - onVideoCodecChange = { videoCodec = it }, - audioEnabled = audioEnabled, - onAudioEnabledChange = { audioEnabled = it }, - audioCodec = audioCodec, - onAudioCodecChange = { audioCodec = it }, - noControl = noControl, + /* onNoControlChange = { noControl = it if (it) { turnScreenOff = false } }, - videoEncoder = videoEncoder, - onVideoEncoderChange = { videoEncoder = it }, - videoCodecOptions = videoCodecOptions, - onVideoCodecOptionsChange = { videoCodecOptions = it }, - audioEncoder = audioEncoder, - onAudioEncoderChange = { audioEncoder = it }, - audioCodecOptions = audioCodecOptions, - onAudioCodecOptionsChange = { audioCodecOptions = it }, - audioDup = audioDup, - onAudioDupChange = { audioDup = it }, - audioSourcePreset = audioSourcePreset, - onAudioSourcePresetChange = { audioSourcePreset = it }, - audioSourceCustom = audioSourceCustom, - onAudioSourceCustomChange = { audioSourceCustom = it }, - videoSourcePreset = videoSourcePreset, - onVideoSourcePresetChange = { videoSourcePreset = it }, - cameraIdInput = cameraIdInput, - onCameraIdInputChange = { cameraIdInput = it }, - cameraFacingPreset = cameraFacingPreset, - onCameraFacingPresetChange = { cameraFacingPreset = it }, - cameraSizePreset = cameraSizePreset, - onCameraSizePresetChange = { cameraSizePreset = it }, - cameraSizeCustom = cameraSizeCustom, - onCameraSizeCustomChange = { cameraSizeCustom = it }, - cameraArInput = cameraArInput, - onCameraArInputChange = { cameraArInput = it }, - cameraFpsInput = cameraFpsInput, - onCameraFpsInputChange = { cameraFpsInput = it }, - cameraHighSpeed = cameraHighSpeed, - onCameraHighSpeedChange = { cameraHighSpeed = it }, - noAudioPlayback = noAudioPlayback, - onNoAudioPlaybackChange = { noAudioPlayback = it }, - noVideo = noVideo, - requireAudio = requireAudio, - onRequireAudioChange = { requireAudio = it }, - turnScreenOff = turnScreenOff, - onTurnScreenOffChange = { turnScreenOff = it }, - maxSizeInput = maxSizeInput, - onMaxSizeInputChange = { maxSizeInput = it }, - maxFpsInput = maxFpsInput, - onMaxFpsInputChange = { maxFpsInput = it }, - newDisplayWidth = newDisplayWidth, - onNewDisplayWidthChange = { newDisplayWidth = it }, - newDisplayHeight = newDisplayHeight, - onNewDisplayHeightChange = { newDisplayHeight = it }, - newDisplayDpi = newDisplayDpi, - onNewDisplayDpiChange = { newDisplayDpi = it }, - displayIdInput = displayIdInput, - onDisplayIdInputChange = { displayIdInput = it }, - cropWidth = cropWidth, - onCropWidthChange = { cropWidth = it }, - cropHeight = cropHeight, - onCropHeightChange = { cropHeight = it }, - cropX = cropX, - onCropXChange = { cropX = it }, - cropY = cropY, - onCropYChange = { cropY = it }, + */ videoEncoderOptions = videoEncoderOptions, onVideoEncoderOptionsChange = { videoEncoderOptions.clear() @@ -558,9 +548,6 @@ fun MainPage() { ), ) }, - adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, - adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice, - adbMdnsLanDiscoveryEnabled = adbMdnsLanDiscoveryEnabled, ) } @@ -574,33 +561,12 @@ fun MainPage() { ) { pagePadding -> SettingsScreen( contentPadding = pagePadding, - themeBaseIndex = themeBaseIndex, - onThemeBaseIndexChange = { themeBaseIndex = it }, - monetEnabled = monetEnabled, - onMonetEnabledChange = { monetEnabled = it }, - fullscreenDebugInfoEnabled = fullscreenDebugInfoEnabled, - onFullscreenDebugInfoEnabledChange = { - fullscreenDebugInfoEnabled = it - }, - keepScreenOnWhenStreamingEnabled = keepScreenOnWhenStreamingEnabled, - onKeepScreenOnWhenStreamingEnabledChange = { - keepScreenOnWhenStreamingEnabled = it - }, - devicePreviewCardHeightDp = devicePreviewCardHeightDp, - onDevicePreviewCardHeightDpChange = { - devicePreviewCardHeightDp = it.coerceAtLeast(120) - }, onOpenReorderDevices = { openReorderDevicesAction?.invoke() }, onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) }, - showFullscreenVirtualButtons = showFullscreenVirtualButtons, - onShowFullscreenVirtualButtonsChange = { - showFullscreenVirtualButtons = it - }, - customServerUri = customServerUri, onPickServer = { picker.launch( arrayOf( @@ -610,19 +576,6 @@ fun MainPage() { ) ) }, - onClearServer = { customServerUri = null }, - serverRemotePath = serverRemotePath, - onServerRemotePathChange = { serverRemotePath = it }, - adbKeyName = adbKeyName, - onAdbKeyNameChange = { adbKeyName = it }, - adbPairingAutoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, - onAdbPairingAutoDiscoverOnDialogOpenChange = { - adbPairingAutoDiscoverOnDialogOpen = it - }, - adbAutoReconnectPairedDevice = adbAutoReconnectPairedDevice, - onAdbAutoReconnectPairedDeviceChange = { - adbAutoReconnectPairedDevice = it - }, scrollBehavior = settingsScrollBehavior, ) } @@ -632,6 +585,8 @@ fun MainPage() { } } + val videoEncoder by scrcpyOptions.videoEncoder.asState() + val audioEncoder by scrcpyOptions.audioEncoder.asState() entry(RootScreen.Advanced) { val videoEncoderDropdownItems = listOf("默认") + videoEncoderOptions val audioEncoderDropdownItems = listOf("默认") + audioEncoderOptions @@ -808,11 +763,11 @@ fun MainPage() { @Composable private fun DeviceMenuPopup( show: Boolean, - canClearLogs: Boolean, onDismissRequest: () -> Unit, onReorderDevices: () -> Unit, onOpenVirtualButtonOrder: () -> Unit, onClearLogs: () -> Unit, + canClearLogs: Boolean, ) { SuperListPopup( show = show, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt index fdad761..3aeab6b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt @@ -11,6 +11,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Clear import androidx.compose.material.icons.rounded.FileOpen import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight @@ -19,6 +22,8 @@ import io.github.miuzarte.scrcpyforandroid.constants.AppDefaults import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.scaffolds.AppPageLazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide +import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Icon @@ -43,11 +48,11 @@ private val THEME_BASE_OPTIONS = listOf( ThemeModeOption("深色", ColorSchemeMode.Dark), ) -fun resolveThemeMode(baseIndex: Int, monetEnabled: Boolean): ColorSchemeMode { +fun resolveThemeMode(baseIndex: Int, monet: Boolean): ColorSchemeMode { return when (baseIndex.coerceIn(0, 2)) { - 0 -> if (monetEnabled) ColorSchemeMode.MonetSystem else ColorSchemeMode.System - 1 -> if (monetEnabled) ColorSchemeMode.MonetLight else ColorSchemeMode.Light - else -> if (monetEnabled) ColorSchemeMode.MonetDark else ColorSchemeMode.Dark + 0 -> if (monet) ColorSchemeMode.MonetSystem else ColorSchemeMode.System + 1 -> if (monet) ColorSchemeMode.MonetLight else ColorSchemeMode.Light + else -> if (monet) ColorSchemeMode.MonetDark else ColorSchemeMode.Dark } } @@ -59,36 +64,37 @@ private fun resolveThemeLabel(baseIndex: Int, monetEnabled: Boolean): String { @Composable fun SettingsScreen( contentPadding: PaddingValues, - themeBaseIndex: Int, - onThemeBaseIndexChange: (Int) -> Unit, - monetEnabled: Boolean, - onMonetEnabledChange: (Boolean) -> Unit, - fullscreenDebugInfoEnabled: Boolean, - onFullscreenDebugInfoEnabledChange: (Boolean) -> Unit, - keepScreenOnWhenStreamingEnabled: Boolean, - onKeepScreenOnWhenStreamingEnabledChange: (Boolean) -> Unit, - devicePreviewCardHeightDp: Int, - onDevicePreviewCardHeightDpChange: (Int) -> Unit, - showFullscreenVirtualButtons: Boolean, onOpenReorderDevices: () -> Unit, onOpenVirtualButtonOrder: () -> Unit, - onShowFullscreenVirtualButtonsChange: (Boolean) -> Unit, - customServerUri: String?, onPickServer: () -> Unit, - onClearServer: () -> Unit, - serverRemotePath: String, - onServerRemotePathChange: (String) -> Unit, - adbKeyName: String, - onAdbKeyNameChange: (String) -> Unit, - adbPairingAutoDiscoverOnDialogOpen: Boolean, - onAdbPairingAutoDiscoverOnDialogOpenChange: (Boolean) -> Unit, - adbAutoReconnectPairedDevice: Boolean, - onAdbAutoReconnectPairedDeviceChange: (Boolean) -> Unit, scrollBehavior: ScrollBehavior, ) { - val baseModeItems = THEME_BASE_OPTIONS.map { it.label } val context = LocalContext.current + val appSettings = remember(context) { AppSettings(context) } + val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + + val baseModeItems = THEME_BASE_OPTIONS.map { it.label } + + // 主题 + var themeBaseIndex by appSettings.themeBaseIndex.asMutableState() + var monet by appSettings.monet.asMutableState() + + // 投屏 + var fullscreenDebugInfo by appSettings.fullscreenDebugInfo.asMutableState() + var keepScreenOnWhenStreaming by appSettings.keepScreenOnWhenStreaming.asMutableState() + var devicePreviewCardHeightDp by appSettings.devicePreviewCardHeightDp.asMutableState() + var showFullscreenVirtualButtons by appSettings.showFullscreenVirtualButtons.asMutableState() + + // scrcpy-server + var customServerUri by appSettings.customServerUri.asMutableState() + var serverRemotePath by appSettings.serverRemotePath.asMutableState() + + // ADB + var adbKeyName by appSettings.adbKeyName.asMutableState() + var adbPairingAutoDiscoverOnDialogOpen by appSettings.adbPairingAutoDiscoverOnDialogOpen.asMutableState() + var adbAutoReconnectPairedDevice by appSettings.adbAutoReconnectPairedDevice.asMutableState() + // 设置 AppPageLazyColumn( contentPadding = contentPadding, @@ -99,16 +105,16 @@ fun SettingsScreen( Card { SuperDropdown( title = "外观模式", - summary = resolveThemeLabel(themeBaseIndex, monetEnabled), + summary = resolveThemeLabel(themeBaseIndex, monet), items = baseModeItems, selectedIndex = themeBaseIndex.coerceIn(0, baseModeItems.lastIndex), - onSelectedIndexChange = onThemeBaseIndexChange, + onSelectedIndexChange = { themeBaseIndex = it }, ) SuperSwitch( title = "Monet", summary = "开启后使用 Monet 动态配色", - checked = monetEnabled, - onCheckedChange = onMonetEnabledChange, + checked = monet, + onCheckedChange = { monet = it }, ) } @@ -117,23 +123,21 @@ fun SettingsScreen( SuperSwitch( title = "启用调试信息", summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS", - checked = fullscreenDebugInfoEnabled, - onCheckedChange = onFullscreenDebugInfoEnabledChange, + checked = fullscreenDebugInfo, + onCheckedChange = { fullscreenDebugInfo = it }, ) SuperSwitch( title = "投屏时保持屏幕常亮", summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开", - checked = keepScreenOnWhenStreamingEnabled, - onCheckedChange = onKeepScreenOnWhenStreamingEnabledChange, + checked = keepScreenOnWhenStreaming, + onCheckedChange = { keepScreenOnWhenStreaming = it }, ) SuperSlide( title = "预览卡高度", summary = "设备页预览卡高度", value = devicePreviewCardHeightDp.toFloat(), onValueChange = { - onDevicePreviewCardHeightDpChange( - it.roundToInt().coerceAtLeast(120) - ) + devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120) }, valueRange = 160f..600f, steps = 439, @@ -142,9 +146,10 @@ fun SettingsScreen( inputInitialValue = devicePreviewCardHeightDp.toString(), inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 120f..Float.MAX_VALUE, - onInputConfirm = { raw -> - raw.toIntOrNull() - ?.let { onDevicePreviewCardHeightDpChange(it.coerceAtLeast(120)) } + onInputConfirm = { input -> + input.toIntOrNull()?.let { + devicePreviewCardHeightDp = it.coerceAtLeast(120) + } }, ) SuperArrow( @@ -161,7 +166,7 @@ fun SettingsScreen( title = "全屏显示虚拟按钮", summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮", checked = showFullscreenVirtualButtons, - onCheckedChange = onShowFullscreenVirtualButtonsChange, + onCheckedChange = { showFullscreenVirtualButtons = it }, ) } @@ -176,20 +181,21 @@ fun SettingsScreen( fontWeight = FontWeight.Medium, ) TextField( - value = customServerUri ?: "", + value = customServerUri, onValueChange = {}, readOnly = true, label = "scrcpy-server-v3.3.4", - useLabelAsPlaceholder = customServerUri == null, + useLabelAsPlaceholder = customServerUri.isBlank(), modifier = Modifier .fillMaxWidth() .padding(horizontal = UiSpacing.CardContent) .padding(bottom = UiSpacing.CardContent), trailingIcon = { Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) { - if (customServerUri != null) IconButton(onClick = onClearServer) { - Icon(Icons.Rounded.Clear, contentDescription = "清空") - } + if (customServerUri.isNotBlank()) + IconButton(onClick = { customServerUri = "" }) { + Icon(Icons.Rounded.Clear, contentDescription = "清空") + } IconButton(onClick = onPickServer) { Icon(Icons.Rounded.FileOpen, contentDescription = "选择文件") } @@ -205,7 +211,7 @@ fun SettingsScreen( ) TextField( value = serverRemotePath, - onValueChange = onServerRemotePathChange, + onValueChange = { serverRemotePath = it }, label = AppDefaults.SERVER_REMOTE_PATH, useLabelAsPlaceholder = true, singleLine = true, @@ -227,7 +233,7 @@ fun SettingsScreen( ) TextField( value = adbKeyName, - onValueChange = onAdbKeyNameChange, + onValueChange = { adbKeyName = it }, label = AppDefaults.ADB_KEY_NAME, useLabelAsPlaceholder = true, singleLine = true, @@ -240,13 +246,13 @@ fun SettingsScreen( title = "配对时自动启用发现服务", summary = "打开配对弹窗后自动搜索可用配对端口", checked = adbPairingAutoDiscoverOnDialogOpen, - onCheckedChange = onAdbPairingAutoDiscoverOnDialogOpenChange, + onCheckedChange = { adbPairingAutoDiscoverOnDialogOpen = it }, ) SuperSwitch( title = "自动重连已配对设备", summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)", checked = adbAutoReconnectPairedDevice, - onCheckedChange = onAdbAutoReconnectPairedDeviceChange, + onCheckedChange = { adbAutoReconnectPairedDevice = it }, ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt index 010a58f..3a056b7 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Shared.kt @@ -68,7 +68,7 @@ class Shared { AAC("aac"), FLAC("flac"), RAW("raw"); // wav raw - + companion object { fun fromString(value: String): Codec { return entries.find { it.string.equals(value, ignoreCase = true) } ?: H264 @@ -79,7 +79,7 @@ class Shared { enum class VideoSource(val string: String) { DISPLAY("display"), // default, ignore when passing CAMERA("camera"); - + companion object { fun fromString(value: String): VideoSource { return entries.find { it.string.equals(value, ignoreCase = true) } ?: DISPLAY @@ -100,7 +100,7 @@ class Shared { VOICE_CALL_UPLINK("voice-call-uplink"), VOICE_CALL_DOWNLINK("voice-call-downlink"), VOICE_PERFORMANCE("voice-performance"); - + companion object { fun fromString(value: String): AudioSource { return entries.find { it.string.equals(value, ignoreCase = true) } ?: AUTO @@ -113,7 +113,7 @@ class Shared { FRONT("front"), BACK("back"), EXTERNAL("external"); - + companion object { fun fromString(value: String): CameraFacing { return entries.find { it.string.equals(value, ignoreCase = true) } ?: ANY @@ -142,6 +142,13 @@ class Shared { else -> error("Invalid rotation value") } } + + companion object { + fun fromInt(value: Int): Orientation { + return entries.getOrNull(value) + ?: error("Invalid orientation value: $value") + } + } } enum class OrientationLock(val string: String) { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt index 3697433..e8e86d2 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt @@ -25,7 +25,6 @@ internal fun loadQuickDevices(context: Context): List { if (host.isNotBlank()) { result.add( DeviceShortcut( - id = "$host:$port", name = name, host = host, port = port, @@ -44,7 +43,6 @@ internal fun loadQuickDevices(context: Context): List { if (host.isNotBlank()) { result.add( DeviceShortcut( - id = "$host:$port", name = name, host = host, port = port, @@ -83,11 +81,9 @@ internal fun upsertQuickDevice( port: Int, online: Boolean, ) { - val id = "$host:$port" - val idx = quickDevices.indexOfFirst { it.id == id } + val idx = quickDevices.indexOfFirst { it.id == "$host:$port" } val existingName = if (idx >= 0) quickDevices[idx].name else "" val item = DeviceShortcut( - id = id, name = existingName, host = host, port = port, @@ -124,7 +120,6 @@ internal fun replaceQuickDevicePort( val old = quickDevices[idx] val updated = old.copy( - id = "$host:$newPort", port = newPort, online = online, ) diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt new file mode 100644 index 0000000..614c4c5 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -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 { + 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 +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings_New.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings_New.kt new file mode 100644 index 0000000..ca8b37c --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings_New.kt @@ -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 { + 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 +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt new file mode 100644 index 0000000..e701d43 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/QuickDevices.kt @@ -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 { + 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 = observe(QUICK_DEVICES) + + suspend fun getQuickDevices(): List { + val raw = getQuickDevicesRaw() + if (raw.isBlank()) return emptyList() + + val result = mutableListOf() + raw.lineSequence().forEach { line -> + DeviceShortcut.unmarshalFrom(line)?.let { result.add(it) } + } + return result + } + + suspend fun setQuickDevices(quickDevices: List) = + setQuickDevicesRaw(quickDevices.joinToString("\n") { it.marshalToString() }) + + fun observeQuickDevices(): Flow> = observeQuickDevicesRaw() + .map { raw -> + if (raw.isBlank()) return@map emptyList() + + val result = mutableListOf() + 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 = observe(QUICK_CONNECT_INPUT) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt new file mode 100644 index 0000000..076b5e2 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/ScrcpyOptions.kt @@ -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 { + 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() + ) + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt new file mode 100644 index 0000000..ba530cf --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt @@ -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? = + ReplaceFileCorruptionHandler { + Log.e(TAG, "Preferences corrupted, resetting.", it) + emptyPreferences() + } +) { + data class Pair( + val key: Preferences.Key, + val defaultValue: T, + ) { + val name: String get() = key.name + } + + /** + * 设置项委托类,自动提供 get/set/observe/observeAsState/asMutableState 方法 + */ + inner class SettingProperty( + private val pair: Pair + ) { + operator fun getValue(thisRef: Any?, property: KProperty<*>): SettingProperty = this + + suspend fun get(): T = getValue(pair) + + suspend fun set(value: T) = setValue(pair, value) + + fun observe(): Flow = this@Settings.observe(pair) + + @Composable + fun asState(): State = this@Settings.asState(pair) + + @Composable + fun asMutableState(): MutableState = this@Settings.asMutableState(pair) + } + + // 为 Context 添加扩展委托属性,确保 DataStore 单例 + private val Context.dataStore: DataStore by preferencesDataStore( + name = this.name, + corruptionHandler = corruptionHandler, + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + ) + + abstract suspend fun toMap(): Map + abstract fun validate(): Boolean + + // 对外暴露的 DataStore 实例 + protected val dataStore: DataStore = context.dataStore + + protected fun setting(pair: Pair): SettingProperty = + SettingProperty(pair) + + protected suspend fun getValue(pair: Pair): T = + dataStore.data.first()[pair.key] ?: pair.defaultValue + + protected suspend fun setValue(pair: Pair, value: T) { + dataStore.edit { preferences -> preferences[pair.key] = value } + } + + protected fun observe(pair: Pair): Flow = + dataStore.data.map { preferences -> preferences[pair.key] ?: pair.defaultValue } + + @Composable + protected fun asState(pair: Pair): State = + observe(pair).collectAsState(initial = pair.defaultValue) + + @Composable + protected fun asMutableState(pair: Pair): MutableState { + val scope = rememberCoroutineScope() + val state = asState(pair) + + return remember(state.value) { + object : MutableState { + 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 } + } + } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/StorageMigration.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/StorageMigration.kt new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt index 3996703..72036ed 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/DeviceWidgets.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInteropFilter import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -70,6 +71,8 @@ import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing import io.github.miuzarte.scrcpyforandroid.haptics.rememberAppHaptics import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlide +import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.ScrcpyOptions import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -92,12 +95,16 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme.isDynamicColor import top.yukonga.miuix.kmp.utils.PressFeedbackType import kotlin.math.roundToInt +// TODO: migrate to scrcpy.Shared + +@Deprecated("scrcpy.Shared.Codec") private val VIDEO_CODEC_OPTIONS = listOf( "h264" to "H.264", "h265" to "H.265", "av1" to "AV1", ) +@Deprecated("scrcpy.Shared.Codec") private val AUDIO_CODEC_OPTIONS = listOf( "opus" to "Opus", "aac" to "AAC", @@ -124,8 +131,14 @@ internal fun StatusCard( sessionInfo: ScrcpySessionInfo?, busyLabel: String?, connectedDeviceLabel: String, - themeBaseIndex: Int, ) { + val context = LocalContext.current + + val appSettings = remember(context) { AppSettings(context) } + val scrcpyOptions = remember(context) { ScrcpyOptions(context) } + + val themeBaseIndex by appSettings.themeBaseIndex.asState() + val cleanStatusLine = normalizeStatusLine(statusLine) // 根据应用主题设置决定是否使用深色模式 @@ -261,9 +274,9 @@ internal fun PreviewCard( previewHeightDp: Int, controlsVisible: Boolean, onTapped: () -> Unit, - onOpenFullscreenHaptic: (() -> Unit)? = null, onOpenFullscreen: () -> Unit, ) { + val haptics = rememberAppHaptics() val alpha by animateFloatAsState(if (controlsVisible) 1f else 0f, label = "preview-controls") Card { @@ -312,7 +325,7 @@ internal fun PreviewCard( Button( onClick = { if (alpha > 0.1) { - onOpenFullscreenHaptic?.invoke() + haptics.contextClick() onOpenFullscreen() } }, @@ -361,94 +374,99 @@ internal fun VirtualButtonCard( @Composable internal fun ConfigPanel( busy: Boolean, - bitRateMbps: Float, - onBitRateSliderChange: (Float) -> Unit, - onBitRateInputChange: (String) -> Unit, - audioBitRateKbps: Int, - onAudioBitRateChange: (Int) -> Unit, - videoCodec: String, - onVideoCodecChange: (String) -> Unit, - audioEnabled: Boolean, - onAudioEnabledChange: (Boolean) -> Unit, audioForwardingSupported: Boolean, - audioCodec: String, - onAudioCodecChange: (String) -> Unit, onOpenAdvanced: () -> Unit, onStartStopHaptic: (() -> Unit)? = null, onStart: () -> Unit, onStop: () -> Unit, sessionStarted: Boolean, ) { - val videoCodecItems = remember { VIDEO_CODEC_OPTIONS.map { it.second } } - val videoCodecIndex = - VIDEO_CODEC_OPTIONS.indexOfFirst { it.first == videoCodec }.coerceAtLeast(0) - val audioCodecItems = remember { AUDIO_CODEC_OPTIONS.map { it.second } } - val audioCodecIndex = - AUDIO_CODEC_OPTIONS.indexOfFirst { it.first == audioCodec }.coerceAtLeast(0) - val audioBitRatePresetIndex = - presetIndexFromInput(audioBitRateKbps.toString(), ScrcpyPresets.AudioBitRate) + val context = LocalContext.current + + // val appSettings = remember(context) { AppSettings(context) } + val scrcpyOptions = remember(context) { ScrcpyOptions(context) } SectionSmallTitle("Scrcpy") Card { + var audio by scrcpyOptions.audio.asMutableState() SuperSwitch( title = "音频转发", summary = "转发设备音频到本机 (Android 11+)", - checked = audioEnabled, - onCheckedChange = onAudioEnabledChange, + checked = audio, + onCheckedChange = { audio = it }, enabled = !sessionStarted && audioForwardingSupported, ) + + var audioCodec by scrcpyOptions.audioCodec.asMutableState() + val audioCodecItems = remember { AUDIO_CODEC_OPTIONS.map { it.second } } + val audioCodecIndex = AUDIO_CODEC_OPTIONS.indexOfFirst { + it.first == audioCodec + }.coerceAtLeast(0) + var audioBitRate by scrcpyOptions.audioBitRate.asMutableState() + val audioBitRateKbps = audioBitRate * 1_000f + val audioBitRatePresetIndex = presetIndexFromInput( + audioBitRateKbps.toString(), + ScrcpyPresets.AudioBitRate + ) SuperDropdown( title = "音频编码", summary = "--audio-codec", items = audioCodecItems, selectedIndex = audioCodecIndex, - onSelectedIndexChange = { onAudioCodecChange(AUDIO_CODEC_OPTIONS[it].first) }, - enabled = !sessionStarted && audioEnabled, + onSelectedIndexChange = { audioCodec = AUDIO_CODEC_OPTIONS[it].first }, + enabled = !sessionStarted && audio, ) - if (audioEnabled && (audioCodec == "opus" || audioCodec == "aac")) { + if (audio && (audioCodec == "opus" || audioCodec == "aac")) { SuperSlide( title = "音频码率", summary = "--audio-bit-rate", value = audioBitRatePresetIndex.toFloat(), onValueChange = { value -> val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex) - onAudioBitRateChange(ScrcpyPresets.AudioBitRate[idx]) + audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1024 }, valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(), steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), enabled = !sessionStarted, unit = "Kbps", - displayText = audioBitRateKbps.toString(), - inputInitialValue = audioBitRateKbps.toString(), + displayText = audioBitRate.toString(), + inputInitialValue = audioBitRate.toString(), inputFilter = { it.filter(Char::isDigit) }, inputValueRange = 1f..Float.MAX_VALUE, onInputConfirm = { raw -> - raw.toIntOrNull()?.takeIf { it > 0 }?.let { onAudioBitRateChange(it) } + raw.toIntOrNull()?.takeIf { it > 0 }?.let { audioBitRate = it } }, ) } + + var videoCodec by scrcpyOptions.videoCodec.asMutableState() + val videoCodecItems = remember { VIDEO_CODEC_OPTIONS.map { it.second } } + val videoCodecIndex = VIDEO_CODEC_OPTIONS.indexOfFirst { + it.first == videoCodec + }.coerceAtLeast(0) + var videoBitRate by scrcpyOptions.videoBitRate.asMutableState() + val videoBitRateMbps = videoBitRate / 1_000_000f SuperDropdown( title = "视频编码", summary = "--video-codec", items = videoCodecItems, selectedIndex = videoCodecIndex, - onSelectedIndexChange = { onVideoCodecChange(VIDEO_CODEC_OPTIONS[it].first) }, + onSelectedIndexChange = { videoCodec = VIDEO_CODEC_OPTIONS[it].first }, enabled = !sessionStarted, ) SuperSlide( title = "视频码率", summary = "--video-bit-rate", - value = bitRateMbps, - onValueChange = { - onBitRateSliderChange(it) - onBitRateInputChange(formatBitRate(it)) + value = videoBitRateMbps, + onValueChange = { mbps -> + videoBitRate = (mbps * 1_000_000).toInt() }, valueRange = 0.1f..40f, steps = 399, enabled = !sessionStarted, unit = "Mbps", displayFormatter = { formatBitRate(it) }, - inputInitialValue = formatBitRate(bitRateMbps), + inputInitialValue = formatBitRate(videoBitRateMbps), inputFilter = { text -> var dotUsed = false text.filter { ch -> @@ -467,18 +485,19 @@ internal fun ConfigPanel( onInputConfirm = { raw -> raw.toFloatOrNull()?.let { parsed -> if (parsed >= 0.1f) { - onBitRateSliderChange(parsed) - onBitRateInputChange(formatBitRate(parsed)) + videoBitRate = (parsed * 1_000_000).toInt() } } }, ) + SuperArrow( title = "高级参数", summary = "更多 scrcpy 启动参数", onClick = onOpenAdvanced, enabled = !sessionStarted, ) + TextButton( text = if (sessionStarted) "停止" else "启动", onClick = { @@ -1353,7 +1372,6 @@ internal fun DeviceEditorScreen( if (h.isNotBlank()) { onSave( DeviceShortcut( - id = "$h:$p", name = name.trim(), host = h, port = p,