From 6c1564f4619b28fba1969fe4d2f5989bf14ce913 Mon Sep 17 00:00:00 2001 From: Miuzarte <982809597@qq.com> Date: Fri, 10 Apr 2026 00:12:35 +0800 Subject: [PATCH] refactor: improve Snackbar functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增强 Codec 枚举,增加 type 和 lossless 属性以改进编解码器管理 - 引入 SnackbarController - 改进 Settings.kt 中的设置处理,优化状态管理 - 增强 DeviceWidgets,支持编辑设备详情并改进 UI - 重构 ReorderableList 和 VirtualButtons,优化布局和间距 - 重构 Scrpcy 编码器获取逻辑 --- .../scrcpyforandroid/NativeCoreFacade.kt | 51 +- .../constants/ScrcpyPresets.kt | 2 +- .../scrcpyforandroid/constants/ThemeModes.kt | 16 + .../scrcpyforandroid/constants/UiSpacing.kt | 8 +- .../scrcpyforandroid/models/DeviceModels.kt | 16 +- .../nativecore/ScrcpyAudioPlayer.kt | 13 +- .../pages/AdvancedConfigPage.kt | 1068 --------------- .../{DevicePage.kt => DeviceTabScreen.kt} | 435 +++---- .../pages/FullscreenControlScreen.kt | 5 +- .../scrcpyforandroid/pages/MainScreen.kt | 164 ++- .../pages/ReorderDevicesScreen.kt | 8 +- .../pages/ScrcpyAllOptionsScreen.kt | 1153 +++++++++++++++++ .../scrcpyforandroid/pages/SettingsPage.kt | 433 ------- .../scrcpyforandroid/pages/SettingsScreen.kt | 468 +++++++ .../pages/VirtualButtonOrderScreen.kt | 6 +- .../scrcpyforandroid/scaffolds/SuperSlider.kt | 3 +- .../scrcpyforandroid/scrcpy/ClientOptions.kt | 35 +- .../scrcpyforandroid/scrcpy/Scrcpy.kt | 602 +++++---- .../scrcpyforandroid/scrcpy/ServerParams.kt | 2 +- .../scrcpyforandroid/scrcpy/Shared.kt | 42 +- .../services/QuickDeviceStore.kt | 64 - .../services/SnackbarController.kt | 31 + .../scrcpyforandroid/storage/AppSettings.kt | 11 +- .../scrcpyforandroid/storage/Settings.kt | 3 +- .../scrcpyforandroid/widgets/DeviceWidgets.kt | 572 ++++---- .../widgets/ReorderableList.kt | 48 +- .../widgets/VirtualButtons.kt | 2 +- 27 files changed, 2715 insertions(+), 2546 deletions(-) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ThemeModes.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt rename app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/{DevicePage.kt => DeviceTabScreen.kt} (77%) create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt delete mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt create mode 100644 app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/SnackbarController.kt diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt index 14e5f21..d5e7b1e 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/NativeCoreFacade.kt @@ -8,6 +8,7 @@ import android.view.Surface import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.ArrayDeque @@ -133,8 +134,8 @@ class NativeCoreFacade private constructor() { videoFpsListeners.remove(listener) } - suspend fun scrcpyBackOrScreenOn(action: Int = 0) { - session?.pressBackOrScreenOn(action) + suspend fun scrcpyBackOrTurnScreenOn(action: Int = 0) { + session?.pressBackOrTurnScreenOn(action) } /** @@ -200,6 +201,7 @@ class NativeCoreFacade private constructor() { @Volatile private var instance: NativeCoreFacade? = null + // TODO ??? fun get(context: Context): NativeCoreFacade { return instance ?: synchronized(this) { instance ?: NativeCoreFacade().also { instance = it } @@ -252,16 +254,19 @@ class NativeCoreFacade private constructor() { decoder = null Log.i( TAG, - "createOrReplaceDecoder(): codec=${session.codecName} size=${session.width}x${session.height} persistent=true" + "createOrReplaceDecoder(): " + + "codec=${session.codec?.string ?: "null"}, " + + "size=${session.width}x${session.height}, " + + "persistent=true" ) val newDecoder = AnnexBDecoder( width = session.width, height = session.height, outputSurface = surface, - mimeType = when (session.codecName.lowercase()) { - "h264" -> "video/avc" - "h265" -> "video/hevc" - "av1" -> "video/av01" + mimeType = when (session.codec) { + Codec.H264 -> "video/avc" + Codec.H265 -> "video/hevc" + Codec.AV1 -> "video/av01" else -> "video/avc" }, onOutputSizeChanged = { width, height -> @@ -334,38 +339,6 @@ class NativeCoreFacade private constructor() { } } - /** - * Attach a single consumer to the session manager to deliver incoming video packets - * to all active decoders. - * - * - Called when a session is active and at least one decoder exists. Packets are - * cached into [bootstrapPackets] to allow late-attaching decoders to catch up. - * - Kept only for documentation parity with the old multi-decoder design. - */ - @Deprecated("TODO: Determine if this is really unnecessary") - private suspend fun ensureVideoConsumerAttached(sessionMgr: Scrcpy.Session) { - sessionMgr.attachVideoConsumer { packet -> - cacheBootstrapPacket(packet) - packetCount += 1 - if (packetCount == 1L || packetCount % 120L == 0L) { - Log.i( - TAG, - "videoFeed(): packets=$packetCount key=${packet.isKeyFrame} cfg=${packet.isConfig} decoder=${decoder != null}" - ) - } - val currentDecoder = decoder ?: return@attachVideoConsumer - if (activeSurfaceId == null) return@attachVideoConsumer - runCatching { - currentDecoder.feedAnnexB( - packet.data, - packet.ptsUs, - packet.isKeyFrame, - packet.isConfig - ) - } - } - } - private fun releaseAllDecoders() { runCatching { decoder?.release() } decoder = null 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 5e01a6a..4c4bf47 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 @@ -58,6 +58,6 @@ fun Preset.indexOfOrNearest(raw: String): Int { object ScrcpyPresets { val MaxSize = Preset(listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840)) // px val MaxFPS = Preset(listOf(0, 24, 30, 45, 60, 90, 120)) // fps - val AudioBitRate = Preset(listOf(32, 64, 96, 128, 160, 192, 256, 320, 384, 512)) // Kbps + val AudioBitRate = Preset(listOf(0, 32, 64, 96, 128, 160, 192, 256, 320, 384, 512)) // Kbps val CameraFps = Preset(listOf(0, 10, 15, 24, 30, 60, 120, 240, 480, 960)) // fps } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ThemeModes.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ThemeModes.kt new file mode 100644 index 0000000..5312e96 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/ThemeModes.kt @@ -0,0 +1,16 @@ +package io.github.miuzarte.scrcpyforandroid.constants + +import top.yukonga.miuix.kmp.theme.ColorSchemeMode + +object ThemeModes { + data class Option( + val label: String, + val mode: ColorSchemeMode, + ) + + val baseOptions = listOf( + Option("跟随系统", ColorSchemeMode.System), + Option("浅色", ColorSchemeMode.Light), + Option("深色", ColorSchemeMode.Dark), + ) +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt index c7ee1de..6535117 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/constants/UiSpacing.kt @@ -16,8 +16,10 @@ object UiSpacing { val SectionTitleTop = 12.dp val SectionTitleBottom = 6.dp val FieldLabelBottom = 4.dp - val CardContent = 12.dp + val Content = 12.dp + val ContentVertical = 12.dp + val ContentHorizontal = 12.dp val CardTitle = 16.dp - val BottomContent = 64.dp - val BottomSheetBottom = 16.dp + val PageBottom = 64.dp + val SheetBottom = 16.dp } 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 d857f54..6e1f9ee 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 @@ -38,7 +38,6 @@ class DeviceShortcuts(val devices: List) : List host: String? = null, port: Int? = null, name: String? = null, - online: Boolean? = null, newPort: Int? = null, updateNameOnlyWhenEmpty: Boolean = false, ): DeviceShortcuts { @@ -48,6 +47,7 @@ class DeviceShortcuts(val devices: List) : List if (idx < 0) return this val old = devices[idx] + val updateById = id != null // 确定最终的属性值 val finalName = when { @@ -55,23 +55,24 @@ class DeviceShortcuts(val devices: List) : List updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name else -> name } - val finalPort = newPort ?: old.port - val finalOnline = online ?: old.online + val finalHost = if (updateById) host ?: old.host else old.host + val finalPort = if (updateById) port ?: old.port else newPort ?: old.port // 若无任何变化,返回原实例 - if (finalName == old.name && finalPort == old.port && finalOnline == old.online) + if (finalName == old.name && finalHost == old.host && finalPort == old.port) return this val newList = devices.toMutableList().apply { this[idx] = DeviceShortcut( name = finalName, - host = old.host, + host = finalHost, port = finalPort, - online = finalOnline ) } return DeviceShortcuts( - if (newPort != null && newPort != old.port) + if ((updateById && (finalHost != old.host || finalPort != old.port)) + || (newPort != null && newPort != old.port) + ) newList.distinctBy { it.id } else newList ) @@ -118,7 +119,6 @@ data class DeviceShortcut( val name: String = "", val host: String, val port: Int = Defaults.ADB_PORT, - val online: Boolean = false, ) { val id: String get() = "$host:$port" diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt index e5d07fa..ea60306 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/nativecore/ScrcpyAudioPlayer.kt @@ -9,6 +9,7 @@ import android.media.AudioTrack import android.media.MediaCodec import android.media.MediaFormat import android.util.Log +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec import java.nio.ByteBuffer import java.nio.ByteOrder @@ -42,9 +43,9 @@ class ScrcpyAudioPlayer(private val codecId: Int) { }" ) when (codecId) { - AUDIO_CODEC_OPUS -> prepareOpus(data) - AUDIO_CODEC_AAC -> prepareAac(data) - AUDIO_CODEC_FLAC -> prepareFlac(data) + Codec.OPUS.id -> prepareOpus(data) + Codec.AAC.id -> prepareAac(data) + Codec.FLAC.id -> prepareFlac(data) // RAW has no config packet } return @@ -55,7 +56,7 @@ class ScrcpyAudioPlayer(private val codecId: Int) { Log.i(TAG, "feedPacket(): packets=$packetCount prepared=$prepared size=${data.size}") } - if (codecId == AUDIO_CODEC_RAW) { + if (codecId == Codec.RAW.id) { ensureRawAudioTrack()?.write(data, 0, data.size, AudioTrack.WRITE_NON_BLOCKING) return } @@ -212,10 +213,6 @@ class ScrcpyAudioPlayer(private val codecId: Int) { companion object { private const val TAG = "ScrcpyAudioPlayer" - const val AUDIO_CODEC_OPUS = 0x6f707573 - const val AUDIO_CODEC_AAC = 0x00616163 - const val AUDIO_CODEC_FLAC = 0x666c6163 - const val AUDIO_CODEC_RAW = 0x00726177 private const val SAMPLE_RATE = 48000 private const val CHANNELS = 2 private const val CODEC_TIMEOUT_US = 10_000L 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 deleted file mode 100644 index 4fd720f..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/AdvancedConfigPage.kt +++ /dev/null @@ -1,1068 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.pages - -import android.annotation.SuppressLint -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets -import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing -import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop -import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay -import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn -import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider -import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField -import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy -import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared -import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec -import io.github.miuzarte.scrcpyforandroid.storage.Settings -import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.Icon -import top.yukonga.miuix.kmp.basic.IconButton -import top.yukonga.miuix.kmp.basic.Scaffold -import top.yukonga.miuix.kmp.basic.ScrollBehavior -import top.yukonga.miuix.kmp.basic.SnackbarHost -import top.yukonga.miuix.kmp.basic.SnackbarHostState -import top.yukonga.miuix.kmp.basic.SpinnerEntry -import top.yukonga.miuix.kmp.basic.Text -import top.yukonga.miuix.kmp.basic.TextField -import top.yukonga.miuix.kmp.basic.TopAppBar -import top.yukonga.miuix.kmp.extra.SuperArrow -import top.yukonga.miuix.kmp.extra.SuperDropdown -import top.yukonga.miuix.kmp.extra.SuperSpinner -import top.yukonga.miuix.kmp.extra.SuperSwitch -import kotlin.math.roundToInt - -@Composable -internal fun AdvancedConfigScreen( - onBack: () -> Unit, - scrollBehavior: ScrollBehavior, - snack: SnackbarHostState, - scrcpy: Scrcpy, -) { - Scaffold( - topBar = { - TopAppBar( - title = "高级参数", - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "返回" - ) - } - }, - scrollBehavior = scrollBehavior, - ) - }, - snackbarHost = { SnackbarHost(snack) }, - ) { contentPadding -> - AdvancedConfigPage( - contentPadding = contentPadding, - scrollBehavior = scrollBehavior, - snack = snack, - scrcpy = scrcpy, - ) - } -} - -@Composable -internal fun AdvancedConfigPage( - contentPadding: PaddingValues, - scrollBehavior: ScrollBehavior, - snack: SnackbarHostState, - scrcpy: Scrcpy, -) { - val focusManager = LocalFocusManager.current - val scope = rememberCoroutineScope() - - var refreshBusy by rememberSaveable { mutableStateOf(false) } - - val soBundleShared by scrcpyOptions.bundleState.collectAsState() - val soBundleSharedLatest by rememberUpdatedState(soBundleShared) - var soBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) } - val soBundleLatest by rememberUpdatedState(soBundle) - LaunchedEffect(soBundleShared) { - if (soBundle != soBundleShared) { - soBundle = soBundleShared - } - } - LaunchedEffect(soBundle) { - delay(Settings.BUNDLE_SAVE_DELAY) - if (soBundle != soBundleSharedLatest) { - scrcpyOptions.saveBundle(soBundle) - } - } - DisposableEffect(Unit) { - onDispose { - scope.launch { - scrcpyOptions.saveBundle(soBundleLatest) - } - } - } - - val audioCodecItems = rememberSaveable { Codec.AUDIO.map { it.displayName } } - val audioCodecIndex = rememberSaveable(soBundle) { - Codec.AUDIO - .indexOfFirst { it.string == soBundle.audioCodec } - .coerceAtLeast(0) - } - - val videoCodecItems = rememberSaveable { Codec.VIDEO.map { it.displayName } } - val videoCodecIndex = rememberSaveable(soBundle) { - Codec.VIDEO - .indexOfFirst { it.string == soBundle.videoCodec } - .coerceAtLeast(0) - } - - val videoSourceItems = rememberSaveable { Shared.VideoSource.entries.map { it.string } } - val videoSourceIndex = rememberSaveable(soBundle) { - Shared.VideoSource.entries - .indexOfFirst { it.string == soBundle.videoSource } - .coerceAtLeast(0) - } - - var displayIdInput by rememberSaveable(soBundle) { - mutableStateOf( - if (soBundle.displayId == -1) "" - else soBundle.displayId.toString() - ) - } - - var cameraIdInput by rememberSaveable(soBundle) { - mutableStateOf(soBundle.cameraId) - } - - val cameraFacingItems = rememberSaveable { - listOf("默认") + Shared.CameraFacing.entries - .drop(1) - .map { it.string } - } - val cameraFacingIndex = rememberSaveable(soBundle) { - if (soBundle.cameraFacing.isEmpty()) { - 0 - } else { - val idx = Shared.CameraFacing.entries - .indexOfFirst { it.string == soBundle.cameraFacing } - if (idx > 0) idx else 0 - } - } - - var cameraSizeCustomInput by rememberSaveable(soBundle) { - mutableStateOf(soBundle.cameraSizeCustom) - } - - val cameraSizeDropdownItems = rememberSaveable(scrcpy.cameraSizes) { - listOf("自动", "自定义") + scrcpy.cameraSizes - } - var cameraSizeDropdownIndex by rememberSaveable(soBundle, scrcpy.cameraSizes) { - mutableIntStateOf( - when { - soBundle.cameraSizeUseCustom -> 1 // "自定义" - soBundle.cameraSize.isEmpty() -> 0 // "自动" - soBundle.cameraSize in scrcpy.cameraSizes -> - scrcpy.cameraSizes.indexOf(soBundle.cameraSize) + 2 - - else -> 0 // 默认自动 - } - ) - } - - var cameraArInput by rememberSaveable(soBundle) { - mutableStateOf(soBundle.cameraAr) - } - - val cameraFpsPresetIndex = rememberSaveable(soBundle) { - ScrcpyPresets.CameraFps.indexOfOrNearest(soBundle.cameraFps) - } - - val audioSourceItems = rememberSaveable { - Shared.AudioSource.entries.map { it.string } - } - val audioSourceIndex = rememberSaveable(soBundle) { - Shared.AudioSource.entries - .indexOfFirst { it.string == soBundle.audioSource } - .coerceAtLeast(0) - } - - val maxSizePresetIndex = rememberSaveable(soBundle) { - ScrcpyPresets.MaxSize.indexOfOrNearest(soBundle.maxSize) - } - - val maxFpsPresetIndex = rememberSaveable(soBundle) { - ScrcpyPresets.MaxFPS.indexOfOrNearest(soBundle.maxFps.toIntOrNull() ?: 0) - } - - var videoCodecOptionsInput by rememberSaveable(soBundle) { - mutableStateOf(soBundle.videoCodecOptions) - } - - var audioCodecOptionsInput by rememberSaveable(soBundle) { - mutableStateOf(soBundle.audioCodecOptions) - } - - val videoEncoderDropdownItems = rememberSaveable(scrcpy.videoEncoders) { - listOf("") + scrcpy.videoEncoders - } - val videoEncoderIndex = rememberSaveable(soBundle, scrcpy.videoEncoders) { - (scrcpy.videoEncoders.indexOf(soBundle.videoEncoder) + 1).coerceAtLeast(0) - } - - val audioEncoderDropdownItems = rememberSaveable(scrcpy.audioEncoders) { - listOf("") + scrcpy.audioEncoders - } - val audioEncoderIndex = rememberSaveable(soBundle, scrcpy.audioEncoders) { - (scrcpy.audioEncoders.indexOf(soBundle.audioEncoder) + 1).coerceAtLeast(0) - } - - val videoEncoderEntries = videoEncoderDropdownItems.map { encoderName -> - if (encoderName == "") { - SpinnerEntry(title = "自动") - } else { - SpinnerEntry( - title = encoderName, - summary = scrcpy.videoEncoderTypes[encoderName], - ) - } - } - - val audioEncoderEntries = audioEncoderDropdownItems.map { encoderName -> - if (encoderName == "") { - SpinnerEntry(title = "自动") - } else { - SpinnerEntry( - title = encoderName, - summary = scrcpy.audioEncoderTypes[encoderName], - ) - } - } - - // [x][/] - val (ndWidth, ndHeight, ndDpi) = remember(soBundle) { - NewDisplay.parseFrom(soBundle.newDisplay) - } - var newDisplayWidthInput by rememberSaveable(soBundle) { - mutableStateOf(ndWidth?.toString() ?: "") - } - var newDisplayHeightInput by rememberSaveable(soBundle) { - mutableStateOf(ndHeight?.toString() ?: "") - } - var newDisplayDpiInput by rememberSaveable(soBundle) { - mutableStateOf(ndDpi?.toString() ?: "") - } - - // width:height:x:y - val (cWidth, cHeight, cX, cY) = remember(soBundle) { - Crop.parseFrom(soBundle.crop) - } - var cropWidthInput by rememberSaveable(soBundle) { - mutableStateOf(cWidth?.toString() ?: "") - } - var cropHeightInput by rememberSaveable(soBundle) { - mutableStateOf(cHeight?.toString() ?: "") - } - var cropXInput by rememberSaveable(soBundle) { - mutableStateOf(cX?.toString() ?: "") - } - var cropYInput by rememberSaveable(soBundle) { - mutableStateOf(cY?.toString() ?: "") - } - - var serverParamsPreview by rememberSaveable { mutableStateOf("") } - // 监听选项变化, 自动更新预览 - LaunchedEffect(soBundle) { - val clientOptions = scrcpyOptions.toClientOptions(soBundle) - - try { - clientOptions.validate() - } catch (e: IllegalArgumentException) { - snack.showSnackbar("Invalid options: ${e.message}") - return@LaunchedEffect - } - - serverParamsPreview = clientOptions - .toServerParams(0u) - .toList(simplify = true) - // improve readability using hard line breaks - .joinToString("\n") - } - - LazyColumn( - contentPadding = contentPadding, - scrollBehavior = scrollBehavior, - ) { - item { - Card { - TextField( - value = serverParamsPreview, - onValueChange = {}, - readOnly = true, - modifier = Modifier.fillMaxWidth(), - ) - } - } - - item { - Card { - SuperSwitch( - title = "启动后关闭屏幕", - summary = "--turn-screen-off", - checked = soBundle.turnScreenOff, - onCheckedChange = { - soBundle = soBundle.copy(turnScreenOff = it) - if (it) scope.launch { - // github.com/Genymobile/scrcpy/issues/3376 - // github.com/Genymobile/scrcpy/issues/4587 - // github.com/Genymobile/scrcpy/issues/5676 - snack.showSnackbar("注意:大部分设备在关闭屏幕后刷新率会降低/减半") - } - }, - ) - SuperSwitch( - title = "禁用控制", - summary = "--no-control", - checked = !soBundle.control, - onCheckedChange = { soBundle = soBundle.copy(control = !it) }, - // 拦不住同时点, 弃用 - // enabled = audio || video, - ) - SuperSwitch( - title = "禁用视频", - summary = "--no-video", - checked = !soBundle.video, - onCheckedChange = { soBundle = soBundle.copy(video = !it) }, - // enabled = audio || control, - ) - SuperSwitch( - title = "禁用音频", - summary = "--no-audio", - checked = !soBundle.audio, - onCheckedChange = { soBundle = soBundle.copy(audio = !it) }, - // enabled = control || video, - ) - } - } - - item { - Card { - SuperDropdown( - title = "音频编码", - summary = "--audio-codec", - items = audioCodecItems, - selectedIndex = audioCodecIndex, - onSelectedIndexChange = { - soBundle = soBundle.copy(audioCodec = Codec.AUDIO[it].string) - }, - ) - SuperSlider( - title = "音频码率", - summary = "--audio-bit-rate", - value = if (soBundle.audioBitRate <= 0) 0f - else (ScrcpyPresets.AudioBitRate - .indexOfOrNearest(soBundle.audioBitRate / 1000) + 1 - ).toFloat(), - onValueChange = { value -> - val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.size) - soBundle = soBundle.copy( - audioBitRate = - if (idx == 0) 0 - else ScrcpyPresets.AudioBitRate[idx - 1] * 1000 - ) - }, - valueRange = 0f..ScrcpyPresets.AudioBitRate.size.toFloat(), - steps = (ScrcpyPresets.AudioBitRate.size - 1).coerceAtLeast(0), - unit = "Kbps", - zeroStateText = "默认", - displayText = (soBundle.audioBitRate / 1_000).toString(), - inputInitialValue = - if (soBundle.audioBitRate <= 0) "" - else (soBundle.audioBitRate / 1_000).toString(), - inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), - onInputConfirm = { raw -> - raw.toIntOrNull() - ?.takeIf { it >= 0 } - ?.let { soBundle = soBundle.copy(audioBitRate = it * 1000) } - }, - ) - - SuperDropdown( - title = "视频编码", - summary = "--video-codec", - items = videoCodecItems, - selectedIndex = videoCodecIndex, - onSelectedIndexChange = { - soBundle = soBundle.copy(videoCodec = Codec.VIDEO[it].string) - }, - ) - @SuppressLint("DefaultLocale") - SuperSlider( - title = "视频码率", - summary = "--video-bit-rate", - value = soBundle.videoBitRate / 1_000_000f, - onValueChange = { mbps -> - soBundle = soBundle.copy( - videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10) - ) - }, - valueRange = 0f..40f, - steps = 400 - 1, - unit = "Mbps", - zeroStateText = "默认", - displayFormatter = { String.format("%.1f", it) }, - inputInitialValue = - if (soBundle.videoBitRate <= 0) "" - else String.format("%.1f", soBundle.videoBitRate / 1_000_000f), - inputFilter = { text -> - var dotUsed = false - text.filter { ch -> - when { - ch.isDigit() -> true - ch == '.' && !dotUsed -> { - dotUsed = true - true - } - - else -> false - } - } - }, - inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), - onInputConfirm = { raw -> - raw.toFloatOrNull()?.let { parsed -> - if (parsed >= 0f) { - soBundle = soBundle.copy( - videoBitRate = (parsed * 1_000_000f).roundToInt() - ) - } - } - }, - ) - } - } - - item { - Card { - SuperDropdown( - title = "视频来源", - summary = "--video-source", - items = videoSourceItems, - selectedIndex = videoSourceIndex, - onSelectedIndexChange = { - soBundle = soBundle.copy( - videoSource = Shared.VideoSource.entries[it].string - ) - }, - ) - AnimatedVisibility(soBundle.videoSource == "display") { - SuperTextField( - value = displayIdInput, - onValueChange = { displayIdInput = it }, - onFocusLost = { - soBundle = soBundle.copy(displayId = displayIdInput.toIntOrNull() ?: -1) - }, - label = "--display-id", - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } - AnimatedVisibility(soBundle.videoSource == "camera") { - SuperTextField( - value = cameraIdInput, - onValueChange = { cameraIdInput = it }, - onFocusLost = { - soBundle = soBundle.copy(cameraId = cameraIdInput) - }, - label = "--camera-id", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } - AnimatedVisibility(soBundle.videoSource == "camera") { - SuperArrow( - title = "重新获取 Camera Sizes", - summary = "--list-camera-sizes", - onClick = { - if (refreshBusy) return@SuperArrow - scope.launch { - refreshBusy = true - try { - scrcpy.refreshCameraSizes() - snack.showSnackbar("Camera Sizes 已刷新") - } catch (e: Exception) { - snack.showSnackbar("刷新失败: ${e.message}") - } finally { - refreshBusy = false - } - } - }, - ) - } - AnimatedVisibility(soBundle.videoSource == "camera") { - SuperDropdown( - title = "摄像头朝向", - summary = "--camera-facing", - items = cameraFacingItems, - selectedIndex = cameraFacingIndex, - onSelectedIndexChange = { - soBundle = soBundle.copy( - cameraFacing = - if (it == 0) "" - else Shared.CameraFacing.entries[it].string - ) - }, - ) - } - AnimatedVisibility(soBundle.videoSource == "camera") { - SuperDropdown( - title = "摄像头分辨率", - summary = "--camera-size", - items = cameraSizeDropdownItems, - selectedIndex = cameraSizeDropdownIndex, - onSelectedIndexChange = { - cameraSizeDropdownIndex = it - when (it) { - 0 -> { - // "自动" - soBundle = soBundle.copy( - cameraSize = "", - cameraSizeUseCustom = false, - ) - cameraSizeCustomInput = "" - } - - 1 -> { - // "自定义" - 进入自定义输入模式 - soBundle = soBundle.copy( - cameraSizeUseCustom = true, - ) - cameraSizeCustomInput = soBundle.cameraSize - } - - else -> { - // 选择列表中的实际分辨率 - soBundle = soBundle.copy( - cameraSize = cameraSizeDropdownItems[it], - cameraSizeUseCustom = false, - ) - cameraSizeCustomInput = "" - } - } - }, - ) - } - // 只在选择"自定义"时显示输入框 - AnimatedVisibility(soBundle.videoSource == "camera" && soBundle.cameraSizeUseCustom) { - SuperTextField( - value = cameraSizeCustomInput, - onValueChange = { cameraSizeCustomInput = it }, - onFocusLost = { - if (cameraSizeCustomInput in scrcpy.cameraSizes) { - // 输入的值存在于列表中, 取消自定义输入 - cameraSizeDropdownIndex = - scrcpy.cameraSizes.indexOf(cameraSizeCustomInput) + 2 - soBundle = soBundle.copy(cameraSizeUseCustom = false) - } else { - soBundle = soBundle.copy(cameraSizeCustom = cameraSizeCustomInput) - } - }, - label = "--camera-size", - useLabelAsPlaceholder = true, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } - AnimatedVisibility(soBundle.videoSource == "camera") { - SuperTextField( - value = cameraArInput, - onValueChange = { cameraArInput = it }, - onFocusLost = { soBundle = soBundle.copy(cameraAr = cameraArInput) }, - label = "--camera-ar", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } - AnimatedVisibility(soBundle.videoSource == "camera") { - SuperSlider( - title = "摄像头帧率", - summary = "--camera-fps", - value = cameraFpsPresetIndex.toFloat(), - onValueChange = { value -> - val idx = value.roundToInt() - .coerceIn(0, ScrcpyPresets.CameraFps.lastIndex) - soBundle = soBundle.copy(cameraFps = ScrcpyPresets.CameraFps[idx]) - }, - valueRange = 0f..ScrcpyPresets.CameraFps.lastIndex.toFloat(), - steps = (ScrcpyPresets.CameraFps.size - 2).coerceAtLeast(0), - unit = "fps", - zeroStateText = "默认", - showUnitWhenZeroState = false, - showKeyPoints = true, - keyPoints = ScrcpyPresets.CameraFps.indices.map { it.toFloat() }, - displayText = soBundle.cameraFps.toString(), - inputSummary = "0 或留空表示默认", - inputInitialValue = soBundle.cameraFps.toString(), - inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), - onInputConfirm = { - soBundle = soBundle.copy( - cameraFps = it.toIntOrNull() ?: run { 0 } - ) - }, - ) - } - AnimatedVisibility(soBundle.videoSource == "camera") { - SuperSwitch( - title = "高帧率模式", - summary = "--camera-high-speed", - checked = soBundle.cameraHighSpeed, - onCheckedChange = { soBundle = soBundle.copy(cameraHighSpeed = it) }, - ) - } - } - } - - item { - Card { - SuperDropdown( - title = "音频来源", - summary = "--audio-source", - items = audioSourceItems, - selectedIndex = audioSourceIndex, - onSelectedIndexChange = { - soBundle = soBundle.copy( - audioSource = Shared.AudioSource.entries[it].string - ) - }, - ) - SuperSwitch( - title = "音频双路输出", - summary = "--audio-dup", - checked = soBundle.audioDup, - onCheckedChange = { soBundle = soBundle.copy(audioDup = it) }, - ) - SuperSwitch( - title = "仅转发不播放", - summary = "--no-audio-playback", - checked = !soBundle.audioPlayback, - onCheckedChange = { soBundle = soBundle.copy(audioPlayback = !it) }, - ) - SuperSwitch( - title = "音频失败时终止", - summary = "--require-audio", - checked = soBundle.requireAudio, - onCheckedChange = { soBundle = soBundle.copy(requireAudio = it) }, - ) - } - } - - item { - Card { - SuperSlider( - title = "最大分辨率", - summary = "--max-size", - value = maxSizePresetIndex.toFloat(), - onValueChange = { - val idx = it.roundToInt() - .coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) - soBundle = soBundle.copy(maxSize = ScrcpyPresets.MaxSize[idx]) - }, - valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(), - steps = (ScrcpyPresets.MaxSize.size - 2).coerceAtLeast(0), - unit = "px", - zeroStateText = "关闭", - showUnitWhenZeroState = false, - showKeyPoints = true, - keyPoints = ScrcpyPresets.MaxSize.indices.map { it.toFloat() }, - displayText = soBundle.maxSize.toString(), - inputTitle = "最大分辨率 (px)", - inputSummary = "0 或留空表示关闭", - inputInitialValue = soBundle.maxSize.takeIf { it != 0 }?.toString() ?: "", - inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), - onInputConfirm = { - soBundle = soBundle.copy( - maxSize = it.toIntOrNull() ?: run { 0 } - ) - }, - ) - SuperSlider( - title = "最大帧率", - summary = "--max-fps", - value = maxFpsPresetIndex.toFloat(), - onValueChange = { value -> - val idx = value.roundToInt() - .coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex) - soBundle = soBundle.copy( - maxFps = - if (idx == 0) "" - else ScrcpyPresets.MaxFPS[idx].toString() - ) - }, - valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(), - steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0), - unit = "fps", - zeroStateText = "关闭", - showUnitWhenZeroState = false, - showKeyPoints = true, - keyPoints = ScrcpyPresets.MaxFPS.indices.map { it.toFloat() }, - displayText = soBundle.maxFps, - inputTitle = "最大帧率 (FPS)", - inputSummary = "0 或留空表示关闭", - inputInitialValue = soBundle.maxFps, - inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), - onInputConfirm = { soBundle = soBundle.copy(maxFps = it) }, - ) - } - } - - item { - Card { - SuperArrow( - title = "重新获取编码器列表", - summary = "--list-encoders", - onClick = { - if (refreshBusy) return@SuperArrow - scope.launch { - refreshBusy = true - try { - scrcpy.refreshEncoders() - snack.showSnackbar("编码器列表已刷新") - } catch (e: Exception) { - snack.showSnackbar("刷新失败: ${e.message}") - } finally { - refreshBusy = false - } - } - }, - ) - SuperSpinner( - title = "视频编码器", - summary = "--video-encoder", - items = videoEncoderEntries, - selectedIndex = videoEncoderIndex, - onSelectedIndexChange = { - soBundle = soBundle.copy(videoEncoder = videoEncoderEntries[it].title ?: "") - }, - ) - SuperTextField( - value = videoCodecOptionsInput, - onValueChange = { videoCodecOptionsInput = it }, - onFocusLost = { - soBundle = soBundle.copy(videoCodecOptions = videoCodecOptionsInput) - }, - label = "--video-codec-options", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - SuperSpinner( - title = "音频编码器", - summary = "--audio-encoder", - items = audioEncoderEntries, - selectedIndex = audioEncoderIndex, - onSelectedIndexChange = { - soBundle = soBundle.copy(audioEncoder = audioEncoderEntries[it].title ?: "") - }, - ) - SuperTextField( - value = audioCodecOptionsInput, - onValueChange = { audioCodecOptionsInput = it }, - onFocusLost = { - soBundle = soBundle.copy(audioCodecOptions = audioCodecOptionsInput) - }, - label = "--audio-codec-options", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } - } - - item { - Card { - Text( - text = "--new-display", - modifier = Modifier - .padding(horizontal = UiSpacing.CardTitle) - .padding( - top = UiSpacing.CardContent, - bottom = UiSpacing.FieldLabelBottom, - ), - fontWeight = FontWeight.Medium, - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), - ) { - SuperTextField( - label = "width", - value = newDisplayWidthInput, - onValueChange = { newDisplayWidthInput = it }, - onFocusLost = { - soBundle = soBundle.copy( - newDisplay = NewDisplay - .parseFrom( - newDisplayWidthInput, - newDisplayHeightInput, - newDisplayDpiInput - ) - .toString() - ) - }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) }, - ), - modifier = Modifier.weight(1f), - ) - SuperTextField( - label = "height", - value = newDisplayHeightInput, - onValueChange = { newDisplayHeightInput = it }, - onFocusLost = { - soBundle = soBundle.copy( - newDisplay = NewDisplay - .parseFrom( - newDisplayWidthInput, - newDisplayHeightInput, - newDisplayDpiInput - ) - .toString() - ) - }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) }, - ), - modifier = Modifier.weight(1f), - ) - SuperTextField( - label = "dpi", - value = newDisplayDpiInput, - onValueChange = { newDisplayDpiInput = it }, - onFocusLost = { - soBundle = soBundle.copy( - newDisplay = NewDisplay - .parseFrom( - newDisplayWidthInput, - newDisplayHeightInput, - newDisplayDpiInput - ) - .toString() - ) - }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions( - onDone = { focusManager.clearFocus() }, - ), - modifier = Modifier.weight(1f), - ) - } - } - } - - item { - Card { - Text( - text = "--crop", - modifier = Modifier - .padding(horizontal = UiSpacing.CardTitle) - .padding( - top = UiSpacing.CardContent, - bottom = UiSpacing.FieldLabelBottom, - ), - fontWeight = FontWeight.Medium, - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), - ) { - SuperTextField( - label = "width", - value = cropWidthInput, - onValueChange = { cropWidthInput = it }, - onFocusLost = { - soBundle = soBundle.copy( - crop = Crop - .parseFrom( - cropWidthInput, - cropHeightInput, - cropXInput, - cropYInput - ) - .toString() - ) - }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) }, - ), - modifier = Modifier.weight(1f), - ) - SuperTextField( - label = "height", - value = cropHeightInput, - onValueChange = { cropHeightInput = it }, - onFocusLost = { - soBundle = soBundle.copy( - crop = Crop - .parseFrom( - cropWidthInput, - cropHeightInput, - cropXInput, - cropYInput - ) - .toString() - ) - }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) }, - ), - modifier = Modifier.weight(1f), - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), - ) { - SuperTextField( - label = "x", - value = cropXInput, - onValueChange = { cropXInput = it }, - onFocusLost = { - soBundle = soBundle.copy( - crop = Crop - .parseFrom( - cropWidthInput, - cropHeightInput, - cropXInput, - cropYInput - ) - .toString() - ) - }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Next, - ), - keyboardActions = KeyboardActions( - onNext = { focusManager.moveFocus(FocusDirection.Next) }, - ), - modifier = Modifier.weight(1f), - ) - SuperTextField( - label = "y", - value = cropYInput, - onValueChange = { cropYInput = it }, - onFocusLost = { - soBundle = soBundle.copy( - crop = Crop - .parseFrom( - cropWidthInput, - cropHeightInput, - cropXInput, - cropYInput - ) - .toString() - ) - }, - singleLine = true, - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions( - onDone = { focusManager.clearFocus() }, - ), - modifier = Modifier.weight(1f), - ) - } - } - } - } - - // TODO: 放进 [AppPageLazyColumn] 里 - item { Spacer(Modifier.height(UiSpacing.BottomContent)) } - } -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt similarity index 77% rename from app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt rename to app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt index cf5c7ed..1aea49f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DevicePage.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/DeviceTabScreen.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.runtime.Composable @@ -34,16 +33,17 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec import io.github.miuzarte.scrcpyforandroid.services.EventLogger import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent +import io.github.miuzarte.scrcpyforandroid.services.SnackbarController import io.github.miuzarte.scrcpyforandroid.services.fetchConnectedDeviceInfo import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel -import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen -import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile +import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTileList import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard @@ -52,7 +52,9 @@ import io.github.miuzarte.scrcpyforandroid.widgets.StatusCard import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -67,7 +69,6 @@ import top.yukonga.miuix.kmp.basic.ListPopupDefaults import top.yukonga.miuix.kmp.basic.PopupPositionProvider import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.ScrollBehavior -import top.yukonga.miuix.kmp.basic.SnackbarHostState import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.basic.TopAppBar @@ -88,7 +89,7 @@ fun DeviceTabScreen( nativeCore: NativeCoreFacade, adbService: NativeAdbService, scrcpy: Scrcpy, - snack: SnackbarHostState, + snackbar: SnackbarController, scrollBehavior: ScrollBehavior, onOpenVirtualButtonOrder: () -> Unit, onSessionStartedChange: (Boolean) -> Unit, @@ -139,7 +140,7 @@ fun DeviceTabScreen( nativeCore = nativeCore, adbService = adbService, scrcpy = scrcpy, - snack = snack, + snackbar = snackbar, scrollBehavior = scrollBehavior, onSessionStartedChange = onSessionStartedChange, onOpenAdvancedPage = onOpenAdvancedPage, @@ -154,13 +155,14 @@ fun DeviceTabPage( nativeCore: NativeCoreFacade, adbService: NativeAdbService, scrcpy: Scrcpy, - snack: SnackbarHostState, + snackbar: SnackbarController, scrollBehavior: ScrollBehavior, onSessionStartedChange: (Boolean) -> Unit, onOpenAdvancedPage: () -> Unit, onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit, ) { val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) } val haptics = rememberAppHaptics() val asBundleShared by appSettings.bundleState.collectAsState() @@ -180,7 +182,7 @@ fun DeviceTabPage( } DisposableEffect(Unit) { onDispose { - scope.launch { + taskScope.launch { appSettings.saveBundle(asBundleLatest) } } @@ -203,7 +205,7 @@ fun DeviceTabPage( } DisposableEffect(Unit) { onDispose { - scope.launch { + taskScope.launch { quickDevices.saveBundle(qdBundleLatest) } } @@ -225,7 +227,7 @@ fun DeviceTabPage( var sessionInfoWidth by rememberSaveable { mutableIntStateOf(0) } var sessionInfoHeight by rememberSaveable { mutableIntStateOf(0) } var sessionInfoDeviceName by rememberSaveable { mutableStateOf("") } - var sessionInfoCodec by rememberSaveable { mutableStateOf("") } + var sessionInfoCodec by rememberSaveable { mutableStateOf(null) } var sessionInfoControlEnabled by rememberSaveable { mutableStateOf(false) } var sessionInfo by remember { mutableStateOf(null) @@ -243,7 +245,7 @@ fun DeviceTabPage( height = sessionInfoHeight, deviceName = sessionInfoDeviceName, codecId = 0, - codecName = sessionInfoCodec, + codec = sessionInfoCodec, audioCodecId = 0, controlEnabled = sessionInfoControlEnabled, host = currentTargetHost, @@ -254,7 +256,7 @@ fun DeviceTabPage( } } var previewControlsVisible by rememberSaveable { mutableStateOf(false) } - var editingDevice by rememberSaveable { mutableStateOf(null) } + var editingDeviceId by rememberSaveable { mutableStateOf(null) } var activeDeviceActionId by rememberSaveable { mutableStateOf(null) } var adbConnecting by rememberSaveable { mutableStateOf(false) } @@ -288,6 +290,9 @@ fun DeviceTabPage( qdBundle = qdBundle.copy(quickDevicesList = serialized) } } + val editingDevice = remember(savedShortcuts, editingDeviceId) { + editingDeviceId?.let(savedShortcuts::get) + } /** * Disconnect the current ADB connection and stop any running scrcpy session. @@ -330,14 +335,12 @@ fun DeviceTabPage( clearQuickOnlineForTarget?.let { target -> if (target.host.isNotBlank()) savedShortcuts = savedShortcuts.update( - host = target.host, port = target.port, online = false + host = target.host, port = target.port ) } logMessage?.let { logEvent(it) } if (!showSnackMessage.isNullOrBlank()) { - scope.launch { - snack.showSnackbar(showSnackMessage) - } + snackbar.show(showSnackMessage) } } @@ -447,7 +450,7 @@ fun DeviceTabPage( fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) { // For non-adb actions (start/stop/pair/list refresh...). if (busy) return - scope.launch(kotlinx.coroutines.Dispatchers.IO) { + taskScope.launch { busy = true try { block() @@ -456,9 +459,7 @@ fun DeviceTabPage( } catch (e: IllegalArgumentException) { val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName logEvent("$label 参数错误: $detail", Log.WARN, e) - scope.launch { - snack.showSnackbar("$label 参数错误: $detail") - } + snackbar.show("$label 参数错误: $detail") } catch (e: Exception) { val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName logEvent("$label 失败: $detail", Log.ERROR, e) @@ -489,7 +490,7 @@ fun DeviceTabPage( ) { // For manual adb operations from user actions. if (adbConnecting) return - scope.launch { + taskScope.launch { onStarted?.invoke() adbConnecting = true try { @@ -499,9 +500,7 @@ fun DeviceTabPage( } catch (e: IllegalArgumentException) { val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName logEvent("$label 参数错误: $detail", Log.WARN, e) - scope.launch { - snack.showSnackbar("$label 参数错误: $detail") - } + snackbar.show("$label 参数错误: $detail") } catch (e: Exception) { val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName logEvent("$label 失败: $detail", Log.ERROR, e) @@ -521,57 +520,6 @@ fun DeviceTabPage( } } - suspend fun refreshEncoderLists() { - if (!adbConnected) return - runCatching { - scrcpy.refreshEncoders() - }.onSuccess { - // Validate current selections - if (soBundleShared.videoEncoder.isNotBlank() && soBundleShared.videoEncoder !in scrcpy.videoEncoders) { - scrcpyOptions.updateBundle { it.copy(videoEncoder = "") } - } - if (soBundleShared.audioEncoder.isNotBlank() && soBundleShared.audioEncoder !in scrcpy.audioEncoders) { - scrcpyOptions.updateBundle { it.copy(audioEncoder = "") } - } - logEvent("编码器列表已刷新: video=${scrcpy.videoEncoders.size} audio=${scrcpy.audioEncoders.size}") - if (scrcpy.videoEncoders.isEmpty() && scrcpy.audioEncoders.isEmpty()) { - logEvent( - "提示: 编码器为空,请检查 server 路径/版本与设备系统日志", - Log.WARN - ) - } - }.onFailure { e -> - logEvent( - "读取编码器列表失败: ${e.message ?: e.javaClass.simpleName}", - Log.ERROR, - e - ) - } - } - - suspend fun refreshCameraSizeLists() { - if (!adbConnected) return - runCatching { - scrcpy.refreshCameraSizes() - }.onSuccess { - // Validate current selection - if ( - soBundleShared.cameraSize.isNotBlank() - && soBundleShared.cameraSize != "custom" - && soBundleShared.cameraSize !in scrcpy.cameraSizes - ) { - scrcpyOptions.updateBundle { it.copy(cameraSize = "") } - } - logEvent("camera sizes 已刷新: count=${scrcpy.cameraSizes.size}") - }.onFailure { e -> - logEvent( - "读取 camera sizes 失败: ${e.message ?: e.javaClass.simpleName}", - Log.ERROR, - e - ) - } - } - suspend fun handleAdbConnected(host: String, port: Int) { currentTargetHost = host currentTargetPort = port @@ -586,8 +534,7 @@ fun DeviceTabPage( connectedDeviceLabel = info.model applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease) savedShortcuts = savedShortcuts.update( - host = host, - port = port, + host = host, port = port, name = fullLabel, updateNameOnlyWhenEmpty = true ) @@ -603,26 +550,7 @@ fun DeviceTabPage( "android=${info.androidRelease.ifBlank { "unknown" }}, " + "sdk=${info.sdkInt}" ) - scope.launch { - snack.showSnackbar("ADB 已连接") - } - refreshEncoderLists() - refreshCameraSizeLists() - } - - LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort, savedShortcuts.size) { - val activeId = if (adbConnected && currentTargetHost.isNotBlank()) { - "$currentTargetHost:$currentTargetPort" - } else { - null - } - for (index in savedShortcuts.indices) { - val item = savedShortcuts[index] - val shouldOnline = activeId != null && item.id == activeId - if (item.online != shouldOnline) { - savedShortcuts = savedShortcuts.update(id = item.id, online = shouldOnline) - } - } + snackbar.show("ADB 已连接") } LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort) { @@ -643,16 +571,12 @@ fun DeviceTabPage( adbConnected = true statusLine = "$host:$port" logEvent("ADB 自动重连成功: $host:$port") - scope.launch { - snack.showSnackbar("ADB 自动重连成功") - } + snackbar.show("ADB 自动重连成功") } catch (e: Exception) { disconnectAdbConnection() statusLine = "ADB 连接断开" logEvent("ADB 自动重连失败: $e", Log.ERROR) - scope.launch { - snack.showSnackbar("ADB 自动重连失败") - } + snackbar.show("ADB 自动重连失败") break } } @@ -690,9 +614,7 @@ fun DeviceTabPage( runAutoAdbConnect(target.host, target.port) adbConnected = true savedShortcuts = savedShortcuts.update( - host = target.host, - port = target.port, - online = true + host = target.host, port = target.port, ) handleAdbConnected(target.host, target.port) logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}") @@ -732,10 +654,8 @@ fun DeviceTabPage( }?.port if (portToReplace != null) { savedShortcuts = savedShortcuts.update( - host = discoveredHost, - port = portToReplace, + host = discoveredHost, port = portToReplace, newPort = discoveredPort, - online = false ) logEvent( "mDNS 发现新端口,已更新快速设备: $discoveredHost:$portToReplace -> $discoveredHost:$discoveredPort" @@ -751,9 +671,7 @@ fun DeviceTabPage( runAutoAdbConnect(discoveredHost, discoveredPort) adbConnected = true savedShortcuts = savedShortcuts.update( - host = discoveredHost, - port = discoveredPort, - online = true + host = discoveredHost, port = discoveredPort, ) handleAdbConnected(discoveredHost, discoveredPort) logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort") @@ -779,13 +697,13 @@ fun DeviceTabPage( sessionInfoWidth = sessionInfo?.width ?: 0 sessionInfoHeight = sessionInfo?.height ?: 0 sessionInfoDeviceName = sessionInfo?.deviceName.orEmpty() - sessionInfoCodec = sessionInfo?.codecName.orEmpty() + sessionInfoCodec = sessionInfo?.codec sessionInfoControlEnabled = sessionInfo?.controlEnabled == true } else { sessionInfoWidth = 0 sessionInfoHeight = 0 sessionInfoDeviceName = "" - sessionInfoCodec = "" + sessionInfoCodec = null sessionInfoControlEnabled = false } onSessionStartedChange(sessionInfo != null) @@ -799,28 +717,6 @@ fun DeviceTabPage( } } - if (editingDevice != null) { - DeviceEditorScreen( - contentPadding = contentPadding, - device = editingDevice!!, - onSave = { updated -> - savedShortcuts = savedShortcuts.update( - id = editingDevice!!.id, - host = updated.host, - port = updated.port, - online = updated.online, - ) - editingDevice = null - }, - onDelete = { - savedShortcuts = savedShortcuts.remove(id = editingDevice!!.id) - editingDevice = null - }, - onBack = { editingDevice = null }, - ) - return - } - val devicePreviewCardHeightDp = asBundle.devicePreviewCardHeightDp val previewVirtualButtonShowText = asBundle.previewVirtualButtonShowText @@ -843,27 +739,38 @@ fun DeviceTabPage( ) } - itemsIndexed(savedShortcuts, key = { _, device -> device.id }) { _, device -> - val host = device.host - val port = device.port - val isConnectedTarget = adbConnected - && currentTarget?.host == host - && currentTarget.port == port - - DeviceTile( - device = device, - actionText = if (!isConnectedTarget) "连接" else "断开", + item { + DeviceTileList( + devices = savedShortcuts, + isConnected = { device -> + adbConnected + && currentTarget?.host == device.host + && currentTarget.port == device.port + }, actionEnabled = !busy && !adbConnecting, - actionInProgress = adbConnecting && activeDeviceActionId == device.id, - onLongPress = { editingDevice = device }, - onContentClick = { - scope.launch { - snack.showSnackbar("长按可编辑设备") + actionInProgress = { device -> + adbConnecting && activeDeviceActionId == device.id + }, + editingDeviceId = editingDeviceId, + onClick = {}, + onLongClick = { device -> + val connected = adbConnected + && currentTarget?.host == device.host + && currentTarget.port == device.port + if (connected) { + snackbar.show("无法修改已连接的设备") + } else { + editingDeviceId = device.id } }, - onAction = { + onAction = { device -> haptics.contextClick() - if (!isConnectedTarget) { + val host = device.host + val port = device.port + val connected = adbConnected + && currentTarget?.host == host + && currentTarget.port == port + if (!connected) { runAdbConnect( "连接 ADB", onStarted = { activeDeviceActionId = device.id }, @@ -873,17 +780,15 @@ fun DeviceTabPage( try { connectWithTimeout(host, port) adbConnected = true - isQuickConnected = false // 标记为快速设备连接 + isQuickConnected = false savedShortcuts = savedShortcuts.update( - host = host, port = port, online = true + host = host, port = port, ) handleAdbConnected(host, port) } catch (e: Exception) { statusLine = "ADB 连接失败" logEvent("ADB 连接失败: $e", Log.ERROR) - scope.launch { - snack.showSnackbar("ADB 连接失败") - } + snackbar.show("ADB 连接失败") } } } else { @@ -902,105 +807,118 @@ fun DeviceTabPage( } } }, + onEditorSave = { device, updated -> + savedShortcuts = savedShortcuts.update( + id = device.id, + name = updated.name, + host = updated.host, + port = updated.port, + ) + editingDeviceId = null + }, + onEditorDelete = { device -> + savedShortcuts = savedShortcuts.remove(id = device.id) + editingDeviceId = null + }, + onEditorCancel = { editingDeviceId = null }, ) } - if (!adbConnected) item { + if (!adbConnected) { // "快速连接" - QuickConnectCard( - input = qdBundle.quickConnectInput, - onValueChange = { - qdBundle = qdBundle.copy(quickConnectInput = it) - }, - enabled = !adbConnecting, - onAddDevice = { - val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput) - ?: return@QuickConnectCard - savedShortcuts = savedShortcuts.upsert( - DeviceShortcut(host = target.host, port = target.port) - ) - Log.d( - "SavedShortcuts", - "size: ${savedShortcuts.size}, list: ${qdBundle.quickDevicesList}" - ) - scope.launch { - snack.showSnackbar("已添加设备: ${target.host}:${target.port}") - } - }, - onConnect = { - val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput) - ?: return@QuickConnectCard - runAdbConnect( - "连接 ADB", - onStarted = { activeDeviceActionId = target.toString() }, - onFinished = { activeDeviceActionId = null }, - ) { - disconnectCurrentTargetBeforeConnecting(target.host, target.port) - try { - connectWithTimeout(target.host, target.port) - adbConnected = true - isQuickConnected = true // 标记为快速连接 - savedShortcuts = savedShortcuts.update( - host = target.host, - port = target.port, - online = true - ) - handleAdbConnected(target.host, target.port) - } catch (e: Exception) { - statusLine = "ADB 连接失败" - logEvent("ADB 连接失败: $e", Log.ERROR) - scope.launch { - snack.showSnackbar("ADB 连接失败") + item { + QuickConnectCard( + input = qdBundle.quickConnectInput, + onValueChange = { + qdBundle = qdBundle.copy(quickConnectInput = it) + }, + enabled = !adbConnecting, + onAddDevice = { + val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput) + ?: return@QuickConnectCard + savedShortcuts = savedShortcuts.upsert( + DeviceShortcut(host = target.host, port = target.port) + ) + Log.d( + "SavedShortcuts", + "size: ${savedShortcuts.size}, list: ${qdBundle.quickDevicesList}" + ) + snackbar.show("已添加设备: ${target.host}:${target.port}") + }, + onConnect = { + val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput) + ?: return@QuickConnectCard + runAdbConnect( + "连接 ADB", + onStarted = { activeDeviceActionId = target.toString() }, + onFinished = { activeDeviceActionId = null }, + ) { + disconnectCurrentTargetBeforeConnecting(target.host, target.port) + try { + connectWithTimeout(target.host, target.port) + adbConnected = true + isQuickConnected = true // 标记为快速连接 + savedShortcuts = savedShortcuts.update( + host = target.host, port = target.port, + ) + handleAdbConnected(target.host, target.port) + } catch (e: Exception) { + statusLine = "ADB 连接失败" + logEvent("ADB 连接失败: $e", Log.ERROR) + snackbar.show("ADB 连接失败") } } - } - }, - ) - SectionSmallTitle("无线配对") - // "使用配对码配对设备" - PairingCard( - busy = busy, - autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, - onDiscoverTarget = { - adbService.discoverPairingService( - includeLanDevices = adbMdnsLanDiscovery, - ) - }, - onPair = { host, port, code -> - runBusy("执行配对") { - val resolvedHost = host.trim() - val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy - val resolvedCode = code.trim() - val ok = adbService.pair( - resolvedHost, - resolvedPort, - resolvedCode, + }, + ) + } + + item { + SectionSmallTitle("无线配对", showLeadingSpacer = false) + // "使用配对码配对设备" + PairingCard( + busy = busy, + autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, + onDiscoverTarget = { + adbService.discoverPairingService( + includeLanDevices = adbMdnsLanDiscovery, ) - logEvent( - if (ok) "配对成功" else "配对失败", - if (ok) Log.INFO else Log.ERROR - ) - scope.launch { - snack.showSnackbar(if (ok) "配对成功" else "配对失败") + }, + onPair = { host, port, code -> + runBusy("执行配对") { + val resolvedHost = host.trim() + val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy + val resolvedCode = code.trim() + val ok = adbService.pair( + resolvedHost, + resolvedPort, + resolvedCode, + ) + logEvent( + if (ok) "配对成功" else "配对失败", + if (ok) Log.INFO else Log.ERROR + ) + snackbar.show(if (ok) "配对成功" else "配对失败") } - } - }, - ) + }, + ) + } } if (adbConnected) { item { + SectionSmallTitle("Scrcpy", showLeadingSpacer = false) ConfigPanel( busy = busy, - snack = snack, + snackbar = snackbar, audioForwardingSupported = audioForwardingSupported, cameraMirroringSupported = cameraMirroringSupported, + adbConnecting = adbConnecting, isQuickConnected = isQuickConnected, onOpenAdvanced = onOpenAdvancedPage, onStartStopHaptic = { haptics.contextClick() }, onStart = { runBusy("启动 scrcpy") { - val options = scrcpyOptions.toClientOptions(soBundleShared) + val options = scrcpyOptions.toClientOptions(soBundleShared).fix() val session = scrcpy.start(options) sessionInfo = session.copy( host = currentTargetHost, @@ -1010,8 +928,8 @@ fun DeviceTabPage( @SuppressLint("DefaultLocale") val videoDetail = if (!options.video) "off" - else if (videoBitRate <= 0) "${session.codecName} ${session.width}x${session.height} @default" - else "${session.codecName} ${session.width}x${session.height} " + + else if (videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default" + else "${session.codec?.string ?: "null"} ${session.width}x${session.height} " + "@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps" val audioDetail = @@ -1025,9 +943,7 @@ fun DeviceTabPage( ", control=${options.control}, turnScreenOff=${options.turnScreenOff}" + ", maxSize=${options.maxSize}, maxFps=${options.maxFps}" ) - scope.launch { - snack.showSnackbar("scrcpy 已启动") - } + snackbar.show("scrcpy 已启动") } }, onStop = { @@ -1036,9 +952,7 @@ fun DeviceTabPage( sessionInfo = null statusLine = "${currentTarget!!.host}:${currentTarget.port}" logEvent("scrcpy 已停止") - scope.launch { - snack.showSnackbar("scrcpy 已停止") - } + snackbar.show("scrcpy 已停止") } }, sessionInfo = sessionInfo, @@ -1094,19 +1008,21 @@ fun DeviceTabPage( } } - if (EventLogger.hasLogs()) item { - Spacer(Modifier.height(UiSpacing.PageItem)) - Card { - TextField( - value = EventLogger.eventLog.joinToString(separator = "\n"), - onValueChange = {}, - readOnly = true, - modifier = Modifier.fillMaxWidth(), - ) + if (EventLogger.hasLogs()) { + item { + SectionSmallTitle("日志", showLeadingSpacer = false) + Card { + TextField( + value = EventLogger.eventLog.joinToString(separator = "\n"), + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth(), + ) + } } } - item { Spacer(Modifier.height(UiSpacing.BottomContent)) } + item { Spacer(Modifier.height(UiSpacing.PageBottom)) } } } @@ -1131,20 +1047,20 @@ private fun DeviceMenuPopup( text = "快速设备排序", optionSize = 3, index = 0, - onClick = onReorderDevices, + onSelectedIndexChange = { onReorderDevices() }, ) DeviceMenuPopupItem( text = "虚拟按钮排序", optionSize = 3, index = 1, - onClick = onOpenVirtualButtonOrder, + onSelectedIndexChange = { onOpenVirtualButtonOrder() }, ) DeviceMenuPopupItem( text = "清空日志", optionSize = 3, index = 2, enabled = canClearLogs, - onClick = onClearLogs, + onSelectedIndexChange = { onClearLogs() }, ) } } @@ -1156,8 +1072,7 @@ private fun DeviceMenuPopupItem( optionSize: Int, index: Int, enabled: Boolean = true, - // TODO: (Int) -> Unit - onClick: () -> Unit, + onSelectedIndexChange: (Int) -> Unit, ) { if (enabled) { DropdownImpl( @@ -1165,7 +1080,7 @@ private fun DeviceMenuPopupItem( optionSize = optionSize, isSelected = false, index = index, - onSelectedIndexChange = { onClick() }, + onSelectedIndexChange = onSelectedIndexChange, ) return } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt index 2c62473..ac35d74 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/FullscreenControlScreen.kt @@ -32,7 +32,9 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.widgets.FullscreenControlScreen import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -53,6 +55,7 @@ fun FullscreenControlScreen( val haptics = rememberAppHaptics() val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val activity = remember(context) { context as? Activity } @@ -73,7 +76,7 @@ fun FullscreenControlScreen( } DisposableEffect(Unit) { onDispose { - scope.launch { + taskScope.launch { appSettings.saveBundle(asBundleLatest) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt index 6125900..caaf808 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/MainScreen.kt @@ -12,9 +12,11 @@ import androidx.activity.compose.PredictiveBackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Devices @@ -42,16 +44,20 @@ import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberDecoratedNavEntries import androidx.navigation3.ui.NavDisplay import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade +import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes import io.github.miuzarte.scrcpyforandroid.constants.UiMotion import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.services.SnackbarController import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -61,6 +67,7 @@ import top.yukonga.miuix.kmp.basic.NavigationBarItem import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.SnackbarHost import top.yukonga.miuix.kmp.basic.SnackbarHostState +import top.yukonga.miuix.kmp.theme.ColorSchemeMode import top.yukonga.miuix.kmp.theme.MiuixTheme import top.yukonga.miuix.kmp.theme.ThemeController @@ -84,6 +91,7 @@ fun MainScreen() { val context = LocalContext.current val appContext = context.applicationContext val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val activity = remember(context) { context as? Activity } val initialOrientation = remember(activity) { @@ -101,11 +109,14 @@ fun MainScreen() { val adbService = remember(appContext) { NativeAdbService(appContext) } - val scrcpy = remember(appContext, adbService) { - Scrcpy(appContext, adbService) - } val snackHostState = remember { SnackbarHostState() } + val snackbarController = remember(scope, snackHostState) { + SnackbarController( + scope = scope, + hostState = snackHostState, + ) + } val saveableStateHolder = rememberSaveableStateHolder() val tabs = remember { MainBottomTabDestination.entries } val pagerState = rememberPagerState( @@ -137,6 +148,75 @@ fun MainScreen() { // 2) switch tab back to Device // 3) double-back to exit and disconnect adb/scrcpy var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) } + + val asBundleShared by appSettings.bundleState.collectAsState() + val asBundleSharedLatest by rememberUpdatedState(asBundleShared) + var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) } + val asBundleLatest by rememberUpdatedState(asBundle) + LaunchedEffect(asBundleShared) { + if (asBundle != asBundleShared) { + asBundle = asBundleShared + } + } + LaunchedEffect(asBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (asBundle != asBundleSharedLatest) { + appSettings.saveBundle(asBundle) + } + } + DisposableEffect(Unit) { + onDispose { + taskScope.launch { + appSettings.saveBundle(asBundleLatest) + } + } + } + + val qdBundleShared by quickDevices.bundleState.collectAsState() + val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared) + var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) } + val qdBundleLatest by rememberUpdatedState(qdBundle) + LaunchedEffect(qdBundleShared) { + if (qdBundle != qdBundleShared) { + qdBundle = qdBundleShared + } + } + LaunchedEffect(qdBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (qdBundle != qdBundleSharedLatest) { + quickDevices.saveBundle(qdBundle) + } + } + DisposableEffect(Unit) { + onDispose { + taskScope.launch { + quickDevices.saveBundle(qdBundleLatest) + } + } + } + + val customServerUri = asBundle.customServerUri + .ifBlank { null } + val customServerVersion = asBundle.customServerVersion + .ifBlank { Scrcpy.DEFAULT_SERVER_VERSION } + val serverRemotePath = asBundle.serverRemotePath + .ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue } + val scrcpy = remember( + appContext, + adbService, + customServerUri, + customServerVersion, + serverRemotePath, + ) { + Scrcpy( + appContext = appContext, + adbService = adbService, + customServerUri = customServerUri, + serverVersion = customServerVersion, + serverRemotePath = serverRemotePath, + ) + } + fun handleBackNavigation() { if (rootBackStack.size > 1) { popRoot() @@ -186,75 +266,12 @@ fun MainScreen() { } } - val asBundleShared by appSettings.bundleState.collectAsState() - val asBundleSharedLatest by rememberUpdatedState(asBundleShared) - var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) } - val asBundleLatest by rememberUpdatedState(asBundle) - LaunchedEffect(asBundleShared) { - if (asBundle != asBundleShared) { - asBundle = asBundleShared - } - } - LaunchedEffect(asBundle) { - delay(Settings.BUNDLE_SAVE_DELAY) - if (asBundle != asBundleSharedLatest) { - appSettings.saveBundle(asBundle) - } - } - DisposableEffect(Unit) { - onDispose { - scope.launch { - appSettings.saveBundle(asBundleLatest) - } - } - } - - val qdBundleShared by quickDevices.bundleState.collectAsState() - val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared) - var qdBundle by rememberSaveable(qdBundleShared) { mutableStateOf(qdBundleShared) } - val qdBundleLatest by rememberUpdatedState(qdBundle) - LaunchedEffect(qdBundleShared) { - if (qdBundle != qdBundleShared) { - qdBundle = qdBundleShared - } - } - LaunchedEffect(qdBundle) { - delay(Settings.BUNDLE_SAVE_DELAY) - if (qdBundle != qdBundleSharedLatest) { - quickDevices.saveBundle(qdBundle) - } - } - DisposableEffect(Unit) { - onDispose { - scope.launch { - quickDevices.saveBundle(qdBundleLatest) - } - } - } - var sessionStarted by remember { mutableStateOf(false) } var showReorderDevices by rememberSaveable { mutableStateOf(false) } var fullscreenOrientation by rememberSaveable { mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) } - var savedShortcuts by remember { - mutableStateOf(DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList)) - } - - LaunchedEffect(qdBundle.quickDevicesList) { - savedShortcuts = DeviceShortcuts.unmarshalFrom(qdBundle.quickDevicesList) - } - LaunchedEffect(savedShortcuts) { - val serialized = savedShortcuts.marshalToString() - if (serialized != qdBundle.quickDevicesList) { - qdBundle = qdBundle.copy(quickDevicesList = serialized) - } - } - - val themeMode = resolveThemeMode(asBundle.themeBaseIndex, asBundle.monet) - val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } - val keepScreenOnWhenStreamingEnabled = asBundle.keepScreenOnWhenStreaming // Keep-screen-on is controlled globally, so fullscreen and preview share the same behavior. DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) { @@ -336,7 +353,7 @@ fun MainScreen() { nativeCore = nativeCore, adbService = adbService, scrcpy = scrcpy, - snack = snackHostState, + snackbar = snackbarController, scrollBehavior = devicesPageScrollBehavior, onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) }, onSessionStartedChange = { sessionStarted = it }, @@ -357,7 +374,7 @@ fun MainScreen() { MainBottomTabDestination.Settings -> SettingsScreen( scrollBehavior = settingsPageScrollBehavior, - snack = snackHostState, + snackbar = snackbarController, onOpenReorderDevices = { showReorderDevices = true }, onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) }, onPickServer = { @@ -382,10 +399,10 @@ fun MainScreen() { } entry(RootScreen.Advanced) { - AdvancedConfigScreen( + ScrcpyAllOptionsScreen( onBack = ::popRoot, scrollBehavior = advancedPageScrollBehavior, - snack = snackHostState, + snackbar = snackbarController, scrcpy = scrcpy, ) } @@ -416,6 +433,13 @@ fun MainScreen() { entryProvider = rootEntryProvider, ) + val themeMode = when (asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex)) { + 1 -> if (!asBundle.monet) ColorSchemeMode.Light else ColorSchemeMode.MonetLight + 2 -> if (!asBundle.monet) ColorSchemeMode.Dark else ColorSchemeMode.MonetDark + else -> if (!asBundle.monet) ColorSchemeMode.System else ColorSchemeMode.MonetSystem + } + val themeController = remember(themeMode) { ThemeController(colorSchemeMode = themeMode) } + MiuixTheme(controller = themeController) { NavDisplay( entries = rootEntries, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt index 2006027..779265f 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ReorderDevicesScreen.kt @@ -19,6 +19,9 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import top.yukonga.miuix.kmp.extra.SuperBottomSheet @@ -29,6 +32,7 @@ fun ReorderDevicesScreen( onDismissRequest: () -> Unit, ) { val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val qdBundleShared by quickDevices.bundleState.collectAsState() val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared) @@ -47,7 +51,7 @@ fun ReorderDevicesScreen( } DisposableEffect(Unit) { onDispose { - scope.launch { + taskScope.launch { quickDevices.saveBundle(qdBundleLatest) } } @@ -85,6 +89,6 @@ fun ReorderDevicesScreen( savedShortcuts = savedShortcuts.move(fromIndex, toIndex) }, ).invoke() - Spacer(Modifier.height(UiSpacing.BottomSheetBottom)) + Spacer(Modifier.height(UiSpacing.SheetBottom)) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt new file mode 100644 index 0000000..8f9a5c7 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/ScrcpyAllOptionsScreen.kt @@ -0,0 +1,1153 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import io.github.miuzarte.scrcpyforandroid.constants.ScrcpyPresets +import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.Crop +import io.github.miuzarte.scrcpyforandroid.models.ScrcpyOptions.NewDisplay +import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn +import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider +import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField +import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec +import io.github.miuzarte.scrcpyforandroid.services.SnackbarController +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.ScrollBehavior +import top.yukonga.miuix.kmp.basic.SnackbarHost +import top.yukonga.miuix.kmp.basic.SpinnerEntry +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextField +import top.yukonga.miuix.kmp.basic.TopAppBar +import top.yukonga.miuix.kmp.extra.SuperArrow +import top.yukonga.miuix.kmp.extra.SuperDropdown +import top.yukonga.miuix.kmp.extra.SuperSpinner +import top.yukonga.miuix.kmp.extra.SuperSwitch +import kotlin.math.roundToInt + +@Composable +internal fun ScrcpyAllOptionsScreen( + onBack: () -> Unit, + scrollBehavior: ScrollBehavior, + snackbar: SnackbarController, + scrcpy: Scrcpy, +) { + Scaffold( + topBar = { + TopAppBar( + title = "所有参数", + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "返回" + ) + } + }, + scrollBehavior = scrollBehavior, + ) + }, + snackbarHost = { SnackbarHost(snackbar.hostState) }, + ) { contentPadding -> + ScrcpyAllOptionsPage( + contentPadding = contentPadding, + scrollBehavior = scrollBehavior, + snackbar = snackbar, + scrcpy = scrcpy, + ) + } +} + +@Composable +internal fun ScrcpyAllOptionsPage( + contentPadding: PaddingValues, + scrollBehavior: ScrollBehavior, + snackbar: SnackbarController, + scrcpy: Scrcpy, +) { + val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) } + + var refreshBusy by rememberSaveable { mutableStateOf(false) } + var listRefreshVersion by rememberSaveable { mutableIntStateOf(0) } + + val soBundleShared by scrcpyOptions.bundleState.collectAsState() + val soBundleSharedLatest by rememberUpdatedState(soBundleShared) + var soBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) } + // 验证配置项合法性用于回滚更改 + var lastValidSoBundle by rememberSaveable(soBundleShared) { mutableStateOf(soBundleShared) } + val soBundleLatest by rememberUpdatedState(soBundle) + LaunchedEffect(soBundleShared) { + if (soBundle != soBundleShared) { + soBundle = soBundleShared + } + lastValidSoBundle = soBundleShared + } + LaunchedEffect(soBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (soBundle != soBundleSharedLatest) { + scrcpyOptions.saveBundle(soBundle) + } + } + DisposableEffect(Unit) { + onDispose { + taskScope.launch { + scrcpyOptions.saveBundle(soBundleLatest) + } + } + } + + val audioCodecItems = rememberSaveable { Codec.AUDIO.map { it.displayName } } + val audioCodecIndex = rememberSaveable(soBundle.audioCodec) { + Codec.AUDIO + .indexOfFirst { it.string == soBundle.audioCodec } + .coerceAtLeast(0) + } + + val videoCodecItems = rememberSaveable { Codec.VIDEO.map { it.displayName } } + val videoCodecIndex = rememberSaveable(soBundle.videoCodec) { + Codec.VIDEO + .indexOfFirst { it.string == soBundle.videoCodec } + .coerceAtLeast(0) + } + + val videoSourceItems = rememberSaveable { Shared.VideoSource.entries.map { it.string } } + val videoSourceIndex = rememberSaveable(soBundle.videoSource) { + Shared.VideoSource.entries + .indexOfFirst { it.string == soBundle.videoSource } + .coerceAtLeast(0) + } + + var displayIdInput by rememberSaveable(soBundle.displayId) { + mutableStateOf( + if (soBundle.displayId == -1) "" + else soBundle.displayId.toString() + ) + } + + var cameraIdInput by rememberSaveable(soBundle.cameraId) { + mutableStateOf(soBundle.cameraId) + } + + val cameraFacingItems = rememberSaveable { + listOf("默认") + Shared.CameraFacing.entries + .drop(1) + .map { it.string } + } + val cameraFacingIndex = rememberSaveable(soBundle.cameraFacing) { + if (soBundle.cameraFacing.isEmpty()) { + 0 + } else { + val idx = Shared.CameraFacing.entries + .indexOfFirst { it.string == soBundle.cameraFacing } + if (idx > 0) idx else 0 + } + } + + var cameraSizeCustomInput by rememberSaveable(soBundle.cameraSizeCustom) { + mutableStateOf(soBundle.cameraSizeCustom) + } + + val cameraSizes = scrcpy.listings.cameraSizes + val cameraSizeDropdownItems = rememberSaveable(cameraSizes, listRefreshVersion) { + listOf("自动", "自定义") + cameraSizes + } + var cameraSizeDropdownIndex by rememberSaveable( + soBundle.cameraSize, + soBundle.cameraSizeUseCustom, + cameraSizes, + listRefreshVersion, + ) { + mutableIntStateOf( + when { + soBundle.cameraSizeUseCustom -> 1 // "自定义" + soBundle.cameraSize.isEmpty() -> 0 // "自动" + soBundle.cameraSize in cameraSizes -> + cameraSizes.indexOf(soBundle.cameraSize) + 2 + + else -> 0 // 默认自动 + } + ) + } + + var cameraArInput by rememberSaveable(soBundle.cameraAr) { + mutableStateOf(soBundle.cameraAr) + } + + val cameraFpsPresetIndex = rememberSaveable(soBundle.cameraFps) { + ScrcpyPresets.CameraFps.indexOfOrNearest(soBundle.cameraFps) + } + + val audioSourceItems = rememberSaveable { + Shared.AudioSource.entries.map { it.string } + } + val audioSourceIndex = rememberSaveable(soBundle.audioSource) { + Shared.AudioSource.entries + .indexOfFirst { it.string == soBundle.audioSource } + .coerceAtLeast(0) + } + + val videoEncoderInfos = scrcpy.listings.videoEncoders + val audioEncoderInfos = scrcpy.listings.audioEncoders + val videoEncoders = remember(videoEncoderInfos) { + videoEncoderInfos.map { it.id } + } + val audioEncoders = remember(audioEncoderInfos) { + audioEncoderInfos.map { it.id } + } + val videoEncoderTypes = remember(videoEncoderInfos) { + videoEncoderInfos.associate { it.id to it.type.s } + } + val audioEncoderTypes = remember(audioEncoderInfos) { + audioEncoderInfos.associate { it.id to it.type.s } + } + + val maxSizePresetIndex = rememberSaveable(soBundle.maxSize) { + ScrcpyPresets.MaxSize.indexOfOrNearest(soBundle.maxSize) + } + + val maxFpsPresetIndex = rememberSaveable(soBundle.maxFps) { + ScrcpyPresets.MaxFPS.indexOfOrNearest(soBundle.maxFps.toIntOrNull() ?: 0) + } + + var videoCodecOptionsInput by rememberSaveable(soBundle.videoCodecOptions) { + mutableStateOf(soBundle.videoCodecOptions) + } + + var audioCodecOptionsInput by rememberSaveable(soBundle.audioCodecOptions) { + mutableStateOf(soBundle.audioCodecOptions) + } + + val videoEncoderDropdownItems = rememberSaveable(videoEncoders, listRefreshVersion) { + listOf("") + videoEncoders + } + val videoEncoderIndex = rememberSaveable(soBundle.videoEncoder, videoEncoders, listRefreshVersion) { + (videoEncoders.indexOf(soBundle.videoEncoder) + 1).coerceAtLeast(0) + } + + val audioEncoderDropdownItems = rememberSaveable(audioEncoders, listRefreshVersion) { + listOf("") + audioEncoders + } + val audioEncoderIndex = rememberSaveable(soBundle.audioEncoder, audioEncoders, listRefreshVersion) { + (audioEncoders.indexOf(soBundle.audioEncoder) + 1).coerceAtLeast(0) + } + + val videoEncoderEntries = videoEncoderDropdownItems.map { encoderName -> + if (encoderName == "") { + SpinnerEntry(title = "自动") + } else { + SpinnerEntry( + title = encoderName, + summary = videoEncoderTypes[encoderName], + ) + } + } + + val audioEncoderEntries = audioEncoderDropdownItems.map { encoderName -> + if (encoderName == "") { + SpinnerEntry(title = "自动") + } else { + SpinnerEntry( + title = encoderName, + summary = audioEncoderTypes[encoderName], + ) + } + } + + // [x][/] + val (ndWidth, ndHeight, ndDpi) = remember(soBundle.newDisplay) { + NewDisplay.parseFrom(soBundle.newDisplay) + } + var newDisplayWidthInput by rememberSaveable(soBundle.newDisplay) { + mutableStateOf(ndWidth?.toString() ?: "") + } + var newDisplayHeightInput by rememberSaveable(soBundle.newDisplay) { + mutableStateOf(ndHeight?.toString() ?: "") + } + var newDisplayDpiInput by rememberSaveable(soBundle.newDisplay) { + mutableStateOf(ndDpi?.toString() ?: "") + } + + // width:height:x:y + val (cWidth, cHeight, cX, cY) = remember(soBundle.crop) { + Crop.parseFrom(soBundle.crop) + } + var cropWidthInput by rememberSaveable(soBundle.crop) { + mutableStateOf(cWidth?.toString() ?: "") + } + var cropHeightInput by rememberSaveable(soBundle.crop) { + mutableStateOf(cHeight?.toString() ?: "") + } + var cropXInput by rememberSaveable(soBundle.crop) { + mutableStateOf(cX?.toString() ?: "") + } + var cropYInput by rememberSaveable(soBundle.crop) { + mutableStateOf(cY?.toString() ?: "") + } + + var serverParamsPreview by rememberSaveable { mutableStateOf("") } + // 监听选项变化, 自动更新预览 + LaunchedEffect(soBundle) { + val clientOptions = scrcpyOptions.toClientOptions(soBundle).fix() + + try { + clientOptions.validate() + } catch (e: IllegalArgumentException) { + if (soBundle != lastValidSoBundle) { + snackbar.show("Invalid options: ${e.message}") + soBundle = lastValidSoBundle + } + return@LaunchedEffect + } + + lastValidSoBundle = soBundle + + serverParamsPreview = clientOptions + .toServerParams(0u) + .toList(simplify = true) + // improve readability using hard line breaks + .joinToString("\n") + } + + LazyColumn( + contentPadding = contentPadding, + scrollBehavior = scrollBehavior, + ) { + item { + Card { + TextField( + value = serverParamsPreview, + onValueChange = {}, + readOnly = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + + item { + Card { + SuperSwitch( + title = "启动后关闭屏幕", + summary = "--turn-screen-off", + checked = soBundle.turnScreenOff, + onCheckedChange = { + soBundle = soBundle.copy( + turnScreenOff = it + ) + if (it) { + // github.com/Genymobile/scrcpy/issues/3376 + // github.com/Genymobile/scrcpy/issues/4587 + // github.com/Genymobile/scrcpy/issues/5676 + snackbar.show("注意:大部分设备在关闭屏幕后刷新率会降低/减半") + } + }, + ) + SuperSwitch( + title = "禁用控制", + summary = "--no-control", + checked = !soBundle.control, + onCheckedChange = { + soBundle = soBundle.copy( + control = !it + ) + }, + // 拦不住同时点, 弃用 + // enabled = audio || video, + ) + SuperSwitch( + title = "禁用视频", + summary = "--no-video", + checked = !soBundle.video, + onCheckedChange = { + soBundle = soBundle.copy( + video = !it + ) + }, + // enabled = audio || control, + ) + SuperSwitch( + title = "禁用音频", + summary = "--no-audio", + checked = !soBundle.audio, + onCheckedChange = { + soBundle = soBundle.copy( + audio = !it + ) + }, + // enabled = control || video, + ) + } + } + + item { + Card { + SuperDropdown( + title = "音频编码", + summary = "--audio-codec", + items = audioCodecItems, + selectedIndex = audioCodecIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy( + audioCodec = Codec.AUDIO[it].string + ) + }, + ) + SuperSlider( + title = "音频码率", + summary = "--audio-bit-rate", + value = ScrcpyPresets.AudioBitRate + .indexOfOrNearest(soBundle.audioBitRate / 1000) + .toFloat(), + onValueChange = { value -> + val idx = value.roundToInt() + .coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex) + soBundle = soBundle.copy( + audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000 + ) + }, + valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(), + steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), + unit = "Kbps", + zeroStateText = "默认", + displayText = (soBundle.audioBitRate / 1_000).toString(), + inputInitialValue = + if (soBundle.audioBitRate <= 0) "" + else (soBundle.audioBitRate / 1_000).toString(), + inputFilter = { it.filter(Char::isDigit) }, + inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), + onInputConfirm = { raw -> + raw.toIntOrNull() + ?.takeIf { it >= 0 } + ?.let { + soBundle = soBundle.copy( + audioBitRate = it * 1000 + ) + } + }, + ) + + SuperDropdown( + title = "视频编码", + summary = "--video-codec", + items = videoCodecItems, + selectedIndex = videoCodecIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy( + videoCodec = Codec.VIDEO[it].string + ) + }, + ) + @SuppressLint("DefaultLocale") + SuperSlider( + title = "视频码率", + summary = "--video-bit-rate", + value = soBundle.videoBitRate / 1_000_000f, + onValueChange = { mbps -> + soBundle = soBundle.copy( + videoBitRate = (mbps * 10).roundToInt() * (1_000_000 / 10) + ) + }, + valueRange = 0f..40f, + steps = 400 - 1, + unit = "Mbps", + zeroStateText = "默认", + displayFormatter = { String.format("%.1f", it) }, + inputInitialValue = + if (soBundle.videoBitRate <= 0) "" + else String.format("%.1f", soBundle.videoBitRate / 1_000_000f), + inputFilter = { text -> + var dotUsed = false + text.filter { ch -> + when { + ch.isDigit() -> true + ch == '.' && !dotUsed -> { + dotUsed = true + true + } + + else -> false + } + } + }, + inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), + onInputConfirm = { raw -> + raw.toFloatOrNull()?.let { parsed -> + if (parsed >= 0f) { + soBundle = soBundle.copy( + videoBitRate = (parsed * 1_000_000f).roundToInt() + ) + } + } + }, + ) + } + } + + item { + Card { + SuperDropdown( + title = "视频来源", + summary = "--video-source", + items = videoSourceItems, + selectedIndex = videoSourceIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy( + videoSource = Shared.VideoSource.entries[it].string + ) + }, + ) + AnimatedVisibility(soBundle.videoSource == "display") { + Column { + SuperTextField( + value = displayIdInput, + onValueChange = { displayIdInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + displayId = displayIdInput.toIntOrNull() ?: -1 + ) + }, + label = "--display-id", + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier + .fillMaxWidth() + .padding(all = UiSpacing.Large), + ) + SuperSlider( + title = "最大分辨率", + summary = "--max-size", + value = maxSizePresetIndex.toFloat(), + onValueChange = { + val idx = it.roundToInt() + .coerceIn(0, ScrcpyPresets.MaxSize.lastIndex) + soBundle = soBundle.copy( + maxSize = ScrcpyPresets.MaxSize[idx] + ) + }, + valueRange = 0f..ScrcpyPresets.MaxSize.lastIndex.toFloat(), + steps = (ScrcpyPresets.MaxSize.size - 2).coerceAtLeast(0), + unit = "px", + zeroStateText = "关闭", + showUnitWhenZeroState = false, + showKeyPoints = true, + keyPoints = ScrcpyPresets.MaxSize.indices.map { it.toFloat() }, + displayText = soBundle.maxSize.toString(), + inputInitialValue = soBundle.maxSize.takeIf { it != 0 }?.toString() + ?: "", + inputFilter = { it.filter(Char::isDigit) }, + inputValueRange = 0f..UInt.MAX_VALUE.toFloat(), + onInputConfirm = { + soBundle = soBundle.copy( + maxSize = it.toIntOrNull() ?: run { 0 } + ) + }, + ) + SuperSlider( + title = "最大帧率", + summary = "--max-fps", + value = maxFpsPresetIndex.toFloat(), + onValueChange = { value -> + val idx = value.roundToInt() + .coerceIn(0, ScrcpyPresets.MaxFPS.lastIndex) + soBundle = soBundle.copy( + maxFps = + if (idx == 0) "" + else ScrcpyPresets.MaxFPS[idx].toString() + ) + }, + valueRange = 0f..ScrcpyPresets.MaxFPS.lastIndex.toFloat(), + steps = (ScrcpyPresets.MaxFPS.size - 2).coerceAtLeast(0), + unit = "fps", + zeroStateText = "关闭", + showUnitWhenZeroState = false, + showKeyPoints = true, + keyPoints = ScrcpyPresets.MaxFPS.indices.map { it.toFloat() }, + displayText = soBundle.maxFps, + inputInitialValue = soBundle.maxFps, + inputFilter = { it.filter(Char::isDigit) }, + inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), + onInputConfirm = { + soBundle = soBundle.copy( + maxFps = it + ) + }, + ) + } + } + AnimatedVisibility(soBundle.videoSource == "camera") { + Column { + SuperTextField( + value = cameraIdInput, + onValueChange = { cameraIdInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + cameraId = cameraIdInput + ) + }, + label = "--camera-id", + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(all = UiSpacing.Large), + ) + SuperDropdown( + title = "摄像头朝向", + summary = "--camera-facing", + items = cameraFacingItems, + selectedIndex = cameraFacingIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy( + cameraFacing = + if (it == 0) "" + else Shared.CameraFacing.entries[it].string + ) + }, + ) + SuperArrow( + title = "获取 Camera Sizes", + summary = "--list-camera-sizes", + onClick = { + if (refreshBusy) return@SuperArrow + scope.launch { + refreshBusy = true + snackbar.show("获取中") + try { + withContext(Dispatchers.IO) { + scrcpy.listings.getCameraSizes(forceRefresh = true) + } + listRefreshVersion += 1 + snackbar.show("获取成功") + } catch (e: Exception) { + snackbar.show("刷新失败: ${e.message}") + } finally { + refreshBusy = false + } + } + }, + ) + SuperDropdown( + title = "摄像头分辨率", + summary = "--camera-size", + items = cameraSizeDropdownItems, + selectedIndex = cameraSizeDropdownIndex, + onSelectedIndexChange = { + cameraSizeDropdownIndex = it + when (it) { + 0 -> { + // "自动" + soBundle = soBundle.copy( + cameraSize = "", + cameraSizeUseCustom = false, + ) + cameraSizeCustomInput = "" + } + + 1 -> { + // "自定义" - 进入自定义输入模式 + soBundle = soBundle.copy( + cameraSizeUseCustom = true + ) + cameraSizeCustomInput = soBundle.cameraSize + } + + else -> { + // 选择列表中的实际分辨率 + soBundle = soBundle.copy( + cameraSize = cameraSizeDropdownItems[it], + cameraSizeUseCustom = false, + ) + cameraSizeCustomInput = "" + } + } + }, + ) + // 只在选择"自定义"时显示输入框 + AnimatedVisibility(soBundle.cameraSizeUseCustom) { + SuperTextField( + value = cameraSizeCustomInput, + onValueChange = { cameraSizeCustomInput = it }, + onFocusLost = { + if (cameraSizeCustomInput in cameraSizes) { + // 输入的值存在于列表中, 取消自定义输入 + cameraSizeDropdownIndex = cameraSizes + .indexOf(cameraSizeCustomInput) + 2 + soBundle = soBundle.copy( + cameraSizeUseCustom = false + ) + } else { + soBundle = soBundle.copy( + cameraSizeCustom = cameraSizeCustomInput + ) + } + }, + label = "--camera-size", + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(all = UiSpacing.Large), + ) + } + SuperTextField( + value = cameraArInput, + onValueChange = { cameraArInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + cameraAr = cameraArInput + ) + }, + label = "--camera-ar", + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(all = UiSpacing.Large), + ) + SuperSlider( + title = "摄像头帧率", + summary = "--camera-fps", + value = cameraFpsPresetIndex.toFloat(), + onValueChange = { value -> + val idx = value.roundToInt() + .coerceIn(0, ScrcpyPresets.CameraFps.lastIndex) + soBundle = soBundle.copy( + cameraFps = ScrcpyPresets.CameraFps[idx] + ) + }, + valueRange = 0f..ScrcpyPresets.CameraFps.lastIndex.toFloat(), + steps = (ScrcpyPresets.CameraFps.size - 2).coerceAtLeast(0), + unit = "fps", + zeroStateText = "默认", + showKeyPoints = true, + keyPoints = ScrcpyPresets.CameraFps.indices.map { it.toFloat() }, + displayText = soBundle.cameraFps.toString(), + inputInitialValue = + if (soBundle.cameraFps <= 0) "" + else soBundle.cameraFps.toString(), + inputFilter = { it.filter(Char::isDigit) }, + inputValueRange = 0f..UShort.MAX_VALUE.toFloat(), + onInputConfirm = { + soBundle = soBundle.copy( + cameraFps = it.toIntOrNull() ?: run { 0 } + ) + }, + ) + SuperSwitch( + title = "高帧率模式", + summary = "--camera-high-speed", + checked = soBundle.cameraHighSpeed, + onCheckedChange = { + soBundle = soBundle.copy( + cameraHighSpeed = it + ) + }, + ) + } + } + + } + } + + item { + Card { + SuperDropdown( + title = "音频来源", + summary = "--audio-source", + items = audioSourceItems, + selectedIndex = audioSourceIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy( + audioSource = Shared.AudioSource.entries[it].string + ) + }, + ) + SuperSwitch( + title = "音频双路输出", + summary = "--audio-dup", + checked = soBundle.audioDup, + onCheckedChange = { + soBundle = soBundle.copy( + audioDup = it + ) + }, + ) + SuperSwitch( + title = "仅转发不播放", + summary = "--no-audio-playback", + checked = !soBundle.audioPlayback, + onCheckedChange = { + soBundle = soBundle.copy( + audioPlayback = !it + ) + }, + ) + SuperSwitch( + title = "音频转发失败时终止", + summary = "--require-audio", + checked = soBundle.requireAudio, + onCheckedChange = { + soBundle = soBundle.copy( + requireAudio = it + ) + }, + ) + } + } + + item { + Card { + SuperArrow( + title = "获取编码器列表", + summary = "--list-encoders", + onClick = { + if (refreshBusy) return@SuperArrow + scope.launch { + refreshBusy = true + snackbar.show("获取中") + try { + withContext(Dispatchers.IO) { + scrcpy.listings.getVideoEncoders(forceRefresh = true) + } + listRefreshVersion += 1 + snackbar.show("获取成功") + } catch (e: Exception) { + snackbar.show("刷新失败: ${e.message}") + } finally { + refreshBusy = false + } + } + }, + ) + // TODO: 在 SuperSpinner / SuperDropdown 支持展开状态回调后, 在展开时触发获取 + SuperSpinner( + title = "视频编码器", + summary = "--video-encoder", + items = videoEncoderEntries, + selectedIndex = videoEncoderIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy( + videoEncoder = videoEncoderEntries[it].title ?: "" + ) + }, + ) + SuperTextField( + value = videoCodecOptionsInput, + onValueChange = { videoCodecOptionsInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + videoCodecOptions = videoCodecOptionsInput + ) + }, + label = "--video-codec-options", + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(all = UiSpacing.Large), + ) + SuperSpinner( + title = "音频编码器", + summary = "--audio-encoder", + items = audioEncoderEntries, + selectedIndex = audioEncoderIndex, + onSelectedIndexChange = { + soBundle = soBundle.copy( + audioEncoder = audioEncoderEntries[it].title ?: "" + ) + }, + ) + SuperTextField( + value = audioCodecOptionsInput, + onValueChange = { audioCodecOptionsInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + audioCodecOptions = audioCodecOptionsInput + ) + }, + label = "--audio-codec-options", + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(all = UiSpacing.Large), + ) + } + } + + if (soBundle.videoSource == "display") item { + Card { + Column( + modifier = Modifier.padding(vertical = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + Column( + modifier = Modifier.padding(horizontal = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + Text( + text = "--new-display", + fontWeight = FontWeight.Medium, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal), + ) { + SuperTextField( + label = "width", + value = newDisplayWidthInput, + onValueChange = { newDisplayWidthInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + newDisplay = NewDisplay + .parseFrom( + newDisplayWidthInput, + newDisplayHeightInput, + newDisplayDpiInput + ) + .toString() + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Next) }, + ), + modifier = Modifier.weight(1f), + ) + SuperTextField( + label = "height", + value = newDisplayHeightInput, + onValueChange = { newDisplayHeightInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + newDisplay = NewDisplay + .parseFrom( + newDisplayWidthInput, + newDisplayHeightInput, + newDisplayDpiInput + ) + .toString() + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Next) }, + ), + modifier = Modifier.weight(1f), + ) + SuperTextField( + label = "dpi", + value = newDisplayDpiInput, + onValueChange = { newDisplayDpiInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + newDisplay = NewDisplay + .parseFrom( + newDisplayWidthInput, + newDisplayHeightInput, + newDisplayDpiInput + ) + .toString() + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { focusManager.clearFocus() }, + ), + modifier = Modifier.weight(1f), + ) + } + } + } + } + } + + if (soBundle.videoSource == "display") item { + Card { + Column( + modifier = Modifier.padding(vertical = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + Column( + modifier = Modifier.padding(horizontal = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + Text( + text = "--crop", + fontWeight = FontWeight.Medium, + ) + Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal), + ) { + SuperTextField( + label = "width", + value = cropWidthInput, + onValueChange = { cropWidthInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + crop = Crop + .parseFrom( + cropWidthInput, + cropHeightInput, + cropXInput, + cropYInput + ) + .toString() + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Next) }, + ), + modifier = Modifier.weight(1f), + ) + SuperTextField( + label = "height", + value = cropHeightInput, + onValueChange = { cropHeightInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + crop = Crop + .parseFrom( + cropWidthInput, + cropHeightInput, + cropXInput, + cropYInput + ) + .toString() + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Next) }, + ), + modifier = Modifier.weight(1f), + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal), + ) { + SuperTextField( + label = "x", + value = cropXInput, + onValueChange = { cropXInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + crop = Crop + .parseFrom( + cropWidthInput, + cropHeightInput, + cropXInput, + cropYInput + ) + .toString() + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Next) }, + ), + modifier = Modifier.weight(1f), + ) + SuperTextField( + label = "y", + value = cropYInput, + onValueChange = { cropYInput = it }, + onFocusLost = { + soBundle = soBundle.copy( + crop = Crop + .parseFrom( + cropWidthInput, + cropHeightInput, + cropXInput, + cropYInput + ) + .toString() + ) + }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { focusManager.clearFocus() }, + ), + modifier = Modifier.weight(1f), + ) + } + } + } + } + } + } + + item { Spacer(Modifier.height(UiSpacing.PageBottom)) } + } +} 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 deleted file mode 100644 index 417d151..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsPage.kt +++ /dev/null @@ -1,433 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.pages - -import android.content.Intent -import android.os.Process -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Clear -import androidx.compose.material.icons.rounded.FileOpen -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.core.net.toUri -import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing -import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn -import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider -import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField -import io.github.miuzarte.scrcpyforandroid.storage.AppSettings -import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration -import io.github.miuzarte.scrcpyforandroid.storage.Settings -import io.github.miuzarte.scrcpyforandroid.storage.Storage -import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import top.yukonga.miuix.kmp.basic.Card -import top.yukonga.miuix.kmp.basic.Icon -import top.yukonga.miuix.kmp.basic.IconButton -import top.yukonga.miuix.kmp.basic.Scaffold -import top.yukonga.miuix.kmp.basic.ScrollBehavior -import top.yukonga.miuix.kmp.basic.SnackbarHostState -import top.yukonga.miuix.kmp.basic.Text -import top.yukonga.miuix.kmp.basic.TextField -import top.yukonga.miuix.kmp.basic.TopAppBar -import top.yukonga.miuix.kmp.extra.SuperArrow -import top.yukonga.miuix.kmp.extra.SuperDropdown -import top.yukonga.miuix.kmp.extra.SuperSwitch -import top.yukonga.miuix.kmp.theme.ColorSchemeMode -import kotlin.math.roundToInt -import kotlin.system.exitProcess - -private data class ThemeModeOption( - val label: String, - val mode: ColorSchemeMode, -) - -private val THEME_BASE_OPTIONS = listOf( - ThemeModeOption("跟随系统", ColorSchemeMode.System), - ThemeModeOption("浅色", ColorSchemeMode.Light), - ThemeModeOption("深色", ColorSchemeMode.Dark), -) - -fun resolveThemeMode(baseIndex: Int, monet: Boolean): ColorSchemeMode { - return when (baseIndex.coerceIn(0, 2)) { - 0 -> if (monet) ColorSchemeMode.MonetSystem else ColorSchemeMode.System - 1 -> if (monet) ColorSchemeMode.MonetLight else ColorSchemeMode.Light - else -> if (monet) ColorSchemeMode.MonetDark else ColorSchemeMode.Dark - } -} - -private fun resolveThemeLabel(baseIndex: Int, monetEnabled: Boolean): String { - val base = THEME_BASE_OPTIONS.getOrNull(baseIndex.coerceIn(0, 2))?.label ?: "跟随系统" - return if (monetEnabled) "Monet($base)" else base -} - -@Composable -fun SettingsScreen( - scrollBehavior: ScrollBehavior, - snack: SnackbarHostState, - onOpenReorderDevices: () -> Unit, - onOpenVirtualButtonOrder: () -> Unit, - onPickServer: () -> Unit, -) { - Scaffold( - topBar = { - TopAppBar( - title = "设置", - scrollBehavior = scrollBehavior, - ) - }, - ) { pagePadding -> - SettingsPage( - contentPadding = pagePadding, - scrollBehavior = scrollBehavior, - snack = snack, - onOpenReorderDevices = onOpenReorderDevices, - onOpenVirtualButtonOrder = onOpenVirtualButtonOrder, - onPickServer = onPickServer, - ) - } -} - -@Composable -fun SettingsPage( - contentPadding: PaddingValues, - scrollBehavior: ScrollBehavior, - snack: SnackbarHostState, - onOpenReorderDevices: () -> Unit, - onOpenVirtualButtonOrder: () -> Unit, - onPickServer: () -> Unit, -) { - val appContext = LocalContext.current.applicationContext - val appSettings = Storage.appSettings - var needMigration by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - needMigration = PreferenceMigration(appContext).needsMigration() - } - - val context = LocalContext.current - - val scope = rememberCoroutineScope() - - val asBundleShared by appSettings.bundleState.collectAsState() - val asBundleSharedLatest by rememberUpdatedState(asBundleShared) - var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) } - val asBundleLatest by rememberUpdatedState(asBundle) - LaunchedEffect(asBundleShared) { - if (asBundle != asBundleShared) { - asBundle = asBundleShared - } - } - LaunchedEffect(asBundle) { - delay(Settings.BUNDLE_SAVE_DELAY) - if (asBundle != asBundleSharedLatest) { - appSettings.saveBundle(asBundle) - } - } - DisposableEffect(Unit) { - onDispose { - scope.launch { - appSettings.saveBundle(asBundleLatest) - } - } - } - - val baseModeItems = THEME_BASE_OPTIONS.map { it.label } - var serverRemotePathInput by rememberSaveable { - mutableStateOf( - if (asBundleShared.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) "" - else asBundleShared.serverRemotePath - ) - } - LaunchedEffect(asBundleShared.serverRemotePath) { - serverRemotePathInput = - if (asBundleShared.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) "" - else asBundleShared.serverRemotePath - } - - var adbKeyNameInput by rememberSaveable { - mutableStateOf( - if (asBundleShared.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) "" - else asBundleShared.adbKeyName - ) - } - LaunchedEffect(asBundleShared.adbKeyName) { - adbKeyNameInput = - if (asBundleShared.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) "" - else asBundleShared.adbKeyName - } - - // 设置 - LazyColumn( - contentPadding = contentPadding, - scrollBehavior = scrollBehavior, - ) { - item { - SectionSmallTitle("主题") - Card { - SuperDropdown( - title = "外观模式", - summary = resolveThemeLabel(asBundle.themeBaseIndex, asBundle.monet), - items = baseModeItems, - selectedIndex = asBundle.themeBaseIndex.coerceIn(0, baseModeItems.lastIndex), - onSelectedIndexChange = { - asBundle = asBundle.copy(themeBaseIndex = it) - }, - ) - SuperSwitch( - title = "Monet", - summary = "开启后使用 Monet 动态配色", - checked = asBundle.monet, - onCheckedChange = { - asBundle = asBundle.copy(monet = it) - }, - ) - } - - SectionSmallTitle("投屏") - Card { - SuperSwitch( - title = "启用调试信息", - summary = "在全屏界面显示触点数量、设备分辨率和实时 FPS", - checked = asBundle.fullscreenDebugInfo, - onCheckedChange = { - asBundle = asBundle.copy(fullscreenDebugInfo = it) - }, - ) - SuperSwitch( - title = "投屏时保持屏幕常亮", - summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开", - checked = asBundle.keepScreenOnWhenStreaming, - onCheckedChange = { - asBundle = asBundle.copy(keepScreenOnWhenStreaming = it) - }, - ) - SuperSlider( - title = "预览卡高度", - summary = "设备页预览卡高度", - value = asBundle.devicePreviewCardHeightDp.toFloat(), - onValueChange = { - asBundle = asBundle.copy( - devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120) - ) - }, - valueRange = 160f..600f, - steps = 600-160-2, - unit = "dp", - displayFormatter = { it.roundToInt().toString() }, - inputInitialValue = asBundle.devicePreviewCardHeightDp.toString(), - inputFilter = { it.filter(Char::isDigit) }, - inputValueRange = 120f..UShort.MAX_VALUE.toFloat(), - onInputConfirm = { input -> - input.toIntOrNull()?.let { - asBundle = asBundle.copy( - devicePreviewCardHeightDp = it.coerceAtLeast(120) - ) - } - }, - ) - SuperArrow( - title = "快速设备排序", - summary = "手动排序设备页的快速设备", - onClick = onOpenReorderDevices, - ) - SuperArrow( - title = "虚拟按钮排序", - summary = "手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外", - onClick = onOpenVirtualButtonOrder, - ) - SuperSwitch( - title = "全屏显示虚拟按钮", - summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮", - checked = asBundle.showFullscreenVirtualButtons, - onCheckedChange = { - asBundle = asBundle.copy(showFullscreenVirtualButtons = it) - }, - ) - } - - SectionSmallTitle("scrcpy-server") - Card { - Spacer(modifier = Modifier.padding(top = UiSpacing.CardContent)) - Text( - text = "自定义 binary", - modifier = Modifier - .padding(horizontal = UiSpacing.CardTitle) - .padding(bottom = UiSpacing.FieldLabelBottom), - fontWeight = FontWeight.Medium, - ) - TextField( - value = asBundle.customServerUri, - onValueChange = {}, - readOnly = true, - label = "scrcpy-server-v3.3.4", - useLabelAsPlaceholder = asBundle.customServerUri.isBlank(), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - trailingIcon = { - Row(modifier = Modifier.padding(end = UiSpacing.SectionTitleLeadingGap)) { - if (asBundle.customServerUri.isNotBlank()) - IconButton(onClick = { - asBundle = asBundle.copy(customServerUri = "") - }) { - Icon(Icons.Rounded.Clear, contentDescription = "清空") - } - IconButton(onClick = onPickServer) { - Icon(Icons.Rounded.FileOpen, contentDescription = "选择文件") - } - } - }, - ) - Text( - text = "Remote Path", - modifier = Modifier - .padding(horizontal = UiSpacing.CardTitle) - .padding(bottom = UiSpacing.FieldLabelBottom), - fontWeight = FontWeight.Medium, - ) - SuperTextField( - value = serverRemotePathInput, - onValueChange = { serverRemotePathInput = it }, - onFocusLost = { - if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue) - serverRemotePathInput = "" - asBundle = asBundle.copy( - serverRemotePath = serverRemotePathInput.ifBlank { - AppSettings.SERVER_REMOTE_PATH.defaultValue - } - ) - }, - label = AppSettings.SERVER_REMOTE_PATH.defaultValue, - useLabelAsPlaceholder = true, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - } - - SectionSmallTitle("ADB") - Card { - Text( - text = "自定义 ADB 密钥名", - modifier = Modifier - .padding(horizontal = UiSpacing.CardTitle) - .padding(top = UiSpacing.CardContent, bottom = UiSpacing.FieldLabelBottom), - fontWeight = FontWeight.Medium, - ) - SuperTextField( - value = adbKeyNameInput, - onValueChange = { adbKeyNameInput = it }, - onFocusLost = { - if (adbKeyNameInput == AppSettings.ADB_KEY_NAME.defaultValue) - adbKeyNameInput = "" - asBundle = asBundle.copy( - adbKeyName = adbKeyNameInput.ifBlank { AppSettings.ADB_KEY_NAME.defaultValue } - ) - }, - label = AppSettings.ADB_KEY_NAME.defaultValue, - useLabelAsPlaceholder = true, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - ) - SuperSwitch( - title = "配对时自动启用发现服务", - summary = "打开配对弹窗后自动搜索可用配对端口", - checked = asBundle.adbPairingAutoDiscoverOnDialogOpen, - onCheckedChange = { - asBundle = asBundle.copy( - adbPairingAutoDiscoverOnDialogOpen = it - ) - }, - ) - SuperSwitch( - title = "自动重连已配对设备", - summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)", - checked = asBundle.adbAutoReconnectPairedDevice, - onCheckedChange = { - asBundle = asBundle.copy( - adbAutoReconnectPairedDevice = it - ) - }, - ) - } - - // 这部分应该不会显示出来, - // 应用启动时就会执行迁移与旧数据的删除 - AnimatedVisibility(needMigration) { - SectionSmallTitle("应用") - } - AnimatedVisibility(needMigration) { - Card { - SuperArrow( - title = "恢复旧版本配置", - summary = "从旧版本的 SharedPreferences 恢复至 DataStore", - onClick = { - scope.launch { - val migration = PreferenceMigration(appContext) - migration.migrate(clearSharedPrefs = false) - snack.showSnackbar("迁移完成,应用将重启") - - delay(1000) - - val intent = context.packageManager.getLaunchIntentForPackage( - context.packageName - ) - intent?.apply { - addFlags( - Intent.FLAG_ACTIVITY_NEW_TASK - or Intent.FLAG_ACTIVITY_CLEAR_TASK - ) - } - context.startActivity(intent) - - Process.killProcess(Process.myPid()) - exitProcess(0) - } - }, - ) - } - } - - SectionSmallTitle("关于") - Card { - SuperArrow( - title = "前往仓库", - summary = "github.com/Miuzarte/ScrcpyForAndroid", - onClick = { - context.startActivity( - Intent( - Intent.ACTION_VIEW, - "https://github.com/Miuzarte/ScrcpyForAndroid".toUri(), - ) - ) - }, - ) - } - } - - item { Spacer(Modifier.height(UiSpacing.BottomContent)) } - } -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt new file mode 100644 index 0000000..e4eb92d --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/SettingsScreen.kt @@ -0,0 +1,468 @@ +package io.github.miuzarte.scrcpyforandroid.pages + +import android.content.Intent +import android.os.Process +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Clear +import androidx.compose.material.icons.rounded.FileOpen +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.core.net.toUri +import io.github.miuzarte.scrcpyforandroid.constants.ThemeModes +import io.github.miuzarte.scrcpyforandroid.constants.UiSpacing +import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn +import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperSlider +import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField +import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy +import io.github.miuzarte.scrcpyforandroid.services.SnackbarController +import io.github.miuzarte.scrcpyforandroid.storage.AppSettings +import io.github.miuzarte.scrcpyforandroid.storage.PreferenceMigration +import io.github.miuzarte.scrcpyforandroid.storage.Settings +import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings +import io.github.miuzarte.scrcpyforandroid.widgets.SectionSmallTitle +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.basic.Card +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.basic.IconButton +import top.yukonga.miuix.kmp.basic.Scaffold +import top.yukonga.miuix.kmp.basic.ScrollBehavior +import top.yukonga.miuix.kmp.basic.Text +import top.yukonga.miuix.kmp.basic.TextField +import top.yukonga.miuix.kmp.basic.TopAppBar +import top.yukonga.miuix.kmp.extra.SuperArrow +import top.yukonga.miuix.kmp.extra.SuperDropdown +import top.yukonga.miuix.kmp.extra.SuperSwitch +import top.yukonga.miuix.kmp.theme.ColorSchemeMode +import kotlin.math.roundToInt +import kotlin.system.exitProcess + +@Composable +fun SettingsScreen( + scrollBehavior: ScrollBehavior, + snackbar: SnackbarController, + onOpenReorderDevices: () -> Unit, + onOpenVirtualButtonOrder: () -> Unit, + onPickServer: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = "设置", + scrollBehavior = scrollBehavior, + ) + }, + ) { pagePadding -> + SettingsPage( + contentPadding = pagePadding, + scrollBehavior = scrollBehavior, + snackbar = snackbar, + onOpenReorderDevices = onOpenReorderDevices, + onOpenVirtualButtonOrder = onOpenVirtualButtonOrder, + onPickServer = onPickServer, + ) + } +} + +@Composable +fun SettingsPage( + contentPadding: PaddingValues, + scrollBehavior: ScrollBehavior, + snackbar: SnackbarController, + onOpenReorderDevices: () -> Unit, + onOpenVirtualButtonOrder: () -> Unit, + onPickServer: () -> Unit, +) { + val context = LocalContext.current + val appContext = context.applicationContext + var needMigration by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + needMigration = PreferenceMigration(appContext).needsMigration() + } + + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + val scope = rememberCoroutineScope() + + val asBundleShared by appSettings.bundleState.collectAsState() + val asBundleSharedLatest by rememberUpdatedState(asBundleShared) + var asBundle by rememberSaveable(asBundleShared) { mutableStateOf(asBundleShared) } + val asBundleLatest by rememberUpdatedState(asBundle) + LaunchedEffect(asBundleShared) { + if (asBundle != asBundleShared) { + asBundle = asBundleShared + } + } + LaunchedEffect(asBundle) { + delay(Settings.BUNDLE_SAVE_DELAY) + if (asBundle != asBundleSharedLatest) { + appSettings.saveBundle(asBundle) + } + } + DisposableEffect(Unit) { + onDispose { + taskScope.launch { + appSettings.saveBundle(asBundleLatest) + } + } + } + + val themeItems = rememberSaveable { ThemeModes.baseOptions.map { it.label } } + + val customServerVersionShowInput = rememberSaveable(asBundle.customServerUri) { + asBundle.customServerUri.isNotBlank() + } + var customServerVersionInput by rememberSaveable(asBundle.customServerVersion) { + mutableStateOf(asBundle.customServerVersion) + } + var serverRemotePathInput by rememberSaveable(asBundle.serverRemotePath) { + mutableStateOf( + if (asBundle.serverRemotePath == AppSettings.SERVER_REMOTE_PATH.defaultValue) "" + else asBundle.serverRemotePath + ) + } + + var adbKeyNameInput by rememberSaveable(asBundle.adbKeyName) { + mutableStateOf( + if (asBundle.adbKeyName == AppSettings.ADB_KEY_NAME.defaultValue) "" + else asBundle.adbKeyName + ) + } + + // 设置 + LazyColumn( + contentPadding = contentPadding, + scrollBehavior = scrollBehavior, + ) { + item { + SectionSmallTitle("主题", showLeadingSpacer = false) + Card { + SuperDropdown( + title = "外观模式", + summary = ThemeModes.baseOptions + .getOrNull(asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex)) + ?.label + ?: "跟随系统", + items = themeItems, + selectedIndex = asBundle.themeBaseIndex.coerceIn(0, ThemeModes.baseOptions.lastIndex), + onSelectedIndexChange = { + asBundle = asBundle.copy(themeBaseIndex = it) + }, + ) + SuperSwitch( + title = "Monet", + summary = "开启后使用 Monet 动态配色", + checked = asBundle.monet, + onCheckedChange = { + asBundle = asBundle.copy(monet = it) + }, + ) + } + } + + item { + SectionSmallTitle("投屏") + Card { + SuperSwitch( + title = "启用调试信息", + summary = "在全屏界面悬浮显示分辨率、帧率和触点信息", + checked = asBundle.fullscreenDebugInfo, + onCheckedChange = { + asBundle = asBundle.copy(fullscreenDebugInfo = it) + }, + ) + SuperSwitch( + title = "投屏时保持屏幕常亮", + summary = "Scrcpy 启动后保持本机屏幕常亮,避免锁屏导致 ADB 断开", + checked = asBundle.keepScreenOnWhenStreaming, + onCheckedChange = { + asBundle = asBundle.copy(keepScreenOnWhenStreaming = it) + }, + ) + SuperSlider( + title = "预览卡高度", + summary = "设备页预览卡高度", + value = asBundle.devicePreviewCardHeightDp.toFloat(), + onValueChange = { + asBundle = asBundle.copy( + devicePreviewCardHeightDp = it.roundToInt().coerceAtLeast(120) + ) + }, + valueRange = 160f..600f, + steps = 600 - 160 - 2, + unit = "dp", + displayFormatter = { it.roundToInt().toString() }, + inputInitialValue = asBundle.devicePreviewCardHeightDp.toString(), + inputFilter = { it.filter(Char::isDigit) }, + inputValueRange = 120f..UShort.MAX_VALUE.toFloat(), + onInputConfirm = { input -> + input.toIntOrNull()?.let { + asBundle = asBundle.copy( + devicePreviewCardHeightDp = it.coerceAtLeast(120) + ) + } + }, + ) + SuperArrow( + title = "快速设备排序", + summary = "手动排序设备页的快速设备", + onClick = onOpenReorderDevices, + ) + SuperArrow( + title = "虚拟按钮排序", + summary = "手动排序预览/全屏时的虚拟按钮,并选择哪些按钮展示在外", + onClick = onOpenVirtualButtonOrder, + ) + SuperSwitch( + title = "全屏显示虚拟按钮", + summary = "在全屏控制页底部显示返回键、主页键等虚拟按钮", + checked = asBundle.showFullscreenVirtualButtons, + onCheckedChange = { + asBundle = asBundle.copy(showFullscreenVirtualButtons = it) + }, + ) + } + } + + item { + SectionSmallTitle("scrcpy-server", showLeadingSpacer = false) + Card { + Column( + modifier = Modifier.padding(vertical = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + Column( + modifier = Modifier.padding(horizontal = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + Text( + text = "自定义 binary", + fontWeight = FontWeight.Medium, + ) + TextField( + value = asBundle.customServerUri, + onValueChange = {}, + readOnly = true, + label = Scrcpy.DEFAULT_SERVER_ASSET_NAME, + useLabelAsPlaceholder = true, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + Row( + modifier = Modifier + .padding(end = UiSpacing.Medium), + ) { + if (asBundle.customServerUri.isNotBlank()) + IconButton( + onClick = { + asBundle = asBundle.copy( + customServerUri = "", + customServerVersion = "", + ) + }, + ) { + Icon( + imageVector = Icons.Rounded.Clear, + contentDescription = "清空", + ) + } + IconButton(onClick = onPickServer) { + Icon( + imageVector = Icons.Rounded.FileOpen, + contentDescription = "选择文件", + ) + } + } + }, + ) + } + AnimatedVisibility(customServerVersionShowInput) { + Column( + modifier = Modifier.padding(horizontal = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + Text( + text = "自定义 binary version", + fontWeight = FontWeight.Medium, + ) + SuperTextField( + value = customServerVersionInput, + onValueChange = { customServerVersionInput = it }, + onFocusLost = { + if (customServerVersionInput == AppSettings.CUSTOM_SERVER_VERSION.defaultValue) + customServerVersionInput = "" + asBundle = asBundle.copy( + customServerVersion = customServerVersionInput + .ifBlank { AppSettings.CUSTOM_SERVER_VERSION.defaultValue } + ) + }, + label = Scrcpy.DEFAULT_SERVER_VERSION, + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + Column( + modifier = Modifier.padding(horizontal = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + Text( + text = "Remote Path", + fontWeight = FontWeight.Medium, + ) + SuperTextField( + value = serverRemotePathInput, + onValueChange = { serverRemotePathInput = it }, + onFocusLost = { + if (serverRemotePathInput == AppSettings.SERVER_REMOTE_PATH.defaultValue) + serverRemotePathInput = "" + asBundle = asBundle.copy( + serverRemotePath = serverRemotePathInput + .ifBlank { AppSettings.SERVER_REMOTE_PATH.defaultValue } + ) + }, + label = Scrcpy.DEFAULT_REMOTE_PATH, + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + } + + item { + SectionSmallTitle("ADB", showLeadingSpacer = false) + Card { + Column( + modifier = Modifier.padding(vertical = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + Column( + modifier = Modifier.padding(horizontal = UiSpacing.Large), + verticalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + Text( + text = "自定义 ADB 密钥名", + fontWeight = FontWeight.Medium, + ) + SuperTextField( + value = adbKeyNameInput, + onValueChange = { adbKeyNameInput = it }, + onFocusLost = { + if (adbKeyNameInput == AppSettings.ADB_KEY_NAME.defaultValue) + adbKeyNameInput = "" + asBundle = asBundle.copy( + adbKeyName = adbKeyNameInput + .ifBlank { AppSettings.ADB_KEY_NAME.defaultValue } + ) + }, + label = AppSettings.ADB_KEY_NAME.defaultValue, + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + } + SuperSwitch( + title = "配对时自动启用发现服务", + summary = "打开配对弹窗后自动搜索可用配对端口", + checked = asBundle.adbPairingAutoDiscoverOnDialogOpen, + onCheckedChange = { + asBundle = asBundle.copy( + adbPairingAutoDiscoverOnDialogOpen = it + ) + }, + ) + SuperSwitch( + title = "自动重连已配对设备", + summary = "自动发现开启无线调试的设备,更新快速设备的随机端口并尝试连接(效果比较随缘)", + checked = asBundle.adbAutoReconnectPairedDevice, + onCheckedChange = { + asBundle = asBundle.copy( + adbAutoReconnectPairedDevice = it + ) + }, + ) + } + } + + if (needMigration) item { + // 这部分应该不会显示出来, + // 应用启动时就会执行迁移与旧数据的删除 + SectionSmallTitle("应用", showLeadingSpacer = false) + Card { + SuperArrow( + title = "恢复旧版本配置", + summary = "从旧版本的 SharedPreferences 恢复至 DataStore", + onClick = { + scope.launch { + val migration = PreferenceMigration(appContext) + migration.migrate(clearSharedPrefs = false) + snackbar.show("迁移完成,应用将重启") + + delay(1000) + + val intent = context.packageManager.getLaunchIntentForPackage( + context.packageName + ) + intent?.apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK + or Intent.FLAG_ACTIVITY_CLEAR_TASK + ) + } + context.startActivity(intent) + + Process.killProcess(Process.myPid()) + exitProcess(0) + } + }, + ) + } + } + + item { + SectionSmallTitle("关于", showLeadingSpacer = false) + Card { + SuperArrow( + title = "前往仓库", + summary = "github.com/Miuzarte/ScrcpyForAndroid", + onClick = { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + "https://github.com/Miuzarte/ScrcpyForAndroid".toUri(), + ) + ) + }, + ) + } + } + + item { Spacer(Modifier.height(UiSpacing.PageBottom)) } + } +} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt index ba3a8ba..7d55607 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/pages/VirtualButtonOrderScreen.kt @@ -22,6 +22,9 @@ import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import top.yukonga.miuix.kmp.basic.Card @@ -67,6 +70,7 @@ internal fun VirtualButtonOrderPage( scrollBehavior: ScrollBehavior, ) { val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val asBundleShared by appSettings.bundleState.collectAsState() val asBundleSharedLatest by rememberUpdatedState(asBundleShared) @@ -85,7 +89,7 @@ internal fun VirtualButtonOrderPage( } DisposableEffect(Unit) { onDispose { - scope.launch { + taskScope.launch { appSettings.saveBundle(asBundleLatest) } } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt index 04f7a7d..fda87f9 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scaffolds/SuperSlider.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -122,7 +123,7 @@ private fun SliderInputDialog( onDismissRequest = onDismissRequest, onDismissFinished = onDismissFinished, content = { - var text by remember(initialValue) { mutableStateOf(initialValue) } + var text by rememberSaveable(initialValue) { mutableStateOf(initialValue) } SuperTextField( modifier = Modifier.padding(bottom = 16.dp), diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt index a0e56d6..c568040 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ClientOptions.kt @@ -11,23 +11,15 @@ import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.OrientationLock import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Tick import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource -// TODO: 修改配置项时, validate() 判断是否合法, 非法则回退修改 - -// TODO: 管理冲突配置项的关系, 在更多参数页中隐藏冲突项 - -// TODO: 禁用配置项验证, 包括不隐藏冲突配置项 - // TODO: 配置切换 -// TODO: 参数预览框可编辑 - data class ClientOptions( // var serial: String = "", // to server // --crop=width:height:x:y var crop: String = "", // to server - // TODO + // TODO: implement // --record var recordFilename: String = "", @@ -236,7 +228,28 @@ data class ClientOptions( } } - fun validate() { + fun fix(): ClientOptions { + when(videoSource) { + VideoSource.DISPLAY -> { + cameraId = "" + cameraFacing = CameraFacing.ANY + cameraSize = "" + cameraAr = "" + cameraFps = 0u + cameraHighSpeed = false + } + VideoSource.CAMERA -> { + displayId = 0 + maxSize = 0u + maxFps = "" + newDisplay = "" + crop = "" + } + } + return this + } + + fun validate(): ClientOptions { if (!video) { videoPlayback = false powerOn = false @@ -494,6 +507,8 @@ data class ClientOptions( ) } } + + return this } fun toServerParams(scid: UInt): ServerParams { diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt index 68138c6..f8ec94b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/Scrcpy.kt @@ -10,6 +10,9 @@ import io.github.miuzarte.scrcpyforandroid.constants.Defaults import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.CameraFacing +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec +import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.EncoderType import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.ListOptions import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import kotlinx.coroutines.Dispatchers @@ -49,7 +52,7 @@ class Scrcpy( private val adbService: NativeAdbService, private val serverAsset: String = DEFAULT_SERVER_ASSET, private val customServerUri: String? = null, - private val serverVersion: String = "3.3.4", + private val serverVersion: String = DEFAULT_SERVER_VERSION, private val serverRemotePath: String = DEFAULT_REMOTE_PATH, ) { private val session = Session(adbService) @@ -64,36 +67,29 @@ class Scrcpy( @Volatile private var audioPlayer: ScrcpyAudioPlayer? = null - // Cached encoder and camera size data - private val _videoEncoders = mutableListOf() - private val _audioEncoders = mutableListOf() - private val _videoEncoderTypes = mutableMapOf() - private val _audioEncoderTypes = mutableMapOf() - private val _cameraSizes = mutableListOf() - - val videoEncoders: List get() = _videoEncoders.toList() - val audioEncoders: List get() = _audioEncoders.toList() - val videoEncoderTypes: Map get() = _videoEncoderTypes.toMap() - val audioEncoderTypes: Map get() = _audioEncoderTypes.toMap() - val cameraSizes: List get() = _cameraSizes.toList() + val listings = Listings() companion object { private const val TAG = "Scrcpy" - const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar" + const val DEFAULT_SERVER_ASSET = "bin/scrcpy-server-v3.3.4" + const val DEFAULT_SERVER_ASSET_NAME = "scrcpy-server-v3.3.4" + const val DEFAULT_SERVER_VERSION = "3.3.4" + const val DEFAULT_REMOTE_PATH = "/data/local/tmp/scrcpy-server.jar" // Regex patterns for parsing server output - private val VIDEO_ENCODER_REGEX = Regex("--video-encoder=([\\w.\\-]+)") - private val AUDIO_ENCODER_REGEX = Regex("--audio-encoder=([\\w.\\-]+)") - private val VIDEO_ENCODER_FALLBACK_REGEX = Regex("""--video-encoder=['"]?([^'"\s]+)""") - private val AUDIO_ENCODER_FALLBACK_REGEX = Regex("""--audio-encoder=['"]?([^'"\s]+)""") - private val VIDEO_ENCODER_TYPE_REGEX = - Regex("""--video-codec=\S+\s+--video-encoder=(\S+).*?\((hw|sw)\)""") - private val AUDIO_ENCODER_TYPE_REGEX = - Regex("""--audio-codec=\S+\s+--audio-encoder=(\S+).*?\((hw|sw)\)""") - private val CAMERA_SIZE_REGEX = Regex("--camera-size=([0-9]+x[0-9]+)") - private val CAMERA_SIZE_FALLBACK_REGEX = Regex("\\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\\b") - private const val PREVIEW_LINES = 32 + private val VIDEO_ENCODER_INFO_REGEX = + Regex("""--video-codec=(\S+)\s+--video-encoder=(\S+)\s+\((hw|sw)\)(\s+\[vendor])?(?:\s+\(alias for (\S+)\))?""") + private val AUDIO_ENCODER_INFO_REGEX = + Regex("""--audio-codec=(\S+)\s+--audio-encoder=(\S+)\s+\((hw|sw)\)(\s+\[vendor])?(?:\s+\(alias for (\S+)\))?""") + private val DISPLAY_REGEX = + Regex("""--display-id=(\d+)\s+\((\d+)x(\d+)\)""") + private val CAMERA_SIZE_REGEX = + Regex("""\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\b""") + private val CAMERA_INFO_REGEX = + Regex("""--camera-id=(\S+)\s+\(([^,]+),\s*([0-9]+x[0-9]+),\s*fps=\[([0-9,\s]+)\]\)""") + private val APP_REGEX = + Regex("""^\s*([*-])\s+(.+?)\s{2,}([A-Za-z0-9._]+)\s*$""", RegexOption.MULTILINE) fun generateScid(): UInt { // Only use 31 bits to avoid issues with signed values on the Java-side @@ -169,7 +165,7 @@ class Scrcpy( Log.i( TAG, "start(): Session started successfully - device=${info.deviceName}, " + - "video=${if (options.video) "${info.codecName} ${info.width}x${info.height}" else "off"}, " + + "video=${if (options.video) "${info.codec?.string ?: "null"} ${info.width}x${info.height}" else "off"}, " + "audio=${if (options.audio) options.audioCodec.string else "off"}, " + "control=${options.control}" ) @@ -218,8 +214,6 @@ class Scrcpy( fun getCurrentSession(): Session.SessionInfo? = currentSession - fun getLastServerCommand(): String? = session.getLastServerCommand() - sealed class ListResult { data class Encoders( val videoEncoders: List, @@ -229,67 +223,176 @@ class Scrcpy( val rawOutput: String = "", ) : ListResult() + data class Displays( + val displays: List, + val rawOutput: String = "", + ) : ListResult() + + data class Cameras( + val cameras: List, + val rawOutput: String = "", + ) : ListResult() + data class CameraSizes( val sizes: List, val rawOutput: String = "", ) : ListResult() + + data class Apps( + val apps: List, + val rawOutput: String = "", + ) : ListResult() } - /** - * Refresh encoder lists from the device. - * Results are cached and can be accessed via videoEncoders, audioEncoders, etc. - * - * @throws Exception if the operation fails - */ - suspend fun refreshEncoders() { - val result = listOptions(ListOptions.ENCODERS) as ListResult.Encoders - _videoEncoders.clear() - _videoEncoders.addAll(result.videoEncoders) - _audioEncoders.clear() - _audioEncoders.addAll(result.audioEncoders) - _videoEncoderTypes.clear() - _videoEncoderTypes.putAll(result.videoEncoderTypes) - _audioEncoderTypes.clear() - _audioEncoderTypes.putAll(result.audioEncoderTypes) + inner class Listings { + private val encodersMutex = Mutex() + private val displaysMutex = Mutex() + private val camerasMutex = Mutex() + private val cameraSizesMutex = Mutex() + private val appsMutex = Mutex() + + @Volatile + private var cachedVideoEncoders: List? = null + + @Volatile + private var cachedAudioEncoders: List? = null + + @Volatile + private var cachedDisplays: List? = null + + @Volatile + private var cachedCameras: List? = null + + @Volatile + private var cachedCameraSizes: List? = null + + @Volatile + private var cachedApps: List? = null + + val videoEncoders: List get() = cachedVideoEncoders.orEmpty() + val audioEncoders: List get() = cachedAudioEncoders.orEmpty() + val displays: List get() = cachedDisplays.orEmpty() + val cameras: List get() = cachedCameras.orEmpty() + val cameraSizes: List get() = cachedCameraSizes.orEmpty() + val apps: List get() = cachedApps.orEmpty() + + suspend fun getVideoEncoders(forceRefresh: Boolean = false): List { + cachedVideoEncoders?.takeUnless { forceRefresh }?.let { return it } + return getEncoders(forceRefresh).first + } + + suspend fun getAudioEncoders(forceRefresh: Boolean = false): List { + cachedAudioEncoders?.takeUnless { forceRefresh }?.let { return it } + return getEncoders(forceRefresh).second + } + + private suspend fun getEncoders(forceRefresh: Boolean = false) + : Pair, List> { + if (!forceRefresh && cachedVideoEncoders != null && cachedAudioEncoders != null) + return cachedVideoEncoders.orEmpty() to cachedAudioEncoders.orEmpty() + + return encodersMutex.withLock { + if (!forceRefresh && cachedVideoEncoders != null && cachedAudioEncoders != null) + return@withLock cachedVideoEncoders.orEmpty() to cachedAudioEncoders.orEmpty() + + val output = executeList(ListOptions.ENCODERS) + val (video, audio) = parseEncoders(output) + cachedVideoEncoders = video + cachedAudioEncoders = audio + logListPreview( + list = ListOptions.ENCODERS, + countSummary = "video=${video.size} audio=${audio.size}", + output = output, + ) + video to audio + } + } + + suspend fun getDisplays(forceRefresh: Boolean = false): List { + cachedDisplays?.takeUnless { forceRefresh }?.let { return it } + return displaysMutex.withLock { + cachedDisplays?.takeUnless { forceRefresh } ?: run { + val output = executeList(ListOptions.DISPLAYS) + val parsed = parseDisplays(output) + cachedDisplays = parsed + logListPreview( + list = ListOptions.DISPLAYS, + countSummary = "displays=${parsed.size}", + output = output, + ) + parsed + } + } + } + + suspend fun getCameras(forceRefresh: Boolean = false): List { + cachedCameras?.takeUnless { forceRefresh }?.let { return it } + return camerasMutex.withLock { + cachedCameras?.takeUnless { forceRefresh } ?: run { + val output = executeList(ListOptions.CAMERAS) + val parsed = parseCameras(output) + cachedCameras = parsed + logListPreview( + list = ListOptions.CAMERAS, + countSummary = "cameras=${parsed.size}", + output = output, + ) + parsed + } + } + } + + suspend fun getCameraSizes(forceRefresh: Boolean = false): List { + cachedCameraSizes?.takeUnless { forceRefresh }?.let { return it } + return cameraSizesMutex.withLock { + cachedCameraSizes?.takeUnless { forceRefresh } ?: run { + val output = executeList(ListOptions.CAMERA_SIZES) + val parsed = parseCameraSizes(output) + .sortedWith(compareByDescending { size -> + size.substringBefore('x').toIntOrNull() ?: 0 + }) + cachedCameraSizes = parsed + logListPreview( + list = ListOptions.CAMERA_SIZES, + countSummary = "sizes=${parsed.size}", + output = output, + ) + parsed + } + } + } + + suspend fun getApps(forceRefresh: Boolean = false): List { + cachedApps?.takeUnless { forceRefresh }?.let { return it } + return appsMutex.withLock { + cachedApps?.takeUnless { forceRefresh } ?: run { + val output = executeList(ListOptions.APPS) + val parsed = parseApps(output) + cachedApps = parsed + logListPreview( + list = ListOptions.APPS, + countSummary = "apps=${parsed.size}", + output = output, + ) + parsed + } + } + } - Log.i(TAG, "refreshEncoders(): video=${_videoEncoders.size}, audio=${_audioEncoders.size}") } - /** - * Refresh camera sizes from the device. - * Results are cached and can be accessed via cameraSizes. - * - * @throws Exception if the operation fails - */ - suspend fun refreshCameraSizes() { - val result = listOptions(ListOptions.CAMERA_SIZES) as ListResult.CameraSizes - _cameraSizes.clear() - _cameraSizes.addAll(result.sizes.sortedWith(compareByDescending { size -> - size.substringBefore('x').toIntOrNull() ?: 0 - })) + private suspend fun executeList(list: ListOptions): String = withContext(Dispatchers.IO) { + require(list != ListOptions.NULL) { "Nothing to do with ListOptions.NULL" } - Log.i(TAG, "refreshCameraSizes(): sizes=${_cameraSizes.size}") - } - - /** - * List various options from the scrcpy server. - * - * @param list The type of list to retrieve (ENCODERS, CAMERA_SIZES, etc.) - * @return ListResult containing the requested information - */ - suspend fun listOptions(list: ListOptions): ListResult = withContext(Dispatchers.IO) { val serverJar = if (customServerUri.isNullOrBlank()) { extractAssetToCache(serverAsset) } else { extractUriToCache(customServerUri.toUri()) } - // Push server jar to device adbService.push(serverJar.toPath(), serverRemotePath) val scid = generateScid() - - // Create ClientOptions for listing val options = ClientOptions( video = false, audio = false, @@ -297,10 +400,7 @@ class Scrcpy( cleanUp = false, list = list, ) - val serverParams = options.toServerParams(scid) - - // Build server command val serverCommand = serverParams.build( "CLASSPATH=$serverRemotePath", "app_process", @@ -310,121 +410,133 @@ class Scrcpy( ) Log.i(TAG, "listOptions(): cmd=$serverCommand") - - // Execute shell command and capture output (merge stderr into stdout) - val output = adbService.shell("$serverCommand 2>&1") - - // Parse output based on list option - return@withContext when (list) { - ListOptions.NULL -> { - throw IllegalArgumentException("Nothing to do with ListOptions.NULL") - } - - ListOptions.ENCODERS -> { - val parsed = parseEncoderLists(output) - val preview = output.lineSequence().take(PREVIEW_LINES).joinToString("\n") - Log.i( - TAG, - "listOptions(ENCODERS): parsed video=${parsed.videoEncoders.size} audio=${parsed.audioEncoders.size}, outputPreview=\n$preview", - ) - ListResult.Encoders( - videoEncoders = parsed.videoEncoders, - audioEncoders = parsed.audioEncoders, - videoEncoderTypes = parsed.videoEncoderTypes, - audioEncoderTypes = parsed.audioEncoderTypes, - rawOutput = output, - ) - } - - ListOptions.DISPLAYS -> { - throw Exception("TODO") - } - - ListOptions.CAMERAS -> { - throw Exception("TODO") - } - - ListOptions.CAMERA_SIZES -> { - val parsed = parseCameraSizeLists(output) - val preview = output.lineSequence().take(PREVIEW_LINES).joinToString("\n") - Log.i( - TAG, - "listOptions(CAMERA_SIZES): parsed sizes=${parsed.sizes.size}, outputPreview=\n$preview", - ) - ListResult.CameraSizes( - sizes = parsed.sizes, - rawOutput = output, - ) - } - - else -> { - throw IllegalArgumentException("Unsupported list option: $list") - } - } + adbService.shell("$serverCommand 2>&1") } - private fun parseEncoderLists(output: String): ParsedEncoders { - val video = LinkedHashSet() - val audio = LinkedHashSet() - val videoTypes = linkedMapOf() - val audioTypes = linkedMapOf() - - VIDEO_ENCODER_REGEX.findAll(output).forEach { match -> - video.add(match.groupValues[1]) - } - AUDIO_ENCODER_REGEX.findAll(output).forEach { match -> - audio.add(match.groupValues[1]) - } - // Fallback for log formats that include codec+encoder in one line. - VIDEO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match -> - video.add(match.groupValues[1]) - } - AUDIO_ENCODER_FALLBACK_REGEX.findAll(output).forEach { match -> - audio.add(match.groupValues[1]) - } - VIDEO_ENCODER_TYPE_REGEX.findAll(output).forEach { match -> - val name = match.groupValues[1] - val type = match.groupValues[2] - if (name.isNotBlank() && type.isNotBlank() && !videoTypes.containsKey(name)) { - videoTypes[name] = type - } - } - AUDIO_ENCODER_TYPE_REGEX.findAll(output).forEach { match -> - val name = match.groupValues[1] - val type = match.groupValues[2] - if (name.isNotBlank() && type.isNotBlank() && !audioTypes.containsKey(name)) { - audioTypes[name] = type - } - } - - return ParsedEncoders( - videoEncoders = video.toList(), - audioEncoders = audio.toList(), - videoEncoderTypes = videoTypes, - audioEncoderTypes = audioTypes, - ) + private fun logListPreview(list: ListOptions, countSummary: String, output: String) { + val preview = output.lineSequence().take(32).joinToString("\n") + Log.i(TAG, "listOptions($list): parsed $countSummary, outputPreview=\n$preview") } - private fun parseCameraSizeLists(output: String): ParsedCameraSizes { + private fun parseEncoders(output: String): Pair, List> { + val videoInfos = linkedMapOf() + val audioInfos = linkedMapOf() + + VIDEO_ENCODER_INFO_REGEX.findAll(output).forEach { match -> + val info = EncoderInfo( + codec = Codec.fromString(match.groupValues[1], Codec.Type.VIDEO), + id = match.groupValues[2], + type = if (match.groupValues[3] == EncoderType.HARDWARE.s) { + EncoderType.HARDWARE + } else { + EncoderType.SOFTWARE + }, + isVendor = match.groupValues[4].isNotBlank(), + aliasOf = match.groupValues[5].ifBlank { null }, + ) + videoInfos.putIfAbsent(info.id, info) + } + + AUDIO_ENCODER_INFO_REGEX.findAll(output).forEach { match -> + val info = EncoderInfo( + codec = Codec.fromString(match.groupValues[1], Codec.Type.AUDIO), + id = match.groupValues[2], + type = if (match.groupValues[3] == EncoderType.HARDWARE.s) { + EncoderType.HARDWARE + } else { + EncoderType.SOFTWARE + }, + isVendor = match.groupValues[4].isNotBlank(), + aliasOf = match.groupValues[5].ifBlank { null }, + ) + audioInfos.putIfAbsent(info.id, info) + } + + return videoInfos.values.toList() to audioInfos.values.toList() + } + + private fun parseDisplays(output: String): List { + val displays = LinkedHashSet() + DISPLAY_REGEX.findAll(output).forEach { match -> + displays.add( + DisplayInfo( + id = match.groupValues[1].toInt(), + width = match.groupValues[2].toInt(), + height = match.groupValues[3].toInt(), + ) + ) + } + return displays.toList() + } + + private fun parseCameras(output: String): List { + val cameras = LinkedHashSet() + CAMERA_INFO_REGEX.findAll(output).forEach { match -> + val facing = match.groupValues[2] + val activeSize = match.groupValues[3] + val fpsValues = match.groupValues[4] + .split(',') + .mapNotNull { it.trim().toIntOrNull() } + + cameras.add( + CameraInfo( + id = match.groupValues[1], + facing = CameraFacing.fromString(facing), + activeSize = activeSize, + fps = fpsValues.map(Int::toUShort), + ) + ) + } + return cameras.toList() + } + + private fun parseCameraSizes(output: String): List { val sizes = LinkedHashSet() CAMERA_SIZE_REGEX.findAll(output).forEach { match -> sizes.add(match.groupValues[1]) } - CAMERA_SIZE_FALLBACK_REGEX.findAll(output).forEach { match -> - sizes.add(match.groupValues[1]) - } - return ParsedCameraSizes(sizes = sizes.toList()) + return sizes.toList() } - private data class ParsedEncoders( - val videoEncoders: List, - val audioEncoders: List, - val videoEncoderTypes: Map, - val audioEncoderTypes: Map, + private fun parseApps(output: String): List { + val apps = LinkedHashSet() + APP_REGEX.findAll(output).forEach { match -> + apps.add( + AppInfo( + system = match.groupValues[1] == "*", + label = match.groupValues[2].trim(), + packageName = match.groupValues[3].trim(), + ) + ) + } + return apps.toList() + } + + data class EncoderInfo( + val codec: Codec, + val id: String, + val type: EncoderType, + val isVendor: Boolean, + val aliasOf: String? = null, ) - private data class ParsedCameraSizes( - val sizes: List, + data class CameraInfo( + val id: String, + val facing: CameraFacing, + val activeSize: String, + val fps: List, + ) + + data class DisplayInfo( + val id: Int, + val width: Int, + val height: Int, + ) + + data class AppInfo( + val system: Boolean, + val label: String, + val packageName: String, ) private suspend fun executeServer( @@ -502,8 +614,6 @@ class Scrcpy( @Volatile private var audioReaderThread: Thread? = null - @Volatile - private var lastServerCommand: String? = null private val serverLogBuffer = ArrayDeque() suspend fun start( @@ -516,7 +626,6 @@ class Scrcpy( val socketName = socketNameFor(scid.toInt()) try { - lastServerCommand = serverCommand val serverStream = adbService.openShellStream(serverCommand) val serverLogThread = startServerLogThread(serverStream, socketName) Thread.sleep(SERVER_BOOT_DELAY_MS) @@ -567,27 +676,58 @@ class Scrcpy( } val deviceName = readDeviceName(firstInput) - val audioCodecId = - if (options.audio) audioCodecIdFromName(options.audioCodec.string) - else 0 - val codecId: Int + val audioCodecId = if (options.audio) { + val aInput = checkNotNull(audioInput) + when (val streamCodecId = aInput.readInt()) { + AUDIO_DISABLED -> { + Log.w(TAG, "audio disabled by server") + if (options.requireAudio) { + throw IllegalStateException( + "Audio is required but was disabled by the server" + ) + } + 0 + } + + AUDIO_ERROR -> { + Log.e(TAG, "audio stream configuration error from server") + if (options.requireAudio) { + throw IllegalStateException( + "Audio is required but the server failed to configure audio capture" + ) + } + 0 + } + + else -> { + Log.i( + TAG, + "audio stream codec=0x${streamCodecId.toUInt().toString(16)}" + ) + streamCodecId + } + } + } else { + 0 + } + val videoCodecId: Int val width: Int val height: Int if (options.video) { val vInput = checkNotNull(videoInput) - codecId = vInput.readInt() + videoCodecId = vInput.readInt() width = vInput.readInt() height = vInput.readInt() } else { - codecId = 0 + videoCodecId = 0 width = 0 height = 0 } val sessionInfo = SessionInfo( deviceName = deviceName, - codecId = codecId, - codecName = codecName(codecId), + codecId = videoCodecId, + codec = Codec.fromId(videoCodecId, Codec.Type.VIDEO), width = width, height = height, audioCodecId = audioCodecId, @@ -669,9 +809,7 @@ class Scrcpy( } } - suspend fun clearVideoConsumer() = mutex.withLock { - videoConsumer = null - } + suspend fun clearVideoConsumer() = mutex.withLock { videoConsumer = null } suspend fun attachAudioConsumer(consumer: (AudioPacket) -> Unit): Unit = mutex.withLock { val session = activeSession ?: throw IllegalStateException("scrcpy session not started") @@ -682,31 +820,6 @@ class Scrcpy( audioReaderThread = thread(start = true, name = "scrcpy-audio-reader") { try { - val streamCodecId = try { - aInput.readInt() - } catch (e: Exception) { - Log.w(TAG, "audio codec header read failed", e) - return@thread - } - when (streamCodecId) { - AUDIO_DISABLED -> { - Log.w(TAG, "audio disabled by server") - return@thread - } - - AUDIO_ERROR -> { - Log.e(TAG, "audio stream configuration error from server") - return@thread - } - - else -> { - Log.i( - TAG, - "audio stream codec=0x${streamCodecId.toUInt().toString(16)}" - ) - } - } - while (activeSession === session && !aStream.closed) { try { val ptsAndFlags = aInput.readLong() @@ -742,18 +855,20 @@ class Scrcpy( } } - suspend fun clearAudioConsumer() = mutex.withLock { - audioConsumer = null - } + suspend fun clearAudioConsumer() = mutex.withLock { audioConsumer = null } - suspend fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) = - mutex.withLock { - try { - requireControlWriter().injectKeycode(action, keycode, repeat, metaState) - } catch (e: IllegalStateException) { - Log.w(TAG, "injectKeycode(): control channel not available", e) - } + suspend fun injectKeycode( + action: Int, + keycode: Int, + repeat: Int = 0, + metaState: Int = 0, + ) = mutex.withLock { + try { + requireControlWriter().injectKeycode(action, keycode, repeat, metaState) + } catch (e: IllegalStateException) { + Log.w(TAG, "injectKeycode(): control channel not available", e) } + } suspend fun injectText(text: String) = mutex.withLock { try { @@ -798,7 +913,7 @@ class Scrcpy( screenHeight: Int, hScroll: Float, vScroll: Float, - buttons: Int + buttons: Int, ) = mutex.withLock { try { requireControlWriter().injectScroll( @@ -815,9 +930,9 @@ class Scrcpy( } } - suspend fun pressBackOrScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock { + suspend fun pressBackOrTurnScreenOn(action: Int = KeyEvent.ACTION_DOWN) = mutex.withLock { try { - requireControlWriter().pressBackOrScreenOn(action) + requireControlWriter().pressBackOrTurnScreenOn(action) } catch (e: IllegalStateException) { Log.w(TAG, "pressBackOrScreenOn(): control channel not available", e) } @@ -865,8 +980,6 @@ class Scrcpy( fun isStarted(): Boolean = activeSession != null - fun getLastServerCommand(): String? = lastServerCommand - private fun requireControlWriter(): ControlWriter { val session = activeSession ?: throw IllegalStateException("scrcpy control channel not available") @@ -948,27 +1061,10 @@ class Scrcpy( return buffer.copyOf(length).toString(Charsets.UTF_8) } - private fun codecName(codecId: Int) = - when (codecId) { - VIDEO_CODEC_H264 -> "h264" - VIDEO_CODEC_H265 -> "h265" - VIDEO_CODEC_AV1 -> "av1" - else -> "unknown" - } - - private fun audioCodecIdFromName(name: String) = - when (name.lowercase()) { - "opus" -> AUDIO_CODEC_OPUS - "aac" -> AUDIO_CODEC_AAC - "raw" -> AUDIO_CODEC_RAW - "flac" -> AUDIO_CODEC_FLAC - else -> 0 - } - data class SessionInfo( val deviceName: String, val codecId: Int, - val codecName: String, + val codec: Codec?, val width: Int, val height: Int, val audioCodecId: Int = 0, @@ -1100,7 +1196,7 @@ class Scrcpy( } @Synchronized - fun pressBackOrScreenOn(action: Int) { + fun pressBackOrTurnScreenOn(action: Int) { output.writeByte(TYPE_BACK_OR_SCREEN_ON) output.writeByte(action) output.flush() @@ -1151,14 +1247,6 @@ class Scrcpy( private const val PACKET_FLAG_KEY_FRAME = 1L shl 62 private const val PACKET_PTS_MASK = (1L shl 62) - 1 - private const val VIDEO_CODEC_H264 = 0x68323634 - private const val VIDEO_CODEC_H265 = 0x68323635 - private const val VIDEO_CODEC_AV1 = 0x00617631 - private const val AUDIO_CODEC_OPUS = 0x6f707573 - private const val AUDIO_CODEC_AAC = 0x00616163 - private const val AUDIO_CODEC_FLAC = 0x666c6163 - private const val AUDIO_CODEC_RAW = 0x00726177 - private const val AUDIO_DISABLED = 0 private const val AUDIO_ERROR = 1 diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt index f8b54d6..0097b4b 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/scrcpy/ServerParams.kt @@ -125,7 +125,7 @@ data class ServerParams( cmd.add("audio=false") } if (audioBitRate > 0) { - if (audioCodec.isLossyAudio()!!) { + if (!audioCodec.isLossless) { // 比官方实现多个判断编解码器类型 cmd.add("audio_bit_rate=$audioBitRate") } 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 71cede1..07d4fcb 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 @@ -38,7 +38,6 @@ class Shared { fun Duration.toTick(): Tick = Tick(this.inWholeMicroseconds) } - enum class ListOptions(val value: Int) { NULL(0x0), ENCODERS(0x1), // --list-encoders @@ -52,6 +51,11 @@ class Shared { infix fun has(other: ListOptions) = this and other != 0 } + enum class EncoderType(val s: String) { + HARDWARE("hw"), + SOFTWARE("sw"), + } + enum class LogLevel(val string: String) { VERBOSE("verbose"), DEBUG("debug"), @@ -60,26 +64,27 @@ class Shared { ERROR("error"); } - enum class Codec(val string: String, val displayName: String) { - H264("h264", "H.264"), // default, ignore when passing - H265("h265", "H.265"), - AV1("av1", "AV1"), - OPUS("opus", "Opus"), // default, ignore when passing - AAC("aac", "AAC"), - FLAC("flac", "FLAC"), - RAW("raw", "RAW"); // wav raw + enum class Codec( + val string: String, + val displayName: String, + val type: Type, + val id: Int, + val isLossless: Boolean, + ) { + H264("h264", "H.264", Type.VIDEO, 0x68323634, false), // default, ignore when passing + H265("h265", "H.265", Type.VIDEO, 0x68323635, false), + AV1("av1", "AV1", Type.VIDEO, 0x00617631, false), - fun isLossyAudio(): Boolean? = when (this) { - OPUS, AAC -> true - FLAC, RAW -> false - else -> null - } + OPUS("opus", "Opus", Type.AUDIO, 0x6f707573, false), // default, ignore when passing + AAC("aac", "AAC", Type.AUDIO, 0x00616163, false), + FLAC("flac", "FLAC", Type.AUDIO, 0x666c6163, true), + RAW("raw", "RAW", Type.AUDIO, 0x00726177, true); // wav raw enum class Type { VIDEO, AUDIO } companion object { - val VIDEO = listOf(H264, H265, AV1) - val AUDIO = listOf(OPUS, AAC, FLAC, RAW) + val VIDEO = entries.filter { it.type == Type.VIDEO } + val AUDIO = entries.filter { it.type == Type.AUDIO } fun fromString(value: String, type: Type = Type.VIDEO) = entries.find { it.string.equals(value, ignoreCase = true) } @@ -87,6 +92,9 @@ class Shared { Type.VIDEO -> H264 Type.AUDIO -> OPUS } + + fun fromId(value: Int, type: Type? = null): Codec? = + entries.find { it.id == value && (type == null || it.type == type) } } } @@ -177,4 +185,4 @@ class Shared { FALLBACK("fallback"), HIDE("hide"); } -} \ No newline at end of file +} 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 deleted file mode 100644 index c89c6db..0000000 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/QuickDeviceStore.kt +++ /dev/null @@ -1,64 +0,0 @@ -package io.github.miuzarte.scrcpyforandroid.services - -import android.content.Context -import androidx.core.content.edit -import io.github.miuzarte.scrcpyforandroid.constants.AppPreferenceKeys -import io.github.miuzarte.scrcpyforandroid.constants.Defaults -import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcut - -internal fun loadQuickDevices(context: Context): List { - val raw = context.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE) - .getString(AppPreferenceKeys.QUICK_DEVICES, "") - .orEmpty() - - if (raw.isBlank()) return emptyList() - - val result = mutableListOf() - raw.lineSequence().forEach { line -> - val parts = line.split("|", limit = 3) - when (parts.size) { - 3 -> { - val name = parts[0].trim() - val host = parts[1].trim() - val port = parts[2].trim().toIntOrNull() ?: Defaults.ADB_PORT - if (host.isNotBlank()) { - result.add( - DeviceShortcut( - name = name, - host = host, - port = port, - online = false, - ), - ) - } - } - - 2 -> { - // Backward compatibility with old format: name|host:port - val name = parts[0].trim() - val host = parts[1].substringBefore(":").trim() - val port = parts[1].substringAfter(":", Defaults.ADB_PORT.toString()).trim() - .toIntOrNull() ?: Defaults.ADB_PORT - if (host.isNotBlank()) { - result.add( - DeviceShortcut( - name = name, - host = host, - port = port, - online = false, - ), - ) - } - } - } - } - return result -} - -internal fun saveQuickDevices(context: Context, quickDevices: List) { - val raw = quickDevices.joinToString("\n") { "${it.name}|${it.host}|${it.port}" } - context.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE) - .edit { - putString(AppPreferenceKeys.QUICK_DEVICES, raw) - } -} diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/SnackbarController.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/SnackbarController.kt new file mode 100644 index 0000000..d23a244 --- /dev/null +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/services/SnackbarController.kt @@ -0,0 +1,31 @@ +package io.github.miuzarte.scrcpyforandroid.services + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.basic.SnackbarDuration +import top.yukonga.miuix.kmp.basic.SnackbarHostState + +class SnackbarController( + private val scope: CoroutineScope, + val hostState: SnackbarHostState, + val defaultActionLabel: String? = null, + val defaultWithDismissAction: Boolean? = null, + val defaultDuration: SnackbarDuration? = null, +) { + fun show( + message: String, + actionLabel: String? = defaultActionLabel, + withDismissAction: Boolean = defaultWithDismissAction ?: true, + duration: SnackbarDuration = defaultDuration ?: SnackbarDuration.Short, + scope: CoroutineScope = this.scope, + ) { + scope.launch { + hostState.showSnackbar( + message = message, + actionLabel = actionLabel, + withDismissAction = withDismissAction, + duration = duration, + ) + } + } +} 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 index e0ee263..6eb5c96 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/AppSettings.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import kotlinx.coroutines.flow.StateFlow import kotlinx.parcelize.Parcelize @@ -47,9 +48,13 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { stringPreferencesKey("custom_server_uri"), "" ) + val CUSTOM_SERVER_VERSION = Pair( + stringPreferencesKey("custom_server_version"), + "" + ) val SERVER_REMOTE_PATH = Pair( stringPreferencesKey("server_remote_path"), - "/data/local/tmp/scrcpy-server.jar" + Scrcpy.DEFAULT_REMOTE_PATH, ) val ADB_KEY_NAME = Pair( stringPreferencesKey("adb_key_name"), @@ -83,6 +88,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { // Scrcpy Server Settings val customServerUri by setting(CUSTOM_SERVER_URI) + val customServerVersion by setting(CUSTOM_SERVER_VERSION) val serverRemotePath by setting(SERVER_REMOTE_PATH) // ADB Settings @@ -102,6 +108,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { val previewVirtualButtonShowText: Boolean, val virtualButtonsLayout: String, val customServerUri: String, + val customServerVersion: String, val serverRemotePath: String, val adbKeyName: String, val adbPairingAutoDiscoverOnDialogOpen: Boolean, @@ -120,6 +127,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { bundleField(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText }, bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout }, bundleField(CUSTOM_SERVER_URI) { bundle: Bundle -> bundle.customServerUri }, + bundleField(CUSTOM_SERVER_VERSION) { bundle: Bundle -> bundle.customServerVersion }, bundleField(SERVER_REMOTE_PATH) { bundle: Bundle -> bundle.serverRemotePath }, bundleField(ADB_KEY_NAME) { bundle: Bundle -> bundle.adbKeyName }, bundleField(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN) { bundle: Bundle -> bundle.adbPairingAutoDiscoverOnDialogOpen }, @@ -139,6 +147,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") { previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT), virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT), customServerUri = preferences.read(CUSTOM_SERVER_URI), + customServerVersion = preferences.read(CUSTOM_SERVER_VERSION), serverRemotePath = preferences.read(SERVER_REMOTE_PATH), adbKeyName = preferences.read(ADB_KEY_NAME), adbPairingAutoDiscoverOnDialogOpen = preferences.read( 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 index 4485978..fd030eb 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/storage/Settings.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.datastore.core.DataStore import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.preferences.core.Preferences @@ -155,7 +156,7 @@ abstract class Settings( val scope = rememberCoroutineScope() val state = asState(pair) - return remember(state.value) { + return rememberSaveable(state.value) { object : MutableState { override var value: T get() = state.value 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 7bd35bc..024e4df 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 @@ -50,6 +50,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput @@ -75,10 +76,13 @@ import io.github.miuzarte.scrcpyforandroid.scaffolds.SuperTextField import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler +import io.github.miuzarte.scrcpyforandroid.services.SnackbarController import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -88,7 +92,6 @@ import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.CardDefaults import top.yukonga.miuix.kmp.basic.CircularProgressIndicator import top.yukonga.miuix.kmp.basic.Icon -import top.yukonga.miuix.kmp.basic.SnackbarHostState import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.basic.TextButton import top.yukonga.miuix.kmp.basic.TextField @@ -157,7 +160,7 @@ internal fun StatusCard( ), secondSmall = StatusSmallCardSpec( "编解码器", - sessionInfo.codecName, + sessionInfo.codec?.displayName ?: "null", ), ) } @@ -295,7 +298,7 @@ internal fun PreviewCard( Box( modifier = Modifier .align(Alignment.BottomEnd) - .padding(UiSpacing.CardContent), + .padding(UiSpacing.ContentVertical), ) { Button( onClick = { @@ -341,7 +344,7 @@ internal fun VirtualButtonCard( onAction = onAction, modifier = Modifier .fillMaxWidth() - .padding(UiSpacing.CardContent), + .padding(UiSpacing.ContentVertical), ) } } @@ -349,9 +352,10 @@ internal fun VirtualButtonCard( @Composable internal fun ConfigPanel( busy: Boolean, - snack: SnackbarHostState, + snackbar: SnackbarController, audioForwardingSupported: Boolean, cameraMirroringSupported: Boolean, + adbConnecting: Boolean, isQuickConnected: Boolean, onOpenAdvanced: () -> Unit, onStartStopHaptic: (() -> Unit)? = null, @@ -360,7 +364,7 @@ internal fun ConfigPanel( sessionInfo: Scrcpy.Session.SessionInfo?, onDisconnect: () -> Unit = {}, ) { - val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } val sessionStarted = sessionInfo != null @@ -381,7 +385,7 @@ internal fun ConfigPanel( } DisposableEffect(Unit) { onDispose { - scope.launch { + taskScope.launch { scrcpyOptions.saveBundle(soBundleLatest) } } @@ -405,7 +409,6 @@ internal fun ConfigPanel( .coerceAtLeast(0) } - SectionSmallTitle("Scrcpy") Card { SuperSwitch( title = "音频转发", @@ -425,33 +428,29 @@ internal fun ConfigPanel( val codec = Codec.AUDIO[it] soBundle = soBundle.copy(audioCodec = codec.string) if (codec == Codec.FLAC) { - scope.launch { - snack.showSnackbar("注意:FLAC编解码会引入较大的延迟") - } + snackbar.show("注意:FLAC编解码会引入较大的延迟") } }, enabled = !sessionStarted && soBundle.audio, ) AnimatedVisibility(audioBitRateVisibility) { - SuperSlider( - title = "音频码率", - summary = "--audio-bit-rate", - value = if (soBundle.audioBitRate <= 0) 0f - else (ScrcpyPresets.AudioBitRate - .indexOfOrNearest(soBundle.audioBitRate / 1000) + 1 - ).toFloat(), - onValueChange = { value -> - val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.size) - soBundle = soBundle.copy( - audioBitRate = - if (idx == 0) 0 - else ScrcpyPresets.AudioBitRate[idx - 1] * 1000 - ) - }, - valueRange = 0f..ScrcpyPresets.AudioBitRate.size.toFloat(), - steps = (ScrcpyPresets.AudioBitRate.size - 1).coerceAtLeast(0), - unit = "Kbps", - zeroStateText = "默认", + SuperSlider( + title = "音频码率", + summary = "--audio-bit-rate", + value = ScrcpyPresets.AudioBitRate + .indexOfOrNearest(soBundle.audioBitRate / 1000) + .toFloat(), + onValueChange = { value -> + val idx = value.roundToInt() + .coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex) + soBundle = soBundle.copy( + audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000 + ) + }, + valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(), + steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0), + unit = "Kbps", + zeroStateText = "默认", displayText = (soBundle.audioBitRate / 1_000).toString(), inputInitialValue = if (soBundle.audioBitRate <= 0) "" @@ -475,9 +474,7 @@ internal fun ConfigPanel( val codec = Codec.VIDEO[it] soBundle = soBundle.copy(videoCodec = codec.string) if (codec == Codec.AV1) { - scope.launch { - snack.showSnackbar("注意:绝大部分设备不支持AV1硬件编码") - } + snackbar.show("注意:绝大部分设备不支持AV1硬件编码") } }, enabled = !sessionStarted, @@ -528,8 +525,8 @@ internal fun ConfigPanel( ) SuperArrow( - title = "高级参数", - summary = "更多 scrcpy 启动参数", + title = "更多参数", + summary = "所有 scrcpy 参数", onClick = onOpenAdvanced, enabled = !sessionStarted, ) @@ -537,8 +534,8 @@ internal fun ConfigPanel( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), + .padding(horizontal = UiSpacing.ContentVertical) + .padding(bottom = UiSpacing.ContentVertical), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), ) { if (isQuickConnected) TextButton( @@ -601,9 +598,10 @@ private fun PairingDialog( var code by rememberSaveable(showDialog) { mutableStateOf("") } var discoveringPort by rememberSaveable(showDialog) { mutableStateOf(false) } val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current suspend fun doDiscover() { - if (onDiscoverTarget == null || discoveringPort || !enabled) return + if (!(enabled && onDiscoverTarget != null && !discoveringPort)) return discoveringPort = true val found = withContext(Dispatchers.IO) { onDiscoverTarget.invoke() } if (found != null) { @@ -619,13 +617,6 @@ private fun PairingDialog( } } - fun clearInputs() { - host = "" - port = "" - code = "" - discoveringPort = false - } - SuperDialog( show = showDialog, title = "使用配对码配对设备", @@ -634,80 +625,94 @@ private fun PairingDialog( onDismissRequest() }, onDismissFinished = { - clearInputs() onDismissFinished() }, content = { - TextField( - value = host, - onValueChange = { host = it }, - label = "IP 地址", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = UiSpacing.CardContent), - ) - TextField( - value = port, - onValueChange = { port = it.filter(Char::isDigit) }, - label = "端口", - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = UiSpacing.CardContent), - ) - TextField( - value = code, - onValueChange = { code = it }, - label = "WLAN 配对码", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = UiSpacing.CardContent), - ) - - TextButton( - text = if (discoveringPort) "发现中..." else "自动发现", - onClick = { - if (onDiscoverTarget == null || discoveringPort || !enabled) return@TextButton - scope.launch { - doDiscover() - } - }, - enabled = enabled && onDiscoverTarget != null && !discoveringPort, - modifier = Modifier - .fillMaxWidth() - .padding( - top = UiSpacing.Medium, - bottom = UiSpacing.CardContent, + Column( + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + TextField( + value = host, + onValueChange = { host = it }, + label = "IP 地址", + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, ), - ) - Row( - modifier = Modifier - .padding(bottom = UiSpacing.PopupHorizontal), - horizontalArrangement = Arrangement.spacedBy(UiSpacing.PopupHorizontal), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Next) }, + ), + modifier = Modifier.fillMaxWidth() + ) + TextField( + value = port, + onValueChange = { port = it.filter(Char::isDigit) }, + label = "端口", + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Next) }, + ), + modifier = Modifier.fillMaxWidth() + ) + TextField( + value = code, + onValueChange = { code = it }, + label = "WLAN 配对码", + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { focusManager.clearFocus() }, + ), + modifier = Modifier.fillMaxWidth() + ) + } + + Spacer(Modifier.height(UiSpacing.ContentVertical * 2)) + + Column( + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), ) { TextButton( - text = "取消", + text = if (!discoveringPort) "自动发现" else "发现中...", onClick = { - onDismissRequest() + if (enabled && onDiscoverTarget != null && !discoveringPort) + scope.launch { doDiscover() } }, - modifier = Modifier.weight(1f), - ) - TextButton( - text = "配对", - onClick = { - onConfirm(host.trim(), port.trim(), code.trim()) - onDismissRequest() - }, - enabled = enabled && - host.trim().isNotBlank() && - port.trim().isNotBlank() && - code.trim().isNotBlank(), - modifier = Modifier.weight(1f), - colors = ButtonDefaults.textButtonColorsPrimary(), + enabled = enabled && onDiscoverTarget != null && !discoveringPort, + modifier = Modifier.fillMaxWidth(), ) + Row( + horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal), + ) { + TextButton( + text = "取消", + onClick = { + onDismissRequest() + }, + modifier = Modifier.weight(1f), + ) + TextButton( + text = "配对", + onClick = { + onConfirm(host.trim(), port.trim(), code.trim()) + onDismissRequest() + }, + enabled = enabled && + host.trim().isNotBlank() && + port.trim().isNotBlank() && + code.trim().isNotBlank(), + modifier = Modifier.weight(1f), + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } } }, ) @@ -816,9 +821,9 @@ fun FullscreenControlScreen( Box( modifier = Modifier .align(Alignment.TopStart) - .padding(start = UiSpacing.CardContent, top = UiSpacing.CardContent) + .padding(start = UiSpacing.ContentVertical, top = UiSpacing.ContentVertical) .background(Color.Black.copy(alpha = 0.5f)) - .padding(horizontal = UiSpacing.CardContent, vertical = UiSpacing.Medium), + .padding(horizontal = UiSpacing.ContentVertical, vertical = UiSpacing.Medium), ) { Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.Tiny)) { Text( @@ -877,6 +882,7 @@ private fun ScrcpyVideoSurface( ) { var currentSurface by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() + val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } LaunchedEffect(session, currentSurface) { val surface = currentSurface @@ -889,7 +895,7 @@ private fun ScrcpyVideoSurface( onDispose { val surface = currentSurface if (surface != null) { - scope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) } + taskScope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) } } } } @@ -924,7 +930,7 @@ private fun ScrcpyVideoSurface( override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean { val surface = currentSurface if (surface != null) { - scope.launch { + taskScope.launch { nativeCore.detachVideoSurface(surface, releaseDecoder = false) } surface.release() @@ -944,48 +950,70 @@ private fun ScrcpyVideoSurface( @Composable internal fun DeviceTile( device: DeviceShortcut, - actionText: String, + isConnected: Boolean, actionEnabled: Boolean, actionInProgress: Boolean, - onLongPress: () -> Unit, - onContentClick: () -> Unit, + editing: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, onAction: () -> Unit, + onEditorSave: (DeviceShortcut) -> Unit, + onEditorDelete: () -> Unit, + onEditorCancel: () -> Unit, ) { val haptics = rememberAppHaptics() + var name by rememberSaveable(device.id) { mutableStateOf(device.name) } + var host by rememberSaveable(device.id) { mutableStateOf(device.host) } + var port by rememberSaveable(device.id) { mutableStateOf(device.port.toString()) } + + LaunchedEffect(device) { + name = device.name + host = device.host + port = device.port.toString() + } + Card( colors = CardDefaults.defaultColors( - color = if (device.online) MiuixTheme.colorScheme.surfaceContainer - else MiuixTheme.colorScheme.surfaceContainer.copy(alpha = 0.6f), + color = + if (isConnected) MiuixTheme.colorScheme.surfaceContainer + else MiuixTheme.colorScheme.surfaceContainer.copy(alpha = 0.6f), ), - pressFeedbackType = PressFeedbackType.Sink, + pressFeedbackType = if (!editing) PressFeedbackType.Sink else PressFeedbackType.None, onClick = haptics.contextClick, ) { Row( modifier = Modifier .fillMaxWidth() - .combinedClickable( - onClick = onContentClick, - onLongClick = onLongPress, + .then( + if (!isConnected) + Modifier.combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) + else Modifier ) .padding(UiSpacing.PageItem), verticalAlignment = Alignment.CenterVertically, ) { Row( modifier = Modifier - .weight(1f) - .padding(end = UiSpacing.CardContent), + .weight(1f), verticalAlignment = Alignment.CenterVertically, ) { + // statu dot Box( modifier = Modifier .size(8.dp) .background( - color = if (device.online) Color(0xFF44C74F) else MiuixTheme.colorScheme.outline, + color = + if (isConnected) Color(0xFF44C74F) + else MiuixTheme.colorScheme.outline, shape = CircleShape, ), ) Spacer(modifier = Modifier.width(UiSpacing.PageItem)) - Column(modifier = Modifier.weight(1f)) { + // device name/address + Column { Text( device.name.ifBlank { device.host }, fontWeight = FontWeight.SemiBold, @@ -1003,20 +1031,121 @@ internal fun DeviceTile( } } - Row( - verticalAlignment = Alignment.CenterVertically, - ) { + Row(verticalAlignment = Alignment.CenterVertically) { if (actionInProgress) { CircularProgressIndicator(progress = null) Spacer(Modifier.width(UiSpacing.Medium)) } TextButton( - text = actionText, + text = if (!isConnected) "连接" else "断开", onClick = onAction, enabled = actionEnabled && !actionInProgress, ) } } + + AnimatedVisibility(editing) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = UiSpacing.ContentVertical) + .padding(bottom = UiSpacing.ContentVertical), + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical) + ) { + SuperTextField( + value = name, + onValueChange = { name = it }, + label = "设备名称", + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + SuperTextField( + value = host, + onValueChange = { host = it }, + label = "IP 地址", + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + SuperTextField( + value = port, + onValueChange = { port = it.filter(Char::isDigit) }, + label = "端口", + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + TextButton( + text = "取消", + onClick = onEditorCancel, + modifier = Modifier.weight(1f), + ) + TextButton( + text = "删除", + onClick = onEditorDelete, + modifier = Modifier.weight(1f), + ) + TextButton( + text = "保存", + onClick = { + val trimmedHost = host.trim() + if (trimmedHost.isNotBlank()) { + onEditorSave( + DeviceShortcut( + name = name.trim(), + host = trimmedHost, + port = port.toIntOrNull() ?: Defaults.ADB_PORT, + ), + ) + } + }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.textButtonColorsPrimary(), + ) + } + } + } + } +} + +@Composable +internal fun DeviceTileList( + devices: List, + isConnected: (DeviceShortcut) -> Boolean, + actionEnabled: Boolean, + actionInProgress: (DeviceShortcut) -> Boolean, + editingDeviceId: String?, + onClick: (DeviceShortcut) -> Unit, + onLongClick: (DeviceShortcut) -> Unit, + onAction: (DeviceShortcut) -> Unit, + onEditorSave: (DeviceShortcut, DeviceShortcut) -> Unit, + onEditorDelete: (DeviceShortcut) -> Unit, + onEditorCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical), + ) { + devices.forEach { device -> + DeviceTile( + device = device, + isConnected = isConnected(device), + actionEnabled = actionEnabled, + actionInProgress = actionInProgress(device), + editing = editingDeviceId == device.id, + onClick = { onClick(device) }, + onLongClick = { onLongClick(device) }, + onAction = { onAction(device) }, + onEditorSave = { updated -> onEditorSave(device, updated) }, + onEditorDelete = { onEditorDelete(device) }, + onEditorCancel = onEditorCancel, + ) + } } } @@ -1029,161 +1158,58 @@ internal fun QuickConnectCard( onAddDevice: () -> Unit, enabled: Boolean = true, ) { - val scope = rememberCoroutineScope() val focusManager = LocalFocusManager.current Card( colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.primaryContainer), - pressFeedbackType = if (enabled) PressFeedbackType.Tilt else PressFeedbackType.None, + pressFeedbackType = + if (enabled) PressFeedbackType.Tilt + else PressFeedbackType.None, + insideMargin = PaddingValues(UiSpacing.Content), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - start = UiSpacing.Large, - end = UiSpacing.Large, - top = UiSpacing.PageItem, - bottom = UiSpacing.Small, - ), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Rounded.AddLink, - contentDescription = "快速连接", - tint = MiuixTheme.colorScheme.onPrimaryContainer, - ) - Spacer(modifier = Modifier.width(UiSpacing.Medium)) - Text( - "快速连接", - fontWeight = FontWeight.Bold, - color = MiuixTheme.colorScheme.onPrimaryContainer, - ) - } - SuperTextField( - value = input, - onValueChange = onValueChange, - label = "IP:PORT", - enabled = enabled, - useLabelAsPlaceholder = true, - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.SectionTitleLeadingGap), - keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - onFocusLost = onFocusChange, - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(bottom = UiSpacing.CardContent), - horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), - ) { - TextButton( - text = "添加设备", - onClick = onAddDevice, - modifier = Modifier.weight(1f), - enabled = enabled, - ) - TextButton( - text = "连接", - onClick = onConnect, - modifier = Modifier.weight(1f), - enabled = enabled, - colors = ButtonDefaults.textButtonColorsPrimary(), - ) - } - } -} - -@Composable -internal fun DeviceEditorScreen( - contentPadding: PaddingValues, - device: DeviceShortcut, - onSave: (DeviceShortcut) -> Unit, - onDelete: () -> Unit, - onBack: () -> Unit, -) { - var name by rememberSaveable(device.id) { mutableStateOf(device.name) } - var host by rememberSaveable(device.id) { mutableStateOf(device.host) } - var port by rememberSaveable(device.id) { mutableStateOf(device.port.toString()) } - - BackHandler(enabled = true, onBack = onBack) - - Column( - modifier = Modifier - .fillMaxSize() - .padding(contentPadding) - .padding(UiSpacing.PageHorizontal), - ) { - SectionSmallTitle("编辑设备") - Card { - TextField( - value = name, - onValueChange = { name = it }, - label = "设备名称", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(top = UiSpacing.CardContent), - ) - TextField( - value = host, - onValueChange = { host = it }, - label = "IP 地址", - singleLine = true, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(top = UiSpacing.CardContent), - ) - TextField( - value = port, - onValueChange = { port = it.filter(Char::isDigit) }, - label = "端口", - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = UiSpacing.CardContent) - .padding(top = UiSpacing.CardContent), - ) + Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical)) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(UiSpacing.CardContent), + modifier = Modifier.padding(horizontal = UiSpacing.Small), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), + ) { + Icon( + Icons.Rounded.AddLink, + contentDescription = "快速连接", + tint = MiuixTheme.colorScheme.onPrimaryContainer, + ) + Text( + "快速连接", + fontWeight = FontWeight.Bold, + color = MiuixTheme.colorScheme.onPrimaryContainer, + ) + } + SuperTextField( + value = input, + onValueChange = onValueChange, + label = "IP:PORT", + enabled = enabled, + useLabelAsPlaceholder = true, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + onFocusLost = onFocusChange, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(UiSpacing.ContentHorizontal), ) { TextButton( - text = "返回", - onClick = onBack, + text = "添加设备", + onClick = onAddDevice, modifier = Modifier.weight(1f), + enabled = enabled, ) TextButton( - text = "删除", - onClick = onDelete, - modifier = Modifier.weight(1f), - ) - TextButton( - text = "保存", - onClick = { - val p = port.toIntOrNull() ?: Defaults.ADB_PORT - val h = host.trim() - if (h.isNotBlank()) { - onSave( - DeviceShortcut( - name = name.trim(), - host = h, - port = p, - online = device.online, - ), - ) - } - }, + text = "连接", + onClick = onConnect, modifier = Modifier.weight(1f), + enabled = enabled, colors = ButtonDefaults.textButtonColorsPrimary(), ) } diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt index 86c0fb6..de22c61 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/ReorderableList.kt @@ -78,44 +78,40 @@ class ReorderableList( ) { Row( verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small) ) { - if (item.icon != null) { - Icon( - item.icon, - contentDescription = item.title - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - } + if (item.icon != null) Icon( + imageVector = item.icon, + contentDescription = item.title + ) Column { Text( text = item.title, color = MiuixTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) - if (item.subtitle.isNotBlank()) { - Text( - text = item.subtitle, - color = MiuixTheme.colorScheme.onSurfaceVariantSummary, - fontSize = 13.sp, - ) - } + if (item.subtitle.isNotBlank()) Text( + text = item.subtitle, + color = MiuixTheme.colorScheme.onSurfaceVariantSummary, + fontSize = 13.sp, + ) } } Row( verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small) ) { - if (showCheckbox) { - Checkbox( - state = if (item.checked) ToggleableState.On else ToggleableState.Off, - onClick = { - onCheckboxChange?.invoke(item.id, !item.checked) - }, - enabled = item.checkboxEnabled - ) - Spacer(Modifier.padding(horizontal = 4.dp)) - } + if (showCheckbox) Checkbox( + state = if (item.checked) ToggleableState.On else ToggleableState.Off, + onClick = { + onCheckboxChange?.invoke(item.id, !item.checked) + }, + enabled = item.checkboxEnabled + ) IconButton( - onClick = {}, + onClick = { + haptics.contextClick() + }, modifier = Modifier .draggableHandle( onDragStarted = { @@ -193,7 +189,7 @@ class ReorderableList( ) } } - Spacer(Modifier.padding(UiSpacing.CardContent)) + Spacer(Modifier.padding(UiSpacing.ContentVertical)) Text( text = item.title, color = MiuixTheme.colorScheme.onSurface, diff --git a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt index e130f30..57585ef 100644 --- a/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt +++ b/app/src/main/java/io/github/miuzarte/scrcpyforandroid/widgets/VirtualButtons.kt @@ -302,7 +302,7 @@ class VirtualButtonBar( action.icon, contentDescription = action.title, modifier = Modifier - .padding(end = UiSpacing.CardContent), + .padding(end = UiSpacing.ContentVertical), ) }, title = action.title,