refactor: improve Snackbar functionality

- 增强 Codec 枚举,增加 type 和 lossless 属性以改进编解码器管理
- 引入 SnackbarController
- 改进 Settings.kt 中的设置处理,优化状态管理
- 增强 DeviceWidgets,支持编辑设备详情并改进 UI
- 重构 ReorderableList 和 VirtualButtons,优化布局和间距
- 重构 Scrpcy 编码器获取逻辑
This commit is contained in:
Miuzarte
2026-04-10 00:12:35 +08:00
parent 95dc24e676
commit 6c1564f461
27 changed files with 2715 additions and 2546 deletions

View File

@@ -8,6 +8,7 @@ import android.view.Surface
import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder import io.github.miuzarte.scrcpyforandroid.nativecore.AnnexBDecoder
import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer import io.github.miuzarte.scrcpyforandroid.nativecore.PersistentVideoRenderer
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.util.ArrayDeque import java.util.ArrayDeque
@@ -133,8 +134,8 @@ class NativeCoreFacade private constructor() {
videoFpsListeners.remove(listener) videoFpsListeners.remove(listener)
} }
suspend fun scrcpyBackOrScreenOn(action: Int = 0) { suspend fun scrcpyBackOrTurnScreenOn(action: Int = 0) {
session?.pressBackOrScreenOn(action) session?.pressBackOrTurnScreenOn(action)
} }
/** /**
@@ -200,6 +201,7 @@ class NativeCoreFacade private constructor() {
@Volatile @Volatile
private var instance: NativeCoreFacade? = null private var instance: NativeCoreFacade? = null
// TODO ???
fun get(context: Context): NativeCoreFacade { fun get(context: Context): NativeCoreFacade {
return instance ?: synchronized(this) { return instance ?: synchronized(this) {
instance ?: NativeCoreFacade().also { instance = it } instance ?: NativeCoreFacade().also { instance = it }
@@ -252,16 +254,19 @@ class NativeCoreFacade private constructor() {
decoder = null decoder = null
Log.i( Log.i(
TAG, 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( val newDecoder = AnnexBDecoder(
width = session.width, width = session.width,
height = session.height, height = session.height,
outputSurface = surface, outputSurface = surface,
mimeType = when (session.codecName.lowercase()) { mimeType = when (session.codec) {
"h264" -> "video/avc" Codec.H264 -> "video/avc"
"h265" -> "video/hevc" Codec.H265 -> "video/hevc"
"av1" -> "video/av01" Codec.AV1 -> "video/av01"
else -> "video/avc" else -> "video/avc"
}, },
onOutputSizeChanged = { width, height -> 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() { private fun releaseAllDecoders() {
runCatching { decoder?.release() } runCatching { decoder?.release() }
decoder = null decoder = null

View File

@@ -58,6 +58,6 @@ fun Preset<Int>.indexOfOrNearest(raw: String): Int {
object ScrcpyPresets { object ScrcpyPresets {
val MaxSize = Preset(listOf(0, 720, 1080, 1280, 1600, 1920, 2160, 2560, 3200, 3840)) // px 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 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 val CameraFps = Preset(listOf(0, 10, 15, 24, 30, 60, 120, 240, 480, 960)) // fps
} }

View File

@@ -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),
)
}

View File

@@ -16,8 +16,10 @@ object UiSpacing {
val SectionTitleTop = 12.dp val SectionTitleTop = 12.dp
val SectionTitleBottom = 6.dp val SectionTitleBottom = 6.dp
val FieldLabelBottom = 4.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 CardTitle = 16.dp
val BottomContent = 64.dp val PageBottom = 64.dp
val BottomSheetBottom = 16.dp val SheetBottom = 16.dp
} }

View File

@@ -38,7 +38,6 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
host: String? = null, host: String? = null,
port: Int? = null, port: Int? = null,
name: String? = null, name: String? = null,
online: Boolean? = null,
newPort: Int? = null, newPort: Int? = null,
updateNameOnlyWhenEmpty: Boolean = false, updateNameOnlyWhenEmpty: Boolean = false,
): DeviceShortcuts { ): DeviceShortcuts {
@@ -48,6 +47,7 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
if (idx < 0) return this if (idx < 0) return this
val old = devices[idx] val old = devices[idx]
val updateById = id != null
// 确定最终的属性值 // 确定最终的属性值
val finalName = when { val finalName = when {
@@ -55,23 +55,24 @@ class DeviceShortcuts(val devices: List<DeviceShortcut>) : List<DeviceShortcut>
updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name updateNameOnlyWhenEmpty && old.name.isNotBlank() -> old.name
else -> name else -> name
} }
val finalPort = newPort ?: old.port val finalHost = if (updateById) host ?: old.host else old.host
val finalOnline = online ?: old.online 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 return this
val newList = devices.toMutableList().apply { val newList = devices.toMutableList().apply {
this[idx] = DeviceShortcut( this[idx] = DeviceShortcut(
name = finalName, name = finalName,
host = old.host, host = finalHost,
port = finalPort, port = finalPort,
online = finalOnline
) )
} }
return DeviceShortcuts( 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 } newList.distinctBy { it.id }
else newList else newList
) )
@@ -118,7 +119,6 @@ data class DeviceShortcut(
val name: String = "", val name: String = "",
val host: String, val host: String,
val port: Int = Defaults.ADB_PORT, val port: Int = Defaults.ADB_PORT,
val online: Boolean = false,
) { ) {
val id: String get() = "$host:$port" val id: String get() = "$host:$port"

View File

@@ -9,6 +9,7 @@ import android.media.AudioTrack
import android.media.MediaCodec import android.media.MediaCodec
import android.media.MediaFormat import android.media.MediaFormat
import android.util.Log import android.util.Log
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
@@ -42,9 +43,9 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
}" }"
) )
when (codecId) { when (codecId) {
AUDIO_CODEC_OPUS -> prepareOpus(data) Codec.OPUS.id -> prepareOpus(data)
AUDIO_CODEC_AAC -> prepareAac(data) Codec.AAC.id -> prepareAac(data)
AUDIO_CODEC_FLAC -> prepareFlac(data) Codec.FLAC.id -> prepareFlac(data)
// RAW has no config packet // RAW has no config packet
} }
return return
@@ -55,7 +56,7 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
Log.i(TAG, "feedPacket(): packets=$packetCount prepared=$prepared size=${data.size}") 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) ensureRawAudioTrack()?.write(data, 0, data.size, AudioTrack.WRITE_NON_BLOCKING)
return return
} }
@@ -212,10 +213,6 @@ class ScrcpyAudioPlayer(private val codecId: Int) {
companion object { companion object {
private const val TAG = "ScrcpyAudioPlayer" 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 SAMPLE_RATE = 48000
private const val CHANNELS = 2 private const val CHANNELS = 2
private const val CODEC_TIMEOUT_US = 10_000L private const val CODEC_TIMEOUT_US = 10_000L

View File

@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.runtime.Composable 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.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn import io.github.miuzarte.scrcpyforandroid.scaffolds.LazyColumn
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy 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
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent 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.services.fetchConnectedDeviceInfo
import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel import io.github.miuzarte.scrcpyforandroid.widgets.ConfigPanel
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceEditorScreen import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTileList
import io.github.miuzarte.scrcpyforandroid.widgets.DeviceTile
import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard import io.github.miuzarte.scrcpyforandroid.widgets.PairingCard
import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard import io.github.miuzarte.scrcpyforandroid.widgets.PreviewCard
import io.github.miuzarte.scrcpyforandroid.widgets.QuickConnectCard 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.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonCard
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch 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.PopupPositionProvider
import top.yukonga.miuix.kmp.basic.Scaffold import top.yukonga.miuix.kmp.basic.Scaffold
import top.yukonga.miuix.kmp.basic.ScrollBehavior 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.Text
import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.basic.TextField
import top.yukonga.miuix.kmp.basic.TopAppBar import top.yukonga.miuix.kmp.basic.TopAppBar
@@ -88,7 +89,7 @@ fun DeviceTabScreen(
nativeCore: NativeCoreFacade, nativeCore: NativeCoreFacade,
adbService: NativeAdbService, adbService: NativeAdbService,
scrcpy: Scrcpy, scrcpy: Scrcpy,
snack: SnackbarHostState, snackbar: SnackbarController,
scrollBehavior: ScrollBehavior, scrollBehavior: ScrollBehavior,
onOpenVirtualButtonOrder: () -> Unit, onOpenVirtualButtonOrder: () -> Unit,
onSessionStartedChange: (Boolean) -> Unit, onSessionStartedChange: (Boolean) -> Unit,
@@ -139,7 +140,7 @@ fun DeviceTabScreen(
nativeCore = nativeCore, nativeCore = nativeCore,
adbService = adbService, adbService = adbService,
scrcpy = scrcpy, scrcpy = scrcpy,
snack = snack, snackbar = snackbar,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
onSessionStartedChange = onSessionStartedChange, onSessionStartedChange = onSessionStartedChange,
onOpenAdvancedPage = onOpenAdvancedPage, onOpenAdvancedPage = onOpenAdvancedPage,
@@ -154,13 +155,14 @@ fun DeviceTabPage(
nativeCore: NativeCoreFacade, nativeCore: NativeCoreFacade,
adbService: NativeAdbService, adbService: NativeAdbService,
scrcpy: Scrcpy, scrcpy: Scrcpy,
snack: SnackbarHostState, snackbar: SnackbarController,
scrollBehavior: ScrollBehavior, scrollBehavior: ScrollBehavior,
onSessionStartedChange: (Boolean) -> Unit, onSessionStartedChange: (Boolean) -> Unit,
onOpenAdvancedPage: () -> Unit, onOpenAdvancedPage: () -> Unit,
onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit, onOpenFullscreenPage: (Scrcpy.Session.SessionInfo) -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(Dispatchers.IO + SupervisorJob()) }
val haptics = rememberAppHaptics() val haptics = rememberAppHaptics()
val asBundleShared by appSettings.bundleState.collectAsState() val asBundleShared by appSettings.bundleState.collectAsState()
@@ -180,7 +182,7 @@ fun DeviceTabPage(
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
scope.launch { taskScope.launch {
appSettings.saveBundle(asBundleLatest) appSettings.saveBundle(asBundleLatest)
} }
} }
@@ -203,7 +205,7 @@ fun DeviceTabPage(
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
scope.launch { taskScope.launch {
quickDevices.saveBundle(qdBundleLatest) quickDevices.saveBundle(qdBundleLatest)
} }
} }
@@ -225,7 +227,7 @@ fun DeviceTabPage(
var sessionInfoWidth by rememberSaveable { mutableIntStateOf(0) } var sessionInfoWidth by rememberSaveable { mutableIntStateOf(0) }
var sessionInfoHeight by rememberSaveable { mutableIntStateOf(0) } var sessionInfoHeight by rememberSaveable { mutableIntStateOf(0) }
var sessionInfoDeviceName by rememberSaveable { mutableStateOf("") } var sessionInfoDeviceName by rememberSaveable { mutableStateOf("") }
var sessionInfoCodec by rememberSaveable { mutableStateOf("") } var sessionInfoCodec by rememberSaveable { mutableStateOf<Codec?>(null) }
var sessionInfoControlEnabled by rememberSaveable { mutableStateOf(false) } var sessionInfoControlEnabled by rememberSaveable { mutableStateOf(false) }
var sessionInfo by remember { var sessionInfo by remember {
mutableStateOf<Scrcpy.Session.SessionInfo?>(null) mutableStateOf<Scrcpy.Session.SessionInfo?>(null)
@@ -243,7 +245,7 @@ fun DeviceTabPage(
height = sessionInfoHeight, height = sessionInfoHeight,
deviceName = sessionInfoDeviceName, deviceName = sessionInfoDeviceName,
codecId = 0, codecId = 0,
codecName = sessionInfoCodec, codec = sessionInfoCodec,
audioCodecId = 0, audioCodecId = 0,
controlEnabled = sessionInfoControlEnabled, controlEnabled = sessionInfoControlEnabled,
host = currentTargetHost, host = currentTargetHost,
@@ -254,7 +256,7 @@ fun DeviceTabPage(
} }
} }
var previewControlsVisible by rememberSaveable { mutableStateOf(false) } var previewControlsVisible by rememberSaveable { mutableStateOf(false) }
var editingDevice by rememberSaveable { mutableStateOf<DeviceShortcut?>(null) } var editingDeviceId by rememberSaveable { mutableStateOf<String?>(null) }
var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) } var activeDeviceActionId by rememberSaveable { mutableStateOf<String?>(null) }
var adbConnecting by rememberSaveable { mutableStateOf(false) } var adbConnecting by rememberSaveable { mutableStateOf(false) }
@@ -288,6 +290,9 @@ fun DeviceTabPage(
qdBundle = qdBundle.copy(quickDevicesList = serialized) 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. * Disconnect the current ADB connection and stop any running scrcpy session.
@@ -330,14 +335,12 @@ fun DeviceTabPage(
clearQuickOnlineForTarget?.let { target -> clearQuickOnlineForTarget?.let { target ->
if (target.host.isNotBlank()) if (target.host.isNotBlank())
savedShortcuts = savedShortcuts.update( savedShortcuts = savedShortcuts.update(
host = target.host, port = target.port, online = false host = target.host, port = target.port
) )
} }
logMessage?.let { logEvent(it) } logMessage?.let { logEvent(it) }
if (!showSnackMessage.isNullOrBlank()) { if (!showSnackMessage.isNullOrBlank()) {
scope.launch { snackbar.show(showSnackMessage)
snack.showSnackbar(showSnackMessage)
}
} }
} }
@@ -447,7 +450,7 @@ fun DeviceTabPage(
fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) { fun runBusy(label: String, onFinished: (() -> Unit)? = null, block: suspend () -> Unit) {
// For non-adb actions (start/stop/pair/list refresh...). // For non-adb actions (start/stop/pair/list refresh...).
if (busy) return if (busy) return
scope.launch(kotlinx.coroutines.Dispatchers.IO) { taskScope.launch {
busy = true busy = true
try { try {
block() block()
@@ -456,9 +459,7 @@ fun DeviceTabPage(
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
logEvent("$label 参数错误: $detail", Log.WARN, e) logEvent("$label 参数错误: $detail", Log.WARN, e)
scope.launch { snackbar.show("$label 参数错误: $detail")
snack.showSnackbar("$label 参数错误: $detail")
}
} catch (e: Exception) { } catch (e: Exception) {
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
logEvent("$label 失败: $detail", Log.ERROR, e) logEvent("$label 失败: $detail", Log.ERROR, e)
@@ -489,7 +490,7 @@ fun DeviceTabPage(
) { ) {
// For manual adb operations from user actions. // For manual adb operations from user actions.
if (adbConnecting) return if (adbConnecting) return
scope.launch { taskScope.launch {
onStarted?.invoke() onStarted?.invoke()
adbConnecting = true adbConnecting = true
try { try {
@@ -499,9 +500,7 @@ fun DeviceTabPage(
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
logEvent("$label 参数错误: $detail", Log.WARN, e) logEvent("$label 参数错误: $detail", Log.WARN, e)
scope.launch { snackbar.show("$label 参数错误: $detail")
snack.showSnackbar("$label 参数错误: $detail")
}
} catch (e: Exception) { } catch (e: Exception) {
val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName val detail = e.message?.takeIf { it.isNotBlank() } ?: e.javaClass.simpleName
logEvent("$label 失败: $detail", Log.ERROR, e) 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) { suspend fun handleAdbConnected(host: String, port: Int) {
currentTargetHost = host currentTargetHost = host
currentTargetPort = port currentTargetPort = port
@@ -586,8 +534,7 @@ fun DeviceTabPage(
connectedDeviceLabel = info.model connectedDeviceLabel = info.model
applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease) applyConnectedDeviceCapabilities(info.sdkInt, info.androidRelease)
savedShortcuts = savedShortcuts.update( savedShortcuts = savedShortcuts.update(
host = host, host = host, port = port,
port = port,
name = fullLabel, name = fullLabel,
updateNameOnlyWhenEmpty = true updateNameOnlyWhenEmpty = true
) )
@@ -603,26 +550,7 @@ fun DeviceTabPage(
"android=${info.androidRelease.ifBlank { "unknown" }}, " + "android=${info.androidRelease.ifBlank { "unknown" }}, " +
"sdk=${info.sdkInt}" "sdk=${info.sdkInt}"
) )
scope.launch { snackbar.show("ADB 已连接")
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)
}
}
} }
LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort) { LaunchedEffect(adbConnected, currentTargetHost, currentTargetPort) {
@@ -643,16 +571,12 @@ fun DeviceTabPage(
adbConnected = true adbConnected = true
statusLine = "$host:$port" statusLine = "$host:$port"
logEvent("ADB 自动重连成功: $host:$port") logEvent("ADB 自动重连成功: $host:$port")
scope.launch { snackbar.show("ADB 自动重连成功")
snack.showSnackbar("ADB 自动重连成功")
}
} catch (e: Exception) { } catch (e: Exception) {
disconnectAdbConnection() disconnectAdbConnection()
statusLine = "ADB 连接断开" statusLine = "ADB 连接断开"
logEvent("ADB 自动重连失败: $e", Log.ERROR) logEvent("ADB 自动重连失败: $e", Log.ERROR)
scope.launch { snackbar.show("ADB 自动重连失败")
snack.showSnackbar("ADB 自动重连失败")
}
break break
} }
} }
@@ -690,9 +614,7 @@ fun DeviceTabPage(
runAutoAdbConnect(target.host, target.port) runAutoAdbConnect(target.host, target.port)
adbConnected = true adbConnected = true
savedShortcuts = savedShortcuts.update( savedShortcuts = savedShortcuts.update(
host = target.host, host = target.host, port = target.port,
port = target.port,
online = true
) )
handleAdbConnected(target.host, target.port) handleAdbConnected(target.host, target.port)
logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}") logEvent("ADB 快速探测连接成功: ${target.host}:${target.port}")
@@ -732,10 +654,8 @@ fun DeviceTabPage(
}?.port }?.port
if (portToReplace != null) { if (portToReplace != null) {
savedShortcuts = savedShortcuts.update( savedShortcuts = savedShortcuts.update(
host = discoveredHost, host = discoveredHost, port = portToReplace,
port = portToReplace,
newPort = discoveredPort, newPort = discoveredPort,
online = false
) )
logEvent( logEvent(
"mDNS 发现新端口,已更新快速设备: $discoveredHost:$portToReplace -> $discoveredHost:$discoveredPort" "mDNS 发现新端口,已更新快速设备: $discoveredHost:$portToReplace -> $discoveredHost:$discoveredPort"
@@ -751,9 +671,7 @@ fun DeviceTabPage(
runAutoAdbConnect(discoveredHost, discoveredPort) runAutoAdbConnect(discoveredHost, discoveredPort)
adbConnected = true adbConnected = true
savedShortcuts = savedShortcuts.update( savedShortcuts = savedShortcuts.update(
host = discoveredHost, host = discoveredHost, port = discoveredPort,
port = discoveredPort,
online = true
) )
handleAdbConnected(discoveredHost, discoveredPort) handleAdbConnected(discoveredHost, discoveredPort)
logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort") logEvent("ADB 自动重连成功: $discoveredHost:$discoveredPort")
@@ -779,13 +697,13 @@ fun DeviceTabPage(
sessionInfoWidth = sessionInfo?.width ?: 0 sessionInfoWidth = sessionInfo?.width ?: 0
sessionInfoHeight = sessionInfo?.height ?: 0 sessionInfoHeight = sessionInfo?.height ?: 0
sessionInfoDeviceName = sessionInfo?.deviceName.orEmpty() sessionInfoDeviceName = sessionInfo?.deviceName.orEmpty()
sessionInfoCodec = sessionInfo?.codecName.orEmpty() sessionInfoCodec = sessionInfo?.codec
sessionInfoControlEnabled = sessionInfo?.controlEnabled == true sessionInfoControlEnabled = sessionInfo?.controlEnabled == true
} else { } else {
sessionInfoWidth = 0 sessionInfoWidth = 0
sessionInfoHeight = 0 sessionInfoHeight = 0
sessionInfoDeviceName = "" sessionInfoDeviceName = ""
sessionInfoCodec = "" sessionInfoCodec = null
sessionInfoControlEnabled = false sessionInfoControlEnabled = false
} }
onSessionStartedChange(sessionInfo != null) 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 devicePreviewCardHeightDp = asBundle.devicePreviewCardHeightDp
val previewVirtualButtonShowText = asBundle.previewVirtualButtonShowText val previewVirtualButtonShowText = asBundle.previewVirtualButtonShowText
@@ -843,27 +739,38 @@ fun DeviceTabPage(
) )
} }
itemsIndexed(savedShortcuts, key = { _, device -> device.id }) { _, device -> item {
val host = device.host DeviceTileList(
val port = device.port devices = savedShortcuts,
val isConnectedTarget = adbConnected isConnected = { device ->
&& currentTarget?.host == host adbConnected
&& currentTarget.port == port && currentTarget?.host == device.host
&& currentTarget.port == device.port
DeviceTile( },
device = device,
actionText = if (!isConnectedTarget) "连接" else "断开",
actionEnabled = !busy && !adbConnecting, actionEnabled = !busy && !adbConnecting,
actionInProgress = adbConnecting && activeDeviceActionId == device.id, actionInProgress = { device ->
onLongPress = { editingDevice = device }, adbConnecting && activeDeviceActionId == device.id
onContentClick = { },
scope.launch { editingDeviceId = editingDeviceId,
snack.showSnackbar("长按可编辑设备") 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() haptics.contextClick()
if (!isConnectedTarget) { val host = device.host
val port = device.port
val connected = adbConnected
&& currentTarget?.host == host
&& currentTarget.port == port
if (!connected) {
runAdbConnect( runAdbConnect(
"连接 ADB", "连接 ADB",
onStarted = { activeDeviceActionId = device.id }, onStarted = { activeDeviceActionId = device.id },
@@ -873,17 +780,15 @@ fun DeviceTabPage(
try { try {
connectWithTimeout(host, port) connectWithTimeout(host, port)
adbConnected = true adbConnected = true
isQuickConnected = false // 标记为快速设备连接 isQuickConnected = false
savedShortcuts = savedShortcuts.update( savedShortcuts = savedShortcuts.update(
host = host, port = port, online = true host = host, port = port,
) )
handleAdbConnected(host, port) handleAdbConnected(host, port)
} catch (e: Exception) { } catch (e: Exception) {
statusLine = "ADB 连接失败" statusLine = "ADB 连接失败"
logEvent("ADB 连接失败: $e", Log.ERROR) logEvent("ADB 连接失败: $e", Log.ERROR)
scope.launch { snackbar.show("ADB 连接失败")
snack.showSnackbar("ADB 连接失败")
}
} }
} }
} else { } 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( item {
input = qdBundle.quickConnectInput, QuickConnectCard(
onValueChange = { input = qdBundle.quickConnectInput,
qdBundle = qdBundle.copy(quickConnectInput = it) onValueChange = {
}, qdBundle = qdBundle.copy(quickConnectInput = it)
enabled = !adbConnecting, },
onAddDevice = { enabled = !adbConnecting,
val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput) onAddDevice = {
?: return@QuickConnectCard val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput)
savedShortcuts = savedShortcuts.upsert( ?: return@QuickConnectCard
DeviceShortcut(host = target.host, port = target.port) savedShortcuts = savedShortcuts.upsert(
) DeviceShortcut(host = target.host, port = target.port)
Log.d( )
"SavedShortcuts", Log.d(
"size: ${savedShortcuts.size}, list: ${qdBundle.quickDevicesList}" "SavedShortcuts",
) "size: ${savedShortcuts.size}, list: ${qdBundle.quickDevicesList}"
scope.launch { )
snack.showSnackbar("已添加设备: ${target.host}:${target.port}") snackbar.show("已添加设备: ${target.host}:${target.port}")
} },
}, onConnect = {
onConnect = { val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput)
val target = ConnectionTarget.unmarshalFrom(qdBundle.quickConnectInput) ?: return@QuickConnectCard
?: return@QuickConnectCard runAdbConnect(
runAdbConnect( "连接 ADB",
"连接 ADB", onStarted = { activeDeviceActionId = target.toString() },
onStarted = { activeDeviceActionId = target.toString() }, onFinished = { activeDeviceActionId = null },
onFinished = { activeDeviceActionId = null }, ) {
) { disconnectCurrentTargetBeforeConnecting(target.host, target.port)
disconnectCurrentTargetBeforeConnecting(target.host, target.port) try {
try { connectWithTimeout(target.host, target.port)
connectWithTimeout(target.host, target.port) adbConnected = true
adbConnected = true isQuickConnected = true // 标记为快速连接
isQuickConnected = true // 标记为快速连接 savedShortcuts = savedShortcuts.update(
savedShortcuts = savedShortcuts.update( host = target.host, port = target.port,
host = target.host, )
port = target.port, handleAdbConnected(target.host, target.port)
online = true } catch (e: Exception) {
) statusLine = "ADB 连接失败"
handleAdbConnected(target.host, target.port) logEvent("ADB 连接失败: $e", Log.ERROR)
} catch (e: Exception) { snackbar.show("ADB 连接失败")
statusLine = "ADB 连接失败"
logEvent("ADB 连接失败: $e", Log.ERROR)
scope.launch {
snack.showSnackbar("ADB 连接失败")
} }
} }
} },
}, )
) }
SectionSmallTitle("无线配对")
// "使用配对码配对设备" item {
PairingCard( SectionSmallTitle("无线配对", showLeadingSpacer = false)
busy = busy, // "使用配对码配对设备"
autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen, PairingCard(
onDiscoverTarget = { busy = busy,
adbService.discoverPairingService( autoDiscoverOnDialogOpen = adbPairingAutoDiscoverOnDialogOpen,
includeLanDevices = adbMdnsLanDiscovery, 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,
) )
logEvent( },
if (ok) "配对成功" else "配对失败", onPair = { host, port, code ->
if (ok) Log.INFO else Log.ERROR runBusy("执行配对") {
) val resolvedHost = host.trim()
scope.launch { val resolvedPort = port.trim().toIntOrNull() ?: return@runBusy
snack.showSnackbar(if (ok) "配对成功" else "配对失败") 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) { if (adbConnected) {
item { item {
SectionSmallTitle("Scrcpy", showLeadingSpacer = false)
ConfigPanel( ConfigPanel(
busy = busy, busy = busy,
snack = snack, snackbar = snackbar,
audioForwardingSupported = audioForwardingSupported, audioForwardingSupported = audioForwardingSupported,
cameraMirroringSupported = cameraMirroringSupported, cameraMirroringSupported = cameraMirroringSupported,
adbConnecting = adbConnecting,
isQuickConnected = isQuickConnected, isQuickConnected = isQuickConnected,
onOpenAdvanced = onOpenAdvancedPage, onOpenAdvanced = onOpenAdvancedPage,
onStartStopHaptic = { haptics.contextClick() }, onStartStopHaptic = { haptics.contextClick() },
onStart = { onStart = {
runBusy("启动 scrcpy") { runBusy("启动 scrcpy") {
val options = scrcpyOptions.toClientOptions(soBundleShared) val options = scrcpyOptions.toClientOptions(soBundleShared).fix()
val session = scrcpy.start(options) val session = scrcpy.start(options)
sessionInfo = session.copy( sessionInfo = session.copy(
host = currentTargetHost, host = currentTargetHost,
@@ -1010,8 +928,8 @@ fun DeviceTabPage(
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
val videoDetail = val videoDetail =
if (!options.video) "off" if (!options.video) "off"
else if (videoBitRate <= 0) "${session.codecName} ${session.width}x${session.height} @default" else if (videoBitRate <= 0) "${session.codec?.string ?: "null"} ${session.width}x${session.height} @default"
else "${session.codecName} ${session.width}x${session.height} " + else "${session.codec?.string ?: "null"} ${session.width}x${session.height} " +
"@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps" "@${String.format("%.1f", videoBitRate / 1_000_000f)}Mbps"
val audioDetail = val audioDetail =
@@ -1025,9 +943,7 @@ fun DeviceTabPage(
", control=${options.control}, turnScreenOff=${options.turnScreenOff}" + ", control=${options.control}, turnScreenOff=${options.turnScreenOff}" +
", maxSize=${options.maxSize}, maxFps=${options.maxFps}" ", maxSize=${options.maxSize}, maxFps=${options.maxFps}"
) )
scope.launch { snackbar.show("scrcpy 已启动")
snack.showSnackbar("scrcpy 已启动")
}
} }
}, },
onStop = { onStop = {
@@ -1036,9 +952,7 @@ fun DeviceTabPage(
sessionInfo = null sessionInfo = null
statusLine = "${currentTarget!!.host}:${currentTarget.port}" statusLine = "${currentTarget!!.host}:${currentTarget.port}"
logEvent("scrcpy 已停止") logEvent("scrcpy 已停止")
scope.launch { snackbar.show("scrcpy 已停止")
snack.showSnackbar("scrcpy 已停止")
}
} }
}, },
sessionInfo = sessionInfo, sessionInfo = sessionInfo,
@@ -1094,19 +1008,21 @@ fun DeviceTabPage(
} }
} }
if (EventLogger.hasLogs()) item { if (EventLogger.hasLogs()) {
Spacer(Modifier.height(UiSpacing.PageItem)) item {
Card { SectionSmallTitle("日志", showLeadingSpacer = false)
TextField( Card {
value = EventLogger.eventLog.joinToString(separator = "\n"), TextField(
onValueChange = {}, value = EventLogger.eventLog.joinToString(separator = "\n"),
readOnly = true, onValueChange = {},
modifier = Modifier.fillMaxWidth(), 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 = "快速设备排序", text = "快速设备排序",
optionSize = 3, optionSize = 3,
index = 0, index = 0,
onClick = onReorderDevices, onSelectedIndexChange = { onReorderDevices() },
) )
DeviceMenuPopupItem( DeviceMenuPopupItem(
text = "虚拟按钮排序", text = "虚拟按钮排序",
optionSize = 3, optionSize = 3,
index = 1, index = 1,
onClick = onOpenVirtualButtonOrder, onSelectedIndexChange = { onOpenVirtualButtonOrder() },
) )
DeviceMenuPopupItem( DeviceMenuPopupItem(
text = "清空日志", text = "清空日志",
optionSize = 3, optionSize = 3,
index = 2, index = 2,
enabled = canClearLogs, enabled = canClearLogs,
onClick = onClearLogs, onSelectedIndexChange = { onClearLogs() },
) )
} }
} }
@@ -1156,8 +1072,7 @@ private fun DeviceMenuPopupItem(
optionSize: Int, optionSize: Int,
index: Int, index: Int,
enabled: Boolean = true, enabled: Boolean = true,
// TODO: (Int) -> Unit onSelectedIndexChange: (Int) -> Unit,
onClick: () -> Unit,
) { ) {
if (enabled) { if (enabled) {
DropdownImpl( DropdownImpl(
@@ -1165,7 +1080,7 @@ private fun DeviceMenuPopupItem(
optionSize = optionSize, optionSize = optionSize,
isSelected = false, isSelected = false,
index = index, index = index,
onSelectedIndexChange = { onClick() }, onSelectedIndexChange = onSelectedIndexChange,
) )
return return
} }

View File

@@ -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.FullscreenControlScreen
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonBar
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -53,6 +55,7 @@ fun FullscreenControlScreen(
val haptics = rememberAppHaptics() val haptics = rememberAppHaptics()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val activity = remember(context) { context as? Activity } val activity = remember(context) { context as? Activity }
@@ -73,7 +76,7 @@ fun FullscreenControlScreen(
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
scope.launch { taskScope.launch {
appSettings.saveBundle(asBundleLatest) appSettings.saveBundle(asBundleLatest)
} }
} }

View File

@@ -12,9 +12,11 @@ import androidx.activity.compose.PredictiveBackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Devices import androidx.compose.material.icons.rounded.Devices
@@ -42,16 +44,20 @@ import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberDecoratedNavEntries import androidx.navigation3.runtime.rememberDecoratedNavEntries
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import io.github.miuzarte.scrcpyforandroid.NativeCoreFacade 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.constants.UiMotion
import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import io.github.miuzarte.scrcpyforandroid.services.SnackbarController
import io.github.miuzarte.scrcpyforandroid.storage.AppSettings import io.github.miuzarte.scrcpyforandroid.storage.AppSettings
import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings import io.github.miuzarte.scrcpyforandroid.storage.Storage.appSettings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.Scaffold
import top.yukonga.miuix.kmp.basic.SnackbarHost import top.yukonga.miuix.kmp.basic.SnackbarHost
import top.yukonga.miuix.kmp.basic.SnackbarHostState 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.MiuixTheme
import top.yukonga.miuix.kmp.theme.ThemeController import top.yukonga.miuix.kmp.theme.ThemeController
@@ -84,6 +91,7 @@ fun MainScreen() {
val context = LocalContext.current val context = LocalContext.current
val appContext = context.applicationContext val appContext = context.applicationContext
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val activity = remember(context) { context as? Activity } val activity = remember(context) { context as? Activity }
val initialOrientation = remember(activity) { val initialOrientation = remember(activity) {
@@ -101,11 +109,14 @@ fun MainScreen() {
val adbService = remember(appContext) { val adbService = remember(appContext) {
NativeAdbService(appContext) NativeAdbService(appContext)
} }
val scrcpy = remember(appContext, adbService) {
Scrcpy(appContext, adbService)
}
val snackHostState = remember { SnackbarHostState() } val snackHostState = remember { SnackbarHostState() }
val snackbarController = remember(scope, snackHostState) {
SnackbarController(
scope = scope,
hostState = snackHostState,
)
}
val saveableStateHolder = rememberSaveableStateHolder() val saveableStateHolder = rememberSaveableStateHolder()
val tabs = remember { MainBottomTabDestination.entries } val tabs = remember { MainBottomTabDestination.entries }
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
@@ -137,6 +148,75 @@ fun MainScreen() {
// 2) switch tab back to Device // 2) switch tab back to Device
// 3) double-back to exit and disconnect adb/scrcpy // 3) double-back to exit and disconnect adb/scrcpy
var lastExitBackPressAtMs by rememberSaveable { mutableLongStateOf(0L) } 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() { fun handleBackNavigation() {
if (rootBackStack.size > 1) { if (rootBackStack.size > 1) {
popRoot() 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 sessionStarted by remember { mutableStateOf(false) }
var showReorderDevices by rememberSaveable { mutableStateOf(false) } var showReorderDevices by rememberSaveable { mutableStateOf(false) }
var fullscreenOrientation by rememberSaveable { var fullscreenOrientation by rememberSaveable {
mutableIntStateOf(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT) 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 val keepScreenOnWhenStreamingEnabled = asBundle.keepScreenOnWhenStreaming
// Keep-screen-on is controlled globally, so fullscreen and preview share the same behavior. // Keep-screen-on is controlled globally, so fullscreen and preview share the same behavior.
DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) { DisposableEffect(activity, keepScreenOnWhenStreamingEnabled, sessionStarted) {
@@ -336,7 +353,7 @@ fun MainScreen() {
nativeCore = nativeCore, nativeCore = nativeCore,
adbService = adbService, adbService = adbService,
scrcpy = scrcpy, scrcpy = scrcpy,
snack = snackHostState, snackbar = snackbarController,
scrollBehavior = devicesPageScrollBehavior, scrollBehavior = devicesPageScrollBehavior,
onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) }, onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) },
onSessionStartedChange = { sessionStarted = it }, onSessionStartedChange = { sessionStarted = it },
@@ -357,7 +374,7 @@ fun MainScreen() {
MainBottomTabDestination.Settings -> SettingsScreen( MainBottomTabDestination.Settings -> SettingsScreen(
scrollBehavior = settingsPageScrollBehavior, scrollBehavior = settingsPageScrollBehavior,
snack = snackHostState, snackbar = snackbarController,
onOpenReorderDevices = { showReorderDevices = true }, onOpenReorderDevices = { showReorderDevices = true },
onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) }, onOpenVirtualButtonOrder = { rootBackStack.add(RootScreen.VirtualButtonOrder) },
onPickServer = { onPickServer = {
@@ -382,10 +399,10 @@ fun MainScreen() {
} }
entry(RootScreen.Advanced) { entry(RootScreen.Advanced) {
AdvancedConfigScreen( ScrcpyAllOptionsScreen(
onBack = ::popRoot, onBack = ::popRoot,
scrollBehavior = advancedPageScrollBehavior, scrollBehavior = advancedPageScrollBehavior,
snack = snackHostState, snackbar = snackbarController,
scrcpy = scrcpy, scrcpy = scrcpy,
) )
} }
@@ -416,6 +433,13 @@ fun MainScreen() {
entryProvider = rootEntryProvider, 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) { MiuixTheme(controller = themeController) {
NavDisplay( NavDisplay(
entries = rootEntries, entries = rootEntries,

View File

@@ -19,6 +19,9 @@ import io.github.miuzarte.scrcpyforandroid.models.DeviceShortcuts
import io.github.miuzarte.scrcpyforandroid.storage.Settings import io.github.miuzarte.scrcpyforandroid.storage.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices import io.github.miuzarte.scrcpyforandroid.storage.Storage.quickDevices
import io.github.miuzarte.scrcpyforandroid.widgets.ReorderableList 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.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.extra.SuperBottomSheet import top.yukonga.miuix.kmp.extra.SuperBottomSheet
@@ -29,6 +32,7 @@ fun ReorderDevicesScreen(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val qdBundleShared by quickDevices.bundleState.collectAsState() val qdBundleShared by quickDevices.bundleState.collectAsState()
val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared) val qdBundleSharedLatest by rememberUpdatedState(qdBundleShared)
@@ -47,7 +51,7 @@ fun ReorderDevicesScreen(
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
scope.launch { taskScope.launch {
quickDevices.saveBundle(qdBundleLatest) quickDevices.saveBundle(qdBundleLatest)
} }
} }
@@ -85,6 +89,6 @@ fun ReorderDevicesScreen(
savedShortcuts = savedShortcuts.move(fromIndex, toIndex) savedShortcuts = savedShortcuts.move(fromIndex, toIndex)
}, },
).invoke() ).invoke()
Spacer(Modifier.height(UiSpacing.BottomSheetBottom)) Spacer(Modifier.height(UiSpacing.SheetBottom))
} }
} }

View File

@@ -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)) }
}
}

View File

@@ -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)) }
}
}

View File

@@ -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.ReorderableList
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonAction
import io.github.miuzarte.scrcpyforandroid.widgets.VirtualButtonActions 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.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import top.yukonga.miuix.kmp.basic.Card import top.yukonga.miuix.kmp.basic.Card
@@ -67,6 +70,7 @@ internal fun VirtualButtonOrderPage(
scrollBehavior: ScrollBehavior, scrollBehavior: ScrollBehavior,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val asBundleShared by appSettings.bundleState.collectAsState() val asBundleShared by appSettings.bundleState.collectAsState()
val asBundleSharedLatest by rememberUpdatedState(asBundleShared) val asBundleSharedLatest by rememberUpdatedState(asBundleShared)
@@ -85,7 +89,7 @@ internal fun VirtualButtonOrderPage(
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
scope.launch { taskScope.launch {
appSettings.saveBundle(asBundleLatest) appSettings.saveBundle(asBundleLatest)
} }
} }

View File

@@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -122,7 +123,7 @@ private fun SliderInputDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
onDismissFinished = onDismissFinished, onDismissFinished = onDismissFinished,
content = { content = {
var text by remember(initialValue) { mutableStateOf(initialValue) } var text by rememberSaveable(initialValue) { mutableStateOf(initialValue) }
SuperTextField( SuperTextField(
modifier = Modifier.padding(bottom = 16.dp), modifier = Modifier.padding(bottom = 16.dp),

View File

@@ -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.Tick
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.VideoSource
// TODO: 修改配置项时, validate() 判断是否合法, 非法则回退修改
// TODO: 管理冲突配置项的关系, 在更多参数页中隐藏冲突项
// TODO: 禁用配置项验证, 包括不隐藏冲突配置项
// TODO: 配置切换 // TODO: 配置切换
// TODO: 参数预览框可编辑
data class ClientOptions( data class ClientOptions(
// var serial: String = "", // to server // var serial: String = "", // to server
// --crop=width:height:x:y // --crop=width:height:x:y
var crop: String = "", // to server var crop: String = "", // to server
// TODO // TODO: implement
// --record // --record
var recordFilename: String = "", 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) { if (!video) {
videoPlayback = false videoPlayback = false
powerOn = false powerOn = false
@@ -494,6 +507,8 @@ data class ClientOptions(
) )
} }
} }
return this
} }
fun toServerParams(scid: UInt): ServerParams { fun toServerParams(scid: UInt): ServerParams {

View File

@@ -10,6 +10,9 @@ import io.github.miuzarte.scrcpyforandroid.constants.Defaults
import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream import io.github.miuzarte.scrcpyforandroid.nativecore.AdbSocketStream
import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService import io.github.miuzarte.scrcpyforandroid.nativecore.NativeAdbService
import io.github.miuzarte.scrcpyforandroid.nativecore.ScrcpyAudioPlayer 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.scrcpy.Shared.ListOptions
import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent import io.github.miuzarte.scrcpyforandroid.services.EventLogger.logEvent
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -49,7 +52,7 @@ class Scrcpy(
private val adbService: NativeAdbService, private val adbService: NativeAdbService,
private val serverAsset: String = DEFAULT_SERVER_ASSET, private val serverAsset: String = DEFAULT_SERVER_ASSET,
private val customServerUri: String? = null, 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 serverRemotePath: String = DEFAULT_REMOTE_PATH,
) { ) {
private val session = Session(adbService) private val session = Session(adbService)
@@ -64,36 +67,29 @@ class Scrcpy(
@Volatile @Volatile
private var audioPlayer: ScrcpyAudioPlayer? = null private var audioPlayer: ScrcpyAudioPlayer? = null
// Cached encoder and camera size data val listings = Listings()
private val _videoEncoders = mutableListOf<String>()
private val _audioEncoders = mutableListOf<String>()
private val _videoEncoderTypes = mutableMapOf<String, String>()
private val _audioEncoderTypes = mutableMapOf<String, String>()
private val _cameraSizes = mutableListOf<String>()
val videoEncoders: List<String> get() = _videoEncoders.toList()
val audioEncoders: List<String> get() = _audioEncoders.toList()
val videoEncoderTypes: Map<String, String> get() = _videoEncoderTypes.toMap()
val audioEncoderTypes: Map<String, String> get() = _audioEncoderTypes.toMap()
val cameraSizes: List<String> get() = _cameraSizes.toList()
companion object { companion object {
private const val TAG = "Scrcpy" 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 = "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 // Regex patterns for parsing server output
private val VIDEO_ENCODER_REGEX = Regex("--video-encoder=([\\w.\\-]+)") private val VIDEO_ENCODER_INFO_REGEX =
private val AUDIO_ENCODER_REGEX = Regex("--audio-encoder=([\\w.\\-]+)") Regex("""--video-codec=(\S+)\s+--video-encoder=(\S+)\s+\((hw|sw)\)(\s+\[vendor])?(?:\s+\(alias for (\S+)\))?""")
private val VIDEO_ENCODER_FALLBACK_REGEX = Regex("""--video-encoder=['"]?([^'"\s]+)""") private val AUDIO_ENCODER_INFO_REGEX =
private val AUDIO_ENCODER_FALLBACK_REGEX = Regex("""--audio-encoder=['"]?([^'"\s]+)""") Regex("""--audio-codec=(\S+)\s+--audio-encoder=(\S+)\s+\((hw|sw)\)(\s+\[vendor])?(?:\s+\(alias for (\S+)\))?""")
private val VIDEO_ENCODER_TYPE_REGEX = private val DISPLAY_REGEX =
Regex("""--video-codec=\S+\s+--video-encoder=(\S+).*?\((hw|sw)\)""") Regex("""--display-id=(\d+)\s+\((\d+)x(\d+)\)""")
private val AUDIO_ENCODER_TYPE_REGEX = private val CAMERA_SIZE_REGEX =
Regex("""--audio-codec=\S+\s+--audio-encoder=(\S+).*?\((hw|sw)\)""") Regex("""\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\b""")
private val CAMERA_SIZE_REGEX = Regex("--camera-size=([0-9]+x[0-9]+)") private val CAMERA_INFO_REGEX =
private val CAMERA_SIZE_FALLBACK_REGEX = Regex("\\b([1-9][0-9]{1,4}x[1-9][0-9]{1,4})\\b") Regex("""--camera-id=(\S+)\s+\(([^,]+),\s*([0-9]+x[0-9]+),\s*fps=\[([0-9,\s]+)\]\)""")
private const val PREVIEW_LINES = 32 private val APP_REGEX =
Regex("""^\s*([*-])\s+(.+?)\s{2,}([A-Za-z0-9._]+)\s*$""", RegexOption.MULTILINE)
fun generateScid(): UInt { fun generateScid(): UInt {
// Only use 31 bits to avoid issues with signed values on the Java-side // Only use 31 bits to avoid issues with signed values on the Java-side
@@ -169,7 +165,7 @@ class Scrcpy(
Log.i( Log.i(
TAG, "start(): Session started successfully - device=${info.deviceName}, " + 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"}, " + "audio=${if (options.audio) options.audioCodec.string else "off"}, " +
"control=${options.control}" "control=${options.control}"
) )
@@ -218,8 +214,6 @@ class Scrcpy(
fun getCurrentSession(): Session.SessionInfo? = currentSession fun getCurrentSession(): Session.SessionInfo? = currentSession
fun getLastServerCommand(): String? = session.getLastServerCommand()
sealed class ListResult { sealed class ListResult {
data class Encoders( data class Encoders(
val videoEncoders: List<String>, val videoEncoders: List<String>,
@@ -229,67 +223,176 @@ class Scrcpy(
val rawOutput: String = "", val rawOutput: String = "",
) : ListResult() ) : ListResult()
data class Displays(
val displays: List<DisplayInfo>,
val rawOutput: String = "",
) : ListResult()
data class Cameras(
val cameras: List<CameraInfo>,
val rawOutput: String = "",
) : ListResult()
data class CameraSizes( data class CameraSizes(
val sizes: List<String>, val sizes: List<String>,
val rawOutput: String = "", val rawOutput: String = "",
) : ListResult() ) : ListResult()
data class Apps(
val apps: List<AppInfo>,
val rawOutput: String = "",
) : ListResult()
} }
/** inner class Listings {
* Refresh encoder lists from the device. private val encodersMutex = Mutex()
* Results are cached and can be accessed via videoEncoders, audioEncoders, etc. private val displaysMutex = Mutex()
* private val camerasMutex = Mutex()
* @throws Exception if the operation fails private val cameraSizesMutex = Mutex()
*/ private val appsMutex = Mutex()
suspend fun refreshEncoders() {
val result = listOptions(ListOptions.ENCODERS) as ListResult.Encoders @Volatile
_videoEncoders.clear() private var cachedVideoEncoders: List<EncoderInfo>? = null
_videoEncoders.addAll(result.videoEncoders)
_audioEncoders.clear() @Volatile
_audioEncoders.addAll(result.audioEncoders) private var cachedAudioEncoders: List<EncoderInfo>? = null
_videoEncoderTypes.clear()
_videoEncoderTypes.putAll(result.videoEncoderTypes) @Volatile
_audioEncoderTypes.clear() private var cachedDisplays: List<DisplayInfo>? = null
_audioEncoderTypes.putAll(result.audioEncoderTypes)
@Volatile
private var cachedCameras: List<CameraInfo>? = null
@Volatile
private var cachedCameraSizes: List<String>? = null
@Volatile
private var cachedApps: List<AppInfo>? = null
val videoEncoders: List<EncoderInfo> get() = cachedVideoEncoders.orEmpty()
val audioEncoders: List<EncoderInfo> get() = cachedAudioEncoders.orEmpty()
val displays: List<DisplayInfo> get() = cachedDisplays.orEmpty()
val cameras: List<CameraInfo> get() = cachedCameras.orEmpty()
val cameraSizes: List<String> get() = cachedCameraSizes.orEmpty()
val apps: List<AppInfo> get() = cachedApps.orEmpty()
suspend fun getVideoEncoders(forceRefresh: Boolean = false): List<EncoderInfo> {
cachedVideoEncoders?.takeUnless { forceRefresh }?.let { return it }
return getEncoders(forceRefresh).first
}
suspend fun getAudioEncoders(forceRefresh: Boolean = false): List<EncoderInfo> {
cachedAudioEncoders?.takeUnless { forceRefresh }?.let { return it }
return getEncoders(forceRefresh).second
}
private suspend fun getEncoders(forceRefresh: Boolean = false)
: Pair<List<EncoderInfo>, List<EncoderInfo>> {
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<DisplayInfo> {
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<CameraInfo> {
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<String> {
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<AppInfo> {
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}")
} }
/** private suspend fun executeList(list: ListOptions): String = withContext(Dispatchers.IO) {
* Refresh camera sizes from the device. require(list != ListOptions.NULL) { "Nothing to do with ListOptions.NULL" }
* 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
}))
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()) { val serverJar = if (customServerUri.isNullOrBlank()) {
extractAssetToCache(serverAsset) extractAssetToCache(serverAsset)
} else { } else {
extractUriToCache(customServerUri.toUri()) extractUriToCache(customServerUri.toUri())
} }
// Push server jar to device
adbService.push(serverJar.toPath(), serverRemotePath) adbService.push(serverJar.toPath(), serverRemotePath)
val scid = generateScid() val scid = generateScid()
// Create ClientOptions for listing
val options = ClientOptions( val options = ClientOptions(
video = false, video = false,
audio = false, audio = false,
@@ -297,10 +400,7 @@ class Scrcpy(
cleanUp = false, cleanUp = false,
list = list, list = list,
) )
val serverParams = options.toServerParams(scid) val serverParams = options.toServerParams(scid)
// Build server command
val serverCommand = serverParams.build( val serverCommand = serverParams.build(
"CLASSPATH=$serverRemotePath", "CLASSPATH=$serverRemotePath",
"app_process", "app_process",
@@ -310,121 +410,133 @@ class Scrcpy(
) )
Log.i(TAG, "listOptions(): cmd=$serverCommand") Log.i(TAG, "listOptions(): cmd=$serverCommand")
adbService.shell("$serverCommand 2>&1")
// 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")
}
}
} }
private fun parseEncoderLists(output: String): ParsedEncoders { private fun logListPreview(list: ListOptions, countSummary: String, output: String) {
val video = LinkedHashSet<String>() val preview = output.lineSequence().take(32).joinToString("\n")
val audio = LinkedHashSet<String>() Log.i(TAG, "listOptions($list): parsed $countSummary, outputPreview=\n$preview")
val videoTypes = linkedMapOf<String, String>()
val audioTypes = linkedMapOf<String, String>()
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 parseCameraSizeLists(output: String): ParsedCameraSizes { private fun parseEncoders(output: String): Pair<List<EncoderInfo>, List<EncoderInfo>> {
val videoInfos = linkedMapOf<String, EncoderInfo>()
val audioInfos = linkedMapOf<String, EncoderInfo>()
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<DisplayInfo> {
val displays = LinkedHashSet<DisplayInfo>()
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<CameraInfo> {
val cameras = LinkedHashSet<CameraInfo>()
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<String> {
val sizes = LinkedHashSet<String>() val sizes = LinkedHashSet<String>()
CAMERA_SIZE_REGEX.findAll(output).forEach { match -> CAMERA_SIZE_REGEX.findAll(output).forEach { match ->
sizes.add(match.groupValues[1]) sizes.add(match.groupValues[1])
} }
CAMERA_SIZE_FALLBACK_REGEX.findAll(output).forEach { match -> return sizes.toList()
sizes.add(match.groupValues[1])
}
return ParsedCameraSizes(sizes = sizes.toList())
} }
private data class ParsedEncoders( private fun parseApps(output: String): List<AppInfo> {
val videoEncoders: List<String>, val apps = LinkedHashSet<AppInfo>()
val audioEncoders: List<String>, APP_REGEX.findAll(output).forEach { match ->
val videoEncoderTypes: Map<String, String>, apps.add(
val audioEncoderTypes: Map<String, String>, 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( data class CameraInfo(
val sizes: List<String>, val id: String,
val facing: CameraFacing,
val activeSize: String,
val fps: List<UShort>,
)
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( private suspend fun executeServer(
@@ -502,8 +614,6 @@ class Scrcpy(
@Volatile @Volatile
private var audioReaderThread: Thread? = null private var audioReaderThread: Thread? = null
@Volatile
private var lastServerCommand: String? = null
private val serverLogBuffer = ArrayDeque<String>() private val serverLogBuffer = ArrayDeque<String>()
suspend fun start( suspend fun start(
@@ -516,7 +626,6 @@ class Scrcpy(
val socketName = socketNameFor(scid.toInt()) val socketName = socketNameFor(scid.toInt())
try { try {
lastServerCommand = serverCommand
val serverStream = adbService.openShellStream(serverCommand) val serverStream = adbService.openShellStream(serverCommand)
val serverLogThread = startServerLogThread(serverStream, socketName) val serverLogThread = startServerLogThread(serverStream, socketName)
Thread.sleep(SERVER_BOOT_DELAY_MS) Thread.sleep(SERVER_BOOT_DELAY_MS)
@@ -567,27 +676,58 @@ class Scrcpy(
} }
val deviceName = readDeviceName(firstInput) val deviceName = readDeviceName(firstInput)
val audioCodecId = val audioCodecId = if (options.audio) {
if (options.audio) audioCodecIdFromName(options.audioCodec.string) val aInput = checkNotNull(audioInput)
else 0 when (val streamCodecId = aInput.readInt()) {
val codecId: Int 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 width: Int
val height: Int val height: Int
if (options.video) { if (options.video) {
val vInput = checkNotNull(videoInput) val vInput = checkNotNull(videoInput)
codecId = vInput.readInt() videoCodecId = vInput.readInt()
width = vInput.readInt() width = vInput.readInt()
height = vInput.readInt() height = vInput.readInt()
} else { } else {
codecId = 0 videoCodecId = 0
width = 0 width = 0
height = 0 height = 0
} }
val sessionInfo = SessionInfo( val sessionInfo = SessionInfo(
deviceName = deviceName, deviceName = deviceName,
codecId = codecId, codecId = videoCodecId,
codecName = codecName(codecId), codec = Codec.fromId(videoCodecId, Codec.Type.VIDEO),
width = width, width = width,
height = height, height = height,
audioCodecId = audioCodecId, audioCodecId = audioCodecId,
@@ -669,9 +809,7 @@ class Scrcpy(
} }
} }
suspend fun clearVideoConsumer() = mutex.withLock { suspend fun clearVideoConsumer() = mutex.withLock { videoConsumer = null }
videoConsumer = null
}
suspend fun attachAudioConsumer(consumer: (AudioPacket) -> Unit): Unit = mutex.withLock { suspend fun attachAudioConsumer(consumer: (AudioPacket) -> Unit): Unit = mutex.withLock {
val session = activeSession ?: throw IllegalStateException("scrcpy session not started") val session = activeSession ?: throw IllegalStateException("scrcpy session not started")
@@ -682,31 +820,6 @@ class Scrcpy(
audioReaderThread = thread(start = true, name = "scrcpy-audio-reader") { audioReaderThread = thread(start = true, name = "scrcpy-audio-reader") {
try { 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) { while (activeSession === session && !aStream.closed) {
try { try {
val ptsAndFlags = aInput.readLong() val ptsAndFlags = aInput.readLong()
@@ -742,18 +855,20 @@ class Scrcpy(
} }
} }
suspend fun clearAudioConsumer() = mutex.withLock { suspend fun clearAudioConsumer() = mutex.withLock { audioConsumer = null }
audioConsumer = null
}
suspend fun injectKeycode(action: Int, keycode: Int, repeat: Int = 0, metaState: Int = 0) = suspend fun injectKeycode(
mutex.withLock { action: Int,
try { keycode: Int,
requireControlWriter().injectKeycode(action, keycode, repeat, metaState) repeat: Int = 0,
} catch (e: IllegalStateException) { metaState: Int = 0,
Log.w(TAG, "injectKeycode(): control channel not available", e) ) = 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 { suspend fun injectText(text: String) = mutex.withLock {
try { try {
@@ -798,7 +913,7 @@ class Scrcpy(
screenHeight: Int, screenHeight: Int,
hScroll: Float, hScroll: Float,
vScroll: Float, vScroll: Float,
buttons: Int buttons: Int,
) = mutex.withLock { ) = mutex.withLock {
try { try {
requireControlWriter().injectScroll( 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 { try {
requireControlWriter().pressBackOrScreenOn(action) requireControlWriter().pressBackOrTurnScreenOn(action)
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
Log.w(TAG, "pressBackOrScreenOn(): control channel not available", e) Log.w(TAG, "pressBackOrScreenOn(): control channel not available", e)
} }
@@ -865,8 +980,6 @@ class Scrcpy(
fun isStarted(): Boolean = activeSession != null fun isStarted(): Boolean = activeSession != null
fun getLastServerCommand(): String? = lastServerCommand
private fun requireControlWriter(): ControlWriter { private fun requireControlWriter(): ControlWriter {
val session = activeSession val session = activeSession
?: throw IllegalStateException("scrcpy control channel not available") ?: throw IllegalStateException("scrcpy control channel not available")
@@ -948,27 +1061,10 @@ class Scrcpy(
return buffer.copyOf(length).toString(Charsets.UTF_8) 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( data class SessionInfo(
val deviceName: String, val deviceName: String,
val codecId: Int, val codecId: Int,
val codecName: String, val codec: Codec?,
val width: Int, val width: Int,
val height: Int, val height: Int,
val audioCodecId: Int = 0, val audioCodecId: Int = 0,
@@ -1100,7 +1196,7 @@ class Scrcpy(
} }
@Synchronized @Synchronized
fun pressBackOrScreenOn(action: Int) { fun pressBackOrTurnScreenOn(action: Int) {
output.writeByte(TYPE_BACK_OR_SCREEN_ON) output.writeByte(TYPE_BACK_OR_SCREEN_ON)
output.writeByte(action) output.writeByte(action)
output.flush() output.flush()
@@ -1151,14 +1247,6 @@ class Scrcpy(
private const val PACKET_FLAG_KEY_FRAME = 1L shl 62 private const val PACKET_FLAG_KEY_FRAME = 1L shl 62
private const val PACKET_PTS_MASK = (1L shl 62) - 1 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_DISABLED = 0
private const val AUDIO_ERROR = 1 private const val AUDIO_ERROR = 1

View File

@@ -125,7 +125,7 @@ data class ServerParams(
cmd.add("audio=false") cmd.add("audio=false")
} }
if (audioBitRate > 0) { if (audioBitRate > 0) {
if (audioCodec.isLossyAudio()!!) { if (!audioCodec.isLossless) {
// 比官方实现多个判断编解码器类型 // 比官方实现多个判断编解码器类型
cmd.add("audio_bit_rate=$audioBitRate") cmd.add("audio_bit_rate=$audioBitRate")
} }

View File

@@ -38,7 +38,6 @@ class Shared {
fun Duration.toTick(): Tick = Tick(this.inWholeMicroseconds) fun Duration.toTick(): Tick = Tick(this.inWholeMicroseconds)
} }
enum class ListOptions(val value: Int) { enum class ListOptions(val value: Int) {
NULL(0x0), NULL(0x0),
ENCODERS(0x1), // --list-encoders ENCODERS(0x1), // --list-encoders
@@ -52,6 +51,11 @@ class Shared {
infix fun has(other: ListOptions) = this and other != 0 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) { enum class LogLevel(val string: String) {
VERBOSE("verbose"), VERBOSE("verbose"),
DEBUG("debug"), DEBUG("debug"),
@@ -60,26 +64,27 @@ class Shared {
ERROR("error"); ERROR("error");
} }
enum class Codec(val string: String, val displayName: String) { enum class Codec(
H264("h264", "H.264"), // default, ignore when passing val string: String,
H265("h265", "H.265"), val displayName: String,
AV1("av1", "AV1"), val type: Type,
OPUS("opus", "Opus"), // default, ignore when passing val id: Int,
AAC("aac", "AAC"), val isLossless: Boolean,
FLAC("flac", "FLAC"), ) {
RAW("raw", "RAW"); // wav raw 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("opus", "Opus", Type.AUDIO, 0x6f707573, false), // default, ignore when passing
OPUS, AAC -> true AAC("aac", "AAC", Type.AUDIO, 0x00616163, false),
FLAC, RAW -> false FLAC("flac", "FLAC", Type.AUDIO, 0x666c6163, true),
else -> null RAW("raw", "RAW", Type.AUDIO, 0x00726177, true); // wav raw
}
enum class Type { VIDEO, AUDIO } enum class Type { VIDEO, AUDIO }
companion object { companion object {
val VIDEO = listOf(H264, H265, AV1) val VIDEO = entries.filter { it.type == Type.VIDEO }
val AUDIO = listOf(OPUS, AAC, FLAC, RAW) val AUDIO = entries.filter { it.type == Type.AUDIO }
fun fromString(value: String, type: Type = Type.VIDEO) = fun fromString(value: String, type: Type = Type.VIDEO) =
entries.find { it.string.equals(value, ignoreCase = true) } entries.find { it.string.equals(value, ignoreCase = true) }
@@ -87,6 +92,9 @@ class Shared {
Type.VIDEO -> H264 Type.VIDEO -> H264
Type.AUDIO -> OPUS 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"), FALLBACK("fallback"),
HIDE("hide"); HIDE("hide");
} }
} }

View File

@@ -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<DeviceShortcut> {
val raw = context.getSharedPreferences(AppPreferenceKeys.PREFS_NAME, Context.MODE_PRIVATE)
.getString(AppPreferenceKeys.QUICK_DEVICES, "")
.orEmpty()
if (raw.isBlank()) return emptyList()
val result = mutableListOf<DeviceShortcut>()
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<DeviceShortcut>) {
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)
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import io.github.miuzarte.scrcpyforandroid.scrcpy.Scrcpy
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@@ -47,9 +48,13 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
stringPreferencesKey("custom_server_uri"), stringPreferencesKey("custom_server_uri"),
"" ""
) )
val CUSTOM_SERVER_VERSION = Pair(
stringPreferencesKey("custom_server_version"),
""
)
val SERVER_REMOTE_PATH = Pair( val SERVER_REMOTE_PATH = Pair(
stringPreferencesKey("server_remote_path"), stringPreferencesKey("server_remote_path"),
"/data/local/tmp/scrcpy-server.jar" Scrcpy.DEFAULT_REMOTE_PATH,
) )
val ADB_KEY_NAME = Pair( val ADB_KEY_NAME = Pair(
stringPreferencesKey("adb_key_name"), stringPreferencesKey("adb_key_name"),
@@ -83,6 +88,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
// Scrcpy Server Settings // Scrcpy Server Settings
val customServerUri by setting(CUSTOM_SERVER_URI) val customServerUri by setting(CUSTOM_SERVER_URI)
val customServerVersion by setting(CUSTOM_SERVER_VERSION)
val serverRemotePath by setting(SERVER_REMOTE_PATH) val serverRemotePath by setting(SERVER_REMOTE_PATH)
// ADB Settings // ADB Settings
@@ -102,6 +108,7 @@ class AppSettings(context: Context) : Settings(context, "AppSettings") {
val previewVirtualButtonShowText: Boolean, val previewVirtualButtonShowText: Boolean,
val virtualButtonsLayout: String, val virtualButtonsLayout: String,
val customServerUri: String, val customServerUri: String,
val customServerVersion: String,
val serverRemotePath: String, val serverRemotePath: String,
val adbKeyName: String, val adbKeyName: String,
val adbPairingAutoDiscoverOnDialogOpen: Boolean, 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(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT) { bundle: Bundle -> bundle.previewVirtualButtonShowText },
bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout }, bundleField(VIRTUAL_BUTTONS_LAYOUT) { bundle: Bundle -> bundle.virtualButtonsLayout },
bundleField(CUSTOM_SERVER_URI) { bundle: Bundle -> bundle.customServerUri }, 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(SERVER_REMOTE_PATH) { bundle: Bundle -> bundle.serverRemotePath },
bundleField(ADB_KEY_NAME) { bundle: Bundle -> bundle.adbKeyName }, bundleField(ADB_KEY_NAME) { bundle: Bundle -> bundle.adbKeyName },
bundleField(ADB_PAIRING_AUTO_DISCOVER_ON_DIALOG_OPEN) { bundle: Bundle -> bundle.adbPairingAutoDiscoverOnDialogOpen }, 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), previewVirtualButtonShowText = preferences.read(PREVIEW_VIRTUAL_BUTTON_SHOW_TEXT),
virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT), virtualButtonsLayout = preferences.read(VIRTUAL_BUTTONS_LAYOUT),
customServerUri = preferences.read(CUSTOM_SERVER_URI), customServerUri = preferences.read(CUSTOM_SERVER_URI),
customServerVersion = preferences.read(CUSTOM_SERVER_VERSION),
serverRemotePath = preferences.read(SERVER_REMOTE_PATH), serverRemotePath = preferences.read(SERVER_REMOTE_PATH),
adbKeyName = preferences.read(ADB_KEY_NAME), adbKeyName = preferences.read(ADB_KEY_NAME),
adbPairingAutoDiscoverOnDialogOpen = preferences.read( adbPairingAutoDiscoverOnDialogOpen = preferences.read(

View File

@@ -8,6 +8,7 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
@@ -155,7 +156,7 @@ abstract class Settings(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val state = asState(pair) val state = asState(pair)
return remember(state.value) { return rememberSaveable(state.value) {
object : MutableState<T> { object : MutableState<T> {
override var value: T override var value: T
get() = state.value get() = state.value

View File

@@ -50,6 +50,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput 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.Scrcpy
import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec import io.github.miuzarte.scrcpyforandroid.scrcpy.Shared.Codec
import io.github.miuzarte.scrcpyforandroid.scrcpy.TouchEventHandler 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.Settings
import io.github.miuzarte.scrcpyforandroid.storage.Storage import io.github.miuzarte.scrcpyforandroid.storage.Storage
import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions import io.github.miuzarte.scrcpyforandroid.storage.Storage.scrcpyOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.CardDefaults
import top.yukonga.miuix.kmp.basic.CircularProgressIndicator import top.yukonga.miuix.kmp.basic.CircularProgressIndicator
import top.yukonga.miuix.kmp.basic.Icon 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.Text
import top.yukonga.miuix.kmp.basic.TextButton import top.yukonga.miuix.kmp.basic.TextButton
import top.yukonga.miuix.kmp.basic.TextField import top.yukonga.miuix.kmp.basic.TextField
@@ -157,7 +160,7 @@ internal fun StatusCard(
), ),
secondSmall = StatusSmallCardSpec( secondSmall = StatusSmallCardSpec(
"编解码器", "编解码器",
sessionInfo.codecName, sessionInfo.codec?.displayName ?: "null",
), ),
) )
} }
@@ -295,7 +298,7 @@ internal fun PreviewCard(
Box( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.padding(UiSpacing.CardContent), .padding(UiSpacing.ContentVertical),
) { ) {
Button( Button(
onClick = { onClick = {
@@ -341,7 +344,7 @@ internal fun VirtualButtonCard(
onAction = onAction, onAction = onAction,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(UiSpacing.CardContent), .padding(UiSpacing.ContentVertical),
) )
} }
} }
@@ -349,9 +352,10 @@ internal fun VirtualButtonCard(
@Composable @Composable
internal fun ConfigPanel( internal fun ConfigPanel(
busy: Boolean, busy: Boolean,
snack: SnackbarHostState, snackbar: SnackbarController,
audioForwardingSupported: Boolean, audioForwardingSupported: Boolean,
cameraMirroringSupported: Boolean, cameraMirroringSupported: Boolean,
adbConnecting: Boolean,
isQuickConnected: Boolean, isQuickConnected: Boolean,
onOpenAdvanced: () -> Unit, onOpenAdvanced: () -> Unit,
onStartStopHaptic: (() -> Unit)? = null, onStartStopHaptic: (() -> Unit)? = null,
@@ -360,7 +364,7 @@ internal fun ConfigPanel(
sessionInfo: Scrcpy.Session.SessionInfo?, sessionInfo: Scrcpy.Session.SessionInfo?,
onDisconnect: () -> Unit = {}, onDisconnect: () -> Unit = {},
) { ) {
val scope = rememberCoroutineScope() val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
val sessionStarted = sessionInfo != null val sessionStarted = sessionInfo != null
@@ -381,7 +385,7 @@ internal fun ConfigPanel(
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
scope.launch { taskScope.launch {
scrcpyOptions.saveBundle(soBundleLatest) scrcpyOptions.saveBundle(soBundleLatest)
} }
} }
@@ -405,7 +409,6 @@ internal fun ConfigPanel(
.coerceAtLeast(0) .coerceAtLeast(0)
} }
SectionSmallTitle("Scrcpy")
Card { Card {
SuperSwitch( SuperSwitch(
title = "音频转发", title = "音频转发",
@@ -425,33 +428,29 @@ internal fun ConfigPanel(
val codec = Codec.AUDIO[it] val codec = Codec.AUDIO[it]
soBundle = soBundle.copy(audioCodec = codec.string) soBundle = soBundle.copy(audioCodec = codec.string)
if (codec == Codec.FLAC) { if (codec == Codec.FLAC) {
scope.launch { snackbar.show("注意FLAC编解码会引入较大的延迟")
snack.showSnackbar("注意FLAC编解码会引入较大的延迟")
}
} }
}, },
enabled = !sessionStarted && soBundle.audio, enabled = !sessionStarted && soBundle.audio,
) )
AnimatedVisibility(audioBitRateVisibility) { AnimatedVisibility(audioBitRateVisibility) {
SuperSlider( SuperSlider(
title = "音频码率", title = "音频码率",
summary = "--audio-bit-rate", summary = "--audio-bit-rate",
value = if (soBundle.audioBitRate <= 0) 0f value = ScrcpyPresets.AudioBitRate
else (ScrcpyPresets.AudioBitRate .indexOfOrNearest(soBundle.audioBitRate / 1000)
.indexOfOrNearest(soBundle.audioBitRate / 1000) + 1 .toFloat(),
).toFloat(), onValueChange = { value ->
onValueChange = { value -> val idx = value.roundToInt()
val idx = value.roundToInt().coerceIn(0, ScrcpyPresets.AudioBitRate.size) .coerceIn(0, ScrcpyPresets.AudioBitRate.lastIndex)
soBundle = soBundle.copy( soBundle = soBundle.copy(
audioBitRate = audioBitRate = ScrcpyPresets.AudioBitRate[idx] * 1000
if (idx == 0) 0 )
else ScrcpyPresets.AudioBitRate[idx - 1] * 1000 },
) valueRange = 0f..ScrcpyPresets.AudioBitRate.lastIndex.toFloat(),
}, steps = (ScrcpyPresets.AudioBitRate.size - 2).coerceAtLeast(0),
valueRange = 0f..ScrcpyPresets.AudioBitRate.size.toFloat(), unit = "Kbps",
steps = (ScrcpyPresets.AudioBitRate.size - 1).coerceAtLeast(0), zeroStateText = "默认",
unit = "Kbps",
zeroStateText = "默认",
displayText = (soBundle.audioBitRate / 1_000).toString(), displayText = (soBundle.audioBitRate / 1_000).toString(),
inputInitialValue = inputInitialValue =
if (soBundle.audioBitRate <= 0) "" if (soBundle.audioBitRate <= 0) ""
@@ -475,9 +474,7 @@ internal fun ConfigPanel(
val codec = Codec.VIDEO[it] val codec = Codec.VIDEO[it]
soBundle = soBundle.copy(videoCodec = codec.string) soBundle = soBundle.copy(videoCodec = codec.string)
if (codec == Codec.AV1) { if (codec == Codec.AV1) {
scope.launch { snackbar.show("注意绝大部分设备不支持AV1硬件编码")
snack.showSnackbar("注意绝大部分设备不支持AV1硬件编码")
}
} }
}, },
enabled = !sessionStarted, enabled = !sessionStarted,
@@ -528,8 +525,8 @@ internal fun ConfigPanel(
) )
SuperArrow( SuperArrow(
title = "高级参数", title = "更多参数",
summary = "更多 scrcpy 启动参数", summary = "所有 scrcpy 参数",
onClick = onOpenAdvanced, onClick = onOpenAdvanced,
enabled = !sessionStarted, enabled = !sessionStarted,
) )
@@ -537,8 +534,8 @@ internal fun ConfigPanel(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = UiSpacing.CardContent) .padding(horizontal = UiSpacing.ContentVertical)
.padding(bottom = UiSpacing.CardContent), .padding(bottom = UiSpacing.ContentVertical),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium),
) { ) {
if (isQuickConnected) TextButton( if (isQuickConnected) TextButton(
@@ -601,9 +598,10 @@ private fun PairingDialog(
var code by rememberSaveable(showDialog) { mutableStateOf("") } var code by rememberSaveable(showDialog) { mutableStateOf("") }
var discoveringPort by rememberSaveable(showDialog) { mutableStateOf(false) } var discoveringPort by rememberSaveable(showDialog) { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
suspend fun doDiscover() { suspend fun doDiscover() {
if (onDiscoverTarget == null || discoveringPort || !enabled) return if (!(enabled && onDiscoverTarget != null && !discoveringPort)) return
discoveringPort = true discoveringPort = true
val found = withContext(Dispatchers.IO) { onDiscoverTarget.invoke() } val found = withContext(Dispatchers.IO) { onDiscoverTarget.invoke() }
if (found != null) { if (found != null) {
@@ -619,13 +617,6 @@ private fun PairingDialog(
} }
} }
fun clearInputs() {
host = ""
port = ""
code = ""
discoveringPort = false
}
SuperDialog( SuperDialog(
show = showDialog, show = showDialog,
title = "使用配对码配对设备", title = "使用配对码配对设备",
@@ -634,80 +625,94 @@ private fun PairingDialog(
onDismissRequest() onDismissRequest()
}, },
onDismissFinished = { onDismissFinished = {
clearInputs()
onDismissFinished() onDismissFinished()
}, },
content = { content = {
TextField( Column(
value = host, verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical),
onValueChange = { host = it }, ) {
label = "IP 地址", TextField(
singleLine = true, value = host,
modifier = Modifier onValueChange = { host = it },
.fillMaxWidth() label = "IP 地址",
.padding(bottom = UiSpacing.CardContent), singleLine = true,
) keyboardOptions = KeyboardOptions(
TextField( keyboardType = KeyboardType.Number,
value = port, imeAction = ImeAction.Next,
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,
), ),
) keyboardActions = KeyboardActions(
Row( onNext = { focusManager.moveFocus(FocusDirection.Next) },
modifier = Modifier ),
.padding(bottom = UiSpacing.PopupHorizontal), modifier = Modifier.fillMaxWidth()
horizontalArrangement = Arrangement.spacedBy(UiSpacing.PopupHorizontal), )
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( TextButton(
text = "取消", text = if (!discoveringPort) "自动发现" else "发现中...",
onClick = { onClick = {
onDismissRequest() if (enabled && onDiscoverTarget != null && !discoveringPort)
scope.launch { doDiscover() }
}, },
modifier = Modifier.weight(1f), enabled = enabled && onDiscoverTarget != null && !discoveringPort,
) modifier = Modifier.fillMaxWidth(),
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(),
) )
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( Box(
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.padding(start = UiSpacing.CardContent, top = UiSpacing.CardContent) .padding(start = UiSpacing.ContentVertical, top = UiSpacing.ContentVertical)
.background(Color.Black.copy(alpha = 0.5f)) .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)) { Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.Tiny)) {
Text( Text(
@@ -877,6 +882,7 @@ private fun ScrcpyVideoSurface(
) { ) {
var currentSurface by remember { mutableStateOf<Surface?>(null) } var currentSurface by remember { mutableStateOf<Surface?>(null) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val taskScope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
LaunchedEffect(session, currentSurface) { LaunchedEffect(session, currentSurface) {
val surface = currentSurface val surface = currentSurface
@@ -889,7 +895,7 @@ private fun ScrcpyVideoSurface(
onDispose { onDispose {
val surface = currentSurface val surface = currentSurface
if (surface != null) { 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 { override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
val surface = currentSurface val surface = currentSurface
if (surface != null) { if (surface != null) {
scope.launch { taskScope.launch {
nativeCore.detachVideoSurface(surface, releaseDecoder = false) nativeCore.detachVideoSurface(surface, releaseDecoder = false)
} }
surface.release() surface.release()
@@ -944,48 +950,70 @@ private fun ScrcpyVideoSurface(
@Composable @Composable
internal fun DeviceTile( internal fun DeviceTile(
device: DeviceShortcut, device: DeviceShortcut,
actionText: String, isConnected: Boolean,
actionEnabled: Boolean, actionEnabled: Boolean,
actionInProgress: Boolean, actionInProgress: Boolean,
onLongPress: () -> Unit, editing: Boolean,
onContentClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit,
onAction: () -> Unit, onAction: () -> Unit,
onEditorSave: (DeviceShortcut) -> Unit,
onEditorDelete: () -> Unit,
onEditorCancel: () -> Unit,
) { ) {
val haptics = rememberAppHaptics() 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( Card(
colors = CardDefaults.defaultColors( colors = CardDefaults.defaultColors(
color = if (device.online) MiuixTheme.colorScheme.surfaceContainer color =
else MiuixTheme.colorScheme.surfaceContainer.copy(alpha = 0.6f), 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, onClick = haptics.contextClick,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.combinedClickable( .then(
onClick = onContentClick, if (!isConnected)
onLongClick = onLongPress, Modifier.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
else Modifier
) )
.padding(UiSpacing.PageItem), .padding(UiSpacing.PageItem),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f),
.padding(end = UiSpacing.CardContent),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// statu dot
Box( Box(
modifier = Modifier modifier = Modifier
.size(8.dp) .size(8.dp)
.background( .background(
color = if (device.online) Color(0xFF44C74F) else MiuixTheme.colorScheme.outline, color =
if (isConnected) Color(0xFF44C74F)
else MiuixTheme.colorScheme.outline,
shape = CircleShape, shape = CircleShape,
), ),
) )
Spacer(modifier = Modifier.width(UiSpacing.PageItem)) Spacer(modifier = Modifier.width(UiSpacing.PageItem))
Column(modifier = Modifier.weight(1f)) { // device name/address
Column {
Text( Text(
device.name.ifBlank { device.host }, device.name.ifBlank { device.host },
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@@ -1003,20 +1031,121 @@ internal fun DeviceTile(
} }
} }
Row( Row(verticalAlignment = Alignment.CenterVertically) {
verticalAlignment = Alignment.CenterVertically,
) {
if (actionInProgress) { if (actionInProgress) {
CircularProgressIndicator(progress = null) CircularProgressIndicator(progress = null)
Spacer(Modifier.width(UiSpacing.Medium)) Spacer(Modifier.width(UiSpacing.Medium))
} }
TextButton( TextButton(
text = actionText, text = if (!isConnected) "连接" else "断开",
onClick = onAction, onClick = onAction,
enabled = actionEnabled && !actionInProgress, 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<DeviceShortcut>,
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, onAddDevice: () -> Unit,
enabled: Boolean = true, enabled: Boolean = true,
) { ) {
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
Card( Card(
colors = CardDefaults.defaultColors(color = MiuixTheme.colorScheme.primaryContainer), 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( Column(verticalArrangement = Arrangement.spacedBy(UiSpacing.ContentVertical)) {
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),
)
Row( Row(
modifier = Modifier modifier = Modifier.padding(horizontal = UiSpacing.Small),
.fillMaxWidth()
.padding(UiSpacing.CardContent),
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Medium), 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( TextButton(
text = "返回", text = "添加设备",
onClick = onBack, onClick = onAddDevice,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
enabled = enabled,
) )
TextButton( TextButton(
text = "删除", text = "连接",
onClick = onDelete, onClick = onConnect,
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,
),
)
}
},
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
enabled = enabled,
colors = ButtonDefaults.textButtonColorsPrimary(), colors = ButtonDefaults.textButtonColorsPrimary(),
) )
} }

View File

@@ -78,44 +78,40 @@ class ReorderableList(
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small)
) { ) {
if (item.icon != null) { if (item.icon != null) Icon(
Icon( imageVector = item.icon,
item.icon, contentDescription = item.title
contentDescription = item.title )
)
Spacer(Modifier.padding(horizontal = 4.dp))
}
Column { Column {
Text( Text(
text = item.title, text = item.title,
color = MiuixTheme.colorScheme.onSurface, color = MiuixTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
if (item.subtitle.isNotBlank()) { if (item.subtitle.isNotBlank()) Text(
Text( text = item.subtitle,
text = item.subtitle, color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary, fontSize = 13.sp,
fontSize = 13.sp, )
)
}
} }
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(UiSpacing.Small)
) { ) {
if (showCheckbox) { if (showCheckbox) Checkbox(
Checkbox( state = if (item.checked) ToggleableState.On else ToggleableState.Off,
state = if (item.checked) ToggleableState.On else ToggleableState.Off, onClick = {
onClick = { onCheckboxChange?.invoke(item.id, !item.checked)
onCheckboxChange?.invoke(item.id, !item.checked) },
}, enabled = item.checkboxEnabled
enabled = item.checkboxEnabled )
)
Spacer(Modifier.padding(horizontal = 4.dp))
}
IconButton( IconButton(
onClick = {}, onClick = {
haptics.contextClick()
},
modifier = Modifier modifier = Modifier
.draggableHandle( .draggableHandle(
onDragStarted = { onDragStarted = {
@@ -193,7 +189,7 @@ class ReorderableList(
) )
} }
} }
Spacer(Modifier.padding(UiSpacing.CardContent)) Spacer(Modifier.padding(UiSpacing.ContentVertical))
Text( Text(
text = item.title, text = item.title,
color = MiuixTheme.colorScheme.onSurface, color = MiuixTheme.colorScheme.onSurface,

View File

@@ -302,7 +302,7 @@ class VirtualButtonBar(
action.icon, action.icon,
contentDescription = action.title, contentDescription = action.title,
modifier = Modifier modifier = Modifier
.padding(end = UiSpacing.CardContent), .padding(end = UiSpacing.ContentVertical),
) )
}, },
title = action.title, title = action.title,